A guest login: temporary, look-only, revocable

Users -> Guest logins makes a throwaway user name and password, good for half
an hour by default, to hand to somebody who wants to see the console. It is a
normal account with `guest: true` and an expiry, so login, sessions and the
saved look need no special case, and it is left out of the account list, out of
"the last account can't be removed", and out of the one account an empty user
name means.

What a guest may do is one allow-list, checked in the auth middleware, rather
than a flag ~190 handlers have to remember. The list is matched by asking the
router which handler a request would reach, so it cannot drift from the routes
it names, and it is checked against the scoped path — /api/nodes/grid/procs is
/api/procs on grid — because the hub proxies to a node with a token that is
full control of that node. The node cannot tell a guest from its owner, so the
only place it can be stopped is here, before the request leaves.

A guest gets the dashboard, containers, stacks, disks, hardware, ZFS and usage,
on this machine and on any managed one, and nothing else: no terminal (attaching
is a GET, and it is a root shell), no files, no container logs or environments,
no stack files, no alert channels, no cron, no tokens, no settings, no power.

Expiry is enforced on every request and swept once a minute, so a console left
open on a visitor's phone stops within the minute rather than at their next
click; revoking signs them out at once. The console hides what a guest cannot
use and says how long is left where the power button would be.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Mitchell-Johnson
2026-09-27 17:23:16 -07:00
co-authored by Claude Opus 5
parent 4450f1285d
commit 8a7167bf03
16 changed files with 860 additions and 48 deletions
+1
View File
@@ -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
+9
View File
@@ -54,6 +54,15 @@ reset a password; your own password confirms the last two). From the shell:
users` lists them, `hamlab user-rm <user>` 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:
| | |
+8 -1
View File
@@ -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 {
+280
View File
@@ -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, ", "))
}
+205
View File
@@ -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")
}
})
}
}
+86 -19
View File
@@ -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,
+12 -2
View File
@@ -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
}
+50 -2
View File
@@ -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.
+28
View File
@@ -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
@@ -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 {
+5 -2
View File
@@ -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<Me | null | undefined>(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)
}, [])
+17 -13
View File
@@ -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<string, Sensor> }>({ 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<MachineUpdates | null>(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 <div className="dash">{err ? <div className="notice">{err === 'not found' ? "This machine's Ham Lab is older than the Docker tab. Update it from Machines." : err}</div> : <Loading label="Asking Docker…" />}</div>
@@ -429,22 +433,22 @@ export default function Docker() {
<div className={'svc docker' + (((cur || sel) && shownTab === 'containers') || selStack ? ' has-sel' : '') + (shownTab === 'updates' ? ' wide' : '')}>
<div className="svc-list">
<div className="svc-tools">
<div className="chips">{(['containers', 'stacks', 'updates', 'images', 'volumes', 'networks'] as Tab[]).map((t) => <button key={t} className={'chip' + (shownTab === t ? ' on' : '')} onClick={() => { setTab(t); if (sel) setSel('') }}>{t}{t === 'containers' ? ` · ${up}/${d.containers.length}` : t === 'stacks' ? ` · ${stacks?.length ?? '…'}` : t === 'updates' ? (behind > 0 ? ` · ${behind}` : '') : ` · ${d[t].length}`}</button>)}</div>
{(shownTab === 'containers' || shownTab === 'stacks') && <div className="svc-actions"><button className="btn" onClick={() => setMaking(shownTab === 'stacks' ? 'stack' : 'container')}>{shownTab === 'stacks' ? '+ New stack' : '+ New container'}</button></div>}
{shownTab === 'images' && <form className="chan-add" onSubmit={async (e) => { e.preventDefault(); try { setPullTask((await api.pullImage(pull.trim())).id) } catch (er) { toast(er instanceof Error ? er.message : 'failed', 'err') } }}>
<div className="chips">{(guest ? ['containers', 'stacks', 'images', 'volumes', 'networks'] as Tab[] : ['containers', 'stacks', 'updates', 'images', 'volumes', 'networks'] as Tab[]).map((t) => <button key={t} className={'chip' + (shownTab === t ? ' on' : '')} onClick={() => { setTab(t); if (sel) setSel('') }}>{t}{t === 'containers' ? ` · ${up}/${d.containers.length}` : t === 'stacks' ? ` · ${stacks?.length ?? '…'}` : t === 'updates' ? (behind > 0 ? ` · ${behind}` : '') : ` · ${d[t].length}`}</button>)}</div>
{!guest && (shownTab === 'containers' || shownTab === 'stacks') && <div className="svc-actions"><button className="btn" onClick={() => setMaking(shownTab === 'stacks' ? 'stack' : 'container')}>{shownTab === 'stacks' ? '+ New stack' : '+ New container'}</button></div>}
{!guest && shownTab === 'images' && <form className="chan-add" onSubmit={async (e) => { e.preventDefault(); try { setPullTask((await api.pullImage(pull.trim())).id) } catch (er) { toast(er instanceof Error ? er.message : 'failed', 'err') } }}>
<input className="mono" value={pull} onChange={(e) => setPull(e.target.value)} placeholder="pull an image — nginx:alpine, ghcr.io/owner/name:tag" spellCheck={false} autoCapitalize="off" autoCorrect="off" />
<button className="btn on" disabled={!pull.trim()}>Pull</button>
{(() => { const unused = d.images.filter((im) => !im.in_use).length; return unused > 0 && <Btn className="btn danger" title="remove every image no container uses" confirm={`Remove all ${unused} image${unused === 1 ? '' : 's'} that no container uses? They can be pulled again, but a stack that is down will have to download its images before it next starts.`} onClick={async () => { try { toast((await api.pruneImages(true)).result); load() } catch (er) { toast(er instanceof Error ? er.message : 'failed', 'err') } }}>Remove unused · {unused}</Btn> })()}
<Btn className="btn" title="remove dangling images: layers no tag points at any more" confirm="Remove dangling images? Only layers that no tag points at any more — nothing a container or a tag uses." onClick={async () => { try { toast((await api.pruneImages()).result); load() } catch (er) { toast(er instanceof Error ? er.message : 'failed', 'err') } }}>Prune</Btn>
</form>}
{shownTab === 'volumes' && (() => { const n = d.volumes.filter((v) => v.anonymous && !((v.used_by as string[] | undefined) ?? []).length).length; return <div className="svc-actions"><span className="muted small">Deleting a volume deletes its data. Only one nothing uses can be deleted.</span><span className="spacer" />{n > 0 && <Btn className="btn danger" title="the nameless volumes an image's VOLUME line leaves behind each time its container is recreated" confirm={`Delete ${n} unused anonymous volume${n === 1 ? '' : 's'} and what is in them? Named volumes are not touched.`} onClick={async () => { try { toast((await api.pruneVolumes()).result); load() } catch (er) { toast(er instanceof Error ? er.message : 'failed', 'err') } }}>Prune anonymous · {n}</Btn>}</div> })()}
{shownTab === 'networks' && (() => { const n = d.networks.filter((x) => !x.predefined && !((x.used_by as string[] | undefined) ?? []).length).length; return <div className="svc-actions"><span className="muted small">A network holds no data. Only one nothing is attached to can be removed.</span><span className="spacer" />{n > 0 && <Btn className="btn" title="remove every network no container is attached to" confirm={`Remove ${n} network${n === 1 ? '' : 's'} nothing is attached to? A stack that is only down counts as unattached — its network comes back on the next deploy, with a new subnet.`} onClick={async () => { try { toast((await api.pruneNetworks()).result || 'nothing to remove'); load() } catch (er) { toast(er instanceof Error ? er.message : 'failed', 'err') } }}>Prune · {n}</Btn>}</div> })()}
{!guest && shownTab === 'volumes' && (() => { const n = d.volumes.filter((v) => v.anonymous && !((v.used_by as string[] | undefined) ?? []).length).length; return <div className="svc-actions"><span className="muted small">Deleting a volume deletes its data. Only one nothing uses can be deleted.</span><span className="spacer" />{n > 0 && <Btn className="btn danger" title="the nameless volumes an image's VOLUME line leaves behind each time its container is recreated" confirm={`Delete ${n} unused anonymous volume${n === 1 ? '' : 's'} and what is in them? Named volumes are not touched.`} onClick={async () => { try { toast((await api.pruneVolumes()).result); load() } catch (er) { toast(er instanceof Error ? er.message : 'failed', 'err') } }}>Prune anonymous · {n}</Btn>}</div> })()}
{!guest && shownTab === 'networks' && (() => { const n = d.networks.filter((x) => !x.predefined && !((x.used_by as string[] | undefined) ?? []).length).length; return <div className="svc-actions"><span className="muted small">A network holds no data. Only one nothing is attached to can be removed.</span><span className="spacer" />{n > 0 && <Btn className="btn" title="remove every network no container is attached to" confirm={`Remove ${n} network${n === 1 ? '' : 's'} nothing is attached to? A stack that is only down counts as unattached — its network comes back on the next deploy, with a new subnet.`} onClick={async () => { try { toast((await api.pruneNetworks()).result || 'nothing to remove'); load() } catch (er) { toast(er instanceof Error ? er.message : 'failed', 'err') } }}>Prune · {n}</Btn>}</div> })()}
{shownTab === 'images' && pullTask && <TaskOutput id={pullTask} onDone={() => load()} />}
{shownTab === 'containers' && <input value={filter} onChange={(e) => setFilter(e.target.value)} placeholder="filter containers, images, stacks" />}
</div>
<div className="svc-rows">
{shownTab === 'stacks' && (stacks ?? []).map((st) => (
<div key={st.name} className={'svc-row' + (st.name === selStack ? ' active' : '')} onClick={() => st.editable ? setSel('stack:' + st.name) : toast(st.note ?? 'not editable here', 'info')}>
<div key={st.name} className={'svc-row' + (st.name === selStack ? ' active' : '')} onClick={() => { if (guest) return; st.editable ? setSel('stack:' + st.name) : toast(st.note ?? 'not editable here', 'info') }}>
<span className={'sdot ' + (st.containers.length === 0 ? '' : st.running === st.containers.length ? 'ok' : st.running ? 'warn' : 'bad')} />
<div className="svc-name"><div className="n">{st.name}</div><div className="d">{st.editable ? (st.held_by ? `on ${st.held_by}: ` : '') + st.host_file : st.note}</div></div>
<div className="svc-state">{st.containers.length === 0 ? 'not deployed' : `${st.running}/${st.containers.length}`}{!st.editable && <><br />read-only</>}</div>
@@ -455,7 +459,7 @@ export default function Docker() {
<div key={stack}>
<h3 className="stack-head">{stack} <span className="muted">· {list.filter((c) => c.state === 'running').length}/{list.length}</span></h3>
{list.map((c) => (
<div key={c.id} className={'svc-row' + (c.name === sel ? ' active' : '')} onClick={() => setSel(c.name)}>
<div key={c.id} className={'svc-row' + (c.name === sel ? ' active' : '')} onClick={() => { if (!guest) setSel(c.name) }}>
<span className={'sdot ' + dot(c)} />
<div className="svc-name"><div className="n">{c.name}<UpdateDot state={imgState[c.image]} /></div><div className="d">{c.image}</div></div>
<div className="svc-state">{c.state === 'running' ? <>{fmtValue(sid(c, 'mem')?.value ?? c.mem, 'B')}<br />{fmtValue(sid(c, 'cpu')?.value, '%')}</> : c.state}</div>
@@ -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 <tr key={name}><td className="mono small" title={String(row.Mountpoint ?? '')}>{anon ? <>{name.slice(0, 12)}… <span className="muted">anonymous</span></> : name}</td><td className="small">{users.length ? users.join(', ') : <span className="muted">nothing</span>}</td><td className="row-end">{users.length ? <span className="badge">in use</span> : <button className="icon-btn" title="delete this volume and its data" onClick={remove}>✕</button>}</td></tr> })}
return <tr key={name}><td className="mono small" title={String(row.Mountpoint ?? '')}>{anon ? <>{name.slice(0, 12)}… <span className="muted">anonymous</span></> : name}</td><td className="small">{users.length ? users.join(', ') : <span className="muted">nothing</span>}</td><td className="row-end">{users.length ? <span className="badge">in use</span> : guest ? null : <button className="icon-btn" title="delete this volume and its data" onClick={remove}>✕</button>}</td></tr> })}
{d.volumes.length === 0 && <tr><td colSpan={3} className="muted small">No volumes.</td></tr>}</tbody></table>
)}
{shownTab === 'networks' && (
<table className="dtable"><thead><tr><th>Network</th><th>Driver</th><th>Used by</th><th /></tr></thead>
<tbody>{d.networks.map((row, i) => { const name = String(row.Name), users = (row.used_by as string[] | undefined) ?? [], own = !!row.predefined
return <tr key={i}><td className="mono small">{name}{own && <span className="muted"> · Docker's own</span>}</td><td className="mono small">{String(row.Driver ?? '')}</td><td className="small">{users.length ? users.join(', ') : <span className="muted">nothing</span>}</td><td className="row-end">{users.length ? <span className="badge" title="a container is attached to this network">in use</span> : own ? null : <Btn className="icon-btn" title="remove this network" confirm={`Remove the network ${name}? Nothing is attached to it. A stack that wants it back gets it recreated on its next deploy, with a new subnet.`} onClick={async () => { try { await api.removeNetwork(name); toast(`${name} removed`); load() } catch (er) { toast(er instanceof Error ? er.message : 'failed', 'err') } }}>✕</Btn>}</td></tr> })}
return <tr key={i}><td className="mono small">{name}{own && <span className="muted"> · Docker's own</span>}</td><td className="mono small">{String(row.Driver ?? '')}</td><td className="small">{users.length ? users.join(', ') : <span className="muted">nothing</span>}</td><td className="row-end">{users.length ? <span className="badge" title="a container is attached to this network">in use</span> : own || guest ? null : <Btn className="icon-btn" title="remove this network" confirm={`Remove the network ${name}? Nothing is attached to it. A stack that wants it back gets it recreated on its next deploy, with a new subnet.`} onClick={async () => { try { await api.removeNetwork(name); toast(`${name} removed`); load() } catch (er) { toast(er instanceof Error ? er.message : 'failed', 'err') } }}>✕</Btn>}</td></tr> })}
{d.networks.length === 0 && <tr><td colSpan={4} className="muted small">No networks.</td></tr>}</tbody></table>
)}
{shownTab === 'images' && (
<table className="dtable"><thead><tr>{imageCols.map(([, h]) => <th key={h}>{h}</th>)}<th /></tr></thead>
<tbody>{d.images.map((row, i) => <tr key={i}>{imageCols.map(([k]) => <td key={k} className="mono small">{String(row[k] ?? '')}</td>)}<td className="row-end">{row.in_use ? <span className="badge" title="a container uses this image">in use</span> : <Btn className="icon-btn" title="remove this image" confirm={`Remove the image ${row.Repository}:${row.Tag}? Docker refuses if a container still uses it.`} onClick={async () => { const ref = row.Tag && row.Tag !== '<none>' ? `${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') } }}>✕</Btn>}</td></tr>)}</tbody></table>
<tbody>{d.images.map((row, i) => <tr key={i}>{imageCols.map(([k]) => <td key={k} className="mono small">{String(row[k] ?? '')}</td>)}<td className="row-end">{row.in_use ? <span className="badge" title="a container uses this image">in use</span> : guest ? null : <Btn className="icon-btn" title="remove this image" confirm={`Remove the image ${row.Repository}:${row.Tag}? Docker refuses if a container still uses it.`} onClick={async () => { const ref = row.Tag && row.Tag !== '<none>' ? `${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') } }}>✕</Btn>}</td></tr>)}</tbody></table>
)}
</div>
</div>
{shownTab === 'containers' && !cur && sel && !selStack && <div className="svc-detail"><CloseBtn onClick={() => setSel('')} /><button className="btn back" onClick={() => setSel('')}>‹ Back</button><div className="notice">There is no container called <b className="mono">{sel}</b> on this machine. It may have been removed, renamed or recreated under another name. <button className="link" onClick={() => setSel('')}>Show them all</button></div></div>}
{shownTab === 'containers' && (cur ? <Detail c={cur} live={sensors} onBack={() => setSel('')} onChanged={load} /> : (sel && !selStack ? null : <div className="svc-empty muted">Select a container.</div>))}
{shownTab === 'containers' && (cur && !guest ? <Detail c={cur} live={sensors} onBack={() => setSel('')} onChanged={load} /> : (sel && !selStack && !guest ? null : <div className="svc-empty muted">{guest ? 'A guest login sees the lists only.' : 'Select a container.'}</div>))}
{making && <StackNew key={'new:' + (dup?.name ?? making)} mode={making} from={dup ?? undefined} networks={nets} taken={taken} onClose={() => { 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' && <Updates upd={upd} onChecked={setUpd} />}
{shownTab === 'stacks' && (selStack ? <StackDetail key={selStack + (startTask?.name === selStack ? startTask.id : '')} name={selStack} startTask={startTask?.name === selStack ? startTask.id : undefined} onBack={() => setSel('')} onChanged={load} onDuplicate={(f) => { setDup(f); setMaking('stack') }} /> : <div className="svc-empty muted">Select a stack to edit its compose file.</div>)}
{shownTab === 'stacks' && (guest ? <div className="svc-empty muted">A guest login sees the lists only.</div> : selStack ? <StackDetail key={selStack + (startTask?.name === selStack ? startTask.id : '')} name={selStack} startTask={startTask?.name === selStack ? startTask.id : undefined} onBack={() => setSel('')} onChanged={load} onDuplicate={(f) => { setDup(f); setMaking('stack') }} /> : <div className="svc-empty muted">Select a stack to edit its compose file.</div>)}
</div>
)
}
+15 -4
View File
@@ -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: #/<machine|hub>/<view>. 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<Record<string, TermHandle>>({})
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 <html>, 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:
<span className="spacer" />
<span className="stat" title="load average, 1 · 5 · 15 min">load {me.load}</span>
<span className={'dot ' + (online && !remoteErr ? 'ok' : 'bad')} title={remoteErr ? `${title} unreachable` : online ? 'connected' : 'disconnected'} />
<Power host={me.host} label={title} rebootNeeded={me.reboot_needed} />
{!guest && <Power host={me.host} label={title} rebootNeeded={me.reboot_needed} />}
{guest && <span className="badge guest" title={`this login expires ${new Date(hubMe.guest_until ?? '').toLocaleTimeString()}`}>guest · {fmtLeft(leftMs)}</span>}
{hubMe.account && <span className="host account" title="signed in as">{hubMe.account}</span>}
<Btn className="link" onClick={logout}>logout</Btn>
</header>
<div className="body">
<Rail groups={machine ? GROUPS.filter((g) => !HUB_ONLY.includes(g.id as View)) : GROUPS} view={view} badges={{ alerts: firing, settings: hubMe.update_available ? 1 : 0 }}
<Rail groups={GROUPS.filter((g) => (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={<MachineSwitcher hub={hubMe.host} hubAddr={hubMe.addr} current={machine} machines={machines} onChange={switchMachine} onManage={() => { setView('machines'); setDrawer(false) }} />}
onView={async (id) => { if (id !== view && !(await okToLeave())) return; setView(id as View); setDrawer(false) }} />
<div className="backdrop" onClick={() => setDrawer(false)} />
+80 -1
View File
@@ -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 (
<div className="dash settings">
<Account me={me} />
<Sessions />
<Guests />
<Accounts me={me} />
</div>
)
}
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<Guest[] | null>(null)
const [minutes, setMinutes] = useState<number>(30)
const [fresh, setFresh] = useState<FreshGuest | null>(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 (
<>
<div className="dash-bar"><h2 className="section">Guest logins</h2><span className="spacer" /><span className="muted small">temporary, and can only look</span></div>
<section className="card">
{list === null && <Loading label="Loading…" />}
{list?.length === 0 && <div className="empty">None. One lets somebody look around without an account.</div>}
{list?.map((g) => {
const left = new Date(g.expires).getTime() - Date.now()
return (
<div className="row" key={g.name}>
<span className="k mono">{g.name}</span>
<span className="muted small">{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}` : ''}</span>
<Btn className="btn danger" onClick={() => revoke(g)}>Revoke</Btn>
</div>
)
})}
</section>
{fresh && <FreshCard g={fresh} onClose={() => setFresh(null)} />}
<div className="card addform" style={{ marginTop: 8 }}>
<label>Lasts
<select value={minutes} onChange={(e) => setMinutes(Number(e.target.value))}>
{GUEST_MINUTES.map((m) => <option key={m} value={m}>{m < 60 ? `${m} minutes` : `${m / 60} hour${m === 60 ? '' : 's'}`}</option>)}
</select>
</label>
<div className="svc-actions"><Btn className="btn on" busy={busy} onClick={create}>Create guest login</Btn><span className="muted small">A 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.</span></div>
</div>
</>
)
}
// 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 (
<section className="card fresh" style={{ marginTop: 8 }}>
<div className="row"><span className="k">User</span><span className="mono">{g.name}</span><Btn className="btn" onClick={() => copy(g.name, 'user')}>Copy</Btn></div>
<div className="row"><span className="k">Password</span><span className="mono">{g.password}</span><Btn className="btn" onClick={() => copy(g.password, 'password')}>Copy</Btn></div>
<div className="svc-actions" style={{ marginTop: 8 }}>
<Btn className="btn on" onClick={() => copy(invite, 'invitation')}>Copy invitation</Btn>
<span className="muted small">Shown once. Expires {new Date(g.expires).toLocaleTimeString()}.</span>
<span className="spacer" />
<button className="btn" onClick={onClose}>Done</button>
</div>
</section>
)
}
function Account({ me }: { me: Me }) {
const [cur, setCur] = useState('')
const [pw1, setPw1] = useState('')
+15 -1
View File
@@ -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/<name>/… 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<T>(method: string, path: string, body?: unknown, local = false): Promise<T> {
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<T>(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<string, string>; partial?: boolean; files: number; dirs: number; size: number; denied: number; read: number; reused: number; partial_dirs: number; bases?: Record<string, string> }
@@ -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<void>('DELETE', `/api/users/${encodeURIComponent(name)}`, { current }, true),
resetPassword: (name: string, current: string, password: string) => req<void>('POST', `/api/users/${encodeURIComponent(name)}/password`, { current, password }, true),
guests: () => req<Guest[]>('GET', '/api/guests', undefined, true),
createGuest: (minutes: number) => req<FreshGuest>('POST', '/api/guests', { minutes }, true),
revokeGuest: (name: string) => req<void>('DELETE', `/api/guests/${encodeURIComponent(name)}`, undefined, true),
sessions: () => req<Session[]>('GET', '/api/sessions', undefined, true),
revokeSession: (id: string) => req<void>('DELETE', `/api/sessions/${encodeURIComponent(id)}`, undefined, true),
revokeOtherSessions: () => req<{ revoked: number }>('POST', '/api/sessions/revoke-others', undefined, true),
+42
View File
@@ -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
}
+5 -1
View File
@@ -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; }