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
+10
-97
@@ -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
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user