#!/usr/bin/env node import { spawnSync } from 'node:child_process'; import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, symlinkSync, writeFileSync, } from 'node:fs'; import { tmpdir } from 'node:os'; import path from 'node:path'; const SCRIPT = 'scripts/deploy/pingora-tls-cert-sync.mjs'; const failures = []; const tmpRoot = mkdtempSync( path.join(tmpdir(), 'genarrative-pingora-tls-cert-sync-'), ); try { main(); } finally { rmSync(tmpRoot, { recursive: true, force: true }); } if (failures.length > 0) { console.error('[check:pingora-tls-cert-sync] FAILED'); for (const failure of failures) { console.error(`- ${failure}`); } process.exit(1); } console.log('[check:pingora-tls-cert-sync] OK'); function main() { assertDryRunAcceptsCertbotLiveSymlinks(); assertApplyCopiesToPrivateTarget(); assertRejectsRelativeAndRootPaths(); assertRejectsControlCharactersBeforeApply(); assertRejectsSymlinkTargetDirectory(); assertRejectsSymlinkTargetFiles(); assertRejectsWorldReadableModes(); } function assertDryRunAcceptsCertbotLiveSymlinks() { const fixture = prepareFixture('dry-run-live-symlink'); const result = runSync(fixture, []); assertStatus(result, 0, 'dry-run 应接受 Certbot live symlink 源。'); if (existsSync(fixture.targetCert) || existsSync(fixture.targetKey)) { failures.push('dry-run 不应写入目标证书文件。'); } const payload = parseJson(result.stdout, 'dry-run JSON 输出'); assertEqual(payload.mode, 'dry-run', 'dry-run JSON mode 应为 dry-run。'); assertEqual( payload.files?.[0]?.source?.sourceIsSymlink, true, 'dry-run JSON 应标记源证书来自 symlink。', ); } function assertApplyCopiesToPrivateTarget() { const fixture = prepareFixture('apply-copy', { symlinkSources: false }); const result = runSync(fixture, ['--apply'], { serviceUser: `#${process.getuid?.() ?? 0}`, serviceGroup: `#${process.getgid?.() ?? 0}`, targetUser: `#${process.getuid?.() ?? 0}`, }); assertStatus(result, 0, 'apply 应复制证书到目标目录。'); assertFileContent(fixture.targetCert, 'fixture fullchain\n'); assertFileContent(fixture.targetKey, 'fixture private key\n'); assertMode(fixture.targetDir, 0o750, '目标目录权限应为 0750。'); assertMode(fixture.targetCert, 0o640, '目标证书权限应为 0640。'); assertMode(fixture.targetKey, 0o640, '目标私钥权限应为 0640。'); } function assertRejectsRelativeAndRootPaths() { const fixture = prepareFixture('reject-paths'); const relative = runRaw([ '--source-cert-file', 'relative/fullchain.pem', '--source-key-file', fixture.sourceKey, '--target-dir', fixture.targetDir, ]); assertNonZero(relative, '相对源证书路径必须失败。'); assertIncludes( `${relative.stdout}\n${relative.stderr}`, '--source-cert-file 必须是绝对路径', '相对路径必须给出明确错误。', ); const root = runRaw([ '--source-cert-file', fixture.sourceCert, '--source-key-file', fixture.sourceKey, '--target-dir', '/', ]); assertNonZero(root, 'target-dir 指向文件系统根目录必须失败。'); assertIncludes( `${root.stdout}\n${root.stderr}`, '--target-dir 不能是文件系统根目录', '根目录目标必须给出明确错误。', ); } function assertRejectsControlCharactersBeforeApply() { const fixture = prepareFixture('reject-control'); const result = runRaw([ '--source-cert-file', `${fixture.sourceCert}\n--apply`, '--source-key-file', fixture.sourceKey, '--target-dir', fixture.targetDir, ]); assertNonZero(result, '带换行的源证书路径必须失败。'); assertIncludes( `${result.stdout}\n${result.stderr}`, '--source-cert-file 不能包含换行或 NUL 字符', '控制字符路径必须给出明确错误。', ); if (existsSync(fixture.targetCert) || existsSync(fixture.targetKey)) { failures.push('控制字符参数失败时不应写入目标证书。'); } } function assertRejectsSymlinkTargetDirectory() { const fixture = prepareFixture('reject-target-dir-symlink'); const realTarget = path.join(fixture.root, 'real-target'); mkdirSync(realTarget, { recursive: true }); rmSync(fixture.targetDir, { recursive: true, force: true }); symlinkSync(realTarget, fixture.targetDir); const result = runSync(fixture, []); assertNonZero(result, 'target-dir 是 symlink 时必须失败。'); assertIncludes( `${result.stdout}\n${result.stderr}`, '--target-dir 已存在路径不能包含符号链接', 'target-dir symlink 必须给出明确错误。', ); } function assertRejectsSymlinkTargetFiles() { const fixture = prepareFixture('reject-target-file-symlink'); mkdirSync(fixture.targetDir, { recursive: true }); const outside = path.join(fixture.root, 'outside-fullchain.pem'); writeFileSync(outside, 'outside\n', 'utf8'); symlinkSync(outside, fixture.targetCert); const result = runSync(fixture, []); assertNonZero(result, '目标证书是 symlink 时必须失败。'); assertIncludes( `${result.stdout}\n${result.stderr}`, '目标 fullchain.pem 不能是符号链接', '目标文件 symlink 必须给出明确错误。', ); } function assertRejectsWorldReadableModes() { const fixture = prepareFixture('reject-world-readable-mode'); const result = runSync(fixture, ['--file-mode', '0644']); assertNonZero(result, 'world-readable file mode 必须失败。'); assertIncludes( `${result.stdout}\n${result.stderr}`, '--file-mode 不能允许 other 读取证书或私钥', 'world-readable file mode 必须给出明确错误。', ); } function prepareFixture(name, options = {}) { const root = path.join(tmpRoot, name); const archiveDir = path.join(root, 'archive'); const liveDir = path.join(root, 'live'); const targetDir = path.join(root, 'target'); mkdirSync(archiveDir, { recursive: true }); mkdirSync(liveDir, { recursive: true }); writeFileSync(path.join(archiveDir, 'fullchain1.pem'), 'fixture fullchain\n', 'utf8'); writeFileSync(path.join(archiveDir, 'privkey1.pem'), 'fixture private key\n', 'utf8'); const symlinkSources = options.symlinkSources !== false; const sourceCert = symlinkSources ? path.join(liveDir, 'fullchain.pem') : path.join(archiveDir, 'fullchain1.pem'); const sourceKey = symlinkSources ? path.join(liveDir, 'privkey.pem') : path.join(archiveDir, 'privkey1.pem'); if (symlinkSources) { symlinkSync('../archive/fullchain1.pem', sourceCert); symlinkSync('../archive/privkey1.pem', sourceKey); } return { root, sourceCert, sourceKey, targetDir, targetCert: path.join(targetDir, 'fullchain.pem'), targetKey: path.join(targetDir, 'privkey.pem'), }; } function runSync(fixture, args = [], options = {}) { return runRaw([ '--source-cert-file', fixture.sourceCert, '--source-key-file', fixture.sourceKey, '--target-dir', fixture.targetDir, '--service-user', options.serviceUser || `#${process.getuid?.() ?? 0}`, '--service-group', options.serviceGroup || `#${process.getgid?.() ?? 0}`, '--target-user', options.targetUser || `#${process.getuid?.() ?? 0}`, '--json', ...args, ]); } function runRaw(args) { return spawnSync('node', [SCRIPT, ...args], { cwd: process.cwd(), encoding: 'utf8', }); } function parseJson(text, label) { try { return JSON.parse(text); } catch (error) { failures.push(`${label} 不是合法 JSON: ${error.message}\n${text}`); return {}; } } function assertStatus(result, expected, reason) { if ((result.status ?? 0) !== expected) { failures.push( `${reason} 实际退出码 ${result.status}。\nstdout:\n${result.stdout}\nstderr:\n${result.stderr}`, ); } } function assertNonZero(result, reason) { if ((result.status ?? 0) === 0) { failures.push(`${reason}\nstdout:\n${result.stdout}\nstderr:\n${result.stderr}`); } } function assertIncludes(value, expected, reason) { if (!String(value).includes(expected)) { failures.push(`${reason} 缺少: ${expected}`); } } function assertEqual(actual, expected, reason) { if (actual !== expected) { failures.push(`${reason} 实际 ${JSON.stringify(actual)},预期 ${JSON.stringify(expected)}`); } } function assertFileContent(filePath, expected) { if (!existsSync(filePath)) { failures.push(`缺少文件: ${filePath}`); return; } const actual = readFileSync(filePath, 'utf8'); if (actual !== expected) { failures.push(`${filePath} 内容不符合预期。`); } } function assertMode(filePath, expected, reason) { const mode = statSync(filePath).mode & 0o777; if (mode !== expected) { failures.push(`${reason} 实际 0${mode.toString(8)},预期 0${expected.toString(8)}。`); } }