Files
Homelab-manager/server/src/services/configBackup.ts
T
bobbanandClaude Sonnet 5 23eb7f0d70 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>
2026-09-19 21:18:41 +02:00

190 lines
6.7 KiB
TypeScript

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;
}