package api // Guest logins: a temporary account for handing to somebody you want to show around. It is // an ordinary account with a clock on it — every tab, every action, on this machine and on // every machine this hub manages — that stops working at its expiry (half an hour by // default) or the moment it is revoked. // // Deliberately not a reduced account: Mitchell, 2026-09-27, replacing a read-only version // built the same day. He gives these to people he trusts and revokes them otherwise, and a // console with half its buttons missing is not the console he wanted to show off. What that // means in practice: a guest has the Terminal tab, which is a shell as the user the daemon // runs as — root on every host here — and can make an account or a token of its own that // outlives the guest login. import ( "encoding/json" "io" "log" "net/http" "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 ) // ---- 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) 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) { 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, ", ")) }