维护模式仅拦截公网流量

Nginx 按真实连接地址让内网绕过全站维护闸
Pingora 统一内网维护放行并拒绝 XFF 伪造
补齐公网与内网维护回归测试和运维文档
This commit is contained in:
2026-07-12 20:27:04 +08:00
parent 8bfedd8ad7
commit 71811f04c3
10 changed files with 126 additions and 120 deletions
+7 -6
View File
@@ -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;
}
+7 -6
View File
@@ -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、内网主站仍 503Rust 单测覆盖 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,以及内网对应路由为 200Rust 单测覆盖 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-serverSpacetimeDB。人工执行 `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-serverSpacetimeDB 或其它已停止的服务。人工执行 `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。
+28 -16
View File
@@ -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');
+43 -21
View File
@@ -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',
+23 -53
View File
@@ -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]