e9c3dc1120
保留 SpacetimeDB 历史表、迁移白名单与旧业务源码 切换前端 active 入口并解除旧创作页面和路由编译链 移除旧后端路由、worker 与纯业务 crate 依赖 收敛 SpacetimeDB 模块为历史数据壳 同步 Nginx、Pingora、验证门禁与架构文档
1388 lines
41 KiB
JavaScript
1388 lines
41 KiB
JavaScript
#!/usr/bin/env node
|
|
|
|
import { createHash, randomBytes } from 'node:crypto';
|
|
import { readFile } from 'node:fs/promises';
|
|
import http from 'node:http';
|
|
import https from 'node:https';
|
|
import net from 'node:net';
|
|
import nodePath from 'node:path';
|
|
import tls from 'node:tls';
|
|
|
|
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-history',
|
|
url: joinUrl(config.httpsBaseUrl, '/api/assets/history'),
|
|
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/assets/history?direct=1'),
|
|
expectedStatus: 301,
|
|
assertHeader: assertPingoraGatewayHeader,
|
|
assertLocation: (location) =>
|
|
location ===
|
|
directHttpsLocation(
|
|
config.httpsBaseUrl,
|
|
'/api/assets/history?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);
|
|
}
|