#!/usr/bin/env node import { fork, spawn, spawnSync } from 'node:child_process'; import fs from 'node:fs'; import https from 'node:https'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import dotenv from 'dotenv'; import { ACCESS_KEY, BUCKET, DATA_DIR, HOST, PORT, SECRET_KEY, SERVER_CERT, s3BaseUrl, } from './config.mjs'; import { caTrustStatus, ensureCerts, installCaTrust } from './ensure-certs.mjs'; const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); dotenv.config({ path: path.join(root, '.env') }); const argv = process.argv.slice(2); const args = new Set(argv.filter((a) => a.startsWith('--'))); const positional = argv.filter((a) => !a.startsWith('--')); const serverOnly = args.has('--server-only'); const skipTrust = args.has('--skip-trust'); const useLatest = args.has('--latest'); const replace = !args.has('--no-replace'); const databaseArg = argv.find((a, i) => argv[i - 1] === '--database'); const defaultDb = process.env.MSSQL_DATABASE || 'eazybusiness'; const database = databaseArg || defaultDb; function sqlcmd(query) { const server = process.env.MSSQL_SERVER || 'localhost'; const port = process.env.MSSQL_PORT || '1433'; const user = process.env.MSSQL_USER || 'sa'; const password = process.env.MSSQL_PASSWORD || ''; return new Promise((resolve, reject) => { const child = spawn( 'sqlcmd', ['-W', '-C', '-S', `${server},${port}`, '-U', user, '-P', password, '-Q', query], { encoding: 'utf8' } ); let stdout = ''; let stderr = ''; child.stdout?.on('data', (chunk) => { stdout += chunk; process.stdout.write(chunk); }); child.stderr?.on('data', (chunk) => { stderr += chunk; process.stderr.write(chunk); }); child.on('close', (code) => { const output = `${stdout}${stderr}`; if (code !== 0 || /^\s*Msg \d+,/m.test(output)) { reject(new Error(output.trim() || 'sqlcmd failed')); } else { resolve(stdout); } }); child.on('error', reject); }); } function listBackups(prefix) { if (!fs.existsSync(DATA_DIR)) return []; return fs .readdirSync(DATA_DIR) .filter((name) => name.endsWith('.bak') && name.startsWith(`${prefix}-`)) .map((name) => ({ name, path: path.join(DATA_DIR, name), mtime: fs.statSync(path.join(DATA_DIR, name)).mtimeMs, })) .sort((a, b) => b.mtime - a.mtime); } function resolveBackupFile() { if (positional.length > 1) { throw new Error(`Expected at most one backup file, got: ${positional.join(', ')}`); } if (positional.length === 1) { const input = positional[0]; if (path.isAbsolute(input) || input.includes('/')) { const resolved = path.resolve(input); if (!fs.existsSync(resolved)) { throw new Error(`Backup file not found: ${resolved}`); } const base = path.basename(resolved); const target = path.join(DATA_DIR, base); if (resolved !== target) { fs.mkdirSync(DATA_DIR, { recursive: true }); fs.copyFileSync(resolved, target); console.log(`Copied ${resolved} -> ${target}`); } return base; } const onDisk = path.join(DATA_DIR, input); if (!fs.existsSync(onDisk)) { throw new Error(`Backup file not found: ${onDisk}`); } return input; } if (useLatest || positional.length === 0) { const matches = listBackups(database); if (matches.length === 0) { throw new Error(`No backups found for ${database} in ${DATA_DIR}`); } console.log(`Using latest backup: ${matches[0].name}`); return matches[0].name; } throw new Error('Specify a backup file or pass --latest'); } function databaseFromBackup(file) { const match = path.basename(file).match(/^(.+)-\d{4}-\d{2}-\d{2}T/); return match ? match[1] : database; } async function isServerUp() { const probeHost = /^\d+\./.test(HOST) ? '127.0.0.1' : HOST; return new Promise((resolve) => { const opts = { host: probeHost, port: PORT, path: '/', method: 'GET', rejectUnauthorized: false, }; if (!/^\d+\./.test(HOST)) { opts.servername = HOST; } const req = https.request(opts, (res) => { res.resume(); resolve(res.statusCode === 403 || res.statusCode === 200); }); req.on('error', () => resolve(false)); req.setTimeout(1000, () => { req.destroy(); resolve(false); }); req.end(); }); } async function waitForServer() { for (let i = 0; i < 40; i++) { if (await isServerUp()) return; await new Promise((r) => setTimeout(r, 250)); } throw new Error(`S3 endpoint did not start on https://${HOST}:${PORT}`); } function pidOnPort(port) { const result = spawnSync('ss', ['-tlnp'], { encoding: 'utf8' }); const match = result.stdout?.match(new RegExp(`:${port}\\s+.*?pid=(\\d+)`)); return match ? Number(match[1]) : null; } async function stopPortListener(port) { const stale = pidOnPort(port); if (!stale) return; try { process.kill(stale); } catch { spawnSync('fuser', ['-k', `${port}/tcp`], { stdio: 'pipe' }); } await new Promise((r) => setTimeout(r, 200)); } async function ensureServerProcess() { await stopPortListener(PORT); return startServerProcess(); } function ensureCredential() { const cred = s3BaseUrl(); return sqlcmd(` IF NOT EXISTS (SELECT 1 FROM sys.credentials WHERE name = N'${cred}') BEGIN CREATE CREDENTIAL [${cred}] WITH IDENTITY = 'S3 Access Key', SECRET = '${ACCESS_KEY}:${SECRET_KEY}'; END `); } async function restoreDatabase(db, file) { const url = `${s3BaseUrl()}/${file}`; const onDisk = path.join(DATA_DIR, file); if (!fs.existsSync(onDisk)) { throw new Error(`Backup file missing on disk: ${onDisk}`); } const mb = (fs.statSync(onDisk).size / (1024 * 1024)).toFixed(1); console.log(`Restoring ${db} <- ${url} (${mb} MB)`); const replaceClause = replace ? ', REPLACE' : ''; await sqlcmd(` IF DB_ID(N'${db}') IS NOT NULL BEGIN ALTER DATABASE [${db}] SET SINGLE_USER WITH ROLLBACK IMMEDIATE; END RESTORE DATABASE [${db}] FROM URL = '${url}' WITH STATS = 10, MAXTRANSFERSIZE = 20971520${replaceClause}; ALTER DATABASE [${db}] SET MULTI_USER; `); } function waitForSql() { for (let i = 0; i < 60; i++) { const result = spawnSync( 'sqlcmd', [ '-W', '-C', '-S', `${process.env.MSSQL_SERVER || 'localhost'},${process.env.MSSQL_PORT || '1433'}`, '-U', process.env.MSSQL_USER || 'sa', '-P', process.env.MSSQL_PASSWORD || '', '-Q', 'SELECT 1', ], { encoding: 'utf8' } ); if (result.status === 0 && !/Msg \d+,/.test(result.stdout || '')) { return; } spawnSync('sleep', ['2']); } throw new Error('MSSQL did not become ready'); } function startServerProcess() { const child = fork(new URL('./server.mjs', import.meta.url), { env: { ...process.env, S3_BACKUP_CHILD: '1' }, stdio: 'inherit', }); return child; } async function main() { if (skipTrust) { const status = caTrustStatus(); if (!status.inSync) { throw new Error( 'PAL CA is out of sync with scripts/s3-backup/certs/ca.pem. Run: npm run backup:s3' ); } console.log('Skipping PAL CA install (--skip-trust)'); if (!fs.existsSync(SERVER_CERT)) { throw new Error('No TLS certs found. Run: npm run backup:s3'); } } else { ensureCerts(HOST); const trust = installCaTrust(); if (!trust.ok) { console.warn('Could not install CA into MSSQL container. Run:'); console.warn(' docker exec -u root mssql mkdir -p /var/opt/mssql/security/ca-certificates'); console.warn(' docker cp scripts/s3-backup/certs/ca.pem mssql:/var/opt/mssql/security/ca-certificates/jtlsrv-s3-ca.pem'); console.warn(' docker exec -u root mssql chown mssql:mssql /var/opt/mssql/security/ca-certificates/jtlsrv-s3-ca.pem'); console.warn(' docker restart mssql'); } else { console.log('Installed S3 CA into MSSQL PAL trust store'); if (trust.restarted) { console.log('Waiting for MSSQL to restart...'); waitForSql(); } } } const serverChild = await ensureServerProcess(); try { await waitForServer(); if (serverOnly) { console.log('Server running (--server-only). Ctrl+C to stop.'); await new Promise((resolve) => serverChild.on('exit', resolve)); return; } const file = resolveBackupFile(); const db = databaseArg || databaseFromBackup(file); await ensureCredential(); await restoreDatabase(db, file); console.log(`\nRestored ${db} from ${file}`); } finally { if (!serverOnly) { serverChild.kill(); await stopPortListener(PORT); } } } if (args.has('--help')) { console.log(`Usage: node scripts/s3-backup/restore.mjs [file.bak] [options] Starts the local S3-compatible HTTPS endpoint and restores MSSQL from a .bak on disk. Arguments: file.bak Backup filename in data/${BUCKET}/, or a path to copy from Options: --latest Use newest backup matching --database (default if no file given) --database Target database (default: MSSQL_DATABASE or name parsed from file) --no-replace Do not pass REPLACE to RESTORE --server-only Start endpoint only, no restore --skip-trust Skip installing CA cert into system trust store Examples: npm run restore:s3:quick npm run restore:s3:quick -- eazybusiness-2026-07-23T19-02-44-903Z.bak npm run restore:s3:quick -- --database eazybusiness --latest Reads MSSQL_* from .env in repo root. Backups are read from scripts/s3-backup/data/${BUCKET}/ `); process.exit(0); } main().catch((err) => { console.error(err.message || err); process.exit(1); });