This commit is contained in:
seb
2026-07-15 03:11:30 +02:00
parent 056c9e18cd
commit 1de4144ae2
23 changed files with 921 additions and 229 deletions

View File

@@ -1,5 +1,5 @@
#!/usr/bin/env node
import { spawnSync } from 'node:child_process';
import { fork, spawn, spawnSync } from 'node:child_process';
import fs from 'node:fs';
import https from 'node:https';
import path from 'node:path';
@@ -14,10 +14,10 @@ import {
HOST,
PORT,
SECRET_KEY,
SERVER_CERT,
s3BaseUrl,
} from './config.mjs';
import { ensureCerts, installCaTrust } from './ensure-certs.mjs';
import { startServer } from './server.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') });
@@ -35,60 +35,114 @@ function sqlcmd(query) {
const port = process.env.MSSQL_PORT || '1433';
const user = process.env.MSSQL_USER || 'sa';
const password = process.env.MSSQL_PASSWORD || '';
const result = spawnSync(
'sqlcmd',
['-W', '-C', '-S', `${server},${port}`, '-U', user, '-P', password, '-Q', query],
{ encoding: 'utf8' }
);
const output = `${result.stdout || ''}${result.stderr || ''}`;
if (result.status !== 0 || /^\s*Msg \d+,/m.test(output)) {
throw new Error(output.trim() || 'sqlcmd failed');
}
return result.stdout;
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 timestamp() {
return new Date().toISOString().replace(/[:.]/g, '-');
}
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++) {
const ok = await new Promise((resolve) => {
const req = https.request(
{ host: HOST, port: PORT, path: '/', method: 'GET', rejectUnauthorized: false },
(res) => {
res.resume();
resolve(res.statusCode === 403 || res.statusCode === 200);
}
);
req.on('error', () => resolve(false));
req.end();
});
if (ok) return;
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();
sqlcmd(`
IF EXISTS (SELECT 1 FROM sys.credentials WHERE name = N'${cred}')
DROP CREDENTIAL [${cred}];
CREATE CREDENTIAL [${cred}]
WITH IDENTITY = 'S3 Access Key',
SECRET = '${ACCESS_KEY}:${SECRET_KEY}';
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
`);
}
function backupDatabase(db) {
async function backupDatabase(db) {
const file = `${db}-${timestamp()}.bak`;
const url = `${s3BaseUrl()}/${file}`;
console.log(`Backing up ${db} -> ${url}`);
sqlcmd(`
await sqlcmd(`
BACKUP DATABASE [${db}]
TO URL = '${url}'
WITH FORMAT, COMPRESSION, STATS = 10;
WITH FORMAT, COMPRESSION, MAXTRANSFERSIZE = 20971520, STATS = 10;
`);
const onDisk = path.join(DATA_DIR, file);
if (!fs.existsSync(onDisk)) {
@@ -99,36 +153,95 @@ WITH FORMAT, COMPRESSION, STATS = 10;
return onDisk;
}
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() {
ensureCerts(HOST);
if (!skipTrust) {
const trusted = installCaTrust();
if (!trusted) {
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 cp scripts/s3-backup/certs/ca.pem mssql:/tmp/jtlsrv-s3-ca.pem`);
console.warn(' docker exec mssql bash -lc "cp /tmp/jtlsrv-s3-ca.pem /usr/local/share/ca-certificates/jtlsrv-s3.crt && update-ca-certificates"');
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 container');
console.log('Installed S3 CA into MSSQL PAL trust store');
if (trust.restarted) {
console.log('Waiting for MSSQL to restart...');
waitForSql();
}
}
}
await startServer();
await waitForServer();
const serverChild = await ensureServerProcess();
try {
await waitForServer();
if (serverOnly) {
console.log('Server running (--server-only). Ctrl+C to stop.');
return;
}
if (serverOnly) {
console.log('Server running (--server-only). Ctrl+C to stop.');
await new Promise((resolve) => serverChild.on('exit', resolve));
return;
}
ensureCredential();
const saved = [];
for (const db of databases) {
saved.push(backupDatabase(db));
}
await ensureCredential();
const saved = [];
for (const db of databases) {
saved.push(await backupDatabase(db));
}
console.log('\nBackups on disk:');
for (const file of saved) {
console.log(` ${file}`);
console.log('\nBackups on disk:');
for (const file of saved) {
console.log(` ${file}`);
}
} finally {
if (!serverOnly) {
serverChild.kill();
await stopPortListener(PORT);
}
}
}