feat(ota): add OS update release endpoints

This commit is contained in:
Mitchell R 2026-05-20 06:19:46 +02:00
parent 9942957bcf
commit 96f5e6a330
No known key found for this signature in database
9 changed files with 640 additions and 5 deletions

View file

@ -20,6 +20,7 @@ import { initSecrets, type SecretsApi } from "../../shared/secrets.js";
import { createAuth, type AuthApi } from "../../shared/auth.js"; import { createAuth, type AuthApi } from "../../shared/auth.js";
import { initNoderedBridge, type NoderedBridge } from "../../shared/nodered-bridge.js"; import { initNoderedBridge, type NoderedBridge } from "../../shared/nodered-bridge.js";
import { initFirmware, type FirmwareApi } from "../../shared/firmware.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 { envStr } from "../../shared/env-overrides.js";
import type { Repository } from "../service-store/repository.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 { registerAdminRoutes } from "./routes-admin.js";
import { registerAccountRoutes } from "./routes-account.js"; import { registerAccountRoutes } from "./routes-account.js";
import { registerFirmwareRoutes } from "./routes-firmware.js"; import { registerFirmwareRoutes } from "./routes-firmware.js";
import { registerOsUpdateRoutes } from "./routes-os-updates.js";
import { registerStaticRoutes } from "./routes-static.js"; import { registerStaticRoutes } from "./routes-static.js";
// ---- Config ----------------------------------------------------------------- // ---- Config -----------------------------------------------------------------
@ -84,6 +86,7 @@ export interface AdminDeps {
cookieName: string; cookieName: string;
nodered: NoderedBridge; nodered: NoderedBridge;
firmware: FirmwareApi; firmware: FirmwareApi;
osUpdates: OsUpdateApi;
dataDir: string; dataDir: string;
} }
@ -139,6 +142,7 @@ export class Plugin extends BSBService<InstanceType<typeof Config>, typeof Event
{ dataDir }, { dataDir },
{ info: (m) => obs.log.info(m as any, {}), warn: (m) => obs.log.warn(m as any, {}) }, { info: (m) => obs.log.info(m as any, {}), warn: (m) => obs.log.warn(m as any, {}) },
); );
const osUpdates = initOsUpdates({ dataDir });
const deps: AdminDeps = { const deps: AdminDeps = {
repo, repo,
@ -147,6 +151,7 @@ export class Plugin extends BSBService<InstanceType<typeof Config>, typeof Event
cookieName, cookieName,
nodered, nodered,
firmware, firmware,
osUpdates,
dataDir, dataDir,
}; };
@ -159,6 +164,7 @@ export class Plugin extends BSBService<InstanceType<typeof Config>, typeof Event
registerAdminRoutes(app, deps); registerAdminRoutes(app, deps);
registerAccountRoutes(app, deps); registerAccountRoutes(app, deps);
registerFirmwareRoutes(app, deps); registerFirmwareRoutes(app, deps);
registerOsUpdateRoutes(app, deps);
// Auth-check endpoint for Angie auth_request subrequest. // Auth-check endpoint for Angie auth_request subrequest.
// Returns 200 if session cookie is valid + admin role, 401 otherwise. // Returns 200 if session cookie is valid + admin role, 401 otherwise.

View file

@ -80,11 +80,12 @@ export function registerMiddleware(app: H3, deps: AdminDeps): void {
if (authz && authz.startsWith("Bearer ")) { if (authz && authz.startsWith("Bearer ")) {
const token = authz.slice(7); const token = authz.slice(7);
if ( if (
path === "/api/admin/firmware/import" && (path === "/api/admin/firmware/import" || path === "/api/admin/os/import") &&
tokenMatchesEnv(token, "BF_FIRMWARE_IMPORT_API_KEY") (tokenMatchesEnv(token, "BF_FIRMWARE_IMPORT_API_KEY") || tokenMatchesEnv(token, "BF_OTA_IMPORT_API_KEY"))
) { ) {
event.context.user = syntheticApiKeyUser("fw-import"); const label = path === "/api/admin/os/import" ? "ota-import" : "fw-import";
event.context.apiKeyPrefix = "fw-import"; event.context.user = syntheticApiKeyUser(label);
event.context.apiKeyPrefix = label;
return; return;
} }

View file

@ -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<FirmwareChannel> = 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,
};
});
}

