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, ", ")) }