mirror of
https://github.com/BetterCorp/BetterFrame.git
synced 2026-05-26 22:26:33 +00:00
OS bundle download was buffering 1.2GB in RAM then writing → network timeout or memory pressure killed it. Now: Kiosk side: - Streams directly to /var/tmp/betterframe/ in 256KB chunks - On network error: resumes from last byte written (Range header) - Up to 5 retries with 10s backoff between attempts - Progress logged every ~50MB - sha256 verified on the complete file on disk (not in memory) Server side: - /api/kiosk/os/download/:id supports Range: bytes=N- header - Returns 206 Partial Content with Content-Range for resume - streamBundle accepts start/end for partial reads via createReadStream - Advertises Accept-Ranges: bytes on all responses
151 lines
4.8 KiB
TypeScript
151 lines
4.8 KiB
TypeScript
/**
|
|
* 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, start?: number, end?: number): 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, start?: number, end?: number): Promise<{ body: ReadableStream<Uint8Array>; size: number }> {
|
|
const totalSize = (await stat(path)).size;
|
|
const opts = (start != null || end != null)
|
|
? { start: start ?? 0, end: end ?? totalSize - 1 }
|
|
: undefined;
|
|
const size = opts ? (opts.end - opts.start + 1) : totalSize;
|
|
return {
|
|
body: Readable.toWeb(createReadStream(path, opts)) 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}`);
|
|
}
|
|
}
|