Files
Genarrative/scripts/check-pingora-direct-preflight.mjs
T
kdletters a7711d2dc3 引入Pingora独立网关正式化链路
新增 pingora-gateway 独立二进制 crate,覆盖路由、静态资源、压缩、接流保护、TLS 直连和访问日志能力。

新增 Nginx canary、realpath canary、direct preflight、direct live、direct enable 和 rollback 脚本。

新增 Pingora 切流证据包、命令证据、manifest 验真、根目录总审计和 release readiness 聚合门禁。

完善 API release、Jenkins、systemd、health patrol、生产部署和发布包自包含校验。

更新 Pingora 试点文档、Nginx README 与 Hermes 共享记忆。
2026-06-17 20:16:51 +08:00

902 lines
26 KiB
JavaScript

#!/usr/bin/env node
import { accessSync, constants, readFileSync } from 'node:fs';
import net from 'node:net';
import { spawnSync } from 'node:child_process';
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;
}