diff --git a/package.json b/package.json index 3b85345..d905cc9 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,8 @@ "qr": "node scripts/create-pairing-qr.mjs", "backup:s3": "node scripts/s3-backup/backup.mjs", "backup:s3:quick": "node scripts/s3-backup/backup.mjs --skip-trust", + "restore:s3": "node scripts/s3-backup/restore.mjs", + "restore:s3:quick": "node scripts/s3-backup/restore.mjs --skip-trust", "db:minimal": "node scripts/create-minimal-db.mjs", "db:minimal:extract": "node scripts/create-minimal-db.mjs extract", "start": "node --watch server.js", diff --git a/scripts/s3-backup/README.md b/scripts/s3-backup/README.md index feb1465..1010f46 100644 --- a/scripts/s3-backup/README.md +++ b/scripts/s3-backup/README.md @@ -16,9 +16,12 @@ First run installs the CA into the `mssql` Docker container (`/var/opt/mssql/sec |---------|-------------| | `npm run backup:s3` | Start S3 endpoint + backup `MSSQL_DATABASE` from `.env` | | `npm run backup:s3:quick` | Same, but skip PAL CA install/restart (after first setup) | +| `npm run restore:s3` | Start S3 endpoint + restore latest `.bak` for `MSSQL_DATABASE` | +| `npm run restore:s3:quick` | Same, but skip PAL CA install/restart (after first setup) | | `node scripts/s3-backup/backup.mjs --all` | Backup `eazybusiness` and `Mandant_3` | | `node scripts/s3-backup/backup.mjs --server-only` | Run endpoint only | | `npm run backup:s3 -- --skip-trust` | Skip CA install (npm needs `--` before script args) | +| `npm run restore:s3:quick -- ` | Restore a specific backup file | ## Layout @@ -26,6 +29,7 @@ First run installs the CA into the `mssql` Docker container (`/var/opt/mssql/sec scripts/s3-backup/ server.mjs S3-compatible HTTPS server (SigV4, multipart upload) backup.mjs Orchestrator: trust CA → start server → sqlcmd BACKUP + restore.mjs Orchestrator: trust CA → start server → sqlcmd RESTORE config.mjs Host, port, credentials ensure-certs.mjs TLS certs + Docker MSSQL PAL trust sigv4.mjs AWS Signature V4 verification diff --git a/scripts/s3-backup/restore.mjs b/scripts/s3-backup/restore.mjs new file mode 100644 index 0000000..32cf308 --- /dev/null +++ b/scripts/s3-backup/restore.mjs @@ -0,0 +1,335 @@ +#!/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); +}); diff --git a/scripts/s3-backup/server.mjs b/scripts/s3-backup/server.mjs index 0f8372f..ca822e5 100644 --- a/scripts/s3-backup/server.mjs +++ b/scripts/s3-backup/server.mjs @@ -214,13 +214,36 @@ async function handle(req, res) { if (!fs.existsSync(file)) { return send(res, 404, xml('NoSuchKeyNot Found')); } - const data = fs.readFileSync(file); + const stat = fs.statSync(file); + const etag = etagFor(fs.readFileSync(file)); + const range = req.headers.range; + if (range) { + const match = /^bytes=(\d+)-(\d*)$/i.exec(range); + if (match) { + const start = Number(match[1]); + const end = match[2] ? Number(match[2]) : stat.size - 1; + if (start >= stat.size || end < start) { + res.writeHead(416, { 'Content-Range': `bytes */${stat.size}` }); + return res.end(); + } + const length = end - start + 1; + res.writeHead(206, { + 'Content-Type': 'application/octet-stream', + 'Content-Length': length, + 'Content-Range': `bytes ${start}-${end}/${stat.size}`, + 'Accept-Ranges': 'bytes', + ETag: etag, + }); + return fs.createReadStream(file, { start, end }).pipe(res); + } + } res.writeHead(200, { 'Content-Type': 'application/octet-stream', - 'Content-Length': data.length, - ETag: etagFor(data), + 'Content-Length': stat.size, + 'Accept-Ranges': 'bytes', + ETag: etag, }); - return res.end(data); + return fs.createReadStream(file).pipe(res); } if (req.method === 'HEAD' && key) { @@ -231,6 +254,7 @@ async function handle(req, res) { const stat = fs.statSync(file); return send(res, 200, '', { 'Content-Length': stat.size, + 'Accept-Ranges': 'bytes', ETag: etagFor(fs.readFileSync(file)), }); }