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>
This commit is contained in:
Mitchell-Johnson
2026-09-27 17:53:55 -07:00
co-authored by Claude Opus 5
parent 8a7167bf03
commit 179f342a2c
11 changed files with 65 additions and 346 deletions
+10 -97
View File
@@ -1,21 +1,22 @@
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.
// 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.
//
// 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.
// 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"
"net/url"
"strings"
"time"
@@ -31,86 +32,6 @@ const (
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) {
@@ -135,10 +56,6 @@ func (s *Server) listGuests(w http.ResponseWriter, r *http.Request) {
// 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"`
}
@@ -194,10 +111,6 @@ func (s *Server) addGuest(w http.ResponseWriter, r *http.Request) {
// 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) {
+4 -139
View File
@@ -3,9 +3,7 @@ package api
import (
"net/http"
"net/http/httptest"
"net/url"
"path/filepath"
"strings"
"testing"
"time"
@@ -14,143 +12,10 @@ import (
"hamlab/internal/store"
)
// The router built without any of the machinery behind it: registering a route never calls
// its handler, so this is the real table, and what guestMay is asked about.
func gateServer(t *testing.T) *Server {
t.Helper()
s := &Server{}
s.mux = s.routes()
return s
}
func may(t *testing.T, s *Server, method, path string) bool {
t.Helper()
return s.guestMay(&http.Request{Method: method, URL: &url.URL{Path: path}})
}
// Every line of guestRoutes has to name a route that exists. A pattern with a typo in it
// would quietly match nothing — safe, but the page that needs it would break with no clue
// why, so the mistake is caught here instead.
func TestGuestRoutesAreReal(t *testing.T) {
s := gateServer(t)
for pattern := range guestRoutes {
method, path, ok := strings.Cut(pattern, " ")
if !ok {
t.Errorf("%q is not \"METHOD /path\"", pattern)
continue
}
probe := strings.NewReplacer("{dev}", "sda", "{unit}", "ssh.service", "{disk}", "sda", "{id}", "x", "{name}", "grid").Replace(path)
_, got := s.mux.Handler(&http.Request{Method: method, URL: &url.URL{Path: probe}})
if got != pattern {
t.Errorf("guestRoutes has %q; %s %s routes to %q", pattern, method, probe, got)
}
}
}
func TestGuestMayOnlyLookAround(t *testing.T) {
s := gateServer(t)
for _, tc := range []struct {
method, path string
want bool
why string
}{
// what a guest is for
{"GET", "/api/me", true, "who am I"},
{"POST", "/api/logout", true, "signing out"},
{"GET", "/api/procs", true, "the Processes page"},
{"GET", "/api/docker", true, "the container list"},
{"GET", "/api/docker/stacks", true, "the stack list"},
{"GET", "/api/zfs", true, "the ZFS page"},
{"GET", "/api/storage/smart/sda", true, "drive health"},
{"GET", "/api/space/sda/tree", true, "the Usage page"},
{"GET", "/api/sensors/stream", true, "live graphs"},
{"POST", "/api/sensors/query", true, "a query too long for a URL is still a read"},
{"GET", "/api/nodes", true, "the machine switcher"},
{"PUT", "/api/prefs/ui", true, "the look is the login's own"},
// the shell, and every road to it
{"GET", "/api/terminals", false, "terminals"},
{"POST", "/api/terminals", false, "opening a shell"},
{"GET", "/api/terminals/abc/attach", false, "attaching is a GET, and it is a root shell"},
{"POST", "/api/docker/containers/gitea/exec", false, "a shell in a container"},
// reads that give away too much
{"GET", "/api/files", false, "the file tree"},
{"GET", "/api/files/read", false, "reading any file, including this config and his keys"},
{"GET", "/api/docker/containers/gitea/inspect", false, "a container's environment is where secrets live"},
{"GET", "/api/docker/containers/gitea/logs", false, "logs leak"},
{"GET", "/api/docker/stacks/gitea", false, "a stack's compose file and .env"},
{"GET", "/api/alerts", false, "a channel URL is a key to his phone"},
{"GET", "/api/cron", false, "cron jobs are commands"},
{"GET", "/api/updates", false, "what is unpatched here"},
{"GET", "/api/tokens", false, "tokens"},
{"GET", "/api/users", false, "the account list"},
{"GET", "/api/sessions", false, "sessions"},
{"GET", "/api/guests", false, "a guest cannot see or make guests"},
{"GET", "/kvm/1", false, "a KVM is a keyboard on a machine"},
{"GET", "/api/debug/pprof/heap", false, "the profiler"},
// anything that changes anything
{"POST", "/api/power/poweroff", false, "turning the box off"},
{"POST", "/api/docker/stacks/gitea/deploy", false, "deploying"},
{"POST", "/api/docker/containers/gitea/restart", false, "restarting a container"},
{"POST", "/api/services/ssh.service/restart", false, "restarting a service"},
{"PUT", "/api/files/write", false, "writing a file"},
{"POST", "/api/procs/1/signal", false, "killing a process"},
{"POST", "/api/users", false, "making an account"},
{"POST", "/api/guests", false, "making a guest login"},
{"DELETE", "/api/guests/guest-abcd", false, "revoking one"},
{"POST", "/api/password", false, "changing a password"},
{"POST", "/api/tokens/read", false, "issuing a token"},
{"POST", "/api/self-update", false, "replacing the binary"},
{"POST", "/api/updates/upgrade", false, "upgrading packages"},
{"PUT", "/api/prefs/history", false, "a machine-wide setting"},
{"PUT", "/api/prefs/sampler", false, "a machine-wide setting"},
{"POST", "/api/nodes", false, "adding a machine"},
{"DELETE", "/api/nodes/grid", false, "removing one"},
{"PUT", "/api/alerts", false, "alert rules"},
{"POST", "/api/space/sda/scan", false, "starting a scan"},
// a managed machine is judged by the same table: the hub proxies as itself, with a
// token that is full control of the node, so this is the only place it can be stopped
{"GET", "/api/nodes/grid/procs", true, "looking at grid"},
{"GET", "/api/nodes/grid/sensors/stream", true, "grid's graphs"},
{"GET", "/api/nodes/grid/docker/stacks", true, "grid's stacks"},
{"GET", "/api/nodes/grid/files/read", false, "reading a file on grid"},
{"GET", "/api/nodes/grid/terminals", false, "a shell on grid"},
{"POST", "/api/nodes/grid/docker/stacks/immich/deploy", false, "deploying on grid"},
{"POST", "/api/nodes/grid/power/reboot", false, "rebooting grid"},
{"PUT", "/api/nodes/grid/files/write", false, "writing a file on grid"},
// and nothing unrouted slips through the catch-alls
{"GET", "/api/nothing-like-this", false, "an unknown API path"},
{"GET", "/api/nodes/grid/nothing-like-this", false, "an unknown path on a machine"},
{"GET", "/anything", false, "a path off the API"},
} {
if got := may(t, s, tc.method, tc.path); got != tc.want {
t.Errorf("%s %s: guest may = %v, want %v (%s)", tc.method, tc.path, got, tc.want, tc.why)
}
}
}
func TestGuestScope(t *testing.T) {
for path, want := range map[string]string{
"/api/procs": "/api/procs",
"/api/nodes": "/api/nodes",
"/api/nodes/grid": "", // the machine record itself, not a route on the machine
"/api/nodes/grid/procs": "/api/procs",
"/api/nodes/grid/docker/containers/x/logs": "/api/docker/containers/x/logs",
"/kvm/1": "/kvm/1",
} {
if got := guestScope(path); got != want {
t.Errorf("guestScope(%q) = %q, want %q", path, got, want)
}
}
}
// A guest whose time is up is stopped at the door, whether or not the minute-by-minute
// sweep has got to their account yet — and their session is dropped on the way, so the
// console they left open goes back to the login form.
// A guest login is an ordinary account with a clock on it, so the clock is the thing worth
// testing: one whose time is up is stopped at the door, whether or not the minute-by-minute
// sweep has reached their account yet, and their session is dropped on the way — so the
// console they left open goes back to the login form rather than carrying on.
func TestExpiredGuestIsRefusedAndSignedOut(t *testing.T) {
for _, tc := range []struct {
name string
+5 -24
View File
@@ -405,27 +405,17 @@ func (s *Server) setCookie(w http.ResponseWriter, r *http.Request, tok string, m
})
}
// signedIn reports whether a request carries a live browser session of a real account,
// without the side effects of the auth middleware. A guest login is not one: this is what
// lets a KVM's own assets through, and a KVM is a keyboard on a machine.
// signedIn reports whether a request carries a live browser session, without the
// side effects of the auth middleware.
func (s *Server) signedIn(r *http.Request) bool {
c, err := r.Cookie(cookieName)
if err != nil {
return false
}
ses, ok := s.sessions.Lookup(c.Value, clientKey(r))
if !ok {
return false
}
var u config.User
var found bool
s.cfg.View(func(cf *config.Config) { u, found = cf.User(ses.User) })
return found && !u.Guest
_, ok := s.sessions.Lookup(c.Value, clientKey(r))
return ok
}
// guestRefusal is what a guest login is told, wherever it reaches for something.
const guestRefusal = "a guest login can only look around"
// cookieActor resolves the session cookie to who is calling. answered means the caller has
// already been sent an error and the handler must not run: the account behind a live session
// has gone away, or a guest login's time is up — so a console left open on a visitor's phone
@@ -455,8 +445,7 @@ func (s *Server) cookieActor(w http.ResponseWriter, r *http.Request) (a actor, o
return actor{User: ses.User, Session: ses.ID, token: c.Value, Guest: u.Guest, Expires: u.Expires}, true, false
}
// auth accepts the browser session cookie or a bearer API token issued to a hub. A guest
// login only reaches the handful of routes guestRoutes names (internal/api/guests.go).
// auth accepts the browser session cookie or a bearer API token issued to a hub.
func (s *Server) auth(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
a, ok, answered := s.cookieActor(w, r)
@@ -464,10 +453,6 @@ func (s *Server) auth(next http.HandlerFunc) http.HandlerFunc {
return
}
if ok {
if a.Guest && !s.guestMay(r) {
jsonErr(w, 403, guestRefusal)
return
}
next(w, withActor(r, a))
return
}
@@ -506,10 +491,6 @@ func (s *Server) sensorAuth(next http.HandlerFunc) http.HandlerFunc {
if a, live, answered := s.cookieActor(w, r); answered {
return
} else if live {
if a.Guest && !s.guestMay(r) {
jsonErr(w, 403, guestRefusal)
return
}
ok, trusted = true, true
r = withActor(r, a)
}