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) {
|
||||
|
||||
Reference in New Issue
Block a user