#!/usr/bin/env node import http from 'node:http'; import https from 'node:https'; const DEFAULT_CANARY_PREFIX = '/__genarrative_pingora_canary'; const REALPATH_HEALTHZ_PATH = '/__genarrative_pingora_realpath_canary/healthz'; const HANDOFF_HEADER_DISPLAY = 'X-Genarrative-Nginx-Handoff'; const HANDOFF_HEADER = 'x-genarrative-nginx-handoff'; const HANDOFF_VALUES = { prefix: 'pingora-canary', realpath: 'pingora-realpath-canary', }; 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-canary-live] FAILED'); for (const failure of failures) { console.error(`- ${failure}`); } process.exit(1); } console.log('[pingora-canary-live] OK'); function usage() { console.log(`Usage: node scripts/check-pingora-canary-live.mjs --base-url [options] Options: --base-url Nginx/public base URL with canary snippet enabled. --prefix Canary prefix, default /__genarrative_pingora_canary. --mode Canary mode, default prefix. --realpath Shortcut for --mode realpath. --host Optional Host header for local --resolve style checks. --path Extra canary path to probe; repeatable. --timeout-ms Request timeout, default 5000. --json Print JSON result. Environment aliases: GENARRATIVE_PINGORA_CANARY_BASE_URL GENARRATIVE_PINGORA_CANARY_PREFIX GENARRATIVE_PINGORA_CANARY_MODE GENARRATIVE_PINGORA_CANARY_HOST GENARRATIVE_PINGORA_CANARY_TIMEOUT_MS `); } function parseArgs(argv) { const result = { baseUrl: process.env.GENARRATIVE_PINGORA_CANARY_BASE_URL || '', prefix: process.env.GENARRATIVE_PINGORA_CANARY_PREFIX || DEFAULT_CANARY_PREFIX, mode: normalizeMode( process.env.GENARRATIVE_PINGORA_CANARY_MODE || 'prefix', 'GENARRATIVE_PINGORA_CANARY_MODE', ), host: process.env.GENARRATIVE_PINGORA_CANARY_HOST || '', timeoutMs: parseOptionalPositiveInt( process.env.GENARRATIVE_PINGORA_CANARY_TIMEOUT_MS, 5000, 'GENARRATIVE_PINGORA_CANARY_TIMEOUT_MS', ), 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 '--base-url': result.baseUrl = requireValue(argv, ++index, arg); break; case '--prefix': result.prefix = normalizePrefix(requireValue(argv, ++index, arg)); break; case '--mode': result.mode = normalizeMode(requireValue(argv, ++index, arg), arg); break; case '--realpath': result.mode = 'realpath'; break; case '--host': result.host = requireValue(argv, ++index, arg); break; case '--path': result.extraPaths.push(requireValue(argv, ++index, arg)); break; case '--timeout-ms': result.timeoutMs = parseRequiredPositiveInt( requireValue(argv, ++index, arg), '--timeout-ms', ); break; case '--json': result.json = true; break; default: throw new Error(`未知参数: ${arg}`); } } result.prefix = normalizePrefix(result.prefix); if (!result.baseUrl) { throw new Error('缺少 --base-url 或 GENARRATIVE_PINGORA_CANARY_BASE_URL'); } validateNoControlCharacters(result.baseUrl, '--base-url'); new URL(result.baseUrl); validateNoControlCharacters(result.prefix, '--prefix'); if (result.host) { validateHostOption(result.host, '--host'); } for (const extraPath of result.extraPaths) { validateNoControlCharacters(extraPath, '--path'); } 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 validateNoControlCharacters(value, label) { if (/[\0\r\n]/u.test(String(value))) { throw new Error(`${label} 不能包含换行或 NUL 字符。`); } } 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 normalizeMode(raw, label) { validateNoControlCharacters(raw, label); const value = String(raw || '').trim(); if (!value || value === 'prefix') { return 'prefix'; } if (value === 'realpath') { return 'realpath'; } throw new Error(`${label} 必须是 prefix 或 realpath。`); } function normalizePrefix(prefix) { if (!prefix || prefix === '/') { return DEFAULT_CANARY_PREFIX; } const withLeadingSlash = prefix.startsWith('/') ? prefix : `/${prefix}`; return withLeadingSlash.endsWith('/') ? withLeadingSlash.slice(0, -1) : withLeadingSlash; } function joinUrl(baseUrl, path) { const base = baseUrl.endsWith('/') ? baseUrl.slice(0, -1) : baseUrl; const suffix = path.startsWith('/') ? path : `/${path}`; return `${base}${suffix}`; } function canaryUrl(path) { const suffix = path.startsWith('/') ? path : `/${path}`; if (config.mode === 'realpath') { return joinUrl(config.baseUrl, suffix); } return joinUrl(config.baseUrl, `${config.prefix}${suffix}`); } async function main() { const handoffValue = HANDOFF_VALUES[config.mode]; const checks = [ { name: 'healthz', path: config.mode === 'realpath' ? REALPATH_HEALTHZ_PATH : '/healthz', expectedStatus: 200, assertBody: (body) => body.includes('"gateway":"pingora-shadow"'), bodyReason: 'body 应包含 gateway=pingora-shadow', }, { name: 'api-history', path: '/api/assets/history', expectedStatuses: [200, 401, 403, 503], }, { name: 'spacetime-identity', path: '/v1/identity', expectedStatuses: [200, 401, 403, 404, 405, 503], }, { name: 'web-assets', path: '/assets/app.js', expectedStatuses: [200, 404, 503], }, { name: 'generated-forbidden', path: '/generated-pingora-canary-smoke', expectedStatus: 404, }, ]; for (const path of config.extraPaths) { checks.push({ name: `extra:${path}`, path, expectedStatuses: [200, 204, 301, 302, 304, 401, 403, 404, 503], }); } const results = []; for (const check of checks) { const result = await runCheck(check, handoffValue); results.push(result); } if (config.json) { console.log( JSON.stringify({ ok: failures.length === 0, results }, null, 2), ); } } async function runCheck(check, handoffValue) { const url = canaryUrl(check.path); const response = await requestUrl(url); const expectedStatuses = check.expectedStatuses ?? [check.expectedStatus].filter(Boolean); const handoff = response.headers[HANDOFF_HEADER] || ''; if (!expectedStatuses.includes(response.statusCode)) { failures.push( `${check.name}: ${url} 返回 ${response.statusCode},预期 ${expectedStatuses.join('/')}`, ); } if (handoff !== handoffValue) { failures.push( `${check.name}: 缺少 ${HANDOFF_HEADER_DISPLAY}: ${handoffValue},实际 ${handoff || '-'}`, ); } if (check.assertBody && !check.assertBody(response.body)) { failures.push(`${check.name}: ${check.bodyReason}`); } console.log( `[pingora-canary-live] ${check.name} ${response.statusCode} ${response.elapsedMs}ms`, ); return { name: check.name, mode: config.mode, url, statusCode: response.statusCode, elapsedMs: response.elapsedMs, handoff, }; } function requestUrl(url) { return new Promise((resolve, reject) => { const startedAt = Date.now(); const parsed = new URL(url); const client = parsed.protocol === 'https:' ? https : http; const headers = { 'User-Agent': 'genarrative-pingora-canary-live/1.0', Accept: 'application/json,text/plain,*/*', Connection: 'close', }; if (config.host) { headers.Host = config.host; } const request = client.request( parsed, { method: 'GET', timeout: config.timeoutMs, headers, }, (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(); }); }