Users -> Guest logins makes a throwaway user name and password, good for half an hour by default, to hand to somebody who wants to see the console. It is a normal account with `guest: true` and an expiry, so login, sessions and the saved look need no special case, and it is left out of the account list, out of "the last account can't be removed", and out of the one account an empty user name means. What a guest may do is one allow-list, checked in the auth middleware, rather than a flag ~190 handlers have to remember. The list is matched by asking the router which handler a request would reach, so it cannot drift from the routes it names, and it is checked against the scoped path — /api/nodes/grid/procs is /api/procs on grid — because the hub proxies to a node with a token that is full control of that node. The node cannot tell a guest from its owner, so the only place it can be stopped is here, before the request leaves. A guest gets the dashboard, containers, stacks, disks, hardware, ZFS and usage, on this machine and on any managed one, and nothing else: no terminal (attaching is a GET, and it is a root shell), no files, no container logs or environments, no stack files, no alert channels, no cron, no tokens, no settings, no power. Expiry is enforced on every request and swept once a minute, so a console left open on a visitor's phone stops within the minute rather than at their next click; revoking signs them out at once. The console hides what a guest cannot use and says how long is left where the power button would be. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
281 lines
8.8 KiB
Go
281 lines
8.8 KiB
Go
package api
|
|
|
|
// Guest logins: a temporary account for showing somebody the console. It expires by itself
|
|
// (half an hour by default), it can be revoked in one tap, and it may only look — it cannot
|
|
// change a thing, on this machine or on any machine this hub manages.
|
|
//
|
|
// The gate is a list of what a guest may reach, and nothing else, so a route added later is
|
|
// shut to guests until somebody decides otherwise. It is applied to the *scoped* path, which
|
|
// is what makes it hold for managed machines too: the hub proxies to a node as itself, with a
|
|
// token that is full control of that node, so the node cannot tell a guest from its owner —
|
|
// the decision has to be made here, before the request leaves.
|
|
|
|
import (
|
|
"encoding/json"
|
|
"io"
|
|
"log"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
"time"
|
|
|
|
"hamlab/internal/auth"
|
|
"hamlab/internal/config"
|
|
"hamlab/internal/store"
|
|
)
|
|
|
|
const (
|
|
guestPrefix = "guest-"
|
|
guestTTLDefault = 30 * time.Minute
|
|
guestTTLMax = 24 * time.Hour
|
|
guestReapEvery = time.Minute
|
|
)
|
|
|
|
// guestRoutes is every route a guest login may reach, by the pattern it is registered under
|
|
// in New. Reading only, with three exceptions that are the guest's own: signing out, and the
|
|
// theme and page order that follow the login.
|
|
//
|
|
// Deliberately not here, though they only read:
|
|
// - terminals — attaching is a root-capable shell, and attaching is a GET
|
|
// - files — reading a file is reading every file, including keys and this config
|
|
// - a container's logs, inspect, events and health — env vars and logs are where secrets are
|
|
// - stack files and .env — same
|
|
// - cron/jobs, alerts — the jobs are commands; a channel URL is a key to his phone
|
|
// - KVM — a screen and a keyboard on a machine, and its assets load with a GET
|
|
// - updates — what is unpatched on his box is not a thing to show a visitor
|
|
var guestRoutes = map[string]bool{
|
|
"GET /api/me": true,
|
|
"POST /api/logout": true,
|
|
"GET /api/nodes": true, // the machine switcher: a guest may look at any of them
|
|
"GET /api/prefs/ui": true,
|
|
"PUT /api/prefs/ui": true, // the look is the login's own
|
|
"GET /api/prefs/history": true,
|
|
"GET /api/prefs/sampler": true,
|
|
"GET /api/procs": true,
|
|
"GET /api/hardware": true,
|
|
"GET /api/storage": true,
|
|
"GET /api/storage/smart/{dev}": true,
|
|
"GET /api/storage/zfs/snapshots": true,
|
|
"GET /api/zfs": true,
|
|
"GET /api/docker": true, // containers, images, volumes, networks — the lists only
|
|
"GET /api/docker/stacks": true,
|
|
"GET /api/services": true,
|
|
"GET /api/services/{unit}": true,
|
|
"GET /api/space": true,
|
|
"GET /api/space/{disk}": true,
|
|
"GET /api/space/{disk}/tree": true,
|
|
"GET /api/space/{disk}/largest": true,
|
|
"GET /api/tasks": true,
|
|
"GET /api/tasks/{id}": true,
|
|
// sensors: the read-only interface the dashboard and every graph are built on. A query
|
|
// is a POST when the list of ids is too long for a URL, and still only a read.
|
|
"GET /api/sensors": true,
|
|
"GET /api/sensors/query": true,
|
|
"POST /api/sensors/query": true,
|
|
"GET /api/sensors/stream": true,
|
|
"GET /api/sensors/history": true,
|
|
"GET /api/sensors/{id}": true,
|
|
"GET /api/sensors/{id}/history": true,
|
|
"GET /api/fleet/sensors/query": true,
|
|
"POST /api/fleet/sensors/query": true,
|
|
}
|
|
|
|
// guestScope is the path whose permission decides this request. A hub route for a managed
|
|
// machine is the same route on that machine — /api/nodes/grid/procs is /api/procs — so it is
|
|
// judged by the same line of guestRoutes. A path with nothing after the machine name is the
|
|
// machine record itself (adding or removing one), which is no guest's business.
|
|
func guestScope(p string) string {
|
|
rest, ok := strings.CutPrefix(p, "/api/nodes/")
|
|
if !ok {
|
|
return p
|
|
}
|
|
i := strings.IndexByte(rest, '/')
|
|
if i < 0 {
|
|
return ""
|
|
}
|
|
return "/api" + rest[i:]
|
|
}
|
|
|
|
// guestMay decides a request by asking the router which handler it would reach, so the
|
|
// answer can never drift from the route it is about. An unrouted path lands on the catch-all,
|
|
// which is not in the list, so it is refused.
|
|
func (s *Server) guestMay(r *http.Request) bool {
|
|
probe := r
|
|
if p := guestScope(r.URL.Path); p != r.URL.Path {
|
|
if p == "" {
|
|
return false
|
|
}
|
|
probe = &http.Request{Method: r.Method, URL: &url.URL{Path: p}, Host: r.Host}
|
|
}
|
|
_, pattern := s.mux.Handler(probe)
|
|
return guestRoutes[pattern]
|
|
}
|
|
|
|
// ---- the accounts themselves ----
|
|
|
|
func (s *Server) listGuests(w http.ResponseWriter, r *http.Request) {
|
|
now := time.Now()
|
|
counts := s.sessions.Count()
|
|
out := []map[string]any{}
|
|
s.cfg.View(func(c *config.Config) {
|
|
for _, u := range c.Users {
|
|
if !u.Guest || u.Expired(now) {
|
|
continue
|
|
}
|
|
out = append(out, map[string]any{
|
|
"name": u.Name, "created": u.Created, "expires": u.Expires,
|
|
"issued_by": u.IssuedBy, "sessions": counts[u.Name], "used": !u.LastSeen.IsZero(),
|
|
})
|
|
}
|
|
})
|
|
jsonOK(w, 200, out)
|
|
}
|
|
|
|
// addGuest makes a guest login and hands back its password — the only time it is ever
|
|
// readable, here as everywhere else a password is generated.
|
|
func (s *Server) addGuest(w http.ResponseWriter, r *http.Request) {
|
|
me := actorOf(r)
|
|
if me.Guest {
|
|
jsonErr(w, 403, "a guest login can only look around")
|
|
return
|
|
}
|
|
var body struct {
|
|
Minutes int `json:"minutes"`
|
|
}
|
|
_ = json.NewDecoder(io.LimitReader(r.Body, 4096)).Decode(&body)
|
|
ttl := guestTTLDefault
|
|
if body.Minutes > 0 {
|
|
ttl = time.Duration(body.Minutes) * time.Minute
|
|
}
|
|
if ttl > guestTTLMax {
|
|
jsonErr(w, 400, "a guest login lasts at most 24 hours")
|
|
return
|
|
}
|
|
pw, err := auth.GuestPassword()
|
|
if err != nil {
|
|
jsonErr(w, 500, err.Error())
|
|
return
|
|
}
|
|
hash, err := auth.HashPassword(pw)
|
|
if err != nil {
|
|
jsonErr(w, 500, err.Error())
|
|
return
|
|
}
|
|
var name string
|
|
for try := 0; try < 8 && name == ""; try++ {
|
|
suffix, err := auth.GuestSuffix()
|
|
if err != nil {
|
|
jsonErr(w, 500, err.Error())
|
|
return
|
|
}
|
|
candidate := guestPrefix + suffix
|
|
s.cfg.View(func(c *config.Config) {
|
|
if _, taken := c.User(candidate); !taken {
|
|
name = candidate
|
|
}
|
|
})
|
|
}
|
|
if name == "" {
|
|
jsonErr(w, 500, "could not find a free guest name")
|
|
return
|
|
}
|
|
now := time.Now()
|
|
expires := now.Add(ttl)
|
|
if err := s.cfg.Update(func(c *config.Config) {
|
|
c.Users = append(c.Users, config.User{Name: name, Hash: hash, Created: now, Guest: true, Expires: expires, IssuedBy: me.User})
|
|
}); err != nil {
|
|
jsonErr(w, 500, err.Error())
|
|
return
|
|
}
|
|
log.Printf("guest login %q issued until %s (by %s)", name, expires.Format(time.RFC3339), clientIP(r))
|
|
jsonOK(w, 200, map[string]any{"name": name, "password": pw, "expires": expires, "minutes": int(ttl.Minutes())})
|
|
}
|
|
|
|
// revokeGuest ends a guest login now. No password: it only ever takes access away, and the
|
|
// whole point of the thing is that ending it is one tap.
|
|
func (s *Server) revokeGuest(w http.ResponseWriter, r *http.Request) {
|
|
if actorOf(r).Guest {
|
|
jsonErr(w, 403, "a guest login can only look around")
|
|
return
|
|
}
|
|
name := r.PathValue("name")
|
|
found := false
|
|
if err := s.cfg.Update(func(c *config.Config) {
|
|
kept := c.Users[:0]
|
|
for _, u := range c.Users {
|
|
if u.Name == name && u.Guest {
|
|
found = true
|
|
continue
|
|
}
|
|
kept = append(kept, u)
|
|
}
|
|
c.Users = kept
|
|
}); err != nil {
|
|
jsonErr(w, 500, err.Error())
|
|
return
|
|
}
|
|
if !found {
|
|
jsonErr(w, 404, "no such guest login")
|
|
return
|
|
}
|
|
s.forgetGuest(name)
|
|
log.Printf("guest login %q revoked (by %s)", name, clientIP(r))
|
|
w.WriteHeader(204)
|
|
}
|
|
|
|
// forgetGuest signs a guest out everywhere and drops the look they left behind.
|
|
func (s *Server) forgetGuest(name string) {
|
|
s.sessions.RevokeUser(name)
|
|
_ = s.store.Update(func(st *store.State) { delete(st.Users, name) })
|
|
}
|
|
|
|
// watchGuests clears out guest logins whose time is up. Expiry is enforced on every request
|
|
// as well — this is what keeps the config file from collecting them, and what signs a guest
|
|
// out of a console they left open rather than waiting for their next click.
|
|
func (s *Server) watchGuests() {
|
|
for {
|
|
s.reapGuests()
|
|
time.Sleep(guestReapEvery)
|
|
}
|
|
}
|
|
|
|
func (s *Server) reapGuests() {
|
|
now := time.Now()
|
|
var gone []string
|
|
var have bool
|
|
s.cfg.View(func(c *config.Config) {
|
|
for _, u := range c.Users {
|
|
if u.Guest {
|
|
have = true
|
|
if u.Expired(now) {
|
|
gone = append(gone, u.Name)
|
|
}
|
|
}
|
|
}
|
|
})
|
|
if !have || len(gone) == 0 {
|
|
return
|
|
}
|
|
expired := map[string]bool{}
|
|
for _, n := range gone {
|
|
expired[n] = true
|
|
}
|
|
if err := s.cfg.Update(func(c *config.Config) {
|
|
kept := c.Users[:0]
|
|
for _, u := range c.Users {
|
|
if expired[u.Name] {
|
|
continue
|
|
}
|
|
kept = append(kept, u)
|
|
}
|
|
c.Users = kept
|
|
}); err != nil {
|
|
log.Printf("guests: %v", err)
|
|
return
|
|
}
|
|
for _, n := range gone {
|
|
s.forgetGuest(n)
|
|
}
|
|
log.Printf("guest login(s) expired: %s", strings.Join(gone, ", "))
|
|
}
|