Files
Genarrative/scripts/check-pingora-route-parity.mjs
T
suzmii d40df89e2c
Project CI / Repository checks (pull_request) Has been cancelled
Project CI / AI game creator shell web tests (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust crates (pull_request) Has been cancelled
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / Frontend tests (pull_request) Has been cancelled
修复付费游戏播放会话在边缘转发 Cookie 导致 403 不可玩
- nginx:三份模板在通用 /api location 之前新增 location ^~ /api/game-distribution/play-sessions/,代理头与通用 /api 一致并清空 Cookie
- nginx:^~ 保证该前缀不被正则 location ~ ^/api(?:/|$) 抢先;只匹配带尾斜杠的前缀,创建会话端点继续走通用 /api 并保留 Cookie
- pingora:新增 RouteDecision::PlaySessionGateway,走 api 上游并同样套用 api 限流分组、大小上限与维护闸
- pingora:抽出 route_clears_cookie,发行入口 ReleaseGateway 与播放会话入口在上游代理阶段统一清空 Cookie
- pingora:classify_path 在通用 /api 分支之前命中播放会话前缀,并新增播放会话路由、Cookie 清除与保护等级用例
- 路由矩阵:新增 play_sessions_gateway 用例,声明清空 Cookie 与 protectionClass api
- 门禁 check:nginx-spa-routes:新增播放会话前缀断言——三份模板存在 ^~ location、块内清空 Cookie、代理头齐全且排在通用 /api location 之前
- 门禁 check:pingora-route-parity:新增断言——平台内容网关用例必须清空 Cookie 且不得复用通用 /api location,Rust 播放会话分支必须排在通用 /api 之前并由 route_clears_cookie 清理
- 门禁 check:pingora-gateway-smoke:新增真实网关用例——播放会话前缀转发到 api 上游并清空 Cookie、创建会话端点保留 Cookie
- dev:vite.config.ts 在 /api/game-distribution 规则之前新增同名前缀代理并清除 Cookie
- 文档:同步 Pingora 试点文档、本地开发运维文档、deploy/nginx/README 与 shared-memory 决策/踩坑记录
2026-10-05 17:53:51 +08:00

459 lines
15 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env node
import { readFileSync } from 'node:fs';
import {
expectedMainSpaRoutes,
expectedPrefixRoutes,
} from './check-nginx-spa-routes.mjs';
const MATRIX_PATH = 'deploy/pingora/nginx-route-parity.matrix.json';
const PRODUCTION_NGINX_PATH = 'deploy/nginx/genarrative.conf';
const DEVELOPMENT_NGINX_PATH = 'deploy/nginx/genarrative-dev-http.conf';
const PINGORA_DOC_PATH =
'docs/technical/【开发运维】Pingora独立网关试点-2026-06-11.md';
const PINGORA_GATEWAY_SOURCE = 'server-rs/crates/pingora-gateway/src/main.rs';
const VALID_KINDS = new Set([
'proxy',
'static',
'release_gateway',
'play_session_gateway',
'redirect_permanent',
'shadow_probe',
'not_found',
]);
const VALID_PROXY_TARGETS = new Set(['api', 'spacetime']);
const VALID_STATIC_ROOTS = new Set(['web', 'acme']);
const VALID_STATIC_MODES = new Set(['exact', 'spa_fallback']);
const VALID_PROTECTION_CLASSES = new Set(['admin_api', 'api', 'spacetime']);
const REQUIRED_ROUTE_IDS = [
'acme_challenge',
'shadow_probe',
'admin_redirect',
'admin_api_proxy',
'admin_assets',
'admin_spa_fallback',
'web_assets',
'generic_api_proxy',
'spacetime_subscribe',
'spacetime_identity',
'v1_forbidden',
'healthz_forbidden',
'readyz_forbidden',
'generated_assets_forbidden',
'web_spa_fallback',
'profile_spa_fallback',
'games_spa_fallback',
'games_release_gateway',
'play_sessions_gateway',
'web_root_spa',
'web_spa_case_trailing_slash',
'web_unknown_path_exact',
'creation_unknown_path_exact',
'runtime_unknown_path_exact',
'puzzle_unknown_path_exact',
];
const files = {
production: readFileSync(PRODUCTION_NGINX_PATH, 'utf8'),
development: readFileSync(DEVELOPMENT_NGINX_PATH, 'utf8'),
};
const docs = readFileSync(PINGORA_DOC_PATH, 'utf8');
const pingoraGatewaySource = readFileSync(PINGORA_GATEWAY_SOURCE, 'utf8');
const matrix = JSON.parse(readFileSync(MATRIX_PATH, 'utf8'));
const failures = [];
function fail(message) {
failures.push(message);
}
function hasOwn(object, key) {
return Object.prototype.hasOwnProperty.call(object, key);
}
function requireString(value, context) {
if (typeof value !== 'string' || value.trim() === '') {
fail(`${context} 必须是非空字符串。`);
return false;
}
return true;
}
function validateExpectation(route) {
const context = `${MATRIX_PATH} route ${route.id}`;
const expect = route.expect;
if (!expect || typeof expect !== 'object' || Array.isArray(expect)) {
fail(`${context} 缺少 expect 对象。`);
return;
}
if (!VALID_KINDS.has(expect.kind)) {
fail(`${context} expect.kind 不支持: ${expect.kind}`);
return;
}
if (expect.kind === 'proxy') {
if (!VALID_PROXY_TARGETS.has(expect.target)) {
fail(`${context} proxy target 不支持: ${expect.target}`);
}
if (
hasOwn(expect, 'bodyLimit') &&
expect.bodyLimit !== null &&
expect.bodyLimit !== 'default' &&
(!Number.isInteger(expect.bodyLimit) || expect.bodyLimit < 1)
) {
fail(`${context} bodyLimit 必须是 null、default 或正整数。`);
}
if (!VALID_PROTECTION_CLASSES.has(expect.protectionClass)) {
fail(
`${context} proxy protectionClass 不支持: ${expect.protectionClass}`,
);
}
return;
}
if (expect.kind === 'play_session_gateway') {
// 播放会话入口与通用 `/api` 同口径(同样吃 api 限流),但路径不重写、也不引入新的头部语义;
// 显式声明 protectionClass 是为了让下面「必须清空 Cookie」的断言有对比基准。
if (!VALID_PROTECTION_CLASSES.has(expect.protectionClass)) {
fail(
`${context} play_session_gateway protectionClass 不支持: ${expect.protectionClass}`,
);
}
if (hasOwn(expect, 'upstreamPath')) {
fail(`${context} play_session_gateway 不做路径重写,不能配置 upstreamPath。`);
}
return;
}
if (hasOwn(expect, 'protectionClass')) {
fail(`${context} 非 proxy 路由不能配置 protectionClass。`);
}
if (expect.kind !== 'release_gateway' && hasOwn(expect, 'upstreamPath')) {
fail(`${context} 只有 release_gateway 路由才能配置 upstreamPath。`);
}
if (expect.kind === 'release_gateway') {
requireString(expect.upstreamPath, `${context} upstreamPath`);
return;
}
if (expect.kind === 'static') {
if (!VALID_STATIC_ROOTS.has(expect.root)) {
fail(`${context} static root 不支持: ${expect.root}`);
}
if (!VALID_STATIC_MODES.has(expect.mode)) {
fail(`${context} static mode 不支持: ${expect.mode}`);
}
}
if (
expect.kind === 'redirect_permanent' &&
!requireString(expect.location, `${context} redirect location`)
) {
fail(`${context} redirect_permanent 必须配置 location。`);
}
}
function validateNginxFragments(route) {
for (const environment of ['production', 'development']) {
const fragments = route.nginx?.[environment];
if (fragments === undefined) {
if (environment === 'production' && route.id !== 'shadow_probe') {
fail(`${MATRIX_PATH} route ${route.id} 缺少 production Nginx 片段。`);
}
continue;
}
if (!Array.isArray(fragments) || fragments.length === 0) {
fail(
`${MATRIX_PATH} route ${route.id} 的 ${environment} Nginx 片段不能为空。`,
);
continue;
}
for (const fragment of fragments) {
if (!requireString(fragment, `${route.id} ${environment} Nginx 片段`)) {
continue;
}
if (!files[environment].includes(fragment)) {
fail(
`${environment} Nginx 模板缺少 route ${route.id} 片段: ${fragment}`,
);
}
}
}
}
function validateDocFragments(route) {
if (!Array.isArray(route.docs) || route.docs.length === 0) {
fail(`${MATRIX_PATH} route ${route.id} 缺少 docs 片段。`);
return;
}
for (const fragment of route.docs) {
if (!requireString(fragment, `${route.id} docs 片段`)) {
continue;
}
if (!docs.includes(fragment)) {
fail(`Pingora 试点文档缺少 route ${route.id} 片段: ${fragment}`);
}
}
}
function validateMatrixShape() {
if (matrix.version !== 1) {
fail(`${MATRIX_PATH} version 必须为 1。`);
}
if (!Array.isArray(matrix.routes) || matrix.routes.length === 0) {
fail(`${MATRIX_PATH} routes 不能为空。`);
return;
}
const ids = new Set();
const samplePaths = new Set();
for (const route of matrix.routes) {
if (!requireString(route.id, `${MATRIX_PATH} route.id`)) {
continue;
}
if (ids.has(route.id)) {
fail(`${MATRIX_PATH} route id 重复: ${route.id}`);
}
ids.add(route.id);
if (!requireString(route.samplePath, `${route.id} samplePath`)) {
continue;
}
if (!route.samplePath.startsWith('/')) {
fail(`${MATRIX_PATH} route ${route.id} samplePath 必须以 / 开头。`);
}
if (samplePaths.has(route.samplePath)) {
fail(`${MATRIX_PATH} samplePath 重复: ${route.samplePath}`);
}
samplePaths.add(route.samplePath);
validateExpectation(route);
validateNginxFragments(route);
validateDocFragments(route);
}
for (const routeId of REQUIRED_ROUTE_IDS) {
if (!ids.has(routeId)) {
fail(`${MATRIX_PATH} 缺少必需 route id: ${routeId}`);
}
}
}
function validateRustTestUsesMatrix() {
for (const fragment of [
'include_str!("../../../../deploy/pingora/nginx-route-parity.matrix.json")',
'serde_json::from_str(ROUTE_PARITY_MATRIX_JSON)',
'protection_class_for_route(&route, &case.sample_path)',
'fn matches_nginx_route_parity_matrix()',
'fn is_main_spa_path(path: &str)',
"path.strip_suffix('/')",
'normalized.eq_ignore_ascii_case(candidate)',
'("play_session_gateway", RouteDecision::PlaySessionGateway)',
]) {
if (!pingoraGatewaySource.includes(fragment)) {
fail(`Pingora Rust 路由 parity 测试缺少矩阵接入片段: ${fragment}`);
}
}
}
// 播放会话前缀的 Pingora 侧判定:必须独立成 `PlaySessionGateway`,在通用 `/api` 分支之前命中,
// 并通过 `route_clears_cookie` 在上游代理阶段清空 Cookie。把前缀合并回通用 `/api` 分支(或删掉
// 清 Cookie 的处理)都会让这条断言失败。
function validateRustPlaySessionGatewayIsolation() {
for (const fragment of [
'fn is_play_session_proxy_path(path: &str) -> bool',
'"/api/game-distribution/play-sessions/"',
'fn route_clears_cookie(route: &RouteDecision) -> bool',
'upstream_request.remove_header("cookie");',
]) {
if (!pingoraGatewaySource.includes(fragment)) {
fail(`Pingora Rust 缺少播放会话 Cookie 隔离实现: ${fragment}`);
}
}
const classifyBlock = pingoraGatewaySource.match(
/fn classify_path\(path: &str\) -> RouteDecision \{([\s\S]*?)\n\}/u,
);
if (!classifyBlock) {
fail('Pingora Rust 缺少 classify_path 路由判定函数。');
return;
}
const playSessionIndex = classifyBlock[1].indexOf(
'if is_play_session_proxy_path(path) {',
);
const genericApiIndex = classifyBlock[1].indexOf('path == "/api" || path.starts_with("/api/")');
if (playSessionIndex < 0) {
fail(
'Pingora classify_path 缺少播放会话前缀判定(必须在通用 /api 分支之前命中)。',
);
return;
}
if (genericApiIndex < 0) {
fail('Pingora classify_path 缺少通用 /api 代理分支。');
return;
}
if (playSessionIndex > genericApiIndex) {
fail(
'Pingora classify_path 的播放会话前缀判定必须排在通用 /api 分支之前。',
);
}
if (!pingoraGatewaySource.includes('if route_clears_cookie(&ctx.route) {')) {
fail(
'Pingora 上游代理阶段必须按 route_clears_cookie 清空 Cookie(发行入口 / 播放会话入口同口径)。',
);
}
}
// 平台内容网关(发行入口 / 播放会话入口)必须在边缘清空 Cookie:api-server 侧对带平台
// refresh Cookie 的请求返回 403(纵深防御保留),Cookie 一旦被转发,sandbox iframe 与包内
// 每个相对资源都会 403,付费游戏实际不可玩(2026-10-05 就是这样被发现的)。
// 这条同时挡住「把播放会话前缀合并回通用 `/api` 规则」:通用规则必须继续转发 Cookie
// (`/api/auth/*` 依赖 refresh cookie),合并后要么清空 Cookie 的片段消失、要么落到通用 location。
function validateContentGatewayCookieIsolation() {
const clearCookieFragment = 'proxy_set_header Cookie "";';
const genericApiLocation = 'location ~ ^/api(?:/|$)';
const contentGatewayKinds = new Set(['release_gateway', 'play_session_gateway']);
for (const route of matrix.routes) {
if (!contentGatewayKinds.has(route.expect?.kind)) {
continue;
}
for (const environment of ['production', 'development']) {
const fragments = route.nginx?.[environment] ?? [];
if (!fragments.includes(clearCookieFragment)) {
fail(
`route ${route.id} 的 ${environment} Nginx 片段必须显式清空 Cookie:${clearCookieFragment}`,
);
}
const mergedIntoTemplate = fragments.some((fragment) =>
fragment.includes(genericApiLocation),
);
if (mergedIntoTemplate) {
fail(
`route ${route.id} 的 ${environment} Nginx 片段不能复用通用 ${genericApiLocation}(通用规则会转发 Cookie)。`,
);
}
}
}
// 播放会话前缀必须排在自己的 `^~` 前缀 location 上,并且在模板里排在通用 `/api` location 之前;
// nginx 的 `^~` 前缀优先于正则 location,但顺序仍按任务要求固定,便于人工核对。
const playSessionLocation = 'location ^~ /api/game-distribution/play-sessions/';
for (const environment of ['production', 'development']) {
const source = files[environment];
const playSessionIndex = source.indexOf(playSessionLocation);
if (playSessionIndex < 0) {
fail(
`${environment} Nginx 模板缺少播放会话前缀 location: ${playSessionLocation}`,
);
continue;
}
const genericApiIndex = source.indexOf(genericApiLocation);
if (genericApiIndex >= 0 && playSessionIndex > genericApiIndex) {
fail(
`${environment} Nginx 模板的播放会话前缀 location 必须排在通用 ${genericApiLocation} 之前。`,
);
}
}
}
// 反向覆盖:模板里出现的每条 location 都必须被矩阵某条用例声明过。
// 只做正向检查(矩阵片段存在于模板)会漏掉「Nginx 模板加/改了路由、矩阵与 Pingora 没跟上」——
// 2026-09-29 的发行网关路由就是这样:Nginx 有三份、Pingora 和矩阵都没有,门禁一直是绿的。
function validateNginxLocationsAreCovered() {
for (const environment of ['production', 'development']) {
const source = files[environment];
const locationFragments = matrix.routes
.flatMap((route) => route.nginx?.[environment] ?? [])
.map((fragment) => fragment.trim())
.filter((fragment) => fragment.startsWith('location'));
for (const match of source.matchAll(/^[ \t]*location\b[^\n]*/gmu)) {
const line = match[0].trim().replace(/\s*\{\s*$/u, '');
if (line.startsWith('#')) {
continue;
}
if (!locationFragments.some((fragment) => line.startsWith(fragment))) {
fail(`${environment} 模板的 location 没有被矩阵覆盖: ${line}`);
}
}
}
}
function validateRustMainSpaRoutes() {
const routeBlock = pingoraGatewaySource.match(
/const MAIN_SPA_PATHS: &\[&str\] = &\[([\s\S]*?)\];/u,
);
if (!routeBlock) {
fail('Pingora Rust 缺少 MAIN_SPA_PATHS allowlist。');
return;
}
const rustRoutes = Array.from(
routeBlock[1].matchAll(/"([^"]+)"/gu),
(match) => match[1],
).sort();
const expected = new Set(expectedMainSpaRoutes);
const actual = new Set(rustRoutes);
const missing = expectedMainSpaRoutes.filter((route) => !actual.has(route));
const extra = rustRoutes.filter((route) => !expected.has(route));
if (missing.length > 0) {
fail(`Pingora MAIN_SPA_PATHS 缺少当前前端路由: ${missing.join(', ')}`);
}
if (extra.length > 0) {
fail(`Pingora MAIN_SPA_PATHS 包含非当前前端路由: ${extra.join(', ')}`);
}
}
function validateRustMainSpaPrefixPaths() {
const prefixBlock = pingoraGatewaySource.match(
/const MAIN_SPA_PREFIX_PATHS: &\[&str\] = &\[([\s\S]*?)\];/u,
);
if (!prefixBlock) {
fail('Pingora Rust 缺少 MAIN_SPA_PREFIX_PATHS allowlist。');
return;
}
const rustPrefixes = Array.from(
prefixBlock[1].matchAll(/"([^"]+)"/gu),
(match) => match[1],
);
const expected = expectedPrefixRoutes.map((route) => route.path);
const missing = expected.filter((prefix) => !rustPrefixes.includes(prefix));
const extra = rustPrefixes.filter((prefix) => !expected.includes(prefix));
if (missing.length > 0) {
fail(
`Pingora MAIN_SPA_PREFIX_PATHS 缺少当前前缀路由: ${missing.join(', ')}`,
);
}
if (extra.length > 0) {
fail(
`Pingora MAIN_SPA_PREFIX_PATHS 包含非当前前缀路由: ${extra.join(', ')}`,
);
}
}
validateMatrixShape();
validateRustTestUsesMatrix();
validateRustPlaySessionGatewayIsolation();
validateContentGatewayCookieIsolation();
validateNginxLocationsAreCovered();
validateRustMainSpaRoutes();
validateRustMainSpaPrefixPaths();
if (failures.length > 0) {
console.error('[check:pingora-route-parity] FAILED');
for (const failure of failures) {
console.error(`- ${failure}`);
}
process.exit(1);
}
console.log(`[check:pingora-route-parity] OK (${matrix.routes.length} routes)`);