/** Encrypted logical backup: pg_dump (custom format) → AES-256-CBC with PBKDF2 (openssl) → BACKUP_DIR, optional rsync to BACKUP_REMOTE, retention 14 daily. */ import { execFile } from "node:child_process"; import { promisify } from "node:util"; import { mkdirSync, readdirSync, statSync, unlinkSync } from "node:fs"; import { join } from "node:path"; const run = promisify(execFile); const PG_BIN = process.env.PG_BIN ?? (process.platform === "darwin" ? "/opt/homebrew/opt/postgresql@17/bin" : "/usr/bin"); export async function runBackup(opts: { dir?: string; url?: string; passphrase?: string } = {}) { const dir = opts.dir ?? process.env.BACKUP_DIR ?? "./backups"; const url = opts.url ?? process.env.DATABASE_URL_ADMIN ?? process.env.DATABASE_URL!; const pass = opts.passphrase ?? process.env.BACKUP_PASSPHRASE; if (!pass) throw new Error("BACKUP_PASSPHRASE not set"); mkdirSync(dir, { recursive: true }); const stamp = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19); const dump = join(dir, `tools-${stamp}.dump`); const enc = `${dump}.enc`; await run(join(PG_BIN, "pg_dump"), ["--format=custom", "--no-owner", "--no-privileges", "--file", dump, url], { env: { ...process.env, PGCONNECT_TIMEOUT: "10" } }); await run("openssl", ["enc", "-aes-256-cbc", "-pbkdf2", "-iter", "200000", "-salt", "-in", dump, "-out", enc, "-pass", "env:TOOLS_BACKUP_PASS"], { env: { ...process.env, TOOLS_BACKUP_PASS: pass } }); unlinkSync(dump); const size = statSync(enc).size; // retention const files = readdirSync(dir).filter((f) => f.startsWith("tools-") && f.endsWith(".dump.enc")).sort(); for (const f of files.slice(0, Math.max(0, files.length - 14))) unlinkSync(join(dir, f)); let remote: string | null = null; if (process.env.BACKUP_REMOTE) { try { await run("rsync", ["-az", "--timeout=60", enc, process.env.BACKUP_REMOTE]); remote = process.env.BACKUP_REMOTE; } catch (e) { remote = `failed: ${(e as Error).message.slice(0, 200)}`; } } return { file: enc, bytes: size, remote }; } export async function restoreInto(encFile: string, targetUrl: string, passphrase: string) { const dump = encFile.replace(/\.enc$/, ".restore.dump"); await run("openssl", ["enc", "-d", "-aes-256-cbc", "-pbkdf2", "-iter", "200000", "-in", encFile, "-out", dump, "-pass", "env:TOOLS_BACKUP_PASS"], { env: { ...process.env, TOOLS_BACKUP_PASS: passphrase } }); await run(join(PG_BIN, "pg_restore"), ["--no-owner", "--no-privileges", "--clean", "--if-exists", "--dbname", targetUrl, dump]); unlinkSync(dump); }