diff --git a/deploy/container/nginx.conf b/deploy/container/nginx.conf index f9dd352eb..69026e740 100644 --- a/deploy/container/nginx.conf +++ b/deploy/container/nginx.conf @@ -88,6 +88,32 @@ http { } + # 平台付费游戏播放会话入口:`/api/game-distribution/play-sessions//…` 是 sandbox iframe + # 的 src,包内相对资源沿同一前缀解析。它与通用 `/api` 同口径代理到 api-server,唯一差别是 + # 清空 Cookie:播放会话不读账号凭证,而 api-server 播放网关对带平台 refresh Cookie 的请求返回 403。 + # 前缀 location 必须写 `^~`:不加时正则 location `~ ^/api(?:/|$)` 会先命中,Cookie 又会被转发。 + location ^~ /api/game-distribution/play-sessions/ { + default_type application/json; + client_max_body_size 210m; + limit_conn genarrative_api_conn 64; + limit_req zone=genarrative_api_rps burst=64 nodelay; + + proxy_pass http://genarrative_api; + proxy_http_version 1.1; + proxy_buffering off; + proxy_read_timeout 3600s; + proxy_send_timeout 3600s; + add_header X-Accel-Buffering no always; + proxy_set_header Connection ""; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Host $host; + proxy_set_header X-Request-Id $request_id; + proxy_set_header Cookie ""; + } + location ~ ^/api(?:/|$) { default_type application/json; # 中文注释:创作接口会携带参考图 Data URL,游戏发行包 PUT 更大,Nginx 只负责放行到 api-server; diff --git a/deploy/nginx/README.md b/deploy/nginx/README.md index e022e295c..a47f8b18a 100644 --- a/deploy/nginx/README.md +++ b/deploy/nginx/README.md @@ -108,3 +108,11 @@ curl -sSI -H 'Accept-Encoding: br' \ - 隔离靠 iframe 沙箱而不是独立来源:游戏文档跑在 `sandbox="allow-scripts"` 的不透明来源里,读不到主站 Cookie、storage 与 DOM,离开页面即随 iframe 卸载。 - 审核通过时 `api-server` 按 gameId 派生同源路径 `/games//` 作为 `entryUrl` 写入公开投影,部署侧不再需要配置发行域名。换版本或下架只改变后端公开投影,边缘不需要改配置。 - 门禁:`npm run check:nginx-spa-routes` 校验三份模板的 SPA allowlist(含 `/games`、`/games/detail`、`/games/play`、`/games/mine`、`/games/publish`、`/pay`、`/profile/payment`)与收银台深链前缀路由 `location ~* "^/pay/[^/]+/?$"`(`/pay/` 只放行「前缀 + 恰好一个路径段」;只放行裸前缀会让真实收银台链接落到默认 location 变 404),脚本自带正/反用例。历史上的独立来源模板与专属门禁已随同源方案上线删除。 + +## 付费游戏播放会话前缀(`/api/game-distribution/play-sessions/`) + +- 付费游戏的可玩入口是 `POST /api/game-distribution/games//play-session` 换到的 `/api/game-distribution/play-sessions//`,直接作为 sandbox iframe 的 `src`;包内相对资源沿同一前缀解析。该前缀落在 `/api/*` 上,而通用 `/api` location 必须转发 Cookie(`/api/auth/*` 依赖 refresh cookie),Cookie 一旦被转发到 `api-server` 播放网关就会命中它的 403 纵深防御,iframe 与包内每个资源都不可用。 +- 因此三份常驻模板(`genarrative.conf`、`genarrative-dev-http.conf`、容器 `deploy/container/nginx.conf`)都在通用 `/api` location 之前内联 `location ^~ /api/game-distribution/play-sessions/`,代理头、`client_max_body_size 210m`、`limit_conn` / `limit_req`、超时与维护判断都与通用 `/api` location 一致,只多一条 `proxy_set_header Cookie ""`。 +- `^~` 不能省:不加时 nginx 会先命中正则 location `~ ^/api(?:/|$)`,Cookie 又被转发回去。前缀末尾的斜杠也不能省:创建会话的 `POST /api/game-distribution/play-sessions` 需要账号凭证,必须继续走通用 `/api` 并保留 Cookie。 +- 本地 dev 由 `vite.config.ts` 里排在 `/api/game-distribution` 之前的同名前缀规则做同一件事(`proxyReq.removeHeader('cookie')`);`api-server` 播放网关的 403 纵深防御不放宽,只保证边缘/dev 转发时不带 Cookie。 +- 门禁:`npm run check:nginx-spa-routes` 对三份模板断言该 `^~` 前缀 location 存在、清空 Cookie、代理头齐全且排在通用 `/api` location 之前;`npm run check:pingora-route-parity` 断言矩阵里的 `play_sessions_gateway` 用例(以及 Pingora 的 `RouteDecision::PlaySessionGateway`)指向独立的清 Cookie 转发,不会被合并回通用 `/api` 规则。 diff --git a/deploy/nginx/genarrative-dev-http.conf b/deploy/nginx/genarrative-dev-http.conf index e2879dff2..53fa93e07 100644 --- a/deploy/nginx/genarrative-dev-http.conf +++ b/deploy/nginx/genarrative-dev-http.conf @@ -116,6 +116,38 @@ server { } + # 平台付费游戏播放会话入口:`/api/game-distribution/play-sessions//…` 是 sandbox iframe + # 的 src,包内相对资源沿同一前缀解析。它与通用 `/api` 同口径代理到 api-server(大小上限、 + # 限流、超时、维护判断都保持一致),唯一差别是清空 Cookie:播放会话不读账号凭证,而 + # api-server 播放网关对带平台 refresh Cookie 的请求返回 403(纵深防御保留)。 + # 前缀 location 必须写 `^~`:不加时正则 location `~ ^/api(?:/|$)` 会先命中,Cookie 又会被转发。 + # 只匹配带尾斜杠的前缀,创建会话的 `POST /api/game-distribution/play-sessions` 仍走通用 `/api`。 + location ^~ /api/game-distribution/play-sessions/ { + default_type application/json; + client_max_body_size 210m; + limit_conn genarrative_api_conn 64; + limit_req zone=genarrative_api_rps burst=64 nodelay; + + if ($genarrative_maintenance) { + return 503 '{"ok":false,"error":{"code":"MAINTENANCE","message":"服务维护中"}}'; + } + + proxy_pass http://genarrative_api; + proxy_http_version 1.1; + proxy_buffering off; + proxy_read_timeout 3600s; + proxy_send_timeout 3600s; + add_header X-Accel-Buffering no always; + proxy_set_header Connection ""; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Host $host; + proxy_set_header X-Request-Id $request_id; + proxy_set_header Cookie ""; + } + # 临时兼容主站仍在使用的 /api/* HTTP facade;前端完成 SpacetimeDB SDK 迁移后删除。 location ~ ^/api(?:/|$) { default_type application/json; diff --git a/deploy/nginx/genarrative.conf b/deploy/nginx/genarrative.conf index 15d8c4d04..1275c3e38 100644 --- a/deploy/nginx/genarrative.conf +++ b/deploy/nginx/genarrative.conf @@ -144,6 +144,38 @@ server { include /etc/nginx/snippets/genarrative-host-extras.conf; + # 平台付费游戏播放会话入口:`/api/game-distribution/play-sessions//…` 是 sandbox iframe + # 的 src,包内相对资源沿同一前缀解析。它与通用 `/api` 同口径代理到 api-server(大小上限、 + # 限流、超时、维护判断都保持一致),唯一差别是清空 Cookie:播放会话不读账号凭证,而 + # api-server 播放网关对带平台 refresh Cookie 的请求返回 403(纵深防御保留)。 + # 前缀 location 必须写 `^~`:不加时正则 location `~ ^/api(?:/|$)` 会先命中,Cookie 又会被转发。 + # 只匹配带尾斜杠的前缀,创建会话的 `POST /api/game-distribution/play-sessions` 仍走通用 `/api`。 + location ^~ /api/game-distribution/play-sessions/ { + default_type application/json; + client_max_body_size 210m; + limit_conn genarrative_api_conn 64; + limit_req zone=genarrative_api_rps burst=64 nodelay; + + if ($genarrative_maintenance) { + return 503 '{"ok":false,"error":{"code":"MAINTENANCE","message":"服务维护中"}}'; + } + + proxy_pass http://genarrative_api; + proxy_http_version 1.1; + proxy_buffering off; + proxy_read_timeout 3600s; + proxy_send_timeout 3600s; + add_header X-Accel-Buffering no always; + proxy_set_header Connection ""; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Host $host; + proxy_set_header X-Request-Id $request_id; + proxy_set_header Cookie ""; + } + # 临时兼容主站仍在使用的 /api/* HTTP facade;前端完成 SpacetimeDB SDK 迁移后删除。 location ~ ^/api(?:/|$) { default_type application/json; diff --git a/deploy/pingora/nginx-route-parity.matrix.json b/deploy/pingora/nginx-route-parity.matrix.json index 7df7ff384..811e08f7f 100644 --- a/deploy/pingora/nginx-route-parity.matrix.json +++ b/deploy/pingora/nginx-route-parity.matrix.json @@ -366,6 +366,27 @@ }, "docs": ["/games/game_<32 位十六进制 id>/…", "平台同源发行入口"] }, + { + "id": "play_sessions_gateway", + "samplePath": "/api/game-distribution/play-sessions/token_1/index.html", + "expect": { + "kind": "play_session_gateway", + "protectionClass": "api" + }, + "nginx": { + "production": [ + "location ^~ /api/game-distribution/play-sessions/", + "proxy_set_header Cookie \"\";", + "proxy_pass http://genarrative_api;" + ], + "development": [ + "location ^~ /api/game-distribution/play-sessions/", + "proxy_set_header Cookie \"\";", + "proxy_pass http://genarrative_api;" + ] + }, + "docs": ["平台付费游戏播放会话入口", "/api/game-distribution/play-sessions//…"] + }, { "id": "web_spa_case_trailing_slash", "samplePath": "/PROJECT/", diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 80c0a762f..6df764acd 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -1,5 +1,16 @@ # 决策记录 +## 2026-10-05 播放会话前缀在三处入口清空 Cookie,网关 403 纵深防御不变 + +- 背景:付费游戏的可玩入口是创建播放会话后拿到的 `/api/game-distribution/play-sessions//`(sandbox iframe 的 `src`,包内相对资源沿同一前缀解析)。该前缀落在 `/api/*` 上,边缘通用 `/api` location 必须转发 Cookie(`/api/auth/*` 需要 refresh cookie),而 `api-server` 播放网关对带可解析平台 refresh Cookie 的请求返回 403,导致真实浏览器里 iframe 与每个包内资源都 403、付费游戏实际不可玩。 +- 决策(边缘):三份 nginx 模板(`deploy/nginx/genarrative.conf`、`deploy/nginx/genarrative-dev-http.conf`、`deploy/container/nginx.conf`)在通用 `/api` location 之前内联 `location ^~ /api/game-distribution/play-sessions/`;代理头、`client_max_body_size 210m`、`limit_conn` / `limit_req`、超时与维护判断与通用 `/api` 保持一致,额外清空 Cookie。`^~` 必填(否则正则 location `~ ^/api(?:/|$)` 优先命中),且只匹配带尾斜杠的前缀。 +- 决策(dev):`vite.config.ts` 在 `/api/game-distribution` 之前加同名前缀代理规则,`proxyReq.removeHeader('cookie')`。 +- 决策(网关):`server-rs/crates/pingora-gateway` 新增 `RouteDecision::PlaySessionGateway`,与通用 `/api` 同口径(api 上游、api 限流分组、大小上限、维护闸),差别只在经新增的 `route_clears_cookie` 清空 Cookie(发行入口 `ReleaseGateway` 复用同一判定)。**不放宽** `api-server` 的 Cookie 拒绝。 +- 决策(边界):前缀只认带尾斜杠的形式,创建会话的 `POST /api/game-distribution/play-sessions` 与 `POST /api/game-distribution/games/{gameId}/play-session` 继续走通用 `/api` 并保留 Cookie。 +- 门禁:矩阵新增 `play_sessions_gateway` 用例;`check:nginx-spa-routes` 新增「该前缀 location 存在、清空 Cookie、排在通用 `/api` 之前」断言,`check:pingora-route-parity` 新增「矩阵用例必须声明清 Cookie 且不得复用通用 `/api` location / Rust 播放会话分支必须排在通用 `/api` 之前」断言,`check:pingora-gateway-smoke` 新增真实网关下「该前缀清 Cookie、创建会话端点保留 Cookie」用例。 +- 影响面:`deploy/nginx/{genarrative.conf,genarrative-dev-http.conf,README.md}`、`deploy/container/nginx.conf`、`vite.config.ts`、`server-rs/crates/pingora-gateway/src/main.rs`、`deploy/pingora/nginx-route-parity.matrix.json`、`scripts/check-{nginx-spa-routes,pingora-route-parity,pingora-gateway-smoke}.mjs`、`docs/technical/【开发运维】Pingora独立网关试点-2026-06-11.md`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`。 +- 关联:`docs/project-memory/shared-memory/pitfalls.md`「付费游戏播放会话前缀落在 `/api/*`」条。 + ## 2026-10-03 AGC 发布版本标签改为由工程内部版本派生,取代「用户可编辑标签」口径 - 背景:用户实机验收指出发布面板「项目版本」显示 v6,而 AGC 工程内部只有 4 条正式版本记录(资源总览「项目版本」栏目 4 张卡,顶栏「智能体修订」下拉同样只有这 4 条)。核实:面板值来自本地清单 `manifest.projectVersion` 这个可编辑标量,它被三条链路反复钉到**平台** `game_distribution_version.version_number` 上——发布成功回写(`apps/ai-game-creator-shell/src-tauri/src/game_distribution_publish.rs:1531-1535`)、打开面板回读绑定回填(`:536-541`)、用户手改(`:944-965`);而 `manifest.versions` 从头到尾不参与该值。`publicationRevision` 只做 CAS,与任何版本号都无推导关系(`module-game-distribution/src/domain.rs:27-40` 的版本号解析只比 `max_existing` 与 `requested`)。 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 444162046..a5f37962d 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -2,6 +2,15 @@ 这里只记录对当前开发仍有用的症状、根因、排查方法和风险边界。同一事实保留一个当前口径;退役对象的专属过程与单轮测试结果由 Git 历史追溯。遇到旧路径或版本时,以现行代码和专题文档为准。 +## 2026-10-05 付费游戏播放会话前缀落在 `/api/*`:边缘转发 Cookie 会让 iframe 与包内每个资源都 403 + +- **现象**:付费游戏在真实浏览器里打不开——播放会话 `src`(`/api/game-distribution/play-sessions//`)本身和包内每个相对资源(JS/CSS/图片/音频)全是 403,同一份包的免费游戏 `/games//` 正常。 +- **原因**:播放会话前缀落在 `/api/*` 上,而通用 `/api` location 必须转发 Cookie(`/api/auth/*` 依赖 refresh cookie);`api-server` 播放网关对带**可解析平台 refresh Cookie** 的请求返回 403(与发行网关同族的纵深防御,本次不放宽),于是沙箱 iframe 的每个同前缀请求都带 Cookie、都被拒。dev 侧同样复现:`vite.config.ts` 原本只对 `/games/...` 清 Cookie,`/api/game-distribution` 规则会转发 Cookie。 +- **处理(现行口径)**:三份 nginx 模板(`deploy/nginx/genarrative.conf`、`deploy/nginx/genarrative-dev-http.conf`、`deploy/container/nginx.conf`)在通用 `/api` location **之前**加 `location ^~ /api/game-distribution/play-sessions/`——`^~` 不能省,否则正则 location `~ ^/api(?:/|$)` 优先命中、Cookie 又被转发;代理头 / `client_max_body_size 210m` / `limit_conn` / `limit_req` / 超时 / 维护判断都与通用 `/api` 一致,只多一条 `proxy_set_header Cookie ""`。`vite.config.ts` 在 `/api/game-distribution` 之前加同名前缀规则(`proxyReq.removeHeader('cookie')`)。Pingora 侧对应 `RouteDecision::PlaySessionGateway`:路径原样走 api 上游,同样套 api 限流分组、大小上限与维护闸,差别只在新增的 `route_clears_cookie` 清空 Cookie(发行入口 `ReleaseGateway` 复用同一判定)。 +- **边界**:前缀**只匹配带尾斜杠**的形式——创建会话的 `POST /api/game-distribution/play-sessions`(以及 `POST /api/game-distribution/games/{gameId}/play-session`)需要账号凭证,必须继续走通用 `/api` 并保留 Cookie。网关的 403 拒绝保持不变,只在边缘/dev 保证请求不带 Cookie。 +- **门禁**:`npm run check:nginx-spa-routes` 对三份模板断言该 `^~` location 存在、块内清空 Cookie、代理头齐全且排在通用 `/api` location 之前(变异验证:删掉块内 `proxy_set_header Cookie "";` 立刻报「播放会话前缀 location 缺少代理片段」);`npm run check:pingora-route-parity` 断言矩阵 `play_sessions_gateway` 用例声明清 Cookie 片段、不复用通用 `/api` location,且 Rust `classify_path` 的播放会话分支排在通用 `/api` 之前(变异验证:把矩阵片段换成通用 location、或在 Rust 里交换两个分支,各自单独判红);`npm run check:pingora-gateway-smoke` 用真实网关二进制断言该前缀清 Cookie、创建会话端点保留 Cookie。三条都串在 `npm run lint` 链里,有自动调用方。 +- **关联**:`server-rs/crates/pingora-gateway/src/main.rs`、`deploy/pingora/nginx-route-parity.matrix.json`、`deploy/nginx/README.md`、`vite.config.ts`;另见本文件「主站 SPA allowlist 有三处真相源」条的「别踩」(发行入口不转发 Cookie 的同族规则)。 + ## 2026-10-03 AGC 随包 plugins 的 feature 档位必须与消费方一致,且门禁会因 build.rs 未重跑而假通过 - **现象**:Windows 本机 `npm run check:generated-bindings`(`npm run lint` 链内,`scripts/check-repository-ci.sh` 的 Repository checks 也走它)在 `build.rs:167:29` panic:`插件随包资源校验失败:随包插件存在未声明文件:.../src-tauri/resources/plugins/agc-godot-editor/native/gdextension/bin/win-x64/agc_godot_editor.dll(目标 x86_64-pc-windows-msvc 与当前 feature 组合不允许;请先执行随包资源准备步骤)`;树上换成 `agc-unity-editor/dotnet/publish/win-x64/Agc.Unity.Attach.exe` 时报同一类错。反向还有更隐蔽的形态:门禁 2 秒就 exit 0 说「通过」,但 tree 上其实带着编辑器产物。 diff --git a/docs/technical/【开发运维】Pingora独立网关试点-2026-06-11.md b/docs/technical/【开发运维】Pingora独立网关试点-2026-06-11.md index e56f73911..e22416c35 100644 --- a/docs/technical/【开发运维】Pingora独立网关试点-2026-06-11.md +++ b/docs/technical/【开发运维】Pingora独立网关试点-2026-06-11.md @@ -532,6 +532,7 @@ dev 根盘空间在安装后曾接近满盘;2026-06-17 进入 canary 前已清 | `/admin/*` | 先读取静态文件或目录 index,失败回退 `/admin/index.html`,HTML 默认 `no-cache`,并支持条件请求返回 `304` 与单段 `Range: bytes=` 返回 `206` / 越界返回 `416`。 | | `/assets/*` | 从 Web 根目录精确读取静态文件;带 Vite 指纹的文件默认长期缓存,其它文件默认 `no-cache`,并支持条件请求返回 `304` 与单段 `Range: bytes=` 返回 `206` / 越界返回 `416`。 | | `/api`、`/api/*` | 转发到 `api-server`,按配置执行 `Content-Length` 与流式 body 累计上限检查。 | +| `/api/game-distribution/play-sessions/*` | 付费游戏播放会话入口,转发到 `api-server`;与 `/api/*` 同样限流、大小上限与维护判断,额外清空 `Cookie`(详见下节)。 | | `/v1/database/{db}/subscribe`、`/v1/identity*` | 转发到 SpacetimeDB,保留 WebSocket Upgrade 头。 | | `/__genarrative_pingora/healthz` | 仅在携带 `X-Genarrative-Pingora-Probe` 且匹配配置 token 时返回 shadow JSON,否则 404。 | | `/v1/*`、`/generated-*`、`/healthz*`、`/readyz*` | 返回 404,保持生产公网不暴露口径。 | @@ -545,6 +546,8 @@ SPA allowlist 里属于游戏分发入口的深链(游戏目录 / 详情 / 游 **平台同源发行入口**(`/games/game_<32 位十六进制 id>/…`)与 SPA allowlist 是两条不同的路由:Nginx 用 `location ~ "^/games/(?game_[0-9a-f]{32})(?/.*)?$"` 把它代理到 api-server 的发行网关(`proxy_set_header Cookie ""` + `proxy_pass .../api/game-distribution/releases/$game_id$game_path`),Pingora 侧对应 `RouteDecision::ReleaseGateway`:走 api 上游,但把上游路径重写成 `/api/game-distribution/releases/`(与 Nginx 的 `proxy_pass` 同口径,原来的 query 不再拼接)、清空 `Cookie`,并按 Nginx 该 location 的语义既不进 SPA fallback、也不套用 `limit_conn` / `limit_req` 分组、不受维护闸拦截。只认小写、固定 32 位十六进制 id;`/games/detail` 这类 SPA 深链与 `/games/game/...` 这类形状不符的路径都不会被吞进发行网关。该口径由矩阵的 `games_release_gateway` 用例(含 `upstreamPath` 期望值)与 `cargo test -p pingora-gateway matches_nginx_route_parity_matrix` 固定。 +**平台付费游戏播放会话入口**(`/api/game-distribution/play-sessions//…`,sandbox iframe 的 `src`,包内相对资源沿同一前缀解析)与通用 `/api` 路由只差一件事:转发时必须清空 `Cookie`。api-server 播放网关对带可解析平台 refresh Cookie 的请求返回 403(纵深防御,保留),Cookie 一旦被边缘转发,iframe 与包内每个相对资源都会 403,付费游戏实际不可玩(2026-10-05 就是这样暴露的)。因此 Nginx 三份模板都加 `location ^~ /api/game-distribution/play-sessions/`(`^~` 不能省,否则正则 location `~ ^/api(?:/|$)` 优先命中、Cookie 又被转发),代理头、`client_max_body_size`、`limit_conn` / `limit_req`、超时与维护判断都与通用 `/api` location 一致,只是多了 `proxy_set_header Cookie ""`;dev 侧 `vite.config.ts` 用同名前缀规则在通用 `/api/game-distribution` 之前清 Cookie。Pingora 侧对应 `RouteDecision::PlaySessionGateway`:路径原样走 api 上游、同样套用 api 限流分组、大小上限与维护闸,唯一差别是经 `route_clears_cookie` 清空 `Cookie`。前缀只匹配带尾斜杠的形式,创建会话的 `POST /api/game-distribution/play-sessions` 仍走通用 `/api` 并保留 Cookie。该口径由矩阵的 `play_sessions_gateway` 用例、`cargo test -p pingora-gateway matches_nginx_route_parity_matrix` 与 `npm run check:nginx-spa-routes` 里的播放会话 Cookie 隔离断言固定。 + 维护模式下,公网 API-like 路由返回 JSON `503`;公网 Web 静态路由先读取 `GENARRATIVE_PINGORA_GATEWAY_MAINTENANCE_PAGE_FILE` 指向的 release 外运行态公告,缺失时回退 `GENARRATIVE_PINGORA_GATEWAY_WEB_ROOT/maintenance.html`,两者都不存在时返回纯文本 `503`。版本化默认页不得包含日期或具体时段,临时公告由 `maintenance-on.sh --page-file` 安装并在 `maintenance-off.sh` 时清理。IPv4 loopback / RFC1918 / link-local 和 IPv6 loopback / ULA / link-local 来源绕过整站维护闸,主站页面与静态资源、普通 API、后台页面与后台 API、SpacetimeDB 路由均按非维护状态继续处理;应用层登录、管理员鉴权和其它业务鉴权保持不变。Pingora 直连按 TCP peer 判定来源;仅当 peer 是 loopback 的同机 Nginx 时才接受 Nginx 强制覆盖的 `X-Real-IP`,绝不使用客户端可伪造的 `X-Forwarded-For` 做维护放行。该放行只绕过网关维护响应;若 `pause-after-stdb` 已停止 api-server,内网普通 API 和后台 API 仍不可用。 代理失败时,API / SpacetimeDB 等代理路由返回统一 JSON 网关错误;本地静态路由仍保持对应 HTTP 错误状态。 静态 `Range` 只支持单段 bytes range;多段 range 暂按完整文件返回,避免在正式替换前引入 multipart 响应面。`If-None-Match` / `If-Modified-Since` 优先于 `Range` 判定,命中时仍返回 `304`;`If-Range` 日期匹配时继续返回 `206`,日期旧于文件或弱 ETag 校验器时回完整 `200`;`206` / `304` / `416` 不做 gzip 压缩,避免 `Content-Range` 语义被响应体改写破坏。Gateway smoke 会用固定 `X-Request-Id` 对账静态 `304`、`405`、`206`、`416` 的 Pingora access log 行,确认本地响应状态也进入正式切换证据链。 diff --git a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md index dc13e3894..b2318066f 100644 --- a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md +++ b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md @@ -745,6 +745,7 @@ Jenkins 按 web / api / Spacetime module / build / deploy / publish 拆分 - 会话与隔离:边缘在转发前 `proxy_set_header Cookie ""`,发行网关自身也对带 `Cookie` 的请求返回 `403`;游戏文档跑在 iframe `sandbox="allow-scripts"` 的不透明来源里,读不到主站 Cookie、storage 与 DOM,离开页面即随 iframe 卸载整套游戏代码。 - 响应头与缓存:`X-Content-Type-Options`、CORP(`cross-origin`)、无凭据 CORS、HTML CSP、内容类型白名单与 `Cache-Control: public, max-age=60, must-revalidate` 都由发行网关设置,边缘不覆盖。换版与下架只改变后端公开投影,因此**最迟 60 秒**内新请求不再拿到旧版本;已经下载到浏览器的脚本无法远程抹除,撤销能力以"停止继续分发"为准。 - 审核动作:管理员只提交审核结论与公开修订号,`entryUrl` 由 `api-server` 按 gameId 派生**同源路径** `/games/{gameId}/` 写入公开投影;dev / release / 预览环境口径完全一致,入口不再由部署侧配置,历史数据里的绝对 URL 继续兼容。 +- 付费游戏播放会话:付费游戏不走 `/games//`(未购买访问 404),可玩入口是创建播放会话后拿到的 `/api/game-distribution/play-sessions//`,它直接作为 iframe `src`,包内相对资源沿同一前缀解析。该前缀落在 `/api/*` 上,而通用 `/api` location 必须转发 Cookie(`/api/auth/*` 依赖 refresh cookie),Cookie 一到 `api-server` 播放网关就会命中它的 `403` 纵深防御,iframe 与包内每个资源都不可用。因此三份 nginx 模板在通用 `/api` location **之前**内联 `location ^~ /api/game-distribution/play-sessions/`(`^~` 不能省,否则正则 `~ ^/api(?:/|$)` 优先命中),代理头、`client_max_body_size 210m`、`limit_conn` / `limit_req`、超时与维护判断都与通用 `/api` 一致,只多一条 `proxy_set_header Cookie ""`;`vite.config.ts` 里排在 `/api/game-distribution` 之前的同名前缀规则做同一件事(`proxyReq.removeHeader('cookie')`)。网关的 Cookie 拒绝不放宽,只是让边缘/dev 转发时不带 Cookie。 - 门禁: ```bash diff --git a/scripts/check-nginx-spa-routes.mjs b/scripts/check-nginx-spa-routes.mjs index be4868bcb..fe7a10320 100644 --- a/scripts/check-nginx-spa-routes.mjs +++ b/scripts/check-nginx-spa-routes.mjs @@ -346,6 +346,74 @@ function validateMaintenanceInternalBypass() { } } +const PLAY_SESSION_LOCATION = 'location ^~ /api/game-distribution/play-sessions/'; +const GENERIC_API_LOCATION = 'location ~ ^/api(?:/|$)'; + +/** 取某个 location 头之后配对的花括号块内容;找不到返回 null。 */ +function findLocationBody(source, locationHeader) { + const start = source.indexOf(locationHeader); + if (start < 0) { + return null; + } + const openBrace = source.indexOf('{', start); + if (openBrace < 0) { + return null; + } + let depth = 0; + for (let index = openBrace; index < source.length; index += 1) { + if (source[index] === '{') { + depth += 1; + } else if (source[index] === '}') { + depth -= 1; + if (depth === 0) { + return source.slice(openBrace + 1, index); + } + } + } + return null; +} + +/** + * 付费游戏播放会话前缀(sandbox iframe src 的前缀,包内相对资源沿同一前缀解析)必须有自己的 + * `^~` 前缀 location 并清空 Cookie:api-server 播放网关对带平台 refresh Cookie 的请求返回 403, + * Cookie 一旦被边缘转发,iframe 与包内每个资源都会 403,付费游戏实际不可玩。 + * `^~` 不能省——不加时正则 location `~ ^/api(?:/|$)` 优先级更高,Cookie 又会被转发回去; + * 前缀末尾的斜杠也不能省——创建会话的 `POST /api/game-distribution/play-sessions` 需要账号凭证。 + */ +function validatePlaySessionCookieIsolation() { + for (const nginxPath of NGINX_PATHS) { + const source = readFileSync(nginxPath, 'utf8'); + const playSessionIndex = source.indexOf(PLAY_SESSION_LOCATION); + if (playSessionIndex < 0) { + fail(`${nginxPath} 缺少播放会话前缀 location:${PLAY_SESSION_LOCATION}`); + continue; + } + const body = findLocationBody(source, PLAY_SESSION_LOCATION); + if (body === null) { + fail(`${nginxPath} 播放会话前缀 location 没有配对的闭合块。`); + continue; + } + for (const fragment of [ + 'proxy_set_header Cookie "";', + 'proxy_pass http://genarrative_api;', + 'proxy_set_header Host $host;', + 'proxy_set_header X-Real-IP $remote_addr;', + 'proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;', + 'proxy_set_header X-Forwarded-Proto $scheme;', + ]) { + if (!body.includes(fragment)) { + fail(`${nginxPath} 播放会话前缀 location 缺少代理片段:${fragment}`); + } + } + const genericApiIndex = source.indexOf(GENERIC_API_LOCATION); + if (genericApiIndex >= 0 && playSessionIndex > genericApiIndex) { + fail( + `${nginxPath} 播放会话前缀 location 必须排在通用 /api location(${GENERIC_API_LOCATION})之前。`, + ); + } + } +} + export const expectedMainSpaRoutes = collectExpectedMainSpaRoutes(); export const expectedPrefixRoutes = collectExpectedPrefixRoutes(); @@ -358,6 +426,7 @@ if (isMainModule) { validateNginxRoutes(nginxPath, expectedMainSpaRoutes, expectedPrefixRoutes); } validateMaintenanceInternalBypass(); + validatePlaySessionCookieIsolation(); if (failures.length > 0) { console.error('[check:nginx-spa-routes] FAILED'); diff --git a/scripts/check-pingora-gateway-smoke.mjs b/scripts/check-pingora-gateway-smoke.mjs index 5ba25f392..13c2d2d91 100644 --- a/scripts/check-pingora-gateway-smoke.mjs +++ b/scripts/check-pingora-gateway-smoke.mjs @@ -1060,6 +1060,74 @@ async function runSmokeCases( '发行入口形状不符时仍是真实 404', ); + // 平台付费游戏播放会话入口:/api/game-distribution/play-sessions//… 原样转发到 api 上游, + // 但必须清空 Cookie——api-server 播放网关对带平台 refresh Cookie 的请求返回 403(纵深防御保留), + // Cookie 一旦被转发,iframe 与包内每个相对资源都会 403。 + const playSessionPath = + '/api/game-distribution/play-sessions/token_smoke/index.html'; + const playSessionBeforeCount = api.state.requests.length; + const playSessionResponse = await expectHttp( + baseUrl, + playSessionPath, + 200, + '"upstream":"api"', + '播放会话入口转发到 api 上游且清空 Cookie', + { + headers: { + // API 接流保护是 1 req/s、无 burst,这里用独立来源 IP 取一个干净的令牌桶, + // 避免与同一进程里其它 loopback API 用例互相耗尽配额(与下面 429 用例同法)。 + 'X-Forwarded-For': '203.0.113.71', + 'X-Request-Id': 'smoke-play-session-request-id', + Host: 'example.test', + Cookie: 'session=smoke-must-not-reach-play-session', + }, + }, + ); + const playSessionPayload = JSON.parse(playSessionResponse.body); + ensure( + playSessionPayload.url === playSessionPath, + `播放会话入口上游路径不应被重写:${playSessionPayload.url}`, + ); + const playSessionUpstreamRequests = api.state.requests.slice( + playSessionBeforeCount, + ); + ensure( + playSessionUpstreamRequests.length === 1 && + playSessionUpstreamRequests[0].url === playSessionPath, + `播放会话入口上游请求不符:${describeRequests(playSessionUpstreamRequests)}`, + ); + ensure( + playSessionUpstreamRequests[0]?.headers.cookie === undefined, + `播放会话入口没有清空 Cookie:${describeRequests(playSessionUpstreamRequests)}`, + ); + + // 创建会话的 `POST /api/game-distribution/play-sessions`(无尾斜杠)需要账号凭证, + // 必须继续走通用 `/api` 规则并保留 Cookie。 + const createSessionBeforeCount = api.state.requests.length; + await expectHttp( + baseUrl, + '/api/game-distribution/play-sessions', + 200, + '"upstream":"api"', + '创建会话入口保留 Cookie', + { + headers: { + 'X-Forwarded-For': '203.0.113.72', + Host: 'example.test', + Cookie: 'session=smoke-must-reach-create-session', + }, + }, + ); + const createSessionUpstreamRequests = api.state.requests.slice( + createSessionBeforeCount, + ); + ensure( + createSessionUpstreamRequests.length === 1 && + createSessionUpstreamRequests[0]?.headers.cookie === + 'session=smoke-must-reach-create-session', + `创建会话入口不应清空 Cookie:${describeRequests(createSessionUpstreamRequests)}`, + ); + await expectHttp( baseUrl, '/api/upload', diff --git a/scripts/check-pingora-route-parity.mjs b/scripts/check-pingora-route-parity.mjs index 49fee68bd..87551a4a7 100644 --- a/scripts/check-pingora-route-parity.mjs +++ b/scripts/check-pingora-route-parity.mjs @@ -18,6 +18,7 @@ const VALID_KINDS = new Set([ 'proxy', 'static', 'release_gateway', + 'play_session_gateway', 'redirect_permanent', 'shadow_probe', 'not_found', @@ -45,6 +46,7 @@ const REQUIRED_ROUTE_IDS = [ 'profile_spa_fallback', 'games_spa_fallback', 'games_release_gateway', + 'play_sessions_gateway', 'web_root_spa', 'web_spa_case_trailing_slash', 'web_unknown_path_exact', @@ -111,6 +113,20 @@ function validateExpectation(route) { 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。`); } @@ -238,6 +254,7 @@ function validateRustTestUsesMatrix() { '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}`); @@ -245,6 +262,109 @@ function validateRustTestUsesMatrix() { } } +// 播放会话前缀的 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 和矩阵都没有,门禁一直是绿的。 @@ -321,6 +441,8 @@ function validateRustMainSpaPrefixPaths() { validateMatrixShape(); validateRustTestUsesMatrix(); +validateRustPlaySessionGatewayIsolation(); +validateContentGatewayCookieIsolation(); validateNginxLocationsAreCovered(); validateRustMainSpaRoutes(); validateRustMainSpaPrefixPaths(); diff --git a/server-rs/crates/pingora-gateway/src/main.rs b/server-rs/crates/pingora-gateway/src/main.rs index 51df31e98..78c5d45fc 100644 --- a/server-rs/crates/pingora-gateway/src/main.rs +++ b/server-rs/crates/pingora-gateway/src/main.rs @@ -862,6 +862,12 @@ enum RouteDecision { ReleaseGateway { upstream_path: String, }, + /// 平台付费游戏播放会话入口:`/api/game-distribution/play-sessions//…`。 + /// 与 Nginx 的同名前缀 location(`^~ /api/game-distribution/play-sessions/`)同口径: + /// 路径原样走 api 上游,其余路由属性(大小上限、限流、维护判断)与通用 `/api` 路由一致, + /// 唯一差别是在代理阶段清空 Cookie——播放会话不读账号凭证,网关对带平台 refresh Cookie + /// 的请求会返回 403(纵深防御保留)。 + PlaySessionGateway, Local(LocalResponse), } @@ -872,10 +878,11 @@ impl RouteDecision { RouteDecision::Proxy { target: ProxyTarget::Api | ProxyTarget::Spacetime, .. - } | RouteDecision::Local(LocalResponse::Static { - root: StaticRoot::Web, - .. - }) + } | RouteDecision::PlaySessionGateway + | RouteDecision::Local(LocalResponse::Static { + root: StaticRoot::Web, + .. + }) ) } @@ -885,15 +892,17 @@ impl RouteDecision { RouteDecision::Proxy { target: ProxyTarget::Api, .. - } + } | RouteDecision::PlaySessionGateway ) } fn proxy_target(&self) -> Option { match self { RouteDecision::Proxy { target, .. } => Some(*target), - // 发行入口同样代理到 api 上游,只有路径与请求头在代理阶段被重写。 - RouteDecision::ReleaseGateway { .. } => Some(ProxyTarget::Api), + // 发行入口与播放会话入口同样代理到 api 上游,只有路径与请求头在代理阶段被改写。 + RouteDecision::ReleaseGateway { .. } | RouteDecision::PlaySessionGateway => { + Some(ProxyTarget::Api) + } RouteDecision::Local(_) => None, } } @@ -903,11 +912,24 @@ impl RouteDecision { RouteDecision::Proxy { body_limit, .. } => *body_limit, // 发行入口只服务静态资源读取,Nginx 侧也没有请求体上限指令。 RouteDecision::ReleaseGateway { .. } => None, + // 播放会话入口与通用 `/api` 路由同口径(Nginx 侧同样 `client_max_body_size 210m`)。 + RouteDecision::PlaySessionGateway => Some(DEFAULT_MAX_API_BODY_BYTES), RouteDecision::Local(_) => None, } } } +/// 该路由的转发是否必须清空 Cookie。 +/// +/// 平台内容网关(发行入口 / 播放会话入口)都不使用账号凭证:Nginx 侧对应 location 都写了 +/// `proxy_set_header Cookie ""`,api-server 网关也会拒绝带平台 refresh Cookie 的请求。 +fn route_clears_cookie(route: &RouteDecision) -> bool { + matches!( + route, + RouteDecision::ReleaseGateway { .. } | RouteDecision::PlaySessionGateway + ) +} + #[derive(Clone, Debug, PartialEq, Eq, Hash)] struct ProtectionKey { class: ProtectionClass, @@ -1199,7 +1221,8 @@ impl ProxyHttp for GenarrativeGateway { match &ctx.route { RouteDecision::Proxy { .. } => Ok(false), - RouteDecision::ReleaseGateway { .. } => Ok(false), + // 发行入口 / 播放会话入口都继续走上游代理,不需要在本阶段就地响应。 + RouteDecision::ReleaseGateway { .. } | RouteDecision::PlaySessionGateway => Ok(false), RouteDecision::Local(LocalResponse::RedirectPermanent { location }) => { respond_redirect(session, location).await?; Ok(true) @@ -1315,10 +1338,14 @@ impl ProxyHttp for GenarrativeGateway { } // 中文注释:平台同源发行入口按 Nginx 的 proxy_pass 口径重写路径——换成 - // /api/game-distribution/releases/,原来的 query 不再拼接; - // 同时清空 Cookie,发行内容不读账号凭证。 + // /api/game-distribution/releases/,原来的 query 不再拼接。 if let RouteDecision::ReleaseGateway { upstream_path } = &ctx.route { upstream_request.set_raw_path(upstream_path.as_bytes())?; + } + + // 中文注释:平台内容网关(发行入口 / 播放会话入口)一律清空 Cookie,内容不读账号凭证; + // Nginx 侧对应 location 同口径写 `proxy_set_header Cookie ""`。 + if route_clears_cookie(&ctx.route) { upstream_request.remove_header("cookie"); } @@ -1674,12 +1701,22 @@ fn release_gateway_upstream_path(path: &str) -> Option { )) } +/// 平台付费游戏播放会话前缀:`/api/game-distribution/play-sessions//…`。 +/// +/// 与 Nginx 的 `location ^~ /api/game-distribution/play-sessions/` 同口径:只认带尾斜杠的 +/// 前缀,创建会话的 `POST /api/game-distribution/play-sessions` 仍然走通用 `/api` 路由。 +fn is_play_session_proxy_path(path: &str) -> bool { + path.starts_with("/api/game-distribution/play-sessions/") +} + fn protection_class_for_route(route: &RouteDecision, path: &str) -> Option { match route { RouteDecision::Proxy { target: ProxyTarget::Api, .. } if path.starts_with("/admin/api/") => Some(ProtectionClass::AdminApi), + // 播放会话入口与通用 `/api` 路由同口径:同样吃 api 限流(Nginx 侧同样声明 limit_conn / limit_req)。 + RouteDecision::PlaySessionGateway => Some(ProtectionClass::Api), RouteDecision::Proxy { target: ProxyTarget::Api, .. @@ -1898,6 +1935,12 @@ fn classify_path(path: &str) -> RouteDecision { }; } + // 播放会话前缀先判:它同样落在 `/api/` 下,但转发时必须清空 Cookie(Nginx 侧同名的 + // `^~` 前缀 location 也是排在通用 `/api` 正则 location 之前)。 + if is_play_session_proxy_path(path) { + return RouteDecision::PlaySessionGateway; + } + if path == "/api" || path.starts_with("/api/") { return RouteDecision::Proxy { target: ProxyTarget::Api, @@ -3190,6 +3233,14 @@ mod tests { case.sample_path ); } + ("play_session_gateway", RouteDecision::PlaySessionGateway) => { + assert!( + route_clears_cookie(route), + "route parity play session gateway must clear cookie: {} {}", + case.id, + case.sample_path + ); + } ( "redirect_permanent", RouteDecision::Local(LocalResponse::RedirectPermanent { location }), @@ -4032,6 +4083,91 @@ mod tests { ); } + #[test] + fn classifies_play_session_paths_and_clears_cookie() { + let play_session_route = + classify_path("/api/game-distribution/play-sessions/token_1/index.html"); + assert_eq!(play_session_route, RouteDecision::PlaySessionGateway); + // 播放会话入口走 api 上游,但与通用 `/api` 一样受限流、大小上限与维护判断约束, + // 唯独多一条「清空 Cookie」。 + assert_eq!(play_session_route.proxy_target(), Some(ProxyTarget::Api)); + assert_eq!( + play_session_route.body_limit(), + Some(DEFAULT_MAX_API_BODY_BYTES) + ); + assert!(play_session_route.applies_maintenance_gate()); + assert!(play_session_route.is_api_like()); + assert_eq!( + protection_class_for_route( + &play_session_route, + "/api/game-distribution/play-sessions/token_1/index.html" + ), + Some(ProtectionClass::Api) + ); + assert!(should_disable_accel_buffering(&play_session_route)); + assert!(route_clears_cookie(&play_session_route)); + + // 包内相对资源沿同一令牌前缀解析,子路径同样命中。 + for path in [ + "/api/game-distribution/play-sessions/token_1/", + "/api/game-distribution/play-sessions/token_1", + "/api/game-distribution/play-sessions/token_1/assets/main.js", + "/api/game-distribution/play-sessions/token_1/audio/bgm.ogg", + ] { + assert!(is_play_session_proxy_path(path), "path: {path}"); + assert_eq!( + classify_path(path), + RouteDecision::PlaySessionGateway, + "path: {path}" + ); + } + + // 创建会话的 `POST /api/game-distribution/play-sessions` 仍然走通用 `/api`, + // 它需要账号凭证,绝不能跟着清空 Cookie。 + for path in [ + "/api/game-distribution/play-sessions", + "/api/game-distribution/games/game_1/play-session", + "/api/game-distribution/releases/game_1/index.html", + "/api/assets/history", + "/api", + ] { + assert!(!is_play_session_proxy_path(path), "path: {path}"); + assert!( + !route_clears_cookie(&classify_path(path)), + "path: {path}" + ); + assert_eq!( + classify_path(path), + RouteDecision::Proxy { + target: ProxyTarget::Api, + body_limit: Some(DEFAULT_MAX_API_BODY_BYTES), + }, + "path: {path}" + ); + } + } + + #[test] + fn only_platform_content_gateways_clear_cookie() { + // `release_gateway` 与 `play_session_gateway` 都要清空 Cookie;通用 API 代理与 + // 静态路由都不清(`/api/auth/*` 依赖 refresh Cookie)。 + assert!(route_clears_cookie(&classify_path( + "/games/game_0123456789abcdef0123456789abcdef/index.html" + ))); + assert!(route_clears_cookie(&classify_path( + "/api/game-distribution/play-sessions/token_1/" + ))); + for path in [ + "/api/auth/refresh", + "/api/assets/history", + "/api/game-distribution/purchases", + "/games/detail", + "/assets/app.js", + ] { + assert!(!route_clears_cookie(&classify_path(path)), "path: {path}"); + } + } + #[test] fn protection_rejects_when_concurrency_is_exhausted() { let config = ProtectionConfig { diff --git a/vite.config.ts b/vite.config.ts index deaae8dd9..e0b596cb5 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -697,6 +697,20 @@ export default defineConfig(({ mode }) => { changeOrigin: true, secure: false, }, + // 平台付费游戏播放会话入口:`/api/game-distribution/play-sessions//…` 是 sandbox + // iframe 的 src,包内相对资源沿同一前缀解析。它必须排在通用 `/api/game-distribution` + // 规则之前,否则 Cookie 会被带到 api-server 播放网关并触发 403(纵深防御保留); + // 生产由 nginx 同名前缀 location 做同一件事。 + '/api/game-distribution/play-sessions/': { + target: runtimeServerTarget, + changeOrigin: true, + secure: false, + configure: (proxy) => { + proxy.on('proxyReq', (proxyRequest) => { + proxyRequest.removeHeader('cookie'); + }); + }, + }, '/api/game-distribution': { target: runtimeServerTarget, changeOrigin: true,