This commit is contained in:
seb
2026-07-27 00:13:47 +02:00
parent 538ecf7a80
commit c27ad52e2a
4 changed files with 369 additions and 4 deletions

View File

@@ -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",

View File

@@ -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 -- <file.bak>` | 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

View File

@@ -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 <name> 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);
});

View File

@@ -214,13 +214,36 @@ async function handle(req, res) {
if (!fs.existsSync(file)) {
return send(res, 404, xml('<Error><Code>NoSuchKey</Code><Message>Not Found</Message></Error>'));
}
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)),
});
}