From 179f342a2c5154630fcc74a4e9664334f6814f57 Mon Sep 17 00:00:00 2001 From: Mitchell-Johnson Date: Sun, 27 Sep 2026 17:53:55 -0700 Subject: [PATCH] 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 --- PLAN.md | 2 +- README.md | 13 ++-- internal/api/guests.go | 107 +++------------------------ internal/api/guests_test.go | 143 +----------------------------------- internal/api/server.go | 29 ++------ web/src/App.tsx | 7 +- web/src/Docker.tsx | 30 ++++---- web/src/Shell.tsx | 24 +++--- web/src/Users.tsx | 14 ++-- web/src/api.ts | 10 +-- web/src/guest.ts | 32 ++------ 11 files changed, 65 insertions(+), 346 deletions(-) diff --git a/PLAN.md b/PLAN.md index 7a858d5..f2004fc 100644 --- a/PLAN.md +++ b/PLAN.md @@ -18,7 +18,7 @@ stub tab or a roadmap item. | Data | `/home/core/apps/hamlab/` — `config.yaml`, `state.db` (SQLite) | `apps/` is the nightly rsync target to `grid`, so it's backed up for free. | | Ingress | NPM proxy host `hamlab.bigham.dev` → `:8100`, LE cert, WebSockets on, caching off, force-SSL + HSTS + HTTP/2 set in a **second** API call (NPM drops them on create-with-new-cert) | `*.bigham.dev` already resolves; the proxy host alone makes it live. | | Repo | Gitea `mitchell/hamlab`, private, `main` | Created 2026-09-17. | -| Guest logins | A **guest** is an account like any other, with `guest: true` and an expiry, so login, sessions and prefs need no special case. What it may *do* is one allow-list of routes checked in the auth middleware, not a flag each handler honours. Denied by default. | Built 2026-09-27, asked for the same day. There are ~190 routes; a per-handler check would be wrong the first time a route was added. The list is matched by asking the router which handler a request would reach, so it cannot drift from the routes it is about, and it is applied to the *scoped* path (`/api/nodes/grid/files/read` → `/api/files/read`) because the hub proxies to a node with a token that is full control of it — the node cannot tell a guest from its owner, so the decision has to be made on the hub. | +| Guest logins | A **guest** is an account like any other, with `guest: true` and an expiry: `login`, sessions and prefs need no special case, and **it may do everything an account may do**. The expiry is the whole feature — checked on every request and swept once a minute — with revoking as the manual version of it. | Built 2026-09-27. A read-only version was built first and **cut the same day**: Mitchell hands these to people he trusts and revokes them otherwise, so a console with half its tabs missing was solving a problem he doesn't have. Consequences, stated once and accepted: a guest has the Terminal tab, which is a root-capable shell on every host, and can make an account or token of its own that outlives the guest login. The one thing a guest is not is *an account*: it is left out of the Users list, out of "the last account can't be removed", and out of the single account an empty user name resolves to. | | Machines | **A view shows one machine: the one in the switcher.** No hub-side page lists or aggregates what its nodes are doing. To see or change anything on another machine, switch to it. | Mitchell, 2026-09-25, cutting a fleet-wide updates list out of the Docker tab. The switcher is the only place a machine is chosen, so there is exactly one answer to "which machine am I looking at" — a page that quietly reaches across breaks that, and it is never obvious which machine an action would land on. The hub still *proxies* for a node, and hub-only admin (Machines, Users, Settings, Alerts) is unaffected. | ## Layout diff --git a/README.md b/README.md index 0e8f575..7642367 100644 --- a/README.md +++ b/README.md @@ -55,13 +55,12 @@ users` lists them, `hamlab user-rm ` removes one. Machine settings — ref graph history, alert rules — stay the machine's and are shared by everyone. **Guest logins**, for showing somebody the console: Users → Guest logins makes a -throwaway user name and password, good for half an hour by default, and hands you the -pair to paste into a message. A guest sees the dashboard, the containers, the disks, the -hardware and the machines this hub manages, and can change none of it — no terminal, no -files, no logs or container environments, no settings, no power. It can be revoked in one -tap, and it disappears on its own when the time is up, wherever it is signed in. What a -guest may reach is a list of routes in `internal/api/guests.go`, so a route added later -is shut to guests until somebody says otherwise. +throwaway user name and password, good for half an hour by default (up to 24 hours), and +hands you the pair to paste into a message. A guest is an ordinary account with a clock on +it — the whole console, every machine, the terminal included — that stops working when the +time is up, wherever it is signed in, or the moment you revoke it. There is no reduced +guest: give one to somebody you would hand the password to, and revoke it if you change +your mind. What adapts to the host, rather than assuming Debian: diff --git a/internal/api/guests.go b/internal/api/guests.go index 24da918..36919d4 100644 --- a/internal/api/guests.go +++ b/internal/api/guests.go @@ -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) { diff --git a/internal/api/guests_test.go b/internal/api/guests_test.go index c4ebf3e..87227b7 100644 --- a/internal/api/guests_test.go +++ b/internal/api/guests_test.go @@ -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 diff --git a/internal/api/server.go b/internal/api/server.go index 044e9bc..90a93b5 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -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) } diff --git a/web/src/App.tsx b/web/src/App.tsx index 3f9701b..378f504 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -7,16 +7,13 @@ import { applyTheme, currentThemeId } from './themes' import { getHistorySec, getRefreshMs, setHistorySec, setRefreshMs } from './prefs' import { setAccents } from './accents' import { setRailOrder } from './railorder' -import { setGuest } from './guest' export default function App() { const [me, setMe] = useState(undefined) const refresh = useCallback(async () => { try { - const m = await api.meHere() - setGuest(!!m.guest, m.guest_until) // before anything renders: pages ask this, not a prop - setMe(m) + const m = await api.meHere(); setMe(m) api.uiPrefs().then((p) => { if (p.theme && p.theme !== currentThemeId()) applyTheme(p.theme) if (p.refreshMs && p.refreshMs !== getRefreshMs()) setRefreshMs(p.refreshMs) @@ -32,7 +29,7 @@ export default function App() { useEffect(() => { refresh() }, [refresh]) // Any page that gets a 401 from the hub sends us straight back to the login form. useEffect(() => { - const h = () => { setGuest(false); setMe((m) => (m ? null : m)) } + const h = () => setMe((m) => (m ? null : m)) window.addEventListener('hamlab:unauthenticated', h) return () => window.removeEventListener('hamlab:unauthenticated', h) }, []) diff --git a/web/src/Docker.tsx b/web/src/Docker.tsx index 924f6f7..6841eff 100644 --- a/web/src/Docker.tsx +++ b/web/src/Docker.tsx @@ -7,7 +7,6 @@ import TaskOutput from './TaskOutput' import StackNew, { DupFrom } from './StackNew' import { Sensor, fetchHistories, fmtValue, useSensorStream } from './sensors' import { useHashSel } from './hashsel' -import { isGuest } from './guest' import { fmtWindowSec, useHistorySec, useRefreshMs } from './prefs' import Sparkline from './Sparkline' import { fmtBytes } from './fmt' @@ -400,9 +399,6 @@ export default function Docker() { const [startTask, setStartTask] = useState<{ name: string; id: string } | null>(null) const [pull, setPull] = useState(''); const [pullTask, setPullTask] = useState('') const rate = useRefreshMs() - // A guest login may read the lists and nothing else: no detail pane (a container's - // environment and logs are where the secrets are), no actions, no image checks. - const guest = isGuest() const load = useCallback(async () => { try { setD(await api.docker()); setErr('') } catch (e) { setErr(e instanceof Error ? e.message : 'failed') } api.stacks().then((r) => setStacks(r.stacks)).catch(() => setStacks([])) }, []) useEffect(() => { load(); const t = setInterval(() => { if (document.visibilityState === 'visible') load() }, Math.max(5000, rate)); return () => clearInterval(t) }, [load, rate]) const live = useSensorStream<{ c: Record }>({ c: 'container.*' }, { everyMs: Math.max(2000, rate) }) @@ -418,7 +414,7 @@ export default function Docker() { // What the registry last said about each image, so a container can be marked without // asking again. Only ever read from the cache here — checking is a deliberate act. const [upd, setUpd] = useState(null) - useEffect(() => { if (!guest) api.machineUpdates().then(setUpd).catch(() => setUpd(null)) }, [guest]) + useEffect(() => { api.machineUpdates().then(setUpd).catch(() => setUpd(null)) }, []) const imgState = useMemo(() => Object.fromEntries((upd?.images ?? []).map((i) => [i.image, i.state])), [upd]) const behind = (upd?.images ?? []).filter((i) => i.state === 'update').length if (!d) return
{err ?
{err === 'not found' ? "This machine's Ham Lab is older than the Docker tab. Update it from Machines." : err}
: }
@@ -433,22 +429,22 @@ export default function Docker() {
-
{(guest ? ['containers', 'stacks', 'images', 'volumes', 'networks'] as Tab[] : ['containers', 'stacks', 'updates', 'images', 'volumes', 'networks'] as Tab[]).map((t) => )}
- {!guest && (shownTab === 'containers' || shownTab === 'stacks') &&
} - {!guest && shownTab === 'images' &&
{ e.preventDefault(); try { setPullTask((await api.pullImage(pull.trim())).id) } catch (er) { toast(er instanceof Error ? er.message : 'failed', 'err') } }}> +
{(['containers', 'stacks', 'updates', 'images', 'volumes', 'networks'] as Tab[]).map((t) => )}
+ {(shownTab === 'containers' || shownTab === 'stacks') &&
} + {shownTab === 'images' && { e.preventDefault(); try { setPullTask((await api.pullImage(pull.trim())).id) } catch (er) { toast(er instanceof Error ? er.message : 'failed', 'err') } }}> setPull(e.target.value)} placeholder="pull an image — nginx:alpine, ghcr.io/owner/name:tag" spellCheck={false} autoCapitalize="off" autoCorrect="off" /> {(() => { const unused = d.images.filter((im) => !im.in_use).length; return unused > 0 && { try { toast((await api.pruneImages(true)).result); load() } catch (er) { toast(er instanceof Error ? er.message : 'failed', 'err') } }}>Remove unused · {unused} })()} { try { toast((await api.pruneImages()).result); load() } catch (er) { toast(er instanceof Error ? er.message : 'failed', 'err') } }}>Prune
} - {!guest && shownTab === 'volumes' && (() => { const n = d.volumes.filter((v) => v.anonymous && !((v.used_by as string[] | undefined) ?? []).length).length; return
Deleting a volume deletes its data. Only one nothing uses can be deleted.{n > 0 && { try { toast((await api.pruneVolumes()).result); load() } catch (er) { toast(er instanceof Error ? er.message : 'failed', 'err') } }}>Prune anonymous · {n}}
})()} - {!guest && shownTab === 'networks' && (() => { const n = d.networks.filter((x) => !x.predefined && !((x.used_by as string[] | undefined) ?? []).length).length; return
A network holds no data. Only one nothing is attached to can be removed.{n > 0 && { try { toast((await api.pruneNetworks()).result || 'nothing to remove'); load() } catch (er) { toast(er instanceof Error ? er.message : 'failed', 'err') } }}>Prune · {n}}
})()} + {shownTab === 'volumes' && (() => { const n = d.volumes.filter((v) => v.anonymous && !((v.used_by as string[] | undefined) ?? []).length).length; return
Deleting a volume deletes its data. Only one nothing uses can be deleted.{n > 0 && { try { toast((await api.pruneVolumes()).result); load() } catch (er) { toast(er instanceof Error ? er.message : 'failed', 'err') } }}>Prune anonymous · {n}}
})()} + {shownTab === 'networks' && (() => { const n = d.networks.filter((x) => !x.predefined && !((x.used_by as string[] | undefined) ?? []).length).length; return
A network holds no data. Only one nothing is attached to can be removed.{n > 0 && { try { toast((await api.pruneNetworks()).result || 'nothing to remove'); load() } catch (er) { toast(er instanceof Error ? er.message : 'failed', 'err') } }}>Prune · {n}}
})()} {shownTab === 'images' && pullTask && load()} />} {shownTab === 'containers' && setFilter(e.target.value)} placeholder="filter containers, images, stacks" />}
{shownTab === 'stacks' && (stacks ?? []).map((st) => ( -
{ if (guest) return; st.editable ? setSel('stack:' + st.name) : toast(st.note ?? 'not editable here', 'info') }}> +
st.editable ? setSel('stack:' + st.name) : toast(st.note ?? 'not editable here', 'info')}>
{st.name}
{st.editable ? (st.held_by ? `on ${st.held_by}: ` : '') + st.host_file : st.note}
{st.containers.length === 0 ? 'not deployed' : `${st.running}/${st.containers.length}`}{!st.editable && <>
read-only}
@@ -459,7 +455,7 @@ export default function Docker() {

{stack} · {list.filter((c) => c.state === 'running').length}/{list.length}

{list.map((c) => ( -
{ if (!guest) setSel(c.name) }}> +
setSel(c.name)}>
{c.name}
{c.image}
{c.state === 'running' ? <>{fmtValue(sid(c, 'mem')?.value ?? c.mem, 'B')}
{fmtValue(sid(c, 'cpu')?.value, '%')} : c.state}
@@ -476,26 +472,26 @@ export default function Docker() { else { const typed = await askText(`Delete the volume "${name}" and everything in it?\n\nNothing uses it right now — but if it belongs to a stack that is only down, this is that stack's data. It can't be brought back.\n\nType its name to confirm.`, '', 'Delete'); if (typed !== name) { if (typed !== null) toast('that is not its name — nothing was deleted', 'info'); return } } try { await api.removeVolume(name); toast('volume deleted'); load() } catch (er) { toast(er instanceof Error ? er.message : 'failed', 'err') } } - return {anon ? <>{name.slice(0, 12)}… anonymous : name}{users.length ? users.join(', ') : nothing}{users.length ? in use : guest ? null : } })} + return {anon ? <>{name.slice(0, 12)}… anonymous : name}{users.length ? users.join(', ') : nothing}{users.length ? in use : } })} {d.volumes.length === 0 && No volumes.} )} {shownTab === 'networks' && ( {d.networks.map((row, i) => { const name = String(row.Name), users = (row.used_by as string[] | undefined) ?? [], own = !!row.predefined - return })} + return })} {d.networks.length === 0 && }
NetworkDriverUsed by
{name}{own && · Docker's own}{String(row.Driver ?? '')}{users.length ? users.join(', ') : nothing}{users.length ? in use : own || guest ? null : { try { await api.removeNetwork(name); toast(`${name} removed`); load() } catch (er) { toast(er instanceof Error ? er.message : 'failed', 'err') } }}>✕}
{name}{own && · Docker's own}{String(row.Driver ?? '')}{users.length ? users.join(', ') : nothing}{users.length ? in use : own ? null : { try { await api.removeNetwork(name); toast(`${name} removed`); load() } catch (er) { toast(er instanceof Error ? er.message : 'failed', 'err') } }}>✕}
No networks.
)} {shownTab === 'images' && ( {imageCols.map(([, h]) => )} - {d.images.map((row, i) => {imageCols.map(([k]) => )})}
{h}
{String(row[k] ?? '')}{row.in_use ? in use : guest ? null : { const ref = row.Tag && row.Tag !== '' ? `${row.Repository}:${row.Tag}` : String(row.ID); try { await api.removeImage(ref); toast(`${ref} removed`); load() } catch (er) { toast(er instanceof Error ? er.message : 'failed', 'err') } }}>✕}
+ {d.images.map((row, i) => {imageCols.map(([k]) => {String(row[k] ?? '')})}{row.in_use ? in use : { const ref = row.Tag && row.Tag !== '' ? `${row.Repository}:${row.Tag}` : String(row.ID); try { await api.removeImage(ref); toast(`${ref} removed`); load() } catch (er) { toast(er instanceof Error ? er.message : 'failed', 'err') } }}>✕})} )}
{shownTab === 'containers' && !cur && sel && !selStack &&
setSel('')} />
There is no container called {sel} on this machine. It may have been removed, renamed or recreated under another name.
} - {shownTab === 'containers' && (cur && !guest ? setSel('')} onChanged={load} /> : (sel && !selStack && !guest ? null :
{guest ? 'A guest login sees the lists only.' : 'Select a container.'}
))} + {shownTab === 'containers' && (cur ? setSel('')} onChanged={load} /> : (sel && !selStack ? null :
Select a container.
))} {making && { setMaking(''); setDup(null) }} onCreated={async (name, deploy) => { setMaking(''); setDup(null); setTab('stacks'); if (deploy) { try { setStartTask({ name, id: (await api.stackAction(name, 'deploy')).id }) } catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') } } else setStartTask(null); await load(); setSel('stack:' + name) }} />} {shownTab === 'updates' && } - {shownTab === 'stacks' && (guest ?
A guest login sees the lists only.
: selStack ? setSel('')} onChanged={load} onDuplicate={(f) => { setDup(f); setMaking('stack') }} /> :
Select a stack to edit its compose file.
)} + {shownTab === 'stacks' && (selStack ? setSel('')} onChanged={load} onDuplicate={(f) => { setDup(f); setMaking('stack') }} /> :
Select a stack to edit its compose file.
)}
) } diff --git a/web/src/Shell.tsx b/web/src/Shell.tsx index 195d62d..0ff0990 100644 --- a/web/src/Shell.tsx +++ b/web/src/Shell.tsx @@ -26,7 +26,7 @@ import { LIST_FLOOR, useRefreshMs } from './prefs' import Power from './Power' import { applyAccent } from './accents' import { Btn, Toasts, askText, askYes, hasUnsaved, okToLeave, toast } from './ui' -import { fmtLeft, useCountdown } from './guest' +import { fmtLeft, useTimeLeft } from './guest' // Inline outline icon so it matches the monochrome glyphs in the rail (emoji would be full colour). const FolderIcon = ( @@ -76,10 +76,6 @@ const GROUPS: Group[] = [ type View = 'terminal' | 'widgets' | 'docker' | 'alerts' | 'services' | 'jobs' | 'updates' | 'disks' | 'hardware' | 'zfs' | 'usage' | 'files' | 'procs' | 'machines' | 'kvm' | 'users' | 'settings' const VIEWS: View[] = ['terminal', 'widgets', 'docker', 'alerts', 'services', 'jobs', 'updates', 'disks', 'hardware', 'zfs', 'usage', 'files', 'procs', 'machines', 'kvm', 'users', 'settings'] const HUB_ONLY: View[] = ['machines', 'users', 'settings', 'alerts'] -// What a guest login is shown: the pages whose reads the server allows it (guestRoutes in -// internal/api/guests.go). Every other page would be a wall of 403s, and the two that are -// missing on purpose — Terminal and Files — are the ones a look-around login must not have. -const GUEST_VIEWS: View[] = ['procs', 'docker', 'disks', 'usage', 'zfs', 'hardware', 'widgets'] // URL hash is the source of truth for where you are: #//. Back and // forward work, reload restores the tab, and links can point at a page on a machine. @@ -109,10 +105,11 @@ export default function Shell({ me: hubMe, onLoggedOut }: { me: Me; onLoggedOut: const [machines, setMachines] = useState([]) const [remoteMe, setRemoteMe] = useState(null) const [remoteErr, setRemoteErr] = useState('') + // A guest login is an account with a clock on it: nothing is hidden from it, but the header + // says how long it has, for whoever is holding the phone. + const leftMs = useTimeLeft(hubMe.guest_until) // A machine is titled by its own hostname; the name it was added under stays its identity // (the URL, its accent, what the hub's API calls it). See titleOf. - const guest = !!hubMe.guest - const leftMs = useCountdown(guest) const title = titleOf(machine, machines, hubMe.host) const me: Me = machine ? (remoteMe ?? { ...hubMe, host: title, load: '', version: '…', uptime: 0, reboot_needed: false }) : hubMe const listRate = useRefreshMs(LIST_FLOOR) @@ -125,13 +122,12 @@ export default function Shell({ me: hubMe, onLoggedOut }: { me: Me; onLoggedOut: const handles = useRef>({}) const reload = useCallback(async () => { - if (guest) { setTerms([]); return } // a guest has no shell, here or anywhere try { const list = await api.terminals() setTerms(list) setActive((a) => (a && list.some((t) => t.id === a) ? a : list[0]?.id ?? null)) } catch (e) { setTerms([]); if (machine) setRemoteErr(e instanceof Error ? e.message : 'unreachable') } - }, [machine, guest]) + }, [machine]) useEffect(() => { reload() }, [reload]) @@ -164,18 +160,16 @@ export default function Shell({ me: hubMe, onLoggedOut }: { me: Me; onLoggedOut: // keep the hash in step, and follow it when the browser navigates const [firing, setFiring] = useState(0) useEffect(() => { - if (guest) return const tick = () => { if (document.visibilityState === 'visible') api.alerts().then((a) => setFiring(a.firing.length)).catch(() => {}) } tick(); const t = setInterval(tick, 15000); window.addEventListener('hamlab:alerts', tick) return () => { clearInterval(t); window.removeEventListener('hamlab:alerts', tick) } - }, [guest]) + }, []) // The machine's colour: set on , so the header stripe and the switcher pick it up. // Hub-only pages make no sense on a remote machine. Judged once both have settled, not while // the machine is changing: the URL sets the machine and the page in one go, and deciding from // inside the machine change read the page we were leaving — so a link from Settings to a // machine's Disks arrived at its Dashboard. useEffect(() => { if (machine && HUB_ONLY.includes(view)) setView('procs') }, [machine, view]) - useEffect(() => { if (guest && !GUEST_VIEWS.includes(view)) setView('procs') }, [guest, view]) useEffect(() => { const paint = () => applyAccent(machine ?? hubMe.host); paint(); window.addEventListener('hamlab:accents', paint); return () => window.removeEventListener('hamlab:accents', paint) }, [machine, hubMe.host]) // The tab's title says where you are, so history entries and open tabs can be told apart. @@ -283,14 +277,14 @@ export default function Shell({ me: hubMe, onLoggedOut }: { me: Me; onLoggedOut: load {me.load} - {!guest && } - {guest && guest · {fmtLeft(leftMs)}} + + {hubMe.guest && guest · {fmtLeft(leftMs)}} {hubMe.account && {hubMe.account}} logout
- (guest ? GUEST_VIEWS.includes(g.id as View) : !machine || !HUB_ONLY.includes(g.id as View)))} view={view} badges={{ alerts: firing, settings: hubMe.update_available ? 1 : 0 }} + !HUB_ONLY.includes(g.id as View)) : GROUPS} view={view} badges={{ alerts: firing, settings: hubMe.update_available ? 1 : 0 }} header={ { setView('machines'); setDrawer(false) }} />} onView={async (id) => { if (id !== view && !(await okToLeave())) return; setView(id as View); setDrawer(false) }} />
setDrawer(false)} /> diff --git a/web/src/Users.tsx b/web/src/Users.tsx index a652c54..14024ed 100644 --- a/web/src/Users.tsx +++ b/web/src/Users.tsx @@ -20,9 +20,9 @@ export default function Users({ me }: { me: Me }) { const GUEST_MINUTES = [15, 30, 60, 120, 480] as const -// A login to hand somebody so they can look at the console: it expires on its own, it can be -// ended here at any moment, and it can only read. Making one is the only way to see its -// password, so it is shown until dismissed rather than in a toast. +// A login to hand somebody you are showing the console to: an ordinary account with a clock +// on it, which expires on its own and can be ended here at any moment. Making one is the only +// time its password is readable, so it is shown until dismissed rather than in a toast. function Guests() { const [list, setList] = useState(null) const [minutes, setMinutes] = useState(30) @@ -39,16 +39,16 @@ function Guests() { finally { setBusy(false) } } const revoke = async (g: Guest) => { - if (!await askYes(`End the guest login ${g.name}? Whoever has it is signed out at once.`, 'Revoke')) return + if (!await askYes(`End the guest login ${g.name}? Whoever has it is signed out at once, everywhere.`, 'Revoke')) return try { await api.revokeGuest(g.name); toast(`${g.name} revoked`); if (fresh?.name === g.name) setFresh(null); load() } catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') } } return ( <> -

Guest logins

temporary, and can only look
+

Guest logins

temporary, and can be revoked
{list === null && } - {list?.length === 0 &&
None. One lets somebody look around without an account.
} + {list?.length === 0 &&
None. One lets somebody in for a while without giving them an account.
} {list?.map((g) => { const left = new Date(g.expires).getTime() - Date.now() return ( @@ -67,7 +67,7 @@ function Guests() { {GUEST_MINUTES.map((m) => )} -
Create guest loginA guest sees the dashboard, containers, disks and hardware, read-only. No terminal, no files, no settings, nothing it can change. It stops working when the time is up, or when you revoke it.
+
Create guest loginA guest can do everything you can, on every machine — including the terminal — until the time is up or you revoke it. Give one out accordingly.
) diff --git a/web/src/api.ts b/web/src/api.ts index a2c996c..34a3f83 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -1,12 +1,5 @@ -import { isGuest } from './guest' - export class Unauthenticated extends Error { constructor(msg = 'unauthenticated') { super(msg) } } -// What a guest login may send. Everything else that would change something is refused here -// as well as by the server, so a button a page forgot to hide fails without a round trip. -const GUEST_WRITES = new Set(['POST /api/logout', 'PUT /api/prefs/ui']) -export const guestRefusal = 'a guest login can only look around' - // Which machine the console is looking at. '' is this host; otherwise every /api/… call // is rewritten to /api/nodes//… and the hub proxies it. Hub-only calls (login, // the machine list itself) pass local=true and are never rewritten. @@ -26,7 +19,6 @@ function track(delta: number) { } async function req(method: string, path: string, body?: unknown, local = false): Promise { - if (isGuest() && method !== 'GET' && !GUEST_WRITES.has(`${method} ${path}`)) throw new Error(guestRefusal) const target = local ? path : apiPath(path) const mach = machineOf(target) if (mach && downUntil[mach] > Date.now()) throw new Error(`${mach} unreachable (retrying shortly)`) @@ -166,7 +158,7 @@ export interface UIPrefs { theme?: string; refreshMs?: number; accents?: Record< export interface User { name: string; created: string; last_seen?: string; sessions: number; self: boolean } export interface Session { id: string; created: string; last_seen: string; expires: string; ip?: string; agent?: string; current: boolean } export interface TokenInfo { name: string; created: string; scope?: string; sensors?: string[]; issued_by?: string } -// A guest login: temporary, look-only, gone at `expires` or when revoked. +// A guest login: a full account with a clock on it, gone at `expires` or when revoked. export interface Guest { name: string; created: string; expires: string; issued_by?: string; sessions: number; used: boolean } export interface FreshGuest { name: string; password: string; expires: string; minutes: number } diff --git a/web/src/guest.ts b/web/src/guest.ts index b45bca6..d3e0d59 100644 --- a/web/src/guest.ts +++ b/web/src/guest.ts @@ -1,27 +1,7 @@ -// Whether this console is being looked at through a guest login, kept here so any page can -// ask without it being threaded through every prop. Set from /api/me when the app loads. -// -// This is politeness, not security: a guest is stopped by the server, which answers 403 to -// everything but the handful of reads it allows (internal/api/guests.go). What it buys is a -// console that doesn't offer a visitor buttons that would only fail. +// A guest login's clock. That is the whole difference between a guest and an account: it +// can do everything, until it can't, so what the console has to show is how long is left. import { useEffect, useState } from 'react' -let guest = false -let until = '' - -export function setGuest(on: boolean, expires?: string) { - guest = on - until = expires ?? '' -} - -export const isGuest = () => guest - -// msLeft is how long this guest login has, or null when it isn't one. -export function msLeft(): number | null { - if (!guest || !until) return null - return Math.max(0, new Date(until).getTime() - Date.now()) -} - // fmtLeft says how long is left, in the roundest terms that are still honest. export function fmtLeft(ms: number): string { const min = Math.floor(ms / 60000) @@ -30,13 +10,15 @@ export function fmtLeft(ms: number): string { return 'under a minute' } -// useCountdown re-renders the caller every 15 seconds, for a clock that has to keep up. -export function useCountdown(active: boolean): number { +// useTimeLeft is the milliseconds until `until`, re-rendering the caller every 15 seconds so +// the number keeps up. 0 when there is no expiry, which is every normal account. +export function useTimeLeft(until?: string): number { const [, tick] = useState(0) + const active = !!until useEffect(() => { if (!active) return const t = setInterval(() => tick((n) => n + 1), 15000) return () => clearInterval(t) }, [active]) - return msLeft() ?? 0 + return until ? Math.max(0, new Date(until).getTime() - Date.now()) : 0 }