#!/usr/bin/env node import { spawnSync } from 'node:child_process'; import { chmodSync, mkdtempSync, readFileSync, rmSync, statSync, symlinkSync, writeFileSync, } from 'node:fs'; import { tmpdir } from 'node:os'; import path from 'node:path'; const SWITCH_SCRIPT = 'scripts/deploy/pingora-gateway-env-shadow-switch.mjs'; const failures = []; const tmpRoot = mkdtempSync( path.join(tmpdir(), 'genarrative-pingora-gateway-env-shadow-switch-'), ); try { main(); } finally { rmSync(tmpRoot, { recursive: true, force: true }); } if (failures.length > 0) { console.error('[check:pingora-gateway-env-shadow-switch] FAILED'); for (const failure of failures) { console.error(`- ${failure}`); } process.exit(1); } console.log('[check:pingora-gateway-env-shadow-switch] OK'); function main() { assertScriptShape(); assertDryRunDoesNotModifyEnv(); assertApplyRestoresShadowAndPreservesOtherKeys(); assertApplyPreservesEnvFileMode(); assertMissingManagedKeysAreAppended(); assertDuplicateManagedKeysFail(); assertRejectsRelativeAndRootEnvFile(); assertRejectsSymlinkEnvFileBeforeWrite(); assertRejectsControlCharacterEnvFile(); } function assertScriptShape() { const content = readFileSync(SWITCH_SCRIPT, 'utf8'); assertIncludes(content, '--apply', '切换脚本必须显式要求 --apply 才写 env。'); assertIncludes( content, '当前是 dry-run', '切换脚本必须在 dry-run 中明确不会写 env。', ); assertIncludes( content, 'GENARRATIVE_PINGORA_GATEWAY_LISTEN: \'127.0.0.1:18081\'', '切换脚本必须固定恢复 Pingora shadow 高端口监听。', ); assertIncludes( content, 'GENARRATIVE_PINGORA_GATEWAY_TLS_LISTEN: \'\'', '切换脚本必须清空 TLS 低端口监听。', ); assertIncludes( content, 'GENARRATIVE_PINGORA_GATEWAY_HTTP_REDIRECT_LISTEN: \'\'', '切换脚本必须清空 HTTP redirect 低端口监听。', ); assertIncludes( content, 'GENARRATIVE_PINGORA_GATEWAY_TLS_CERT_FILE: \'\'', '切换脚本必须清空 TLS 证书链路径,避免无 TLS_LISTEN 但残留 cert。', ); assertIncludes( content, 'GENARRATIVE_PINGORA_GATEWAY_TLS_KEY_FILE: \'\'', '切换脚本必须清空 TLS 私钥路径,避免无 TLS_LISTEN 但残留 key。', ); assertIncludes( content, 'assertShadowEnv(checkFile);', '切换脚本必须先用临时目标 env 复核 shadow 口径。', ); assertIncludes( content, 'writeEnvFileAtomically(config.envFile, nextText);', '切换脚本通过复核后才可原子写入真实 env。', ); assertIncludes( content, '--env-file 不能是符号链接', 'apply 写入真实 env 前必须拒绝符号链接目标。', ); assertIncludes( content, 'DRY_RUN_ENV_FILE_MODE = 0o600', '临时复核 env 文件权限必须固定为 0600。', ); assertIncludes( content, 'chownSync(tempFile, currentStat.uid, currentStat.gid);', '真实 env 原子替换必须保留原文件 owner/group。', ); } function assertDryRunDoesNotModifyEnv() { const envFile = writeEnv('dry-run.env', { GENARRATIVE_PINGORA_GATEWAY_LISTEN: '0.0.0.0:443', GENARRATIVE_PINGORA_GATEWAY_TLS_LISTEN: '0.0.0.0:443', GENARRATIVE_PINGORA_GATEWAY_HTTP_REDIRECT_LISTEN: '0.0.0.0:80', GENARRATIVE_PINGORA_GATEWAY_TLS_CERT_FILE: '/etc/genarrative/pingora-tls/example/fullchain.pem', GENARRATIVE_PINGORA_GATEWAY_TLS_KEY_FILE: '/etc/genarrative/pingora-tls/example/privkey.pem', }); const before = readFileSync(envFile, 'utf8'); const result = runSwitch(['--env-file', envFile]); assertStatus(result, 0, 'dry-run 应成功。'); assertEqual( readFileSync(envFile, 'utf8'), before, 'dry-run 不应修改真实 Pingora gateway env。', ); assertIncludes( result.stdout, '当前是 dry-run', 'dry-run 输出必须明确不会写入 env。', ); } function assertApplyRestoresShadowAndPreservesOtherKeys() { const envFile = writeEnv('apply.env', { GENARRATIVE_PINGORA_GATEWAY_UPSTREAM_API: 'http://127.0.0.1:8082', GENARRATIVE_PINGORA_GATEWAY_LISTEN: '0.0.0.0:443', GENARRATIVE_PINGORA_GATEWAY_TLS_LISTEN: '0.0.0.0:443', GENARRATIVE_PINGORA_GATEWAY_HTTP_REDIRECT_LISTEN: '0.0.0.0:80', GENARRATIVE_PINGORA_GATEWAY_TLS_CERT_FILE: '/etc/genarrative/pingora-tls/example/fullchain.pem', GENARRATIVE_PINGORA_GATEWAY_TLS_KEY_FILE: '/etc/genarrative/pingora-tls/example/privkey.pem', GENARRATIVE_PINGORA_GATEWAY_PROTECTION_ENABLED: 'true', }); const result = runSwitch(['--env-file', envFile, '--apply']); const content = readFileSync(envFile, 'utf8'); assertStatus(result, 0, 'apply 应成功。'); assertIncludes( content, 'GENARRATIVE_PINGORA_GATEWAY_LISTEN=127.0.0.1:18081', 'apply 必须恢复 shadow 高端口监听。', ); assertIncludes( content, 'GENARRATIVE_PINGORA_GATEWAY_TLS_LISTEN=', 'apply 必须清空 TLS 低端口监听。', ); assertIncludes( content, 'GENARRATIVE_PINGORA_GATEWAY_HTTP_REDIRECT_LISTEN=', 'apply 必须清空 HTTP redirect 低端口监听。', ); assertIncludes( content, 'GENARRATIVE_PINGORA_GATEWAY_TLS_CERT_FILE=', 'apply 必须清空 TLS 证书链路径。', ); assertIncludes( content, 'GENARRATIVE_PINGORA_GATEWAY_TLS_KEY_FILE=', 'apply 必须清空 TLS 私钥路径。', ); assertIncludes( content, 'GENARRATIVE_PINGORA_GATEWAY_UPSTREAM_API=http://127.0.0.1:8082', 'apply 不应修改其它 Pingora gateway env。', ); assertIncludes( content, 'GENARRATIVE_PINGORA_GATEWAY_PROTECTION_ENABLED=true', 'apply 不应修改接流保护配置。', ); } function assertApplyPreservesEnvFileMode() { const envFile = writeEnv('mode.env', { GENARRATIVE_PINGORA_GATEWAY_LISTEN: '0.0.0.0:443', GENARRATIVE_PINGORA_GATEWAY_TLS_LISTEN: '0.0.0.0:443', GENARRATIVE_PINGORA_GATEWAY_HTTP_REDIRECT_LISTEN: '0.0.0.0:80', GENARRATIVE_PINGORA_GATEWAY_TLS_CERT_FILE: '/etc/genarrative/pingora-tls/example/fullchain.pem', GENARRATIVE_PINGORA_GATEWAY_TLS_KEY_FILE: '/etc/genarrative/pingora-tls/example/privkey.pem', }); chmodSync(envFile, 0o640); const before = statSync(envFile); const result = runSwitch(['--env-file', envFile, '--apply']); const after = statSync(envFile); assertStatus(result, 0, 'apply 保留权限 smoke 应成功。'); assertEqual( after.mode & 0o777, before.mode & 0o777, 'apply 原子替换必须保留 env 文件权限。', ); } function assertMissingManagedKeysAreAppended() { const envFile = writeEnv('append.env', { GENARRATIVE_PINGORA_GATEWAY_UPSTREAM_API: 'http://127.0.0.1:8082', }); const result = runSwitch(['--env-file', envFile, '--apply']); const content = readFileSync(envFile, 'utf8'); assertStatus(result, 0, '缺失目标键时 apply 应成功。'); assertIncludes( content, 'GENARRATIVE_PINGORA_GATEWAY_LISTEN=127.0.0.1:18081', '缺失 LISTEN 时必须追加 shadow 默认值。', ); assertIncludes( content, 'GENARRATIVE_PINGORA_GATEWAY_TLS_LISTEN=', '缺失 TLS_LISTEN 时必须追加空值。', ); assertIncludes( content, 'GENARRATIVE_PINGORA_GATEWAY_HTTP_REDIRECT_LISTEN=', '缺失 HTTP_REDIRECT_LISTEN 时必须追加空值。', ); assertIncludes( content, 'GENARRATIVE_PINGORA_GATEWAY_TLS_CERT_FILE=', '缺失 TLS_CERT_FILE 时必须追加空值。', ); assertIncludes( content, 'GENARRATIVE_PINGORA_GATEWAY_TLS_KEY_FILE=', '缺失 TLS_KEY_FILE 时必须追加空值。', ); } function assertDuplicateManagedKeysFail() { const envFile = path.join(tmpRoot, 'duplicate.env'); writeFileSync( envFile, [ 'GENARRATIVE_PINGORA_GATEWAY_LISTEN=0.0.0.0:443', 'GENARRATIVE_PINGORA_GATEWAY_LISTEN=127.0.0.1:18081', '', ].join('\n'), 'utf8', ); const result = runSwitch(['--env-file', envFile]); assertStatus(result, 1, '重复目标键必须失败。'); assertIncludes( result.stderr, 'pingora gateway env 中存在重复配置', '重复目标键失败时必须说明具体原因。', ); } function assertRejectsRelativeAndRootEnvFile() { const relative = runSwitch(['--env-file', 'relative.env']); assertStatus(relative, 1, '相对 env 路径必须失败。'); assertIncludes( relative.stderr, '--env-file 必须是绝对路径', '相对 env 路径失败时必须说明原因。', ); const root = runSwitch(['--env-file', '/']); assertStatus(root, 1, '文件系统根目录 env 路径必须失败。'); assertIncludes( root.stderr, '--env-file 不能是文件系统根目录', '根目录 env 路径失败时必须说明原因。', ); } function assertRejectsSymlinkEnvFileBeforeWrite() { const target = writeEnv('symlink-target.env', { GENARRATIVE_PINGORA_GATEWAY_LISTEN: '0.0.0.0:443', }); const link = path.join(tmpRoot, 'symlink.env'); symlinkSync(target, link); const before = readFileSync(target, 'utf8'); const result = runSwitch(['--env-file', link, '--apply']); assertStatus(result, 1, '符号链接 env 必须失败。'); assertIncludes( result.stderr, '--env-file 不能是符号链接', '符号链接 env 失败时必须说明原因。', ); assertEqual( readFileSync(target, 'utf8'), before, '符号链接 env 被拒绝后不应写真实目标文件。', ); } function assertRejectsControlCharacterEnvFile() { const result = spawnSync( process.execPath, ['--', SWITCH_SCRIPT, '--env-file', `${tmpRoot}/bad\n.env`], { cwd: process.cwd(), encoding: 'utf8', }, ); assertStatus(result, 1, '控制字符 env 路径必须失败。'); assertIncludes( result.stderr, '--env-file 不能包含换行或 NUL 字符', '控制字符 env 路径失败时必须说明原因。', ); } function runSwitch(args) { return spawnSync(process.execPath, ['--', SWITCH_SCRIPT, ...args], { cwd: process.cwd(), encoding: 'utf8', }); } function writeEnv(fileName, values) { const filePath = path.join(tmpRoot, fileName); const lines = Object.entries(values).map(([key, value]) => `${key}=${value}`); writeFileSync(filePath, `${lines.join('\n')}\n`, 'utf8'); return filePath; } function assertStatus(result, expected, message) { if ((result.status ?? 0) !== expected) { failures.push( `${message} 实际退出码 ${result.status}。stdout=${result.stdout || ''} stderr=${result.stderr || ''}`, ); } } function assertIncludes(value, expected, message) { if (!String(value || '').includes(expected)) { failures.push(`${message} 缺少 ${expected}。`); } } function assertEqual(actual, expected, message) { if (actual !== expected) { failures.push(`${message} 实际 ${actual},预期 ${expected}。`); } }