Paired-backup verifier now checks more than the manifest hash
This commit is contained in:
@@ -17,6 +17,8 @@ import { auditPostgres } from './market/postgres-custody.ts';
|
||||
const pairManifestSchema = z.object({ format: z.literal('wikideck-coordinated-backup'), version: z.literal(1),
|
||||
network: z.literal(NETWORK), chainId: z.literal(CHAIN_ID), profile: z.literal(PROFILE),
|
||||
createdAt: z.string().datetime(), custodianId: z.string().uuid(), database: z.string().regex(/^[a-zA-Z0-9_-]+$/),
|
||||
postgresServerVersion: z.string().min(1).max(64), postgresServerVersionNum: z.string().regex(/^\d{5,6}$/),
|
||||
pgDumpVersion: z.string().min(1).max(128), pgRestoreVersion: z.string().min(1).max(128),
|
||||
sqliteCutoverSourceDigest: z.string().regex(/^[a-f0-9]{64}$/).nullable(),
|
||||
nodeManifestSha256: z.string().regex(/^[a-f0-9]{64}$/),
|
||||
postgresDump: z.object({ name: z.literal('custody.dump'), bytes: z.number().int().positive().max(256 * 1024 ** 3),
|
||||
@@ -49,7 +51,11 @@ function childEnvironment(config: Awaited<ReturnType<typeof postgresConfig>>, ca
|
||||
const tls = ssl === false ? 'disable' : 'verify-full';
|
||||
const ca = typeof ssl === 'object' && ssl && 'ca' in ssl ? ssl.ca : undefined;
|
||||
if (ca && !caFile) throw new Error('Private PostgreSQL CA must be staged for the backup client');
|
||||
const env: NodeJS.ProcessEnv = { ...process.env, PGHOST: config.host, PGPORT: String(config.port), PGDATABASE: config.database,
|
||||
// Whitelist child environment. Inherited PGHOSTADDR/SSL* variables could redirect libpq
|
||||
// to a different endpoint than the node-pg advisory-lock connection we just audited.
|
||||
const env: NodeJS.ProcessEnv = { PATH: process.env.PATH ?? '',
|
||||
...(process.platform === 'win32' ? { SYSTEMROOT: process.env.SYSTEMROOT ?? '', WINDIR: process.env.WINDIR ?? '' } : {}),
|
||||
PGHOST: config.host, PGPORT: String(config.port), PGDATABASE: config.database,
|
||||
PGUSER: config.user, PGPASSWORD: String(config.password ?? ''), PGPASSFILE: passFile, PGCONNECT_TIMEOUT: '10',
|
||||
PGSERVICEFILE: serviceFile, PGSSLMODE: tls, ...(caFile ? { PGSSLROOTCERT: caFile } : {}) };
|
||||
delete env.PGSERVICE; delete env.PGSSLCERT; delete env.PGSSLKEY; delete env.PGOPTIONS;
|
||||
@@ -88,6 +94,32 @@ async function dumpDatabase(config: Awaited<ReturnType<typeof postgresConfig>>,
|
||||
});
|
||||
} finally { await rm(temp, { recursive: true, force: true }); }
|
||||
}
|
||||
async function toolVersion(binary: string): Promise<string> {
|
||||
return new Promise<string>((resolveVersion, reject) => {
|
||||
let stdout = '';
|
||||
const child: ChildProcess = spawn(binary, ['--version'], { shell: false, windowsHide: true,
|
||||
stdio: ['ignore', 'pipe', 'ignore'], env: { PATH: process.env.PATH ?? '', ...(process.platform === 'win32'
|
||||
? { SYSTEMROOT: process.env.SYSTEMROOT ?? '', WINDIR: process.env.WINDIR ?? '' } : {}) } });
|
||||
child.stdout?.on('data', chunk => {
|
||||
if (stdout.length + chunk.length > 1024) { child.kill(); return; }
|
||||
stdout += chunk.toString('utf8');
|
||||
});
|
||||
const timeout = setTimeout(() => child.kill(), 10_000); timeout.unref();
|
||||
child.once('error', () => { clearTimeout(timeout); reject(new Error('PostgreSQL client utility failed to start')); });
|
||||
child.once('exit', code => {
|
||||
clearTimeout(timeout);
|
||||
const version = stdout.trim();
|
||||
if (code !== 0 || !/^pg_(?:dump|restore) \(PostgreSQL\) \d+(?:\.\d+){1,2}$/.test(version)) {
|
||||
reject(new Error('PostgreSQL client utility returned an invalid version response'));
|
||||
} else resolveVersion(version);
|
||||
});
|
||||
});
|
||||
}
|
||||
function pgMajor(version: string): number {
|
||||
const match = /\(PostgreSQL\) (\d+)/.exec(version);
|
||||
if (!match) throw new Error('Invalid PostgreSQL client version');
|
||||
return Number(match[1]);
|
||||
}
|
||||
|
||||
/** Stop-node + PostgreSQL writer-fenced snapshot. This sandbox tool never restores or resets a database. */
|
||||
export async function createCoordinatedCustodyBackup(dataDirectory: string, destination: string,
|
||||
@@ -98,7 +130,11 @@ export async function createCoordinatedCustodyBackup(dataDirectory: string, dest
|
||||
const runtimeConfig = await postgresConfig(env);
|
||||
const backupConfig = await postgresConfig({ ...env, WIKIDECK_CUSTODY_PG_URL_FILE: backupUrlFile });
|
||||
const dumpBinary = env.WIKIDECK_PG_DUMP_BINARY ?? 'pg_dump';
|
||||
const restoreBinary = env.WIKIDECK_PG_RESTORE_BINARY ?? 'pg_restore';
|
||||
if (dumpBinary !== 'pg_dump' && !isAbsolute(dumpBinary)) throw new Error('Custom PostgreSQL dump binary must use an absolute path');
|
||||
if (restoreBinary !== 'pg_restore' && !isAbsolute(restoreBinary)) throw new Error('Custom PostgreSQL restore binary must use an absolute path');
|
||||
const [pgDumpVersion, pgRestoreVersion] = await Promise.all([toolVersion(dumpBinary), toolVersion(restoreBinary)]);
|
||||
if (pgMajor(pgRestoreVersion) < pgMajor(pgDumpVersion)) throw new Error('PostgreSQL restore utility is older than the dump utility');
|
||||
if (backupConfig.host !== runtimeConfig.host || backupConfig.port !== runtimeConfig.port ||
|
||||
backupConfig.database !== runtimeConfig.database || backupConfig.user === runtimeConfig.user) {
|
||||
throw new Error('Backup database target must match custody, using a separate backup role');
|
||||
@@ -126,6 +162,8 @@ export async function createCoordinatedCustodyBackup(dataDirectory: string, dest
|
||||
client = await pool.connect();
|
||||
const actualDb = await client.query('SELECT current_database() AS name');
|
||||
if (actualDb.rows[0].name !== backupConfig.database) throw new Error('Backup connected to an unexpected database');
|
||||
const serverVersion = await client.query(`SELECT current_setting('server_version') AS version,
|
||||
current_setting('server_version_num')::text AS version_num`);
|
||||
await client.query('SELECT pg_advisory_lock($1::bigint)', [CUSTODY_WRITE_LOCK]);
|
||||
const identity = await client.query("SELECT value FROM wk_custody.custody_meta WHERE key='identity'");
|
||||
if (!identity.rowCount) throw new Error('Custody PostgreSQL identity missing');
|
||||
@@ -159,7 +197,9 @@ export async function createCoordinatedCustodyBackup(dataDirectory: string, dest
|
||||
await flush(dumpPath);
|
||||
const manifest = pairManifestSchema.parse({ format: 'wikideck-coordinated-backup', version: 1, network: NETWORK,
|
||||
chainId: CHAIN_ID, profile: PROFILE, createdAt: new Date().toISOString(), custodianId: binding.custodianId,
|
||||
database: backupConfig.database, sqliteCutoverSourceDigest: imported.rows[0]?.value ?? null,
|
||||
database: backupConfig.database, postgresServerVersion: serverVersion.rows[0].version,
|
||||
postgresServerVersionNum: serverVersion.rows[0].version_num, pgDumpVersion, pgRestoreVersion,
|
||||
sqliteCutoverSourceDigest: imported.rows[0]?.value ?? null,
|
||||
nodeManifestSha256: digest(JSON.stringify(nodeManifest)),
|
||||
postgresDump: { name: 'custody.dump', bytes: dumpInfo.size, sha256: await fileDigest(dumpPath) } });
|
||||
await writeFile(join(target, 'manifest.json'), JSON.stringify(manifest, null, 2), { flag: 'wx', mode: 0o600 });
|
||||
@@ -176,20 +216,66 @@ export async function createCoordinatedCustodyBackup(dataDirectory: string, dest
|
||||
});
|
||||
}
|
||||
|
||||
export async function verifyCoordinatedCustodyBackup(directory: string): Promise<CoordinatedBackupManifest> {
|
||||
async function verifyDumpArchive(path: string, binary: string): Promise<void> {
|
||||
if (binary !== 'pg_restore' && !isAbsolute(binary)) throw new Error('Custom PostgreSQL restore binary must use an absolute path');
|
||||
await new Promise<void>((resolveArchive, reject) => {
|
||||
let diagnostic = '', listing = '', listingTooLarge = false;
|
||||
const child: ChildProcess = spawn(binary, ['--list', path], {
|
||||
shell: false, windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'],
|
||||
env: { PATH: process.env.PATH ?? '', SYSTEMROOT: process.env.SYSTEMROOT ?? '', WINDIR: process.env.WINDIR ?? '' },
|
||||
});
|
||||
child.stdout?.on('data', chunk => {
|
||||
if (listing.length + chunk.length > 2 * 1024 * 1024) {
|
||||
listingTooLarge = true; child.kill(); return;
|
||||
}
|
||||
listing += chunk.toString('utf8');
|
||||
});
|
||||
child.stderr?.on('data', chunk => { diagnostic = (diagnostic + chunk.toString('utf8')).slice(-1024); });
|
||||
let timedOut = false;
|
||||
const timeout = setTimeout(() => {
|
||||
timedOut = true; child.kill();
|
||||
const forceKill = setTimeout(() => child.kill('SIGKILL'), 5000); forceKill.unref();
|
||||
child.once('exit', () => clearTimeout(forceKill));
|
||||
}, 60_000);
|
||||
timeout.unref();
|
||||
child.once('error', () => { clearTimeout(timeout); reject(new Error('PostgreSQL restore utility failed to start')); });
|
||||
child.once('exit', code => {
|
||||
clearTimeout(timeout);
|
||||
if (timedOut) reject(new Error('PostgreSQL archive verification exceeded its time budget'));
|
||||
else if (listingTooLarge) reject(new Error('PostgreSQL dump archive listing exceeds verification budget'));
|
||||
else if (code !== 0) reject(new Error(`PostgreSQL dump archive is invalid (${code ?? 'signal'}): ${diagnostic.replace(/[\r\n\x00-\x1f]/g, ' ').slice(0, 500)}`));
|
||||
else if (!['schema_versions', 'custody_meta', 'accounts', 'journals', 'entries'].every(name =>
|
||||
new RegExp(`\\bTABLE\\s+wk_custody\\s+${name}(?:\\s|$)`).test(listing))) {
|
||||
reject(new Error('PostgreSQL dump archive is missing required custody accounting tables'));
|
||||
}
|
||||
else resolveArchive();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function verifyCoordinatedCustodyBackup(directory: string,
|
||||
restoreBinary = process.env.WIKIDECK_PG_RESTORE_BINARY ?? 'pg_restore'): Promise<CoordinatedBackupManifest> {
|
||||
if (restoreBinary !== 'pg_restore' && !isAbsolute(restoreBinary)) throw new Error('Custom PostgreSQL restore binary must use an absolute path');
|
||||
const root = await realpath(directory);
|
||||
const manifestPath = join(root, 'manifest.json'), info = await regularFile(manifestPath);
|
||||
if (info.size > 16 * 1024) throw new Error('Coordinated backup manifest exceeds limit');
|
||||
const manifest = pairManifestSchema.parse(JSON.parse(await readFile(manifestPath, 'utf8')));
|
||||
const restoreVersion = await toolVersion(restoreBinary);
|
||||
if (pgMajor(restoreVersion) < pgMajor(manifest.pgDumpVersion)) throw new Error('Installed pg_restore is older than archive pg_dump; use a compatible maintenance image');
|
||||
const nodePath = join(root, 'node'), nodeInfo = await lstat(nodePath);
|
||||
if (!nodeInfo.isDirectory() || nodeInfo.isSymbolicLink()) throw new Error('Node backup package must be a regular directory');
|
||||
const nodeManifest = await verifyBackup(nodePath);
|
||||
if (digest(JSON.stringify(nodeManifest)) !== manifest.nodeManifestSha256 || nodeManifest.network !== manifest.network ||
|
||||
nodeManifest.chainId !== manifest.chainId || nodeManifest.profile !== manifest.profile) throw new Error('Node snapshot does not match pair manifest');
|
||||
const expectedNodeEntries = [...nodeManifest.files.map(file => file.name), 'manifest.json'].sort();
|
||||
if ((await readdir(nodePath)).sort().join('|') !== expectedNodeEntries.join('|')) {
|
||||
throw new Error('Unexpected coordinated node backup contents');
|
||||
}
|
||||
const dumpPath = join(root, manifest.postgresDump.name), dumpInfo = await regularFile(dumpPath);
|
||||
if (dumpInfo.size !== manifest.postgresDump.bytes || await fileDigest(dumpPath) !== manifest.postgresDump.sha256) {
|
||||
throw new Error('PostgreSQL dump hash/size mismatch');
|
||||
}
|
||||
await verifyDumpArchive(dumpPath, restoreBinary);
|
||||
const entries = (await readdir(root)).sort();
|
||||
if (entries.join('|') !== ['custody.dump', 'manifest.json', 'node'].sort().join('|')) throw new Error('Unexpected coordinated backup contents');
|
||||
return manifest;
|
||||
|
||||
Reference in New Issue
Block a user