4bca480f3c
新增 Pingora 直连彩排状态脚本,核验 health patrol、端口归属、systemd、realpath canary 和 current release 自审 将彩排状态脚本纳入 API release 构建、部署、Jenkins 归档和生产运维护栏 补充 release readiness、current release audit、cutover snapshot 与发布部署检查对彩排脚本的覆盖 更新 Pingora 运维文档和共享记忆,记录 dev health patrol 真实配置漂移与修复方式 在 dev 服务器真实部署验证 Nginx 仍接 80/443、Pingora shadow 与 realpath canary 高端口正常、health patrol 和彩排状态均为 OK
967 lines
28 KiB
JavaScript
967 lines
28 KiB
JavaScript
#!/usr/bin/env node
|
||
|
||
import { execFile } from 'node:child_process';
|
||
import { constants as fsConstants } from 'node:fs';
|
||
import { access, readFile, stat } from 'node:fs/promises';
|
||
import path from 'node:path';
|
||
|
||
const STATUS_RANK = {
|
||
OK: 0,
|
||
WARNING: 1,
|
||
CRITICAL: 2,
|
||
};
|
||
|
||
const PUBLIC_GATEWAYS = new Set(['none', 'nginx', 'pingora-direct']);
|
||
const GATEWAY_MODES = new Set(['nginx', 'pingora-direct']);
|
||
const DEFAULT_PORTS = [80, 443, 18081, 18083];
|
||
const SECRET_ENV_KEY_PATTERN =
|
||
/(?:TOKEN|SECRET|PASSWORD|PRIVATE_KEY|ACCESS_KEY|API_KEY|AUTH|CREDENTIAL)/iu;
|
||
|
||
const config = parseArgs(process.argv.slice(2));
|
||
const status = await buildStatus(config);
|
||
|
||
console.log(`${JSON.stringify(status, null, 2)}\n`);
|
||
|
||
if (config.failOnCritical && status.summary.status === 'CRITICAL') {
|
||
process.exit(1);
|
||
}
|
||
|
||
function parseArgs(argv) {
|
||
const result = {
|
||
releaseRoot:
|
||
process.env.GENARRATIVE_PINGORA_REHEARSAL_RELEASE_ROOT ||
|
||
'/opt/genarrative/current',
|
||
healthPatrolEnvFile:
|
||
process.env.GENARRATIVE_HEALTH_PATROL_ENV_FILE ||
|
||
'/etc/genarrative/health-patrol.env',
|
||
pingoraEnvFile:
|
||
process.env.GENARRATIVE_PINGORA_DIRECT_PREFLIGHT_ENV_FILE ||
|
||
'/etc/genarrative/pingora-gateway.env',
|
||
realpathConfigFile:
|
||
process.env.GENARRATIVE_PINGORA_REALPATH_CANARY_CONFIG_FILE ||
|
||
'/etc/nginx/conf.d/zz-genarrative-pingora-realpath-canary.conf',
|
||
expectedPublicGateway:
|
||
process.env.GENARRATIVE_PINGORA_REHEARSAL_EXPECT_PUBLIC_GATEWAY ||
|
||
'none',
|
||
expectedHealthPatrolGatewayMode:
|
||
process.env.GENARRATIVE_HEALTH_PATROL_EXPECTED_GATEWAY_MODE || '',
|
||
requireRealpathCanary: readBoolEnv(
|
||
'GENARRATIVE_PINGORA_REHEARSAL_REQUIRE_REALPATH_CANARY',
|
||
),
|
||
requirePingoraShadow: readBoolEnv(
|
||
'GENARRATIVE_PINGORA_REHEARSAL_REQUIRE_PINGORA_SHADOW',
|
||
),
|
||
requireCurrentReleaseGateway: readBoolEnv(
|
||
'GENARRATIVE_PINGORA_REHEARSAL_REQUIRE_CURRENT_RELEASE_GATEWAY',
|
||
),
|
||
timeoutMs: parseOptionalPositiveInt(
|
||
process.env.GENARRATIVE_PINGORA_REHEARSAL_TIMEOUT_MS,
|
||
5000,
|
||
'GENARRATIVE_PINGORA_REHEARSAL_TIMEOUT_MS',
|
||
),
|
||
failOnCritical: readBoolEnv(
|
||
'GENARRATIVE_PINGORA_REHEARSAL_FAIL_ON_CRITICAL',
|
||
),
|
||
};
|
||
|
||
for (let index = 0; index < argv.length; index += 1) {
|
||
const arg = argv[index];
|
||
switch (arg) {
|
||
case '-h':
|
||
case '--help':
|
||
printUsage();
|
||
process.exit(0);
|
||
break;
|
||
case '--release-root':
|
||
result.releaseRoot = requireValue(argv, ++index, arg);
|
||
break;
|
||
case '--health-patrol-env-file':
|
||
result.healthPatrolEnvFile = requireValue(argv, ++index, arg);
|
||
break;
|
||
case '--pingora-env-file':
|
||
result.pingoraEnvFile = requireValue(argv, ++index, arg);
|
||
break;
|
||
case '--realpath-config-file':
|
||
result.realpathConfigFile = requireValue(argv, ++index, arg);
|
||
break;
|
||
case '--expect-public-gateway':
|
||
result.expectedPublicGateway = requireValue(argv, ++index, arg);
|
||
break;
|
||
case '--expected-health-patrol-gateway-mode':
|
||
result.expectedHealthPatrolGatewayMode = requireValue(
|
||
argv,
|
||
++index,
|
||
arg,
|
||
);
|
||
break;
|
||
case '--require-realpath-canary':
|
||
result.requireRealpathCanary = true;
|
||
break;
|
||
case '--require-pingora-shadow':
|
||
result.requirePingoraShadow = true;
|
||
break;
|
||
case '--require-current-release-gateway':
|
||
result.requireCurrentReleaseGateway = true;
|
||
break;
|
||
case '--timeout-ms':
|
||
result.timeoutMs = parseRequiredPositiveInt(
|
||
requireValue(argv, ++index, arg),
|
||
arg,
|
||
);
|
||
break;
|
||
case '--fail-on-critical':
|
||
result.failOnCritical = true;
|
||
break;
|
||
default:
|
||
throw new Error(`未知参数: ${arg}`);
|
||
}
|
||
}
|
||
|
||
validateConfig(result);
|
||
return result;
|
||
}
|
||
|
||
function printUsage() {
|
||
console.log(`Usage:
|
||
node scripts/ops/pingora-direct-rehearsal-status.mjs [options]
|
||
|
||
Options:
|
||
--release-root <path> current release 根目录,默认 /opt/genarrative/current。
|
||
--health-patrol-env-file <path> health-patrol env 文件,默认 /etc/genarrative/health-patrol.env。
|
||
--pingora-env-file <path> pingora-gateway env 文件,默认 /etc/genarrative/pingora-gateway.env。
|
||
--realpath-config-file <path> realpath canary Nginx 配置,默认 /etc/nginx/conf.d/zz-genarrative-pingora-realpath-canary.conf。
|
||
--expect-public-gateway <mode> 可选,none / nginx / pingora-direct;nginx 模式会要求 80/443 仍由 Nginx 接流。
|
||
--expected-health-patrol-gateway-mode <mode>
|
||
可选,nginx 或 pingora-direct;不传时随 --expect-public-gateway 推导。
|
||
--require-realpath-canary 要求 realpath canary 配置存在,且 127.0.0.1:18083 由 Nginx 监听。
|
||
--require-pingora-shadow 要求 Pingora shadow 127.0.0.1:18081 正在监听。
|
||
--require-current-release-gateway 要求 current release 自审确认 pingora-gateway、checksum 和 manifest。
|
||
--timeout-ms <ms> systemctl / ss / 子检查超时,默认 5000。
|
||
--fail-on-critical 如果状态中出现 CRITICAL,则以退出码 1 结束。
|
||
|
||
该脚本只读采集 dev / release 上的 Pingora 直连切换彩排状态,不写 /etc、不 reload systemd、不修改 Nginx 或 Pingora。
|
||
`);
|
||
}
|
||
|
||
function validateConfig(input) {
|
||
for (const [label, value] of [
|
||
['--release-root', input.releaseRoot],
|
||
['--health-patrol-env-file', input.healthPatrolEnvFile],
|
||
['--pingora-env-file', input.pingoraEnvFile],
|
||
['--realpath-config-file', input.realpathConfigFile],
|
||
]) {
|
||
validateSafeAbsoluteFilePath(value, label);
|
||
}
|
||
|
||
if (!PUBLIC_GATEWAYS.has(input.expectedPublicGateway)) {
|
||
throw new Error(
|
||
`--expect-public-gateway 只支持 none / nginx / pingora-direct: ${input.expectedPublicGateway}`,
|
||
);
|
||
}
|
||
if (
|
||
input.expectedHealthPatrolGatewayMode &&
|
||
!GATEWAY_MODES.has(input.expectedHealthPatrolGatewayMode)
|
||
) {
|
||
throw new Error(
|
||
`--expected-health-patrol-gateway-mode 只支持 nginx 或 pingora-direct: ${input.expectedHealthPatrolGatewayMode}`,
|
||
);
|
||
}
|
||
}
|
||
|
||
function requireValue(argv, index, flag) {
|
||
const value = argv[index];
|
||
if (value === undefined || value.startsWith('--')) {
|
||
throw new Error(`${flag} 缺少参数值`);
|
||
}
|
||
return value;
|
||
}
|
||
|
||
function readBoolEnv(name) {
|
||
const value = process.env[name];
|
||
if (value === undefined || value === null || String(value).trim() === '') {
|
||
return false;
|
||
}
|
||
const normalized = String(value).trim().toLowerCase();
|
||
if (['1', 'true', 'yes', 'on'].includes(normalized)) {
|
||
return true;
|
||
}
|
||
if (['0', 'false', 'no', 'off'].includes(normalized)) {
|
||
return false;
|
||
}
|
||
throw new Error(`${name} 必须是布尔值 true/false 或 1/0。`);
|
||
}
|
||
|
||
function parseOptionalPositiveInt(raw, fallback, label) {
|
||
if (raw === undefined || raw === null || String(raw).trim() === '') {
|
||
return fallback;
|
||
}
|
||
return parseRequiredPositiveInt(raw, label);
|
||
}
|
||
|
||
function parseRequiredPositiveInt(raw, label) {
|
||
const text = String(raw ?? '').trim();
|
||
if (!/^[1-9][0-9]*$/u.test(text)) {
|
||
throw new Error(`${label} 必须是正整数。`);
|
||
}
|
||
return Number.parseInt(text, 10);
|
||
}
|
||
|
||
function validateSafeAbsoluteFilePath(value, label) {
|
||
validateNoControlCharacters(value, label);
|
||
if (!path.isAbsolute(value)) {
|
||
throw new Error(`${label} 必须是绝对路径。`);
|
||
}
|
||
if (isFilesystemRootPath(value)) {
|
||
throw new Error(`${label} 不能是文件系统根目录。`);
|
||
}
|
||
}
|
||
|
||
function validateNoControlCharacters(value, label) {
|
||
if (/[\0\r\n]/u.test(String(value))) {
|
||
throw new Error(`${label} 不能包含换行或 NUL 字符。`);
|
||
}
|
||
}
|
||
|
||
function isFilesystemRootPath(value) {
|
||
const resolved = path.resolve(String(value));
|
||
return resolved === path.parse(resolved).root;
|
||
}
|
||
|
||
async function buildStatus(input) {
|
||
const healthPatrolEnv = await inspectHealthPatrolEnv(input);
|
||
const pingoraEnv = await inspectPingoraEnv(input);
|
||
const secrets = collectSecretValues(healthPatrolEnv, pingoraEnv);
|
||
delete healthPatrolEnv.secretValues;
|
||
delete pingoraEnv.secretValues;
|
||
const services = await inspectServices(input);
|
||
const systemd = await inspectPingoraSystemd(input);
|
||
const ports = await inspectPorts(input);
|
||
const realpathCanary = await inspectRealpathCanary(input, ports);
|
||
const currentReleaseAudit = await inspectCurrentRelease(input, secrets);
|
||
const publicBoundary = inspectPublicBoundary(input, ports, systemd);
|
||
const checks = [
|
||
healthPatrolEnv.status,
|
||
pingoraEnv.status,
|
||
services.status,
|
||
systemd.status,
|
||
ports.status,
|
||
realpathCanary.status,
|
||
currentReleaseAudit.status,
|
||
publicBoundary.status,
|
||
];
|
||
|
||
return {
|
||
schemaVersion: 1,
|
||
generatedAt: new Date().toISOString(),
|
||
summary: summarize(checks),
|
||
releaseRoot: input.releaseRoot,
|
||
expectations: {
|
||
publicGateway: input.expectedPublicGateway,
|
||
healthPatrolGatewayMode: expectedHealthPatrolGatewayMode(input),
|
||
requireRealpathCanary: input.requireRealpathCanary,
|
||
requirePingoraShadow: input.requirePingoraShadow,
|
||
requireCurrentReleaseGateway: input.requireCurrentReleaseGateway,
|
||
},
|
||
publicBoundary,
|
||
ports,
|
||
services,
|
||
systemd,
|
||
healthPatrolEnv,
|
||
pingoraEnv,
|
||
realpathCanary,
|
||
currentReleaseAudit,
|
||
};
|
||
}
|
||
|
||
async function inspectHealthPatrolEnv(input) {
|
||
const parsed = await readEnvFile(input.healthPatrolEnvFile);
|
||
if (parsed.status === 'CRITICAL') {
|
||
return parsed;
|
||
}
|
||
|
||
const values = parsed.values;
|
||
const expectedMode = expectedHealthPatrolGatewayMode(input);
|
||
const gatewayMode = values.GENARRATIVE_HEALTH_PATROL_GATEWAY_MODE || '';
|
||
const diagnostics = [...parsed.diagnostics];
|
||
let status = parsed.status;
|
||
|
||
if (expectedMode && gatewayMode !== expectedMode) {
|
||
diagnostics.push(
|
||
`GENARRATIVE_HEALTH_PATROL_GATEWAY_MODE 应为 ${expectedMode},实际 ${gatewayMode || '(空)'}`,
|
||
);
|
||
status = maxStatus(status, 'CRITICAL');
|
||
}
|
||
|
||
return {
|
||
path: input.healthPatrolEnvFile,
|
||
status,
|
||
secretValues: collectSecretValuesFromEnv(values),
|
||
values: {
|
||
gatewayMode,
|
||
publicBaseUrl:
|
||
values.GENARRATIVE_HEALTH_PATROL_PUBLIC_BASE_URL || '',
|
||
publicHost: values.GENARRATIVE_HEALTH_PATROL_PUBLIC_HOST || '',
|
||
apiBaseUrl: values.GENARRATIVE_HEALTH_PATROL_API_BASE_URL || '',
|
||
pingoraBaseUrl:
|
||
values.GENARRATIVE_HEALTH_PATROL_PINGORA_BASE_URL || '',
|
||
hasPingoraProbeToken: Boolean(
|
||
values.GENARRATIVE_HEALTH_PATROL_PINGORA_PROBE_TOKEN ||
|
||
values.GENARRATIVE_PINGORA_GATEWAY_PROBE_TOKEN,
|
||
),
|
||
},
|
||
diagnostics,
|
||
};
|
||
}
|
||
|
||
function expectedHealthPatrolGatewayMode(input) {
|
||
if (input.expectedHealthPatrolGatewayMode) {
|
||
return input.expectedHealthPatrolGatewayMode;
|
||
}
|
||
if (input.expectedPublicGateway === 'nginx') {
|
||
return 'nginx';
|
||
}
|
||
if (input.expectedPublicGateway === 'pingora-direct') {
|
||
return 'pingora-direct';
|
||
}
|
||
return '';
|
||
}
|
||
|
||
async function inspectPingoraEnv(input) {
|
||
const parsed = await readEnvFile(input.pingoraEnvFile);
|
||
if (parsed.status === 'CRITICAL') {
|
||
return {
|
||
...parsed,
|
||
values: {},
|
||
};
|
||
}
|
||
const values = parsed.values;
|
||
return {
|
||
path: input.pingoraEnvFile,
|
||
status: parsed.status,
|
||
secretValues: collectSecretValuesFromEnv(values),
|
||
values: {
|
||
listen: values.GENARRATIVE_PINGORA_GATEWAY_LISTEN || '',
|
||
tlsListen: values.GENARRATIVE_PINGORA_GATEWAY_TLS_LISTEN || '',
|
||
httpRedirectListen:
|
||
values.GENARRATIVE_PINGORA_GATEWAY_HTTP_REDIRECT_LISTEN || '',
|
||
tlsCertFile: values.GENARRATIVE_PINGORA_GATEWAY_TLS_CERT_FILE || '',
|
||
hasTlsKeyFile: Boolean(
|
||
values.GENARRATIVE_PINGORA_GATEWAY_TLS_KEY_FILE,
|
||
),
|
||
hasProbeToken: Boolean(values.GENARRATIVE_PINGORA_GATEWAY_PROBE_TOKEN),
|
||
accessLogFile:
|
||
values.GENARRATIVE_PINGORA_GATEWAY_ACCESS_LOG_FILE || '',
|
||
compressionAlgorithms:
|
||
values.GENARRATIVE_PINGORA_GATEWAY_COMPRESSION_ALGORITHMS || '',
|
||
trustXForwardedFor:
|
||
values.GENARRATIVE_PINGORA_GATEWAY_TRUST_X_FORWARDED_FOR || '',
|
||
},
|
||
diagnostics: parsed.diagnostics,
|
||
};
|
||
}
|
||
|
||
async function readEnvFile(filePath) {
|
||
let text;
|
||
try {
|
||
text = await readFile(filePath, 'utf8');
|
||
} catch (error) {
|
||
return {
|
||
path: filePath,
|
||
status: 'CRITICAL',
|
||
values: {},
|
||
diagnostics: [`无法读取 env 文件: ${error.message}`],
|
||
};
|
||
}
|
||
|
||
const values = {};
|
||
const diagnostics = [];
|
||
for (const [index, rawLine] of text.split(/\r?\n/u).entries()) {
|
||
let line = rawLine.trim();
|
||
if (!line || line.startsWith('#')) {
|
||
continue;
|
||
}
|
||
if (line.startsWith('export ')) {
|
||
line = line.slice('export '.length).trim();
|
||
}
|
||
const equalsIndex = line.indexOf('=');
|
||
if (equalsIndex <= 0) {
|
||
diagnostics.push(`第 ${index + 1} 行不是 KEY=VALUE 格式`);
|
||
continue;
|
||
}
|
||
const key = line.slice(0, equalsIndex).trim();
|
||
values[key] = stripQuotes(line.slice(equalsIndex + 1).trim());
|
||
}
|
||
|
||
return {
|
||
path: filePath,
|
||
status: diagnostics.length > 0 ? 'WARNING' : 'OK',
|
||
values,
|
||
diagnostics,
|
||
};
|
||
}
|
||
|
||
function stripQuotes(value) {
|
||
if (value.length >= 2) {
|
||
const first = value[0];
|
||
const last = value[value.length - 1];
|
||
if ((first === '"' && last === '"') || (first === "'" && last === "'")) {
|
||
return value.slice(1, -1);
|
||
}
|
||
}
|
||
return value;
|
||
}
|
||
|
||
function collectSecretValuesFromEnv(values) {
|
||
const secrets = [];
|
||
for (const [key, value] of Object.entries(values || {})) {
|
||
if (!SECRET_ENV_KEY_PATTERN.test(key) || typeof value !== 'string') {
|
||
continue;
|
||
}
|
||
const trimmed = value.trim();
|
||
if (trimmed.length > 0) {
|
||
secrets.push(trimmed);
|
||
}
|
||
}
|
||
return secrets;
|
||
}
|
||
|
||
function collectSecretValues(...envSnapshots) {
|
||
const secrets = new Set();
|
||
for (const snapshot of envSnapshots) {
|
||
for (const value of snapshot?.secretValues || []) {
|
||
if (typeof value === 'string' && value.trim()) {
|
||
secrets.add(value.trim());
|
||
}
|
||
}
|
||
}
|
||
return [...secrets].sort((left, right) => right.length - left.length);
|
||
}
|
||
|
||
async function inspectServices(input) {
|
||
const serviceNames = [
|
||
'nginx.service',
|
||
'genarrative-pingora-gateway.service',
|
||
'genarrative-api.service',
|
||
'spacetimedb.service',
|
||
];
|
||
const services = [];
|
||
for (const service of serviceNames) {
|
||
services.push(await inspectService(service, input));
|
||
}
|
||
return {
|
||
status: summarize(services.map((service) => service.status)).status,
|
||
services,
|
||
};
|
||
}
|
||
|
||
async function inspectService(service, input) {
|
||
const result = await runCommand('systemctl', ['is-active', service], input);
|
||
const state = result.stdout.trim() || result.stderr.trim() || result.error;
|
||
let status = 'OK';
|
||
if (service === 'nginx.service' && input.expectedPublicGateway === 'nginx') {
|
||
status = result.code === 0 && state === 'active' ? 'OK' : 'CRITICAL';
|
||
} else if (
|
||
service === 'genarrative-pingora-gateway.service' &&
|
||
(input.requirePingoraShadow ||
|
||
input.expectedPublicGateway === 'pingora-direct')
|
||
) {
|
||
status = result.code === 0 && state === 'active' ? 'OK' : 'CRITICAL';
|
||
} else if (
|
||
(service === 'genarrative-api.service' ||
|
||
service === 'spacetimedb.service') &&
|
||
result.code !== 0
|
||
) {
|
||
status = 'WARNING';
|
||
}
|
||
return {
|
||
name: service,
|
||
activeState: state || 'unknown',
|
||
status,
|
||
command: result.command,
|
||
};
|
||
}
|
||
|
||
async function inspectPingoraSystemd(input) {
|
||
const catResult = await runCommand(
|
||
'systemctl',
|
||
['cat', 'genarrative-pingora-gateway.service'],
|
||
input,
|
||
);
|
||
const showResult = await runCommand(
|
||
'systemctl',
|
||
[
|
||
'show',
|
||
'genarrative-pingora-gateway.service',
|
||
'--property=FragmentPath',
|
||
'--property=DropInPaths',
|
||
'--property=User',
|
||
'--property=ExecStart',
|
||
'--no-pager',
|
||
],
|
||
input,
|
||
);
|
||
const unitText = catResult.stdout;
|
||
const hasAmbientCapability = unitText.includes(
|
||
'AmbientCapabilities=CAP_NET_BIND_SERVICE',
|
||
);
|
||
const hasCapabilityBoundingSet = unitText.includes(
|
||
'CapabilityBoundingSet=CAP_NET_BIND_SERVICE',
|
||
);
|
||
const environmentFiles = [
|
||
...unitText.matchAll(/^\s*EnvironmentFile=(.+)$/gmu),
|
||
].map((match) => match[1].trim());
|
||
const environmentFileMatchesPingoraEnvFile = environmentFilesInclude(
|
||
environmentFiles,
|
||
input.pingoraEnvFile,
|
||
);
|
||
const show = parseSystemctlShow(showResult.stdout);
|
||
const diagnostics = [];
|
||
let status = 'OK';
|
||
|
||
if (catResult.code !== 0) {
|
||
diagnostics.push(`systemctl cat 失败: ${catResult.stderr || catResult.error}`);
|
||
status = 'CRITICAL';
|
||
}
|
||
if (showResult.code !== 0) {
|
||
diagnostics.push(
|
||
`systemctl show 失败: ${showResult.stderr || showResult.error}`,
|
||
);
|
||
status = 'CRITICAL';
|
||
}
|
||
if (catResult.code === 0 && !environmentFileMatchesPingoraEnvFile) {
|
||
diagnostics.push(
|
||
`systemctl cat genarrative-pingora-gateway.service EnvironmentFile 未包含本次 --pingora-env-file: ${input.pingoraEnvFile}`,
|
||
);
|
||
status = 'CRITICAL';
|
||
}
|
||
if (
|
||
input.expectedPublicGateway === 'nginx' &&
|
||
(hasAmbientCapability || hasCapabilityBoundingSet)
|
||
) {
|
||
diagnostics.push(
|
||
'Nginx 接公网阶段不应残留 Pingora 低端口 CAP_NET_BIND_SERVICE。',
|
||
);
|
||
status = 'CRITICAL';
|
||
}
|
||
if (
|
||
input.expectedPublicGateway === 'pingora-direct' &&
|
||
(!hasAmbientCapability || !hasCapabilityBoundingSet)
|
||
) {
|
||
diagnostics.push(
|
||
'pingora-direct 接公网阶段必须在 systemd 最终配置中包含 CAP_NET_BIND_SERVICE。',
|
||
);
|
||
status = 'CRITICAL';
|
||
}
|
||
|
||
return {
|
||
status,
|
||
hasAmbientCapability,
|
||
hasCapabilityBoundingSet,
|
||
environmentFiles,
|
||
environmentFileMatchesPingoraEnvFile,
|
||
fragmentPath: show.FragmentPath || '',
|
||
dropInPaths: show.DropInPaths || '',
|
||
user: show.User || '',
|
||
execStart: show.ExecStart || '',
|
||
diagnostics,
|
||
commands: [catResult.command, showResult.command],
|
||
};
|
||
}
|
||
|
||
function environmentFilesInclude(environmentFiles, expectedPath) {
|
||
return environmentFiles.some((entry) =>
|
||
splitSystemdEnvironmentFileEntry(entry).includes(expectedPath),
|
||
);
|
||
}
|
||
|
||
function splitSystemdEnvironmentFileEntry(entry) {
|
||
const files = [];
|
||
for (const word of String(entry || '').split(/\s+/u)) {
|
||
if (!word) {
|
||
continue;
|
||
}
|
||
let normalized = word.trim();
|
||
if (normalized.startsWith('-')) {
|
||
normalized = normalized.slice(1);
|
||
}
|
||
if (
|
||
(normalized.startsWith('"') && normalized.endsWith('"')) ||
|
||
(normalized.startsWith("'") && normalized.endsWith("'"))
|
||
) {
|
||
normalized = normalized.slice(1, -1);
|
||
}
|
||
if (normalized) {
|
||
files.push(normalized);
|
||
}
|
||
}
|
||
return files;
|
||
}
|
||
|
||
function parseSystemctlShow(text) {
|
||
const result = {};
|
||
for (const line of text.split(/\r?\n/u)) {
|
||
const equalsIndex = line.indexOf('=');
|
||
if (equalsIndex <= 0) {
|
||
continue;
|
||
}
|
||
result[line.slice(0, equalsIndex)] = line.slice(equalsIndex + 1);
|
||
}
|
||
return result;
|
||
}
|
||
|
||
async function inspectPorts(input) {
|
||
const result = await runCommand('ss', ['-H', '-ltnp'], input);
|
||
const listeners = result.code === 0 ? parseSsOutput(result.stdout) : [];
|
||
const byPort = {};
|
||
const statuses = [];
|
||
for (const port of DEFAULT_PORTS) {
|
||
const portListeners = listeners.filter((item) => item.port === port);
|
||
const entry = inspectPort(port, portListeners, input);
|
||
byPort[String(port)] = entry;
|
||
statuses.push(entry.status);
|
||
}
|
||
if (result.code !== 0) {
|
||
statuses.push('CRITICAL');
|
||
}
|
||
return {
|
||
status: summarize(statuses).status,
|
||
command: result.command,
|
||
error: result.code === 0 ? '' : trimForJson(result.stderr || result.error),
|
||
byPort,
|
||
};
|
||
}
|
||
|
||
function parseSsOutput(stdout) {
|
||
const listeners = [];
|
||
for (const line of String(stdout || '').split(/\r?\n/u)) {
|
||
const trimmed = line.trim();
|
||
if (!trimmed) {
|
||
continue;
|
||
}
|
||
const parts = trimmed.split(/\s+/u);
|
||
const local = parts[3] || '';
|
||
const port = parsePortFromLocalAddress(local);
|
||
if (!port) {
|
||
continue;
|
||
}
|
||
const processText = parts.slice(5).join(' ');
|
||
const processNames = [
|
||
...new Set([...processText.matchAll(/"([^"]+)"/gu)].map((match) => match[1])),
|
||
];
|
||
listeners.push({
|
||
localAddress: local,
|
||
port,
|
||
loopback: isLoopbackLocalAddress(local),
|
||
processNames,
|
||
});
|
||
}
|
||
return listeners;
|
||
}
|
||
|
||
function parsePortFromLocalAddress(value) {
|
||
const bracketMatch = String(value).match(/^\[[^\]]+\]:(\d+)$/u);
|
||
const plainMatch = String(value).match(/:(\d+)$/u);
|
||
const portText = bracketMatch?.[1] || plainMatch?.[1] || '';
|
||
const port = Number.parseInt(portText, 10);
|
||
return Number.isInteger(port) && port > 0 ? port : null;
|
||
}
|
||
|
||
function isLoopbackLocalAddress(value) {
|
||
const text = String(value || '').toLowerCase();
|
||
return (
|
||
text.startsWith('127.') ||
|
||
text.startsWith('[::1]') ||
|
||
text.startsWith('::1') ||
|
||
text.startsWith('localhost:')
|
||
);
|
||
}
|
||
|
||
function inspectPort(port, listeners, input) {
|
||
const hasNginx = listeners.some((listener) =>
|
||
listener.processNames.some((name) => name.toLowerCase().includes('nginx')),
|
||
);
|
||
const hasPingora = listeners.some((listener) =>
|
||
listener.processNames.some((name) => name.toLowerCase().includes('pingora')),
|
||
);
|
||
const hasUnknownProcess =
|
||
listeners.length > 0 &&
|
||
listeners.some((listener) => listener.processNames.length === 0);
|
||
const loopbackOnly =
|
||
listeners.length > 0 && listeners.every((listener) => listener.loopback);
|
||
const diagnostics = [];
|
||
let status = 'OK';
|
||
|
||
if ([80, 443].includes(port)) {
|
||
if (input.expectedPublicGateway === 'nginx') {
|
||
if (listeners.length === 0) {
|
||
diagnostics.push(`${port} 未监听,Nginx 公网入口未就绪。`);
|
||
status = 'CRITICAL';
|
||
}
|
||
if (!hasNginx) {
|
||
diagnostics.push(`${port} 未看到 Nginx 监听。`);
|
||
status = 'CRITICAL';
|
||
}
|
||
if (hasPingora) {
|
||
diagnostics.push(`${port} 已被 Pingora 监听,不能作为未切公网彩排状态。`);
|
||
status = 'CRITICAL';
|
||
}
|
||
}
|
||
if (input.expectedPublicGateway === 'pingora-direct') {
|
||
if (!hasPingora) {
|
||
diagnostics.push(`${port} 未看到 Pingora 监听。`);
|
||
status = 'CRITICAL';
|
||
}
|
||
if (hasNginx) {
|
||
diagnostics.push(`${port} 仍由 Nginx 监听。`);
|
||
status = 'CRITICAL';
|
||
}
|
||
}
|
||
}
|
||
|
||
if (port === 18081 && input.requirePingoraShadow) {
|
||
if (!hasPingora) {
|
||
diagnostics.push('18081 未看到 Pingora shadow 监听。');
|
||
status = 'CRITICAL';
|
||
}
|
||
if (!loopbackOnly) {
|
||
diagnostics.push('Pingora shadow 18081 必须只监听 loopback。');
|
||
status = 'CRITICAL';
|
||
}
|
||
}
|
||
|
||
if (port === 18083 && input.requireRealpathCanary) {
|
||
if (!hasNginx) {
|
||
diagnostics.push('18083 未看到 Nginx realpath canary 监听。');
|
||
status = 'CRITICAL';
|
||
}
|
||
if (!loopbackOnly) {
|
||
diagnostics.push('Nginx realpath canary 18083 必须只监听 loopback。');
|
||
status = 'CRITICAL';
|
||
}
|
||
}
|
||
|
||
if (
|
||
hasUnknownProcess &&
|
||
([80, 443].includes(port) ||
|
||
(port === 18081 && input.requirePingoraShadow) ||
|
||
(port === 18083 && input.requireRealpathCanary))
|
||
) {
|
||
diagnostics.push('ss 未返回进程名,无法确认监听归属。');
|
||
status = maxStatus(status, 'WARNING');
|
||
}
|
||
|
||
return {
|
||
port,
|
||
status,
|
||
listening: listeners.length > 0,
|
||
loopbackOnly,
|
||
hasNginx,
|
||
hasPingora,
|
||
hasUnknownProcess,
|
||
listeners,
|
||
diagnostics,
|
||
};
|
||
}
|
||
|
||
async function inspectRealpathCanary(input, ports) {
|
||
const diagnostics = [];
|
||
let status = 'OK';
|
||
let exists = false;
|
||
let templateLooksValid = false;
|
||
try {
|
||
const fileStat = await stat(input.realpathConfigFile);
|
||
exists = fileStat.isFile();
|
||
} catch {
|
||
exists = false;
|
||
}
|
||
|
||
if (!exists) {
|
||
diagnostics.push(`realpath canary 配置不存在: ${input.realpathConfigFile}`);
|
||
status = input.requireRealpathCanary ? 'CRITICAL' : 'WARNING';
|
||
} else {
|
||
const content = await readFile(input.realpathConfigFile, 'utf8');
|
||
const requiredSnippets = [
|
||
'listen 127.0.0.1:18083',
|
||
'genarrative-pingora-realpath-canary.access.log',
|
||
'X-Genarrative-Nginx-Handoff pingora-realpath-canary',
|
||
];
|
||
const missing = requiredSnippets.filter((snippet) => !content.includes(snippet));
|
||
templateLooksValid = missing.length === 0;
|
||
if (missing.length > 0) {
|
||
diagnostics.push(`realpath canary 配置缺少关键片段: ${missing.join(', ')}`);
|
||
status = 'CRITICAL';
|
||
}
|
||
}
|
||
|
||
const portStatus = ports.byPort['18083'];
|
||
if (input.requireRealpathCanary && portStatus?.status === 'CRITICAL') {
|
||
status = 'CRITICAL';
|
||
}
|
||
|
||
return {
|
||
path: input.realpathConfigFile,
|
||
status,
|
||
exists,
|
||
templateLooksValid,
|
||
port: portStatus,
|
||
diagnostics,
|
||
};
|
||
}
|
||
|
||
async function inspectCurrentRelease(input, secrets) {
|
||
const script = path.join(
|
||
input.releaseRoot,
|
||
'scripts/ops/pingora-current-release-audit.mjs',
|
||
);
|
||
try {
|
||
await access(script, fsConstants.R_OK);
|
||
} catch (error) {
|
||
return {
|
||
status: 'CRITICAL',
|
||
script,
|
||
code: null,
|
||
diagnostics: [`无法读取 current release 自审脚本: ${error.message}`],
|
||
};
|
||
}
|
||
|
||
const args = [
|
||
'--',
|
||
script,
|
||
'--release-root',
|
||
input.releaseRoot,
|
||
'--systemd-show',
|
||
'--timeout-ms',
|
||
String(input.timeoutMs),
|
||
];
|
||
if (input.requireCurrentReleaseGateway) {
|
||
args.push('--require-pingora-gateway');
|
||
}
|
||
const result = await runCommand('node', args, input);
|
||
const parsed = parseJsonObject(result.stdout);
|
||
let status = result.code === 0 ? 'OK' : 'CRITICAL';
|
||
const diagnostics = [];
|
||
if (!parsed.ok) {
|
||
diagnostics.push(`无法解析 current release 自审 JSON: ${parsed.error}`);
|
||
status = 'CRITICAL';
|
||
}
|
||
return {
|
||
status,
|
||
script,
|
||
code: result.code,
|
||
command: result.command,
|
||
summary: parsed.ok ? parsed.value?.summary || null : null,
|
||
pingoraGateway: parsed.ok ? parsed.value?.pingoraGateway || null : null,
|
||
releaseManifest: parsed.ok ? parsed.value?.releaseManifest || null : null,
|
||
stdout: trimForJson(redactSecrets(result.stdout, secrets)),
|
||
stderr: trimForJson(redactSecrets(result.stderr || result.error, secrets)),
|
||
diagnostics,
|
||
};
|
||
}
|
||
|
||
function inspectPublicBoundary(input, ports, systemd) {
|
||
const diagnostics = [];
|
||
let status = 'OK';
|
||
if (input.expectedPublicGateway === 'none') {
|
||
return {
|
||
status,
|
||
expectedPublicGateway: input.expectedPublicGateway,
|
||
diagnostics,
|
||
};
|
||
}
|
||
|
||
for (const port of [80, 443]) {
|
||
const portStatus = ports.byPort[String(port)];
|
||
if (portStatus?.status === 'CRITICAL') {
|
||
status = 'CRITICAL';
|
||
diagnostics.push(...portStatus.diagnostics);
|
||
}
|
||
}
|
||
if (systemd.status === 'CRITICAL') {
|
||
status = 'CRITICAL';
|
||
diagnostics.push(...systemd.diagnostics);
|
||
}
|
||
|
||
return {
|
||
status,
|
||
expectedPublicGateway: input.expectedPublicGateway,
|
||
diagnostics,
|
||
};
|
||
}
|
||
|
||
function runCommand(command, args, input, env = process.env) {
|
||
validateNoControlCharacters(command, '子命令可执行文件');
|
||
for (const arg of args) {
|
||
validateNoControlCharacters(arg, '子命令参数');
|
||
}
|
||
return new Promise((resolve) => {
|
||
execFile(
|
||
command,
|
||
args,
|
||
{
|
||
env,
|
||
timeout: input.timeoutMs,
|
||
windowsHide: true,
|
||
maxBuffer: 1024 * 1024,
|
||
},
|
||
(error, stdout, stderr) => {
|
||
resolve({
|
||
command: formatCommand(command, args),
|
||
code: typeof error?.code === 'number' ? error.code : error ? 1 : 0,
|
||
stdout: String(stdout || ''),
|
||
stderr: String(stderr || ''),
|
||
timedOut: Boolean(error?.killed),
|
||
error: error ? error.message : '',
|
||
});
|
||
},
|
||
);
|
||
});
|
||
}
|
||
|
||
function formatCommand(command, args) {
|
||
return [command, ...args].join(' ');
|
||
}
|
||
|
||
function redactSecrets(value, secrets) {
|
||
let text = String(value || '');
|
||
for (const secret of secrets) {
|
||
if (!secret) {
|
||
continue;
|
||
}
|
||
text = text.split(secret).join('<redacted>');
|
||
}
|
||
return text;
|
||
}
|
||
|
||
function trimForJson(value) {
|
||
const text = String(value || '').trim();
|
||
if (text.length <= 1000) {
|
||
return text;
|
||
}
|
||
return `${text.slice(0, 1000)}...<truncated>`;
|
||
}
|
||
|
||
function parseJsonObject(value) {
|
||
try {
|
||
return { ok: true, value: JSON.parse(value) };
|
||
} catch (error) {
|
||
return { ok: false, error: error.message };
|
||
}
|
||
}
|
||
|
||
function summarize(statuses) {
|
||
const normalized = statuses.filter(Boolean);
|
||
const status = normalized.reduce(
|
||
(current, item) => maxStatus(current, item),
|
||
'OK',
|
||
);
|
||
return {
|
||
status,
|
||
criticalCount: normalized.filter((item) => item === 'CRITICAL').length,
|
||
warningCount: normalized.filter((item) => item === 'WARNING').length,
|
||
};
|
||
}
|
||
|
||
function maxStatus(left, right) {
|
||
return STATUS_RANK[right] > STATUS_RANK[left] ? right : left;
|
||
}
|