实现 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
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:
@@ -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 行,确认本地响应状态也进入正式切换证据链。
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user