import { copyFileSync, createReadStream, createWriteStream, existsSync, mkdirSync, readdirSync, readFileSync, statSync, unlinkSync, writeFileSync, } from "node:fs"; import { createCipheriv, createDecipheriv, randomBytes, scryptSync } from "node:crypto"; import { dirname, join, extname, basename } from "node:path"; import { Readable } from "node:stream"; import { pipeline } from "node:stream/promises"; import { resolveDataDir } from "../data-dir.mjs"; import { getBaseUrl, isServerUp } from "../api.mjs"; import { t } from "../i18n.mjs"; import { backupSqliteFile } from "../sqlite.mjs"; import { CLI_TOKEN_HEADER, getCliToken } from "../utils/cliToken.mjs"; function getBackupDir() { return join(resolveDataDir(), "backups"); } const FILES_TO_BACKUP = [ { name: "storage.sqlite" }, { name: "settings.json" }, { name: "combos.json" }, { name: "providers.json" }, ]; export function registerBackup(program) { const backup = program.command("backup").description(t("backup.description")); backup .command("create") .description(t("backup.createDescription")) .option("--name ", t("backup.nameOpt")) .option("--cloud", t("backup.cloudOpt")) .option("--encrypt", t("backup.encryptOpt")) .option("--key-file ", t("backup.keyFileOpt")) .option("--exclude ", t("backup.excludeOpt"), (v, prev = []) => [...prev, v], []) .option("--retention ", t("backup.retentionOpt"), parseInt) .action(async (opts) => { const exitCode = await runBackupCommand(opts); if (exitCode !== 0) process.exit(exitCode); }); const auto = backup.command("auto").description(t("backup.auto.title")); auto .command("enable") .description(t("backup.auto.enableDescription")) .option("--cron ", t("backup.auto.cronOpt"), "0 3 * * *") .option("--cloud", t("backup.cloudOpt")) .option("--encrypt", t("backup.encryptOpt")) .option("--retention ", t("backup.retentionOpt"), parseInt) .action(async (opts) => { const exitCode = await runBackupAutoEnableCommand(opts); if (exitCode !== 0) process.exit(exitCode); }); auto .command("disable") .description(t("backup.auto.disableDescription")) .action(async () => { const exitCode = await runBackupAutoDisableCommand(); if (exitCode !== 0) process.exit(exitCode); }); auto .command("status") .description(t("backup.auto.statusDescription")) .action(async () => { const exitCode = await runBackupAutoStatusCommand(); if (exitCode !== 0) process.exit(exitCode); }); // Legacy: `omniroute backup` without subcommand still creates a backup backup.action(async (opts) => { const exitCode = await runBackupCommand(opts); if (exitCode !== 0) process.exit(exitCode); }); backup .option("--name ", t("backup.nameOpt")) .option("--cloud", t("backup.cloudOpt")) .option("--encrypt", t("backup.encryptOpt")) .option("--key-file ", t("backup.keyFileOpt")) .option("--exclude ", t("backup.excludeOpt"), (v, prev = []) => [...prev, v], []) .option("--retention ", t("backup.retentionOpt"), parseInt); } export function registerRestore(program) { program .command("restore [backupId]") .description(t("backup.restoreDescription")) .option("--list", "List available backups") .option("--yes", "Skip confirmation") .action(async (backupId, opts) => { const exitCode = await runRestoreCommand(backupId, opts); if (exitCode !== 0) process.exit(exitCode); }); } function matchesGlob(fileName, pattern) { if (!pattern.includes("*")) return fileName === pattern; const parts = pattern.split("*"); let pos = 0; for (let i = 0; i < parts.length; i++) { const part = parts[i]; if (!part) continue; if (i === 0) { if (!fileName.startsWith(part)) return false; pos = part.length; } else if (i === parts.length - 1) { if (!fileName.endsWith(part)) return false; if (fileName.length < pos + part.length) return false; } else { const idx = fileName.indexOf(part, pos); if (idx === -1) return false; pos = idx + part.length; } } return true; } function shouldExclude(fileName, patterns) { if (!patterns || patterns.length === 0) return false; return patterns.some((p) => matchesGlob(fileName, p)); } async function encryptFile(srcPath, destPath, passphrase) { const salt = randomBytes(16); const iv = randomBytes(12); const key = scryptSync(passphrase, salt, 32); const cipher = createCipheriv("aes-256-gcm", key, iv); const tmpCipherPath = `${destPath}.ciphertext`; await pipeline(createReadStream(srcPath), cipher, createWriteStream(tmpCipherPath)); const authTag = cipher.getAuthTag(); // Format: salt(16) + iv(12) + authTag(16) + ciphertext const out = createWriteStream(destPath); try { await new Promise((resolve, reject) => { out.write(Buffer.concat([salt, iv, authTag]), (err) => { if (err) reject(err); else resolve(); }); }); await pipeline(createReadStream(tmpCipherPath), out); } finally { try { unlinkSync(tmpCipherPath); } catch {} } } async function promptPassphrase() { const readline = await import("node:readline"); const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); return new Promise((resolve) => rl.question(t("backup.passphrasePrompt"), (ans) => { rl.close(); resolve(ans.trim()); }) ); } async function pruneBackups(backupDir, retention) { if (!retention || retention <= 0 || !existsSync(backupDir)) return; try { const dirs = readdirSync(backupDir) .filter((f) => f.startsWith("omniroute-backup-")) .sort() .reverse(); for (const old of dirs.slice(retention)) { const { rmSync } = await import("node:fs"); rmSync(join(backupDir, old), { recursive: true, force: true }); } } catch {} } export async function runBackupCommand(opts = {}) { const dataDir = resolveDataDir(); const backupDir = getBackupDir(); const timestamp = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19); const safeName = opts.name ? String(opts.name).replace(/[/\\]/g, "_") : null; const backupName = safeName ? `omniroute-backup-${safeName}` : `omniroute-backup-${timestamp}`; const backupPath = join(backupDir, backupName); const excludePatterns = opts.exclude || []; console.log(t("backup.creating")); let passphrase = null; if (opts.encrypt) { if (opts.keyFile) { passphrase = readFileSync(opts.keyFile, "utf8").trim(); } else { passphrase = await promptPassphrase(); if (!passphrase) { console.error(t("backup.noPassphrase")); return 1; } } } try { if (!existsSync(backupDir)) mkdirSync(backupDir, { recursive: true }); let backedUp = 0; let skipped = 0; for (const file of FILES_TO_BACKUP) { if (shouldExclude(file.name, excludePatterns)) { skipped++; continue; } const sourcePath = join(dataDir, file.name); if (existsSync(sourcePath)) { const destName = opts.encrypt ? `${file.name}.enc` : file.name; const destPath = join(backupPath, destName); mkdirSync(dirname(destPath), { recursive: true }); if (file.name.endsWith(".sqlite")) { const tmpPath = destPath.replace(/\.enc$/, ""); await backupSqliteFile(sourcePath, tmpPath); if (opts.encrypt) { await encryptFile(tmpPath, destPath, passphrase); unlinkSync(tmpPath); } } else if (opts.encrypt) { await encryptFile(sourcePath, destPath, passphrase); } else { copyFileSync(sourcePath, destPath); } backedUp++; } else { skipped++; } } if (backedUp > 0) { const info = { timestamp: new Date().toISOString(), version: "omniroute-cli-v1", encrypted: !!opts.encrypt, files: FILES_TO_BACKUP.filter( (f) => existsSync(join(dataDir, f.name)) && !shouldExclude(f.name, excludePatterns) ).map((f) => (opts.encrypt ? `${f.name}.enc` : f.name)), }; writeFileSync(join(backupPath, "backup-info.json"), JSON.stringify(info, null, 2), "utf8"); if (opts.cloud) { const cloudCode = await _uploadBackupToCloud(backupPath, info); if (cloudCode !== 0) { console.warn(t("backup.cloudFailed")); } } if (opts.retention) { await pruneBackups(backupDir, opts.retention); } console.log(t("backup.done", { path: backupPath })); console.log( `\x1b[2m ${backedUp} backed up, ${skipped} skipped${opts.encrypt ? " (encrypted)" : ""}\x1b[0m` ); return 0; } console.log(t("backup.noFiles")); return 0; } catch (err) { console.error(t("backup.failed", { error: err instanceof Error ? err.message : String(err) })); return 1; } } async function _uploadBackupToCloud(backupPath, info) { const serverUp = await isServerUp(); if (!serverUp) { console.warn(t("common.serverOffline")); return 1; } try { const boundary = `omniroute-backup-${Date.now().toString(36)}-${randomBytes(8).toString("hex")}`; const headers = new Headers({ accept: "application/json", "content-type": `multipart/form-data; boundary=${boundary}`, }); const apiKey = process.env.OMNIROUTE_API_KEY; if (apiKey) headers.set("authorization", `Bearer ${apiKey}`); const cliToken = process.env.OMNIROUTE_CLI_TOKEN ?? (await getCliToken()); if (cliToken) headers.set(CLI_TOKEN_HEADER, cliToken); const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), 30000); const res = await fetch(`${getBaseUrl()}/api/db-backups/cloud`, { method: "POST", headers, body: Readable.from(createBackupMultipartStream(backupPath, info, boundary)), duplex: "half", signal: controller.signal, }).finally(() => clearTimeout(timeout)); if (res.ok) { const data = await res.json(); console.log(t("backup.cloudUploaded", { url: data.url || "(stored)" })); return 0; } return 1; } catch { return 1; } } async function* createBackupMultipartStream(backupPath, info, boundary) { const encoder = new TextEncoder(); const encode = (value) => encoder.encode(value); yield encode( `--${boundary}\r\nContent-Disposition: form-data; name="info"\r\nContent-Type: application/json\r\n\r\n${JSON.stringify(info)}\r\n` ); for (const fname of readdirSync(backupPath)) { const fullPath = join(backupPath, fname); const stat = statSync(fullPath); if (!stat.isFile()) continue; const safeName = fname.replace(/["\r\n]/g, "_"); yield encode( `--${boundary}\r\nContent-Disposition: form-data; name="files"; filename="${safeName}"\r\nContent-Type: application/octet-stream\r\n\r\n` ); yield* createReadStream(fullPath); yield encode("\r\n"); } yield encode(`--${boundary}--\r\n`); } function getSchedulePath() { return join(resolveDataDir(), "backup-schedule.json"); } export async function runBackupAutoEnableCommand(opts = {}) { const schedulePath = getSchedulePath(); const schedule = { enabled: true, cron: opts.cron || "0 3 * * *", cloud: !!opts.cloud, encrypt: !!opts.encrypt, retention: opts.retention || null, updatedAt: new Date().toISOString(), }; mkdirSync(dirname(schedulePath), { recursive: true }); writeFileSync(schedulePath, JSON.stringify(schedule, null, 2), "utf8"); console.log(t("backup.auto.enabled", { cron: schedule.cron })); console.log(t("backup.auto.hint")); return 0; } export async function runBackupAutoDisableCommand() { const schedulePath = getSchedulePath(); if (existsSync(schedulePath)) { const schedule = JSON.parse(readFileSync(schedulePath, "utf8")); schedule.enabled = false; schedule.updatedAt = new Date().toISOString(); writeFileSync(schedulePath, JSON.stringify(schedule, null, 2), "utf8"); } console.log(t("backup.auto.disabled")); return 0; } export async function runBackupAutoStatusCommand() { const schedulePath = getSchedulePath(); if (!existsSync(schedulePath)) { console.log(t("backup.auto.notConfigured")); return 0; } const schedule = JSON.parse(readFileSync(schedulePath, "utf8")); const statusLabel = schedule.enabled ? "\x1b[32m● enabled\x1b[0m" : "\x1b[31m○ disabled\x1b[0m"; console.log(`${t("backup.auto.title")}: ${statusLabel}`); console.log(` cron: ${schedule.cron}`); console.log(` cloud: ${schedule.cloud ? "yes" : "no"}`); console.log(` encrypt: ${schedule.encrypt ? "yes" : "no"}`); console.log(` retention: ${schedule.retention ?? "unlimited"}`); return 0; } export async function runRestoreCommand(backupId, opts = {}) { const backupDir = getBackupDir(); if (opts.list || !backupId) { console.log(`\n\x1b[1m\x1b[36m${t("backup.listTitle")}\x1b[0m\n`); if (!existsSync(backupDir)) { console.log(t("backup.noBackups")); return 0; } try { const dirs = readdirSync(backupDir) .filter((f) => f.startsWith("omniroute-backup-")) .sort() .reverse(); if (dirs.length === 0) { console.log(t("backup.noBackups")); return 0; } for (const dir of dirs) { const infoPath = join(backupDir, dir, "backup-info.json"); if (existsSync(infoPath)) { const info = JSON.parse(readFileSync(infoPath, "utf8")); const id = dir.replace("omniroute-backup-", ""); const dateStr = new Date(info.timestamp).toLocaleString(); console.log(` ${id}`); console.log(`\x1b[2m ${dateStr} — ${info.files?.length || 0} files\x1b[0m`); } else { console.log(`\x1b[2m ${dir.replace("omniroute-backup-", "")}\x1b[0m`); } } } catch (err) { console.error( t("common.error", { message: err instanceof Error ? err.message : String(err) }) ); return 1; } if (!backupId) console.log("\nUsage: omniroute restore "); return 0; } const safeBackupId = String(backupId).replace(/[/\\]/g, "_"); const backupPath = join(backupDir, `omniroute-backup-${safeBackupId}`); if (!existsSync(backupPath)) { console.error(t("backup.notFound", { name: backupId })); return 1; } const infoPath = join(backupPath, "backup-info.json"); const ts = existsSync(infoPath) ? JSON.parse(readFileSync(infoPath, "utf8")).timestamp : backupId; if (!opts.yes) { const readline = await import("node:readline"); const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); const answer = await new Promise((resolve) => rl.question(t("backup.confirmRestore", { ts }) + " [y/N] ", resolve) ); rl.close(); if (!/^y(es)?$/i.test(answer)) { console.log(t("common.cancelled")); return 0; } } console.log(t("backup.restoring", { path: backupPath })); const dataDir = resolveDataDir(); try { for (const file of FILES_TO_BACKUP) { const sourcePath = join(backupPath, file.name); if (existsSync(sourcePath)) { copyFileSync(sourcePath, join(dataDir, file.name)); console.log(`\x1b[2m Restored: ${file.name}\x1b[0m`); } } console.log(t("backup.restored")); return 0; } catch (err) { console.error(t("common.error", { message: err instanceof Error ? err.message : String(err) })); return 1; } }