diff --git a/deploy/nginx/genarrative-dev-http.conf b/deploy/nginx/genarrative-dev-http.conf index f5baa3959..336a486e8 100644 --- a/deploy/nginx/genarrative-dev-http.conf +++ b/deploy/nginx/genarrative-dev-http.conf @@ -18,13 +18,14 @@ limit_req_zone $binary_remote_addr zone=genarrative_gallery_rps:10m rate=5000r/s limit_req_zone $binary_remote_addr zone=genarrative_api_rps:10m rate=300r/s; limit_req_zone $binary_remote_addr zone=genarrative_admin_rps:10m rate=30r/s; -# 维护期间只允许真实 TCP 内网来源继续访问后台;不信任请求头伪造的客户端地址。 -geo $genarrative_internal_client { +# 维护期间允许真实 TCP 内网来源继续访问整站;不信任请求头伪造的客户端地址。 +geo $remote_addr $genarrative_internal_client { default 0; 127.0.0.0/8 1; 10.0.0.0/8 1; 172.16.0.0/12 1; 192.168.0.0/16 1; + 169.254.0.0/16 1; ::1 1; fc00::/7 1; fe80::/10 1; @@ -67,7 +68,7 @@ server { limit_conn genarrative_api_conn 64; limit_req zone=genarrative_admin_rps burst=16 nodelay; - if ($genarrative_admin_maintenance) { + if ($genarrative_maintenance) { return 503 '{"ok":false,"error":{"code":"MAINTENANCE","message":"服务维护中"}}'; } @@ -84,7 +85,7 @@ server { location = /admin { error_page 503 /maintenance.html; - if ($genarrative_admin_maintenance) { + if ($genarrative_maintenance) { return 503; } @@ -94,7 +95,7 @@ server { location ^~ /admin/assets/ { error_page 503 /maintenance.html; - if ($genarrative_admin_maintenance) { + if ($genarrative_maintenance) { return 503; } @@ -104,7 +105,7 @@ server { location ^~ /admin/ { error_page 503 /maintenance.html; - if ($genarrative_admin_maintenance) { + if ($genarrative_maintenance) { return 503; } diff --git a/deploy/nginx/genarrative.conf b/deploy/nginx/genarrative.conf index 9b99bcc1f..c3b202613 100644 --- a/deploy/nginx/genarrative.conf +++ b/deploy/nginx/genarrative.conf @@ -16,13 +16,14 @@ limit_req_zone $binary_remote_addr zone=genarrative_gallery_rps:10m rate=5000r/s limit_req_zone $binary_remote_addr zone=genarrative_api_rps:10m rate=300r/s; limit_req_zone $binary_remote_addr zone=genarrative_admin_rps:10m rate=30r/s; -# 维护期间只允许真实 TCP 内网来源继续访问后台;不信任请求头伪造的客户端地址。 -geo $genarrative_internal_client { +# 维护期间允许真实 TCP 内网来源继续访问整站;不信任请求头伪造的客户端地址。 +geo $remote_addr $genarrative_internal_client { default 0; 127.0.0.0/8 1; 10.0.0.0/8 1; 172.16.0.0/12 1; 192.168.0.0/16 1; + 169.254.0.0/16 1; ::1 1; fc00::/7 1; fe80::/10 1; @@ -87,7 +88,7 @@ server { limit_conn genarrative_api_conn 64; limit_req zone=genarrative_admin_rps burst=16 nodelay; - if ($genarrative_admin_maintenance) { + if ($genarrative_maintenance) { return 503 '{"ok":false,"error":{"code":"MAINTENANCE","message":"服务维护中"}}'; } @@ -104,7 +105,7 @@ server { location = /admin { error_page 503 /maintenance.html; - if ($genarrative_admin_maintenance) { + if ($genarrative_maintenance) { return 503; } @@ -114,7 +115,7 @@ server { location ^~ /admin/assets/ { error_page 503 /maintenance.html; - if ($genarrative_admin_maintenance) { + if ($genarrative_maintenance) { return 503; } @@ -124,7 +125,7 @@ server { location ^~ /admin/ { error_page 503 /maintenance.html; - if ($genarrative_admin_maintenance) { + if ($genarrative_maintenance) { return 503; } diff --git a/deploy/nginx/snippets/genarrative-maintenance.conf b/deploy/nginx/snippets/genarrative-maintenance.conf index 8d6f876e9..e101c488f 100644 --- a/deploy/nginx/snippets/genarrative-maintenance.conf +++ b/deploy/nginx/snippets/genarrative-maintenance.conf @@ -1,13 +1,12 @@ # 维护模式由发布脚本或人工运维通过固定文件控制。 -# 文件存在时,普通页面展示维护页,API 返回 503;真实内网来源仍可访问后台。 +# 文件存在时,公网页面展示维护页、API 返回 503;真实 TCP 内网来源仍可访问整站。 set $genarrative_maintenance 0; if (-f /var/lib/genarrative/maintenance/enabled) { set $genarrative_maintenance 1; } -set $genarrative_admin_maintenance $genarrative_maintenance; if ($genarrative_internal_client) { - set $genarrative_admin_maintenance 0; + set $genarrative_maintenance 0; } location = /maintenance.html { diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 5e2820f50..6be33af5a 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -3981,10 +3981,11 @@ - 影响范围:`jenkins/Jenkinsfile.production-full-build-and-deploy`、`jenkins/Jenkinsfile.production-stdb-module-build`、`jenkins/Jenkinsfile.production-stdb-module-publish`、生产运维门禁与 live Job 参数 schema。 - 验证方式:`node --check scripts/check-production-ops-guardrails.mjs`、`npm run check:production-ops`、`npm run check:encoding`、`git diff --check`;推送后用首阶段 fail-closed 运行刷新三个 live Job 参数,再只读核对 credential 默认值、`normal` 默认值与 timer。 -## 2026-07-11 维护模式仅向内网放行后台 +## 2026-07-12 维护模式只拦截公网流量 -- 背景:维护 marker 会同时拦截后台页面与 `/admin/api/**`,无法在人工维护期间通过后台改数据;但不能因此对公网取消维护闸。 -- 决策:Nginx 与 Pingora 仅允许 IPv4 loopback / RFC1918 / link-local 和 IPv6 loopback / ULA / link-local 来源在维护期间访问 `/admin` 与 `/admin/**`。公网后台及所有普通业务路由继续维护响应,后台应用鉴权不变。 -- 信任边界:Nginx 使用 TCP `$remote_addr`;Pingora 使用 TCP peer,只有同机 loopback Nginx 才可通过其强制覆盖的 `X-Real-IP` 传递原始地址,禁止使用 XFF 做授权。 -- 限制:网关放行不等于后端存活;`pause-after-stdb` 停止 api-server 时后台 API 仍不可用。 +- 背景:此前维护 marker 只对内网放行后台,内网排障或人工维护仍无法访问主站、普通 API 和 SpacetimeDB 路由;当前维护目标是隔离公网访问,不应阻断可信内网流量。 +- 决策:Nginx 与 Pingora 允许 IPv4 loopback / RFC1918 / link-local 和 IPv6 loopback / ULA / link-local 来源在维护期间访问整站,包括主站页面与静态资源、普通 API、后台页面与 `/admin/api/**`、SpacetimeDB 路由。公网应用主站、普通 API、后台和 SpacetimeDB 路由继续维护响应,应用层鉴权不变。 +- 信任边界:Nginx 使用 TCP `$remote_addr`;Pingora 使用 TCP peer,只有同机 loopback Nginx 才可通过其强制覆盖的 `X-Real-IP` 传递原始地址,禁止使用 `X-Forwarded-For` 做维护放行判断。 +- 限制:网关放行不等于后端存活;`pause-after-stdb` 停止 api-server 时,内网普通 API 和后台 API 仍不可用。 - 影响范围:生产 / dev Nginx 模板、维护 snippet、Pingora maintenance gate、Nginx 静态门禁与 Pingora smoke。 +- 验证方式:`npm run check:nginx-spa-routes`、`npm run check:pingora-route-parity`、`cargo test -p pingora-gateway --manifest-path server-rs/Cargo.toml`、`npm run check:pingora-gateway-smoke`、`npm run check:production-ops`、`npm run check:encoding`、`git diff --check`。 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index dd2c8758d..7cbc7e606 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -2961,10 +2961,10 @@ - 验证:推送后让 Full / Stdb Build 用不存在的源码分支在 checkout 阶段 fail-closed,让 Stdb Publish 用空构建版本在 Prepare 阶段 fail-closed,以安全刷新参数 schema;随后只读检查三个 live `config.xml` 的参数描述和默认值,确认 Full timer 仍为 `0 4 * * *`、rollout 默认值为 `normal`,并确认刷新运行未进入 publish / deploy stage。 - 关联:`jenkins/Jenkinsfile.production-full-build-and-deploy`、`jenkins/Jenkinsfile.production-stdb-module-build`、`jenkins/Jenkinsfile.production-stdb-module-publish`、`scripts/check-production-ops-guardrails.mjs`。 -## 维护后台内网放行不能信任 X-Forwarded-For +## 维护模式内网全站放行不能信任 X-Forwarded-For -- 现象:维护期间希望从内网打开后台,如果直接按 `X-Forwarded-For: 192.168.x.x` 放行,公网请求可伪造该头绕过维护闸;只放行后台 HTML 又会让数据接口继续 503。 -- 原因:XFF 是客户端可提交的普通请求头,当前 Nginx 的 `$proxy_add_x_forwarded_for` 还会保留已有前缀;维护放行属于授权判断,必须建立在不可伪造的网络来源边界上。 -- 处理:Nginx 按 TCP `$remote_addr` 判断内网;Pingora 按 TCP peer 判断,只有 peer 为 loopback 的同机 Nginx 时才接受 Nginx 强制覆盖的 `X-Real-IP`。路径必须完整覆盖 `/admin`、后台页面、静态资源和 `/admin/api/**`,其他内网路径仍保持维护响应。 -- 验证:Pingora smoke 同时覆盖公网后台 503、内网后台三类路径 200、内网主站仍 503;Rust 单测覆盖 IPv4 / IPv6 内网、公网、非后台路径和空来源;Nginx 静态门禁反查两份模板的 geo 与四个后台维护判断。 -- 限制:如果发布门禁已经停止 api-server,网关放行后后台 API 仍会失败;需要改数据时应确保后端仍运行,不能把维护页绕过误当作服务可用性保证。 +- 现象:维护期间希望让内网继续访问整站,如果直接按 `X-Forwarded-For: 192.168.x.x` 放行,公网请求可伪造该头绕过维护闸;如果仍按路径只放行后台,又会让内网主站和普通 API 继续返回 503。 +- 原因:XFF 是客户端可提交的普通请求头,当前 Nginx 的 `$proxy_add_x_forwarded_for` 还会保留已有前缀;维护放行属于授权判断,必须建立在不可伪造的网络来源边界上,并在路由分类前按来源统一决定是否绕过维护闸。 +- 处理:Nginx 按 TCP `$remote_addr` 判断内网;Pingora 按 TCP peer 判断,只有 peer 为 loopback 的同机 Nginx 时才接受 Nginx 强制覆盖的 `X-Real-IP`。可信内网来源绕过整站维护响应,公网应用主站、普通 API、后台和 SpacetimeDB 路由仍保持维护响应;绝不能用 `X-Forwarded-For` 做放行判断。 +- 验证:Pingora smoke 同时覆盖公网主站、普通 API、后台为 503,以及内网对应路由为 200;Rust 单测覆盖 IPv4 / IPv6 内网、公网和空来源;Nginx 静态门禁反查两份模板的内网来源定义与全局维护变量清零逻辑。 +- 限制:如果发布门禁已经停止 api-server,网关放行后普通 API 和后台 API 仍会失败;需要调用后端时应确保对应服务仍运行,不能把维护页绕过误当作服务可用性保证。 diff --git a/docs/technical/【开发运维】Pingora独立网关试点-2026-06-11.md b/docs/technical/【开发运维】Pingora独立网关试点-2026-06-11.md index 13f8f6d7e..7a11eb150 100644 --- a/docs/technical/【开发运维】Pingora独立网关试点-2026-06-11.md +++ b/docs/technical/【开发运维】Pingora独立网关试点-2026-06-11.md @@ -545,7 +545,7 @@ dev 根盘空间在安装后曾接近满盘;2026-06-17 进入 canary 前已清 | 主站 SPA allowlist | 只对当前前端完整路由及兼容恢复路径 `/creation/rpg/agent` 失败回退 `/index.html`;匹配大小写不敏感并允许一个尾部斜杠,HTML 默认 `no-cache`。 | | 其它 Web 路径 | 只读取真实静态文件或目录 index,缺失时返回真实 404;`/creation/not-exist`、`/runtime/not-exist`、`/puzzle/not-exist` 不进入 SPA fallback。 | -维护模式下,API-like 路由返回 JSON `503`,Web 静态路由优先返回 `maintenance.html`,不存在时返回纯文本 `503`。唯一例外是内网来源访问 `/admin` 与 `/admin/**`:IPv4 loopback / RFC1918 / link-local 和 IPv6 loopback / ULA / link-local 可继续进入后台页面、静态资源和后台 API,应用层登录与管理员鉴权保持不变;公网后台仍返回维护响应,内网访问主站或普通 API 也仍受维护闸。Pingora 直连按 TCP peer 判定来源;仅当 peer 是 loopback 的同机 Nginx 时才接受 Nginx 强制覆盖的 `X-Real-IP`,绝不使用客户端可伪造的 `X-Forwarded-For` 做后台放行。该放行只绕过网关维护响应;若 `pause-after-stdb` 已停止 api-server,后台 API 仍不可用。 +维护模式下,公网 API-like 路由返回 JSON `503`,公网 Web 静态路由优先返回 `maintenance.html`,不存在时返回纯文本 `503`。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 行,确认本地响应状态也进入正式切换证据链。 静态路由只允许 `GET` / `HEAD` 读取;其它方法在确认命中静态候选后返回 `405` 并写入 `Allow: GET, HEAD`,缺失文件仍返回 `404`,避免直连后错误客户端把静态入口当作可写接口。 diff --git a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md index 73056ea46..2bf6d98b8 100644 --- a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md +++ b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md @@ -403,11 +403,11 @@ Pingora current release 自审脚本 `scripts/ops/pingora-current-release-audit. `Genarrative-Web-Build` 打包 `web.tar.gz` 前、`Genarrative-Web-Deploy` 解包后都会把 Web 静态目录规范为目录 `755`、文件 `644`。如果前端页面能打开但 public 图片、字体或音频返回 `403 Forbidden`,优先检查当前 `/srv/genarrative/web` 指向的 release 中对应文件权限是否被异常归档为 `600`,临时恢复可对该 release 的 `web` 目录执行目录 `755`、文件 `644` 的权限修正。 -## 维护模式后台内网访问 +## 维护模式只拦截公网流量 -Nginx 与 Pingora 在维护 marker 存在时只对内网来源放行 `/admin` 与 `/admin/**`,覆盖后台入口、后台静态资源和 `/admin/api/**`;公网后台、普通主站、普通 API 和 SpacetimeDB 路由继续返回维护响应。内网范围为 IPv4 loopback / RFC1918 / link-local 和 IPv6 loopback / ULA / link-local。Nginx 只按真实 `$remote_addr` 判定;Pingora 只按 TCP peer 判定,peer 为 loopback 的同机 Nginx 时才读取 Nginx 强制覆盖的 `X-Real-IP`,不得把 `X-Forwarded-For` 用作后台放行依据。后台本身的登录与管理员鉴权不变。 +Nginx 与 Pingora 在维护 marker 存在时对内网来源绕过整站维护闸,主站页面与静态资源、普通 API、后台页面与 `/admin/api/**`、SpacetimeDB 路由均按非维护状态继续处理;公网应用主站、普通 API、后台和 SpacetimeDB 路由继续返回维护响应。内网范围为 IPv4 loopback / RFC1918 / link-local 和 IPv6 loopback / ULA / link-local。Nginx 只按 TCP `$remote_addr` 判定;Pingora 只按 TCP peer 判定,peer 为 loopback 的同机 Nginx 时才读取 Nginx 强制覆盖的 `X-Real-IP`,绝不能把客户端可伪造的 `X-Forwarded-For` 用作维护放行依据。应用本身的登录、管理员鉴权和其它业务鉴权不变。 -该规则只绕过网关维护页,不会自动拉起 api-server 或 SpacetimeDB。人工执行 `maintenance-on.sh` 且后端仍运行时,可以从内网继续修改后台数据;`pause-after-stdb` 会停止旧 API/controller/worker,在 API 被停期间后台壳可能加载,但 `/admin/api/**` 仍不可用。验证使用 `npm run check:nginx-spa-routes`、`npm run check:pingora-route-parity`、`cargo test -p pingora-gateway --manifest-path server-rs/Cargo.toml` 和 `npm run check:pingora-gateway-smoke`,不要在 live 机器上为测试临时创建维护 marker。 +该规则只绕过网关维护响应,不会自动拉起 api-server、SpacetimeDB 或其它已停止的服务。人工执行 `maintenance-on.sh` 且后端仍运行时,可以从内网继续访问整站和修改后台数据;`pause-after-stdb` 会停止旧 API/controller/worker,在 API 被停期间静态页面可能仍可加载,但普通 API 与 `/admin/api/**` 仍不可用。验证使用 `npm run check:nginx-spa-routes`、`npm run check:pingora-route-parity`、`cargo test -p pingora-gateway --manifest-path server-rs/Cargo.toml`、`npm run check:pingora-gateway-smoke` 和 `npm run check:production-ops`,不要在 live 机器上为测试临时创建维护 marker。 生产 Jenkins 的 `Pipeline script from SCM` 由 Jenkins controller 读取 Jenkinsfile。`Genarrative-Server-Provision` 是服务器初始化流水线,Job 配置里的 SCM URL 必须使用 controller 本机可访问的仓库路径或内网 Gitea 地址,不能使用 `https://git.genarrative.world/...`;否则日志一开始的 `Checking out git ... to read jenkins/Jenkinsfile.production-server-provision` 就会先从公网拉 Jenkinsfile。构建类流水线和 `Genarrative-Server-Provision` 的 Jenkinsfile 内部源码准备阶段统一使用 `ssh://git@192.168.35.82:2222/GenarrativeAI/Genarrative.git`,并显式传入 Jenkins SSH 凭据 `genarrative-local-gitea-ssh`;不再配置 `https://git.genarrative.world/...` 公网 fallback,也不再默认使用 `http://genarrative-station/git/GenarrativeAI/Genarrative.git`。所有 `GitSCM checkout` 都必须保留单分支 refspec、`shallow=true`、`depth=1`、`noTags=true` 与 `honorRefspec=true`。API / Web / Stdb 发布类流水线不在目标机器 checkout Git,统一执行上游构建归档里的部署脚本,避免产物 commit 与部署脚本 commit 漂移;Server-Provision 也不在目标 dev / release agent checkout Git,而是由 Jenkins 构建节点先准备 provision 脚本与配置并上传给目标 agent。 diff --git a/scripts/check-nginx-spa-routes.mjs b/scripts/check-nginx-spa-routes.mjs index d3140a031..3ab5682c0 100644 --- a/scripts/check-nginx-spa-routes.mjs +++ b/scripts/check-nginx-spa-routes.mjs @@ -160,38 +160,50 @@ function validateNginxRoutes(nginxPath, expectedRoutes) { } } -function validateMaintenanceAdminBypass() { +function validateMaintenanceInternalBypass() { const snippet = readFileSync(MAINTENANCE_SNIPPET_PATH, 'utf8'); - for (const fragment of [ - 'set $genarrative_admin_maintenance $genarrative_maintenance;', - 'if ($genarrative_internal_client)', - 'set $genarrative_admin_maintenance 0;', - ]) { - if (!snippet.includes(fragment)) { - fail(`${MAINTENANCE_SNIPPET_PATH} 缺少内网后台维护放行片段: ${fragment}`); - } + const internalBypassPattern = + /set \$genarrative_maintenance 0;\s*if \(-f \/var\/lib\/genarrative\/maintenance\/enabled\) \{\s*set \$genarrative_maintenance 1;\s*\}\s*if \(\$genarrative_internal_client\) \{\s*set \$genarrative_maintenance 0;\s*\}/u; + if (!internalBypassPattern.test(snippet)) { + fail( + `${MAINTENANCE_SNIPPET_PATH} 必须在读取维护 marker 后为真实内网来源清除全站维护状态。`, + ); + } + if (snippet.includes('$genarrative_admin_maintenance')) { + fail(`${MAINTENANCE_SNIPPET_PATH} 不应保留仅后台使用的维护变量。`); } for (const nginxPath of MAINTENANCE_NGINX_PATHS) { const source = readFileSync(nginxPath, 'utf8'); for (const fragment of [ - 'geo $genarrative_internal_client {', + 'geo $remote_addr $genarrative_internal_client {', '127.0.0.0/8 1;', '10.0.0.0/8 1;', '172.16.0.0/12 1;', '192.168.0.0/16 1;', + '169.254.0.0/16 1;', '::1 1;', 'fc00::/7 1;', + 'fe80::/10 1;', ]) { if (!source.includes(fragment)) { fail(`${nginxPath} 缺少内网来源识别片段: ${fragment}`); } } - const adminMaintenanceChecks = source.match( - /if \(\$genarrative_admin_maintenance\)/gu, - ); - if ((adminMaintenanceChecks?.length ?? 0) !== 4) { - fail(`${nginxPath} 的后台入口、页面、静态资源与 API 必须只按内网感知的维护变量拦截。`); + if (source.includes('$genarrative_admin_maintenance')) { + fail(`${nginxPath} 的维护入口必须统一使用全站维护变量。`); + } + const maintenanceChecks = + source.match(/if \(\$genarrative_[a-z_]*maintenance\)/gu) ?? []; + if (maintenanceChecks.length === 0) { + fail(`${nginxPath} 缺少维护状态判断。`); + } + for (const maintenanceCheck of maintenanceChecks) { + if (maintenanceCheck !== 'if ($genarrative_maintenance)') { + fail( + `${nginxPath} 存在未统一到全站维护变量的判断: ${maintenanceCheck}`, + ); + } } } } @@ -201,7 +213,7 @@ export const expectedMainSpaRoutes = collectExpectedMainSpaRoutes(); for (const nginxPath of NGINX_PATHS) { validateNginxRoutes(nginxPath, expectedMainSpaRoutes); } -validateMaintenanceAdminBypass(); +validateMaintenanceInternalBypass(); if (failures.length > 0) { console.error('[check:nginx-spa-routes] FAILED'); diff --git a/scripts/check-pingora-gateway-smoke.mjs b/scripts/check-pingora-gateway-smoke.mjs index c0075069b..96bf12f53 100644 --- a/scripts/check-pingora-gateway-smoke.mjs +++ b/scripts/check-pingora-gateway-smoke.mjs @@ -1125,6 +1125,11 @@ async function runSmokeCases( await mkdir(path.dirname(maintenanceFile), { recursive: true }); await writeFile(maintenanceFile, 'enabled'); + const publicClientHeaders = { + 'X-Real-IP': '203.0.113.50', + 'X-Forwarded-For': '192.168.35.50', + }; + const internalClientHeaders = { 'X-Real-IP': '192.168.35.50' }; const giteaRequestsBeforeMaintenance = gitea.state.requests.length; await expectHttp( baseUrl, @@ -1135,6 +1140,7 @@ async function runSmokeCases( { headers: { Host: 'git.genarrative.world', + ...publicClientHeaders, }, }, ); @@ -1142,25 +1148,49 @@ async function runSmokeCases( gitea.state.requests.length === giteaRequestsBeforeMaintenance + 1, '维护模式 Gitea Host 请求没有打到 Gitea mock', ); - await expectHttp( - baseUrl, - '/api/creation-entry/config', - 503, - 'MAINTENANCE', - '维护模式 API JSON', - ); - await expectHttp(baseUrl, '/', 503, 'maintenance', '维护模式 Web 页面'); - const publicClientHeaders = { 'X-Real-IP': '203.0.113.50' }; - const internalClientHeaders = { 'X-Real-IP': '192.168.35.50' }; for (const [path, bodyNeedle, label] of [ + ['/', 'maintenance', '公网主站页面'], + ['/api/creation-entry/config', 'MAINTENANCE', '公网普通 API'], + ['/v1/identity', 'maintenance', '公网 SpacetimeDB 路由'], ['/admin/settings', 'maintenance', '公网后台页面'], ['/admin/assets/admin.js', 'maintenance', '公网后台静态资源'], ['/admin/api/users', 'MAINTENANCE', '公网后台 API'], ]) { - await expectHttp(baseUrl, path, 503, bodyNeedle, `维护模式继续拦截${label}`, { - headers: publicClientHeaders, - }); + await expectHttp( + baseUrl, + path, + 503, + bodyNeedle, + `维护模式继续拦截${label}`, + { + headers: publicClientHeaders, + }, + ); } + await expectHttp( + baseUrl, + '/', + 200, + 'site-shell', + '维护模式允许内网主站页面', + { headers: internalClientHeaders }, + ); + await expectHttp( + baseUrl, + '/api/creation-entry/config', + 200, + '"upstream":"api"', + '维护模式允许内网普通 API', + { headers: internalClientHeaders }, + ); + await expectHttp( + baseUrl, + '/v1/identity', + 200, + '"upstream":"spacetime"', + '维护模式允许内网 SpacetimeDB 路由', + { headers: internalClientHeaders }, + ); await expectHttp( baseUrl, '/admin/settings', @@ -1185,14 +1215,6 @@ async function runSmokeCases( '维护模式允许内网后台 API', { headers: internalClientHeaders }, ); - await expectHttp( - baseUrl, - '/', - 503, - 'maintenance', - '维护模式仍拦截内网主站页面', - { headers: internalClientHeaders }, - ); await expectAccessLogContains(accessLogFile, [ 'status=503', 'path=/api/creation-entry/config', diff --git a/server-rs/crates/pingora-gateway/src/main.rs b/server-rs/crates/pingora-gateway/src/main.rs index e4872d229..8c080f789 100644 --- a/server-rs/crates/pingora-gateway/src/main.rs +++ b/server-rs/crates/pingora-gateway/src/main.rs @@ -1193,13 +1193,10 @@ impl ProxyHttp for GenarrativeGateway { ctx.request_id = resolve_request_id(session); if self.is_maintenance_enabled() { - let internal_admin_bypass = - allows_internal_admin_maintenance_bypass(path, request_source_ip(session).as_ref()); - let should_apply_maintenance = if is_admin_request_path(path) { - !internal_admin_bypass - } else { - ctx.route.applies_maintenance_gate() - }; + let internal_bypass = + allows_internal_maintenance_bypass(request_source_ip(session).as_ref()); + let should_apply_maintenance = !internal_bypass + && (is_admin_request_path(path) || ctx.route.applies_maintenance_gate()); if should_apply_maintenance { respond_maintenance(session, ctx.route.is_api_like(), &self.config.web_root) .await?; @@ -2826,8 +2823,8 @@ fn is_internal_network_ip(ip: &IpAddr) -> bool { } } -fn allows_internal_admin_maintenance_bypass(path: &str, client_ip: Option<&IpAddr>) -> bool { - is_admin_request_path(path) && client_ip.is_some_and(is_internal_network_ip) +fn allows_internal_maintenance_bypass(client_ip: Option<&IpAddr>) -> bool { + client_ip.is_some_and(is_internal_network_ip) } fn append_forwarded_for(session: &Session, client_ip: &str) -> String { @@ -3376,52 +3373,25 @@ mod tests { } #[test] - fn maintenance_only_allows_internal_clients_to_reach_admin_routes() { - let internal_v4: IpAddr = "192.168.35.50".parse().unwrap(); - let internal_v6: IpAddr = "fd00::50".parse().unwrap(); - let public_v4: IpAddr = "203.0.113.50".parse().unwrap(); - let public_v6: IpAddr = "2001:db8::50".parse().unwrap(); - - for path in [ - "/admin", - "/admin/", - "/admin/settings", - "/admin/assets/admin.js", - "/admin/api/users", + fn maintenance_allows_internal_clients_to_bypass_all_gated_routes() { + for value in [ + "127.0.0.1", + "10.35.0.50", + "172.16.0.50", + "192.168.35.50", + "169.254.0.50", + "::1", + "fd00::50", + "fe80::50", ] { - assert!(allows_internal_admin_maintenance_bypass( - path, - Some(&internal_v4) - )); - assert!(allows_internal_admin_maintenance_bypass( - path, - Some(&internal_v6) - )); - assert!(!allows_internal_admin_maintenance_bypass( - path, - Some(&public_v4) - )); - assert!(!allows_internal_admin_maintenance_bypass( - path, - Some(&public_v6) - )); + let internal_ip: IpAddr = value.parse().unwrap(); + assert!(allows_internal_maintenance_bypass(Some(&internal_ip))); } - assert!(!allows_internal_admin_maintenance_bypass( - "/", - Some(&internal_v4) - )); - assert!(!allows_internal_admin_maintenance_bypass( - "/api/creation-entry/config", - Some(&internal_v4) - )); - assert!(!allows_internal_admin_maintenance_bypass( - "/administrator", - Some(&internal_v4) - )); - assert!(!allows_internal_admin_maintenance_bypass( - "/admin/settings", - None - )); + for value in ["203.0.113.50", "2001:db8::50"] { + let public_ip: IpAddr = value.parse().unwrap(); + assert!(!allows_internal_maintenance_bypass(Some(&public_ip))); + } + assert!(!allows_internal_maintenance_bypass(None)); } #[test]