import assert from 'node:assert/strict'; import { chmodSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { spawnSync } from 'node:child_process'; import { test } from 'node:test'; const scriptPath = fileURLToPath(new URL('../scripts/sync-tls-certificate.sh', import.meta.url)); const scriptSource = readFileSync(scriptPath, 'utf8'); const opensslLookup = process.platform === 'win32' ? { status: 1, stdout: '' } : spawnSync('sh', ['-c', 'command -v openssl'], { encoding: 'utf8' }); const opensslPath = opensslLookup.status === 0 ? opensslLookup.stdout.trim() : ''; const canRun = process.platform !== 'win32' && Boolean(opensslPath); test('uses protocol-specific STARTTLS probes for explicit TLS upgrade ports', () => { assert.match(scriptSource, /25\|587\|2525\) protocol_args=\(-starttls smtp\)/); assert.match(scriptSource, /110\).*protocol_args=\(-starttls pop3\)/); assert.match(scriptSource, /143\).*protocol_args=\(-starttls imap\)/); }); test('atomically synchronizes a valid wildcard certificate and preserves safe permissions', { skip: !canRun }, (t) => { const fixture = createFixture(t); generateCertificate(fixture.sourceDir, 'example.test'); const first = runSync(fixture); assert.equal(first.status, 0, first.stderr); assert.match(first.stdout, /MailHub TLS certificate synchronized for mail\.example\.test\./); assert.deepEqual(readFileSync(fixture.targetCert), readFileSync(fixture.sourceCert)); assert.deepEqual(readFileSync(fixture.targetKey), readFileSync(fixture.sourceKey)); assert.equal(fileMode(fixture.targetCert), 0o644); assert.equal(fileMode(fixture.targetKey), 0o640); assert.deepEqual(stagingFiles(fixture.certsDir), []); const previousCert = readFileSync(fixture.targetCert); const previousKey = readFileSync(fixture.targetKey); chmodSync(fixture.targetCert, 0o640); chmodSync(fixture.targetKey, 0o600); generateCertificate(fixture.sourceDir, 'example.test'); const replacement = runSync(fixture); assert.equal(replacement.status, 0, replacement.stderr); assert.deepEqual(readFileSync(`${fixture.targetCert}.previous`), previousCert); assert.deepEqual(readFileSync(`${fixture.targetKey}.previous`), previousKey); assert.deepEqual(readFileSync(fixture.targetCert), readFileSync(fixture.sourceCert)); assert.deepEqual(readFileSync(fixture.targetKey), readFileSync(fixture.sourceKey)); assert.equal(fileMode(fixture.targetCert), 0o644); assert.equal(fileMode(fixture.targetKey), 0o640); assert.deepEqual(stagingFiles(fixture.certsDir), []); }); test('is idempotent when the synchronized certificate is already current', { skip: !canRun }, (t) => { const fixture = createFixture(t); generateCertificate(fixture.sourceDir, 'example.test'); const first = runSync(fixture); assert.equal(first.status, 0, first.stderr); const certBefore = statSnapshot(fixture.targetCert); const keyBefore = statSnapshot(fixture.targetKey); const second = runSync(fixture); assert.equal(second.status, 0, second.stderr); assert.match(second.stdout, /MailHub TLS certificate is already up to date\./); assert.deepEqual(statSnapshot(fixture.targetCert), certBefore); assert.deepEqual(statSnapshot(fixture.targetKey), keyBefore); assert.equal(readdirSync(fixture.certsDir).some((name) => name.endsWith('.previous')), false); assert.deepEqual(stagingFiles(fixture.certsDir), []); }); test('rejects a certificate that does not cover MAIL_HOSTNAME without touching the targets', { skip: !canRun }, (t) => { const fixture = createFixture(t); generateCertificate(fixture.sourceDir, 'other.test'); mkdirSync(fixture.certsDir, { recursive: true }); writeFileSync(fixture.targetCert, 'existing certificate\n'); writeFileSync(fixture.targetKey, 'existing private key\n'); chmodSync(fixture.targetCert, 0o640); chmodSync(fixture.targetKey, 0o600); const certBefore = readFileSync(fixture.targetCert); const keyBefore = readFileSync(fixture.targetKey); const result = runSync(fixture); assert.notEqual(result.status, 0); assert.match(result.stderr, /source certificate does not cover mail\.example\.test\./); assert.deepEqual(readFileSync(fixture.targetCert), certBefore); assert.deepEqual(readFileSync(fixture.targetKey), keyBefore); assert.equal(fileMode(fixture.targetCert), 0o640); assert.equal(fileMode(fixture.targetKey), 0o600); assert.equal(readdirSync(fixture.certsDir).some((name) => name.endsWith('.previous')), false); assert.deepEqual(stagingFiles(fixture.certsDir), []); }); test('rejects certificate and private key paths that resolve to the same target', { skip: !canRun }, (t) => { const fixture = createFixture(t); generateCertificate(fixture.sourceDir, 'example.test'); mkdirSync(fixture.certsDir, { recursive: true }); writeEnvironment(fixture, { SUBMISSION_TLS_KEY: '/certs/fullchain.pem' }); const result = runSync(fixture); assert.notEqual(result.status, 0); assert.equal(readdirSync(fixture.certsDir).some((name) => name === 'fullchain.pem' || name === 'privkey.pem'), false); assert.deepEqual(stagingFiles(fixture.certsDir), []); }); test('rejects a mismatched certificate and private key without touching the targets', { skip: !canRun }, (t) => { const fixture = createFixture(t); const unrelatedDir = path.join(fixture.root, 'unrelated'); mkdirSync(unrelatedDir, { recursive: true }); generateCertificate(fixture.sourceDir, 'example.test'); generateCertificate(unrelatedDir, 'example.test'); writeFileSync(fixture.sourceKey, readFileSync(path.join(unrelatedDir, 'privkey.pem'))); mkdirSync(fixture.certsDir, { recursive: true }); writeFileSync(fixture.targetCert, 'existing certificate\n'); writeFileSync(fixture.targetKey, 'existing private key\n'); const certBefore = readFileSync(fixture.targetCert); const keyBefore = readFileSync(fixture.targetKey); const result = runSync(fixture); assert.notEqual(result.status, 0); assert.match(result.stderr, /certificate and private key do not match/i); assert.deepEqual(readFileSync(fixture.targetCert), certBefore); assert.deepEqual(readFileSync(fixture.targetKey), keyBefore); assert.equal(readdirSync(fixture.certsDir).some((name) => name.endsWith('.previous')), false); assert.deepEqual(stagingFiles(fixture.certsDir), []); }); test('tightens an overly permissive existing private key to mode 0640', { skip: !canRun }, (t) => { const fixture = createFixture(t); generateCertificate(fixture.sourceDir, 'example.test'); const first = runSync(fixture); assert.equal(first.status, 0, first.stderr); chmodSync(fixture.targetKey, 0o666); generateCertificate(fixture.sourceDir, 'example.test'); const replacement = runSync(fixture); assert.equal(replacement.status, 0, replacement.stderr); assert.equal(fileMode(fixture.targetKey), 0o640); assert.deepEqual(readFileSync(fixture.targetKey), readFileSync(fixture.sourceKey)); }); test('validates and installs a staged source snapshot when managed files change mid-run', { skip: !canRun }, (t) => { const fixture = createFixture(t); const replacementDir = path.join(fixture.root, 'replacement'); mkdirSync(replacementDir, { recursive: true }); generateCertificate(fixture.sourceDir, 'example.test'); generateCertificate(replacementDir, 'example.test'); const expectedCert = readFileSync(fixture.sourceCert); const expectedKey = readFileSync(fixture.sourceKey); const marker = path.join(fixture.root, 'source-mutated'); const mutationPath = createOpenSslMutationPath(fixture.root, fixture.path); const result = runSync(fixture, { env: { PATH: mutationPath, MAILHUB_TEST_REAL_OPENSSL: opensslPath, MAILHUB_TEST_MUTATION_MARKER: marker, MAILHUB_TEST_SOURCE_CERT: fixture.sourceCert, MAILHUB_TEST_SOURCE_KEY: fixture.sourceKey, MAILHUB_TEST_REPLACEMENT_CERT: path.join(replacementDir, 'fullchain.pem'), MAILHUB_TEST_REPLACEMENT_KEY: path.join(replacementDir, 'privkey.pem') } }); assert.equal(result.status, 0, result.stderr); assert.equal(readFileSync(fixture.targetCert).equals(expectedCert), true, 'installed certificate must use the staged snapshot'); assert.equal(readFileSync(fixture.targetKey).equals(expectedKey), true, 'installed private key must use the staged snapshot'); assert.notDeepEqual(readFileSync(fixture.sourceCert), expectedCert); assert.notDeepEqual(readFileSync(fixture.sourceKey), expectedKey); assert.deepEqual(stagingFiles(fixture.certsDir), []); }); test('rolls back both certificate files when post-restart health validation fails', { skip: !canRun }, (t) => { const fixture = createFixture(t); generateCertificate(fixture.sourceDir, 'example.test'); const first = runSync(fixture); assert.equal(first.status, 0, first.stderr); const certBefore = readFileSync(fixture.targetCert); const keyBefore = readFileSync(fixture.targetKey); const certModeBefore = fileMode(fixture.targetCert); const keyModeBefore = fileMode(fixture.targetKey); generateCertificate(fixture.sourceDir, 'example.test'); const failurePath = createFailedHealthPath(fixture.root, fixture.path); const result = runSync(fixture, { restart: '1', env: { PATH: failurePath } }); assert.notEqual(result.status, 0); assert.match(result.stderr, /did not become healthy/i); assert.equal(readFileSync(fixture.targetCert).equals(certBefore), true, 'certificate must roll back after failed health validation'); assert.equal(readFileSync(fixture.targetKey).equals(keyBefore), true, 'private key must roll back after failed health validation'); assert.equal(fileMode(fixture.targetCert), certModeBefore); assert.equal(fileMode(fixture.targetKey), keyModeBefore); assert.deepEqual(stagingFiles(fixture.certsDir), []); }); function createFixture(t) { const root = mkdtempSync(path.join(os.tmpdir(), 'mailhub-cert-sync-')); const projectDir = path.join(root, 'project'); const sourceDir = path.join(root, 'source'); const certsDir = path.join(projectDir, 'certs'); const envFile = path.join(projectDir, '.env'); mkdirSync(projectDir, { recursive: true }); mkdirSync(sourceDir, { recursive: true }); writeFileSync(envFile, [ 'MAIL_HOSTNAME=mail.example.test', 'SUBMISSION_TLS_CERT=/certs/fullchain.pem', 'SUBMISSION_TLS_KEY=/certs/privkey.pem', '' ].join('\n')); t.after(() => rmSync(root, { recursive: true, force: true })); return { root, projectDir, sourceDir, certsDir, envFile, sourceCert: path.join(sourceDir, 'fullchain.pem'), sourceKey: path.join(sourceDir, 'privkey.pem'), targetCert: path.join(certsDir, 'fullchain.pem'), targetKey: path.join(certsDir, 'privkey.pem'), path: createCompatibilityPath(root) }; } function writeEnvironment(fixture, overrides = {}) { const values = { MAIL_HOSTNAME: 'mail.example.test', SUBMISSION_TLS_CERT: '/certs/fullchain.pem', SUBMISSION_TLS_KEY: '/certs/privkey.pem', ...overrides }; writeFileSync(fixture.envFile, [ `MAIL_HOSTNAME=${values.MAIL_HOSTNAME}`, `SUBMISSION_TLS_CERT=${values.SUBMISSION_TLS_CERT}`, `SUBMISSION_TLS_KEY=${values.SUBMISSION_TLS_KEY}`, '' ].join('\n')); } function generateCertificate(sourceDir, domain) { const result = spawnSync('openssl', [ 'req', '-x509', '-newkey', 'rsa:2048', '-nodes', '-sha256', '-days', '30', '-subj', `/CN=*.${domain}`, '-addext', `subjectAltName=DNS:*.${domain},DNS:${domain}`, '-keyout', path.join(sourceDir, 'privkey.pem'), '-out', path.join(sourceDir, 'fullchain.pem') ], { encoding: 'utf8' }); assert.equal(result.status, 0, result.stderr); } function runSync(fixture, { restart = '0', env = {} } = {}) { const rootOwnershipEnv = typeof process.getuid === 'function' && process.getuid() === 0 ? { MAILHUB_CERT_READER_GID: String(process.getgid()) } : {}; return spawnSync('bash', [scriptPath], { cwd: fixture.projectDir, encoding: 'utf8', env: { ...process.env, PATH: fixture.path, MAILHUB_CERT_PROJECT_DIR: fixture.projectDir, MAILHUB_CERT_ENV_FILE: fixture.envFile, MAILHUB_CERT_SOURCE_DIR: fixture.sourceDir, MAILHUB_CERT_RESTART: restart, ...rootOwnershipEnv, ...env } }); } function statSnapshot(file) { const stat = statSync(file); return { ino: stat.ino, mode: stat.mode & 0o777, size: stat.size, mtimeMs: stat.mtimeMs }; } function fileMode(file) { return statSync(file).mode & 0o777; } function stagingFiles(directory) { return readdirSync(directory).filter((name) => name.includes('.tmp.') || name.startsWith('.cert-sync.')); } function createCompatibilityPath(root) { if (process.platform !== 'darwin') return process.env.PATH || ''; const binDir = path.join(root, 'bin'); mkdirSync(binDir, { recursive: true }); writeExecutable(path.join(binDir, 'cp'), stripDoubleDashWrapper('/bin/cp')); writeExecutable(path.join(binDir, 'mv'), stripDoubleDashWrapper('/bin/mv')); writeExecutable(path.join(binDir, 'rm'), stripDoubleDashWrapper('/bin/rm')); writeExecutable(path.join(binDir, 'chmod'), `#!/usr/bin/env bash set -euo pipefail if [[ "\${1:-}" == --reference=* ]]; then reference="\${1#*=}" shift mode="$(/usr/bin/stat -f '%Lp' "\${reference}")" exec /bin/chmod "\${mode}" "$@" fi exec /bin/chmod "$@" `); writeExecutable(path.join(binDir, 'chown'), `#!/usr/bin/env bash set -euo pipefail if [[ "\${1:-}" == --reference=* ]]; then reference="\${1#*=}" shift owner="$(/usr/bin/stat -f '%u:%g' "\${reference}")" exec /usr/sbin/chown "\${owner}" "$@" fi exec /usr/sbin/chown "$@" `); writeExecutable(path.join(binDir, 'sha256sum'), `#!/usr/bin/env bash exec /usr/bin/shasum -a 256 "$@" `); return `${binDir}${path.delimiter}${process.env.PATH || ''}`; } function createOpenSslMutationPath(root, basePath) { const binDir = path.join(root, 'mutating-openssl-bin'); mkdirSync(binDir, { recursive: true }); writeExecutable(path.join(binDir, 'openssl'), `#!/usr/bin/env bash set -euo pipefail if [[ ! -e "\${MAILHUB_TEST_MUTATION_MARKER}" ]]; then : > "\${MAILHUB_TEST_MUTATION_MARKER}" cp "\${MAILHUB_TEST_REPLACEMENT_CERT}" "\${MAILHUB_TEST_SOURCE_CERT}" cp "\${MAILHUB_TEST_REPLACEMENT_KEY}" "\${MAILHUB_TEST_SOURCE_KEY}" fi exec "\${MAILHUB_TEST_REAL_OPENSSL}" "$@" `); return `${binDir}${path.delimiter}${basePath}`; } function createFailedHealthPath(root, basePath) { const binDir = path.join(root, 'failed-health-bin'); mkdirSync(binDir, { recursive: true }); writeExecutable(path.join(binDir, 'docker'), `#!/usr/bin/env bash set -euo pipefail if [[ "$*" == *"tls.createSecureContext"* ]]; then exit 0 fi if [[ "$*" == *"test -r"* ]]; then exit 0 fi if [[ "\${1:-}" == "compose" && "\${2:-}" == "restart" ]]; then exit 0 fi exit 1 `); writeExecutable(path.join(binDir, 'sleep'), `#!/usr/bin/env bash exit 0 `); return `${binDir}${path.delimiter}${basePath}`; } function stripDoubleDashWrapper(command) { return `#!/usr/bin/env bash set -euo pipefail args=() for argument in "$@"; do [[ "\${argument}" == "--" ]] || args+=("\${argument}") done exec ${command} "\${args[@]}" `; } function writeExecutable(file, content) { writeFileSync(file, content); chmodSync(file, 0o755); }