View file

@ -22,6 +22,7 @@ import { initiatePairing, claimPairing } from "../../shared/pairing.js";
import { generateBundle } from "../../shared/bundle.js"; import { generateBundle } from "../../shared/bundle.js";
import { initNoderedBridge, type NoderedBridge } from "../../shared/nodered-bridge.js"; import { initNoderedBridge, type NoderedBridge } from "../../shared/nodered-bridge.js";
import { initFirmware, type FirmwareApi } from "../../shared/firmware.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 { envStr } from "../../shared/env-overrides.js";
import { createRateLimiter } from "../../shared/rate-limit.js"; import { createRateLimiter } from "../../shared/rate-limit.js";
import { initMqttBridge, type MqttBridge } from "../../shared/mqtt-bridge.js"; import { initMqttBridge, type MqttBridge } from "../../shared/mqtt-bridge.js";
@ -120,6 +121,7 @@ export class Plugin extends BSBService<InstanceType<typeof Config>, typeof Event
{ dataDir }, { dataDir },
{ info: (m) => obs.log.info(m as any, {}), warn: (m) => obs.log.warn(m as any, {}) }, { info: (m) => obs.log.info(m as any, {}), warn: (m) => obs.log.warn(m as any, {}) },
); );
const osUpdates = initOsUpdates({ dataDir });
const mqtt = initMqttBridge({ const mqtt = initMqttBridge({
info: (m) => obs.log.info(m as any, {}), info: (m) => obs.log.info(m as any, {}),
warn: (m) => obs.log.warn(m as any, {}), warn: (m) => obs.log.warn(m as any, {}),
@ -153,7 +155,7 @@ export class Plugin extends BSBService<InstanceType<typeof Config>, typeof Event
}); });
registerPairingRoutes(app, repo, auth, secrets, codeTtl); 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, { this.server = serve(app, {
port: this.config.port, port: this.config.port,
@ -270,6 +272,7 @@ function registerKioskRoutes(
secrets: SecretsApi, secrets: SecretsApi,
nodered: NoderedBridge, nodered: NoderedBridge,
firmware: FirmwareApi, firmware: FirmwareApi,
osUpdates: OsUpdateApi,
mqtt: MqttBridge, mqtt: MqttBridge,
): void { ): void {
// Bundle delivery // Bundle delivery
@ -611,6 +614,108 @@ function registerKioskRoutes(
repo.recordKioskFirmwareAttempt(kiosk.id, body.version, body.error ?? null); repo.recordKioskFirmwareAttempt(kiosk.id, body.version, body.error ?? null);
return { ok: true }; 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 };
});
} }
/** /**

View file

@ -38,6 +38,9 @@ import type {
LayoutPriority, LayoutPriority,
LayoutRegion, LayoutRegion,
LayoutTemplate, LayoutTemplate,
OsUpdateRelease,
OsUpdateRollout,
OsUpdateRolloutState,
PairingCode, PairingCode,
Session, Session,
SetupState, SetupState,
@ -262,6 +265,11 @@ export function rowToKiosk(r: Row): Kiosk {
firmware_last_attempt_at: sn(r["firmware_last_attempt_at"]), firmware_last_attempt_at: sn(r["firmware_last_attempt_at"]),
firmware_last_attempt_version: sn(r["firmware_last_attempt_version"]), firmware_last_attempt_version: sn(r["firmware_last_attempt_version"]),
firmware_last_error: sn(r["firmware_last_error"]), 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_key: sn(r["local_key"]),
local_port: nn(r["local_port"]), local_port: nn(r["local_port"]),
local_last_ip: sn(r["local_last_ip"]), 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<number[]>(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 { export function rowToLabel(r: Row): Label {
return { return {
id: n(r["id"]), id: n(r["id"]),

View file

@ -745,6 +745,39 @@ export const MIGRATIONS: readonly MigrationEntry[] = [
) STRICT`, ) STRICT`,
`CREATE INDEX IF NOT EXISTS idx_firmware_rollouts_state ON firmware_rollouts(state)`, `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. // Per-kiosk firmware preferences + update tracking.
(db: DatabaseSync) => { (db: DatabaseSync) => {
addColumnIfNotExists(db, "kiosks", "firmware_channel", "TEXT NOT NULL DEFAULT 'stable'"); 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_at", "TEXT");
addColumnIfNotExists(db, "kiosks", "firmware_last_attempt_version", "TEXT"); addColumnIfNotExists(db, "kiosks", "firmware_last_attempt_version", "TEXT");
addColumnIfNotExists(db, "kiosks", "firmware_last_error", "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 ------------------ // ---- Kiosk LAN-side local server: reported via heartbeat ------------------

View file

@ -40,6 +40,9 @@ import type {
Layout, Layout,
LayoutCell, LayoutCell,
LayoutTemplate, LayoutTemplate,
OsUpdateRelease,
OsUpdateRollout,
OsUpdateRolloutState,
PairingCode, PairingCode,
Session, Session,
SetupState, SetupState,
@ -64,6 +67,8 @@ import {
rowToLayout, rowToLayout,
rowToLayoutCell, rowToLayoutCell,
rowToLayoutTemplate, rowToLayoutTemplate,
rowToOsUpdateRelease,
rowToOsUpdateRollout,
rowToPairingCode, rowToPairingCode,
rowToSession, rowToSession,
rowToSetupState, rowToSetupState,
@ -1311,6 +1316,176 @@ export class Repository {
void this.notify("firmware_rollouts", "update", id); 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<string, unknown>) : 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<string, unknown>) : 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<string, unknown>) : 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<string, unknown>));
}
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<string, unknown>) : 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<string, unknown>))
.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<string, unknown>));
}
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 // pairing_codes
// =========================================================================== // ===========================================================================

View file

@ -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<StoredOsBundle>;
storeFromUrl(url: string, expectedSha256?: string | null): Promise<StoredOsBundle>;
readBundle(path: string, expectedSha256: string): Promise<Buffer>;
streamBundle(path: string): Promise<{ body: ReadableStream<Uint8Array>; size: number }>;
removeBundle(path: string): Promise<void>;
}
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<StoredOsBundle> {
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<StoredOsBundle> {
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<Buffer> {
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<Uint8Array>; size: number }> {
const size = (await stat(path)).size;
return {
body: Readable.toWeb(createReadStream(path)) as ReadableStream<Uint8Array>,
size,
};
}
return {
osUpdateDir: () => osUpdateDir,
storeBuffer,
storeFromUrl,
readBundle,
streamBundle,
removeBundle: removeIfExists,
};
}
async function writeBufferAtomic(tmp: string, path: string, bytes: Buffer): Promise<void> {
try {
await writeFile(tmp, bytes, { mode: 0o644 });
await rename(tmp, path);
} catch (err) {
await removeIfExists(tmp);
throw err;
}
}
async function removeIfExists(path: string): Promise<void> {
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}`);
}
}

View file

@ -216,6 +216,11 @@ export interface Kiosk {
firmware_last_attempt_at: string | null; firmware_last_attempt_at: string | null;
firmware_last_attempt_version: string | null; firmware_last_attempt_version: string | null;
firmware_last_error: 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_key: string | null;
local_port: number | null; local_port: number | null;
local_last_ip: string | null; local_last_ip: string | null;
@ -249,6 +254,7 @@ export interface AuditEntry {
export type FirmwareChannel = "stable" | "beta" | "dev"; export type FirmwareChannel = "stable" | "beta" | "dev";
export type FirmwareRolloutState = "queued" | "active" | "paused" | "complete"; export type FirmwareRolloutState = "queued" | "active" | "paused" | "complete";
export type OsUpdateRolloutState = FirmwareRolloutState;
export interface FirmwareRelease { export interface FirmwareRelease {
id: string; id: string;
@ -277,6 +283,33 @@ export interface FirmwareRollout {
created_by: number | null; 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 { export interface Label {
id: number; id: number;
name: string; name: string;