SPB Git
2.5 KB · 32 lines typescript
1/** Encrypted logical backup: pg_dump (custom format) → AES-256-CBC with PBKDF2 (openssl) → BACKUP_DIR, optional rsync to BACKUP_REMOTE, retention 14 daily. */2import { execFile } from "node:child_process";3import { promisify } from "node:util";4import { mkdirSync, readdirSync, statSync, unlinkSync } from "node:fs";5import { join } from "node:path";6const run = promisify(execFile);7const PG_BIN = process.env.PG_BIN ?? (process.platform === "darwin" ? "/opt/homebrew/opt/postgresql@17/bin" : "/usr/bin");89export async function runBackup(opts: { dir?: string; url?: string; passphrase?: string } = {}) {10  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;11  if (!pass) throw new Error("BACKUP_PASSPHRASE not set");12  mkdirSync(dir, { recursive: true });13  const stamp = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19);14  const dump = join(dir, `tools-${stamp}.dump`); const enc = `${dump}.enc`;15  await run(join(PG_BIN, "pg_dump"), ["--format=custom", "--no-owner", "--no-privileges", "--file", dump, url], { env: { ...process.env, PGCONNECT_TIMEOUT: "10" } });16  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 } });17  unlinkSync(dump);18  const size = statSync(enc).size;19  // retention20  const files = readdirSync(dir).filter((f) => f.startsWith("tools-") && f.endsWith(".dump.enc")).sort();21  for (const f of files.slice(0, Math.max(0, files.length - 14))) unlinkSync(join(dir, f));22  let remote: string | null = null;23  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)}`; } }24  return { file: enc, bytes: size, remote };25}26export async function restoreInto(encFile: string, targetUrl: string, passphrase: string) {27  const dump = encFile.replace(/\.enc$/, ".restore.dump");28  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 } });29  await run(join(PG_BIN, "pg_restore"), ["--no-owner", "--no-privileges", "--clean", "--if-exists", "--dbname", targetUrl, dump]);30  unlinkSync(dump);31}32
Files · main
Menu

Theme