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
+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