import { randomUUID } from "node:crypto" import { spawn } from "node:child_process" import { mkdir, readFile, rm, writeFile } from "node:fs/promises" import os from "node:os" import path from "node:path" import { env } from "../config.js" import { withAdvisoryLock } from "../db/index.js" import { refreshScheduler, stopScheduler } from "./scheduler.js" import { startTrafficFlowListener, stopTrafficFlowListener, } from "./traffic-flow-ingest.js" const PG_DUMP_MAGIC = Buffer.from("PGDMP") const MAX_RESTORE_BYTES = 512 * 1024 * 1024 const BACKUP_LOCK_KEY = 8_723_102 function fmtTimestamp(date = new Date()): string { const pad = (n: number) => String(n).padStart(2, "0") return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}_${pad(date.getHours())}-${pad(date.getMinutes())}-${pad(date.getSeconds())}` } function assertPgDumpFile(buffer: Buffer): void { if (buffer.length < PG_DUMP_MAGIC.length) { throw new Error("Файл слишком маленький для дампа PostgreSQL") } if (!buffer.subarray(0, PG_DUMP_MAGIC.length).equals(PG_DUMP_MAGIC)) { throw new Error("Файл не похож на резервную копию PostgreSQL (ожидается формат custom, PGDMP)") } } function spawnCollected(command: string, args: string[], opts?: { stdin?: Buffer }): Promise { return new Promise((resolve, reject) => { const child = spawn(command, args, { env: { ...process.env, DATABASE_URL: env.DATABASE_URL }, stdio: ["pipe", "pipe", "pipe"], }) const chunks: Buffer[] = [] let err = "" child.stdout.on("data", (chunk: Buffer) => chunks.push(chunk)) child.stderr.on("data", (chunk: Buffer) => { err += chunk.toString("utf8") }) child.on("error", reject) child.on("close", (code) => { if (code !== 0) { reject(new Error(err.trim() || `${command} завершился с кодом ${code}`)) return } resolve(Buffer.concat(chunks)) }) if (opts?.stdin) child.stdin.end(opts.stdin) else child.stdin.end() }) } async function withDatabaseOperation(fn: () => Promise): Promise { return await withAdvisoryLock(BACKUP_LOCK_KEY, async () => { stopTrafficFlowListener() stopScheduler() try { return await fn() } finally { await startTrafficFlowListener() await refreshScheduler() } }) } export async function exportSystemDatabaseBackup(): Promise<{ filename: string; buffer: Buffer }> { return await withDatabaseOperation(async () => { const buffer = await spawnCollected("pg_dump", ["-Fc", env.DATABASE_URL]) return { filename: `mikrotik-manager_${fmtTimestamp()}.dump`, buffer, } }) } export async function restoreSystemDatabaseBackup(buffer: Buffer): Promise { if (buffer.length > MAX_RESTORE_BYTES) { throw new Error("Файл бэкапа слишком большой") } assertPgDumpFile(buffer) await withDatabaseOperation(async () => { const tempDir = path.join(os.tmpdir(), "mmapp-db-restore") await mkdir(tempDir, { recursive: true }) const tempPath = path.join(tempDir, `restore-${randomUUID()}.dump`) try { await writeFile(tempPath, buffer) await spawnCollected("pg_restore", ["--clean", "--if-exists", "-d", env.DATABASE_URL, tempPath]) } finally { await rm(tempPath, { force: true }) } }) } export function getSystemDatabasePath(): string { return env.DATABASE_URL }