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
- 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 决策/踩坑记录
459 lines
15 KiB
JavaScript
459 lines
15 KiB
JavaScript
#!/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)`);
|