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>
194 lines
5.2 KiB
Go
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, ", "))
|
|
}
|