diff --git a/sec-config.yaml b/sec-config.yaml index 2a0a9a9..b6b343c 100644 --- a/sec-config.yaml +++ b/sec-config.yaml @@ -50,9 +50,13 @@ default: plugin: service-api-http enabled: true config: - host: 127.0.0.1 + host: 0.0.0.0 port: 18081 codeTtlSeconds: 600 # 10m pairing code TTL + dataDir: /var/lib/betterframe + argon2Memory: 65536 + argon2TimeCost: 3 + argon2Parallelism: 2 # ----- Live kiosk WebSocket channel ----- service-coordinator-ws: diff --git a/server/src/plugins/service-admin-http/routes-admin.ts b/server/src/plugins/service-admin-http/routes-admin.ts index 195318a..a0858b2 100644 --- a/server/src/plugins/service-admin-http/routes-admin.ts +++ b/server/src/plugins/service-admin-http/routes-admin.ts @@ -4,6 +4,7 @@ import { type H3, readBody } from "h3"; import { htmlPage } from "./html-response.js"; import type { AdminDeps } from "./index.js"; +import { confirmPairing } from "../../shared/pairing.js"; import { OverviewPage, CamerasPage, @@ -135,6 +136,34 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void { return htmlPage(KiosksPage({ user: user.username, kiosks, pendingCodes: pending })); }); + app.post("/admin/kiosks/pair", async (event) => { + const body = await readBody>(event); + const code = (body?.["code"] ?? "").trim().toUpperCase(); + const nameOverride = (body?.["name_override"] ?? "").trim() || undefined; + const labelsStr = (body?.["initial_labels"] ?? "").trim(); + const initialLabels = labelsStr ? labelsStr.split(",").map((s) => s.trim()).filter(Boolean) : undefined; + + try { + await confirmPairing(deps.repo, deps.auth, deps.secrets, { + code, + nameOverride, + initialLabels, + }); + } catch (err) { + const user = event.context.user!; + const kiosks = deps.repo.listKiosks(); + const pending = deps.repo.listPendingPairingCodes(); + return htmlPage(KiosksPage({ + user: user.username, + kiosks, + pendingCodes: pending, + error: (err as Error).message, + })); + } + + return new Response(null, { status: 302, headers: { location: "/admin/kiosks" } }); + }); + // ---- Simple list pages (templates, layouts, displays, labels) ------------- app.get("/admin/templates", (event) => { diff --git a/server/src/plugins/service-api-http/index.ts b/server/src/plugins/service-api-http/index.ts index 2a15756..c74a367 100644 --- a/server/src/plugins/service-api-http/index.ts +++ b/server/src/plugins/service-api-http/index.ts @@ -1,8 +1,8 @@ /** * service-api-http — h3 listener for kiosk-facing REST API. * - * Serves pairing, bundle, and kiosk management endpoints. - * Port 18081 behind Angie proxy. + * Port 18081 behind Angie proxy. Handles pairing, bundle delivery, + * heartbeat, and event forwarding. */ import * as av from "@anyvali/js"; import { @@ -12,12 +12,36 @@ import { createEventSchemas, type Observable, } from "@bsb/base"; +import { H3, serve, readBody, getRequestHeader, createError } from "h3"; +import type { Server } from "srvx"; + +import { getRepo } from "../../shared/plugin-registry.js"; +import { initSecrets } from "../../shared/secrets.js"; +import { createAuth } from "../../shared/auth.js"; +import { initiatePairing, claimPairing } from "../../shared/pairing.js"; +import { generateBundle } from "../../shared/bundle.js"; +import type { Repository } from "../service-store/repository.js"; +import type { AuthApi } from "../../shared/auth.js"; +import type { SecretsApi } from "../../shared/secrets.js"; + +// ---- Config ----------------------------------------------------------------- const ConfigSchema = av.object( { - host: av.string().default("127.0.0.1"), + host: av.string().default("0.0.0.0"), port: av.int().min(1).max(65535).default(18081), codeTtlSeconds: av.int().min(60).max(3600).default(600), + // Secrets + auth config (shared with admin-http for now) + dataDir: av.string().minLength(1).default("/var/lib/betterframe"), + argon2Memory: av.int().min(8).default(65536), + argon2TimeCost: av.int().min(1).default(3), + argon2Parallelism: av.int().min(1).default(2), + cookieName: av.string().minLength(1).default("betterframe_session"), + sessionIdleSeconds: av.int().min(60).default(43200), + sessionMaxSeconds: av.int().min(3600).default(2592000), + loginLockoutThreshold: av.int().min(1).default(8), + loginLockoutSeconds: av.int().min(1).default(900), + totpIssuer: av.string().minLength(1).default("BetterFrame"), }, { unknownKeys: "strip" }, ); @@ -40,6 +64,8 @@ export const EventSchemas = createEventSchemas({ onBroadcast: {}, }); +// ---- Plugin ----------------------------------------------------------------- + export class Plugin extends BSBService, typeof EventSchemas> { static override Config = Config; static override EventSchemas = EventSchemas; @@ -49,14 +75,196 @@ export class Plugin extends BSBService, typeof Event runBeforePlugins?: string[]; runAfterPlugins?: string[]; + private server?: Server; + constructor(cfg: BSBServiceConstructor, typeof EventSchemas>) { super(cfg); } - async init(_obs: Observable): Promise { - // TODO: create h3 app, mount kiosk + pairing routes + async init(obs: Observable): Promise { + const repo = getRepo(); + const secrets = initSecrets( + { dataDir: this.config.dataDir }, + { info: (m) => obs.log.info(m as any, {}), warn: (m) => obs.log.warn(m as any, {}) }, + ); + const auth = createAuth(repo, secrets, { + sessionIdleSeconds: this.config.sessionIdleSeconds, + sessionMaxSeconds: this.config.sessionMaxSeconds, + loginLockoutThreshold: this.config.loginLockoutThreshold, + loginLockoutSeconds: this.config.loginLockoutSeconds, + argon2Memory: this.config.argon2Memory, + argon2TimeCost: this.config.argon2TimeCost, + argon2Parallelism: this.config.argon2Parallelism, + totpIssuer: this.config.totpIssuer, + cookieName: this.config.cookieName, + }); + const codeTtl = this.config.codeTtlSeconds; + + const app = new H3(); + + registerPairingRoutes(app, repo, auth, secrets, codeTtl); + registerKioskRoutes(app, repo, auth, secrets); + + this.server = serve(app, { + port: this.config.port, + hostname: this.config.host, + }); + + obs.log.info("api-http listening on {host}:{port}", { + host: this.config.host, + port: this.config.port, + }); } async run(_obs: Observable): Promise {} - async dispose(): Promise {} + + async dispose(): Promise { + if (this.server) { + await this.server.close(); + } + } +} + +// ---- Helpers ---------------------------------------------------------------- + +function extractBearerToken(event: any): string | null { + const hdr = getRequestHeader(event, "authorization"); + if (!hdr?.startsWith("Bearer ")) return null; + return hdr.slice(7); +} + +function getClusterKey(repo: Repository, secrets: SecretsApi): string | undefined { + const enc = repo.getSetupExtra("cluster_key_encrypted") as string | undefined; + if (!enc) return undefined; + return secrets.decryptString(enc, "cluster"); +} + +// ---- Pairing routes --------------------------------------------------------- + +function registerPairingRoutes( + app: H3, + repo: Repository, + auth: AuthApi, + secrets: SecretsApi, + codeTtl: number, +): void { + // Kiosk initiates pairing — no auth required + app.post("/api/pair/initiate", async (event) => { + const body = await readBody<{ + proposed_name?: string; + hardware_model?: string; + capabilities?: string[]; + }>(event); + + const result = initiatePairing(repo, { + proposedName: body?.proposed_name ?? null, + hardwareModel: body?.hardware_model ?? null, + capabilities: body?.capabilities ?? [], + codeTtlSeconds: codeTtl, + }); + + return { code: result.code, expires_at: result.expiresAt }; + }); + + // Kiosk polls for claim result — no auth required + app.post("/api/pair/claim", async (event) => { + const body = await readBody<{ code?: string }>(event); + const code = (body?.code ?? "").trim().toUpperCase(); + if (!code) throw createError({ statusCode: 400, statusMessage: "code required" }); + + const result = claimPairing(repo, code); + if (result.status === "pending") { + return new Response(JSON.stringify({ status: "pending" }), { + status: 202, + headers: { "content-type": "application/json" }, + }); + } + + return { + status: "claimed", + kiosk_id: result.kioskId, + kiosk_name: result.kioskName, + kiosk_key: result.kioskKey, + cluster_key: result.clusterKey, + bundle_url: result.bundleUrl, + }; + }); +} + +// ---- Kiosk routes (require Bearer kiosk key) -------------------------------- + +function registerKioskRoutes( + app: H3, + repo: Repository, + auth: AuthApi, + secrets: SecretsApi, +): void { + // Bundle delivery + app.get("/api/kiosk/bundle", async (event) => { + const token = extractBearerToken(event); + if (!token) throw createError({ statusCode: 401, statusMessage: "Bearer token required" }); + + const kiosk = await auth.verifyKioskKey(token); + if (!kiosk) throw createError({ statusCode: 401, statusMessage: "Invalid kiosk key" }); + + const clusterKey = getClusterKey(repo, secrets); + const bundle = generateBundle(repo, secrets, kiosk.id, clusterKey); + if (!bundle) throw createError({ statusCode: 404, statusMessage: "Kiosk not found" }); + + return bundle; + }); + + // Heartbeat + app.post("/api/kiosk/heartbeat", async (event) => { + const token = extractBearerToken(event); + if (!token) throw createError({ statusCode: 401, statusMessage: "Bearer token required" }); + + const kiosk = await auth.verifyKioskKey(token); + if (!kiosk) throw createError({ statusCode: 401, statusMessage: "Invalid kiosk key" }); + + const body = await readBody<{ + bundle_version?: string; + kiosk_app_version?: string; + os_version?: string; + }>(event); + + repo.touchKiosk(kiosk.id, { + bundle_version: body?.bundle_version ?? null, + kiosk_app_version: body?.kiosk_app_version ?? null, + os_version: body?.os_version ?? null, + }); + + return { ok: true, now: new Date().toISOString() }; + }); + + // Event forwarding + app.post("/api/kiosk/event", async (event) => { + const token = extractBearerToken(event); + if (!token) throw createError({ statusCode: 401, statusMessage: "Bearer token required" }); + + const kiosk = await auth.verifyKioskKey(token); + if (!kiosk) throw createError({ statusCode: 401, statusMessage: "Invalid kiosk key" }); + + const body = await readBody<{ + topic: string; + source_type?: string; + camera_id?: number; + property_op?: string; + payload?: Record; + }>(event); + + if (!body?.topic) throw createError({ statusCode: 400, statusMessage: "topic required" }); + + const eventId = repo.insertEvent({ + source_kiosk_id: kiosk.id, + source_camera_id: body.camera_id ?? null, + source_type: (body.source_type as any) ?? "system", + topic: body.topic, + property_op: body.property_op ?? null, + payload: body.payload ?? {}, + forwarded_to_nodered: false, + }); + + return { ok: true, event_id: eventId }; + }); } diff --git a/server/src/shared/bundle.ts b/server/src/shared/bundle.ts index 3ff771b..88fca6f 100644 --- a/server/src/shared/bundle.ts +++ b/server/src/shared/bundle.ts @@ -1,4 +1,158 @@ /** - * Label-scoped bundle generation — shared module stub. - * TODO: implement from old-python reference. + * Label-scoped bundle generation — shared module. + * + * Queries cameras/layouts/templates for a kiosk's label set, + * encrypts ONVIF passwords with cluster key, returns versioned bundle. */ +import { createHash } from "node:crypto"; +import type { Repository } from "../plugins/service-store/repository.js"; +import type { SecretsApi } from "./secrets.js"; + +export interface BundleCamera { + id: number; + name: string; + type: string; + rtsp_url: string | null; + onvif_host: string | null; + onvif_port: number | null; + onvif_username: string | null; + onvif_password_encrypted: string | null; + streams: Array<{ + id: number; + role: string; + name: string; + rtsp_uri: string; + width: number | null; + height: number | null; + encoding: string | null; + framerate: number | null; + }>; +} + +export interface BundleLayout { + id: number; + name: string; + template_id: number | null; + display_id: number | null; + priority: string; + cooling_timeout_seconds: number | null; + preload_camera_ids: number[]; + is_default: boolean; + resets_idle_timer: boolean; + cells: Array<{ + region_name: string; + content_type: string; + camera_id: number | null; + stream_selector: string | null; + web_url: string | null; + html_content: string | null; + }>; +} + +export interface KioskBundle { + kiosk_id: number; + kiosk_name: string; + labels: string[]; + operate_labels: string[]; + cameras: BundleCamera[]; + layouts: BundleLayout[]; + templates: Array<{ + id: number; + name: string; + regions: unknown; + grid_cols: number; + grid_rows: number; + }>; + version: string; +} + +export function generateBundle( + repo: Repository, + secrets: SecretsApi, + kioskId: number, + clusterKey: string | undefined, +): KioskBundle | null { + const kiosk = repo.getKioskById(kioskId); + if (!kiosk) return null; + + const scope = repo.bundleScope(kioskId); + const cameras = repo.camerasForLabelIds(scope.labelIds); + const layouts = repo.layoutsForLabelIds(scope.labelIds); + + const bundleCameras: BundleCamera[] = cameras.map((cam) => { + const streams = repo.listCameraStreams(cam.id); + let onvifPwEncrypted: string | null = null; + if (cam.onvif_password && clusterKey) { + onvifPwEncrypted = secrets.encryptForCluster(cam.onvif_password, clusterKey); + } + return { + id: cam.id, + name: cam.name, + type: cam.type, + rtsp_url: cam.rtsp_url, + onvif_host: cam.onvif_host, + onvif_port: cam.onvif_port, + onvif_username: cam.onvif_username, + onvif_password_encrypted: onvifPwEncrypted, + streams: streams.map((s) => ({ + id: s.id, + role: s.role, + name: s.name, + rtsp_uri: s.rtsp_uri, + width: s.width, + height: s.height, + encoding: s.encoding, + framerate: s.framerate, + })), + }; + }); + + const templateIds = [...new Set(layouts.map((l) => l.template_id).filter((id): id is number => id !== null))]; + const templates = templateIds.length > 0 ? repo.layoutTemplates(templateIds) : []; + + const bundleLayouts: BundleLayout[] = layouts.map((l) => { + const cells = repo.layoutCells(l.id); + return { + id: l.id, + name: l.name, + template_id: l.template_id, + display_id: l.display_id, + priority: l.priority, + cooling_timeout_seconds: l.cooling_timeout_seconds, + preload_camera_ids: l.preload_camera_ids, + is_default: l.is_default, + resets_idle_timer: l.resets_idle_timer, + cells: cells.map((c) => ({ + region_name: c.region_name, + content_type: c.content_type, + camera_id: c.camera_id, + stream_selector: c.stream_selector, + web_url: c.web_url, + html_content: c.html_content, + })), + }; + }); + + const bundle: KioskBundle = { + kiosk_id: kioskId, + kiosk_name: kiosk.name, + labels: scope.labelNames, + operate_labels: scope.operateLabelNames, + cameras: bundleCameras, + layouts: bundleLayouts, + templates: templates.map((t) => ({ + id: t.id, + name: t.name, + regions: t.regions, + grid_cols: t.grid_cols, + grid_rows: t.grid_rows, + })), + version: "", + }; + + bundle.version = createHash("sha256") + .update(JSON.stringify(bundle)) + .digest("hex"); + + return bundle; +} diff --git a/server/src/shared/pairing.ts b/server/src/shared/pairing.ts index f1e8659..f47053e 100644 --- a/server/src/shared/pairing.ts +++ b/server/src/shared/pairing.ts @@ -1,4 +1,155 @@ /** - * Pairing state machine — shared module stub. - * TODO: implement initiate/claim/poll from old-python reference. + * Pairing state machine — shared module. + * + * Flow: + * 1. Kiosk calls initiate → gets 8-char code + expiry + * 2. Kiosk polls claim → 202 until admin confirms, then 200 + credentials + * 3. Admin enters code in UI → confirmPairing creates kiosk + kiosk_key */ +import { randomBytes } from "node:crypto"; +import type { Repository } from "../plugins/service-store/repository.js"; +import type { AuthApi } from "./auth.js"; +import type { SecretsApi } from "./secrets.js"; +import type { PairingCode } from "./types.js"; + +const CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; // no 0/O/1/I +const CODE_LENGTH = 8; + +function generateCode(): string { + const buf = randomBytes(CODE_LENGTH); + let code = ""; + for (let i = 0; i < CODE_LENGTH; i++) { + code += CODE_ALPHABET[buf[i]! % CODE_ALPHABET.length]; + } + return code; +} + +export interface PairingInitiateInput { + proposedName: string | null; + hardwareModel: string | null; + capabilities: string[]; + codeTtlSeconds: number; +} + +export interface PairingInitiateResult { + code: string; + expiresAt: string; +} + +export function initiatePairing( + repo: Repository, + input: PairingInitiateInput, +): PairingInitiateResult { + let code: string; + let attempts = 0; + do { + code = generateCode(); + attempts++; + if (attempts > 20) throw new Error("failed to generate unique pairing code"); + } while (repo.getPairingCode(code) !== null); + + const expiresAt = new Date(Date.now() + input.codeTtlSeconds * 1000).toISOString(); + + repo.createPairingCode({ + code, + kiosk_proposed_name: input.proposedName, + kiosk_hardware_model: input.hardwareModel, + kiosk_capabilities: input.capabilities, + expires_at: expiresAt, + extras: {}, + }); + + return { code, expiresAt }; +} + +export interface PairingClaimResult { + status: "pending" | "claimed"; + kioskId?: number; + kioskName?: string; + kioskKey?: string; + clusterKey?: string; + bundleUrl?: string; +} + +export function claimPairing( + repo: Repository, + code: string, +): PairingClaimResult { + const pc = repo.getPairingCode(code); + if (!pc) return { status: "pending" }; + if (new Date(pc.expires_at) < new Date()) return { status: "pending" }; + if (!pc.consumed_at) return { status: "pending" }; + + const extras = pc.extras as Record; + const kioskKey = extras["kiosk_key_plaintext"] as string | undefined; + + if (!kioskKey || !pc.consumed_by_kiosk_id) return { status: "pending" }; + + const kiosk = repo.getKioskById(pc.consumed_by_kiosk_id); + const clusterKey = extras["cluster_key"] as string | undefined; + + // Wipe plaintext key from extras after first claim + repo.updatePairingCodeExtras(code, { ...extras, kiosk_key_plaintext: undefined, cluster_key: undefined }); + + return { + status: "claimed", + kioskId: pc.consumed_by_kiosk_id, + kioskName: kiosk?.name ?? pc.kiosk_proposed_name ?? "kiosk", + kioskKey, + clusterKey, + bundleUrl: "/api/kiosk/bundle", + }; +} + +export interface PairingConfirmInput { + code: string; + nameOverride?: string; + initialLabels?: string[]; +} + +export async function confirmPairing( + repo: Repository, + auth: AuthApi, + secrets: SecretsApi, + input: PairingConfirmInput, +): Promise<{ kioskId: number; kioskName: string }> { + const pc = repo.getPairingCode(input.code); + if (!pc) throw new Error("pairing code not found"); + if (pc.consumed_at) throw new Error("pairing code already used"); + if (new Date(pc.expires_at) < new Date()) throw new Error("pairing code expired"); + + const kioskName = input.nameOverride || pc.kiosk_proposed_name || `kiosk-${input.code.toLowerCase()}`; + const kioskKeyPlaintext = `bf-${randomBytes(24).toString("base64url")}`; + const kioskKeyHash = await auth.hashPassword(kioskKeyPlaintext); + const kioskKeyPrefix = kioskKeyPlaintext.slice(0, 8); + + const kiosk = repo.createKiosk({ + name: kioskName, + key_hash: kioskKeyHash, + key_prefix: kioskKeyPrefix, + capabilities: pc.kiosk_capabilities, + hardware_model: pc.kiosk_hardware_model, + }); + + // Attach initial labels + if (input.initialLabels?.length) { + for (const labelName of input.initialLabels) { + const trimmed = labelName.trim().toLowerCase(); + if (!trimmed) continue; + const label = repo.ensureLabel(trimmed); + repo.attachKioskLabel(kiosk.id, label.id, "consume"); + } + } + + // Get cluster key for kiosk + const clusterKeyEncrypted = repo.getSetupExtra("cluster_key_encrypted") as string | undefined; + const clusterKey = clusterKeyEncrypted ? secrets.decryptString(clusterKeyEncrypted, "cluster") : undefined; + + // Store plaintext kiosk_key + cluster_key in extras for kiosk to claim once + repo.markPairingCodeClaimed(input.code, kiosk.id, { + kiosk_key_plaintext: kioskKeyPlaintext, + cluster_key: clusterKey, + }); + + return { kioskId: kiosk.id, kioskName }; +} diff --git a/server/src/web-templates/admin-pages.tsx b/server/src/web-templates/admin-pages.tsx index 4779d33..8024d9b 100644 --- a/server/src/web-templates/admin-pages.tsx +++ b/server/src/web-templates/admin-pages.tsx @@ -246,11 +246,12 @@ interface KiosksProps { user: string; kiosks: Kiosk[]; pendingCodes: PairingCode[]; + error?: string; } export function KiosksPage(props: KiosksProps) { return ( - +