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
+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),