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:
1 parent
b5a4c6e2d9
commit
23eb7f0d70
6 files changed
+460
No files matched your search
@@ -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);
|
||||
}));
|
||||
@@ -0,0 +1,189 @@
|
||||
import { createCipheriv, createDecipheriv, randomBytes, scryptSync } from "node:crypto";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import { db } from "../db/client.js";
|
||||
import { integrations, integrationCredentials, dnsProviders, type IntegrationType, type DnsProviderType } from "../db/schema.js";
|
||||
import { encryptSecret, decryptSecret } from "../crypto.js";
|
||||
import { getSettings, updateSettings, type AppSettings } from "./settingsStore.js";
|
||||
import { resolveBaseUrl } from "../integrations/fieldSchemas.js";
|
||||
|
||||
const ALGO = "aes-256-gcm";
|
||||
const SCRYPT_KEYLEN = 32;
|
||||
|
||||
export interface EncryptedExportFile {
|
||||
app: "homelab-manager-backup";
|
||||
version: 1;
|
||||
salt: string;
|
||||
iv: string;
|
||||
authTag: string;
|
||||
ciphertext: string;
|
||||
}
|
||||
|
||||
export interface ExportPayload {
|
||||
exportedAt: string;
|
||||
settings: AppSettings;
|
||||
integrations: {
|
||||
type: IntegrationType;
|
||||
name: string;
|
||||
enabled: boolean;
|
||||
config: Record<string, string | boolean>;
|
||||
secretFields: Record<string, string | boolean>;
|
||||
}[];
|
||||
dnsProviders: {
|
||||
providerType: DnsProviderType;
|
||||
name: string;
|
||||
enabled: boolean;
|
||||
config: Record<string, string | boolean>;
|
||||
secretFields: Record<string, string | boolean>;
|
||||
}[];
|
||||
}
|
||||
|
||||
async function decryptCredential(credentialId: number | null): Promise<Record<string, string | boolean>> {
|
||||
if (!credentialId) return {};
|
||||
const [cred] = await db.select().from(integrationCredentials).where(eq(integrationCredentials.id, credentialId)).limit(1);
|
||||
if (!cred) return {};
|
||||
return JSON.parse(decryptSecret(cred.encryptedSecret));
|
||||
}
|
||||
|
||||
/** Gathers every integration, DNS provider (with credentials decrypted), and app setting into one exportable payload. */
|
||||
export async function buildExportPayload(): Promise<ExportPayload> {
|
||||
const settings = await getSettings();
|
||||
|
||||
const integrationRows = await db.select().from(integrations);
|
||||
const exportedIntegrations = await Promise.all(
|
||||
integrationRows.map(async (row) => ({
|
||||
type: row.type,
|
||||
name: row.name,
|
||||
enabled: row.enabled,
|
||||
config: row.config ? JSON.parse(row.config) : {},
|
||||
secretFields: await decryptCredential(row.credentialId),
|
||||
})),
|
||||
);
|
||||
|
||||
const providerRows = await db.select().from(dnsProviders);
|
||||
const exportedProviders = await Promise.all(
|
||||
providerRows.map(async (row) => ({
|
||||
providerType: row.providerType,
|
||||
name: row.name,
|
||||
enabled: row.enabled,
|
||||
config: row.config ? JSON.parse(row.config) : {},
|
||||
secretFields: await decryptCredential(row.credentialId),
|
||||
})),
|
||||
);
|
||||
|
||||
return {
|
||||
exportedAt: new Date().toISOString(),
|
||||
settings,
|
||||
integrations: exportedIntegrations,
|
||||
dnsProviders: exportedProviders,
|
||||
};
|
||||
}
|
||||
|
||||
/** Encrypts an export payload with a user-chosen passphrase (scrypt-derived key, AES-256-GCM) so the file is portable across instances with different CREDENTIALS_ENCRYPTION_KEY values. */
|
||||
export function encryptExport(payload: ExportPayload, passphrase: string): EncryptedExportFile {
|
||||
const salt = randomBytes(16);
|
||||
const key = scryptSync(passphrase, salt, SCRYPT_KEYLEN);
|
||||
const iv = randomBytes(12);
|
||||
const cipher = createCipheriv(ALGO, key, iv);
|
||||
const ciphertext = Buffer.concat([cipher.update(JSON.stringify(payload), "utf8"), cipher.final()]);
|
||||
const authTag = cipher.getAuthTag();
|
||||
return {
|
||||
app: "homelab-manager-backup",
|
||||
version: 1,
|
||||
salt: salt.toString("hex"),
|
||||
iv: iv.toString("hex"),
|
||||
authTag: authTag.toString("hex"),
|
||||
ciphertext: ciphertext.toString("hex"),
|
||||
};
|
||||
}
|
||||
|
||||
/** Reverses encryptExport(). Throws (GCM auth failure) if the passphrase is wrong or the file was tampered with/corrupted. */
|
||||
export function decryptExport(file: EncryptedExportFile, passphrase: string): ExportPayload {
|
||||
const salt = Buffer.from(file.salt, "hex");
|
||||
const key = scryptSync(passphrase, salt, SCRYPT_KEYLEN);
|
||||
const decipher = createDecipheriv(ALGO, key, Buffer.from(file.iv, "hex"));
|
||||
decipher.setAuthTag(Buffer.from(file.authTag, "hex"));
|
||||
const plaintext = Buffer.concat([decipher.update(Buffer.from(file.ciphertext, "hex")), decipher.final()]);
|
||||
return JSON.parse(plaintext.toString("utf8"));
|
||||
}
|
||||
|
||||
export interface ImportResult {
|
||||
integrationsCreated: number;
|
||||
integrationsSkipped: string[];
|
||||
dnsProvidersCreated: number;
|
||||
dnsProvidersSkipped: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies an imported payload: settings are merged onto current settings
|
||||
* (same per-key merge as a normal settings update); integrations and DNS
|
||||
* providers are only created when no existing row shares their type/name —
|
||||
* an import never overwrites or deletes an existing integration, so it's
|
||||
* safe to re-run against a live instance without risking a working
|
||||
* credential you didn't mean to touch.
|
||||
*/
|
||||
export async function applyImportPayload(payload: ExportPayload): Promise<ImportResult> {
|
||||
await updateSettings(payload.settings);
|
||||
|
||||
const result: ImportResult = { integrationsCreated: 0, integrationsSkipped: [], dnsProvidersCreated: 0, dnsProvidersSkipped: [] };
|
||||
|
||||
for (const item of payload.integrations) {
|
||||
const [existing] = await db
|
||||
.select({ id: integrations.id })
|
||||
.from(integrations)
|
||||
.where(and(eq(integrations.type, item.type), eq(integrations.name, item.name)))
|
||||
.limit(1);
|
||||
if (existing) {
|
||||
result.integrationsSkipped.push(item.name);
|
||||
continue;
|
||||
}
|
||||
|
||||
let credentialId: number | null = null;
|
||||
if (Object.keys(item.secretFields).length > 0) {
|
||||
const [cred] = await db
|
||||
.insert(integrationCredentials)
|
||||
.values({ name: `${item.type}:${item.name}`, encryptedSecret: encryptSecret(JSON.stringify(item.secretFields)) })
|
||||
.returning();
|
||||
credentialId = cred.id;
|
||||
}
|
||||
await db.insert(integrations).values({
|
||||
type: item.type,
|
||||
name: item.name,
|
||||
baseUrl: resolveBaseUrl(item.type, item.config),
|
||||
credentialId,
|
||||
config: JSON.stringify(item.config),
|
||||
enabled: item.enabled,
|
||||
});
|
||||
result.integrationsCreated++;
|
||||
}
|
||||
|
||||
for (const item of payload.dnsProviders) {
|
||||
const [existing] = await db
|
||||
.select({ id: dnsProviders.id })
|
||||
.from(dnsProviders)
|
||||
.where(and(eq(dnsProviders.providerType, item.providerType), eq(dnsProviders.name, item.name)))
|
||||
.limit(1);
|
||||
if (existing) {
|
||||
result.dnsProvidersSkipped.push(item.name);
|
||||
continue;
|
||||
}
|
||||
|
||||
let credentialId: number | null = null;
|
||||
if (Object.keys(item.secretFields).length > 0) {
|
||||
const [cred] = await db
|
||||
.insert(integrationCredentials)
|
||||
.values({ name: `${item.providerType}:${item.name}`, encryptedSecret: encryptSecret(JSON.stringify(item.secretFields)) })
|
||||
.returning();
|
||||
credentialId = cred.id;
|
||||
}
|
||||
await db.insert(dnsProviders).values({
|
||||
providerType: item.providerType,
|
||||
name: item.name,
|
||||
credentialId,
|
||||
config: JSON.stringify(item.config),
|
||||
enabled: item.enabled,
|
||||
});
|
||||
result.dnsProvidersCreated++;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
Reference in new issue
Block a user