Files
hamlab/internal/api/guests.go
T
Mitchell-JohnsonandClaude Opus 5 179f342a2c A guest login is a full account with a clock on it
Mitchell, on the read-only version built earlier today: scratch it. He hands
these to people he trusts and revokes them otherwise, so a console with half
its tabs missing solved a problem he doesn't have.

So the allow-list, the middleware check and every "not for a guest" branch in
the UI are gone. What is left is the part that was the point: a throwaway
account that expires by itself, is checked on every request, swept once a
minute, and can be ended in one tap. A guest now gets the whole console on
every machine, terminal included.

Said once, since it is his call and he has made it: that means a guest has a
root-capable shell on every host, and can make an account or a token of its own
that outlives the guest login.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-27 17:53:55 -07:00

194 lines
5.2 KiB
Go

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