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:
Mitchell-Johnson
2026-09-27 17:53:55 -07:00
co-authored by Claude Opus 5
parent 8a7167bf03
commit 179f342a2c
11 changed files with 65 additions and 346 deletions
+10 -97
View File
@@ -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) {