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:
co-authored by
Claude Opus 5
parent
8a7167bf03
commit
179f342a2c
+4
-139
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user