修复付费游戏播放会话在边缘转发 Cookie 导致 403 不可玩
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
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 决策/踩坑记录
This commit is contained in:
@@ -88,6 +88,32 @@ http {
|
||||
}
|
||||
|
||||
|
||||
# 平台付费游戏播放会话入口:`/api/game-distribution/play-sessions/<token>/…` 是 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;
|
||||
|
||||
@@ -108,3 +108,11 @@ curl -sSI -H 'Accept-Encoding: br' \
|
||||
- 隔离靠 iframe 沙箱而不是独立来源:游戏文档跑在 `sandbox="allow-scripts"` 的不透明来源里,读不到主站 Cookie、storage 与 DOM,离开页面即随 iframe 卸载。
|
||||
- 审核通过时 `api-server` 按 gameId 派生同源路径 `/games/<gameId>/` 作为 `entryUrl` 写入公开投影,部署侧不再需要配置发行域名。换版本或下架只改变后端公开投影,边缘不需要改配置。
|
||||
- 门禁:`npm run check:nginx-spa-routes` 校验三份模板的 SPA allowlist(含 `/games`、`/games/detail`、`/games/play`、`/games/mine`、`/games/publish`、`/pay`、`/profile/payment`)与收银台深链前缀路由 `location ~* "^/pay/[^/]+/?$"`(`/pay/<checkoutToken>` 只放行「前缀 + 恰好一个路径段」;只放行裸前缀会让真实收银台链接落到默认 location 变 404),脚本自带正/反用例。历史上的独立来源模板与专属门禁已随同源方案上线删除。
|
||||
|
||||
## 付费游戏播放会话前缀(`/api/game-distribution/play-sessions/`)
|
||||
|
||||
- 付费游戏的可玩入口是 `POST /api/game-distribution/games/<gameId>/play-session` 换到的 `/api/game-distribution/play-sessions/<token>/`,直接作为 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` 规则。
|
||||
|
||||
@@ -116,6 +116,38 @@ server {
|
||||
}
|
||||
|
||||
|
||||
# 平台付费游戏播放会话入口:`/api/game-distribution/play-sessions/<token>/…` 是 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;
|
||||
|
||||
@@ -144,6 +144,38 @@ server {
|
||||
include /etc/nginx/snippets/genarrative-host-extras.conf;
|
||||
|
||||
|
||||
# 平台付费游戏播放会话入口:`/api/game-distribution/play-sessions/<token>/…` 是 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;
|
||||
|
||||
@@ -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/<token>/…"]
|
||||
},
|
||||
{
|
||||
"id": "web_spa_case_trailing_slash",
|
||||
"samplePath": "/PROJECT/",
|
||||
|
||||
@@ -1,5 +1,16 @@
|
||||
# 决策记录
|
||||
|
||||
## 2026-10-05 播放会话前缀在三处入口清空 Cookie,网关 403 纵深防御不变
|
||||
|
||||
- 背景:付费游戏的可玩入口是创建播放会话后拿到的 `/api/game-distribution/play-sessions/<token>/`(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`)。
|
||||
|
||||
@@ -2,6 +2,15 @@
|
||||
|
||||
这里只记录对当前开发仍有用的症状、根因、排查方法和风险边界。同一事实保留一个当前口径;退役对象的专属过程与单轮测试结果由 Git 历史追溯。遇到旧路径或版本时,以现行代码和专题文档为准。
|
||||
|
||||
## 2026-10-05 付费游戏播放会话前缀落在 `/api/*`:边缘转发 Cookie 会让 iframe 与包内每个资源都 403
|
||||
|
||||
- **现象**:付费游戏在真实浏览器里打不开——播放会话 `src`(`/api/game-distribution/play-sessions/<token>/`)本身和包内每个相对资源(JS/CSS/图片/音频)全是 403,同一份包的免费游戏 `/games/<gameId>/` 正常。
|
||||
- **原因**:播放会话前缀落在 `/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 上其实带着编辑器产物。
|
||||
|
||||
@@ -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_id>game_[0-9a-f]{32})(?<game_path>/.*)?$"` 把它代理到 api-server 的发行网关(`proxy_set_header Cookie ""` + `proxy_pass .../api/game-distribution/releases/$game_id$game_path`),Pingora 侧对应 `RouteDecision::ReleaseGateway`:走 api 上游,但把上游路径重写成 `/api/game-distribution/releases/<gameId><asset 路径>`(与 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/<token>/…`,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 行,确认本地响应状态也进入正式切换证据链。
|
||||
|
||||
@@ -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/<gameId>/`(未购买访问 404),可玩入口是创建播放会话后拿到的 `/api/game-distribution/play-sessions/<token>/`,它直接作为 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
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -1060,6 +1060,74 @@ async function runSmokeCases(
|
||||
'发行入口形状不符时仍是真实 404',
|
||||
);
|
||||
|
||||
// 平台付费游戏播放会话入口:/api/game-distribution/play-sessions/<token>/… 原样转发到 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',
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -862,6 +862,12 @@ enum RouteDecision {
|
||||
ReleaseGateway {
|
||||
upstream_path: String,
|
||||
},
|
||||
/// 平台付费游戏播放会话入口:`/api/game-distribution/play-sessions/<token>/…`。
|
||||
/// 与 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<ProxyTarget> {
|
||||
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/<gameId><asset 路径>,原来的 query 不再拼接;
|
||||
// 同时清空 Cookie,发行内容不读账号凭证。
|
||||
// /api/game-distribution/releases/<gameId><asset 路径>,原来的 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<String> {
|
||||
))
|
||||
}
|
||||
|
||||
/// 平台付费游戏播放会话前缀:`/api/game-distribution/play-sessions/<token>/…`。
|
||||
///
|
||||
/// 与 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<ProtectionClass> {
|
||||
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 {
|
||||
|
||||
@@ -697,6 +697,20 @@ export default defineConfig(({ mode }) => {
|
||||
changeOrigin: true,
|
||||
secure: false,
|
||||
},
|
||||
// 平台付费游戏播放会话入口:`/api/game-distribution/play-sessions/<token>/…` 是 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,
|
||||
|
||||
Reference in New Issue
Block a user