071faa482c
纳入 AGC Cargo workspace 的统一 rustfmt 检查与格式化入口 完成项目 TypeScript/Prettier 与 Rust 全量格式化 修复 Pingora expected executable 门禁的空白敏感误报 同步开发运维文档与 AGC skill pack 格式化忽略规则
299 lines
8.8 KiB
JavaScript
299 lines
8.8 KiB
JavaScript
#!/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)}。`,
|
|
);
|
|
}
|
|
}
|