#!/usr/bin/env node import { spawn, spawnSync } from 'node:child_process'; import { createHash, randomBytes } from 'node:crypto'; import { existsSync } from 'node:fs'; import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; import http from 'node:http'; import https from 'node:https'; import net from 'node:net'; import os from 'node:os'; import path from 'node:path'; import { gunzipSync } from 'node:zlib'; const repoRoot = process.cwd(); const failures = []; const childProcesses = []; const servers = []; const sockets = new Set(); const tempDirs = []; const SPACETIME_WEBSOCKET_PROTOCOL = 'v2.bsatn.spacetimedb'; const SECRET_VALUE_FLAGS = new Set(['--probe-token']); const PNG_MAGIC_BYTES = Buffer.from([ 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, ]); const config = parseArgs(process.argv.slice(2)); try { await main(); } catch (error) { failures.push(error instanceof Error ? error.message : String(error)); } finally { await cleanup(); } if (failures.length > 0) { console.error('\n[pingora-gateway-smoke] 未通过:'); for (const failure of failures) { console.error(`- ${failure}`); } process.exit(1); } console.log('\n[pingora-gateway-smoke] 通过'); async function main() { const tempRoot = await mkdtemp( path.join(os.tmpdir(), 'genarrative-pingora-smoke-'), ); tempDirs.push(tempRoot); const webRoot = path.join(tempRoot, 'web'); const acmeRoot = path.join(tempRoot, 'acme'); const maintenanceFile = path.join(tempRoot, 'maintenance', 'enabled'); const maintenancePageFile = path.join(tempRoot, 'maintenance', 'page.html'); const accessLogFile = path.join(tempRoot, 'logs', 'pingora.access.log'); await prepareStaticRoots(webRoot, acmeRoot); const api = await startApiMock(); const spacetime = await startSpacetimeMock(); const gitea = await startGiteaMock(); const listenPort = await getFreePort(); const tlsListenPort = await getFreePort(); const redirectListenPort = await getFreePort(); const tlsFiles = await createSelfSignedCertificate(tempRoot); const probeToken = `probe-${randomBytes(8).toString('hex')}`; if (!config.skipBuild) { runCommand('cargo', [ 'build', '-p', 'pingora-gateway', '--manifest-path', 'server-rs/Cargo.toml', ]); } const gatewayBinary = resolveGatewayBinary(); expectInvalidConfigRejected( gatewayBinary, { GENARRATIVE_PINGORA_GATEWAY_PROBE_TOKEN: '__GENARRATIVE_PINGORA_PROBE_TOKEN__', }, 'GENARRATIVE_PINGORA_GATEWAY_PROBE_TOKEN', 'probe token 占位值', ); expectInvalidConfigRejected( gatewayBinary, { GENARRATIVE_PINGORA_GATEWAY_GZIP_LEVEL: '10', }, 'GENARRATIVE_PINGORA_GATEWAY_GZIP_LEVEL', 'gzip 等级越界', ); expectInvalidConfigRejected( gatewayBinary, { GENARRATIVE_PINGORA_GATEWAY_COMPRESSION_ALGORITHMS: 'br', }, 'GENARRATIVE_PINGORA_GATEWAY_COMPRESSION_ALGORITHMS', '未验收压缩算法', ); expectInvalidConfigRejected( gatewayBinary, { GENARRATIVE_PINGORA_GATEWAY_UPSTREAM_API_READ_TIMEOUT_SECONDS: '0', }, 'GENARRATIVE_PINGORA_GATEWAY_UPSTREAM_API_READ_TIMEOUT_SECONDS', '上游读取超时为 0', ); expectInvalidConfigRejected( gatewayBinary, { GENARRATIVE_PINGORA_GATEWAY_TLS_LISTEN: `127.0.0.1:${tlsListenPort}`, }, 'GENARRATIVE_PINGORA_GATEWAY_TLS_CERT_FILE', 'TLS 证书未配置', ); expectInvalidConfigRejected( gatewayBinary, { GENARRATIVE_PINGORA_GATEWAY_HTTP_REDIRECT_LISTEN: `127.0.0.1:${redirectListenPort}`, }, 'GENARRATIVE_PINGORA_GATEWAY_HTTP_REDIRECT_LISTEN', 'HTTP 重定向未配置 TLS 入口', ); expectInvalidConfigRejected( gatewayBinary, { GENARRATIVE_PINGORA_GATEWAY_GITEA_HOSTS: 'git.genarrative.world', }, 'GENARRATIVE_PINGORA_GATEWAY_GITEA_UPSTREAM', 'Gitea Host 缺少上游', ); expectInvalidConfigRejected( gatewayBinary, { GENARRATIVE_PINGORA_GATEWAY_GITEA_UPSTREAM: '127.0.0.1:3000', }, 'GENARRATIVE_PINGORA_GATEWAY_GITEA_HOSTS', 'Gitea 上游缺少 Host', ); const gateway = spawn(gatewayBinary, [], { cwd: repoRoot, env: { ...smokeEnv(), GENARRATIVE_PINGORA_GATEWAY_LISTEN: `127.0.0.1:${listenPort}`, GENARRATIVE_PINGORA_GATEWAY_TLS_LISTEN: `127.0.0.1:${tlsListenPort}`, GENARRATIVE_PINGORA_GATEWAY_TLS_CERT_FILE: tlsFiles.certFile, GENARRATIVE_PINGORA_GATEWAY_TLS_KEY_FILE: tlsFiles.keyFile, GENARRATIVE_PINGORA_GATEWAY_HTTP_REDIRECT_LISTEN: `127.0.0.1:${redirectListenPort}`, GENARRATIVE_PINGORA_GATEWAY_HTTP_REDIRECT_TARGET_SCHEME: 'https', GENARRATIVE_PINGORA_GATEWAY_API_UPSTREAM: `127.0.0.1:${api.port}`, GENARRATIVE_PINGORA_GATEWAY_SPACETIME_UPSTREAM: `127.0.0.1:${spacetime.port}`, GENARRATIVE_PINGORA_GATEWAY_GITEA_HOSTS: 'git.genarrative.world, Git-Alias.Genarrative.World:443', GENARRATIVE_PINGORA_GATEWAY_GITEA_UPSTREAM: `127.0.0.1:${gitea.port}`, GENARRATIVE_PINGORA_GATEWAY_WEB_ROOT: webRoot, GENARRATIVE_PINGORA_GATEWAY_ACME_ROOT: acmeRoot, GENARRATIVE_PINGORA_GATEWAY_MAINTENANCE_FILE: maintenanceFile, GENARRATIVE_PINGORA_GATEWAY_MAINTENANCE_PAGE_FILE: maintenancePageFile, GENARRATIVE_PINGORA_GATEWAY_ACCESS_LOG_FILE: accessLogFile, GENARRATIVE_PINGORA_GATEWAY_PROBE_TOKEN: probeToken, GENARRATIVE_PINGORA_GATEWAY_FORWARDED_PROTO: 'https', GENARRATIVE_PINGORA_GATEWAY_COMPRESSION_ALGORITHMS: 'gzip', GENARRATIVE_PINGORA_GATEWAY_GZIP_ENABLED: 'true', GENARRATIVE_PINGORA_GATEWAY_GZIP_LEVEL: '5', GENARRATIVE_PINGORA_GATEWAY_UPSTREAM_CONNECT_TIMEOUT_MS: '1000', GENARRATIVE_PINGORA_GATEWAY_UPSTREAM_DEFAULT_READ_TIMEOUT_SECONDS: '2', GENARRATIVE_PINGORA_GATEWAY_UPSTREAM_API_READ_TIMEOUT_SECONDS: '1', GENARRATIVE_PINGORA_GATEWAY_UPSTREAM_LONG_READ_TIMEOUT_SECONDS: '60', GENARRATIVE_PINGORA_GATEWAY_UPSTREAM_WRITE_TIMEOUT_SECONDS: '2', GENARRATIVE_PINGORA_GATEWAY_TRUST_X_FORWARDED_FOR: 'true', GENARRATIVE_PINGORA_GATEWAY_TRUSTED_FRONT_PROXY_CONFIRMED: 'true', GENARRATIVE_PINGORA_GATEWAY_MAX_API_BODY_BYTES: '12', GENARRATIVE_PINGORA_GATEWAY_API_MAX_CONCURRENT: '1', GENARRATIVE_PINGORA_GATEWAY_API_RATE_PER_SECOND: '1', GENARRATIVE_PINGORA_GATEWAY_API_BURST: '0', GENARRATIVE_PINGORA_GATEWAY_ADMIN_API_MAX_CONCURRENT: '1', GENARRATIVE_PINGORA_GATEWAY_ADMIN_API_RATE_PER_SECOND: '0', GENARRATIVE_PINGORA_GATEWAY_ADMIN_API_BURST: '0', GENARRATIVE_PINGORA_GATEWAY_SPACETIME_MAX_CONCURRENT: '16', GENARRATIVE_PINGORA_GATEWAY_SPACETIME_RATE_PER_SECOND: '100', GENARRATIVE_PINGORA_GATEWAY_SPACETIME_BURST: '100', GENARRATIVE_PINGORA_GATEWAY_LOG: config.verbose ? 'info,pingora=info,pingora_gateway=info' : 'warn,pingora=warn,pingora_gateway=warn', }, stdio: ['ignore', 'pipe', 'pipe'], }); childProcesses.push({ child: gateway, name: 'pingora-gateway' }); collectProcessLogs(gateway, 'pingora-gateway'); const baseUrl = `http://127.0.0.1:${listenPort}`; const tlsBaseUrl = `https://127.0.0.1:${tlsListenPort}`; const redirectBaseUrl = `http://127.0.0.1:${redirectListenPort}`; await waitForHttp(`${baseUrl}/`, 200); await runDirectLiveSmoke(tlsBaseUrl, redirectBaseUrl, probeToken, accessLogFile); await runSmokeCases( baseUrl, tlsBaseUrl, redirectBaseUrl, probeToken, maintenanceFile, maintenancePageFile, accessLogFile, api, spacetime, gitea, ); } function expectInvalidConfigRejected( gatewayBinary, env, expectedNeedle, label, ) { console.log(`[pingora-gateway-smoke] 配置错误启动失败: ${label}`); const result = spawnSync(gatewayBinary, [], { cwd: repoRoot, env: { ...smokeEnv(), GENARRATIVE_PINGORA_GATEWAY_LISTEN: '127.0.0.1:0', ...env, }, shell: false, encoding: 'utf8', timeout: 3000, }); if (result.error?.code === 'ETIMEDOUT') { failures.push(`配置错误启动失败: ${label} 未被拒绝,进程超时后已终止`); } else if (result.status === 0) { failures.push(`配置错误启动失败: ${label} 未被拒绝`); } const output = `${result.stdout || ''}\n${result.stderr || ''}`; if (!output.includes(expectedNeedle)) { failures.push(`配置错误启动失败: ${label} 错误输出缺少 ${expectedNeedle}`); } } function parseArgs(argv) { const result = { skipBuild: false, verbose: false, }; for (const arg of argv) { switch (arg) { case '--skip-build': result.skipBuild = true; break; case '--verbose': result.verbose = true; break; case '-h': case '--help': console.log(`Usage: node scripts/check-pingora-gateway-smoke.mjs [--skip-build] [--verbose] 本地启动 mock api-server、mock SpacetimeDB 和 pingora-gateway, 覆盖静态路由、API 代理、body limit、429 接流保护、维护模式和 WebSocket Upgrade。 `); process.exit(0); break; default: throw new Error(`未知参数: ${arg}`); } } return result; } async function prepareStaticRoots(webRoot, acmeRoot) { await mkdir(path.join(webRoot, 'admin', 'assets'), { recursive: true }); await mkdir(path.join(webRoot, 'assets'), { recursive: true }); await mkdir( path.join( webRoot, 'Icons', "Admurin's Pixel Items", "Admurin's Pixel Items", 'General', 'Singles', ), { recursive: true }, ); await mkdir(path.join(acmeRoot, '.well-known', 'acme-challenge'), { recursive: true, }); await writeFile( path.join(webRoot, 'index.html'), [ '
site-shell
', '', '', ].join(''), ); await writeFile( path.join(webRoot, 'admin', 'index.html'), '
admin-shell
', ); await writeFile( path.join(webRoot, 'assets', 'app.js'), 'console.log("site asset");\n', ); await writeFile( path.join(webRoot, 'assets', 'index-B4dmVw0r.js'), 'console.log("fingerprinted site asset");\n', ); await writeFile( path.join(webRoot, 'assets', 'large-app.js'), `console.log("site asset large");\n${'/* asset filler */\n'.repeat(120)}`, ); await writeFile( path.join(webRoot, 'assets', 'large-image.webp'), Buffer.alloc(4096, 0x52), ); await writeFile( path.join( webRoot, 'Icons', "Admurin's Pixel Items", "Admurin's Pixel Items", 'General', 'Singles', '499_Iron_Gear.png', ), Buffer.concat([ PNG_MAGIC_BYTES, Buffer.from([0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52]), ]), ); await writeFile( path.join(webRoot, 'admin', 'assets', 'admin.js'), 'console.log("admin asset");', ); await writeFile( path.join(webRoot, 'admin', 'assets', 'admin-Ca8f3012.css'), 'body{color:#123456;}', ); await writeFile( path.join(webRoot, 'maintenance.html'), '
default-maintenance
', ); await writeFile(path.join(webRoot, '404.html'), '
not-found-page
'); await writeFile( path.join(acmeRoot, '.well-known', 'acme-challenge', 'token'), 'acme-token', ); } async function createSelfSignedCertificate(tempRoot) { const tlsDir = path.join(tempRoot, 'tls'); await mkdir(tlsDir, { recursive: true }); const certFile = path.join(tlsDir, 'cert.pem'); const keyFile = path.join(tlsDir, 'key.pem'); runCommand('openssl', [ 'req', '-x509', '-newkey', 'rsa:2048', '-nodes', '-keyout', keyFile, '-out', certFile, '-subj', '/CN=localhost', '-addext', 'subjectAltName=DNS:localhost,IP:127.0.0.1', '-days', '1', ]); return { certFile, keyFile }; } async function startApiMock() { const state = { requests: [], releaseHold: undefined, }; const server = http.createServer(async (request, response) => { let body; try { body = await readRequestBody(request); } catch (error) { state.requests.push({ method: request.method || '', url: request.url || '', headers: request.headers, body: Buffer.alloc(0), aborted: true, error: error instanceof Error ? error.message : String(error), }); return; } state.requests.push({ method: request.method || '', url: request.url || '', headers: request.headers, body, }); if (request.url?.endsWith('/hold')) { await new Promise((resolve) => { state.releaseHold = resolve; }); } else if (request.url?.endsWith('/upstream-close')) { request.socket.destroy(); return; } else if (request.url?.endsWith('/slow')) { await delay(1500); } response.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8', 'X-Upstream': 'api', }); response.end( JSON.stringify({ ok: true, upstream: 'api', method: request.method, url: request.url, requestId: request.headers['x-request-id'] || '', forwardedProto: request.headers['x-forwarded-proto'] || '', forwardedHost: request.headers['x-forwarded-host'] || '', forwardedFor: request.headers['x-forwarded-for'] || '', realIp: request.headers['x-real-ip'] || '', host: request.headers.host || '', bodyBytes: body.length, }), ); }); const port = await listen(server); return { port, state, }; } async function startSpacetimeMock() { const state = { websocketMessages: [], requests: [], }; const server = http.createServer(async (request, response) => { state.requests.push({ method: request.method || '', url: request.url || '', headers: request.headers, }); response.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8', }); response.end( JSON.stringify({ ok: true, upstream: 'spacetime', url: request.url }), ); }); server.on('upgrade', (request, socket) => { socket.on('error', () => {}); state.requests.push({ method: request.method || '', url: request.url || '', headers: request.headers, }); const key = request.headers['sec-websocket-key']; if (!key) { socket.destroy(); return; } const accept = createHash('sha1') .update(`${key}258EAFA5-E914-47DA-95CA-C5AB0DC85B11`) .digest('base64'); socket.write( [ 'HTTP/1.1 101 Switching Protocols', 'Upgrade: websocket', 'Connection: Upgrade', `Sec-WebSocket-Protocol: ${SPACETIME_WEBSOCKET_PROTOCOL}`, `Sec-WebSocket-Accept: ${accept}`, '', '', ].join('\r\n'), ); socket.write(encodeWebSocketTextFrame('spacetime-upgrade-ok')); socket.on('data', (chunk) => { state.websocketMessages.push(chunk); }); }); const port = await listen(server); return { port, state, }; } async function startGiteaMock() { const state = { requests: [], }; const server = http.createServer(async (request, response) => { const body = await readRequestBody(request); state.requests.push({ method: request.method || '', url: request.url || '', headers: request.headers, body, }); response.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8', 'X-Upstream': 'gitea', }); response.end( JSON.stringify({ ok: true, upstream: 'gitea', method: request.method, url: request.url, forwardedProto: request.headers['x-forwarded-proto'] || '', forwardedHost: request.headers['x-forwarded-host'] || '', host: request.headers.host || '', }), ); }); const port = await listen(server); return { port, state, }; } async function runSmokeCases( baseUrl, tlsBaseUrl, redirectBaseUrl, probeToken, maintenanceFile, maintenancePageFile, accessLogFile, api, spacetime, gitea, ) { await expectHttp( baseUrl, '/', 200, 'site-shell', '主站根路径返回 index.html', { validate: (response) => response.headers['cache-control'] === 'no-cache', }, ); await expectHttp( baseUrl, '/creation', 200, 'site-shell', '新创作主页深链回退 index.html', { validate: (response) => response.headers['cache-control'] === 'no-cache', }, ); await expectHttp( baseUrl, '/project', 200, 'site-shell', '主站 allowlist 深链回退 index.html', { validate: (response) => response.headers['cache-control'] === 'no-cache', }, ); await expectHttp( baseUrl, '/profile', 200, 'site-shell', '个人页深链回退 index.html', { validate: (response) => response.headers['cache-control'] === 'no-cache', }, ); await expectHttp( baseUrl, '/PROJECT/', 200, 'site-shell', '主站 allowlist 允许大小写差异和尾部斜杠', ); for (const unknownPath of [ '/some/deep/link', '/creation/not-exist', '/runtime/not-exist', '/puzzle/not-exist', ]) { await expectHttp( baseUrl, unknownPath, 404, '', `主站未知路径返回真实 404: ${unknownPath}`, ); } await expectHttp( baseUrl, '/some/browser/navigation', 404, 'not-found-page', '浏览器导航未知路径返回品牌 404 页面', { headers: { Accept: 'text/html,application/xhtml+xml', }, validate: (response) => response.headers['cache-control'] === 'no-store' && response.headers['content-type']?.startsWith('text/html'), }, ); await expectHttp(baseUrl, '/admin', 301, '', '/admin 301 到 /admin/', { validate: (response) => response.headers.location === '/admin/', }); await expectHttp( baseUrl, '/admin/settings', 200, 'admin-shell', '后台深链回退 admin/index.html', ); const staticAssetResponse = await expectHttp( baseUrl, '/assets/app.js', 200, 'site asset', '主站静态资源精确读取', { validate: (response) => response.headers['cache-control'] === 'no-cache', }, ); assertStaticValidatorHeaders(staticAssetResponse, '主站静态资源精确读取'); ensure( staticAssetResponse.headers['accept-ranges'] === 'bytes', `主站静态资源精确读取: 缺少 Accept-Ranges=bytes,实际 ${staticAssetResponse.headers['accept-ranges'] || '-'}`, ); await expectHttp( baseUrl, '/assets/app.js', 200, '', 'HEAD 静态资源只返回响应头', { method: 'HEAD', validate: (response) => response.body === '' && response.headers.etag === staticAssetResponse.headers.etag && response.headers['last-modified'] === staticAssetResponse.headers['last-modified'] && Number(response.headers['content-length']) === Buffer.byteLength('console.log("site asset");\n'), }, ); await expectStaticNotModifiedByEtag( baseUrl, '/assets/app.js', staticAssetResponse, { headers: { 'X-Request-Id': 'static-etag-304-request-id', }, }, ); await expectStaticNotModifiedByLastModified( baseUrl, '/assets/app.js', staticAssetResponse, { headers: { 'X-Request-Id': 'static-last-modified-304-request-id', }, }, ); await expectHttp( baseUrl, '/assets/app.js', 405, '', '静态资源非读取方法返回 405', { method: 'POST', headers: { 'X-Request-Id': 'static-method-405-request-id', }, body: 'ignored', validate: (response) => response.body === '' && response.headers.allow === 'GET, HEAD' && response.headers['x-genarrative-gateway'] === 'pingora-shadow', }, ); await expectStaticRange( baseUrl, '/assets/app.js', 'bytes=0-6', 206, 'console', 'bytes 0-6/27', '静态资源 Range 返回 206', { headers: { 'X-Request-Id': 'static-range-206-request-id', }, }, ); await expectStaticRange( baseUrl, '/assets/app.js', 'bytes=0-6', 206, 'console', 'bytes 0-6/27', '静态资源 If-Range 日期匹配返回 206', { headers: { 'If-Range': staticAssetResponse.headers['last-modified'], }, }, ); await expectStaticRangeFallback( baseUrl, '/assets/app.js', 'bytes=0-6', 'Tue, 14 Nov 2023 22:13:12 GMT', '静态资源 If-Range 旧日期回完整文件', ); await expectStaticRangeFallback( baseUrl, '/assets/app.js', 'bytes=0-6', staticAssetResponse.headers.etag, '静态资源 If-Range 弱 ETag 回完整文件', ); await expectStaticRange( baseUrl, '/assets/app.js', 'bytes=-8', 206, 'sset");\n', 'bytes 19-26/27', '静态资源 suffix Range 返回 206', ); await expectStaticRange( baseUrl, '/assets/app.js', 'bytes=999-1000', 416, '', 'bytes */27', '静态资源越界 Range 返回 416', { headers: { 'X-Request-Id': 'static-range-416-request-id', }, }, ); await expectStaticRange( baseUrl, '/assets/app.js', 'bytes=0-6', 206, '', 'bytes 0-6/27', 'HEAD 静态资源 Range 只返回响应头', { method: 'HEAD', expectedContentLength: 7, headers: { 'X-Request-Id': 'static-head-range-206-request-id', }, }, ); await expectHttp( baseUrl, '/assets/index-B4dmVw0r.js', 200, 'fingerprinted site asset', '主站指纹静态资源长缓存', { validate: (response) => response.headers['cache-control'] === 'public, max-age=31536000, immutable', }, ); await expectNotCompressedResponse( baseUrl, '/assets/app.js', 'site asset', 'gzip 最小长度不压缩小响应', ); await expectGzipResponse(baseUrl, '/assets/large-app.js', 'site asset large'); await expectGzipResponse(baseUrl, '/assets/large-app.js', 'site asset large', { acceptEncoding: 'br, gzip', label: 'gzip-only 压缩算法白名单', }); await expectNotCompressedResponse( baseUrl, '/assets/large-image.webp', undefined, 'gzip types 不压缩图片资源', ); await expectStaticPng( baseUrl, "/Icons/Admurin%27s%20Pixel%20Items/Admurin%27s%20Pixel%20Items/General/Singles/499_Iron_Gear.png", '百分号编码图标路径返回 PNG', ); await expectHttp( baseUrl, '/assets%2ffavicon.svg', 404, '', '静态路径拒绝编码斜杠', ); await expectRawPathHttp( baseUrl, '/assets/%2e%2e/favicon.svg', 404, '', '静态路径拒绝编码上级目录', ); await expectHttp( baseUrl, '/assets/%GG', 404, '', '静态路径拒绝非法百分号编码', ); await expectHttp( baseUrl, '/admin/assets/admin.js', 200, 'admin asset', '后台静态资源精确读取', { validate: (response) => response.headers['cache-control'] === 'no-cache', }, ); await expectHttp( baseUrl, '/admin/assets/admin-Ca8f3012.css', 200, 'body{color', '后台指纹静态资源长缓存', { validate: (response) => response.headers['cache-control'] === 'public, max-age=31536000, immutable', }, ); await expectHttp( baseUrl, '/.well-known/acme-challenge/token', 200, 'acme-token', 'ACME challenge 读取', { validate: (response) => response.headers['cache-control'] === 'no-cache', }, ); await expectHttp( tlsBaseUrl, '/', 200, 'site-shell', 'TLS 直连接口返回 index.html', { insecureTls: true }, ); await expectHttp( redirectBaseUrl, '/api/assets/history?kind=character_visual&from=smoke', 301, '', 'HTTP 入口 301 到 HTTPS', { headers: { Host: 'example.test' }, validate: (response) => response.headers.location === 'https://example.test/api/assets/history?kind=character_visual&from=smoke', }, ); await expectHttp( redirectBaseUrl, '/.well-known/acme-challenge/token', 200, 'acme-token', 'HTTP 重定向入口保留 ACME challenge', ); await expectHttp( baseUrl, '/generated-puzzle-assets/a.png', 404, '', 'generated 私有资产公网拒绝', ); await expectHttp(baseUrl, '/healthz', 404, '', '公网 healthz 拒绝'); await expectHttp( baseUrl, '/v1/ping', 404, '', '非白名单 SpacetimeDB 路由拒绝', ); await expectHttp( baseUrl, '/__genarrative_pingora/healthz', 404, '', 'shadow probe 无 token 返回 404', ); await expectHttp( baseUrl, '/__genarrative_pingora/healthz', 200, '"gateway":"pingora-shadow"', 'shadow probe token 通过', { headers: { 'X-Genarrative-Pingora-Probe': probeToken }, }, ); const apiResponse = await expectHttp( baseUrl, '/api/assets/history', 200, '"upstream":"api"', '通用 API 转发', { headers: { 'X-Request-Id': 'smoke-request-id', 'X-Forwarded-For': '203.0.113.10', Host: 'example.test', }, }, ); ensure( apiResponse.headers['x-genarrative-gateway'] === 'pingora-shadow', 'API 响应缺少网关标记', ); ensure( apiResponse.headers['x-accel-buffering'] === 'no', 'API 响应缺少 X-Accel-Buffering=no', ); const apiPayload = JSON.parse(apiResponse.body); ensure( apiPayload.requestId === 'smoke-request-id', 'API 上游未收到 X-Request-Id', ); ensure( apiPayload.forwardedProto === 'https', 'API 上游未收到配置的 X-Forwarded-Proto', ); ensure(apiPayload.host === 'example.test', 'API 上游 Host 未透传'); ensure( apiPayload.forwardedHost === 'example.test', 'API 上游未收到 X-Forwarded-Host', ); ensure( apiPayload.forwardedFor.endsWith(', 127.0.0.1'), `API 上游 X-Forwarded-For 未追加 TCP 对端 IP:${apiPayload.forwardedFor}`, ); ensure( apiPayload.realIp === '127.0.0.1', `API 上游 X-Real-IP 未使用 TCP 对端 IP:${apiPayload.realIp}`, ); await expectHttp( baseUrl, '/api/upload', 413, 'PAYLOAD_TOO_LARGE', 'Content-Length body limit', { method: 'POST', body: '0123456789abcdef', }, ); await expectChunkedLimit(baseUrl, '/api/upload'); await expectConcurrencyLimit(baseUrl, api); const rateLimitHeaders = { 'X-Forwarded-For': '203.0.113.13' }; const beforeRateLimitRequestCount = api.state.requests.length; await expectHttp( baseUrl, '/api/rate-limited', 200, '"upstream":"api"', 'API RPS 首次请求放行', { headers: rateLimitHeaders, }, ); await expectHttp( baseUrl, '/api/rate-limited', 429, 'GATEWAY_RATE_LIMITED', 'API RPS 保护', { headers: rateLimitHeaders, }, ); ensure( api.state.requests.length === beforeRateLimitRequestCount + 1, 'API RPS 保护的 429 请求不应打到上游', ); await expectHttp( baseUrl, '/api/upstream-close', 502, 'GATEWAY_UPSTREAM_ERROR', 'API 上游断连返回稳定 JSON', { headers: { 'X-Forwarded-For': '203.0.113.14' }, }, ); await expectHttp( baseUrl, '/api/slow', 504, 'GATEWAY_UPSTREAM_TIMEOUT', 'API 上游读取超时返回稳定 JSON', { headers: { 'X-Forwarded-For': '203.0.113.15' }, timeoutMs: 5000, }, ); await expectHttp( baseUrl, '/v1/identity', 200, '"upstream":"spacetime"', 'SpacetimeDB identity 转发', ); await expectWebSocketUpgrade( baseUrl, '/v1/database/genarrative/subscribe', spacetime, ); const giteaBeforeRequests = gitea.state.requests.length; const giteaResponse = await expectHttp( baseUrl, '/api/v1/repos/GenarrativeAI/Genarrative', 200, '"upstream":"gitea"', 'Gitea Host 路由转发', { headers: { Host: 'git.genarrative.world:443', 'X-Request-Id': 'gitea-host-request-id', }, }, ); const giteaPayload = JSON.parse(giteaResponse.body || '{}'); ensure( giteaPayload.host === 'git.genarrative.world:443', `Gitea 上游 Host 未透传:${giteaPayload.host || '-'}`, ); ensure( giteaPayload.forwardedHost === 'git.genarrative.world:443', `Gitea 上游 X-Forwarded-Host 未透传:${giteaPayload.forwardedHost || '-'}`, ); ensure( giteaPayload.forwardedProto === 'https', `Gitea 上游 X-Forwarded-Proto 未对齐:${giteaPayload.forwardedProto || '-'}`, ); await expectHttp( baseUrl, '/api/v1/repos/GenarrativeAI/Genarrative', 200, '"upstream":"gitea"', 'Gitea Host 别名路由转发', { headers: { Host: 'git-alias.genarrative.world', }, }, ); ensure( gitea.state.requests.length === giteaBeforeRequests + 2, `Gitea Host 路由没有稳定打到 Gitea mock:${gitea.state.requests.length - giteaBeforeRequests}`, ); await expectAccessLog(accessLogFile); await expectAccessLogEntries(accessLogFile, [ { requestId: 'static-etag-304-request-id', method: 'GET', path: '/assets/app.js', status: 304, }, { requestId: 'static-last-modified-304-request-id', method: 'GET', path: '/assets/app.js', status: 304, }, { requestId: 'static-method-405-request-id', method: 'POST', path: '/assets/app.js', status: 405, }, { requestId: 'static-range-206-request-id', method: 'GET', path: '/assets/app.js', status: 206, }, { requestId: 'static-range-416-request-id', method: 'GET', path: '/assets/app.js', status: 416, }, { requestId: 'static-head-range-206-request-id', method: 'HEAD', path: '/assets/app.js', status: 206, }, { requestId: 'gitea-host-request-id', method: 'GET', path: '/api/v1/repos/GenarrativeAI/Genarrative', status: 200, proxyTarget: 'Gitea', }, ]); await mkdir(path.dirname(maintenanceFile), { recursive: true }); await writeFile(maintenancePageFile, '
runtime-maintenance
'); await writeFile(maintenanceFile, 'enabled'); const publicClientHeaders = { 'X-Real-IP': '203.0.113.50', 'X-Forwarded-For': '192.168.35.50', }; const internalClientHeaders = { 'X-Real-IP': '192.168.35.50' }; const giteaRequestsBeforeMaintenance = gitea.state.requests.length; await expectHttp( baseUrl, '/user/login', 200, '"upstream":"gitea"', '维护模式不拦截 Gitea Host', { headers: { Host: 'git.genarrative.world', ...publicClientHeaders, }, }, ); ensure( gitea.state.requests.length === giteaRequestsBeforeMaintenance + 1, '维护模式 Gitea Host 请求没有打到 Gitea mock', ); for (const [path, bodyNeedle, label] of [ ['/', 'runtime-maintenance', '公网主站页面'], ['/api/assets/history', 'MAINTENANCE', '公网普通 API'], ['/v1/identity', 'runtime-maintenance', '公网 SpacetimeDB 路由'], ['/admin/settings', 'runtime-maintenance', '公网后台页面'], ['/admin/assets/admin.js', 'runtime-maintenance', '公网后台静态资源'], ['/admin/api/users', 'MAINTENANCE', '公网后台 API'], ]) { await expectHttp( baseUrl, path, 503, bodyNeedle, `维护模式继续拦截${label}`, { headers: publicClientHeaders, }, ); } await rm(maintenancePageFile, { force: true }); await expectHttp( baseUrl, '/', 503, 'default-maintenance', '运行态公告缺失时回退 Web 制品默认维护页', { headers: publicClientHeaders }, ); await expectHttp( baseUrl, '/', 200, 'site-shell', '维护模式允许内网主站页面', { headers: internalClientHeaders }, ); await expectHttp( baseUrl, '/api/assets/history', 200, '"upstream":"api"', '维护模式允许内网普通 API', { headers: internalClientHeaders }, ); await expectHttp( baseUrl, '/v1/identity', 200, '"upstream":"spacetime"', '维护模式允许内网 SpacetimeDB 路由', { headers: internalClientHeaders }, ); await expectHttp( baseUrl, '/admin/settings', 200, 'admin-shell', '维护模式允许内网后台页面', { headers: internalClientHeaders }, ); await expectHttp( baseUrl, '/admin/assets/admin.js', 200, 'admin asset', '维护模式允许内网后台静态资源', { headers: internalClientHeaders }, ); await expectHttp( baseUrl, '/admin/api/users', 200, '"upstream":"api"', '维护模式允许内网后台 API', { headers: internalClientHeaders }, ); await expectAccessLogContains(accessLogFile, [ 'status=503', 'path=/api/assets/history', ]); } async function expectAccessLog(accessLogFile) { console.log('[pingora-gateway-smoke] access log 落盘'); await waitForCondition(async () => { try { const content = await readFile(accessLogFile, 'utf8'); return ( content.includes('request_id=smoke-request-id') && content.includes('method=GET') && content.includes('path=/api/assets/history') && content.includes('status=200') && content.includes('proxy_target=Api') && content.includes('protection_class=api') && content.includes('status=429') && content.includes('status=502') && content.includes('status=504') ); } catch { return false; } }); } function assertStaticValidatorHeaders(response, label) { ensure(Boolean(response.headers.etag), `${label}: 缺少 ETag`); ensure( /^W\/"[0-9a-f]+-[0-9a-f]+"$/iu.test(String(response.headers.etag || '')), `${label}: ETag 格式不符合静态弱校验器口径:${response.headers.etag || '-'}`, ); ensure(Boolean(response.headers['last-modified']), `${label}: 缺少 Last-Modified`); ensure( Number.isFinite(Date.parse(response.headers['last-modified'] || '')), `${label}: Last-Modified 不是有效 HTTP 日期:${response.headers['last-modified'] || '-'}`, ); } async function expectStaticNotModifiedByEtag( baseUrl, route, sourceResponse, options = {}, ) { await expectHttp( baseUrl, route, 304, '', '静态资源 If-None-Match 返回 304', { headers: { 'If-None-Match': sourceResponse.headers.etag, ...(options.headers || {}), }, validate: (response) => response.body === '' && response.headers.etag === sourceResponse.headers.etag && response.headers['cache-control'] === sourceResponse.headers['cache-control'], }, ); } async function expectStaticNotModifiedByLastModified( baseUrl, route, sourceResponse, options = {}, ) { await expectHttp( baseUrl, route, 304, '', '静态资源 If-Modified-Since 返回 304', { headers: { 'If-Modified-Since': sourceResponse.headers['last-modified'], ...(options.headers || {}), }, validate: (response) => response.body === '' && response.headers['last-modified'] === sourceResponse.headers['last-modified'] && response.headers['cache-control'] === sourceResponse.headers['cache-control'], }, ); } async function expectStaticRange( baseUrl, route, range, status, bodyNeedle, contentRange, label, options = {}, ) { const headers = { Range: range, 'Accept-Encoding': 'gzip', ...(options.headers || {}), }; await expectHttp(baseUrl, route, status, bodyNeedle, label, { method: options.method, headers, validate: (response) => response.headers['accept-ranges'] === 'bytes' && response.headers['content-range'] === contentRange && !response.headers['content-encoding'] && (options.method === 'HEAD' ? response.body === '' : true) && (status === 206 ? Number(response.headers['content-length']) === (options.expectedContentLength ?? Buffer.byteLength(bodyNeedle)) : Number(response.headers['content-length']) === 0), }); } async function expectStaticRangeFallback(baseUrl, route, range, ifRange, label) { await expectHttp(baseUrl, route, 200, 'site asset', label, { headers: { Range: range, 'If-Range': ifRange, 'Accept-Encoding': 'gzip', }, validate: (response) => response.headers['accept-ranges'] === 'bytes' && !response.headers['content-range'] && Number(response.headers['content-length']) === Buffer.byteLength('console.log("site asset");\n'), }); } async function expectGzipResponse(baseUrl, route, bodyNeedle, options = {}) { return expectCompressedResponse(baseUrl, route, bodyNeedle, { acceptEncoding: options.acceptEncoding || 'gzip', contentEncoding: 'gzip', decode: (bodyBuffer) => gunzipSync(bodyBuffer).toString('utf8'), label: options.label || 'gzip 压缩响应', }); } async function expectNotCompressedResponse(baseUrl, route, bodyNeedle, label) { console.log(`[pingora-gateway-smoke] ${label}`); const response = await requestHttp(`${baseUrl}${route}`, { headers: { 'Accept-Encoding': 'gzip', }, rawBody: true, }); if (response.status !== 200) { failures.push(`${label}: 期望 HTTP 200,实际 ${response.status}`); return; } if (response.headers['content-encoding']) { failures.push( `${label}: 不应返回 Content-Encoding,实际 ${response.headers['content-encoding']}`, ); } const body = response.bodyBuffer.toString('utf8'); if (bodyNeedle && !body.includes(bodyNeedle)) { failures.push(`${label}: 响应体缺少 ${bodyNeedle}`); } } async function expectStaticPng(baseUrl, route, label) { console.log(`[pingora-gateway-smoke] ${label}`); const response = await requestHttp(`${baseUrl}${route}`, { rawBody: true, }); if (response.status !== 200) { failures.push(`${label}: 期望 HTTP 200,实际 ${response.status}`); return; } if (!String(response.headers['content-type'] || '').startsWith('image/png')) { failures.push( `${label}: 期望 Content-Type=image/png,实际 ${response.headers['content-type'] || '-'}`, ); } if (!response.bodyBuffer.subarray(0, 8).equals(PNG_MAGIC_BYTES)) { failures.push(`${label}: 响应体不是 PNG magic bytes`); } if (response.body.includes('')) { failures.push(`${label}: 命中了 SPA fallback HTML`); } } async function expectRawPathHttp(baseUrl, route, status, bodyNeedle, label) { console.log(`[pingora-gateway-smoke] ${label}`); let response; try { response = await rawHttpPathRequest(baseUrl, route); } catch (error) { failures.push( `${label}: ${error instanceof Error ? error.message : String(error)}`, ); return; } if (response.status !== status) { failures.push(`${label}: 期望 HTTP ${status},实际 ${response.status}`); } if (bodyNeedle && !response.body.includes(bodyNeedle)) { failures.push(`${label}: 响应体缺少 ${bodyNeedle}`); } } async function expectCompressedResponse(baseUrl, route, bodyNeedle, options) { const label = options.label; console.log(`[pingora-gateway-smoke] ${label}`); const response = await requestHttp(`${baseUrl}${route}`, { headers: { 'Accept-Encoding': options.acceptEncoding, }, rawBody: true, }); if (response.status !== 200) { failures.push(`${label}: 期望 HTTP 200,实际 ${response.status}`); return; } if (response.headers['content-encoding'] !== options.contentEncoding) { failures.push( `${label}: 缺少 Content-Encoding=${options.contentEncoding},实际 ${response.headers['content-encoding'] || '-'}`, ); } if ( !String(response.headers.vary || '') .toLowerCase() .includes('accept-encoding') ) { failures.push( `${label}: 缺少 Vary: Accept-Encoding,实际 ${response.headers.vary || '-'}`, ); } let decoded = ''; try { decoded = options.decode(response.bodyBuffer); } catch (error) { failures.push( `${label}: 解压失败:${error instanceof Error ? error.message : String(error)}`, ); return; } if (!decoded.includes(bodyNeedle)) { failures.push(`${label}: 解压后内容缺少 ${bodyNeedle}`); } } async function expectAccessLogContains(accessLogFile, needles) { await waitForCondition(async () => { try { const content = await readFile(accessLogFile, 'utf8'); return needles.every((needle) => content.includes(needle)); } catch { return false; } }); } async function expectAccessLogEntries(accessLogFile, expectedEntries) { console.log('[pingora-gateway-smoke] access log 静态边界状态对账'); await waitForCondition(async () => { try { const content = await readFile(accessLogFile, 'utf8'); const entries = content.split(/\r?\n/u).filter(Boolean).map(parseAccessLogLine); return expectedEntries.every((expected) => { const entry = entries.find((item) => item.request_id === expected.requestId); return ( entry && entry.method === expected.method && entry.path === expected.path && Number.parseInt(entry.status || '', 10) === expected.status && entry.proxy_target === (expected.proxyTarget || 'Local') ); }); } catch { return false; } }); } 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 value .replace(/\\\\/gu, '\\') .replace(/\\t/gu, '\t') .replace(/\\n/gu, '\n') .replace(/\\r/gu, '\r'); } async function expectHttp( baseUrl, route, status, bodyNeedle, label, options = {}, ) { console.log(`[pingora-gateway-smoke] ${label}`); const pending = options.beforeRequest ? await options.beforeRequest() : undefined; let response; try { response = await requestHttp(`${baseUrl}${route}`, { method: options.method, headers: options.headers, body: options.body, insecureTls: options.insecureTls, }); } catch (error) { failures.push( `${label}: ${error instanceof Error ? error.message : String(error)}`, ); } finally { if (options.afterRequest) { try { await options.afterRequest(pending); } catch (error) { failures.push(error instanceof Error ? error.message : String(error)); } } } if (!response) { return { status: 0, headers: {}, body: '' }; } if (config.verbose) { console.log(`[pingora-gateway-smoke] ${label} -> HTTP ${response.status}`); } if (response.status !== status) { failures.push(`${label}: 期望 HTTP ${status},实际 ${response.status}`); } if (bodyNeedle && !response.body.includes(bodyNeedle)) { failures.push(`${label}: 响应体缺少 ${bodyNeedle}`); } if (options.validate && !options.validate(response)) { failures.push(`${label}: 自定义响应校验未通过`); } return response; } async function expectChunkedLimit(baseUrl, route) { console.log('[pingora-gateway-smoke] chunked body limit'); const response = await new Promise((resolve, reject) => { const request = http.request( `${baseUrl}${route}`, { method: 'POST', headers: { 'Transfer-Encoding': 'chunked', 'X-Forwarded-For': '203.0.113.11', }, }, (incoming) => { collectIncoming(incoming).then(resolve, reject); }, ); request.on('error', reject); request.write('012345'); request.write('6789abcdef'); request.end(); }); if (response.status !== 413 || !response.body.includes('PAYLOAD_TOO_LARGE')) { failures.push( `chunked body limit: 期望 413 PAYLOAD_TOO_LARGE,实际 ${response.status} ${response.body}`, ); } } async function expectConcurrencyLimit(baseUrl, api) { console.log('[pingora-gateway-smoke] API 并发保护'); const hold = await openRawHttpRequest(`${baseUrl}/admin/api/hold`, { 'X-Forwarded-For': '203.0.113.12', }); try { await waitForCondition(() => typeof api.state.releaseHold === 'function'); const beforeLimitedRequestCount = api.state.requests.length; const response = await rawHttpRequest(`${baseUrl}/admin/api/users`, { 'X-Forwarded-For': '203.0.113.12', }); if (response.status !== 429) { failures.push(`API 并发保护: 期望 HTTP 429,实际 ${response.status}`); } if (!response.body.includes('GATEWAY_CONCURRENCY_LIMITED')) { failures.push('API 并发保护: 响应体缺少 GATEWAY_CONCURRENCY_LIMITED'); } ensure( api.state.requests.length === beforeLimitedRequestCount, `API 并发保护的 429 请求不应打到上游,上游请求为 ${describeRequests(api.state.requests.slice(beforeLimitedRequestCount))}`, ); } finally { api.state.releaseHold?.(); hold.socket.end(); const holdResponse = await hold.done.catch((error) => ({ error })); if (holdResponse?.error) { failures.push( `API 并发保护: hold 请求失败:${holdResponse.error.message}`, ); } } } async function expectWebSocketUpgrade(baseUrl, route, spacetime) { console.log('[pingora-gateway-smoke] SpacetimeDB WebSocket Upgrade'); const url = new URL(route, baseUrl); const key = randomBytes(16).toString('base64'); const response = await new Promise((resolve, reject) => { const socket = net.connect( { host: url.hostname, port: Number(url.port) }, () => { socket.write( [ `GET ${url.pathname} HTTP/1.1`, `Host: ${url.host}`, 'Upgrade: websocket', 'Connection: Upgrade', `Sec-WebSocket-Key: ${key}`, 'Sec-WebSocket-Version: 13', '', '', ].join('\r\n'), ); }, ); let raw = Buffer.alloc(0); let settled = false; const timeout = setTimeout(() => { settled = true; socket.destroy(); reject(new Error('WebSocket Upgrade 超时')); }, 5000); socket.on('data', (chunk) => { raw = Buffer.concat([raw, chunk]); const text = raw.toString('latin1'); if (text.includes('\r\n\r\n') && text.includes('spacetime-upgrade-ok')) { settled = true; clearTimeout(timeout); socket.destroy(); resolve(raw); } }); socket.on('error', (error) => { settled = true; clearTimeout(timeout); reject(error); }); socket.on('close', () => { clearTimeout(timeout); if (!settled) { reject(new Error('WebSocket Upgrade 连接提前关闭')); } }); }); const text = response.toString('latin1'); ensure( text.startsWith('HTTP/1.1 101'), `WebSocket Upgrade 未返回 101:${text.split('\r\n')[0] || text}`, ); ensure( text.includes('spacetime-upgrade-ok'), 'WebSocket tunnel 未收到上游 welcome frame', ); const upgradeRequest = spacetime.state.requests.find( (request) => request.url === route && request.headers.upgrade === 'websocket', ); ensure( Boolean(upgradeRequest), 'SpacetimeDB mock 未收到 WebSocket Upgrade 请求', ); } function requestHttp(url, options = {}) { return new Promise((resolve, reject) => { const headers = { ...(options.headers || {}) }; if ( options.body !== undefined && !hasHeader(headers, 'content-length') && !hasHeader(headers, 'transfer-encoding') ) { headers['Content-Length'] = Buffer.byteLength(String(options.body)); } const client = url.startsWith('https:') ? https : http; const request = client.request( url, { method: options.method || 'GET', headers, agent: false, rejectUnauthorized: options.insecureTls ? false : undefined, }, (incoming) => { collectIncoming(incoming).then(resolve, reject); }, ); const timeout = setTimeout(() => { request.destroy(new Error(`HTTP 请求超时:${url}`)); }, options.timeoutMs || 5000); request.on('error', reject); request.on('close', () => { clearTimeout(timeout); }); if (options.body !== undefined) { request.write(options.body); } request.end(); }); } function openRawHttpRequest(url, headers = {}) { return new Promise((resolve, reject) => { const target = new URL(url); const socket = net.connect( { host: target.hostname, port: Number(target.port) }, () => { socket.write(buildRawHttpRequest(target, headers)); resolve({ socket, done: collectRawHttpResponse(socket, url, 35000), }); }, ); socket.on('error', reject); }); } async function rawHttpRequest(url, headers = {}) { const request = await openRawHttpRequest(url, headers); return request.done.finally(() => { request.socket.destroy(); }); } function rawHttpPathRequest(baseUrl, route, headers = {}) { return new Promise((resolve, reject) => { const target = new URL(baseUrl); const socket = net.connect( { host: target.hostname, port: Number(target.port) }, () => { socket.write(buildRawHttpPathRequest(target, route, headers)); collectRawHttpResponse(socket, `${baseUrl}${route}`, 35000) .then(resolve, reject) .finally(() => socket.destroy()); }, ); socket.on('error', reject); }); } function buildRawHttpRequest(target, headers) { const mergedHeaders = { Host: target.host, Connection: 'close', ...headers, }; const headerLines = Object.entries(mergedHeaders).map( ([name, value]) => `${name}: ${value}`, ); return [ `GET ${target.pathname}${target.search} HTTP/1.1`, ...headerLines, '', '', ].join('\r\n'); } function buildRawHttpPathRequest(target, route, headers) { const mergedHeaders = { Host: target.host, Connection: 'close', ...headers, }; const headerLines = Object.entries(mergedHeaders).map( ([name, value]) => `${name}: ${value}`, ); return [`GET ${route} HTTP/1.1`, ...headerLines, '', ''].join('\r\n'); } function collectRawHttpResponse(socket, url, timeoutMs) { return new Promise((resolve, reject) => { let raw = Buffer.alloc(0); let settled = false; const timer = setTimeout(() => { settled = true; socket.destroy(); reject(new Error(`HTTP 请求超时:${url}`)); }, timeoutMs); socket.on('data', (chunk) => { raw = Buffer.concat([raw, chunk]); let parsed; try { parsed = tryParseRawHttpResponse(raw); } catch (error) { settled = true; clearTimeout(timer); socket.destroy(); reject(error); return; } if (parsed) { settled = true; clearTimeout(timer); resolve(parsed); } }); socket.on('error', (error) => { if (!settled) { settled = true; clearTimeout(timer); reject(error); } }); socket.on('close', () => { if (!settled) { settled = true; clearTimeout(timer); try { const parsed = tryParseRawHttpResponse(raw, { final: true }); if (parsed) { resolve(parsed); } else { reject(new Error(`HTTP 响应提前关闭:${url}`)); } } catch (error) { reject(error); } } }); }); } function tryParseRawHttpResponse(raw, options = {}) { 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 status = 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(); } const bodyStart = headerEnd + 4; const body = raw.slice(bodyStart); if (hasToken(headers['transfer-encoding'], 'chunked')) { const decoded = decodeChunkedBody(body); if (!decoded.complete) { return null; } return { status, headers, body: decoded.body.toString('utf8'), }; } const contentLength = Number.parseInt(headers['content-length'] || '', 10); if (Number.isFinite(contentLength) && body.length < contentLength) { return null; } if (Number.isFinite(contentLength)) { return { status, headers, body: body.slice(0, contentLength).toString('utf8'), }; } if (!options.final) { return null; } return { status, headers, body: body.toString('utf8'), }; } function hasToken(value, expected) { if (!value) { return false; } const normalized = expected.toLowerCase(); return value .split(',') .map((token) => token.trim().toLowerCase()) .includes(normalized); } function decodeChunkedBody(body) { let offset = 0; const chunks = []; while (offset < body.length) { const lineEnd = body.indexOf('\r\n', offset); if (lineEnd < 0) { return { complete: false, body: Buffer.alloc(0) }; } const sizeLine = body.slice(offset, lineEnd).toString('latin1'); const sizeText = sizeLine.split(';')[0]?.trim() || ''; const size = Number.parseInt(sizeText, 16); if (!Number.isFinite(size)) { throw new Error(`无法解析 chunked 响应大小:${sizeLine}`); } offset = lineEnd + 2; if (size === 0) { const trailerEnd = body.indexOf('\r\n\r\n', offset); if (trailerEnd >= 0) { return { complete: true, body: Buffer.concat(chunks) }; } if (body.slice(offset, offset + 2).toString('latin1') === '\r\n') { return { complete: true, body: Buffer.concat(chunks) }; } return { complete: false, body: Buffer.alloc(0) }; } const chunkStart = offset; const chunkEnd = chunkStart + size; const nextChunkStart = chunkEnd + 2; if (body.length < nextChunkStart) { return { complete: false, body: Buffer.alloc(0) }; } if (body.slice(chunkEnd, nextChunkStart).toString('latin1') !== '\r\n') { throw new Error('chunked 响应缺少 chunk 结束换行'); } chunks.push(body.slice(chunkStart, chunkEnd)); offset = nextChunkStart; } return { complete: false, body: Buffer.alloc(0) }; } function hasHeader(headers, name) { const expected = name.toLowerCase(); return Object.keys(headers).some((key) => key.toLowerCase() === expected); } function collectIncoming(incoming, options = {}) { return new Promise((resolve, reject) => { const chunks = []; incoming.on('data', (chunk) => chunks.push(chunk)); incoming.on('error', reject); incoming.on('end', () => { const bodyBuffer = Buffer.concat(chunks); resolve({ status: incoming.statusCode || 0, headers: incoming.headers, body: options.rawBody ? '' : bodyBuffer.toString('utf8'), bodyBuffer, }); }); }); } function readRequestBody(request) { return new Promise((resolve, reject) => { const chunks = []; request.on('data', (chunk) => chunks.push(chunk)); request.on('error', reject); request.on('end', () => resolve(Buffer.concat(chunks))); }); } function listen(server) { return new Promise((resolve, reject) => { server.on('error', reject); server.on('connection', (socket) => { sockets.add(socket); socket.on('close', () => { sockets.delete(socket); }); }); server.listen(0, '127.0.0.1', () => { servers.push(server); const address = server.address(); if (!address || typeof address === 'string') { reject(new Error('无法读取 mock server 端口')); return; } resolve(address.port); }); }); } function getFreePort() { return new Promise((resolve, reject) => { const server = net.createServer(); server.on('error', reject); server.listen(0, '127.0.0.1', () => { const address = server.address(); if (!address || typeof address === 'string') { server.close(); reject(new Error('无法分配临时端口')); return; } const { port } = address; server.close(() => resolve(port)); }); }); } async function waitForHttp(url, expectedStatus) { const startedAt = Date.now(); let lastError = ''; while (Date.now() - startedAt < 10000) { try { const response = await requestHttp(url); if (response.status === expectedStatus) { return; } lastError = `HTTP ${response.status}`; } catch (error) { lastError = error instanceof Error ? error.message : String(error); } await delay(100); } throw new Error(`等待 Pingora 网关就绪超时:${lastError}`); } function runCommand(command, args) { console.log( `[pingora-gateway-smoke] ${command} ${redactSecretArgs(args).join(' ')}`, ); const result = spawnSync(command, args, { cwd: repoRoot, env: smokeEnv(), shell: false, stdio: 'inherit', }); if (result.error) { throw new Error(`${command} 启动失败:${result.error.message}`); } if (result.signal) { throw new Error(`${command} 被信号终止:${result.signal}`); } if ((result.status ?? 0) !== 0) { throw new Error( `${command} ${redactSecretArgs(args).join(' ')} 退出码 ${result.status}`, ); } } async function runDirectLiveSmoke(tlsBaseUrl, redirectBaseUrl, probeToken, accessLogFile) { const output = await runCommandAsync('node', [ 'scripts/check-pingora-direct-live.mjs', '--https-base-url', tlsBaseUrl, '--http-base-url', redirectBaseUrl, '--host', new URL(tlsBaseUrl).host, '--probe-token', probeToken, '--pingora-access-log', accessLogFile, '--redirect-host', new URL(tlsBaseUrl).host, '--spacetime-database', 'genarrative', '--require-wss-upgrade', '--insecure-tls', '--json', ]); assertDirectLiveAccessLogEvidence(output.stdout); } function runCommandAsync(command, args) { console.log( `[pingora-gateway-smoke] ${command} ${redactSecretArgs(args).join(' ')}`, ); return new Promise((resolve, reject) => { let stdout = ''; let stderr = ''; const child = spawn(command, args, { cwd: repoRoot, env: smokeEnv(), shell: false, stdio: ['ignore', 'pipe', 'pipe'], }); child.stdout?.on('data', (chunk) => { const text = String(chunk); stdout += text; process.stdout.write(text); }); child.stderr?.on('data', (chunk) => { const text = String(chunk); stderr += text; process.stderr.write(text); }); child.on('error', (error) => { reject(new Error(`${command} 启动失败:${error.message}`)); }); child.on('exit', (status, signal) => { if (signal) { reject(new Error(`${command} 被信号终止:${signal}`)); return; } if ((status ?? 0) !== 0) { reject( new Error( `${command} ${redactSecretArgs(args).join(' ')} 退出码 ${status}\nstdout:\n${stdout}\nstderr:\n${stderr}`, ), ); return; } resolve({ stdout, stderr }); }); }); } function assertDirectLiveAccessLogEvidence(stdout) { const payload = extractJsonObject(stdout, stdout.indexOf('{')); if (!payload) { failures.push('direct live JSON 输出缺失。'); return; } let result; try { result = JSON.parse(payload); } catch (error) { failures.push( `direct live JSON 输出无法解析:${error instanceof Error ? error.message : String(error)}`, ); return; } const accessLogCheck = result.results?.find( (item) => item.name === 'direct-access-log', ); const staticAssetCheck = result.results?.find( (item) => item.name === 'https-static-asset', ); ensure( staticAssetCheck?.fingerprinted?.statusCode === 200, 'direct live 必须验证首页引用的指纹静态资源 GET 200。', ); ensure( staticAssetCheck?.fingerprinted?.head?.statusCode === 200, 'direct live 必须验证首页引用的指纹静态资源 HEAD 200。', ); ensure( staticAssetCheck?.fingerprinted?.range?.statusCode === 206, 'direct live 必须验证首页引用的指纹静态资源 Range 206。', ); ensure( staticAssetCheck?.headers?.['cache-control'] === 'no-cache', 'direct live JSON 必须保留普通静态资源 Cache-Control 证据。', ); ensure( Boolean(staticAssetCheck?.headers?.etag), 'direct live JSON 必须保留普通静态资源 ETag 证据。', ); ensure( staticAssetCheck?.range?.headers?.['content-range']?.startsWith('bytes 0-0/'), 'direct live JSON 必须保留普通静态资源 Content-Range 证据。', ); ensure( staticAssetCheck?.fingerprinted?.headers?.['cache-control'] === 'public, max-age=31536000, immutable', 'direct live JSON 必须保留指纹静态资源 immutable Cache-Control 证据。', ); ensure( Boolean(staticAssetCheck?.fingerprinted?.headers?.etag), 'direct live JSON 必须保留指纹静态资源 ETag 证据。', ); ensure( staticAssetCheck?.fingerprinted?.range?.headers?.['content-range']?.startsWith( 'bytes 0-0/', ), 'direct live JSON 必须保留指纹静态资源 Content-Range 证据。', ); ensure( staticAssetCheck?.notModified?.etag?.statusCode === 304, 'direct live 必须验证首页引用的静态资源 ETag 304。', ); ensure( staticAssetCheck?.notModified?.lastModified?.statusCode === 304, 'direct live 必须验证首页引用的静态资源 Last-Modified 304。', ); ensure( staticAssetCheck?.fingerprinted?.notModified?.etag?.statusCode === 304, 'direct live 必须验证首页引用的指纹静态资源 ETag 304。', ); ensure( staticAssetCheck?.fingerprinted?.notModified?.lastModified?.statusCode === 304, 'direct live 必须验证首页引用的指纹静态资源 Last-Modified 304。', ); ensure( staticAssetCheck?.notModified?.etag?.headers?.['cache-control'] === staticAssetCheck?.headers?.['cache-control'], 'direct live JSON 必须保留普通静态 304 Cache-Control 证据。', ); ensure( staticAssetCheck?.fingerprinted?.notModified?.etag?.headers?.['cache-control'] === staticAssetCheck?.fingerprinted?.headers?.['cache-control'], 'direct live JSON 必须保留指纹静态 304 Cache-Control 证据。', ); if (!accessLogCheck) { failures.push('direct live JSON 缺少 direct-access-log 检查结果。'); return; } ensure(accessLogCheck.checked >= 10, 'direct live access log 检查请求数不足。'); ensure( accessLogCheck.matchedCount === accessLogCheck.checked, 'direct live access log matchedCount 必须等于 checked。', ); ensure( accessLogCheck.missingCount === 0, 'direct live access log 不应缺少 request_id。', ); ensure( accessLogCheck.mismatchCount === 0, 'direct live access log 不应出现 method/path/status 漂移。', ); ensure( Array.isArray(accessLogCheck.matched) && accessLogCheck.matched.some( (item) => item.name === 'wss-spacetime-subscribe' && item.expectedMethod === 'GET' && item.actualMethod === 'GET' && item.expectedStatusCode === 101 && item.actualStatusCode === 101, ), 'direct live access log 必须保留 WSS 101 的 method/path/status 明细。', ); ensure( Array.isArray(accessLogCheck.matched) && accessLogCheck.matched.some( (item) => item.name === 'https-static-asset-head' && item.expectedMethod === 'HEAD' && item.actualMethod === 'HEAD' && item.expectedStatusCode === 200 && item.actualStatusCode === 200, ), 'direct live access log 必须保留静态 HEAD 的 method/path/status 明细。', ); ensure( Array.isArray(accessLogCheck.matched) && accessLogCheck.matched.some( (item) => item.name === 'https-static-fingerprinted-asset' && item.expectedMethod === 'GET' && item.actualMethod === 'GET' && item.expectedStatusCode === 200 && item.actualStatusCode === 200, ), 'direct live access log 必须保留指纹静态 GET 的 method/path/status 明细。', ); ensure( Array.isArray(accessLogCheck.matched) && accessLogCheck.matched.some( (item) => item.name === 'https-static-fingerprinted-asset-head' && item.expectedMethod === 'HEAD' && item.actualMethod === 'HEAD' && item.expectedStatusCode === 200 && item.actualStatusCode === 200, ), 'direct live access log 必须保留指纹静态 HEAD 的 method/path/status 明细。', ); ensure( Array.isArray(accessLogCheck.matched) && accessLogCheck.matched.some( (item) => item.name === 'https-static-fingerprinted-asset-range' && item.expectedMethod === 'GET' && item.actualMethod === 'GET' && item.expectedStatusCode === 206 && item.actualStatusCode === 206, ), 'direct live access log 必须保留指纹静态 Range 的 method/path/status 明细。', ); ensure( Array.isArray(accessLogCheck.matched) && accessLogCheck.matched.some( (item) => item.name === 'https-static-asset-etag-304' && item.expectedMethod === 'GET' && item.actualMethod === 'GET' && item.expectedStatusCode === 304 && item.actualStatusCode === 304, ), 'direct live access log 必须保留静态 ETag 304 的 method/path/status 明细。', ); ensure( Array.isArray(accessLogCheck.matched) && accessLogCheck.matched.some( (item) => item.name === 'https-static-fingerprinted-asset-etag-304' && item.expectedMethod === 'GET' && item.actualMethod === 'GET' && item.expectedStatusCode === 304 && item.actualStatusCode === 304, ), 'direct live access log 必须保留指纹静态 ETag 304 的 method/path/status 明细。', ); ensure( Array.isArray(accessLogCheck.missing) && accessLogCheck.missing.length === 0, 'direct live access log missing 明细必须为空数组。', ); ensure( Array.isArray(accessLogCheck.mismatches) && accessLogCheck.mismatches.length === 0, 'direct live access log mismatches 明细必须为空数组。', ); } function extractJsonObject(text, startIndex) { if (startIndex < 0) { return ''; } let depth = 0; let inString = false; let escaped = false; for (let index = startIndex; index < text.length; index += 1) { const char = text[index]; if (inString) { if (escaped) { escaped = false; } else if (char === '\\') { escaped = true; } else if (char === '"') { inString = false; } continue; } if (char === '"') { inString = true; continue; } if (char === '{') { depth += 1; continue; } if (char === '}') { depth -= 1; if (depth === 0) { return text.slice(startIndex, index + 1); } } } return ''; } function redactSecretArgs(args) { return args.map((arg, index) => index > 0 && SECRET_VALUE_FLAGS.has(args[index - 1]) ? '' : arg, ); } function resolveGatewayBinary() { const explicit = process.env.GENARRATIVE_PINGORA_GATEWAY_BINARY; if (explicit) { return explicit; } const candidates = [ path.join(repoRoot, 'server-rs', 'target', 'debug', 'pingora-gateway'), path.join(repoRoot, 'target', 'debug', 'pingora-gateway'), ]; const found = candidates.find((candidate) => existsSync(candidate)); if (!found) { throw new Error( `未找到 pingora-gateway debug 二进制,请先构建或设置 GENARRATIVE_PINGORA_GATEWAY_BINARY。候选:${candidates.join(', ')}`, ); } return found; } function smokeEnv() { return { ...process.env, PATH: `${path.join(os.homedir(), '.local', 'bin')}:${process.env.PATH || ''}`, }; } function collectProcessLogs(child, name) { child.stdout?.on('data', (chunk) => { if (config.verbose) { process.stdout.write(`[${name}] ${chunk}`); } }); child.stderr?.on('data', (chunk) => { if (config.verbose) { process.stderr.write(`[${name}] ${chunk}`); } }); } function encodeWebSocketTextFrame(text) { const payload = Buffer.from(text, 'utf8'); if (payload.length > 125) { throw new Error('smoke WebSocket frame payload too large'); } return Buffer.concat([Buffer.from([0x81, payload.length]), payload]); } function delay(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } async function waitForCondition(predicate, timeoutMs = 2000) { const startedAt = Date.now(); while (Date.now() - startedAt < timeoutMs) { if (await predicate()) { return; } await delay(20); } throw new Error('等待 smoke 条件达成超时'); } function ensure(condition, message) { if (!condition) { failures.push(message); } } function describeRequests(requests) { return JSON.stringify( requests.map((request) => ({ method: request.method, url: request.url, xForwardedFor: request.headers?.['x-forwarded-for'], aborted: Boolean(request.aborted), })), ); } async function cleanup() { for (const { child, name } of childProcesses.reverse()) { await stopChild(child, name); } for (const socket of sockets) { socket.destroy(); } sockets.clear(); await Promise.all( servers.reverse().map( (server) => new Promise((resolve) => { const timer = setTimeout(resolve, 1000); server.close(() => { clearTimeout(timer); resolve(); }); }), ), ); await Promise.all( tempDirs.reverse().map((dir) => rm(dir, { recursive: true, force: true })), ); } async function stopChild(child, name) { if (child.exitCode !== null || child.signalCode !== null) { return; } child.kill('SIGTERM'); const exited = await waitForExit(child, 3000); if (!exited) { child.kill('SIGKILL'); const killed = await waitForExit(child, 3000); if (!killed) { failures.push(`${name} 未能退出`); } } } function waitForExit(child, timeoutMs) { return new Promise((resolve) => { if (child.exitCode !== null || child.signalCode !== null) { resolve(true); return; } const timer = setTimeout(() => { child.off('exit', onExit); resolve(false); }, timeoutMs); function onExit() { clearTimeout(timer); resolve(true); } child.once('exit', onExit); }); }