diff --git a/PLAN.md b/PLAN.md index 508df5c..7a858d5 100644 --- a/PLAN.md +++ b/PLAN.md @@ -18,6 +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. | | 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 e1288f3..0e8f575 100644 --- a/README.md +++ b/README.md @@ -54,6 +54,15 @@ reset a password; your own password confirms the last two). From the shell: users` lists them, `hamlab user-rm ` removes one. Machine settings — refresh rate, 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. + What adapts to the host, rather than assuming Debian: | | | diff --git a/cmd/hamlab/main.go b/cmd/hamlab/main.go index 5fba657..5c25894 100644 --- a/cmd/hamlab/main.go +++ b/cmd/hamlab/main.go @@ -151,7 +151,14 @@ func main() { if !u.LastSeen.IsZero() { seen = u.LastSeen.Local().Format("2006-01-02 15:04") } - fmt.Printf("%-20s created %s last seen %s\n", u.Name, u.Created.Local().Format("2006-01-02"), seen) + note := "" + if u.Guest { // a temporary look-only login, not one of the machine's accounts + note = " guest, until " + u.Expires.Local().Format("15:04") + if u.Expired(time.Now()) { + note = " guest, expired" + } + } + fmt.Printf("%-20s created %s last seen %s%s\n", u.Name, u.Created.Local().Format("2006-01-02"), seen, note) } case "user-rm": if err := userRm(cfg, flag.Arg(1)); err != nil { diff --git a/internal/api/guests.go b/internal/api/guests.go new file mode 100644 index 0000000..24da918 --- /dev/null +++ b/internal/api/guests.go @@ -0,0 +1,280 @@ +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. +// +// 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. + +import ( + "encoding/json" + "io" + "log" + "net/http" + "net/url" + "strings" + "time" + + "hamlab/internal/auth" + "hamlab/internal/config" + "hamlab/internal/store" +) + +const ( + guestPrefix = "guest-" + guestTTLDefault = 30 * time.Minute + guestTTLMax = 24 * time.Hour + 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) { + now := time.Now() + counts := s.sessions.Count() + out := []map[string]any{} + s.cfg.View(func(c *config.Config) { + for _, u := range c.Users { + if !u.Guest || u.Expired(now) { + continue + } + out = append(out, map[string]any{ + "name": u.Name, "created": u.Created, "expires": u.Expires, + "issued_by": u.IssuedBy, "sessions": counts[u.Name], "used": !u.LastSeen.IsZero(), + }) + } + }) + jsonOK(w, 200, out) +} + +// addGuest makes a guest login and hands back its password — the only time it is ever +// 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"` + } + _ = json.NewDecoder(io.LimitReader(r.Body, 4096)).Decode(&body) + ttl := guestTTLDefault + if body.Minutes > 0 { + ttl = time.Duration(body.Minutes) * time.Minute + } + if ttl > guestTTLMax { + jsonErr(w, 400, "a guest login lasts at most 24 hours") + return + } + pw, err := auth.GuestPassword() + if err != nil { + jsonErr(w, 500, err.Error()) + return + } + hash, err := auth.HashPassword(pw) + if err != nil { + jsonErr(w, 500, err.Error()) + return + } + var name string + for try := 0; try < 8 && name == ""; try++ { + suffix, err := auth.GuestSuffix() + if err != nil { + jsonErr(w, 500, err.Error()) + return + } + candidate := guestPrefix + suffix + s.cfg.View(func(c *config.Config) { + if _, taken := c.User(candidate); !taken { + name = candidate + } + }) + } + if name == "" { + jsonErr(w, 500, "could not find a free guest name") + return + } + now := time.Now() + expires := now.Add(ttl) + if err := s.cfg.Update(func(c *config.Config) { + c.Users = append(c.Users, config.User{Name: name, Hash: hash, Created: now, Guest: true, Expires: expires, IssuedBy: me.User}) + }); err != nil { + jsonErr(w, 500, err.Error()) + return + } + log.Printf("guest login %q issued until %s (by %s)", name, expires.Format(time.RFC3339), clientIP(r)) + jsonOK(w, 200, map[string]any{"name": name, "password": pw, "expires": expires, "minutes": int(ttl.Minutes())}) +} + +// 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) { + kept := c.Users[:0] + for _, u := range c.Users { + if u.Name == name && u.Guest { + found = true + continue + } + kept = append(kept, u) + } + c.Users = kept + }); err != nil { + jsonErr(w, 500, err.Error()) + return + } + if !found { + jsonErr(w, 404, "no such guest login") + return + } + s.forgetGuest(name) + log.Printf("guest login %q revoked (by %s)", name, clientIP(r)) + w.WriteHeader(204) +} + +// forgetGuest signs a guest out everywhere and drops the look they left behind. +func (s *Server) forgetGuest(name string) { + s.sessions.RevokeUser(name) + _ = s.store.Update(func(st *store.State) { delete(st.Users, name) }) +} + +// watchGuests clears out guest logins whose time is up. Expiry is enforced on every request +// as well — this is what keeps the config file from collecting them, and what signs a guest +// out of a console they left open rather than waiting for their next click. +func (s *Server) watchGuests() { + for { + s.reapGuests() + time.Sleep(guestReapEvery) + } +} + +func (s *Server) reapGuests() { + now := time.Now() + var gone []string + var have bool + s.cfg.View(func(c *config.Config) { + for _, u := range c.Users { + if u.Guest { + have = true + if u.Expired(now) { + gone = append(gone, u.Name) + } + } + } + }) + if !have || len(gone) == 0 { + return + } + expired := map[string]bool{} + for _, n := range gone { + expired[n] = true + } + if err := s.cfg.Update(func(c *config.Config) { + kept := c.Users[:0] + for _, u := range c.Users { + if expired[u.Name] { + continue + } + kept = append(kept, u) + } + c.Users = kept + }); err != nil { + log.Printf("guests: %v", err) + return + } + for _, n := range gone { + s.forgetGuest(n) + } + log.Printf("guest login(s) expired: %s", strings.Join(gone, ", ")) +} diff --git a/internal/api/guests_test.go b/internal/api/guests_test.go new file mode 100644 index 0000000..c4ebf3e --- /dev/null +++ b/internal/api/guests_test.go @@ -0,0 +1,205 @@ +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") + } + }) + } +} diff --git a/internal/api/server.go b/internal/api/server.go index 01361de..044e9bc 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -140,9 +140,18 @@ func New(cfg *config.Config, sessions *auth.Sessions, mgr *term.Manager, col *me } go s.keepFleetInStep() } + go s.watchGuests() go s.watchForUpdates(context.Background()) go s.watchDockerEvents(context.Background()) + s.mux = s.routes() + return s +} + +// routes is the whole HTTP surface. A method of its own, rather than inline in New, so a +// test can build the real router without the machinery behind it — which is what the guest +// gate is checked against, since it decides a request by asking this router where it would go. +func (s *Server) routes() *http.ServeMux { m := http.NewServeMux() m.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(204) }) m.HandleFunc("POST /api/login", s.login) @@ -289,6 +298,9 @@ func New(cfg *config.Config, sessions *auth.Sessions, mgr *term.Manager, col *me m.HandleFunc("POST /api/users", s.auth(s.addUser)) m.HandleFunc("DELETE /api/users/{name}", s.auth(s.removeUser)) m.HandleFunc("POST /api/users/{name}/password", s.auth(s.resetPassword)) + m.HandleFunc("GET /api/guests", s.auth(s.listGuests)) + m.HandleFunc("POST /api/guests", s.auth(s.addGuest)) + m.HandleFunc("DELETE /api/guests/{name}", s.auth(s.revokeGuest)) m.HandleFunc("GET /api/sessions", s.auth(s.listSessions)) m.HandleFunc("DELETE /api/sessions/{id}", s.auth(s.revokeSession)) m.HandleFunc("POST /api/sessions/revoke-others", s.auth(s.revokeOtherSessions)) @@ -318,8 +330,7 @@ func New(cfg *config.Config, sessions *auth.Sessions, mgr *term.Manager, col *me jsonErr(w, 404, "not found") })) m.HandleFunc("/", s.static) - s.mux = m - return s + return m } // sensorPath reports whether a path belongs to the read-only sensor API. @@ -394,25 +405,71 @@ func (s *Server) setCookie(w http.ResponseWriter, r *http.Request, tok string, m }) } -// signedIn reports whether a request carries a live browser session, without the -// side effects of the auth middleware. +// 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. func (s *Server) signedIn(r *http.Request) bool { c, err := r.Cookie(cookieName) if err != nil { return false } - _, ok := s.sessions.Lookup(c.Value, clientKey(r)) - return ok + 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 } -// auth accepts the browser session cookie or a bearer API token issued to a hub. +// 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 +// stops at the expiry, not at whatever they click next. +func (s *Server) cookieActor(w http.ResponseWriter, r *http.Request) (a actor, ok, answered bool) { + c, err := r.Cookie(cookieName) + if err != nil { + return actor{}, false, false + } + ses, live := s.sessions.Lookup(c.Value, clientKey(r)) + if !live { + return actor{}, false, false + } + var u config.User + var found bool + s.cfg.View(func(cf *config.Config) { u, found = cf.User(ses.User) }) + if !found || u.Expired(time.Now()) { + s.sessions.Delete(c.Value) + s.setCookie(w, r, "", -1) + if found { + jsonErr(w, 401, "this guest login has expired") + } else { + jsonErr(w, 401, "that account no longer exists") + } + return actor{}, false, true + } + 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). func (s *Server) auth(next http.HandlerFunc) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - if c, err := r.Cookie(cookieName); err == nil { - if ses, ok := s.sessions.Lookup(c.Value, clientKey(r)); ok { - next(w, withActor(r, actor{User: ses.User, Session: ses.ID, token: c.Value})) + a, ok, answered := s.cookieActor(w, r) + if answered { + return + } + if ok { + if a.Guest && !s.guestMay(r) { + jsonErr(w, 403, guestRefusal) return } + next(w, withActor(r, a)) + return } if h := r.Header.Get("Authorization"); strings.HasPrefix(h, "Bearer ") { var hashes, names []string @@ -446,11 +503,15 @@ func (s *Server) sensorAuth(next http.HandlerFunc) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { ok, trusted := false, false var patterns []string - if c, err := r.Cookie(cookieName); err == nil { - if ses, valid := s.sessions.Lookup(c.Value, clientKey(r)); valid { - ok, trusted = true, true - r = withActor(r, actor{User: ses.User, Session: ses.ID, token: c.Value}) + 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) } if !ok { presented, fromURL := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer "), false @@ -919,11 +980,11 @@ func (s *Server) login(w http.ResponseWriter, r *http.Request) { // name is allowed only while there is exactly one account — what a client from before // user names sends, and what a hub pairing with a one-user node may leave out. func (s *Server) checkLogin(username, password string) (string, bool) { - var users []config.User - s.cfg.View(func(c *config.Config) { users = append(users, c.Users...) }) + var users, real []config.User + s.cfg.View(func(c *config.Config) { users, real = append(users, c.Users...), c.Accounts() }) username = strings.TrimSpace(username) - if username == "" && len(users) == 1 { - username = users[0].Name + if username == "" && len(real) == 1 { // guest logins are not the account an empty name means + username = real[0].Name } if username == "" { auth.CheckUser(nil, "", password) // take as long as a real check @@ -1058,8 +1119,14 @@ func (s *Server) me(w http.ResponseWriter, r *http.Request) { addr = h // the address this console listens on, for the machine menu } }) + me := actorOf(r) + var guestUntil any + if me.Guest { + guestUntil = me.Expires + } jsonOK(w, 200, map[string]any{ - "host": s.hostName(), "user": s.user, "account": actorOf(r).User, "version": s.version, + "host": s.hostName(), "user": s.user, "account": me.User, "version": s.version, + "guest": me.Guest, "guest_until": guestUntil, "load": load, "uptime": uptime, "service_started": s.started, "reboot_needed": rebootErr == nil, "fingerprint": s.fingerprint, "arch": runtime.GOARCH, "addr": addr, "update_available": updateVersion, diff --git a/internal/api/users.go b/internal/api/users.go index 3e77a57..75606aa 100644 --- a/internal/api/users.go +++ b/internal/api/users.go @@ -26,6 +26,10 @@ type actor struct { Session string // session ID, "" for a token token string // the raw cookie token, so a handler can spare the current session Token string // token name, "" for a session + // Guest marks a temporary look-around login, and Expires is when it stops working. What + // a guest may reach is decided in the middleware, not here; see internal/api/guests.go. + Guest bool + Expires time.Time } type ctxKey int @@ -44,6 +48,8 @@ func actorOf(r *http.Request) actor { // who names the caller for a log line: "core@10.0.0.5" for a person, "token:hub:core@…" for a hub. func (a actor) String() string { switch { + case a.User != "" && a.Guest: + return "guest:" + a.User case a.User != "": return a.User case a.Token != "": @@ -59,7 +65,7 @@ func (s *Server) listUsers(w http.ResponseWriter, r *http.Request) { counts := s.sessions.Count() var out []map[string]any s.cfg.View(func(c *config.Config) { - for _, u := range c.Users { + for _, u := range c.Accounts() { // guest logins are listed on their own, see listGuests var seen any if !u.LastSeen.IsZero() { seen = u.LastSeen @@ -84,6 +90,10 @@ func (s *Server) addUser(w http.ResponseWriter, r *http.Request) { jsonErr(w, 400, "a user name is 1–32 lower-case letters, digits, . _ or -, starting with a letter or digit") return } + if strings.HasPrefix(name, guestPrefix) { + jsonErr(w, 400, "names starting with "+guestPrefix+" belong to guest logins") + return + } if len(body.Password) < auth.MinPasswordLen { jsonErr(w, 400, "password must be at least 12 characters") return @@ -141,7 +151,7 @@ func (s *Server) removeUser(w http.ResponseWriter, r *http.Request) { } found, last := false, false if err := s.cfg.Update(func(c *config.Config) { - if len(c.Users) <= 1 { + if len(c.Accounts()) <= 1 { // guest logins don't keep the last real account alive last = true return } diff --git a/internal/auth/auth.go b/internal/auth/auth.go index f973caa..92eb22e 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -113,11 +113,14 @@ var dummyHash = func() string { return h }() -// CheckUser verifies a password for one of users. A missing user fails in the same time -// as a wrong password. +// CheckUser verifies a password for one of users. A missing user — or a guest login whose +// time is up — fails in the same time as a wrong password. func CheckUser(users []config.User, name, pw string) bool { for _, u := range users { if u.Name == name { + if u.Expired(time.Now()) { + break + } return VerifyPassword(u.Hash, pw) } } @@ -125,6 +128,51 @@ func CheckUser(users []config.User, name, pw string) bool { return false } +// ---- guest logins ---- + +// guestAlphabet leaves out every letter and digit that can be misread in a screenshot or +// over the phone: i, l, o, 0 and 1. A guest login is read aloud and typed by hand. +const guestAlphabet = "abcdefghjkmnpqrstuvwxyz23456789" + +// randomFrom returns n characters of guestAlphabet, chosen without modulo bias. +func randomFrom(n int) (string, error) { + out := make([]byte, 0, n) + buf := make([]byte, n) + limit := byte(256 - 256%len(guestAlphabet)) // the top of the last whole run of the alphabet + for len(out) < n { + if _, err := rand.Read(buf); err != nil { + return "", err + } + for _, b := range buf { + if b >= limit { + continue // would favour the first few letters; ask for another byte instead + } + out = append(out, guestAlphabet[int(b)%len(guestAlphabet)]) + if len(out) == n { + break + } + } + } + return string(out), nil +} + +// GuestPassword is a password for a guest login: four groups of four, ~78 bits, in an +// alphabet with nothing ambiguous in it. +func GuestPassword() (string, error) { + var groups []string + for i := 0; i < 4; i++ { + g, err := randomFrom(4) + if err != nil { + return "", err + } + groups = append(groups, g) + } + return strings.Join(groups, "-"), nil +} + +// GuestSuffix is the tail of a generated guest name. +func GuestSuffix() (string, error) { return randomFrom(4) } + // ---- sessions ---- // Sessions is a token store backed by the state file. Expiry is sliding. diff --git a/internal/config/config.go b/internal/config/config.go index e7efd42..557946f 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -46,6 +46,22 @@ type User struct { Hash string `json:"hash"` Created time.Time `json:"created"` LastSeen time.Time `json:"last_seen,omitempty"` + // Guest marks a temporary login made to show somebody the console: it may only read, + // it expires by itself at Expires, and it is not one of the machine's accounts — it is + // left out of the account list, does not count towards "the last account", and cannot + // be the one an empty user name means. See internal/api/guests.go. + Guest bool `json:"guest,omitempty"` + // Expires is when a guest login stops working. A guest without one has already + // expired, which is the safe way to read a half-written file. + Expires time.Time `json:"expires,omitempty"` + // IssuedBy is the account that made this guest login. + IssuedBy string `json:"issued_by,omitempty"` +} + +// Expired reports whether a guest login's time is up. A real account never expires; a +// guest with no expiry at all counts as expired rather than as forever. +func (u User) Expired(now time.Time) bool { + return u.Guest && (u.Expires.IsZero() || now.After(u.Expires)) } // UpdateSource is where this machine looks for a newer Ham Lab, and how often. The field @@ -82,8 +98,8 @@ type Config struct { Listen string `json:"listen"` // PasswordHash is the single login of installs before users existed. On load it becomes // the first user (see MigrateUsers) and is then dropped from the file. - PasswordHash string `json:"password_hash,omitempty"` - Users []User `json:"users,omitempty"` + PasswordHash string `json:"password_hash,omitempty"` + Users []User `json:"users,omitempty"` TLS string `json:"tls,omitempty"` // "auto" → self-signed cert in the data dir; "" → plain HTTP Tokens []Token `json:"tokens,omitempty"` Nodes []Node `json:"nodes,omitempty"` @@ -145,6 +161,18 @@ func (c *Config) MigrateUsers(name string) bool { return true } +// Accounts is every real account: the machine's users, without the guest logins. +// Call under View/Update or on a config nobody else holds. +func (c *Config) Accounts() []User { + out := make([]User, 0, len(c.Users)) + for _, u := range c.Users { + if !u.Guest { + out = append(out, u) + } + } + return out +} + // User finds an account by name. Call under View/Update or on a config nobody else holds. func (c *Config) User(name string) (User, bool) { for _, u := range c.Users { diff --git a/web/src/App.tsx b/web/src/App.tsx index 378f504..3f9701b 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -7,13 +7,16 @@ 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(); setMe(m) + const m = await api.meHere() + setGuest(!!m.guest, m.guest_until) // before anything renders: pages ask this, not a prop + setMe(m) api.uiPrefs().then((p) => { if (p.theme && p.theme !== currentThemeId()) applyTheme(p.theme) if (p.refreshMs && p.refreshMs !== getRefreshMs()) setRefreshMs(p.refreshMs) @@ -29,7 +32,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 = () => setMe((m) => (m ? null : m)) + const h = () => { setGuest(false); 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 6841eff..924f6f7 100644 --- a/web/src/Docker.tsx +++ b/web/src/Docker.tsx @@ -7,6 +7,7 @@ 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' @@ -399,6 +400,9 @@ 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) }) @@ -414,7 +418,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(() => { api.machineUpdates().then(setUpd).catch(() => setUpd(null)) }, []) + useEffect(() => { if (!guest) api.machineUpdates().then(setUpd).catch(() => setUpd(null)) }, [guest]) 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}
: }
@@ -429,22 +433,22 @@ export default function Docker() {
-
{(['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') } }}> +
{(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') } }}> 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
} - {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}}
})()} + {!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 === 'images' && pullTask && load()} />} {shownTab === 'containers' && setFilter(e.target.value)} placeholder="filter containers, images, stacks" />}
{shownTab === 'stacks' && (stacks ?? []).map((st) => ( -
st.editable ? setSel('stack:' + st.name) : toast(st.note ?? 'not editable here', 'info')}> +
{ if (guest) return; 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}
@@ -455,7 +459,7 @@ export default function Docker() {

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

{list.map((c) => ( -
setSel(c.name)}> +
{ if (!guest) 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}
@@ -472,26 +476,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 : } })} + return {anon ? <>{name.slice(0, 12)}… anonymous : name}{users.length ? users.join(', ') : nothing}{users.length ? in use : guest ? null : } })} {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 ? 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 || guest ? 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 : { 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 : 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') } }}>✕})} )}
{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 ? setSel('')} onChanged={load} /> : (sel && !selStack ? null :
Select a container.
))} + {shownTab === 'containers' && (cur && !guest ? setSel('')} onChanged={load} /> : (sel && !selStack && !guest ? null :
{guest ? 'A guest login sees the lists only.' : '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' && (selStack ? setSel('')} onChanged={load} onDuplicate={(f) => { setDup(f); setMaking('stack') }} /> :
Select a stack to edit its compose file.
)} + {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.
)}
) } diff --git a/web/src/Shell.tsx b/web/src/Shell.tsx index 8c7f215..195d62d 100644 --- a/web/src/Shell.tsx +++ b/web/src/Shell.tsx @@ -26,6 +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' // Inline outline icon so it matches the monochrome glyphs in the rail (emoji would be full colour). const FolderIcon = ( @@ -75,6 +76,10 @@ 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. @@ -106,6 +111,8 @@ export default function Shell({ me: hubMe, onLoggedOut }: { me: Me; onLoggedOut: const [remoteErr, setRemoteErr] = useState('') // 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) @@ -118,12 +125,13 @@ 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]) + }, [machine, guest]) useEffect(() => { reload() }, [reload]) @@ -156,16 +164,18 @@ 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. @@ -273,13 +283,14 @@ export default function Shell({ me: hubMe, onLoggedOut }: { me: Me; onLoggedOut: load {me.load} - + {!guest && } + {guest && guest · {fmtLeft(leftMs)}} {hubMe.account && {hubMe.account}} logout
- !HUB_ONLY.includes(g.id as View)) : GROUPS} view={view} badges={{ alerts: firing, settings: hubMe.update_available ? 1 : 0 }} + (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 }} 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 b92f0cd..a652c54 100644 --- a/web/src/Users.tsx +++ b/web/src/Users.tsx @@ -3,19 +3,98 @@ // a token rather than a login, so a node's accounts are managed on that node's console. import { FormEvent, useCallback, useEffect, useState } from 'react' import { createRoot } from 'react-dom/client' -import { api, Me, Session, User } from './api' +import { api, FreshGuest, Guest, Me, Session, User } from './api' import { Btn, Loading, askYes, toast, useEscape } from './ui' +import { fmtLeft } from './guest' export default function Users({ me }: { me: Me }) { return (
+
) } +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. +function Guests() { + const [list, setList] = useState(null) + const [minutes, setMinutes] = useState(30) + const [fresh, setFresh] = useState(null) + const [busy, setBusy] = useState(false) + const load = useCallback(async () => { try { setList(await api.guests()) } catch { setList([]) } }, []) + useEffect(() => { load() }, [load]) + // The clock is the point of the thing, so it keeps up without a reload. + useEffect(() => { const t = setInterval(load, 30000); return () => clearInterval(t) }, [load]) + const create = async () => { + setBusy(true) + try { setFresh(await api.createGuest(minutes)); load() } + catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') } + 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 + 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
+
+ {list === null && } + {list?.length === 0 &&
None. One lets somebody look around without an account.
} + {list?.map((g) => { + const left = new Date(g.expires).getTime() - Date.now() + return ( +
+ {g.name} + {left > 0 ? `expires in ${fmtLeft(left)}` : 'expiring'} · {g.sessions > 0 ? `${g.sessions} signed in` : g.used ? 'signed in earlier' : 'not used yet'}{g.issued_by ? ` · by ${g.issued_by}` : ''} + revoke(g)}>Revoke +
+ ) + })} +
+ {fresh && setFresh(null)} />} +
+ +
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.
+
+ + ) +} + +// The credentials, once. Copy takes the whole invitation, because that is what gets pasted +// into a message. +function FreshCard({ g, onClose }: { g: FreshGuest; onClose: () => void }) { + const invite = `${location.origin}\nuser: ${g.name}\npassword: ${g.password}\nexpires ${new Date(g.expires).toLocaleTimeString()}` + const copy = async (text: string, what: string) => { + try { await navigator.clipboard.writeText(text); toast(`${what} copied`) } + catch { toast('select and copy it by hand', 'err') } + } + return ( +
+
User{g.name} copy(g.name, 'user')}>Copy
+
Password{g.password} copy(g.password, 'password')}>Copy
+
+ copy(invite, 'invitation')}>Copy invitation + Shown once. Expires {new Date(g.expires).toLocaleTimeString()}. + + +
+
+ ) +} + function Account({ me }: { me: Me }) { const [cur, setCur] = useState('') const [pw1, setPw1] = useState('') diff --git a/web/src/api.ts b/web/src/api.ts index 2f1af82..a2c996c 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -1,5 +1,12 @@ +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. @@ -19,6 +26,7 @@ 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)`) @@ -55,7 +63,7 @@ async function req(method: string, path: string, body?: unknown, local = fals } export interface UpdateStatus { current: string; latest: string; arch: string; behind: boolean; checking: boolean; installing: boolean; enabled: boolean; auto: boolean; url: string; notes?: string; published?: string; checked?: string; error?: string } -export interface Me { addr?: string; host: string; user: string; account?: string; update_available?: string; version: string; load: string; uptime: number; reboot_needed?: boolean; fingerprint?: string; arch?: string } +export interface Me { addr?: string; host: string; user: string; account?: string; guest?: boolean; guest_until?: string; update_available?: string; version: string; load: string; uptime: number; reboot_needed?: boolean; fingerprint?: string; arch?: string } export interface PushResult { name: string; ok: boolean; version?: string; error?: string } // What a machine is called on screen: the hostname it reports, which follows a rename within // a poll. The name it was added under stays its identity — the URL, its accent colour, and @@ -158,6 +166,9 @@ 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. +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 } export interface SpaceProgress { files: number; dirs: number; reused: number; bytes: number; denied: number; current: string } export interface SpaceScan { disk: string; roots: string[]; started: string; finished?: string; running: boolean; loading?: boolean; error?: string; progress: SpaceProgress; as_root: boolean; method?: string; by_root?: Record; partial?: boolean; files: number; dirs: number; size: number; denied: number; read: number; reused: number; partial_dirs: number; bases?: Record } @@ -261,6 +272,9 @@ export const api = { addUser: (name: string, password: string) => req<{ name: string }>('POST', '/api/users', { name, password }, true), removeUser: (name: string, current: string) => req('DELETE', `/api/users/${encodeURIComponent(name)}`, { current }, true), resetPassword: (name: string, current: string, password: string) => req('POST', `/api/users/${encodeURIComponent(name)}/password`, { current, password }, true), + guests: () => req('GET', '/api/guests', undefined, true), + createGuest: (minutes: number) => req('POST', '/api/guests', { minutes }, true), + revokeGuest: (name: string) => req('DELETE', `/api/guests/${encodeURIComponent(name)}`, undefined, true), sessions: () => req('GET', '/api/sessions', undefined, true), revokeSession: (id: string) => req('DELETE', `/api/sessions/${encodeURIComponent(id)}`, undefined, true), revokeOtherSessions: () => req<{ revoked: number }>('POST', '/api/sessions/revoke-others', undefined, true), diff --git a/web/src/guest.ts b/web/src/guest.ts new file mode 100644 index 0000000..b45bca6 --- /dev/null +++ b/web/src/guest.ts @@ -0,0 +1,42 @@ +// 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. +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) + if (min >= 120) return `${Math.floor(min / 60)} h ${min % 60} min` + if (min >= 1) return `${min} min` + 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 { + const [, tick] = useState(0) + useEffect(() => { + if (!active) return + const t = setInterval(() => tick((n) => n + 1), 15000) + return () => clearInterval(t) + }, [active]) + return msLeft() ?? 0 +} diff --git a/web/src/styles.css b/web/src/styles.css index a3ab1d3..bb8535a 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -418,8 +418,12 @@ h2.section { margin: 0; font-size: 13px; font-weight: 600; color: var(--muted); .mitem .mname { flex: 1; } .mitem.manage { color: var(--accent2); } .addform { display: flex; flex-direction: column; gap: 10px; } .addform label { display: flex; flex-direction: column; gap: 4px; font-size: 12px; color: var(--muted); } -.addform input { padding: 8px 10px; background: var(--bg); border: 1px solid var(--border); border-radius: 6px; color: var(--fg); font-size: 15px; } +.addform input, .addform select { padding: 8px 10px; background: var(--bg); border: 1px solid var(--border); border-radius: 6px; color: var(--fg); font-size: 15px; } .header .badge.bad { color: var(--bad); border-color: var(--bad); } +/* a guest login says so, and how long it has, where the power button would be */ +.badge.guest { color: var(--accent2); border-color: var(--accent2); text-transform: none; margin-left: 0; white-space: nowrap; } +.card.fresh { border-color: var(--accent2); } +.card.fresh .mono { user-select: all; } /* settings + themes */ .themes { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 12px; }