#!/usr/bin/env node 'use strict'; const assert = require('node:assert/strict'); const { spawnSync } = require('node:child_process'); const { randomBytes } = require('node:crypto'); const fs = require('node:fs'); const os = require('node:os'); const path = require('node:path'); const { performance } = require('node:perf_hooks'); const { createPostgresDatabaseOpener, inspectPostgresCertificateAuthorityFile, } = require('../packages/ql3-cluster-postgres/dist/entrypoints/runtime.js'); const { auditPostgresCaOverlapFingerprints, } = require('./ql3-postgres-ca-overlap-audit.cjs'); const IMAGE = process.env.QL3_TLS_POSTGRES_IMAGE ?? 'postgres:18'; const DATABASE = 'ql3_tls_contract'; const USER = 'postgres'; const PASSWORD = 'postgres'; const SERVERNAME = 'postgres-rw.internal'; const WRONG_SERVERNAME = 'standby.internal'; const COMMAND_TIMEOUT_MS = 120_000; const IMAGE_PULL_TIMEOUT_MS = 300_000; const WAIT_TIMEOUT_MS = 30_000; function docker(args, options = {}) { const result = spawnSync('docker', args, { encoding: 'utf8', timeout: options.timeoutMs ?? COMMAND_TIMEOUT_MS, maxBuffer: 4 * 1024 * 1024, }); if (result.error) throw result.error; if (result.status !== 0 && !options.allowFailure) { const detail = [result.stderr, result.stdout] .map((value) => value?.trim()) .filter(Boolean) .join('\n'); throw new Error( `docker ${args[0] ?? ''} failed with ${result.status}${ detail ? `: ${detail}` : '' }`, ); } return { status: result.status, stdout: result.stdout.trim(), stderr: result.stderr.trim(), }; } function runCertificateTool(directory, args) { docker([ 'run', '--rm', '--user', '0:0', '--volume', `${directory}:/work`, '--workdir', '/work', IMAGE, 'openssl', ...args, ]); } function createCertificateAuthorities(directory) { for (const authority of ['a', 'b']) { runCertificateTool(directory, [ 'req', '-x509', '-newkey', 'rsa:2048', '-sha256', '-days', '2', '-nodes', '-subj', `/CN=QL3 PostgreSQL TLS Test CA ${authority.toUpperCase()}`, '-addext', 'basicConstraints=critical,CA:TRUE', '-addext', 'keyUsage=critical,keyCertSign,cRLSign', '-keyout', `/work/ca-${authority}.key`, '-out', `/work/ca-${authority}.crt`, ]); } runCertificateTool(directory, [ 'req', '-new', '-newkey', 'rsa:2048', '-sha256', '-nodes', '-subj', `/CN=${SERVERNAME}`, '-addext', `subjectAltName=DNS:${SERVERNAME}`, '-addext', 'extendedKeyUsage=serverAuth', '-keyout', '/work/server.key', '-out', '/work/server.csr', ]); for (const authority of ['a', 'b']) { runCertificateTool(directory, [ 'x509', '-req', '-in', '/work/server.csr', '-CA', `/work/ca-${authority}.crt`, '-CAkey', `/work/ca-${authority}.key`, '-CAcreateserial', '-days', '2', '-sha256', '-copy_extensions', 'copy', '-out', `/work/server-${authority}.crt`, ]); } const oldBundle = fs.readFileSync(path.join(directory, 'ca-a.crt'), 'utf8'); const newBundle = fs.readFileSync(path.join(directory, 'ca-b.crt'), 'utf8'); fs.writeFileSync( path.join(directory, 'ca-overlap.crt'), `${oldBundle.trim()}\n${newBundle.trim()}\n`, { mode: 0o400 }, ); activateServerCertificate(directory, 'a'); for (const fileName of [ 'ca-a.crt', 'ca-b.crt', 'ca-overlap.crt', 'server-a.crt', 'server-b.crt', 'active.crt', ]) { fs.chmodSync(path.join(directory, fileName), 0o400); } fs.chmodSync(path.join(directory, 'server.key'), 0o600); } function activateServerCertificate(directory, generation) { const source = path.join(directory, `server-${generation}.crt`); const target = path.join(directory, 'active.crt'); const temporary = path.join(directory, `.active-${process.pid}.crt`); fs.copyFileSync(source, temporary); fs.chmodSync(temporary, 0o400); fs.renameSync(temporary, target); } function preparePostgresFileOwnership(directory) { const identity = docker([ 'run', '--rm', IMAGE, 'sh', '-c', 'id -u postgres; id -g postgres', ]).stdout.split(/\s+/); if ( identity.length !== 2 || !identity.every((value) => /^\d+$/.test(value)) ) { throw new Error('cannot resolve PostgreSQL container identity'); } docker([ 'run', '--rm', '--user', '0:0', '--volume', `${directory}:/work`, IMAGE, 'chown', `${identity[0]}:${identity[1]}`, '/work/server.key', ]); } function mappedPostgresPort(containerName) { const output = docker(['port', containerName, '5432/tcp']).stdout; const match = output.match(/:(\d+)\s*$/); if (!match) throw new Error(`cannot parse mapped PostgreSQL port: ${output}`); return Number(match[1]); } function delay(milliseconds) { return new Promise((resolve) => setTimeout(resolve, milliseconds)); } async function waitFor(operation, description, timeoutMs = WAIT_TIMEOUT_MS) { const deadline = performance.now() + timeoutMs; let lastError; while (performance.now() < deadline) { try { const value = await operation(); if (value) return value; } catch (error) { lastError = error; } await delay(100); } throw new Error( `timed out waiting for ${description}${ lastError instanceof Error ? `: ${lastError.message}` : '' }`, ); } async function waitForPostgres(containerName) { await waitFor( () => docker( [ 'exec', containerName, 'pg_isready', '-h', '127.0.0.1', '-U', USER, '-d', DATABASE, ], { allowFailure: true }, ).status === 0, 'TLS PostgreSQL readiness', ); } function databaseUrl(port) { return `postgresql://${USER}:${PASSWORD}@127.0.0.1:${port}/${DATABASE}`; } function databaseOpener(port, certificateAuthority, servername, label) { return createPostgresDatabaseOpener({ role: 'runtime', connection: { connectionString: databaseUrl(port), tls: { mode: 'verify-full', ca: certificateAuthority, servername, }, }, pool: { applicationName: `ql3-tls-${label}`, maxConnections: 1, connectionTimeoutMs: 3_000, }, onPoolError() {}, }); } async function inspectTlsConnection( port, certificateAuthority, servername, label, ) { const database = await databaseOpener( port, certificateAuthority, servername, label, )(); try { const result = await database.pool.query(` SELECT ssl.ssl, ssl.version AS "tlsVersion", pg_is_in_recovery() AS "inRecovery", current_setting('transaction_read_only') AS "transactionReadOnly", current_user AS "currentUser", current_setting('server_version_num') AS "serverVersionNum" FROM pg_stat_ssl ssl WHERE ssl.pid = pg_backend_pid() `); assert.equal(result.rows.length, 1); const row = result.rows[0]; assert.equal(row.ssl, true); assert.match(row.tlsVersion, /^TLSv1\.[23]$/); assert.equal(row.inRecovery, false); assert.equal(row.transactionReadOnly, 'off'); assert.equal(row.currentUser, USER); assert.match(row.serverVersionNum, /^18\d{4}$/); return Object.freeze({ tlsVersion: row.tlsVersion, writablePrimary: true, serverVersionNum: row.serverVersionNum, }); } finally { await database.close(); } } async function expectTlsRejection( port, certificateAuthority, servername, label, ) { let database; try { database = await databaseOpener( port, certificateAuthority, servername, label, )(); await database.pool.query('SELECT 1'); } catch (error) { return Object.freeze({ rejected: true, code: error && typeof error === 'object' && typeof error.code === 'string' && /^[A-Z0-9_]{1,64}$/.test(error.code) ? error.code : 'TLS_REJECTED', }); } finally { await database?.close().catch(() => {}); } throw new Error(`${label} unexpectedly established a TLS connection`); } async function reloadCertificate(containerName, directory, generation) { activateServerCertificate(directory, generation); docker(['kill', '--signal', 'HUP', containerName]); await delay(500); } async function main() { const suffix = `${process.pid}-${randomBytes(4).toString('hex')}`; const containerName = `ql3-postgres-tls-${suffix}`; const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'ql3-postgres-tls-')); fs.chmodSync(directory, 0o755); let containerStarted = false; const startedAt = performance.now(); try { if ( docker(['image', 'inspect', IMAGE], { allowFailure: true }).status !== 0 ) { docker(['pull', IMAGE], { timeoutMs: IMAGE_PULL_TIMEOUT_MS }); } createCertificateAuthorities(directory); preparePostgresFileOwnership(directory); const oldInspection = inspectPostgresCertificateAuthorityFile( path.join(directory, 'ca-a.crt'), ); const overlapInspection = inspectPostgresCertificateAuthorityFile( path.join(directory, 'ca-overlap.crt'), ); const newInspection = inspectPostgresCertificateAuthorityFile( path.join(directory, 'ca-b.crt'), ); const overlapContract = auditPostgresCaOverlapFingerprints({ oldFingerprints: oldInspection.fingerprints256, overlapFingerprints: overlapInspection.fingerprints256, newFingerprints: newInspection.fingerprints256, }); docker([ 'run', '--detach', '--name', containerName, '--env', `POSTGRES_DB=${DATABASE}`, '--env', `POSTGRES_USER=${USER}`, '--env', `POSTGRES_PASSWORD=${PASSWORD}`, '--publish', '127.0.0.1::5432', '--volume', `${directory}:/tls:ro`, IMAGE, '-c', 'ssl=on', '-c', 'ssl_cert_file=/tls/active.crt', '-c', 'ssl_key_file=/tls/server.key', '-c', 'ssl_min_protocol_version=TLSv1.2', ]); containerStarted = true; await waitForPostgres(containerName); const port = mappedPostgresPort(containerName); const oldOnly = await inspectTlsConnection( port, oldInspection.bundle, SERVERNAME, 'old-only', ); const wrongServername = await expectTlsRejection( port, oldInspection.bundle, WRONG_SERVERNAME, 'wrong-servername', ); const overlapOnOld = await inspectTlsConnection( port, overlapInspection.bundle, SERVERNAME, 'overlap-old', ); await reloadCertificate(containerName, directory, 'b'); const oldAfterRotation = await expectTlsRejection( port, oldInspection.bundle, SERVERNAME, 'old-after-rotation', ); const newOnly = await inspectTlsConnection( port, newInspection.bundle, SERVERNAME, 'new-only', ); const overlapOnNew = await inspectTlsConnection( port, overlapInspection.bundle, SERVERNAME, 'overlap-new', ); await reloadCertificate(containerName, directory, 'a'); const newAfterRollback = await expectTlsRejection( port, newInspection.bundle, SERVERNAME, 'new-after-rollback', ); const overlapAfterRollback = await inspectTlsConnection( port, overlapInspection.bundle, SERVERNAME, 'overlap-rollback', ); const oldAfterRollback = await inspectTlsConnection( port, oldInspection.bundle, SERVERNAME, 'old-rollback', ); const image = docker([ 'image', 'inspect', '--format', '{{.Architecture}} {{index .RepoDigests 0}}', IMAGE, ]).stdout; process.stdout.write( `${JSON.stringify({ contract: 'qinglong/postgresql-tls-rotation@v1', image, servername: SERVERNAME, overlap: overlapContract, phases: { oldOnly, wrongServername, overlapOnOld, oldAfterRotation, newOnly, overlapOnNew, newAfterRollback, overlapAfterRollback, oldAfterRollback, }, elapsedMs: Number((performance.now() - startedAt).toFixed(3)), limitations: [ 'single PostgreSQL container, not an operator or proxy', 'SIGHUP certificate reload, not primary promotion or STONITH', 'host-published Docker port, not a Kubernetes Service or Pod partition', ], passed: true, })}\n`, ); } finally { if (containerStarted) { docker(['rm', '--force', '--volumes', containerName], { allowFailure: true, }); } fs.rmSync(directory, { recursive: true, force: true }); } } main().catch((error) => { process.stderr.write( `${JSON.stringify({ contract: 'qinglong/postgresql-tls-rotation@v1', passed: false, error: error instanceof Error ? error.message : String(error), })}\n`, ); process.exitCode = 1; });