feat(server): backup + restore (AES-256-GCM, PBKDF2, admin UI)

This commit is contained in:
Mitchell R 2026-05-14 07:44:01 +02:00
parent a6c1fb4d8d
commit aa4e91491b
5 changed files with 249 additions and 0 deletions

View file

@ -84,6 +84,7 @@ export interface AdminDeps {
cookieName: string; cookieName: string;
nodered: NoderedBridge; nodered: NoderedBridge;
firmware: FirmwareApi; firmware: FirmwareApi;
dataDir: string;
} }
// ---- Plugin ----------------------------------------------------------------- // ---- Plugin -----------------------------------------------------------------
@ -146,6 +147,7 @@ export class Plugin extends BSBService<InstanceType<typeof Config>, typeof Event
cookieName, cookieName,
nodered, nodered,
firmware, firmware,
dataDir,
}; };
const app = new H3(); const app = new H3();

View file

@ -13,6 +13,7 @@ import {
CameraEditPage, CameraEditPage,
CameraDiscoverPage, CameraDiscoverPage,
AuditLogPage, AuditLogPage,
BackupPage,
CameraDiscoverResultsPage, CameraDiscoverResultsPage,
EntitiesPage, EntitiesPage,
EntityNewPage, EntityNewPage,
@ -39,6 +40,7 @@ import { generateBundle } from "../../shared/bundle.js";
import { captureSnapshot } from "../../shared/snapshot.js"; import { captureSnapshot } from "../../shared/snapshot.js";
import { stripSecrets } from "../../shared/strip-secrets.js"; import { stripSecrets } from "../../shared/strip-secrets.js";
import { audit } from "../../shared/audit.js"; import { audit } from "../../shared/audit.js";
import { createBackup, restoreBackup } from "../../shared/backup.js";
interface DiscoverAddStream { interface DiscoverAddStream {
profile_name: string; profile_name: string;
@ -271,6 +273,63 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
return new Response(null, { status: 301, headers: { location: "/admin/" } }); return new Response(null, { status: 301, headers: { location: "/admin/" } });
}); });
// ---- Backup / restore -----------------------------------------------------
app.get("/admin/backup", (event) => {
const user = event.context.user!;
return htmlPage(BackupPage({ user: user.username }));
});
app.post("/admin/backup/download", async (event) => {
const body = await readBody<Record<string, string>>(event);
const pass = body?.["passphrase"] ?? "";
let res;
try {
res = createBackup(deps.dataDir, pass);
} catch (err) {
audit(deps.repo, event as any, "backup.create", {
result: "failed", metadata: { error: (err as Error).message },
});
return htmlPage(BackupPage({ user: event.context.user!.username, error: (err as Error).message }));
}
audit(deps.repo, event as any, "backup.create", {
metadata: { file_count: res.fileCount, size: res.blob.length },
});
return new Response(new Uint8Array(res.blob), {
status: 200,
headers: {
"content-type": "application/octet-stream",
"content-disposition": `attachment; filename="${res.filename}"`,
"content-length": String(res.blob.length),
},
});
});
app.post("/admin/backup/restore", async (event) => {
const form = await event.req.formData();
const file = form.get("blob");
const pass = String(form.get("passphrase") ?? "");
if (!(file instanceof File) || !pass) {
return htmlPage(BackupPage({ user: event.context.user!.username, error: "blob + passphrase required" }));
}
try {
const buf = Buffer.from(await file.arrayBuffer());
const res = restoreBackup(deps.dataDir, pass, buf);
audit(deps.repo, event as any, "backup.restore", {
metadata: { file_count: res.fileCount, files: res.files },
});
return htmlPage(BackupPage({
user: event.context.user!.username,
success: `Restored ${String(res.fileCount)} files: ${res.files.join(", ")}. RESTART THE SERVER NOW for changes to take effect.`,
}));
} catch (err) {
audit(deps.repo, event as any, "backup.restore", {
result: "failed", metadata: { error: (err as Error).message },
});
return htmlPage(BackupPage({ user: event.context.user!.username, error: (err as Error).message }));
}
});
// ---- Audit log ------------------------------------------------------------ // ---- Audit log ------------------------------------------------------------
app.get("/admin/audit", (event) => { app.get("/admin/audit", (event) => {

128
server/src/shared/backup.ts Normal file
View file

@ -0,0 +1,128 @@
/**
* Encrypted server backup / restore.
*
* Bundles the SQLite DB + master secret + firmware signing keypair into a
* single `.bfbak` blob encrypted with AES-256-GCM. Key is derived from an
* admin-supplied passphrase via PBKDF2(SHA-256, 200k iters).
*
* format: "bfbak1" || salt[32] || nonce[12] || ciphertext+tag
*
* inner plaintext: JSON
* { version: 1,
* created_at: <iso>,
* files: { "betterframe.db": "<b64>", "secret.key": "<b64>", ... } }
*
* Firmware blobs (firmware/*.bin) are excluded re-upload via the admin
* Firmware page after restore. They can be GB-sized and would defeat the
* point of a "small portable backup".
*/
import {
createCipheriv,
createDecipheriv,
pbkdf2Sync,
randomBytes,
} from "node:crypto";
import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
import { join } from "node:path";
const MAGIC = Buffer.from("bfbak1", "utf8"); // 6 bytes
const SALT_LEN = 32;
const NONCE_LEN = 12;
const PBKDF2_ITERS = 200_000;
const KEY_LEN = 32;
const BACKED_UP_FILES = [
"betterframe.db",
"secret.key",
"firmware-signing.key",
"firmware-signing.pub",
] as const;
export interface BackupResult {
blob: Buffer;
filename: string;
fileCount: number;
}
export function createBackup(dataDir: string, passphrase: string): BackupResult {
if (passphrase.length < 8) {
throw new Error("passphrase must be at least 8 characters");
}
const files: Record<string, string> = {};
for (const name of BACKED_UP_FILES) {
const path = join(dataDir, name);
if (existsSync(path)) {
files[name] = readFileSync(path).toString("base64");
}
}
const inner = JSON.stringify({
version: 1,
created_at: new Date().toISOString(),
files,
});
const salt = randomBytes(SALT_LEN);
const nonce = randomBytes(NONCE_LEN);
const key = pbkdf2Sync(passphrase, salt, PBKDF2_ITERS, KEY_LEN, "sha256");
const cipher = createCipheriv("aes-256-gcm", key, nonce);
const enc = Buffer.concat([cipher.update(Buffer.from(inner, "utf8")), cipher.final()]);
const tag = cipher.getAuthTag();
const blob = Buffer.concat([MAGIC, salt, nonce, enc, tag]);
return {
blob,
filename: `betterframe-${new Date().toISOString().replace(/[:.]/g, "-")}.bfbak`,
fileCount: Object.keys(files).length,
};
}
export function restoreBackup(dataDir: string, passphrase: string, blob: Buffer): {
fileCount: number;
files: string[];
} {
if (blob.length < MAGIC.length + SALT_LEN + NONCE_LEN + 16) {
throw new Error("backup file too short / corrupt");
}
if (!blob.subarray(0, MAGIC.length).equals(MAGIC)) {
throw new Error("not a bfbak file");
}
let off = MAGIC.length;
const salt = blob.subarray(off, off + SALT_LEN);
off += SALT_LEN;
const nonce = blob.subarray(off, off + NONCE_LEN);
off += NONCE_LEN;
const tag = blob.subarray(blob.length - 16);
const enc = blob.subarray(off, blob.length - 16);
const key = pbkdf2Sync(passphrase, salt, PBKDF2_ITERS, KEY_LEN, "sha256");
const decipher = createDecipheriv("aes-256-gcm", key, nonce);
decipher.setAuthTag(tag);
let dec: Buffer;
try {
dec = Buffer.concat([decipher.update(enc), decipher.final()]);
} catch {
throw new Error("wrong passphrase or corrupted backup");
}
const inner = JSON.parse(dec.toString("utf8")) as {
version: number;
files: Record<string, string>;
};
if (inner.version !== 1) throw new Error(`unsupported backup version ${String(inner.version)}`);
mkdirSync(dataDir, { recursive: true });
const restored: string[] = [];
for (const [name, b64] of Object.entries(inner.files)) {
// Refuse path traversal in filenames.
if (name.includes("/") || name.includes("\\") || name.startsWith(".")) continue;
if (!(BACKED_UP_FILES as readonly string[]).includes(name)) continue;
const target = join(dataDir, name);
writeFileSync(target, Buffer.from(b64, "base64"), {
mode: name.endsWith(".key") ? 0o600 : 0o644,
});
restored.push(name);
}
return { fileCount: restored.length, files: restored };
}

View file

@ -3045,3 +3045,62 @@ export function AuditLogPage(props: AuditLogPageProps) {
</Layout> </Layout>
); );
} }
// ---- Backup / restore -------------------------------------------------------
interface BackupPageProps {
user: string;
error?: string;
success?: string;
}
export function BackupPage(props: BackupPageProps) {
return (
<Layout title="Backup & restore" user={props.user} activeNav="backup"
flash={
props.error ? { type: "error", message: props.error }
: props.success ? { type: "success", message: props.success }
: undefined
}
>
<p style="color:#666; margin-bottom:1rem">
Encrypted snapshot of the SQLite DB + master secret + firmware signing
key. Passphrase protects the file (AES-256-GCM, PBKDF2 200k). Lose
the passphrase = lose the backup. Firmware binaries are excluded.
</p>
<div class="two-col">
<div class="card">
<h2 style="margin:0 0 1rem; font-size:1.1rem">Download backup</h2>
<form method="post" action="/admin/backup/download">
<div class="form-group">
<label for="dl_pass">Passphrase</label>
<input id="dl_pass" name="passphrase" type="password" minlength="8" required class="form-input" />
<div class="form-hint">Min 8 chars. Store somewhere safe.</div>
</div>
<button type="submit" class="btn btn-primary">Download .bfbak</button>
</form>
</div>
<div class="card">
<h2 style="margin:0 0 1rem; font-size:1.1rem">Restore from backup</h2>
<form method="post" action="/admin/backup/restore" enctype="multipart/form-data">
<div class="form-group">
<label for="blob">Backup file (.bfbak)</label>
<input id="blob" name="blob" type="file" required class="form-input" />
</div>
<div class="form-group">
<label for="rs_pass">Passphrase</label>
<input id="rs_pass" name="passphrase" type="password" required class="form-input" />
</div>
<div style="background:#fee; border:1px solid #fcc; padding:0.5rem; font-size:0.85rem; margin-bottom:0.75rem">
<strong>Warning:</strong> overwrites DB and master keys.
Restart the server immediately after restore.
</div>
<button type="submit" class="btn btn-danger">Restore</button>
</form>
</div>
</div>
</Layout>
);
}

View file

@ -52,6 +52,7 @@ function Sidebar(props: { activeNav?: string }) {
<NavItem href="/admin/firmware" label="Firmware" icon="&#9650;" active={a === "firmware"} /> <NavItem href="/admin/firmware" label="Firmware" icon="&#9650;" active={a === "firmware"} />
<NavItem href="/admin/labels" label="Labels" icon="&#9670;" active={a === "labels"} /> <NavItem href="/admin/labels" label="Labels" icon="&#9670;" active={a === "labels"} />
<NavItem href="/admin/audit" label="Audit" icon="&#9678;" active={a === "audit"} /> <NavItem href="/admin/audit" label="Audit" icon="&#9678;" active={a === "audit"} />
<NavItem href="/admin/backup" label="Backup" icon="&#9788;" active={a === "backup"} />
<hr /> <hr />
<NavItem href="/admin/account" label="Account" icon="&#9679;" active={a === "account"} /> <NavItem href="/admin/account" label="Account" icon="&#9679;" active={a === "account"} />
<NavItem href="/admin/nodered" label="Node-RED" icon="&#8594;" active={a === "nodered"} /> <NavItem href="/admin/nodered" label="Node-RED" icon="&#8594;" active={a === "nodered"} />