Add passphrase-protected export/import for integrations, DNS providers, and settings

Nothing let you back up or migrate the app's own configuration short
of copying the raw SQLite file. Adds Settings -> Backup: export
decrypts every integration/DNS provider credential (normally
encrypted at rest with this server's CREDENTIALS_ENCRYPTION_KEY) and
re-encrypts the whole payload with a passphrase you choose (scrypt-
derived key, AES-256-GCM), so the file is portable to a different
instance with a different encryption key rather than being tied to
this one. Import decrypts with that passphrase and merges settings
onto the current ones; integrations/DNS providers are only added when
no existing row shares their type+name, so re-running an import never
duplicates or overwrites a working credential.

Scope is configuration only — no DNS records, secrets, IPAM, servers,
or audit/diagnostic log data.

Verified end-to-end against two isolated scratch databases with
different encryption keys (proving actual cross-instance portability,
not just round-tripping through the same key): export -> encrypt ->
write file -> decrypt on the other DB -> import -> re-decrypt the
newly created integration/provider using the target's own key,
confirming the plaintext credentials survived correctly; a wrong
passphrase failed loudly (GCM auth failure) as expected; and
re-running the same import a second time skipped both rows instead of
duplicating them. Confirmed the real dev database's mtime was
untouched throughout.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
bobbanandClaude Sonnet 5 committed 2026-09-19 21:18:41 +02:00
1 parent b5a4c6e2d9
commit 23eb7f0d70
6 files changed
+460

No files matched your search

+58
View File
@@ -8,6 +8,7 @@ import { scheduleTailscaleKeyExpiryCheck } from "../services/tailscaleKeyExpiryS
import { scheduleLogRetentionPurge } from "../services/logRetentionScheduler.js";
import { purgeOldLogs } from "../services/logRetention.js";
import { testGotify, testNtfy, testSmtp, testWebhook } from "../services/notify.js";
import { buildExportPayload, encryptExport, decryptExport, applyImportPayload, type EncryptedExportFile } from "../services/configBackup.js";
import { asyncHandler } from "../utils/asyncHandler.js";
export const settingsRouter = Router();
@@ -174,3 +175,60 @@ settingsRouter.post("/purge-logs", requireRole("admin"), asyncHandler(async (req
});
res.json(result);
}));
const exportSchema = z.object({ passphrase: z.string().min(8) });
settingsRouter.post("/export", requireRole("admin"), asyncHandler(async (req, res) => {
const parsed = exportSchema.safeParse(req.body);
if (!parsed.success) return res.status(400).json({ error: "invalid_body", details: parsed.error.flatten() });
const payload = await buildExportPayload();
const file = encryptExport(payload, parsed.data.passphrase);
await recordAudit({
actor: req.currentUser!,
category: "settings",
action: "export_config",
detail: { integrations: payload.integrations.length, dnsProviders: payload.dnsProviders.length },
});
res.json(file);
}));
const encryptedFileSchema = z.object({
app: z.literal("homelab-manager-backup"),
version: z.literal(1),
salt: z.string().min(1),
iv: z.string().min(1),
authTag: z.string().min(1),
ciphertext: z.string().min(1),
});
const importSchema = z.object({ passphrase: z.string().min(1), file: encryptedFileSchema });
settingsRouter.post("/import", requireRole("admin"), asyncHandler(async (req, res) => {
const parsed = importSchema.safeParse(req.body);
if (!parsed.success) return res.status(400).json({ error: "invalid_body", details: parsed.error.flatten() });
let payload;
try {
payload = decryptExport(parsed.data.file as EncryptedExportFile, parsed.data.passphrase);
} catch {
return res.status(400).json({ error: "decrypt_failed", message: "Wrong passphrase, or the file is corrupted." });
}
if (!payload || typeof payload !== "object" || !Array.isArray(payload.integrations) || !Array.isArray(payload.dnsProviders) || !payload.settings) {
return res.status(400).json({ error: "invalid_payload", message: "Decrypted file doesn't look like a Homelab Manager backup." });
}
const result = await applyImportPayload(payload);
await recordAudit({
actor: req.currentUser!,
category: "settings",
action: "import_config",
detail: result,
});
res.json(result);
}));