mirror of
https://github.com/BetterCorp/BetterFrame.git
synced 2026-05-26 19:06:34 +00:00
feat(ota): add OS update release endpoints
This commit is contained in:
parent
9942957bcf
commit
96f5e6a330
9 changed files with 640 additions and 5 deletions
|
|
@ -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<InstanceType<typeof Config>, 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<InstanceType<typeof Config>, typeof Event
|
|||
cookieName,
|
||||
nodered,
|
||||
firmware,
|
||||
osUpdates,
|
||||
dataDir,
|
||||
};
|
||||
|
||||
|
|
@ -159,6 +164,7 @@ export class Plugin extends BSBService<InstanceType<typeof Config>, 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.
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
91
server/src/plugins/service-admin-http/routes-os-updates.ts
Normal file
91
server/src/plugins/service-admin-http/routes-os-updates.ts
Normal 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,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
|
@ -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<InstanceType<typeof Config>, 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<InstanceType<typeof Config>, 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 };
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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<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 {
|
||||
return {
|
||||
id: n(r["id"]),
|
||||
|
|
|
|||
|
|
@ -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 ------------------
|
||||
|
|
|
|||
|
|
@ -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<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
|
||||
// ===========================================================================
|
||||
|
|
|
|||
147
server/src/shared/os-updates.ts
Normal file
147
server/src/shared/os-updates.ts
Normal 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}`);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in a new issue