Files
Genarrative/scripts/check-pingora-direct-live.mjs
T
kdletters e9c3dc1120 退役旧创作模板业务
保留 SpacetimeDB 历史表、迁移白名单与旧业务源码
切换前端 active 入口并解除旧创作页面和路由编译链
移除旧后端路由、worker 与纯业务 crate 依赖
收敛 SpacetimeDB 模块为历史数据壳
同步 Nginx、Pingora、验证门禁与架构文档
2026-07-17 22:07:52 +08:00

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);
}