Users -> Guest logins makes a throwaway user name and password, good for half an hour by default, to hand to somebody who wants to see the console. It is a normal account with `guest: true` and an expiry, so login, sessions and the saved look need no special case, and it is left out of the account list, out of "the last account can't be removed", and out of the one account an empty user name means. What a guest may do is one allow-list, checked in the auth middleware, rather than a flag ~190 handlers have to remember. The list is matched by asking the router which handler a request would reach, so it cannot drift from the routes it names, and it is checked against the scoped path — /api/nodes/grid/procs is /api/procs on grid — because the hub proxies to a node with a token that is full control of that node. The node cannot tell a guest from its owner, so the only place it can be stopped is here, before the request leaves. A guest gets the dashboard, containers, stacks, disks, hardware, ZFS and usage, on this machine and on any managed one, and nothing else: no terminal (attaching is a GET, and it is a root shell), no files, no container logs or environments, no stack files, no alert channels, no cron, no tokens, no settings, no power. Expiry is enforced on every request and swept once a minute, so a console left open on a visitor's phone stops within the minute rather than at their next click; revoking signs them out at once. The console hides what a guest cannot use and says how long is left where the power button would be. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
206 lines
8.2 KiB
Go
206 lines
8.2 KiB
Go
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")
|
|
}
|
|
})
|
|
}
|
|
}
|