071faa482c
纳入 AGC Cargo workspace 的统一 rustfmt 检查与格式化入口 完成项目 TypeScript/Prettier 与 Rust 全量格式化 修复 Pingora expected executable 门禁的空白敏感误报 同步开发运维文档与 AGC skill pack 格式化忽略规则
898 lines
26 KiB
JavaScript
898 lines
26 KiB
JavaScript
#!/usr/bin/env node
|
|
|
|
import { spawnSync } from 'node:child_process';
|
|
import { accessSync, constants, readFileSync } from 'node:fs';
|
|
import net from 'node:net';
|
|
import { userInfo } from 'node:os';
|
|
import path from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
|
|
const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url));
|
|
const REPO_ROOT = path.resolve(SCRIPT_DIR, '..');
|
|
const DEFAULT_SERVICE_UNIT = path.join(
|
|
REPO_ROOT,
|
|
'deploy/systemd/genarrative-pingora-gateway.service',
|
|
);
|
|
const DEFAULT_DROPIN_TEMPLATE = path.join(
|
|
REPO_ROOT,
|
|
'deploy/systemd/genarrative-pingora-gateway-direct-entry.conf',
|
|
);
|
|
const DEFAULT_ENV_EXAMPLE = path.join(
|
|
REPO_ROOT,
|
|
'deploy/pingora/pingora-gateway.env.example',
|
|
);
|
|
const DEFAULT_SYSTEMD_SERVICE = 'genarrative-pingora-gateway.service';
|
|
const DEFAULT_SERVICE_USER = 'genarrative';
|
|
|
|
const config = parseArgs(process.argv.slice(2));
|
|
const failures = [];
|
|
const warnings = [];
|
|
|
|
await main();
|
|
|
|
if (warnings.length > 0) {
|
|
for (const warning of warnings) {
|
|
console.warn(`[pingora-direct-preflight] WARNING ${warning}`);
|
|
}
|
|
}
|
|
|
|
if (failures.length > 0) {
|
|
console.error('[pingora-direct-preflight] FAILED');
|
|
for (const failure of failures) {
|
|
console.error(`- ${failure}`);
|
|
}
|
|
process.exit(1);
|
|
}
|
|
|
|
console.log('[pingora-direct-preflight] OK');
|
|
|
|
function usage() {
|
|
console.log(`Usage:
|
|
node scripts/check-pingora-direct-preflight.mjs [options]
|
|
|
|
默认只检查仓库内模板,适合本机提交前护栏。
|
|
|
|
Options:
|
|
--env-file <path> 目标机 /etc/genarrative/pingora-gateway.env。
|
|
--require-live-env 强制 env 文件包含 TLS_LISTEN / HTTP_REDIRECT_LISTEN / cert / key。
|
|
--check-cert-readable 检查 env 中 cert/key 文件当前用户可读;目标机切换窗口建议启用。
|
|
--check-service-env-file
|
|
检查 service EnvironmentFile 包含本次 --env-file。
|
|
--check-service-user-cert-readable
|
|
检查 systemd 服务用户可读 env 中 cert/key;目标机切换窗口必须启用。
|
|
--check-service-binary-executable
|
|
检查 service ExecStart 指向的 pingora-gateway 已存在且可执行。
|
|
--check-ports-free 检查 env 中 TLS_LISTEN / HTTP_REDIRECT_LISTEN 端口当前可绑定。
|
|
--allow-loopback-only 只允许直连入口监听 127.0.0.1 / ::1;本机高端口 smoke 可用。
|
|
--systemd-cat 调用 systemctl cat 检查已生效 service。
|
|
--systemd-service <name>
|
|
systemd service 名,默认 genarrative-pingora-gateway.service。
|
|
--service-user <name> 检查 cert/key 可读性时使用的 systemd 服务用户;默认从 service 模板 User= 推导。
|
|
--service-unit <path> 仓库内主 service 模板路径。
|
|
--dropin-template <path>
|
|
仓库内 direct-entry drop-in 模板路径。
|
|
--env-example <path> 仓库内 env 示例路径。
|
|
--json 输出 JSON 结果。
|
|
|
|
Environment aliases:
|
|
GENARRATIVE_PINGORA_DIRECT_PREFLIGHT_ENV_FILE
|
|
GENARRATIVE_PINGORA_DIRECT_PREFLIGHT_REQUIRE_LIVE_ENV
|
|
GENARRATIVE_PINGORA_DIRECT_PREFLIGHT_CHECK_CERT_READABLE
|
|
GENARRATIVE_PINGORA_DIRECT_PREFLIGHT_CHECK_SERVICE_ENV_FILE
|
|
GENARRATIVE_PINGORA_DIRECT_PREFLIGHT_CHECK_SERVICE_USER_CERT_READABLE
|
|
GENARRATIVE_PINGORA_DIRECT_PREFLIGHT_CHECK_SERVICE_BINARY_EXECUTABLE
|
|
GENARRATIVE_PINGORA_DIRECT_PREFLIGHT_SERVICE_USER
|
|
GENARRATIVE_PINGORA_DIRECT_PREFLIGHT_CHECK_PORTS_FREE
|
|
GENARRATIVE_PINGORA_DIRECT_PREFLIGHT_SYSTEMD_CAT
|
|
`);
|
|
}
|
|
|
|
function parseArgs(argv) {
|
|
const result = {
|
|
envFile: process.env.GENARRATIVE_PINGORA_DIRECT_PREFLIGHT_ENV_FILE || '',
|
|
requireLiveEnv: parseBoolEnv(
|
|
process.env.GENARRATIVE_PINGORA_DIRECT_PREFLIGHT_REQUIRE_LIVE_ENV,
|
|
false,
|
|
'GENARRATIVE_PINGORA_DIRECT_PREFLIGHT_REQUIRE_LIVE_ENV',
|
|
),
|
|
checkCertReadable: parseBoolEnv(
|
|
process.env.GENARRATIVE_PINGORA_DIRECT_PREFLIGHT_CHECK_CERT_READABLE,
|
|
false,
|
|
'GENARRATIVE_PINGORA_DIRECT_PREFLIGHT_CHECK_CERT_READABLE',
|
|
),
|
|
checkServiceEnvFile: parseBoolEnv(
|
|
process.env.GENARRATIVE_PINGORA_DIRECT_PREFLIGHT_CHECK_SERVICE_ENV_FILE,
|
|
false,
|
|
'GENARRATIVE_PINGORA_DIRECT_PREFLIGHT_CHECK_SERVICE_ENV_FILE',
|
|
),
|
|
checkServiceUserCertReadable: parseBoolEnv(
|
|
process.env
|
|
.GENARRATIVE_PINGORA_DIRECT_PREFLIGHT_CHECK_SERVICE_USER_CERT_READABLE,
|
|
false,
|
|
'GENARRATIVE_PINGORA_DIRECT_PREFLIGHT_CHECK_SERVICE_USER_CERT_READABLE',
|
|
),
|
|
checkServiceBinaryExecutable: parseBoolEnv(
|
|
process.env
|
|
.GENARRATIVE_PINGORA_DIRECT_PREFLIGHT_CHECK_SERVICE_BINARY_EXECUTABLE,
|
|
false,
|
|
'GENARRATIVE_PINGORA_DIRECT_PREFLIGHT_CHECK_SERVICE_BINARY_EXECUTABLE',
|
|
),
|
|
serviceUser:
|
|
process.env.GENARRATIVE_PINGORA_DIRECT_PREFLIGHT_SERVICE_USER || '',
|
|
checkPortsFree: parseBoolEnv(
|
|
process.env.GENARRATIVE_PINGORA_DIRECT_PREFLIGHT_CHECK_PORTS_FREE,
|
|
false,
|
|
'GENARRATIVE_PINGORA_DIRECT_PREFLIGHT_CHECK_PORTS_FREE',
|
|
),
|
|
systemdCat: parseBoolEnv(
|
|
process.env.GENARRATIVE_PINGORA_DIRECT_PREFLIGHT_SYSTEMD_CAT,
|
|
false,
|
|
'GENARRATIVE_PINGORA_DIRECT_PREFLIGHT_SYSTEMD_CAT',
|
|
),
|
|
allowLoopbackOnly: false,
|
|
systemdService: DEFAULT_SYSTEMD_SERVICE,
|
|
serviceUnit: DEFAULT_SERVICE_UNIT,
|
|
dropinTemplate: DEFAULT_DROPIN_TEMPLATE,
|
|
envExample: DEFAULT_ENV_EXAMPLE,
|
|
json: false,
|
|
};
|
|
|
|
for (let index = 0; index < argv.length; index += 1) {
|
|
const arg = argv[index];
|
|
switch (arg) {
|
|
case '-h':
|
|
case '--help':
|
|
usage();
|
|
process.exit(0);
|
|
break;
|
|
case '--env-file':
|
|
result.envFile = requireValue(argv, ++index, arg);
|
|
break;
|
|
case '--require-live-env':
|
|
result.requireLiveEnv = true;
|
|
break;
|
|
case '--check-cert-readable':
|
|
result.checkCertReadable = true;
|
|
break;
|
|
case '--check-service-env-file':
|
|
result.checkServiceEnvFile = true;
|
|
break;
|
|
case '--check-service-user-cert-readable':
|
|
result.checkServiceUserCertReadable = true;
|
|
break;
|
|
case '--check-service-binary-executable':
|
|
result.checkServiceBinaryExecutable = true;
|
|
break;
|
|
case '--check-ports-free':
|
|
result.checkPortsFree = true;
|
|
break;
|
|
case '--allow-loopback-only':
|
|
result.allowLoopbackOnly = true;
|
|
break;
|
|
case '--systemd-cat':
|
|
result.systemdCat = true;
|
|
break;
|
|
case '--systemd-service':
|
|
result.systemdService = requireValue(argv, ++index, arg);
|
|
break;
|
|
case '--service-user':
|
|
result.serviceUser = requireValue(argv, ++index, arg);
|
|
break;
|
|
case '--service-unit':
|
|
result.serviceUnit = requireValue(argv, ++index, arg);
|
|
break;
|
|
case '--dropin-template':
|
|
result.dropinTemplate = requireValue(argv, ++index, arg);
|
|
break;
|
|
case '--env-example':
|
|
result.envExample = requireValue(argv, ++index, arg);
|
|
break;
|
|
case '--json':
|
|
result.json = true;
|
|
break;
|
|
default:
|
|
throw new Error(`未知参数: ${arg}`);
|
|
}
|
|
}
|
|
|
|
if (
|
|
(result.requireLiveEnv ||
|
|
result.checkCertReadable ||
|
|
result.checkServiceEnvFile ||
|
|
result.checkServiceUserCertReadable ||
|
|
result.checkPortsFree) &&
|
|
!result.envFile
|
|
) {
|
|
throw new Error(
|
|
'--require-live-env / --check-cert-readable / --check-service-env-file / --check-service-user-cert-readable / --check-ports-free 需要同时提供 --env-file',
|
|
);
|
|
}
|
|
if (result.envFile) {
|
|
validateSafeAbsoluteFilePath(result.envFile, '--env-file');
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
function requireValue(argv, index, flag) {
|
|
const value = argv[index];
|
|
if (!value || value.startsWith('--')) {
|
|
throw new Error(`${flag} 缺少参数值`);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function parseBoolEnv(raw, fallback, label) {
|
|
if (raw === undefined || raw === null || String(raw).trim() === '') {
|
|
return fallback;
|
|
}
|
|
const normalized = String(raw).trim().toLowerCase();
|
|
if (['1', 'true', 'yes', 'on'].includes(normalized)) {
|
|
return true;
|
|
}
|
|
if (['0', 'false', 'no', 'off'].includes(normalized)) {
|
|
return false;
|
|
}
|
|
throw new Error(`${label} 必须是布尔值 true/false 或 1/0。`);
|
|
}
|
|
|
|
function validateSafeAbsoluteFilePath(value, flag) {
|
|
validateNoControlCharacters(value, flag);
|
|
if (!path.isAbsolute(value)) {
|
|
throw new Error(`${flag} 必须是绝对路径。`);
|
|
}
|
|
if (isFilesystemRootPath(value)) {
|
|
throw new Error(`${flag} 不能是文件系统根目录。`);
|
|
}
|
|
}
|
|
|
|
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 main() {
|
|
const serviceTemplate = readFile(config.serviceUnit);
|
|
const dropinTemplate = readFile(config.dropinTemplate);
|
|
const envExample = readFile(config.envExample);
|
|
if (!config.serviceUser) {
|
|
config.serviceUser = resolveServiceUser(serviceTemplate);
|
|
}
|
|
|
|
assertTemplateShape(serviceTemplate, dropinTemplate, envExample);
|
|
if (config.checkServiceEnvFile) {
|
|
assertServiceEnvFile(serviceTemplate, config.serviceUnit);
|
|
}
|
|
if (config.checkServiceBinaryExecutable) {
|
|
assertServiceBinaryExecutable(serviceTemplate);
|
|
}
|
|
|
|
let env = new Map();
|
|
if (config.envFile) {
|
|
env = parseEnvFile(config.envFile);
|
|
await assertLiveEnv(env);
|
|
} else if (config.requireLiveEnv) {
|
|
failures.push('缺少 --env-file,无法验证目标机直连 env。');
|
|
}
|
|
|
|
if (config.systemdCat) {
|
|
const systemdCatContent = assertSystemdCat();
|
|
if (config.checkServiceEnvFile && systemdCatContent) {
|
|
assertServiceEnvFile(
|
|
systemdCatContent,
|
|
`systemctl cat ${config.systemdService}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
if (config.json) {
|
|
console.log(
|
|
JSON.stringify(
|
|
{
|
|
ok: failures.length === 0,
|
|
warnings,
|
|
envFile: config.envFile,
|
|
tlsListen: env.get('GENARRATIVE_PINGORA_GATEWAY_TLS_LISTEN') || '',
|
|
httpRedirectListen:
|
|
env.get('GENARRATIVE_PINGORA_GATEWAY_HTTP_REDIRECT_LISTEN') || '',
|
|
systemdCat: config.systemdCat,
|
|
checkServiceEnvFile: config.checkServiceEnvFile,
|
|
checkServiceUserCertReadable: config.checkServiceUserCertReadable,
|
|
checkServiceBinaryExecutable: config.checkServiceBinaryExecutable,
|
|
serviceUser: config.serviceUser,
|
|
},
|
|
null,
|
|
2,
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
function readFile(file) {
|
|
try {
|
|
return readFileSync(file, 'utf8');
|
|
} catch (error) {
|
|
failures.push(`无法读取 ${file}: ${error.message}`);
|
|
return '';
|
|
}
|
|
}
|
|
|
|
function assertTemplateShape(serviceTemplate, dropinTemplate, envExample) {
|
|
assertIncludes(
|
|
config.serviceUnit,
|
|
serviceTemplate,
|
|
'影子网关只监听本机高端口',
|
|
'主 service 必须保留 shadow 说明。',
|
|
);
|
|
assertExcludes(
|
|
config.serviceUnit,
|
|
serviceTemplate,
|
|
'AmbientCapabilities=CAP_NET_BIND_SERVICE',
|
|
'主 service 不能默认授予低端口 capability。',
|
|
);
|
|
assertIncludes(
|
|
config.dropinTemplate,
|
|
dropinTemplate,
|
|
'AmbientCapabilities=CAP_NET_BIND_SERVICE',
|
|
'direct-entry drop-in 必须授予低端口绑定 capability。',
|
|
);
|
|
assertIncludes(
|
|
config.dropinTemplate,
|
|
dropinTemplate,
|
|
'CapabilityBoundingSet=CAP_NET_BIND_SERVICE',
|
|
'direct-entry drop-in 必须限制 capability 边界。',
|
|
);
|
|
assertIncludes(
|
|
config.dropinTemplate,
|
|
dropinTemplate,
|
|
'/etc/systemd/system/genarrative-pingora-gateway.service.d/direct-entry.conf',
|
|
'direct-entry drop-in 必须写明人工启用位置。',
|
|
);
|
|
assertIncludes(
|
|
config.envExample,
|
|
envExample,
|
|
'CAP_NET_BIND_SERVICE',
|
|
'env 示例必须提示低端口需要 systemd drop-in。',
|
|
);
|
|
}
|
|
|
|
function assertIncludes(file, content, needle, reason) {
|
|
if (!content.includes(needle)) {
|
|
failures.push(`${file} 缺少 ${needle}。${reason}`);
|
|
}
|
|
}
|
|
|
|
function assertExcludes(file, content, needle, reason) {
|
|
if (content.includes(needle)) {
|
|
failures.push(`${file} 不应包含 ${needle}。${reason}`);
|
|
}
|
|
}
|
|
|
|
function parseEnvFile(file) {
|
|
const content = readFile(file);
|
|
const env = new Map();
|
|
for (const rawLine of content.split(/\r?\n/u)) {
|
|
const line = rawLine.trim();
|
|
if (!line || line.startsWith('#')) {
|
|
continue;
|
|
}
|
|
const separator = line.indexOf('=');
|
|
if (separator <= 0) {
|
|
continue;
|
|
}
|
|
const key = line.slice(0, separator).trim();
|
|
const value = unquoteEnvValue(line.slice(separator + 1).trim());
|
|
env.set(key, value);
|
|
}
|
|
return env;
|
|
}
|
|
|
|
function unquoteEnvValue(value) {
|
|
if (
|
|
(value.startsWith('"') && value.endsWith('"')) ||
|
|
(value.startsWith("'") && value.endsWith("'"))
|
|
) {
|
|
return value.slice(1, -1);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
async function assertLiveEnv(env) {
|
|
const tlsListen = requireEnv(env, 'GENARRATIVE_PINGORA_GATEWAY_TLS_LISTEN');
|
|
const httpListen = requireEnv(
|
|
env,
|
|
'GENARRATIVE_PINGORA_GATEWAY_HTTP_REDIRECT_LISTEN',
|
|
);
|
|
const certFile = requireEnv(env, 'GENARRATIVE_PINGORA_GATEWAY_TLS_CERT_FILE');
|
|
const keyFile = requireEnv(env, 'GENARRATIVE_PINGORA_GATEWAY_TLS_KEY_FILE');
|
|
const proto = requireEnv(env, 'GENARRATIVE_PINGORA_GATEWAY_FORWARDED_PROTO');
|
|
|
|
validateEnvValueNoControlCharacters(
|
|
tlsListen,
|
|
'GENARRATIVE_PINGORA_GATEWAY_TLS_LISTEN',
|
|
);
|
|
validateEnvValueNoControlCharacters(
|
|
httpListen,
|
|
'GENARRATIVE_PINGORA_GATEWAY_HTTP_REDIRECT_LISTEN',
|
|
);
|
|
validateEnvValueNoControlCharacters(
|
|
certFile,
|
|
'GENARRATIVE_PINGORA_GATEWAY_TLS_CERT_FILE',
|
|
);
|
|
validateEnvValueNoControlCharacters(
|
|
keyFile,
|
|
'GENARRATIVE_PINGORA_GATEWAY_TLS_KEY_FILE',
|
|
);
|
|
validateEnvValueNoControlCharacters(
|
|
proto,
|
|
'GENARRATIVE_PINGORA_GATEWAY_FORWARDED_PROTO',
|
|
);
|
|
|
|
if (proto && proto !== 'https') {
|
|
failures.push(
|
|
`GENARRATIVE_PINGORA_GATEWAY_FORWARDED_PROTO 应为 https,实际 ${proto}`,
|
|
);
|
|
}
|
|
|
|
const tlsEndpoint = parseListenAddress(
|
|
tlsListen,
|
|
'GENARRATIVE_PINGORA_GATEWAY_TLS_LISTEN',
|
|
);
|
|
const httpEndpoint = parseListenAddress(
|
|
httpListen,
|
|
'GENARRATIVE_PINGORA_GATEWAY_HTTP_REDIRECT_LISTEN',
|
|
);
|
|
|
|
if (
|
|
tlsEndpoint &&
|
|
httpEndpoint &&
|
|
endpointsEqual(tlsEndpoint, httpEndpoint)
|
|
) {
|
|
failures.push('TLS_LISTEN 和 HTTP_REDIRECT_LISTEN 不能复用同一监听地址。');
|
|
}
|
|
|
|
assertForwardedForTrust(env, [tlsEndpoint, httpEndpoint].filter(Boolean));
|
|
|
|
if (config.allowLoopbackOnly) {
|
|
for (const endpoint of [tlsEndpoint, httpEndpoint].filter(Boolean)) {
|
|
if (!isLoopbackHost(endpoint.host)) {
|
|
failures.push(
|
|
`${endpoint.label}=${endpoint.raw} 不是 loopback 地址;--allow-loopback-only 下不允许。`,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (config.checkCertReadable) {
|
|
assertReadableFile(certFile, 'TLS_CERT_FILE');
|
|
assertReadableFile(keyFile, 'TLS_KEY_FILE');
|
|
}
|
|
|
|
if (config.checkServiceUserCertReadable) {
|
|
assertReadableFileAsServiceUser(certFile, 'TLS_CERT_FILE');
|
|
assertReadableFileAsServiceUser(keyFile, 'TLS_KEY_FILE');
|
|
}
|
|
|
|
if (config.checkPortsFree) {
|
|
for (const endpoint of [tlsEndpoint, httpEndpoint].filter(Boolean)) {
|
|
await assertPortBindable(endpoint);
|
|
}
|
|
}
|
|
|
|
assertProtectionInstanceBoundary(env);
|
|
}
|
|
|
|
function requireEnv(env, key) {
|
|
const value = env.get(key) || '';
|
|
if (!value && config.requireLiveEnv) {
|
|
failures.push(`${config.envFile} 缺少 ${key}`);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function validateEnvValueNoControlCharacters(value, label) {
|
|
if (!value) {
|
|
return;
|
|
}
|
|
try {
|
|
validateNoControlCharacters(value, label);
|
|
} catch (error) {
|
|
failures.push(error.message);
|
|
}
|
|
}
|
|
|
|
function assertForwardedForTrust(env, endpoints) {
|
|
const trustForwardedFor = parseBoolValue(
|
|
env.get('GENARRATIVE_PINGORA_GATEWAY_TRUST_X_FORWARDED_FOR'),
|
|
'GENARRATIVE_PINGORA_GATEWAY_TRUST_X_FORWARDED_FOR',
|
|
);
|
|
if (!trustForwardedFor) {
|
|
return;
|
|
}
|
|
|
|
const confirmed = parseBoolValue(
|
|
env.get('GENARRATIVE_PINGORA_GATEWAY_TRUSTED_FRONT_PROXY_CONFIRMED'),
|
|
'GENARRATIVE_PINGORA_GATEWAY_TRUSTED_FRONT_PROXY_CONFIRMED',
|
|
);
|
|
if (!confirmed) {
|
|
failures.push(
|
|
'GENARRATIVE_PINGORA_GATEWAY_TRUST_X_FORWARDED_FOR=true 时必须同时设置 GENARRATIVE_PINGORA_GATEWAY_TRUSTED_FRONT_PROXY_CONFIRMED=true。',
|
|
);
|
|
}
|
|
|
|
const publicEndpoints = endpoints.filter(
|
|
(endpoint) => !isLoopbackHost(endpoint.host),
|
|
);
|
|
if (publicEndpoints.length > 0) {
|
|
failures.push(
|
|
`公网直连 Pingora 时 GENARRATIVE_PINGORA_GATEWAY_TRUST_X_FORWARDED_FOR 必须保持 false,当前监听 ${publicEndpoints.map((endpoint) => `${endpoint.label}=${endpoint.raw}`).join(', ')}。`,
|
|
);
|
|
}
|
|
}
|
|
|
|
function assertProtectionInstanceBoundary(env) {
|
|
const protectionEnabled = parseBoolValue(
|
|
env.get('GENARRATIVE_PINGORA_GATEWAY_PROTECTION_ENABLED'),
|
|
'GENARRATIVE_PINGORA_GATEWAY_PROTECTION_ENABLED',
|
|
true,
|
|
);
|
|
const instanceCount = parseOptionalPositiveIntValue(
|
|
env.get('GENARRATIVE_PINGORA_GATEWAY_INSTANCE_COUNT'),
|
|
1,
|
|
'GENARRATIVE_PINGORA_GATEWAY_INSTANCE_COUNT',
|
|
);
|
|
const sharedProtectionConfirmed = parseBoolValue(
|
|
env.get('GENARRATIVE_PINGORA_GATEWAY_SHARED_PROTECTION_CONFIRMED'),
|
|
'GENARRATIVE_PINGORA_GATEWAY_SHARED_PROTECTION_CONFIRMED',
|
|
);
|
|
|
|
if (protectionEnabled && instanceCount > 1 && !sharedProtectionConfirmed) {
|
|
failures.push(
|
|
'Pingora 接流保护当前默认是进程内状态;GENARRATIVE_PINGORA_GATEWAY_INSTANCE_COUNT>1 且 PROTECTION_ENABLED=true 时,必须设置 GENARRATIVE_PINGORA_GATEWAY_SHARED_PROTECTION_CONFIRMED=true,或关闭网关保护并由前置层承担。',
|
|
);
|
|
}
|
|
}
|
|
|
|
function parseBoolValue(raw, label, fallback = false) {
|
|
if (raw === undefined || raw === null || String(raw).trim() === '') {
|
|
return fallback;
|
|
}
|
|
const normalized = String(raw).trim().toLowerCase();
|
|
if (['1', 'true', 'yes', 'on'].includes(normalized)) {
|
|
return true;
|
|
}
|
|
if (['0', 'false', 'no', 'off'].includes(normalized)) {
|
|
return false;
|
|
}
|
|
failures.push(`${label} 必须是布尔值 true/false 或 1/0。`);
|
|
return fallback;
|
|
}
|
|
|
|
function parseOptionalPositiveIntValue(raw, fallback, label) {
|
|
if (raw === undefined || raw === null || String(raw).trim() === '') {
|
|
return fallback;
|
|
}
|
|
const text = String(raw).trim();
|
|
if (!/^[1-9]\d*$/u.test(text)) {
|
|
failures.push(`${label} 必须是正整数。`);
|
|
return fallback;
|
|
}
|
|
return Number.parseInt(text, 10);
|
|
}
|
|
|
|
function parseListenAddress(raw, label) {
|
|
if (!raw) {
|
|
return null;
|
|
}
|
|
const lastColon = raw.lastIndexOf(':');
|
|
if (lastColon <= 0 || lastColon === raw.length - 1) {
|
|
failures.push(`${label}=${raw} 不是 host:port 格式。`);
|
|
return null;
|
|
}
|
|
|
|
let host = raw.slice(0, lastColon);
|
|
const port = Number.parseInt(raw.slice(lastColon + 1), 10);
|
|
if (host.startsWith('[') && host.endsWith(']')) {
|
|
host = host.slice(1, -1);
|
|
}
|
|
if (!Number.isFinite(port) || port <= 0 || port > 65535) {
|
|
failures.push(`${label}=${raw} 端口无效。`);
|
|
return null;
|
|
}
|
|
|
|
return { label, raw, host, port };
|
|
}
|
|
|
|
function endpointsEqual(left, right) {
|
|
return left.host === right.host && left.port === right.port;
|
|
}
|
|
|
|
function isLoopbackHost(host) {
|
|
return ['127.0.0.1', '::1', 'localhost'].includes(host);
|
|
}
|
|
|
|
function assertReadableFile(file, label) {
|
|
if (!file) {
|
|
return;
|
|
}
|
|
try {
|
|
accessSync(file, constants.R_OK);
|
|
} catch (error) {
|
|
failures.push(`${label} 当前用户不可读: ${file} (${error.message})`);
|
|
}
|
|
}
|
|
|
|
function assertExecutableFile(file, label) {
|
|
if (!file) {
|
|
return;
|
|
}
|
|
try {
|
|
accessSync(file, constants.X_OK);
|
|
} catch (error) {
|
|
failures.push(`${label} 不存在或不可执行: ${file} (${error.message})`);
|
|
}
|
|
}
|
|
|
|
function assertReadableFileAsServiceUser(file, label) {
|
|
if (!file) {
|
|
return;
|
|
}
|
|
const user = config.serviceUser || DEFAULT_SERVICE_USER;
|
|
try {
|
|
validateNoControlCharacters(user, '--service-user');
|
|
validateNoControlCharacters(file, label);
|
|
} catch (error) {
|
|
failures.push(error.message);
|
|
return;
|
|
}
|
|
const currentUser = currentUsername();
|
|
if (currentUser === user) {
|
|
assertReadableFile(file, `${label} 服务用户 ${user}`);
|
|
return;
|
|
}
|
|
const result = runCommand('sudo', ['-n', '-u', user, 'test', '-r', file]);
|
|
if (result.controlCharacterError) {
|
|
failures.push(result.controlCharacterError);
|
|
return;
|
|
}
|
|
if (result.error) {
|
|
failures.push(
|
|
`${label} 无法以服务用户 ${user} 检查可读性: ${result.error.message};请在目标机安装 sudo,或以 ${user} 用户运行 preflight。`,
|
|
);
|
|
return;
|
|
}
|
|
if (result.status !== 0) {
|
|
const detail = (result.stderr || result.stdout || '').trim();
|
|
failures.push(
|
|
`${label} 服务用户 ${user} 不可读: ${file}${detail ? ` (${detail})` : ''}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
function runCommand(command, args) {
|
|
try {
|
|
validateNoControlCharacters(command, '子命令可执行文件');
|
|
for (const arg of args) {
|
|
validateNoControlCharacters(arg, '子命令参数');
|
|
}
|
|
} catch (error) {
|
|
return {
|
|
status: 1,
|
|
stdout: '',
|
|
stderr: '',
|
|
error: null,
|
|
controlCharacterError: error.message,
|
|
};
|
|
}
|
|
return spawnSync(command, args, {
|
|
encoding: 'utf8',
|
|
});
|
|
}
|
|
|
|
function assertServiceEnvFile(serviceTemplate, sourceLabel) {
|
|
const envFiles = resolveServiceEnvFiles(serviceTemplate);
|
|
const expected = path.resolve(config.envFile);
|
|
if (envFiles.length === 0) {
|
|
failures.push(
|
|
`${sourceLabel} 缺少 EnvironmentFile,无法确认 service 会读取本次 preflight env。`,
|
|
);
|
|
return;
|
|
}
|
|
if (!envFiles.some((envFile) => path.resolve(envFile) === expected)) {
|
|
failures.push(
|
|
`${sourceLabel} EnvironmentFile 未包含本次 --env-file: ${config.envFile};实际 ${envFiles.join(', ')}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
function resolveServiceEnvFiles(serviceTemplate) {
|
|
const envFiles = [];
|
|
for (const rawLine of serviceTemplate.split(/\r?\n/u)) {
|
|
const line = rawLine.trim();
|
|
if (!line.startsWith('EnvironmentFile=')) {
|
|
continue;
|
|
}
|
|
const rawValue = line.slice('EnvironmentFile='.length).trim();
|
|
if (!rawValue) {
|
|
envFiles.length = 0;
|
|
continue;
|
|
}
|
|
for (const word of splitSystemdWords(rawValue)) {
|
|
const value = word.startsWith('-') ? word.slice(1).trim() : word;
|
|
if (value) {
|
|
envFiles.push(value);
|
|
}
|
|
}
|
|
}
|
|
return envFiles;
|
|
}
|
|
|
|
function splitSystemdWords(value) {
|
|
const words = [];
|
|
let current = '';
|
|
let quote = '';
|
|
for (let index = 0; index < value.length; index += 1) {
|
|
const char = value[index];
|
|
if (quote) {
|
|
if (char === quote) {
|
|
quote = '';
|
|
} else {
|
|
current += char;
|
|
}
|
|
continue;
|
|
}
|
|
if (char === '"' || char === "'") {
|
|
quote = char;
|
|
continue;
|
|
}
|
|
if (/\s/u.test(char)) {
|
|
if (current) {
|
|
words.push(current);
|
|
current = '';
|
|
}
|
|
continue;
|
|
}
|
|
current += char;
|
|
}
|
|
if (current) {
|
|
words.push(current);
|
|
}
|
|
return words;
|
|
}
|
|
|
|
function resolveServiceUser(serviceTemplate) {
|
|
const userLine = serviceTemplate
|
|
.split(/\r?\n/u)
|
|
.map((line) => line.trim())
|
|
.find((line) => line.startsWith('User='));
|
|
if (!userLine) {
|
|
return DEFAULT_SERVICE_USER;
|
|
}
|
|
const user = userLine.slice('User='.length).trim();
|
|
return user || DEFAULT_SERVICE_USER;
|
|
}
|
|
|
|
function assertServiceBinaryExecutable(serviceTemplate) {
|
|
const execStart = resolveServiceExecStart(serviceTemplate);
|
|
if (!execStart) {
|
|
failures.push(`${config.serviceUnit} 缺少 ExecStart,无法检查网关二进制。`);
|
|
return;
|
|
}
|
|
const binaryPath = firstExecWord(execStart);
|
|
if (!binaryPath || !path.isAbsolute(binaryPath)) {
|
|
failures.push(
|
|
`${config.serviceUnit} ExecStart 不是绝对可执行文件路径: ${execStart}`,
|
|
);
|
|
return;
|
|
}
|
|
assertExecutableFile(binaryPath, 'Pingora service ExecStart');
|
|
}
|
|
|
|
function resolveServiceExecStart(serviceTemplate) {
|
|
const execLine = serviceTemplate
|
|
.split(/\r?\n/u)
|
|
.map((line) => line.trim())
|
|
.find((line) => line.startsWith('ExecStart='));
|
|
if (!execLine) {
|
|
return '';
|
|
}
|
|
return execLine.slice('ExecStart='.length).trim();
|
|
}
|
|
|
|
function firstExecWord(execStart) {
|
|
const trimmed = execStart.trim();
|
|
if (!trimmed) {
|
|
return '';
|
|
}
|
|
if (trimmed.startsWith('"')) {
|
|
const closingQuote = trimmed.indexOf('"', 1);
|
|
return closingQuote > 1 ? trimmed.slice(1, closingQuote) : '';
|
|
}
|
|
return trimmed.split(/\s+/u)[0] || '';
|
|
}
|
|
|
|
function currentUsername() {
|
|
if (process.env.USER || process.env.LOGNAME) {
|
|
return process.env.USER || process.env.LOGNAME;
|
|
}
|
|
try {
|
|
return userInfo().username;
|
|
} catch {
|
|
return '';
|
|
}
|
|
}
|
|
|
|
function assertPortBindable(endpoint) {
|
|
return new Promise((resolve) => {
|
|
const server = net.createServer();
|
|
let settled = false;
|
|
|
|
function finish(error) {
|
|
if (settled) {
|
|
return;
|
|
}
|
|
settled = true;
|
|
if (error) {
|
|
failures.push(
|
|
`${endpoint.label}=${endpoint.raw} 当前不可绑定: ${error.message}`,
|
|
);
|
|
resolve();
|
|
} else {
|
|
console.log(
|
|
`[pingora-direct-preflight] ${endpoint.label}=${endpoint.raw} 当前端口可绑定`,
|
|
);
|
|
server.close(() => resolve());
|
|
}
|
|
}
|
|
|
|
server.once('error', finish);
|
|
server.listen(
|
|
{
|
|
host: endpoint.host,
|
|
port: endpoint.port,
|
|
exclusive: true,
|
|
},
|
|
() => finish(null),
|
|
);
|
|
});
|
|
}
|
|
|
|
function assertSystemdCat() {
|
|
const result = runCommand('systemctl', ['cat', config.systemdService]);
|
|
if (result.controlCharacterError) {
|
|
failures.push(result.controlCharacterError);
|
|
return '';
|
|
}
|
|
if (result.error) {
|
|
failures.push(`systemctl cat 启动失败: ${result.error.message}`);
|
|
return '';
|
|
}
|
|
if (result.status !== 0) {
|
|
failures.push(
|
|
`systemctl cat ${config.systemdService} 失败: ${(result.stderr || result.stdout || '').trim()}`,
|
|
);
|
|
return '';
|
|
}
|
|
|
|
const content = result.stdout || '';
|
|
assertIncludes(
|
|
`systemctl cat ${config.systemdService}`,
|
|
content,
|
|
'AmbientCapabilities=CAP_NET_BIND_SERVICE',
|
|
'目标机直连低端口前必须启用 direct-entry drop-in。',
|
|
);
|
|
assertIncludes(
|
|
`systemctl cat ${config.systemdService}`,
|
|
content,
|
|
'CapabilityBoundingSet=CAP_NET_BIND_SERVICE',
|
|
'目标机 direct-entry drop-in 必须限制 capability 边界。',
|
|
);
|
|
return content;
|
|
}
|