From 96f5e6a3303dc54da7b491f1fccb8295eee3ee52 Mon Sep 17 00:00:00 2001 From: Mitchell R Date: Wed, 20 May 2026 06:19:46 +0200 Subject: [PATCH] feat(ota): add OS update release endpoints --- .../src/plugins/service-admin-http/index.ts | 6 + .../plugins/service-admin-http/middleware.ts | 9 +- .../service-admin-http/routes-os-updates.ts | 91 +++++++++ server/src/plugins/service-api-http/index.ts | 107 ++++++++++- server/src/plugins/service-store/mappers.ts | 39 ++++ .../src/plugins/service-store/migrations.ts | 38 ++++ .../src/plugins/service-store/repository.ts | 175 ++++++++++++++++++ server/src/shared/os-updates.ts | 147 +++++++++++++++ server/src/shared/types.ts | 33 ++++ 9 files changed, 640 insertions(+), 5 deletions(-) create mode 100644 server/src/plugins/service-admin-http/routes-os-updates.ts create mode 100644 server/src/shared/os-updates.ts diff --git a/server/src/plugins/service-admin-http/index.ts b/server/src/plugins/service-admin-http/index.ts index 856533f..4cb0cd2 100644 --- a/server/src/plugins/service-admin-http/index.ts +++ b/server/src/plugins/service-admin-http/index.ts @@ -20,6 +20,7 @@ import { initSecrets, type SecretsApi } from "../../shared/secrets.js"; import { createAuth, type AuthApi } from "../../shared/auth.js"; import { initNoderedBridge, type NoderedBridge } from "../../shared/nodered-bridge.js"; import { initFirmware, type FirmwareApi } from "../../shared/firmware.js"; +import { initOsUpdates, type OsUpdateApi } from "../../shared/os-updates.js"; import { envStr } from "../../shared/env-overrides.js"; import type { Repository } from "../service-store/repository.js"; @@ -29,6 +30,7 @@ import { registerAuthRoutes } from "./routes-auth.js"; import { registerAdminRoutes } from "./routes-admin.js"; import { registerAccountRoutes } from "./routes-account.js"; import { registerFirmwareRoutes } from "./routes-firmware.js"; +import { registerOsUpdateRoutes } from "./routes-os-updates.js"; import { registerStaticRoutes } from "./routes-static.js"; // ---- Config ----------------------------------------------------------------- @@ -84,6 +86,7 @@ export interface AdminDeps { cookieName: string; nodered: NoderedBridge; firmware: FirmwareApi; + osUpdates: OsUpdateApi; dataDir: string; } @@ -139,6 +142,7 @@ export class Plugin extends BSBService, typeof Event { dataDir }, { info: (m) => obs.log.info(m as any, {}), warn: (m) => obs.log.warn(m as any, {}) }, ); + const osUpdates = initOsUpdates({ dataDir }); const deps: AdminDeps = { repo, @@ -147,6 +151,7 @@ export class Plugin extends BSBService, typeof Event cookieName, nodered, firmware, + osUpdates, dataDir, }; @@ -159,6 +164,7 @@ export class Plugin extends BSBService, typeof Event registerAdminRoutes(app, deps); registerAccountRoutes(app, deps); registerFirmwareRoutes(app, deps); + registerOsUpdateRoutes(app, deps); // Auth-check endpoint for Angie auth_request subrequest. // Returns 200 if session cookie is valid + admin role, 401 otherwise. diff --git a/server/src/plugins/service-admin-http/middleware.ts b/server/src/plugins/service-admin-http/middleware.ts index 90c151e..a79da34 100644 --- a/server/src/plugins/service-admin-http/middleware.ts +++ b/server/src/plugins/service-admin-http/middleware.ts @@ -80,11 +80,12 @@ export function registerMiddleware(app: H3, deps: AdminDeps): void { if (authz && authz.startsWith("Bearer ")) { const token = authz.slice(7); if ( - path === "/api/admin/firmware/import" && - tokenMatchesEnv(token, "BF_FIRMWARE_IMPORT_API_KEY") + (path === "/api/admin/firmware/import" || path === "/api/admin/os/import") && + (tokenMatchesEnv(token, "BF_FIRMWARE_IMPORT_API_KEY") || tokenMatchesEnv(token, "BF_OTA_IMPORT_API_KEY")) ) { - event.context.user = syntheticApiKeyUser("fw-import"); - event.context.apiKeyPrefix = "fw-import"; + const label = path === "/api/admin/os/import" ? "ota-import" : "fw-import"; + event.context.user = syntheticApiKeyUser(label); + event.context.apiKeyPrefix = label; return; } diff --git a/server/src/plugins/service-admin-http/routes-os-updates.ts b/server/src/plugins/service-admin-http/routes-os-updates.ts new file mode 100644 index 0000000..e691d3f --- /dev/null +++ b/server/src/plugins/service-admin-http/routes-os-updates.ts @@ -0,0 +1,91 @@ +/** + * Admin OS-update routes. + * + * Full OS OTA artifacts are RAUC `.raucb` bundles. CI imports by URL so large + * bundles are streamed server-side instead of base64 encoded into JSON. + */ +import { type H3, readBody, createError } from "h3"; +import { randomUUID } from "node:crypto"; + +import type { AdminDeps } from "./index.js"; +import type { FirmwareChannel } from "../../shared/types.js"; +import { audit } from "../../shared/audit.js"; + +const ALLOWED_CHANNELS: ReadonlySet = new Set(["stable", "beta", "dev"]); + +export function registerOsUpdateRoutes(app: H3, deps: AdminDeps): void { + app.post("/api/admin/os/import", async (event) => { + const body = await readBody<{ + version: string; + channel: FirmwareChannel; + compatibility: string; + release_notes?: string; + source_url: string; + sha256?: string; + }>(event); + + const version = body?.version?.trim(); + const channel = body?.channel; + const compatibility = body?.compatibility?.trim(); + const sourceUrl = body?.source_url?.trim(); + const expectedSha256 = body?.sha256?.trim() || null; + + if (!version || !channel || !compatibility || !sourceUrl) { + throw createError({ statusCode: 400, statusMessage: "version, channel, compatibility, source_url required" }); + } + if (!ALLOWED_CHANNELS.has(channel)) { + throw createError({ statusCode: 400, statusMessage: `invalid channel '${channel}'` }); + } + if (!/^\d+\.\d+\.\d+(-[\w.]+)?$/.test(version)) { + throw createError({ statusCode: 400, statusMessage: `invalid version '${version}' (expected semver)` }); + } + if (!/^[a-z0-9][a-z0-9._-]{2,127}$/i.test(compatibility)) { + throw createError({ statusCode: 400, statusMessage: "invalid compatibility" }); + } + + let stored; + try { + stored = await deps.osUpdates.storeFromUrl(sourceUrl, expectedSha256); + } catch (err) { + throw createError({ statusCode: 400, statusMessage: (err as Error).message }); + } + + let release; + try { + release = deps.repo.createOsUpdateRelease({ + id: randomUUID(), + version, + channel, + compatibility, + artifact_path: stored.path, + size_bytes: stored.size_bytes, + sha256: stored.sha256, + release_notes: body.release_notes ?? null, + uploaded_by: event.context.user?.id || null, + }); + } catch (err) { + await deps.osUpdates.removeBundle(stored.path); + throw createError({ statusCode: 409, statusMessage: (err as Error).message }); + } + + audit(deps.repo, event as any, "os_update.import", { + resource_type: "os_update_release", + resource_id: release.id, + metadata: { + version, + channel, + compatibility, + sha256: stored.sha256, + size: stored.size_bytes, + source_url: sourceUrl, + }, + }); + + return { + ok: true, + release_id: release.id, + sha256: stored.sha256, + size_bytes: stored.size_bytes, + }; + }); +} diff --git a/server/src/plugins/service-api-http/index.ts b/server/src/plugins/service-api-http/index.ts index 43af9d3..3ef0fa0 100644 --- a/server/src/plugins/service-api-http/index.ts +++ b/server/src/plugins/service-api-http/index.ts @@ -22,6 +22,7 @@ import { initiatePairing, claimPairing } from "../../shared/pairing.js"; import { generateBundle } from "../../shared/bundle.js"; import { initNoderedBridge, type NoderedBridge } from "../../shared/nodered-bridge.js"; import { initFirmware, type FirmwareApi } from "../../shared/firmware.js"; +import { initOsUpdates, type OsUpdateApi } from "../../shared/os-updates.js"; import { envStr } from "../../shared/env-overrides.js"; import { createRateLimiter } from "../../shared/rate-limit.js"; import { initMqttBridge, type MqttBridge } from "../../shared/mqtt-bridge.js"; @@ -120,6 +121,7 @@ export class Plugin extends BSBService, typeof Event { dataDir }, { info: (m) => obs.log.info(m as any, {}), warn: (m) => obs.log.warn(m as any, {}) }, ); + const osUpdates = initOsUpdates({ dataDir }); const mqtt = initMqttBridge({ info: (m) => obs.log.info(m as any, {}), warn: (m) => obs.log.warn(m as any, {}), @@ -153,7 +155,7 @@ export class Plugin extends BSBService, typeof Event }); registerPairingRoutes(app, repo, auth, secrets, codeTtl); - registerKioskRoutes(app, repo, auth, secrets, nodered, firmware, mqtt); + registerKioskRoutes(app, repo, auth, secrets, nodered, firmware, osUpdates, mqtt); this.server = serve(app, { port: this.config.port, @@ -270,6 +272,7 @@ function registerKioskRoutes( secrets: SecretsApi, nodered: NoderedBridge, firmware: FirmwareApi, + osUpdates: OsUpdateApi, mqtt: MqttBridge, ): void { // Bundle delivery @@ -611,6 +614,108 @@ function registerKioskRoutes( repo.recordKioskFirmwareAttempt(kiosk.id, body.version, body.error ?? null); return { ok: true }; }); + + /** + * Full OS OTA check. `compatibility` is the RAUC compatible string baked + * into the image, e.g. betterframe-rpi5-aarch64. The kiosk-side installer + * will hand the downloaded bundle to `rauc install`. + */ + app.get("/api/kiosk/os/check", async (event) => { + const token = extractBearerToken(event); + if (!token) throw createError({ statusCode: 401, statusMessage: "Bearer token required" }); + const verified = await auth.verifyKioskKey(token); + if (!verified) throw createError({ statusCode: 401, statusMessage: "Invalid kiosk key" }); + const kiosk = repo.getKioskById(verified.id); + if (!kiosk) throw createError({ statusCode: 404, statusMessage: "kiosk not found" }); + + const url = new URL(event.req.url); + const compatibility = url.searchParams.get("compatibility")?.trim(); + if (!compatibility) { + throw createError({ statusCode: 400, statusMessage: "compatibility query param required" }); + } + const currentVersion = url.searchParams.get("current")?.trim() ?? kiosk.os_version ?? ""; + + let release = null; + if (kiosk.os_update_target_version) { + release = repo.getOsUpdateReleaseByVersionCompatibility(kiosk.os_update_target_version, compatibility); + if (release?.yanked_at) release = null; + } + if (!release) { + const rollouts = repo.listActiveOsUpdateRolloutsForKiosk(kiosk.id); + for (const rollout of rollouts) { + if (!isKioskInRolloutBucket(kiosk.id, rollout.id, rollout.percentage)) continue; + const r = repo.getOsUpdateRelease(rollout.release_id); + if (!r || r.yanked_at) continue; + if (r.compatibility !== compatibility) continue; + release = r; + break; + } + } + if (!release) { + const channel = (kiosk.os_update_channel ?? "stable") as FirmwareChannel; + release = repo.getLatestOsUpdateRelease(channel, compatibility); + } + + if (!release || release.version === currentVersion) { + return { up_to_date: true }; + } + + return { + up_to_date: false, + update: { + release_id: release.id, + version: release.version, + channel: release.channel, + compatibility: release.compatibility, + sha256: release.sha256, + size_bytes: release.size_bytes, + bundle_format: release.bundle_format, + download_url: `/api/kiosk/os/download/${release.id}`, + }, + }; + }); + + app.get("/api/kiosk/os/download/:id", 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 id = (event.context as any).params?.id as string | undefined + ?? new URL(event.req.url).pathname.split("/").pop(); + if (!id) throw createError({ statusCode: 400, statusMessage: "release id required" }); + + const release = repo.getOsUpdateRelease(id); + if (!release || release.yanked_at) { + throw createError({ statusCode: 404, statusMessage: "release not found" }); + } + + const bundle = await osUpdates.streamBundle(release.artifact_path); + return new Response(bundle.body, { + status: 200, + headers: { + "content-type": "application/vnd.rauc", + "content-length": String(bundle.size), + "x-bf-sha256": release.sha256, + "x-bf-version": release.version, + "x-bf-compatibility": release.compatibility, + }, + }); + }); + + app.post("/api/kiosk/os/applied", 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<{ version: string; error?: string }>(event); + if (!body?.version) { + throw createError({ statusCode: 400, statusMessage: "version required" }); + } + repo.recordKioskOsUpdateAttempt(kiosk.id, body.version, body.error ?? null); + return { ok: true }; + }); } /** diff --git a/server/src/plugins/service-store/mappers.ts b/server/src/plugins/service-store/mappers.ts index b6c24e2..bd32997 100644 --- a/server/src/plugins/service-store/mappers.ts +++ b/server/src/plugins/service-store/mappers.ts @@ -38,6 +38,9 @@ import type { LayoutPriority, LayoutRegion, LayoutTemplate, + OsUpdateRelease, + OsUpdateRollout, + OsUpdateRolloutState, PairingCode, Session, SetupState, @@ -262,6 +265,11 @@ export function rowToKiosk(r: Row): Kiosk { firmware_last_attempt_at: sn(r["firmware_last_attempt_at"]), firmware_last_attempt_version: sn(r["firmware_last_attempt_version"]), firmware_last_error: sn(r["firmware_last_error"]), + os_update_channel: (s(r["os_update_channel"] ?? "stable")) as FirmwareChannel, + os_update_target_version: sn(r["os_update_target_version"]), + os_update_last_attempt_at: sn(r["os_update_last_attempt_at"]), + os_update_last_attempt_version: sn(r["os_update_last_attempt_version"]), + os_update_last_error: sn(r["os_update_last_error"]), local_key: sn(r["local_key"]), local_port: nn(r["local_port"]), local_last_ip: sn(r["local_last_ip"]), @@ -322,6 +330,37 @@ export function rowToFirmwareRollout(r: Row): FirmwareRollout { }; } +export function rowToOsUpdateRelease(r: Row): OsUpdateRelease { + return { + id: s(r["id"]), + version: s(r["version"]), + channel: s(r["channel"]) as FirmwareChannel, + compatibility: s(r["compatibility"]), + artifact_path: s(r["artifact_path"]), + size_bytes: n(r["size_bytes"]), + sha256: s(r["sha256"]), + bundle_format: "raucb", + release_notes: sn(r["release_notes"]), + uploaded_at: s(r["uploaded_at"]), + uploaded_by: nn(r["uploaded_by"]), + yanked_at: sn(r["yanked_at"]), + }; +} + +export function rowToOsUpdateRollout(r: Row): OsUpdateRollout { + return { + id: s(r["id"]), + release_id: s(r["release_id"]), + target_kiosk_ids: j(r["target_kiosk_ids"], []), + state: s(r["state"]) as OsUpdateRolloutState, + percentage: n(r["percentage"]), + started_at: sn(r["started_at"]), + finished_at: sn(r["finished_at"]), + created_at: s(r["created_at"]), + created_by: nn(r["created_by"]), + }; +} + export function rowToLabel(r: Row): Label { return { id: n(r["id"]), diff --git a/server/src/plugins/service-store/migrations.ts b/server/src/plugins/service-store/migrations.ts index 0b471db..1403ea4 100644 --- a/server/src/plugins/service-store/migrations.ts +++ b/server/src/plugins/service-store/migrations.ts @@ -745,6 +745,39 @@ export const MIGRATIONS: readonly MigrationEntry[] = [ ) STRICT`, `CREATE INDEX IF NOT EXISTS idx_firmware_rollouts_state ON firmware_rollouts(state)`, + // ---- full OS OTA --------------------------------------------------------- + // One row per signed RAUC bundle. compatibility must match the kiosk's RAUC + // compatible string (for example betterframe-rpi5-aarch64). + `CREATE TABLE IF NOT EXISTS os_update_releases ( + id TEXT PRIMARY KEY, + version TEXT NOT NULL, + channel TEXT NOT NULL CHECK(channel IN ('stable', 'beta', 'dev')), + compatibility TEXT NOT NULL, + artifact_path TEXT NOT NULL, + size_bytes INTEGER NOT NULL, + sha256 TEXT NOT NULL, + bundle_format TEXT NOT NULL DEFAULT 'raucb' CHECK(bundle_format = 'raucb'), + release_notes TEXT, + uploaded_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + uploaded_by INTEGER REFERENCES users(id) ON DELETE SET NULL, + yanked_at TEXT + ) STRICT`, + `CREATE UNIQUE INDEX IF NOT EXISTS idx_os_update_releases_version_compat ON os_update_releases(version, compatibility)`, + `CREATE INDEX IF NOT EXISTS idx_os_update_releases_channel ON os_update_releases(channel, compatibility, uploaded_at DESC)`, + + `CREATE TABLE IF NOT EXISTS os_update_rollouts ( + id TEXT PRIMARY KEY, + release_id TEXT NOT NULL REFERENCES os_update_releases(id) ON DELETE CASCADE, + target_kiosk_ids TEXT NOT NULL DEFAULT '[]', + state TEXT NOT NULL DEFAULT 'queued' CHECK(state IN ('queued', 'active', 'paused', 'complete')), + percentage INTEGER NOT NULL DEFAULT 100 CHECK(percentage BETWEEN 1 AND 100), + started_at TEXT, + finished_at TEXT, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + created_by INTEGER REFERENCES users(id) ON DELETE SET NULL + ) STRICT`, + `CREATE INDEX IF NOT EXISTS idx_os_update_rollouts_state ON os_update_rollouts(state)`, + // Per-kiosk firmware preferences + update tracking. (db: DatabaseSync) => { addColumnIfNotExists(db, "kiosks", "firmware_channel", "TEXT NOT NULL DEFAULT 'stable'"); @@ -752,6 +785,11 @@ export const MIGRATIONS: readonly MigrationEntry[] = [ addColumnIfNotExists(db, "kiosks", "firmware_last_attempt_at", "TEXT"); addColumnIfNotExists(db, "kiosks", "firmware_last_attempt_version", "TEXT"); addColumnIfNotExists(db, "kiosks", "firmware_last_error", "TEXT"); + addColumnIfNotExists(db, "kiosks", "os_update_channel", "TEXT NOT NULL DEFAULT 'stable'"); + addColumnIfNotExists(db, "kiosks", "os_update_target_version", "TEXT"); + addColumnIfNotExists(db, "kiosks", "os_update_last_attempt_at", "TEXT"); + addColumnIfNotExists(db, "kiosks", "os_update_last_attempt_version", "TEXT"); + addColumnIfNotExists(db, "kiosks", "os_update_last_error", "TEXT"); }, // ---- Kiosk LAN-side local server: reported via heartbeat ------------------ diff --git a/server/src/plugins/service-store/repository.ts b/server/src/plugins/service-store/repository.ts index cc1f474..d25b7ee 100644 --- a/server/src/plugins/service-store/repository.ts +++ b/server/src/plugins/service-store/repository.ts @@ -40,6 +40,9 @@ import type { Layout, LayoutCell, LayoutTemplate, + OsUpdateRelease, + OsUpdateRollout, + OsUpdateRolloutState, PairingCode, Session, SetupState, @@ -64,6 +67,8 @@ import { rowToLayout, rowToLayoutCell, rowToLayoutTemplate, + rowToOsUpdateRelease, + rowToOsUpdateRollout, rowToPairingCode, rowToSession, rowToSetupState, @@ -1311,6 +1316,176 @@ export class Repository { void this.notify("firmware_rollouts", "update", id); } + // =========================================================================== + // os_update_releases + os_update_rollouts + // =========================================================================== + + createOsUpdateRelease(input: { + id: string; + version: string; + channel: FirmwareChannel; + compatibility: string; + artifact_path: string; + size_bytes: number; + sha256: string; + release_notes: string | null; + uploaded_by: number | null; + }): OsUpdateRelease { + this.prep( + `INSERT INTO os_update_releases + (id, version, channel, compatibility, artifact_path, size_bytes, sha256, + bundle_format, release_notes, uploaded_by) + VALUES (?, ?, ?, ?, ?, ?, ?, 'raucb', ?, ?)`, + ).run( + input.id, + input.version, + input.channel, + input.compatibility, + input.artifact_path, + input.size_bytes, + input.sha256, + input.release_notes, + input.uploaded_by, + ); + void this.notify("os_update_releases", "create", input.id); + const r = this.getOsUpdateRelease(input.id); + if (!r) throw new Error("OS update release vanished after insert"); + return r; + } + + getOsUpdateRelease(id: string): OsUpdateRelease | null { + const r = this.prep("SELECT * FROM os_update_releases WHERE id = ?").get(id); + return r ? rowToOsUpdateRelease(r as Record) : null; + } + + getOsUpdateReleaseByVersionCompatibility(version: string, compatibility: string): OsUpdateRelease | null { + const r = this.prep( + "SELECT * FROM os_update_releases WHERE version = ? AND compatibility = ?", + ).get(version, compatibility); + return r ? rowToOsUpdateRelease(r as Record) : null; + } + + getLatestOsUpdateRelease(channel: FirmwareChannel, compatibility: string): OsUpdateRelease | null { + const r = this.prep( + `SELECT * FROM os_update_releases + WHERE channel = ? AND compatibility = ? AND yanked_at IS NULL + ORDER BY uploaded_at DESC + LIMIT 1`, + ).get(channel, compatibility); + return r ? rowToOsUpdateRelease(r as Record) : null; + } + + listOsUpdateReleases(): OsUpdateRelease[] { + const rs = this.prep( + "SELECT * FROM os_update_releases ORDER BY uploaded_at DESC", + ).all(); + return rs.map((r) => rowToOsUpdateRelease(r as Record)); + } + + yankOsUpdateRelease(id: string): void { + this.prep("UPDATE os_update_releases SET yanked_at = ? WHERE id = ?").run(isoNow(), id); + void this.notify("os_update_releases", "update", id); + } + + recordKioskOsUpdateAttempt( + kioskId: number, + version: string, + error: string | null, + ): void { + this.prep( + `UPDATE kiosks SET + os_update_last_attempt_at = ?, + os_update_last_attempt_version = ?, + os_update_last_error = ? + WHERE id = ?`, + ).run(isoNow(), version, error, kioskId); + void this.notify("kiosks", "update", kioskId); + } + + setKioskOsUpdatePref( + kioskId: number, + patch: { channel?: FirmwareChannel; target_version?: string | null }, + ): void { + const sets: string[] = []; + const vals: unknown[] = []; + if (patch.channel !== undefined) { + sets.push("os_update_channel = ?"); + vals.push(patch.channel); + } + if (patch.target_version !== undefined) { + sets.push("os_update_target_version = ?"); + vals.push(patch.target_version); + } + if (sets.length === 0) return; + vals.push(kioskId); + this.db.prepare(`UPDATE kiosks SET ${sets.join(", ")} WHERE id = ?`).run(...(vals as any[])); + void this.notify("kiosks", "update", kioskId); + } + + createOsUpdateRollout(input: { + id: string; + release_id: string; + target_kiosk_ids: number[]; + percentage: number; + created_by: number | null; + }): OsUpdateRollout { + this.prep( + `INSERT INTO os_update_rollouts + (id, release_id, target_kiosk_ids, percentage, created_by, state) + VALUES (?, ?, ?, ?, ?, 'queued')`, + ).run( + input.id, + input.release_id, + J(input.target_kiosk_ids), + input.percentage, + input.created_by, + ); + void this.notify("os_update_rollouts", "create", input.id); + const r = this.getOsUpdateRollout(input.id); + if (!r) throw new Error("OS update rollout vanished after insert"); + return r; + } + + getOsUpdateRollout(id: string): OsUpdateRollout | null { + const r = this.prep("SELECT * FROM os_update_rollouts WHERE id = ?").get(id); + return r ? rowToOsUpdateRollout(r as Record) : null; + } + + listActiveOsUpdateRolloutsForKiosk(kioskId: number): OsUpdateRollout[] { + const rs = this.prep( + `SELECT * FROM os_update_rollouts WHERE state = 'active' ORDER BY created_at DESC`, + ).all(); + return rs + .map((r) => rowToOsUpdateRollout(r as Record)) + .filter((r) => r.target_kiosk_ids.length === 0 || r.target_kiosk_ids.includes(kioskId)); + } + + listOsUpdateRollouts(): OsUpdateRollout[] { + const rs = this.prep( + "SELECT * FROM os_update_rollouts ORDER BY created_at DESC", + ).all(); + return rs.map((r) => rowToOsUpdateRollout(r as Record)); + } + + updateOsUpdateRolloutState( + id: string, + state: OsUpdateRolloutState, + ): void { + const now = isoNow(); + if (state === "active") { + this.prep( + `UPDATE os_update_rollouts SET state = ?, started_at = COALESCE(started_at, ?) WHERE id = ?`, + ).run(state, now, id); + } else if (state === "complete") { + this.prep( + `UPDATE os_update_rollouts SET state = ?, finished_at = ? WHERE id = ?`, + ).run(state, now, id); + } else { + this.prep(`UPDATE os_update_rollouts SET state = ? WHERE id = ?`).run(state, id); + } + void this.notify("os_update_rollouts", "update", id); + } + // =========================================================================== // pairing_codes // =========================================================================== diff --git a/server/src/shared/os-updates.ts b/server/src/shared/os-updates.ts new file mode 100644 index 0000000..82e39e0 --- /dev/null +++ b/server/src/shared/os-updates.ts @@ -0,0 +1,147 @@ +/** + * OS update bundle storage helpers. + * + * Full-device OTA uses RAUC bundles. Unlike the legacy app-binary updater, + * bundle authenticity is verified by RAUC's X.509 keyring on the kiosk; the + * server stores and serves metadata plus a sha256 integrity check. + */ +import { createHash } from "node:crypto"; +import { createReadStream, createWriteStream, existsSync, mkdirSync } from "node:fs"; +import { readFile, rename, stat, unlink, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { Readable, Transform } from "node:stream"; +import { pipeline } from "node:stream/promises"; + +export interface OsUpdateApi { + osUpdateDir(): string; + storeBuffer(bytes: Buffer, expectedSha256?: string | null): Promise; + storeFromUrl(url: string, expectedSha256?: string | null): Promise; + readBundle(path: string, expectedSha256: string): Promise; + streamBundle(path: string): Promise<{ body: ReadableStream; size: number }>; + removeBundle(path: string): Promise; +} + +export interface StoredOsBundle { + path: string; + sha256: string; + size_bytes: number; +} + +export interface OsUpdateConfig { + dataDir: string; +} + +export function initOsUpdates(config: OsUpdateConfig): OsUpdateApi { + const osUpdateDir = join(config.dataDir, "os-updates"); + if (!existsSync(osUpdateDir)) { + mkdirSync(osUpdateDir, { recursive: true, mode: 0o755 }); + } + + async function storeBuffer(bytes: Buffer, expectedSha256?: string | null): Promise { + const sha256 = createHash("sha256").update(bytes).digest("hex"); + assertSha256(sha256, expectedSha256); + const path = join(osUpdateDir, `${sha256}.raucb`); + const tmp = `${path}.tmp`; + await writeBufferAtomic(tmp, path, bytes); + return { path, sha256, size_bytes: bytes.length }; + } + + async function storeFromUrl(url: string, expectedSha256?: string | null): Promise { + const parsed = new URL(url); + if (parsed.protocol !== "https:") { + throw new Error("OS update source_url must use https"); + } + + const response = await fetch(parsed); + if (!response.ok || !response.body) { + throw new Error(`OS update source fetch failed: HTTP ${response.status}`); + } + + const tmp = join(osUpdateDir, `download-${Date.now()}-${Math.random().toString(16).slice(2)}.tmp`); + const hash = createHash("sha256"); + let size = 0; + const meter = new Transform({ + transform(chunk, _encoding, callback) { + const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); + size += buf.length; + hash.update(buf); + callback(null, buf); + }, + }); + + try { + await pipeline( + Readable.fromWeb(response.body as any), + meter, + createWriteStream(tmp, { mode: 0o644 }), + ); + } catch (err) { + await removeIfExists(tmp); + throw err; + } + + const sha256 = hash.digest("hex"); + try { + assertSha256(sha256, expectedSha256); + } catch (err) { + await removeIfExists(tmp); + throw err; + } + const path = join(osUpdateDir, `${sha256}.raucb`); + await rename(tmp, path); + return { path, sha256, size_bytes: size }; + } + + async function readBundle(path: string, expectedSha256: string): Promise { + const buf = await readFile(path); + const got = createHash("sha256").update(buf).digest("hex"); + assertSha256(got, expectedSha256); + return buf; + } + + async function streamBundle(path: string): Promise<{ body: ReadableStream; size: number }> { + const size = (await stat(path)).size; + return { + body: Readable.toWeb(createReadStream(path)) as ReadableStream, + size, + }; + } + + return { + osUpdateDir: () => osUpdateDir, + storeBuffer, + storeFromUrl, + readBundle, + streamBundle, + removeBundle: removeIfExists, + }; +} + +async function writeBufferAtomic(tmp: string, path: string, bytes: Buffer): Promise { + try { + await writeFile(tmp, bytes, { mode: 0o644 }); + await rename(tmp, path); + } catch (err) { + await removeIfExists(tmp); + throw err; + } +} + +async function removeIfExists(path: string): Promise { + try { + await unlink(path); + } catch (err) { + const code = (err as NodeJS.ErrnoException).code; + if (code !== "ENOENT") throw err; + } +} + +function assertSha256(actual: string, expected?: string | null): void { + if (!expected) return; + if (!/^[a-f0-9]{64}$/i.test(expected)) { + throw new Error("expected sha256 must be 64 hex characters"); + } + if (actual.toLowerCase() !== expected.toLowerCase()) { + throw new Error(`OS update sha256 mismatch: expected ${expected}, got ${actual}`); + } +} diff --git a/server/src/shared/types.ts b/server/src/shared/types.ts index a591a34..a160360 100644 --- a/server/src/shared/types.ts +++ b/server/src/shared/types.ts @@ -216,6 +216,11 @@ export interface Kiosk { firmware_last_attempt_at: string | null; firmware_last_attempt_version: string | null; firmware_last_error: string | null; + os_update_channel: FirmwareChannel; + os_update_target_version: string | null; + os_update_last_attempt_at: string | null; + os_update_last_attempt_version: string | null; + os_update_last_error: string | null; local_key: string | null; local_port: number | null; local_last_ip: string | null; @@ -249,6 +254,7 @@ export interface AuditEntry { export type FirmwareChannel = "stable" | "beta" | "dev"; export type FirmwareRolloutState = "queued" | "active" | "paused" | "complete"; +export type OsUpdateRolloutState = FirmwareRolloutState; export interface FirmwareRelease { id: string; @@ -277,6 +283,33 @@ export interface FirmwareRollout { created_by: number | null; } +export interface OsUpdateRelease { + id: string; + version: string; + channel: FirmwareChannel; + compatibility: string; + artifact_path: string; + size_bytes: number; + sha256: string; + bundle_format: "raucb"; + release_notes: string | null; + uploaded_at: string; + uploaded_by: number | null; + yanked_at: string | null; +} + +export interface OsUpdateRollout { + id: string; + release_id: string; + target_kiosk_ids: number[]; + state: OsUpdateRolloutState; + percentage: number; + started_at: string | null; + finished_at: string | null; + created_at: string; + created_by: number | null; +} + export interface Label { id: number; name: string;