#!/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 [options] Options: --https-base-url HTTPS base URL served directly by pingora-gateway. --http-base-url Optional HTTP base URL that should 301 to HTTPS. --host Optional Host header for local --resolve style checks. --redirect-host Optional expected Location host for HTTP redirects. --redirect-base-url Optional expected HTTPS base URL for HTTP redirects. --probe-token Optional Pingora shadow probe token. --spacetime-database 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 Extra HTTPS path to probe; repeatable. --pingora-access-log Optional Pingora access log path. When set, direct live asserts its generated request ids were logged. --access-log-since-lines Tail lines to read from Pingora access log, default 2000. --timeout-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/,实际 ${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); }