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; secretFields: Record; }[]; dnsProviders: { providerType: DnsProviderType; name: string; enabled: boolean; config: Record; secretFields: Record; }[]; } async function decryptCredential(credentialId: number | null): Promise> { 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 { 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 { 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; }