维护模式仅拦截公网流量
Nginx 按真实连接地址让内网绕过全站维护闸 Pingora 统一内网维护放行并拒绝 XFF 伪造 补齐公网与内网维护回归测试和运维文档
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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`。
|
||||
|
||||
@@ -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 仍会失败;需要调用后端时应确保对应服务仍运行,不能把维护页绕过误当作服务可用性保证。
|
||||
|
||||
@@ -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`,避免直连后错误客户端把静态入口当作可写接口。
|
||||
|
||||
@@ -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,也不再默认使用 `https://git.genarrative.world/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。
|
||||
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user