实现 Pingora 发行网关路由并关闭两处路由漂移待办
Project CI / AI game creator shell Rust crates (push) Failing after 1m17s
Project CI / AI game creator shell Rust smoke (push) Successful in 2m1s
Project CI / AI game creator shell Rust lane 2/2 (push) Has been cancelled
Project CI / Backend tests (push) Has been cancelled
Project CI / Frontend tests (push) Has been cancelled
Project CI / Repository checks (push) Has been cancelled
Project CI / AI game creator shell web tests (push) Has been cancelled
Project CI / AI game creator shell Rust lane 1/2 (push) Has been cancelled
Project CI / Native shell tests (push) Has been cancelled

- 新增 RouteDecision::ReleaseGateway:/games/game_<32 位小写十六进制 id>/… 重写上游路径到 /api/game-distribution/releases/<gameId><asset>、清空 Cookie、不进 SPA fallback、不套接流保护分组、不受维护闸拦截,与 nginx 同名 location 同口径(只认小写 32 位十六进制)
- 路由矩阵新增 games_release_gateway 用例(含 upstreamPath 期望值)与对应断言;parity 门禁支持 release_gateway 并把 games_spa_fallback、games_release_gateway 列入必查清单
- check:pingora-gateway-smoke 新增三条运行时断言:重写到发行网关且上游拿不到 Cookie、/games/detail 仍走 SPA、/games/game/index.html 仍是真实 404
- 文档:Pingora 试点文档补「平台同源发行入口」语义;pitfalls 记录 SPA allowlist 三处真相源与「发行入口不是 SPA」;按 docs/project-memory/README.md 删除两份已关闭待办,关闭记录写入开放事项索引第五节
- 验证:网关 39 passed、check:pingora-route-parity OK(23 路由)、check:nginx-spa-routes OK(12 路由 / 3 模板)、check:pingora-gateway-smoke 通过、cargo fmt --all --check、check:production-ops、encoding / doc-index / diff 检查全绿;变异验证:矩阵 upstreamPath 写错或拿掉 /games/detail 都会立刻变红
This commit is contained in:
kdletters
2026-09-29 07:24:52 +08:00
parent 16ff10111f
commit 5fee115f10
9 changed files with 202 additions and 84 deletions
@@ -317,6 +317,27 @@
},
"docs": ["主站 SPA allowlist", "游戏目录 / 详情 / 游玩 / 我的 / 发布深链"]
},
{
"id": "games_release_gateway",
"samplePath": "/games/game_0123456789abcdef0123456789abcdef/index.html",
"expect": {
"kind": "release_gateway",
"upstreamPath": "/api/game-distribution/releases/game_0123456789abcdef0123456789abcdef/index.html"
},
"nginx": {
"production": [
"location ~ \"^/games/(?<game_id>game_[0-9a-f]{32})(?<game_path>/.*)?$\"",
"proxy_set_header Cookie \"\";",
"proxy_pass http://genarrative_api/api/game-distribution/releases/$game_id$game_path;"
],
"development": [
"location ~ \"^/games/(?<game_id>game_[0-9a-f]{32})(?<game_path>/.*)?$\"",
"proxy_set_header Cookie \"\";",
"proxy_pass http://genarrative_api/api/game-distribution/releases/$game_id$game_path;"
]
},
"docs": ["/games/game_<32 位十六进制 id>/…", "平台同源发行入口"]
},
{
"id": "web_spa_case_trailing_slash",
"samplePath": "/PROJECT/",
@@ -17,6 +17,13 @@
- **不要踩的坑**:`ss -t` 在没有状态过滤时不显示 LISTEN,连接被瞬拒时也抓不到 TCP 连接,不能用它判断 agent 是否在线;要看 `journalctl -u jenkins-agent@<name>`、`ss -tlnp` 的端口和 Jenkins 工作区归属。停 JNLP 单元前先确认控制器 `slaveAgentPort=-1` 且 SSH launcher 仍在跑,避免把唯一通道停掉。
- **关联**:`/etc/systemd/system/jenkins-agent@.service`、`/etc/jenkins-agent/*.env`、`scripts/deploy/install-jenkins-inbound-agent.sh`。
## 2026-09-29 主站 SPA allowlist 有三处真相源,只改一处就会让深链 404(含 Pingora)
- **事实**:主站 SPA 路由要三处同批更新才自洽——① 前端路由源 `src/routing/activeAppPageRoutes.ts` 的 `STAGE_ROUTE_ENTRIES` 与 `src/routing/activeAppRoutes.tsx`;② Nginx 三份模板(`deploy/nginx/genarrative.conf`、`deploy/nginx/genarrative-dev-http.conf`、`deploy/container/nginx.conf`)里 `# BEGIN GENARRATIVE MAIN SPA ROUTES` 的精确 allowlist;③ Pingora 网关 `server-rs/crates/pingora-gateway/src/main.rs` 的 `MAIN_SPA_PATHS`(精确匹配、大小写不敏感、允许一个尾部斜杠)。
- **代价**:2026-08-26 加 `/components`、`/design-system` 时只加了前端路由,两个门禁红了一个月(生产深链会 404 而不是 `index.html`);2026-09-28(`87e52860a`「游戏发行入口改为平台同源路径」)补了 nginx 侧的 5 条 `/games*`,却漏了 Pingora 侧,`check:pingora-route-parity` 继续红到 2026-09-29 才补齐(`MAIN_SPA_PATHS` 5→12 条)。
- **判据/入口**:`npm run check:nginx-spa-routes`(12 条路由 × 3 份模板;断言未知路径只读真实静态文件并 404、大小写与一个尾部斜杠容忍)与 `npm run check:pingora-route-parity`(同一套路由 + `MAIN_SPA_PATHS` 逐条相等)。后者还读 `deploy/pingora/nginx-route-parity.matrix.json`:新增路由要同时补矩阵用例,矩阵里的 `docs` 片段会去 `docs/technical/【开发运维】Pingora独立网关试点-2026-06-11.md` 里找,文档没写同样判红。
- **别踩**:`/games/game_<32 位小写十六进制 id>/…` **不是** SPA 深链,而是发行网关路由(Nginx 代理到 `/api/game-distribution/releases/<gameId><asset>` 并 `proxy_set_header Cookie ""`),Pingora 侧对应 `RouteDecision::ReleaseGateway`(重写上游路径、清空 Cookie、不进 SPA fallback、不套 `limit_conn`/`limit_req`、不受维护闸拦截)。把它写进 `MAIN_SPA_PATHS`,或让 SPA 正则吞掉它,都会破坏在线游玩入口。`check:pingora-gateway-smoke` 已覆盖「重写到发行网关 + 清空 Cookie + 形状不符仍 404」;本机跑它要先设 `OPENSSL_CONF`(见本文件另一条),且改过网关源码后**不能**加 `--skip-build`(会拿旧二进制得到假 404)。
## AGC 版本探测必须显式提供隔离用户目录
清空子进程环境后缺少 `USERPROFILE/HOME` 会让 npm 依赖 Windows 后备用户查询,部分机器报 `uv_os_homedir` / `ENOMEM`。正常机器探测成功不能证明环境完整,需同时验证子进程环境。版本探测使用临时用户目录和空 npm 配置,详见 [AGC 实施计划](../../technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md) 的“Web 环境版本探测的用户目录隔离”。
@@ -1,28 +0,0 @@
# 【待办】Pingora 网关缺"发行网关"路由(Nginx 已有)
更新时间:`2026-09-29`
状态:开放。这是修「主站 SPA allowlist 漂移」时**顺手查出来的另一处漂移**:三份 Nginx 模板都把 `/games/game_<32 位十六进制 id>/…` 映射到发行网关,Pingora 网关没有对应分支。两个路由 parity 门禁现在都是绿的(`check:nginx-spa-routes` OK 12 路由、`check:pingora-route-parity` OK 22 路由),所以这条**不是**它们在报的问题,而是路由对照矩阵本身没覆盖的空白。
## 现象(2026-09-29 只读核对)
- Nginx:`deploy/nginx/genarrative.conf:205`、`deploy/nginx/genarrative-dev-http.conf:193`、`deploy/container/nginx.conf:150` 都有
`location ~ "^/games/(?<game_id>game_[0-9a-f]{32})(?<game_path>/.*)?$"`,
内部 `proxy_set_header Cookie "";` + `proxy_pass http://genarrative_api/api/game-distribution/releases/$game_id$game_path;`。
- Pingora:`server-rs/crates/pingora-gateway/src/main.rs` 的 `classify_path` 没有任何 `/games/...` 分支(`MAIN_SPA_PATHS` 里也**不应该**有它),于是落到末尾的 `Static { root: Web, mode: Exact }` —— 真机上就是 404。
- 矩阵:`deploy/pingora/nginx-route-parity.matrix.json` 没有这条用例,所以 `cargo test -p pingora-gateway matches_nginx_route_parity_matrix` 覆盖不到。
## 影响
Pingora 目前只监听 `127.0.0.1:18081`(shadow / direct 试点),公网入口仍是 Nginx,**当前没有用户可见故障**。但试点文档写明「切流前必须与 Nginx 逐条对齐」;一旦把公网入口切到 Pingora,**所有已公开游戏的在线游玩入口 `/games/game_<id>/` 会 404**,而目录与详情仍然 200 —— 必须从详情页点「立即玩」才会暴露,属于很难第一时间发现的缺口。
## 修法建议
1. 网关加一个独立的"发行网关代理"决策(不要塞进 SPA allowlist):匹配 `^/games/game_[0-9a-f]{32}(?:/.*)?$`,代理到 api 上游的 `/api/game-distribution/releases/<game_id><game_path>`,转发时**清空 Cookie**(与 Nginx 同口径:发行内容不读账号凭证),其余响应头(CSP / nosniff / CORP)仍由 api-server 出。
2. 矩阵补 `games_release_gateway` 用例并把三份模板的同名片段写进 `nginx` 字段;因为这是「路径重写 + 清 Cookie」,需要给矩阵加一种表达(新增 `expect.kind`,或给 `proxy` 增加可选的重写/头字段),让 `check:pingora-route-parity` 能逐条比对。
3. 验证:`cargo test -p pingora-gateway matches_nginx_route_parity_matrix` + `check:pingora-gateway-smoke`(本机需先设 `OPENSSL_CONF`,见 pitfalls);再加一条反例断言 `/games/not-a-game-id/` 仍是真实 404。
## 关闭条件
- 矩阵里有该路由的用例,`npm run check:pingora-route-parity` 与 `npm run check:nginx-spa-routes` 都绿,`cargo test -p pingora-gateway matches_nginx_route_parity_matrix` 逐条断言通过。
- `check:pingora-gateway-smoke` 在真实网关进程上证明 `/games/game_<32hex>/…` 被代理到发行网关、且上游拿不到 Cookie;`/games/not-a-game-id/` 仍返回 404。
@@ -1,54 +0,0 @@
# 【待办】主站 SPA allowlist 与前端路由源不一致
更新时间:`2026-09-24`
状态:**已关闭(2026-09-29)**。两个门禁都已转绿,关闭证据见文末「关闭记录(2026-09-29)」。
## 现象(2026-09-24 实跑)
- `npm run check:nginx-spa-routes` → **exit 1**:`deploy/nginx/genarrative.conf`、`deploy/nginx/genarrative-dev-http.conf`、`deploy/container/nginx.conf` 三份模板的 SPA allowlist 原本都缺 `/components`、`/design-system`、`/games`、`/games/detail`、`/games/play`、`/games/mine`、`/games/publish`。
- `npm run check:pingora-route-parity` → 同样失败:它 `import { expectedMainSpaRoutes } from './check-nginx-spa-routes.mjs'`,而后者在模块级就执行整套校验。
- 前端事实源:`src/routing/activeAppPageRoutes.ts` 的 `STAGE_ROUTE_ENTRIES`(含 5 条 games)与 `src/routing/activeAppRoutes.tsx` 的 `/components`、`/design-system`。
## 影响
生产模板里未列入 allowlist 的路径落到 `location /` 的 `try_files $uri $uri/ =404`,因此这些 SPA 深链在公网返回 404,而不是渲染 index.html:
- `/components`(兼容别名 `/design-system`):共享组件展示页,技术文档明确它「不经过账号 Gate」,用于网站与客户端接入前的视觉回归和人工验收。
- `/games`、`/games/detail`、`/games/play`、`/games/mine`、`/games/publish`:游戏分发系列的目录、详情、游玩、我的与发布页。
## 历史(为什么红了这么久)
- `c5200c7d4`(2026-08-26,PR #202)新增 `/components`、`/design-system` 路由与展示页,nginx 模板没有同步,门禁从那时起就是红的。
- `39aed2e48`(2026-09-20)曾把这两条连同 games 路由一起补进三份模板的同一行正则。
- `429f991bd`(2026-09-20,回滚误入 master 的游戏分发 WIP)把整行改回 `creation|editor/canvas|profile|project`,两条展示页路由被连带移除。
- decision-log 2026-09-23「游戏发行包上限提升到 200 MiB」条目已把这处失败记为「改动前同样失败,与本次口径无关」。
- `.gitea/workflows/project-ci.yml` 没有运行 `check:nginx-spa-routes`,根 `npm run lint` 也不含它,所以这盏红灯长期只在人工执行时可见。
## 本轮处理(2026-09-24)
- 三份模板的 SPA 正则加回 `components|design-system`(一行 × 3 文件),展示页深链在模板层面恢复:`location ~* "^/(?:components|creation|design-system|editor/canvas|profile|project)/?$"`。
- 复跑 `npm run check:nginx-spa-routes`:失败清单从 7 条降到 5 条,只剩 `/games`、`/games/detail`、`/games/mine`、`/games/play`、`/games/publish`。
- 相关门禁未受影响:`npm run check:release-origin-config`、`npm run check:production-ops`、`npm run check:maintenance-page`、`npm run check:preview-deployer` 全部通过。
## 剩余修法(游戏部分)
把 5 条 games 路由加回三条正则,并同步 `docs/technical/【开发运维】Pingora独立网关试点-2026-06-11.md` 的 SPA allowlist 表(必要时还有 `deploy/pingora/nginx-route-parity.matrix.json`),门禁才会真正转绿。该部分属游戏分发系列,按用户本轮要求不推进,随阶段 A 验收结论一并处理。
## 关闭条件
1. `npm run check:nginx-spa-routes` 与 `npm run check:pingora-route-parity` 通过,三份模板的 allowlist 集合与前端路由源逐条一致。
2. 大小写与尾部斜杠容忍、`/creation/not-exist` 等未知路径仍返回 404 的判据不被放宽。
3. 游戏部分补齐前,本文件保持打开,不能把「门禁仍红」当成已收口。
## 关闭记录(2026-09-29)
三条关闭条件逐条复验通过:
1. **两个门禁都绿**:`npm run check:nginx-spa-routes` → `OK (12 SPA routes, 3 Nginx templates)`;`npm run check:pingora-route-parity` → `OK (22 routes)`。nginx 侧那 5 条 `/games*` 是随 `87e52860a`(游戏发行入口改为平台同源路径)补进三份模板的;这轮把**漏掉的 Pingora 侧**补齐——`server-rs/crates/pingora-gateway/src/main.rs` 的 `MAIN_SPA_PATHS` 从 5 条补到 12 条(`/components`、`/design-system`、`/games`、`/games/detail`、`/games/mine`、`/games/play`、`/games/publish` 与原有 5 条),并同步 `docs/technical/【开发运维】Pingora独立网关试点-2026-06-11.md` 的 SPA allowlist 表。
2. **判据没有被放宽**:`check-nginx-spa-routes.mjs` 仍在断言大小写不敏感、允许一个尾部斜杠、`/creation/not-exist` 等未知路径必须只读真实静态文件并返回 404;`is_main_spa_path()` 仍是精确(大小写不敏感 + 去一个尾部斜杠)匹配,不做前缀匹配,所以 `/games/game_<32hex>/` 不会被误当成 SPA。
3. **运行时口径**:`dev.genarrative.world` 与 `genarrative.world` 的 `/games`、`/games/detail?id=1`、`/games/play?id=1`、`/games/mine`、`/games/publish` 实测都返回 200 + SPA 外壳(`<title>陶泥儿 Genarrative|美术编辑器与项目工作台</title>`)。
验证证据:`deploy/pingora/nginx-route-parity.matrix.json` 新增 `games_spa_fallback`(`samplePath=/games/detail`,期望 `static/web/spa_fallback`),`cargo test -p pingora-gateway matches_nginx_route_parity_matrix` → **1 passed**;**变异验证**:临时从 `MAIN_SPA_PATHS` 拿掉 `"/games/detail"` 后该用例立刻失败(`route parity static mode mismatch: games_spa_fallback /games/detail,left: Some("exact") right: Some("spa_fallback")`),`check:pingora-route-parity` 同时报出缺路由。网关全量单测 `cargo test -p pingora-gateway` → **38 passed**;`npm run check:production-ops` 通过;`cargo fmt --all -- --check` 通过。
**顺带发现并另立待办**:三份 Nginx 模板都有 `/games/game_<32hex>/…` → 发行网关的代理(清 Cookie),Pingora 没有对应分支,切流后会 404。见 [`【待办】Pingora网关缺发行网关路由-2026-09-29.md`](【待办】Pingora网关缺发行网关路由-2026-09-29.md)。
@@ -46,11 +46,12 @@
| `【待办】引用输入区后续收口-2026-09-24.md` | CSS 类名改名一条已按现状关闭(接受不改名);剩「归一化撞名是否提示」与真机手感验收 | 产品决定 + 真机 |
| `【待办】画布验收后续修复-2026-09-18.md` | 三批修复与三项画布后续需求已落地本地;动画生成失败的真实复现、多选卡顿的现场 Profiler、以及全部真机观感仍未取证 | 真实客户端 + 现场 |
| `【目标编辑器适配】Unity与Unreal项目识别与导入-2026-09-11.md` | 明确「暂不实现」的新功能 | 排期决定 |
| `【待办】Pingora网关缺发行网关路由-2026-09-29.md`(新增) | 三份 Nginx 模板都有 `/games/game_<32hex>/…` → 发行网关(清 Cookie),Pingora 的 `classify_path` 没有对应分支,切流后会 404;两个路由 parity 门禁与矩阵都覆盖不到这一条。当前 Pingora 只在 `127.0.0.1:18081` shadow,无用户可见故障 | 归 Pingora 试点切流前的对齐清单 |
## 五、已收口口径(供复核)
- **已关闭(2026-09-29)**:`【待办】主站SPA路由白名单与前端路由源不一致-2026-09-24.md`。nginx 侧 5 条 `/games*` 随 `87e52860a` 补齐;这轮补上漏掉的 Pingora 侧(`MAIN_SPA_PATHS` 5→12 条 + 矩阵新增 `games_spa_fallback` 用例 + 文档表同步)。`check:nginx-spa-routes` OK(12 路由 / 3 模板)、`check:pingora-route-parity` OK(22 路由)、`cargo test -p pingora-gateway matches_nginx_route_parity_matrix` 1 passed(变异验证:拿掉 `/games/detail` 立刻变红)、网关全量 38 passed;dev 与生产上 `/games`、`/games/detail`、`/games/play`、`/games/mine`、`/games/publish` 实测 200 + SPA 外壳。该文件已在正文写明关闭记录,闭项从第四节移到本条。
- **已关闭(2026-09-29)两处路由漂移**,按 `docs/project-memory/README.md`「todos 只留真实开放事项」的规则,两份待办文件已删除,结论与证据保留在这里和权威文档里:
1. **主站 SPA allowlist 与前端路由源不一致**:nginx 侧 5 条 `/games*` 随 `87e52860a` 补齐;本轮补上漏掉的 Pingora 侧——`MAIN_SPA_PATHS` 5→12 条、矩阵新增 `games_spa_fallback`(`/games/detail`)用例、Pingora 试点文档表同步。`check:nginx-spa-routes` OK(12 路由 / 3 模板)、`check:pingora-route-parity` OK(23 路由)、`cargo test -p pingora-gateway matches_nginx_route_parity_matrix` 1 passed(变异验证:拿掉 `/games/detail` 或改错矩阵期望立刻变红)、网关全量 39 passed;dev 与生产上 `/games`、`/games/detail`、`/games/play`、`/games/mine`、`/games/publish` 实测 200 + SPA 外壳。
2. **Pingora 缺发行网关路由**:三份 nginx 模板早就把 `/games/game_<32 位十六进制 id>/…` 代理到发行网关(清 Cookie),Pingora 没有对应分支,切流后「立即玩」会 404。本轮实现 `RouteDecision::ReleaseGateway`(重写上游路径到 `/api/game-distribution/releases/<gameId><asset>`、清空 Cookie、不进 SPA fallback、不套接流保护、不受维护闸拦截),矩阵补 `games_release_gateway`(含 `upstreamPath` 期望)用例;`check:pingora-gateway-smoke` 新增三条断言(重写+清 Cookie、`/games/detail` 仍走 SPA、`/games/game/index.html` 仍真实 404)并通过。口径与三处真相源已写入 `docs/technical/【开发运维】Pingora独立网关试点-2026-06-11.md` 与 `pitfalls.md`。
- 74 份计划里 63 份为 `implemented-awaiting-runtime-acceptance`(含用括号写清边界的那批,含 `- Status:` 与表格两种写法);其余 11 份全部落在本文件一、二、三节里——这三节另外还列了 10 份状态已是 `implemented-awaiting-runtime-acceptance`、但仍有条目未勾选或未验收的计划,所以三节合计 21 份,不要与本条那 11 份混算。
- 未勾选复选框从 85 条降到 43 条(2026-09-24 复算口径:`- [ ]` 行首复选框,74 份计划合计):本轮把已有本地证据的条目逐条勾选,并在每份计划里写了本轮的勾选依据或复验记录——多数是「逐条勾选依据(2026-09-24)」章节,另有「本轮复验(2026-09-24)」「本轮复核(2026-09-24)」写法,`【里程碑】DirectProject聊天真相源收敛-2026-09-16.md` 则把自动化证据直接写在条目里(`【自动化:…】`);仍勾不动的条目都能在依据里读到具体原因。
@@ -539,6 +539,8 @@ dev 根盘空间在安装后曾接近满盘;2026-06-17 进入 canary 前已清
SPA allowlist 里属于游戏分发入口的深链(游戏目录 / 详情 / 游玩 / 我的 / 发布深链:`/games`、`/games/detail`、`/games/play`、`/games/mine`、`/games/publish`)与 Nginx 三份模板同口径;Pingora 侧由路由对照矩阵的 `games_spa_fallback` 用例与 `cargo test -p pingora-gateway matches_nginx_route_parity_matrix` 逐条断言。发行网关路径 `/games/game_<32 位十六进制 id>/…` 不走 SPA,见下一节的对照说明。
**平台同源发行入口**(`/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-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 行,确认本地响应状态也进入正式切换证据链。
+49
View File
@@ -1011,6 +1011,55 @@ async function runSmokeCases(
`API 上游 X-Real-IP 未使用 TCP 对端 IP:${apiPayload.realIp}`,
);
// 平台同源发行入口:/games/game_<32 位小写十六进制 id>/… 重写到发行网关并清空 Cookie,
// 形状不符的路径不许被吞进发行网关(SPA 深链仍是 SPA,`/games/game/...` 仍是真实 404)。
const releaseGameId = 'game_0123456789abcdef0123456789abcdef';
const releaseUpstreamPath = `/api/game-distribution/releases/${releaseGameId}/index.html`;
const releaseBeforeCount = api.state.requests.length;
const releaseResponse = await expectHttp(
baseUrl,
`/games/${releaseGameId}/index.html`,
200,
'"upstream":"api"',
'平台同源发行入口转发到发行网关',
{
headers: {
'X-Request-Id': 'smoke-release-request-id',
Host: 'example.test',
Cookie: 'session=smoke-must-not-reach-release-gateway',
},
},
);
const releasePayload = JSON.parse(releaseResponse.body);
ensure(
releasePayload.url === releaseUpstreamPath,
`发行入口上游路径没有重写:${releasePayload.url}`,
);
const releaseUpstreamRequests = api.state.requests.slice(releaseBeforeCount);
ensure(
releaseUpstreamRequests.length === 1 &&
releaseUpstreamRequests[0].url === releaseUpstreamPath,
`发行入口上游请求不符:${describeRequests(releaseUpstreamRequests)}`,
);
ensure(
releaseUpstreamRequests[0]?.headers.cookie === undefined,
`发行入口没有清空 Cookie:${describeRequests(releaseUpstreamRequests)}`,
);
await expectHttp(
baseUrl,
'/games/detail',
200,
'site-shell',
'发行入口形状不符时 SPA 深链照常回退',
);
await expectHttp(
baseUrl,
'/games/game/index.html',
404,
'',
'发行入口形状不符时仍是真实 404',
);
await expectHttp(
baseUrl,
'/api/upload',
+12
View File
@@ -14,6 +14,7 @@ const PINGORA_GATEWAY_SOURCE = 'server-rs/crates/pingora-gateway/src/main.rs';
const VALID_KINDS = new Set([
'proxy',
'static',
'release_gateway',
'redirect_permanent',
'shadow_probe',
'not_found',
@@ -39,6 +40,8 @@ const REQUIRED_ROUTE_IDS = [
'generated_assets_forbidden',
'web_spa_fallback',
'profile_spa_fallback',
'games_spa_fallback',
'games_release_gateway',
'web_spa_case_trailing_slash',
'web_unknown_path_exact',
'creation_unknown_path_exact',
@@ -108,6 +111,15 @@ function validateExpectation(route) {
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}`);
@@ -849,6 +849,12 @@ enum RouteDecision {
target: ProxyTarget,
body_limit: Option<u64>,
},
/// 平台同源发行入口:`/games/game_<32 位小写十六进制 id>(/<asset>)?`。
/// 与 Nginx 的同名 location 同口径:走 api 上游,但在代理阶段把路径重写成
/// `/api/game-distribution/releases/<gameId><asset 路径>` 并清空 Cookie。
ReleaseGateway {
upstream_path: String,
},
Local(LocalResponse),
}
@@ -879,6 +885,8 @@ impl RouteDecision {
fn proxy_target(&self) -> Option<ProxyTarget> {
match self {
RouteDecision::Proxy { target, .. } => Some(*target),
// 发行入口同样代理到 api 上游,只有路径与请求头在代理阶段被重写。
RouteDecision::ReleaseGateway { .. } => Some(ProxyTarget::Api),
RouteDecision::Local(_) => None,
}
}
@@ -886,6 +894,8 @@ impl RouteDecision {
fn body_limit(&self) -> Option<u64> {
match self {
RouteDecision::Proxy { body_limit, .. } => *body_limit,
// 发行入口只服务静态资源读取,Nginx 侧也没有请求体上限指令。
RouteDecision::ReleaseGateway { .. } => None,
RouteDecision::Local(_) => None,
}
}
@@ -1182,6 +1192,7 @@ impl ProxyHttp for GenarrativeGateway {
match &ctx.route {
RouteDecision::Proxy { .. } => Ok(false),
RouteDecision::ReleaseGateway { .. } => Ok(false),
RouteDecision::Local(LocalResponse::RedirectPermanent { location }) => {
respond_redirect(session, location).await?;
Ok(true)
@@ -1296,6 +1307,14 @@ impl ProxyHttp for GenarrativeGateway {
upstream_request.insert_header("X-Forwarded-For", forwarded_for.as_str())?;
}
// 中文注释:平台同源发行入口按 Nginx 的 proxy_pass 口径重写路径——换成
// /api/game-distribution/releases/<gameId><asset 路径>,原来的 query 不再拼接;
// 同时清空 Cookie,发行内容不读账号凭证。
if let RouteDecision::ReleaseGateway { upstream_path } = &ctx.route {
upstream_request.set_raw_path(upstream_path.as_bytes())?;
upstream_request.remove_header("cookie");
}
// 中文注释:SpacetimeDB 订阅走 WebSocket Upgrade;普通 API 连接头保持干净,贴近当前 Nginx 模板。
if ctx.route.proxy_target() == Some(ProxyTarget::Spacetime) && is_upgrade_request(session) {
upstream_request.insert_header("Connection", "Upgrade")?;
@@ -1623,6 +1642,31 @@ fn is_generic_api_proxy_path(path: &str) -> bool {
path == "/api" || path.starts_with("/api/")
}
/// 平台同源发行入口:`/games/game_<32 位小写十六进制 id>` 可选跟一段 `/…` 资源路径。
///
/// 与 Nginx 的 `location ~ "^/games/(?<game_id>game_[0-9a-f]{32})(?<game_path>/.*)?$"` 同口径:
/// 只认小写十六进制、固定 32 位,不做大小写放宽;命中后上游路径是
/// `/api/game-distribution/releases/<gameId><asset 路径>`,其余返回 `None` 交给后面的 SPA / 静态分支。
fn release_gateway_upstream_path(path: &str) -> Option<String> {
let rest = path.strip_prefix("/games/")?;
let (game_id, asset_path) = match rest.find('/') {
Some(index) => (&rest[..index], &rest[index..]),
None => (rest, ""),
};
let hex = game_id.strip_prefix("game_")?;
if hex.len() != 32
|| !hex
.bytes()
.all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
{
return None;
}
Some(format!(
"/api/game-distribution/releases/{game_id}{asset_path}"
))
}
fn protection_class_for_route(route: &RouteDecision, path: &str) -> Option<ProtectionClass> {
match route {
RouteDecision::Proxy {
@@ -1641,6 +1685,8 @@ fn protection_class_for_route(route: &RouteDecision, path: &str) -> Option<Prote
target: ProxyTarget::Gitea,
..
} => None,
// 发行入口在 Nginx 侧没有任何 limit_conn / limit_req 指令,保持同口径。
RouteDecision::ReleaseGateway { .. } => None,
RouteDecision::Local(_) => None,
}
}
@@ -1868,6 +1914,10 @@ fn classify_path(path: &str) -> RouteDecision {
return RouteDecision::Local(LocalResponse::NotFound);
}
if let Some(upstream_path) = release_gateway_upstream_path(path) {
return RouteDecision::ReleaseGateway { upstream_path };
}
if path.starts_with("/admin/assets/") || path.starts_with("/assets/") {
return RouteDecision::Local(LocalResponse::Static {
root: StaticRoot::Web,
@@ -3065,6 +3115,7 @@ mod tests {
mode: Option<String>,
location: Option<String>,
protection_class: Option<String>,
upstream_path: Option<String>,
}
#[derive(Deserialize)]
@@ -3108,6 +3159,15 @@ mod tests {
case.sample_path
);
}
("release_gateway", RouteDecision::ReleaseGateway { upstream_path }) => {
assert_eq!(
Some(upstream_path.as_str()),
case.expect.upstream_path.as_deref(),
"route parity release gateway upstream path mismatch: {} {}",
case.id,
case.sample_path
);
}
(
"redirect_permanent",
RouteDecision::Local(LocalResponse::RedirectPermanent { location }),
@@ -3872,6 +3932,54 @@ mod tests {
}
}
#[test]
fn classifies_platform_same_origin_release_paths() {
let game_id = "game_0123456789abcdef0123456789abcdef";
assert_eq!(
release_gateway_upstream_path(&format!("/games/{game_id}/index.html")),
Some(format!(
"/api/game-distribution/releases/{game_id}/index.html"
))
);
// 不带资源路径(尾斜杠缺失)时与 Nginx 一样仍然命中,只是上游没有 asset 段。
assert_eq!(
release_gateway_upstream_path(&format!("/games/{game_id}")),
Some(format!("/api/game-distribution/releases/{game_id}"))
);
assert!(matches!(
classify_path(&format!("/games/{game_id}/assets/app.js")),
RouteDecision::ReleaseGateway { .. }
));
// 大小写、位数、字符集、以及「像但不是」的路径都不许被吞进发行网关。
for path in [
"/games/detail",
"/games/mine",
"/games/game_0123456789ABCDEF0123456789ABCDEF/index.html",
"/games/game_0123456789abcdef0123456789abcde/index.html",
"/games/game_0123456789abcdef0123456789abcdef0/index.html",
"/games/game_zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz/index.html",
"/games/game/index.html",
] {
assert_eq!(release_gateway_upstream_path(path), None, "path: {path}");
}
// 这些路径仍然按 SPA / 精确静态分类,不受发行入口影响。
assert_eq!(
classify_path("/games/detail"),
RouteDecision::Local(LocalResponse::Static {
root: StaticRoot::Web,
mode: StaticMode::SpaFallback,
})
);
assert_eq!(
classify_path("/games/game/index.html"),
RouteDecision::Local(LocalResponse::Static {
root: StaticRoot::Web,
mode: StaticMode::Exact,
})
);
}
#[test]
fn protection_rejects_when_concurrency_is_exhausted() {
let config = ProtectionConfig {