fleet-manager: checkpoint 6 — the admin SPA (fleet command deck)

The themed single-page admin UI (serving/fleet-manager/ui/), embedded into the
binary and served same-origin over the admin plane.

- Svelte 5 + Tailwind 4 + Vite; a sibling of serving/kiosk-app. Theme = LYSANDRA
  'restricted-violet' (high-security evolution of the kiosk's flight-deck DNA):
  amber deck, VIOLET reserved for privileged/destructive + auth surfaces, teal
  telemetry, red alert, clearance chrome (corner-reticle panels, scanlines, mono
  clearance labels, secure-link status bar).
- Passkey gate: Login (usernameless discoverable WebAuthn) + Enrol (from the
  register-admin one-time link) via navigator.credentials, with base64url<->buffer
  helpers and the go-webauthn response shapes.
- Three pillars over the JSON API: Monitor (fleet roster + version-drift + health
  + per-host detail with bespoke SVG telemetry charts), Authorise (pending queue:
  fingerprint cross-check, approve+bind in violet, deny), Lifecycle (desired-state
  form; drain/reboot confirm-gated). Session-aware fetch client (cookie + CSRF).
- go:embed behind the  build tag (spa_embed.go / spa_stub.go) so the
  default go build/test need no dist/; mounted at / (api/auth/healthz win).

Verified: npm build clean (71KB JS / 33KB CSS + self-hosted fonts); default +
-tags embedspa go builds; smoke test serves index + assets + API through the SPA
origin. Against an insecure+seeded backend the deck renders passkey-free for
theme work. dist/ + node_modules gitignored.
This commit is contained in:
Audiobuzz
2026-07-04 05:54:41 +00:00
parent c40dd5ac93
commit fae3624c58
30 changed files with 3203 additions and 1 deletions
+39
View File
@@ -0,0 +1,39 @@
// api.js — thin JSON fetch wrapper for the manager's admin API. Sends the session
// cookie (same-origin) and the CSRF header on mutations; surfaces {error} bodies.
import { session } from './session.svelte.js'
export class ApiError extends Error {
constructor(status, msg) {
super(msg)
this.status = status
}
}
async function req(method, path, body) {
const headers = {}
if (body !== undefined) headers['Content-Type'] = 'application/json'
if (method !== 'GET' && session.csrf) headers['X-Fleet-CSRF'] = session.csrf
const res = await fetch(path, {
method,
headers,
credentials: 'same-origin',
body: body !== undefined ? JSON.stringify(body) : undefined,
})
if (res.status === 401) {
session.admin = null
session.csrf = ''
throw new ApiError(401, 'not signed in')
}
const text = await res.text()
const data = text ? JSON.parse(text) : null
if (!res.ok) {
throw new ApiError(res.status, (data && data.error) || res.statusText)
}
return data
}
export const api = {
get: (p) => req('GET', p),
post: (p, b) => req('POST', p, b),
put: (p, b) => req('PUT', p, b),
}
+34
View File
@@ -0,0 +1,34 @@
// Small formatting helpers shared across the deck.
export function relTime(iso) {
if (!iso) return '—'
const t = new Date(iso).getTime()
if (!t) return '—'
const s = Math.max(0, Math.round((Date.now() - t) / 1000))
if (s < 60) return s + 's ago'
if (s < 3600) return Math.floor(s / 60) + 'm ago'
if (s < 86400) return Math.floor(s / 3600) + 'h ago'
return Math.floor(s / 86400) + 'd ago'
}
export function num(v, digits = 0) {
if (v === undefined || v === null || Number.isNaN(v)) return '—'
return Number(v).toFixed(digits)
}
export function pct(v) {
if (v === undefined || v === null) return '—'
return num(v, 0) + '%'
}
// health → one of ok | warn | alert (drives the state pills / colours).
export function healthState(h) {
if (!h) return 'off'
if (h.nvme_root === 'down' || h.auth === 'down') return 'alert'
if ((h.tmpfs_used_pct || 0) >= 90 || (h.temp_c || 0) >= 85 || h.selinux === 'permissive' || h.nvme_root === 'degraded') return 'warn'
return 'ok'
}
export function shortSerial(s) {
return s ? s.slice(0, 8) : '—'
}
@@ -0,0 +1,24 @@
// Minimal hash router (Svelte 5 rune store). Routes: monitor | authorise |
// lifecycle | enrol. The enrol route carries the one-time ?token= from the CLI
// link. Hash routing keeps the SPA a single embedded index.html (no server-side
// route table needed).
function parse() {
const raw = location.hash.replace(/^#\/?/, '')
const [path, query] = raw.split('?')
return { path: path || 'monitor', query: new URLSearchParams(query || '') }
}
const initial = parse()
export const router = $state({ path: initial.path, token: initial.query.get('token') || '' })
if (typeof window !== 'undefined') {
window.addEventListener('hashchange', () => {
const r = parse()
router.path = r.path
router.token = r.query.get('token') || ''
})
}
export function go(path) {
location.hash = '#/' + path
}
@@ -0,0 +1,42 @@
// Auth/session state (Svelte 5 rune store). `admin` is the signed-in passkey
// identity; `csrf` is echoed on mutations. In the manager's INSECURE dev mode
// /api/whoami returns admin="dev-insecure" with no auth, so the SPA renders
// without a passkey — handy for testing against a seeded insecure backend.
export const session = $state({ admin: null, csrf: '', ready: false })
export async function refreshSession() {
try {
const res = await fetch('/api/whoami', { credentials: 'same-origin' })
if (res.ok) {
const d = await res.json()
session.admin = d.admin
session.csrf = d.csrf || ''
} else {
session.admin = null
session.csrf = ''
}
} catch {
session.admin = null
session.csrf = ''
}
session.ready = true
}
export function setSession(d) {
session.admin = d.admin
session.csrf = d.csrf || ''
}
export async function logout() {
try {
await fetch('/auth/logout', {
method: 'POST',
credentials: 'same-origin',
headers: session.csrf ? { 'X-Fleet-CSRF': session.csrf } : {},
})
} catch {
/* ignore */
}
session.admin = null
session.csrf = ''
}
@@ -0,0 +1,92 @@
// webauthn.js — the browser side of the passkey ceremonies. Converts the
// manager's base64url-encoded options into the BufferSources navigator.credentials
// needs, runs the ceremony, and serialises the result back into the JSON shape
// go-webauthn parses. Login is usernameless (discoverable); registration is
// authorised by the one-time enrol token from `register-admin`.
import { api } from './api.js'
import { setSession } from './session.svelte.js'
function b64uToBuf(s) {
s = s.replace(/-/g, '+').replace(/_/g, '/')
while (s.length % 4) s += '='
const bin = atob(s)
const buf = new Uint8Array(bin.length)
for (let i = 0; i < bin.length; i++) buf[i] = bin.charCodeAt(i)
return buf.buffer
}
function bufToB64u(buf) {
const bytes = new Uint8Array(buf)
let s = ''
for (const b of bytes) s += String.fromCharCode(b)
return btoa(s).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
}
function prepCreation(pk) {
pk.challenge = b64uToBuf(pk.challenge)
pk.user.id = b64uToBuf(pk.user.id)
if (Array.isArray(pk.excludeCredentials)) {
for (const c of pk.excludeCredentials) c.id = b64uToBuf(c.id)
}
return pk
}
function prepRequest(pk) {
pk.challenge = b64uToBuf(pk.challenge)
if (Array.isArray(pk.allowCredentials)) {
for (const c of pk.allowCredentials) c.id = b64uToBuf(c.id)
}
return pk
}
function serializeAttestation(cred) {
return {
id: cred.id,
rawId: bufToB64u(cred.rawId),
type: cred.type,
authenticatorAttachment: cred.authenticatorAttachment || undefined,
clientExtensionResults: cred.getClientExtensionResults ? cred.getClientExtensionResults() : {},
response: {
clientDataJSON: bufToB64u(cred.response.clientDataJSON),
attestationObject: bufToB64u(cred.response.attestationObject),
transports: cred.response.getTransports ? cred.response.getTransports() : undefined,
},
}
}
function serializeAssertion(cred) {
return {
id: cred.id,
rawId: bufToB64u(cred.rawId),
type: cred.type,
authenticatorAttachment: cred.authenticatorAttachment || undefined,
clientExtensionResults: cred.getClientExtensionResults ? cred.getClientExtensionResults() : {},
response: {
clientDataJSON: bufToB64u(cred.response.clientDataJSON),
authenticatorData: bufToB64u(cred.response.authenticatorData),
signature: bufToB64u(cred.response.signature),
userHandle: cred.response.userHandle ? bufToB64u(cred.response.userHandle) : undefined,
},
}
}
export function passkeySupported() {
return typeof window !== 'undefined' && !!window.PublicKeyCredential && !!navigator.credentials
}
// passkeyLogin runs the discoverable (usernameless) login ceremony and, on
// success, sets the session. Throws on failure/cancellation.
export async function passkeyLogin() {
const { cid, options } = await api.post('/auth/login/begin')
const cred = await navigator.credentials.get({ publicKey: prepRequest(options.publicKey) })
const res = await api.post(`/auth/login/finish?cid=${encodeURIComponent(cid)}`, serializeAssertion(cred))
setSession(res)
return res
}
// passkeyRegister runs the registration ceremony authorised by the enrol token.
export async function passkeyRegister(token) {
const { cid, options } = await api.post(`/auth/register/begin?token=${encodeURIComponent(token)}`)
const cred = await navigator.credentials.create({ publicKey: prepCreation(options.publicKey) })
return api.post(`/auth/register/finish?cid=${encodeURIComponent(cid)}`, serializeAttestation(cred))
}