75b85ee5a9
direct live 支持独立 redirect base URL 预期 release readiness 和切换证据链透传 redirect base URL 直连启用脚本透传高端口 rehearsal 参数 补充 direct live guard、运维门禁和文档示例
1388 lines
41 KiB
JavaScript
1388 lines
41 KiB
JavaScript
#!/usr/bin/env node
|
|
|
|
import http from 'node:http';
|
|
import https from 'node:https';
|
|
import net from 'node:net';
|
|
import tls from 'node:tls';
|
|
import { createHash, randomBytes } from 'node:crypto';
|
|
import { readFile } from 'node:fs/promises';
|
|
import nodePath from 'node:path';
|
|
|
|
const DEFAULT_TIMEOUT_MS = 5000;
|
|
const DEFAULT_SPACETIME_DATABASE = 'genarrative-prod';
|
|
const SPACETIME_WEBSOCKET_PROTOCOL = 'v2.bsatn.spacetimedb';
|
|
|
|
const failures = [];
|
|
let config;
|
|
|
|
try {
|
|
config = parseArgs(process.argv.slice(2));
|
|
await main();
|
|
} catch (error) {
|
|
failures.push(error instanceof Error ? error.message : String(error));
|
|
}
|
|
|
|
if (failures.length > 0) {
|
|
console.error('[pingora-direct-live] FAILED');
|
|
for (const failure of failures) {
|
|
console.error(`- ${failure}`);
|
|
}
|
|
process.exit(1);
|
|
}
|
|
|
|
console.log('[pingora-direct-live] OK');
|
|
|
|
function usage() {
|
|
console.log(`Usage:
|
|
node scripts/check-pingora-direct-live.mjs --https-base-url <url> [options]
|
|
|
|
Options:
|
|
--https-base-url <url> HTTPS base URL served directly by pingora-gateway.
|
|
--http-base-url <url> Optional HTTP base URL that should 301 to HTTPS.
|
|
--host <host> Optional Host header for local --resolve style checks.
|
|
--redirect-host <host> Optional expected Location host for HTTP redirects.
|
|
--redirect-base-url <url>
|
|
Optional expected HTTPS base URL for HTTP redirects.
|
|
--probe-token <token> Optional Pingora shadow probe token.
|
|
--spacetime-database <name>
|
|
SpacetimeDB database for WSS subscribe smoke, default genarrative-prod.
|
|
--require-wss-upgrade Require WSS subscribe to return 101 Switching Protocols.
|
|
--skip-wss Skip WSS subscribe handshake smoke.
|
|
--path <path> Extra HTTPS path to probe; repeatable.
|
|
--pingora-access-log <path>
|
|
Optional Pingora access log path. When set, direct live asserts its generated request ids were logged.
|
|
--access-log-since-lines <count>
|
|
Tail lines to read from Pingora access log, default 2000.
|
|
--timeout-ms <ms> Request timeout, default 5000.
|
|
--insecure-tls Allow self-signed certificates for local smoke only.
|
|
--json Print JSON result.
|
|
|
|
Environment aliases:
|
|
GENARRATIVE_PINGORA_DIRECT_HTTPS_BASE_URL
|
|
GENARRATIVE_PINGORA_DIRECT_HTTP_BASE_URL
|
|
GENARRATIVE_PINGORA_DIRECT_HOST
|
|
GENARRATIVE_PINGORA_DIRECT_REDIRECT_HOST
|
|
GENARRATIVE_PINGORA_DIRECT_REDIRECT_BASE_URL
|
|
GENARRATIVE_PINGORA_DIRECT_PROBE_TOKEN
|
|
GENARRATIVE_PINGORA_DIRECT_SPACETIME_DATABASE
|
|
GENARRATIVE_PINGORA_DIRECT_REQUIRE_WSS_UPGRADE
|
|
GENARRATIVE_PINGORA_DIRECT_SKIP_WSS
|
|
GENARRATIVE_PINGORA_DIRECT_PINGORA_ACCESS_LOG
|
|
GENARRATIVE_PINGORA_DIRECT_ACCESS_LOG_SINCE_LINES
|
|
GENARRATIVE_PINGORA_DIRECT_TIMEOUT_MS
|
|
GENARRATIVE_PINGORA_DIRECT_INSECURE_TLS
|
|
`);
|
|
}
|
|
|
|
function parseArgs(argv) {
|
|
const result = {
|
|
httpsBaseUrl: process.env.GENARRATIVE_PINGORA_DIRECT_HTTPS_BASE_URL || '',
|
|
httpBaseUrl: process.env.GENARRATIVE_PINGORA_DIRECT_HTTP_BASE_URL || '',
|
|
host: process.env.GENARRATIVE_PINGORA_DIRECT_HOST || '',
|
|
redirectHost: process.env.GENARRATIVE_PINGORA_DIRECT_REDIRECT_HOST || '',
|
|
redirectBaseUrl:
|
|
process.env.GENARRATIVE_PINGORA_DIRECT_REDIRECT_BASE_URL || '',
|
|
probeToken: process.env.GENARRATIVE_PINGORA_DIRECT_PROBE_TOKEN || '',
|
|
spacetimeDatabase:
|
|
process.env.GENARRATIVE_PINGORA_DIRECT_SPACETIME_DATABASE ||
|
|
process.env.GENARRATIVE_SPACETIME_DATABASE ||
|
|
DEFAULT_SPACETIME_DATABASE,
|
|
requireWssUpgrade: parseBoolEnv(
|
|
process.env.GENARRATIVE_PINGORA_DIRECT_REQUIRE_WSS_UPGRADE,
|
|
false,
|
|
'GENARRATIVE_PINGORA_DIRECT_REQUIRE_WSS_UPGRADE',
|
|
),
|
|
skipWss: parseBoolEnv(
|
|
process.env.GENARRATIVE_PINGORA_DIRECT_SKIP_WSS,
|
|
false,
|
|
'GENARRATIVE_PINGORA_DIRECT_SKIP_WSS',
|
|
),
|
|
pingoraAccessLog:
|
|
process.env.GENARRATIVE_PINGORA_DIRECT_PINGORA_ACCESS_LOG || '',
|
|
accessLogSinceLines: parseOptionalPositiveInt(
|
|
process.env.GENARRATIVE_PINGORA_DIRECT_ACCESS_LOG_SINCE_LINES,
|
|
2000,
|
|
'GENARRATIVE_PINGORA_DIRECT_ACCESS_LOG_SINCE_LINES',
|
|
),
|
|
timeoutMs: parseOptionalPositiveInt(
|
|
process.env.GENARRATIVE_PINGORA_DIRECT_TIMEOUT_MS,
|
|
DEFAULT_TIMEOUT_MS,
|
|
'GENARRATIVE_PINGORA_DIRECT_TIMEOUT_MS',
|
|
),
|
|
insecureTls: parseBoolEnv(
|
|
process.env.GENARRATIVE_PINGORA_DIRECT_INSECURE_TLS,
|
|
false,
|
|
'GENARRATIVE_PINGORA_DIRECT_INSECURE_TLS',
|
|
),
|
|
json: false,
|
|
extraPaths: [],
|
|
};
|
|
|
|
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 '--https-base-url':
|
|
result.httpsBaseUrl = requireValue(argv, ++index, arg);
|
|
break;
|
|
case '--http-base-url':
|
|
result.httpBaseUrl = requireValue(argv, ++index, arg);
|
|
break;
|
|
case '--host':
|
|
result.host = requireValue(argv, ++index, arg);
|
|
break;
|
|
case '--redirect-host':
|
|
result.redirectHost = requireValue(argv, ++index, arg);
|
|
break;
|
|
case '--redirect-base-url':
|
|
result.redirectBaseUrl = requireValue(argv, ++index, arg);
|
|
break;
|
|
case '--probe-token':
|
|
result.probeToken = requireValue(argv, ++index, arg);
|
|
break;
|
|
case '--spacetime-database':
|
|
result.spacetimeDatabase = requireValue(argv, ++index, arg);
|
|
break;
|
|
case '--require-wss-upgrade':
|
|
result.requireWssUpgrade = true;
|
|
break;
|
|
case '--skip-wss':
|
|
result.skipWss = true;
|
|
break;
|
|
case '--path':
|
|
result.extraPaths.push(requireValue(argv, ++index, arg));
|
|
break;
|
|
case '--pingora-access-log':
|
|
result.pingoraAccessLog = requireValue(argv, ++index, arg);
|
|
break;
|
|
case '--access-log-since-lines':
|
|
result.accessLogSinceLines = parseRequiredPositiveInt(
|
|
requireValue(argv, ++index, arg),
|
|
'--access-log-since-lines',
|
|
);
|
|
break;
|
|
case '--timeout-ms':
|
|
result.timeoutMs = parseRequiredPositiveInt(
|
|
requireValue(argv, ++index, arg),
|
|
'--timeout-ms',
|
|
);
|
|
break;
|
|
case '--insecure-tls':
|
|
result.insecureTls = true;
|
|
break;
|
|
case '--json':
|
|
result.json = true;
|
|
break;
|
|
default:
|
|
throw new Error(`未知参数: ${arg}`);
|
|
}
|
|
}
|
|
|
|
if (!result.httpsBaseUrl) {
|
|
throw new Error(
|
|
'缺少 --https-base-url 或 GENARRATIVE_PINGORA_DIRECT_HTTPS_BASE_URL',
|
|
);
|
|
}
|
|
validateNoControlCharacters(result.httpsBaseUrl, '--https-base-url');
|
|
const httpsUrl = new URL(result.httpsBaseUrl);
|
|
if (httpsUrl.protocol !== 'https:') {
|
|
throw new Error('--https-base-url 必须使用 https://');
|
|
}
|
|
if (result.httpBaseUrl) {
|
|
validateNoControlCharacters(result.httpBaseUrl, '--http-base-url');
|
|
const httpUrl = new URL(result.httpBaseUrl);
|
|
if (httpUrl.protocol !== 'http:') {
|
|
throw new Error('--http-base-url 必须使用 http://');
|
|
}
|
|
}
|
|
if (result.host) {
|
|
validateHostOption(result.host, '--host');
|
|
}
|
|
if (result.redirectHost) {
|
|
validateHostOption(result.redirectHost, '--redirect-host');
|
|
}
|
|
if (result.redirectBaseUrl) {
|
|
validateHttpsBaseUrl(result.redirectBaseUrl, '--redirect-base-url');
|
|
}
|
|
if (result.probeToken) {
|
|
validateNoControlCharacters(result.probeToken, '--probe-token');
|
|
}
|
|
for (const extraPath of result.extraPaths) {
|
|
validateNoControlCharacters(extraPath, '--path');
|
|
}
|
|
if (result.pingoraAccessLog) {
|
|
validateSafeAbsoluteFilePath(
|
|
result.pingoraAccessLog,
|
|
'--pingora-access-log',
|
|
);
|
|
}
|
|
validateNoControlCharacters(
|
|
result.spacetimeDatabase,
|
|
'--spacetime-database',
|
|
);
|
|
if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(result.spacetimeDatabase)) {
|
|
throw new Error(
|
|
'--spacetime-database 必须匹配 SpacetimeDB 数据库名规则 ^[a-z0-9]+(-[a-z0-9]+)*$',
|
|
);
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
function requireValue(argv, index, flag) {
|
|
const value = argv[index];
|
|
if (!value || value.startsWith('--')) {
|
|
throw new Error(`${flag} 缺少参数值`);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function validateHostOption(value, flag) {
|
|
const raw = String(value);
|
|
validateNoControlCharacters(raw, flag);
|
|
if (raw !== raw.trim() || raw.includes('://') || /[\s/?#@]/.test(raw)) {
|
|
throw new Error(
|
|
`${flag} 只能是 host 或 host:port,不能包含 scheme、路径、查询、片段或空白字符`,
|
|
);
|
|
}
|
|
try {
|
|
const parsed = new URL(`https://${raw}`);
|
|
if (
|
|
!parsed.hostname ||
|
|
parsed.pathname !== '/' ||
|
|
parsed.search ||
|
|
parsed.hash ||
|
|
parsed.username ||
|
|
parsed.password
|
|
) {
|
|
throw new Error('invalid host');
|
|
}
|
|
} catch {
|
|
throw new Error(`${flag} 不是合法的 host 或 host:port`);
|
|
}
|
|
}
|
|
|
|
function validateHttpsBaseUrl(value, flag) {
|
|
validateNoControlCharacters(value, flag);
|
|
const parsed = new URL(value);
|
|
if (parsed.protocol !== 'https:') {
|
|
throw new Error(`${flag} 必须使用 https://`);
|
|
}
|
|
if (
|
|
!parsed.hostname ||
|
|
parsed.pathname !== '/' ||
|
|
parsed.search ||
|
|
parsed.hash ||
|
|
parsed.username ||
|
|
parsed.password
|
|
) {
|
|
throw new Error(
|
|
`${flag} 只能是 HTTPS base URL,不能包含路径、查询、片段或认证信息。`,
|
|
);
|
|
}
|
|
}
|
|
|
|
function validateSafeAbsoluteFilePath(filePath, label) {
|
|
validateNoControlCharacters(filePath, label);
|
|
if (!nodePath.isAbsolute(filePath)) {
|
|
throw new Error(`${label} 必须是绝对路径。`);
|
|
}
|
|
if (isFilesystemRootPath(filePath)) {
|
|
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 = nodePath.resolve(String(value));
|
|
return resolved === nodePath.parse(resolved).root;
|
|
}
|
|
|
|
function parseOptionalPositiveInt(raw, fallback, label) {
|
|
if (raw === undefined || raw === null || String(raw).trim() === '') {
|
|
return fallback;
|
|
}
|
|
return parseRequiredPositiveInt(raw, label);
|
|
}
|
|
|
|
function parseRequiredPositiveInt(raw, label) {
|
|
validateNoControlCharacters(raw, label);
|
|
const text = String(raw ?? '').trim();
|
|
if (!/^[1-9]\d*$/.test(text)) {
|
|
throw new Error(`${label} 必须是正整数。`);
|
|
}
|
|
return Number.parseInt(text, 10);
|
|
}
|
|
|
|
function parseBoolEnv(raw, fallback, label) {
|
|
if (raw === undefined || raw === null || String(raw).trim() === '') {
|
|
return fallback;
|
|
}
|
|
validateNoControlCharacters(raw, label);
|
|
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 joinUrl(baseUrl, path) {
|
|
const base = baseUrl.endsWith('/') ? baseUrl.slice(0, -1) : baseUrl;
|
|
const suffix = path.startsWith('/') ? path : `/${path}`;
|
|
return `${base}${suffix}`;
|
|
}
|
|
|
|
async function main() {
|
|
const directRequestIds = [];
|
|
const checks = [
|
|
{
|
|
name: 'https-root',
|
|
url: joinUrl(config.httpsBaseUrl, '/'),
|
|
expectedStatuses: [200, 503],
|
|
assertHeader: assertPingoraGatewayHeader,
|
|
},
|
|
{
|
|
name: 'https-api-config',
|
|
url: joinUrl(config.httpsBaseUrl, '/api/creation-entry/config'),
|
|
expectedStatuses: [200, 401, 403, 503],
|
|
assertHeader: assertPingoraGatewayHeader,
|
|
},
|
|
{
|
|
name: 'https-spacetime-identity',
|
|
url: joinUrl(config.httpsBaseUrl, '/v1/identity'),
|
|
expectedStatuses: [200, 401, 403, 404, 405, 503],
|
|
assertHeader: assertPingoraGatewayHeader,
|
|
},
|
|
{
|
|
name: 'https-generated-forbidden',
|
|
url: joinUrl(config.httpsBaseUrl, '/generated-pingora-direct-smoke'),
|
|
expectedStatus: 404,
|
|
assertHeader: assertPingoraGatewayHeader,
|
|
},
|
|
{
|
|
name: 'https-public-healthz-forbidden',
|
|
url: joinUrl(config.httpsBaseUrl, '/healthz'),
|
|
expectedStatus: 404,
|
|
assertHeader: assertPingoraGatewayHeader,
|
|
},
|
|
];
|
|
|
|
if (config.probeToken) {
|
|
checks.push({
|
|
name: 'https-shadow-probe',
|
|
url: joinUrl(config.httpsBaseUrl, '/__genarrative_pingora/healthz'),
|
|
expectedStatus: 200,
|
|
headers: {
|
|
'X-Genarrative-Pingora-Probe': config.probeToken,
|
|
},
|
|
assertBody: (body) => body.includes('"gateway":"pingora-shadow"'),
|
|
bodyReason: 'body 应包含 gateway=pingora-shadow',
|
|
assertHeader: assertPingoraGatewayHeader,
|
|
});
|
|
}
|
|
|
|
if (config.httpBaseUrl) {
|
|
checks.push(
|
|
{
|
|
name: 'http-root-redirect',
|
|
url: joinUrl(config.httpBaseUrl, '/'),
|
|
expectedStatus: 301,
|
|
assertHeader: assertPingoraGatewayHeader,
|
|
assertLocation: (location) =>
|
|
location === directHttpsLocation(config.httpsBaseUrl, '/'),
|
|
},
|
|
{
|
|
name: 'http-api-redirect',
|
|
url: joinUrl(config.httpBaseUrl, '/api/creation-entry/config?direct=1'),
|
|
expectedStatus: 301,
|
|
assertHeader: assertPingoraGatewayHeader,
|
|
assertLocation: (location) =>
|
|
location ===
|
|
directHttpsLocation(
|
|
config.httpsBaseUrl,
|
|
'/api/creation-entry/config?direct=1',
|
|
),
|
|
},
|
|
{
|
|
name: 'http-acme-static',
|
|
url: joinUrl(config.httpBaseUrl, '/.well-known/acme-challenge/token'),
|
|
expectedStatuses: [200, 404],
|
|
assertHeader: assertPingoraGatewayHeader,
|
|
},
|
|
);
|
|
}
|
|
|
|
for (const path of config.extraPaths) {
|
|
checks.push({
|
|
name: `extra:${path}`,
|
|
url: joinUrl(config.httpsBaseUrl, path),
|
|
expectedStatuses: [200, 204, 301, 302, 304, 401, 403, 404, 503],
|
|
assertHeader: assertPingoraGatewayHeader,
|
|
});
|
|
}
|
|
|
|
const results = [];
|
|
let rootResult = null;
|
|
for (const check of checks) {
|
|
const result = await runCheck(check, directRequestIds);
|
|
if (check.name === 'https-root') {
|
|
rootResult = result;
|
|
}
|
|
results.push(result);
|
|
}
|
|
results.push(await runDiscoveredStaticAssetCheck(rootResult, directRequestIds));
|
|
results.push(await runHttp2AlpnCheck());
|
|
if (!config.skipWss) {
|
|
results.push(await runWssSubscribeCheck(directRequestIds));
|
|
}
|
|
if (config.pingoraAccessLog) {
|
|
results.push(await runDirectAccessLogCheck(directRequestIds));
|
|
}
|
|
|
|
if (config.json) {
|
|
console.log(
|
|
JSON.stringify({ ok: failures.length === 0, results }, null, 2),
|
|
);
|
|
}
|
|
}
|
|
|
|
async function runCheck(check, directRequestIds) {
|
|
const requestId = makeDirectRequestId(check.name);
|
|
const directRequest = {
|
|
requestId,
|
|
name: check.name,
|
|
method: check.method || 'GET',
|
|
path: new URL(check.url).pathname,
|
|
statusCode: null,
|
|
};
|
|
directRequestIds.push(directRequest);
|
|
const response = await requestUrl(check.url, {
|
|
method: check.method,
|
|
headers: {
|
|
'X-Request-Id': requestId,
|
|
...(check.headers || {}),
|
|
},
|
|
});
|
|
directRequest.statusCode = response.statusCode;
|
|
const expectedStatuses =
|
|
check.expectedStatuses ?? [check.expectedStatus].filter(Boolean);
|
|
|
|
if (!expectedStatuses.includes(response.statusCode)) {
|
|
failures.push(
|
|
`${check.name}: ${check.url} 返回 ${response.statusCode},预期 ${expectedStatuses.join('/')}`,
|
|
);
|
|
}
|
|
if (check.assertHeader) {
|
|
check.assertHeader(check, response);
|
|
}
|
|
if (check.assertBody && !check.assertBody(response.body)) {
|
|
failures.push(`${check.name}: ${check.bodyReason}`);
|
|
}
|
|
if (check.assertResponse) {
|
|
check.assertResponse(check, response);
|
|
}
|
|
if (
|
|
check.assertLocation &&
|
|
!check.assertLocation(String(response.headers.location || ''))
|
|
) {
|
|
failures.push(
|
|
`${check.name}: Location 不符合预期,实际 ${response.headers.location || '-'}`,
|
|
);
|
|
}
|
|
|
|
console.log(
|
|
`[pingora-direct-live] ${check.name} ${response.statusCode} ${response.elapsedMs}ms`,
|
|
);
|
|
const result = {
|
|
name: check.name,
|
|
url: check.url,
|
|
statusCode: response.statusCode,
|
|
elapsedMs: response.elapsedMs,
|
|
gateway: response.headers['x-genarrative-gateway'] || '',
|
|
location: response.headers.location || '',
|
|
requestId,
|
|
};
|
|
if (check.evidenceHeaders) {
|
|
result.headers = pickEvidenceHeaders(response.headers);
|
|
}
|
|
Object.defineProperty(result, 'bodySample', {
|
|
value: response.body,
|
|
enumerable: false,
|
|
});
|
|
Object.defineProperty(result, 'rawHeaders', {
|
|
value: response.headers,
|
|
enumerable: false,
|
|
});
|
|
return result;
|
|
}
|
|
|
|
async function runDiscoveredStaticAssetCheck(rootResult, directRequestIds) {
|
|
const assetPaths = discoverStaticAssetPaths(rootResult);
|
|
const assetPath = findStaticAssetPath(assetPaths, { fingerprinted: false });
|
|
if (!assetPath) {
|
|
console.log('[pingora-direct-live] https-static-asset skipped');
|
|
return {
|
|
name: 'https-static-asset',
|
|
skipped: true,
|
|
reason:
|
|
rootResult?.statusCode === 200
|
|
? 'root HTML 未发现 /assets 或 /admin/assets 引用'
|
|
: `root status=${rootResult?.statusCode ?? '-'}`,
|
|
};
|
|
}
|
|
|
|
const check = {
|
|
name: 'https-static-asset',
|
|
url: joinUrl(config.httpsBaseUrl, assetPath),
|
|
expectedStatus: 200,
|
|
assertHeader: assertPingoraGatewayHeader,
|
|
assertResponse: assertStaticAssetHeaders,
|
|
evidenceHeaders: true,
|
|
};
|
|
const result = await runCheck(check, directRequestIds);
|
|
const headResult = await runStaticAssetHeadCheck(assetPath, directRequestIds);
|
|
const rangeResult = await runStaticAssetRangeCheck(assetPath, directRequestIds);
|
|
const notModifiedResult = await runStaticAssetNotModifiedChecks(
|
|
assetPath,
|
|
result.rawHeaders,
|
|
directRequestIds,
|
|
);
|
|
const fingerprintedResult = await runFingerprintedStaticAssetCheck(
|
|
findStaticAssetPath(assetPaths, { fingerprinted: true }),
|
|
rootResult,
|
|
directRequestIds,
|
|
);
|
|
return {
|
|
...result,
|
|
head: headResult,
|
|
range: rangeResult,
|
|
notModified: notModifiedResult,
|
|
fingerprinted: fingerprintedResult,
|
|
};
|
|
}
|
|
|
|
function discoverStaticAssetPaths(rootResult) {
|
|
if (!rootResult || rootResult.statusCode !== 200) {
|
|
return [];
|
|
}
|
|
const body = String(rootResult.bodySample || '');
|
|
const paths = [];
|
|
const pattern = /(?:src|href)=["']([^"']*\/(?:admin\/)?assets\/[^"']+)["']/giu;
|
|
for (const match of body.matchAll(pattern)) {
|
|
try {
|
|
const pathname = new URL(match[1], config.httpsBaseUrl).pathname;
|
|
if (!paths.includes(pathname)) {
|
|
paths.push(pathname);
|
|
}
|
|
} catch {
|
|
// 保留其他可解析的资产引用,避免单个异常 href 掩盖真实证据。
|
|
}
|
|
}
|
|
return paths;
|
|
}
|
|
|
|
function findStaticAssetPath(paths, options = {}) {
|
|
const except = options.except || '';
|
|
const candidates = paths.filter((item) => item && item !== except);
|
|
if (options.fingerprinted === true) {
|
|
return candidates.find(isFingerprintedStaticAssetPath) || '';
|
|
}
|
|
if (options.fingerprinted === false) {
|
|
return (
|
|
candidates.find((item) => !isFingerprintedStaticAssetPath(item)) ||
|
|
candidates[0] ||
|
|
''
|
|
);
|
|
}
|
|
return candidates[0] || '';
|
|
}
|
|
|
|
function isFingerprintedStaticAssetPath(assetPath) {
|
|
const fileName = decodeURIComponent(assetPath.split('/').pop() || '');
|
|
return /-[A-Za-z0-9_-]{8,}\.[A-Za-z0-9]+$/u.test(fileName);
|
|
}
|
|
|
|
function assertStaticAssetHeaders(check, response) {
|
|
const cacheControl = String(response.headers['cache-control'] || '');
|
|
if (!cacheControl) {
|
|
failures.push(`${check.name}: 缺少 Cache-Control`);
|
|
}
|
|
if (response.headers['accept-ranges'] !== 'bytes') {
|
|
failures.push(
|
|
`${check.name}: 缺少 Accept-Ranges: bytes,实际 ${response.headers['accept-ranges'] || '-'}`,
|
|
);
|
|
}
|
|
if (!response.headers.etag) {
|
|
failures.push(`${check.name}: 缺少 ETag`);
|
|
}
|
|
if (!response.headers['last-modified']) {
|
|
failures.push(`${check.name}: 缺少 Last-Modified`);
|
|
}
|
|
}
|
|
|
|
function assertFingerprintedStaticAssetHeaders(check, response) {
|
|
assertStaticAssetHeaders(check, response);
|
|
const cacheControl = String(response.headers['cache-control'] || '');
|
|
if (cacheControl !== 'public, max-age=31536000, immutable') {
|
|
failures.push(
|
|
`${check.name}: Cache-Control 应为 public, max-age=31536000, immutable,实际 ${cacheControl || '-'}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
async function runStaticAssetHeadCheck(assetPath, directRequestIds, options = {}) {
|
|
const check = {
|
|
name: options.name || 'https-static-asset-head',
|
|
method: 'HEAD',
|
|
url: joinUrl(config.httpsBaseUrl, assetPath),
|
|
expectedStatus: 200,
|
|
assertHeader: assertPingoraGatewayHeader,
|
|
assertResponse: options.assertResponse || assertStaticAssetHeadHeaders,
|
|
evidenceHeaders: true,
|
|
};
|
|
return runCheck(check, directRequestIds);
|
|
}
|
|
|
|
function assertStaticAssetHeadHeaders(check, response) {
|
|
assertStaticAssetHeaders(check, response);
|
|
if (response.body !== '') {
|
|
failures.push(`${check.name}: HEAD 响应不应包含 body`);
|
|
}
|
|
if (!response.headers['content-length']) {
|
|
failures.push(`${check.name}: 缺少 Content-Length`);
|
|
}
|
|
}
|
|
|
|
function assertFingerprintedStaticAssetHeadHeaders(check, response) {
|
|
assertFingerprintedStaticAssetHeaders(check, response);
|
|
if (response.body !== '') {
|
|
failures.push(`${check.name}: HEAD 响应不应包含 body`);
|
|
}
|
|
if (!response.headers['content-length']) {
|
|
failures.push(`${check.name}: 缺少 Content-Length`);
|
|
}
|
|
}
|
|
|
|
async function runFingerprintedStaticAssetCheck(
|
|
assetPath,
|
|
rootResult,
|
|
directRequestIds,
|
|
) {
|
|
if (!assetPath) {
|
|
console.log('[pingora-direct-live] https-static-fingerprinted-asset skipped');
|
|
return {
|
|
name: 'https-static-fingerprinted-asset',
|
|
skipped: true,
|
|
reason:
|
|
rootResult?.statusCode === 200
|
|
? 'root HTML 未发现 Vite 指纹 /assets 或 /admin/assets 引用'
|
|
: `root status=${rootResult?.statusCode ?? '-'}`,
|
|
};
|
|
}
|
|
|
|
const check = {
|
|
name: 'https-static-fingerprinted-asset',
|
|
url: joinUrl(config.httpsBaseUrl, assetPath),
|
|
expectedStatus: 200,
|
|
assertHeader: assertPingoraGatewayHeader,
|
|
assertResponse: assertFingerprintedStaticAssetHeaders,
|
|
evidenceHeaders: true,
|
|
};
|
|
const result = await runCheck(check, directRequestIds);
|
|
const headResult = await runStaticAssetHeadCheck(assetPath, directRequestIds, {
|
|
name: 'https-static-fingerprinted-asset-head',
|
|
assertResponse: assertFingerprintedStaticAssetHeadHeaders,
|
|
});
|
|
const rangeResult = await runStaticAssetRangeCheck(assetPath, directRequestIds, {
|
|
name: 'https-static-fingerprinted-asset-range',
|
|
});
|
|
const notModifiedResult = await runStaticAssetNotModifiedChecks(
|
|
assetPath,
|
|
result.rawHeaders,
|
|
directRequestIds,
|
|
{
|
|
etagName: 'https-static-fingerprinted-asset-etag-304',
|
|
lastModifiedName: 'https-static-fingerprinted-asset-last-modified-304',
|
|
},
|
|
);
|
|
return {
|
|
...result,
|
|
head: headResult,
|
|
range: rangeResult,
|
|
notModified: notModifiedResult,
|
|
};
|
|
}
|
|
|
|
async function runStaticAssetRangeCheck(assetPath, directRequestIds, options = {}) {
|
|
const check = {
|
|
name: options.name || 'https-static-asset-range',
|
|
url: joinUrl(config.httpsBaseUrl, assetPath),
|
|
expectedStatus: 206,
|
|
headers: {
|
|
Range: 'bytes=0-0',
|
|
'Accept-Encoding': 'gzip',
|
|
},
|
|
assertHeader: assertPingoraGatewayHeader,
|
|
assertResponse: assertStaticAssetRangeHeaders,
|
|
evidenceHeaders: true,
|
|
};
|
|
return runCheck(check, directRequestIds);
|
|
}
|
|
|
|
async function runStaticAssetNotModifiedChecks(
|
|
assetPath,
|
|
sourceHeaders,
|
|
directRequestIds,
|
|
options = {},
|
|
) {
|
|
const result = {};
|
|
if (sourceHeaders?.etag) {
|
|
result.etag = await runCheck(
|
|
{
|
|
name: options.etagName || 'https-static-asset-etag-304',
|
|
url: joinUrl(config.httpsBaseUrl, assetPath),
|
|
expectedStatus: 304,
|
|
headers: {
|
|
'If-None-Match': sourceHeaders.etag,
|
|
'Accept-Encoding': 'gzip',
|
|
},
|
|
sourceHeaders,
|
|
assertHeader: assertPingoraGatewayHeader,
|
|
assertResponse: assertStaticAssetNotModifiedHeaders,
|
|
evidenceHeaders: true,
|
|
},
|
|
directRequestIds,
|
|
);
|
|
}
|
|
if (sourceHeaders?.['last-modified']) {
|
|
result.lastModified = await runCheck(
|
|
{
|
|
name:
|
|
options.lastModifiedName ||
|
|
'https-static-asset-last-modified-304',
|
|
url: joinUrl(config.httpsBaseUrl, assetPath),
|
|
expectedStatus: 304,
|
|
headers: {
|
|
'If-Modified-Since': sourceHeaders['last-modified'],
|
|
'Accept-Encoding': 'gzip',
|
|
},
|
|
sourceHeaders,
|
|
assertHeader: assertPingoraGatewayHeader,
|
|
assertResponse: assertStaticAssetNotModifiedHeaders,
|
|
evidenceHeaders: true,
|
|
},
|
|
directRequestIds,
|
|
);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
function assertStaticAssetNotModifiedHeaders(check, response) {
|
|
if (response.body !== '') {
|
|
failures.push(`${check.name}: 304 响应不应包含 body`);
|
|
}
|
|
if (response.headers['content-encoding']) {
|
|
failures.push(
|
|
`${check.name}: 304 响应不应压缩,实际 Content-Encoding=${response.headers['content-encoding']}`,
|
|
);
|
|
}
|
|
if (
|
|
check.sourceHeaders?.['cache-control'] &&
|
|
response.headers['cache-control'] !== check.sourceHeaders['cache-control']
|
|
) {
|
|
failures.push(
|
|
`${check.name}: Cache-Control 与源响应不一致,实际 ${response.headers['cache-control'] || '-'}`,
|
|
);
|
|
}
|
|
if (
|
|
check.sourceHeaders?.etag &&
|
|
response.headers.etag &&
|
|
response.headers.etag !== check.sourceHeaders.etag
|
|
) {
|
|
failures.push(`${check.name}: ETag 与源响应不一致`);
|
|
}
|
|
if (
|
|
check.sourceHeaders?.['last-modified'] &&
|
|
response.headers['last-modified'] &&
|
|
response.headers['last-modified'] !== check.sourceHeaders['last-modified']
|
|
) {
|
|
failures.push(`${check.name}: Last-Modified 与源响应不一致`);
|
|
}
|
|
}
|
|
|
|
function pickEvidenceHeaders(headers) {
|
|
const allowlist = [
|
|
'cache-control',
|
|
'etag',
|
|
'last-modified',
|
|
'accept-ranges',
|
|
'content-range',
|
|
'content-length',
|
|
'content-encoding',
|
|
];
|
|
const result = {};
|
|
for (const name of allowlist) {
|
|
if (headers[name] !== undefined) {
|
|
result[name] = String(headers[name]);
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
|
|
function assertStaticAssetRangeHeaders(check, response) {
|
|
if (response.headers['accept-ranges'] !== 'bytes') {
|
|
failures.push(
|
|
`${check.name}: 缺少 Accept-Ranges: bytes,实际 ${response.headers['accept-ranges'] || '-'}`,
|
|
);
|
|
}
|
|
if (!/^bytes 0-0\/\d+$/u.test(String(response.headers['content-range'] || ''))) {
|
|
failures.push(
|
|
`${check.name}: Content-Range 不符合 bytes 0-0/<len>,实际 ${response.headers['content-range'] || '-'}`,
|
|
);
|
|
}
|
|
if (String(response.headers['content-length'] || '') !== '1') {
|
|
failures.push(
|
|
`${check.name}: Content-Length 应为 1,实际 ${response.headers['content-length'] || '-'}`,
|
|
);
|
|
}
|
|
if (response.headers['content-encoding']) {
|
|
failures.push(
|
|
`${check.name}: Range 响应不应压缩,实际 Content-Encoding=${response.headers['content-encoding']}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
function runHttp2AlpnCheck() {
|
|
const parsed = new URL(config.httpsBaseUrl);
|
|
const port = Number(parsed.port || 443);
|
|
return new Promise((resolve, reject) => {
|
|
const startedAt = Date.now();
|
|
const socket = tls.connect({
|
|
host: parsed.hostname,
|
|
port,
|
|
servername: tlsServername(parsed),
|
|
ALPNProtocols: ['h2', 'http/1.1'],
|
|
rejectUnauthorized: !config.insecureTls,
|
|
});
|
|
const timeout = setTimeout(() => {
|
|
socket.destroy();
|
|
reject(new Error(`HTTP/2 ALPN 握手超时: ${config.httpsBaseUrl}`));
|
|
}, config.timeoutMs);
|
|
|
|
socket.on('secureConnect', () => {
|
|
const elapsedMs = Date.now() - startedAt;
|
|
const protocol = socket.alpnProtocol || '';
|
|
clearTimeout(timeout);
|
|
socket.destroy();
|
|
if (protocol !== 'h2') {
|
|
failures.push(
|
|
`https-http2-alpn: ALPN 实际 ${protocol || '-'},预期 h2`,
|
|
);
|
|
}
|
|
console.log(
|
|
`[pingora-direct-live] https-http2-alpn ${protocol || '-'} ${elapsedMs}ms`,
|
|
);
|
|
resolve({
|
|
name: 'https-http2-alpn',
|
|
url: config.httpsBaseUrl,
|
|
elapsedMs,
|
|
protocol,
|
|
});
|
|
});
|
|
socket.on('error', (error) => {
|
|
clearTimeout(timeout);
|
|
reject(error);
|
|
});
|
|
});
|
|
}
|
|
|
|
async function runWssSubscribeCheck(directRequestIds) {
|
|
const path = `/v1/database/${config.spacetimeDatabase}/subscribe?compression=Brotli`;
|
|
const url = buildWebSocketUrl(config.httpsBaseUrl, path);
|
|
const requestId = makeDirectRequestId('wss-spacetime-subscribe');
|
|
const directRequest = {
|
|
requestId,
|
|
name: 'wss-spacetime-subscribe',
|
|
method: 'GET',
|
|
path: `/v1/database/${config.spacetimeDatabase}/subscribe`,
|
|
statusCode: null,
|
|
};
|
|
directRequestIds.push(directRequest);
|
|
const response = await websocketHandshake(url, {
|
|
expectedStatus: config.requireWssUpgrade
|
|
? [101]
|
|
: [101, 401, 403, 404, 503],
|
|
requestId,
|
|
});
|
|
directRequest.statusCode = response.statusCode;
|
|
|
|
const gateway = response.headers['x-genarrative-gateway'] || '';
|
|
if (gateway !== 'pingora-shadow') {
|
|
failures.push(
|
|
`wss-spacetime-subscribe: 缺少 X-Genarrative-Gateway: pingora-shadow,实际 ${gateway || '-'}`,
|
|
);
|
|
}
|
|
|
|
console.log(
|
|
`[pingora-direct-live] wss-spacetime-subscribe ${response.statusCode} ${response.elapsedMs}ms`,
|
|
);
|
|
return {
|
|
name: 'wss-spacetime-subscribe',
|
|
url,
|
|
statusCode: response.statusCode,
|
|
elapsedMs: response.elapsedMs,
|
|
gateway: response.headers['x-genarrative-gateway'] || '',
|
|
requestId,
|
|
};
|
|
}
|
|
|
|
async function runDirectAccessLogCheck(directRequestIds) {
|
|
const deadline = Date.now() + config.timeoutMs;
|
|
let entries = [];
|
|
let matched = [];
|
|
let missing = directRequestIds;
|
|
let mismatched = [];
|
|
|
|
while (Date.now() <= deadline) {
|
|
const content = await readTailLines(
|
|
config.pingoraAccessLog,
|
|
config.accessLogSinceLines,
|
|
);
|
|
entries = content.split(/\r?\n/u).filter(Boolean).map(parseAccessLogLine);
|
|
({ matched, missing, mismatched } = compareDirectAccessLogEntries(
|
|
directRequestIds,
|
|
entries,
|
|
));
|
|
if (missing.length === 0 && mismatched.length === 0) {
|
|
break;
|
|
}
|
|
await sleep(100);
|
|
}
|
|
|
|
({ matched, missing, mismatched } = compareDirectAccessLogEntries(
|
|
directRequestIds,
|
|
entries,
|
|
));
|
|
|
|
if (missing.length > 0) {
|
|
failures.push(
|
|
`direct-access-log: 缺少 Pingora access log request_id=${missing
|
|
.map((item) => item.requestId)
|
|
.join(',')}`,
|
|
);
|
|
}
|
|
if (mismatched.length > 0) {
|
|
failures.push(
|
|
`direct-access-log: method/path/status 不一致 ${mismatched
|
|
.map(
|
|
(item) =>
|
|
`${item.requestId}: ${item.reasons.join(', ')}`,
|
|
)
|
|
.join('; ')}`,
|
|
);
|
|
}
|
|
|
|
console.log(
|
|
`[pingora-direct-live] direct-access-log checked=${directRequestIds.length} missing=${missing.length} mismatched=${mismatched.length}`,
|
|
);
|
|
return {
|
|
name: 'direct-access-log',
|
|
logFile: config.pingoraAccessLog,
|
|
sinceLines: config.accessLogSinceLines,
|
|
scannedLineCount: entries.length,
|
|
checked: directRequestIds.length,
|
|
matchedCount: matched.length,
|
|
missingCount: missing.length,
|
|
mismatchCount: mismatched.length,
|
|
matched,
|
|
missing,
|
|
mismatches: mismatched,
|
|
};
|
|
}
|
|
|
|
function compareDirectAccessLogEntries(directRequestIds, entries) {
|
|
const matched = [];
|
|
const missing = [];
|
|
const mismatched = [];
|
|
|
|
for (const expected of directRequestIds) {
|
|
const entry = entries.find((item) => item.request_id === expected.requestId);
|
|
if (!entry) {
|
|
missing.push(formatExpectedAccessLogEvidence(expected));
|
|
continue;
|
|
}
|
|
const actualStatus = parseAccessLogStatus(entry.status);
|
|
const actual = {
|
|
method: entry.method || '-',
|
|
path: entry.path || '-',
|
|
statusCode: actualStatus,
|
|
statusRaw: entry.status || '-',
|
|
};
|
|
const expectedEvidence = formatExpectedAccessLogEvidence(expected);
|
|
const reasons = [];
|
|
if (actual.method !== expected.method) {
|
|
reasons.push(`method expected=${expected.method} actual=${actual.method}`);
|
|
}
|
|
if (actual.path !== expected.path) {
|
|
reasons.push(`path expected=${expected.path} actual=${actual.path}`);
|
|
}
|
|
if (
|
|
expected.statusCode !== null &&
|
|
actual.statusCode !== expected.statusCode
|
|
) {
|
|
reasons.push(
|
|
`status expected=${expected.statusCode} actual=${actual.statusRaw}`,
|
|
);
|
|
}
|
|
if (reasons.length > 0) {
|
|
mismatched.push({
|
|
...expectedEvidence,
|
|
actualMethod: actual.method,
|
|
actualPath: actual.path,
|
|
actualStatusCode: actual.statusCode,
|
|
actualStatusRaw: actual.statusRaw,
|
|
reasons,
|
|
});
|
|
continue;
|
|
}
|
|
matched.push({
|
|
...expectedEvidence,
|
|
actualMethod: actual.method,
|
|
actualPath: actual.path,
|
|
actualStatusCode: actual.statusCode,
|
|
});
|
|
}
|
|
|
|
return { matched, missing, mismatched };
|
|
}
|
|
|
|
function formatExpectedAccessLogEvidence(expected) {
|
|
return {
|
|
name: expected.name,
|
|
requestId: expected.requestId,
|
|
expectedMethod: expected.method,
|
|
expectedPath: expected.path,
|
|
expectedStatusCode: expected.statusCode,
|
|
};
|
|
}
|
|
|
|
function parseAccessLogStatus(value) {
|
|
const text = String(value ?? '').trim();
|
|
if (!/^\d{3}$/u.test(text)) {
|
|
return null;
|
|
}
|
|
return Number.parseInt(text, 10);
|
|
}
|
|
|
|
function assertPingoraGatewayHeader(check, response) {
|
|
const gateway = response.headers['x-genarrative-gateway'] || '';
|
|
if (gateway !== 'pingora-shadow') {
|
|
failures.push(
|
|
`${check.name}: 缺少 X-Genarrative-Gateway: pingora-shadow,实际 ${gateway || '-'}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
function directHttpsLocation(httpsBaseUrl, path) {
|
|
if (config.redirectBaseUrl) {
|
|
return joinUrl(config.redirectBaseUrl, path);
|
|
}
|
|
if (!config.redirectHost) {
|
|
return joinUrl(httpsBaseUrl, path);
|
|
}
|
|
const parsed = new URL(httpsBaseUrl);
|
|
parsed.host = config.redirectHost;
|
|
return joinUrl(parsed.toString(), path);
|
|
}
|
|
|
|
function buildWebSocketUrl(baseUrl, path) {
|
|
const parsed = new URL(joinUrl(baseUrl, path));
|
|
parsed.protocol = parsed.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
return parsed.toString();
|
|
}
|
|
|
|
function requestUrl(url, options = {}) {
|
|
return new Promise((resolve, reject) => {
|
|
const startedAt = Date.now();
|
|
const parsed = new URL(url);
|
|
const client = parsed.protocol === 'https:' ? https : http;
|
|
const method = options.method || 'GET';
|
|
const headers = {
|
|
'User-Agent': 'genarrative-pingora-direct-live/1.0',
|
|
Accept: 'application/json,text/plain,*/*',
|
|
Connection: 'close',
|
|
...(options.headers || {}),
|
|
};
|
|
if (config.host) {
|
|
headers.Host = config.host;
|
|
}
|
|
|
|
const request = client.request(
|
|
parsed,
|
|
{
|
|
method,
|
|
timeout: config.timeoutMs,
|
|
headers,
|
|
servername: tlsServername(parsed),
|
|
rejectUnauthorized:
|
|
parsed.protocol === 'https:' && config.insecureTls
|
|
? false
|
|
: undefined,
|
|
},
|
|
(response) => {
|
|
let body = '';
|
|
response.setEncoding('utf8');
|
|
response.on('data', (chunk) => {
|
|
if (body.length < 4096) {
|
|
body += chunk;
|
|
}
|
|
});
|
|
response.on('end', () => {
|
|
resolve({
|
|
elapsedMs: Date.now() - startedAt,
|
|
statusCode: response.statusCode || 0,
|
|
headers: response.headers,
|
|
body,
|
|
});
|
|
});
|
|
},
|
|
);
|
|
|
|
request.on('timeout', () => {
|
|
request.destroy(new Error(`请求超时: ${url}`));
|
|
});
|
|
request.on('error', reject);
|
|
request.end();
|
|
});
|
|
}
|
|
|
|
function websocketHandshake(url, options) {
|
|
return new Promise((resolve, reject) => {
|
|
const startedAt = Date.now();
|
|
const parsed = new URL(url);
|
|
const secure = parsed.protocol === 'wss:';
|
|
const port = Number(parsed.port || (secure ? 443 : 80));
|
|
const key = randomBytes(16).toString('base64');
|
|
const headers = {
|
|
Host: config.host || parsed.host,
|
|
Upgrade: 'websocket',
|
|
Connection: 'Upgrade',
|
|
'Sec-WebSocket-Key': key,
|
|
'Sec-WebSocket-Version': '13',
|
|
'Sec-WebSocket-Protocol': SPACETIME_WEBSOCKET_PROTOCOL,
|
|
'User-Agent': 'genarrative-pingora-direct-live/1.0',
|
|
'X-Request-Id': options.requestId,
|
|
};
|
|
|
|
const socket = secure
|
|
? tls.connect({
|
|
host: parsed.hostname,
|
|
port,
|
|
servername: tlsServername(parsed),
|
|
rejectUnauthorized: !config.insecureTls,
|
|
})
|
|
: net.connect({ host: parsed.hostname, port });
|
|
|
|
let raw = Buffer.alloc(0);
|
|
let settled = false;
|
|
const timeout = setTimeout(() => {
|
|
settled = true;
|
|
socket.destroy();
|
|
reject(new Error(`WebSocket 请求超时: ${url}`));
|
|
}, config.timeoutMs);
|
|
|
|
function settle(response) {
|
|
if (settled) {
|
|
return;
|
|
}
|
|
settled = true;
|
|
clearTimeout(timeout);
|
|
socket.destroy();
|
|
resolve(response);
|
|
}
|
|
|
|
socket.on(secure ? 'secureConnect' : 'connect', () => {
|
|
socket.write(buildWebSocketUpgradeRequest(parsed, headers));
|
|
});
|
|
socket.on('data', (chunk) => {
|
|
raw = Buffer.concat([raw, chunk]);
|
|
const parsedResponse = tryParseWebSocketHandshake(raw);
|
|
if (!parsedResponse) {
|
|
return;
|
|
}
|
|
parsedResponse.elapsedMs = Date.now() - startedAt;
|
|
if (!options.expectedStatus.includes(parsedResponse.statusCode)) {
|
|
failures.push(
|
|
`wss-spacetime-subscribe: ${url} 返回 ${parsedResponse.statusCode},预期 ${options.expectedStatus.join('/')}`,
|
|
);
|
|
}
|
|
if (parsedResponse.statusCode === 101) {
|
|
assertWebSocketAccept(parsedResponse.headers, key, url);
|
|
assertSpacetimeWebSocketProtocol(parsedResponse.headers, url);
|
|
}
|
|
settle(parsedResponse);
|
|
});
|
|
socket.on('error', (error) => {
|
|
if (!settled) {
|
|
settled = true;
|
|
clearTimeout(timeout);
|
|
reject(error);
|
|
}
|
|
});
|
|
socket.on('close', () => {
|
|
if (!settled) {
|
|
const parsedResponse = tryParseWebSocketHandshake(raw);
|
|
if (parsedResponse) {
|
|
parsedResponse.elapsedMs = Date.now() - startedAt;
|
|
settle(parsedResponse);
|
|
return;
|
|
}
|
|
settled = true;
|
|
clearTimeout(timeout);
|
|
reject(new Error(`WebSocket 响应提前关闭: ${url}`));
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
async function readTailLines(file, maxLines) {
|
|
const content = await readFile(file, 'utf8');
|
|
const lines = content.split(/\r?\n/u);
|
|
const tail = lines.slice(Math.max(0, lines.length - maxLines - 1));
|
|
return tail.join('\n');
|
|
}
|
|
|
|
function parseAccessLogLine(line) {
|
|
const fields = {};
|
|
for (const part of line.split('\t')) {
|
|
const separator = part.indexOf('=');
|
|
if (separator <= 0) {
|
|
continue;
|
|
}
|
|
fields[part.slice(0, separator)] = unescapeAccessLogValue(
|
|
part.slice(separator + 1),
|
|
);
|
|
}
|
|
return fields;
|
|
}
|
|
|
|
function unescapeAccessLogValue(value) {
|
|
return String(value)
|
|
.replaceAll('\\t', '\t')
|
|
.replaceAll('\\n', '\n')
|
|
.replaceAll('\\r', '\r')
|
|
.replaceAll('\\\\', '\\');
|
|
}
|
|
|
|
function makeDirectRequestId(name) {
|
|
const normalized = String(name)
|
|
.toLowerCase()
|
|
.replace(/[^a-z0-9]+/gu, '-')
|
|
.replace(/^-+|-+$/gu, '')
|
|
.slice(0, 48);
|
|
return `pingora-direct-${normalized}-${randomBytes(6).toString('hex')}`;
|
|
}
|
|
|
|
function sleep(ms) {
|
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
}
|
|
|
|
function tlsServername(parsedUrl) {
|
|
const servername = config.host
|
|
? stripPort(config.host)
|
|
: stripPort(parsedUrl.hostname);
|
|
return net.isIP(servername) ? undefined : servername;
|
|
}
|
|
|
|
function stripPort(host) {
|
|
if (host.startsWith('[')) {
|
|
const end = host.indexOf(']');
|
|
return end > 0 ? host.slice(1, end) : host;
|
|
}
|
|
return host.split(':')[0] || host;
|
|
}
|
|
|
|
function buildWebSocketUpgradeRequest(parsed, headers) {
|
|
const path = `${parsed.pathname}${parsed.search}`;
|
|
const lines = [
|
|
`GET ${path} HTTP/1.1`,
|
|
...Object.entries(headers).map(([name, value]) => `${name}: ${value}`),
|
|
'',
|
|
'',
|
|
];
|
|
return lines.join('\r\n');
|
|
}
|
|
|
|
function tryParseWebSocketHandshake(raw) {
|
|
const headerEnd = raw.indexOf('\r\n\r\n');
|
|
if (headerEnd < 0) {
|
|
return null;
|
|
}
|
|
const headerText = raw.slice(0, headerEnd).toString('latin1');
|
|
const [statusLine, ...headerLines] = headerText.split('\r\n');
|
|
const statusCode = Number.parseInt(statusLine.split(' ')[1] || '0', 10);
|
|
const headers = {};
|
|
for (const line of headerLines) {
|
|
const separator = line.indexOf(':');
|
|
if (separator <= 0) {
|
|
continue;
|
|
}
|
|
headers[line.slice(0, separator).trim().toLowerCase()] = line
|
|
.slice(separator + 1)
|
|
.trim();
|
|
}
|
|
return {
|
|
elapsedMs: 0,
|
|
statusCode,
|
|
headers,
|
|
body: raw.slice(headerEnd + 4),
|
|
};
|
|
}
|
|
|
|
function assertWebSocketAccept(headers, key, url) {
|
|
const expectedAccept = createHash('sha1')
|
|
.update(`${key}258EAFA5-E914-47DA-95CA-C5AB0DC85B11`)
|
|
.digest('base64');
|
|
const actualAccept = headers['sec-websocket-accept'] || '';
|
|
if (actualAccept !== expectedAccept) {
|
|
failures.push(
|
|
`wss-spacetime-subscribe: ${url} Sec-WebSocket-Accept 不符合预期`,
|
|
);
|
|
}
|
|
if (!hasToken(headers.upgrade, 'websocket')) {
|
|
failures.push(`wss-spacetime-subscribe: ${url} 缺少 Upgrade: websocket`);
|
|
}
|
|
}
|
|
|
|
function assertSpacetimeWebSocketProtocol(headers, url) {
|
|
const actual = headers['sec-websocket-protocol'] || '';
|
|
if (actual !== SPACETIME_WEBSOCKET_PROTOCOL) {
|
|
failures.push(
|
|
`wss-spacetime-subscribe: ${url} Sec-WebSocket-Protocol 实际 ${actual || '-'},预期 ${SPACETIME_WEBSOCKET_PROTOCOL}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
function hasToken(value, expected) {
|
|
if (!value) {
|
|
return false;
|
|
}
|
|
const normalized = expected.toLowerCase();
|
|
return String(value)
|
|
.split(',')
|
|
.map((token) => token.trim().toLowerCase())
|
|
.includes(normalized);
|
|
}
|