package api import ( "net/http" "net/http/httptest" "net/url" "path/filepath" "strings" "testing" "time" "hamlab/internal/auth" "hamlab/internal/config" "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. func TestExpiredGuestIsRefusedAndSignedOut(t *testing.T) { for _, tc := range []struct { name string expires time.Time wantOK bool }{ {"still has time", time.Now().Add(10 * time.Minute), true}, {"time is up", time.Now().Add(-time.Second), false}, {"no expiry at all", time.Time{}, false}, } { t.Run(tc.name, func(t *testing.T) { dir := t.TempDir() cfg := config.Default(filepath.Join(dir, "config.json")) if err := cfg.Update(func(c *config.Config) { c.Users = []config.User{{Name: "guest-abcd", Hash: "x", Guest: true, Expires: tc.expires}} }); err != nil { t.Fatal(err) } st, err := store.Open(filepath.Join(dir, "state.json")) if err != nil { t.Fatal(err) } s := &Server{cfg: cfg, store: st, sessions: auth.NewSessions(st)} tok, err := s.sessions.Create("guest-abcd", "10.0.0.5", "curl") if err != nil { t.Fatal(err) } r := httptest.NewRequest("GET", "/api/me", nil) r.AddCookie(&http.Cookie{Name: cookieName, Value: tok}) w := httptest.NewRecorder() a, ok, answered := s.cookieActor(w, r) if ok != tc.wantOK { t.Fatalf("ok = %v, want %v", ok, tc.wantOK) } if tc.wantOK { if !a.Guest || a.User != "guest-abcd" { t.Errorf("actor = %+v, want the guest account marked as one", a) } if answered { t.Error("answered the caller for a guest that still has time") } return } if !answered || w.Code != 401 { t.Errorf("answered = %v, code = %d, want true and 401", answered, w.Code) } if _, live := s.sessions.Lookup(tok, "10.0.0.5"); live { t.Error("the session of an expired guest is still live") } }) } }