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:
@@ -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),
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
Reference in New Issue
Block a user