diff --git a/deploy/nginx/genarrative-dev-http.conf b/deploy/nginx/genarrative-dev-http.conf index b5a33d8e3..f5baa3959 100644 --- a/deploy/nginx/genarrative-dev-http.conf +++ b/deploy/nginx/genarrative-dev-http.conf @@ -18,6 +18,18 @@ 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 { + 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; + ::1 1; + fc00::/7 1; + fe80::/10 1; +} + server { listen 80; server_name genarrative.example.com; @@ -55,7 +67,7 @@ server { limit_conn genarrative_api_conn 64; limit_req zone=genarrative_admin_rps burst=16 nodelay; - if ($genarrative_maintenance) { + if ($genarrative_admin_maintenance) { return 503 '{"ok":false,"error":{"code":"MAINTENANCE","message":"服务维护中"}}'; } @@ -70,17 +82,29 @@ server { } location = /admin { + error_page 503 /maintenance.html; + + if ($genarrative_admin_maintenance) { + return 503; + } + return 301 /admin/; } location ^~ /admin/assets/ { + error_page 503 /maintenance.html; + + if ($genarrative_admin_maintenance) { + return 503; + } + try_files $uri =404; } location ^~ /admin/ { error_page 503 /maintenance.html; - if ($genarrative_maintenance) { + if ($genarrative_admin_maintenance) { return 503; } diff --git a/deploy/nginx/genarrative.conf b/deploy/nginx/genarrative.conf index f4b3455dc..9b99bcc1f 100644 --- a/deploy/nginx/genarrative.conf +++ b/deploy/nginx/genarrative.conf @@ -16,6 +16,18 @@ 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 { + 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; + ::1 1; + fc00::/7 1; + fe80::/10 1; +} + server { listen 80; server_name genarrative.example.com; @@ -75,7 +87,7 @@ server { limit_conn genarrative_api_conn 64; limit_req zone=genarrative_admin_rps burst=16 nodelay; - if ($genarrative_maintenance) { + if ($genarrative_admin_maintenance) { return 503 '{"ok":false,"error":{"code":"MAINTENANCE","message":"服务维护中"}}'; } @@ -90,17 +102,29 @@ server { } location = /admin { + error_page 503 /maintenance.html; + + if ($genarrative_admin_maintenance) { + return 503; + } + return 301 /admin/; } location ^~ /admin/assets/ { + error_page 503 /maintenance.html; + + if ($genarrative_admin_maintenance) { + return 503; + } + try_files $uri =404; } location ^~ /admin/ { error_page 503 /maintenance.html; - if ($genarrative_maintenance) { + if ($genarrative_admin_maintenance) { return 503; } diff --git a/deploy/nginx/snippets/genarrative-maintenance.conf b/deploy/nginx/snippets/genarrative-maintenance.conf index e6c844fd0..8d6f876e9 100644 --- a/deploy/nginx/snippets/genarrative-maintenance.conf +++ b/deploy/nginx/snippets/genarrative-maintenance.conf @@ -1,10 +1,15 @@ # 维护模式由发布脚本或人工运维通过固定文件控制。 -# 文件存在时,普通页面展示维护页,管理 API 返回 503。 +# 文件存在时,普通页面展示维护页,API 返回 503;真实内网来源仍可访问后台。 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; +} + location = /maintenance.html { root /srv/genarrative/web; add_header Cache-Control "no-store"; diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 5ba50491b..0604cd677 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -3956,3 +3956,19 @@ - 决策:新增真实 `robots.txt` 和仅首页的 `sitemap.xml`,首页共享 head 提供基础 SEO/OG/JSON-LD 文本但不使用未确认的 image/logo URL;首页 DOM 只保留一个稳定产品定位 H1。Nginx 与 Pingora 只允许当前完整 SPA 路径回退 `index.html`,同前缀未知路径必须返回 404;`/admin` 继续走独立子应用。路由变化必须同步三套 Nginx、Pingora、route parity matrix 和自动门禁。 - 影响范围:`index.html`、`public/robots.txt`、`public/sitemap.xml`、首页组件、三套 Nginx、Pingora 网关和路由 parity 门禁。 - 验证方式:前端定向测试与构建、`npm run check:nginx-spa-routes`、`npm run check:pingora-route-parity`、`npm run check:pingora-gateway-smoke`、`npm run check:encoding`、`git diff --check`,部署后同时抽查根级未知路径和 `/creation/not-exist` 等同前缀未知路径。 + +## 2026-07-11 Jenkins Secret File 默认值与 dev 定时发布 + +- 背景:Stdb Build / Publish / Full Job 改用 Secret File 后,live Job UI 默认值为空且会被 SCM Jenkinsfile 覆盖;Full Job 的 04:00 timer 又与默认人工 rollout gate 冲突。dev 服务器不对外,允许定时完整发布。 +- 决策:三个 Jenkinsfile 将 `MIGRATION_BOOTSTRAP_SECRET_CREDENTIAL_ID` 默认固定为 `genarrative-spacetime-bootstrap-secret-dev-file`。Full Job 保留 04:00 timer,默认 `DEPLOY_TARGET=development`、`STDB_API_ROLLOUT_MODE=normal`,按 Stdb → API → Web 完整发布 dev;三个下游 Build 都显式传 `PUBLISH_AFTER_BUILD=false`,防止提前发布和顺序漂移。人工维护窗口才选择 `pause-after-stdb` 并强制校验 approvers。 +- 凭据边界:Secret 原文以 Jenkins Secret File 为事实源;credential ID 与参数行为以仓库 Jenkinsfile 为事实源。旧 Secret Text 继续服务 Database Import / Export,不原地改类型或删除。 +- 影响范围:`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 维护模式仅向内网放行后台 + +- 背景:维护 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 仍不可用。 +- 影响范围:生产 / dev Nginx 模板、维护 snippet、Pingora maintenance gate、Nginx 静态门禁与 Pingora smoke。 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index bb8bc7f5b..7ff9389c7 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -2944,3 +2944,19 @@ - 处理:SPA fallback 必须精确匹配当前真实完整路径,同时允许前端已有的大小写归一和尾部斜杠;最终 catch-all 只提供真实静态文件,失败返回 404。路由增删同步三套 Nginx、Pingora、route parity matrix 和路由门禁。 - 验证:除全部真实 SPA 路径外,至少检查 `/not-exist`、`/creation/not-exist`、`/runtime/not-exist` 和 `/puzzle/not-exist` 均返回 404;维护模式仍保持页面 503 优先语义。 - 关联:`src/routing/appRoutes.tsx`、`src/routing/appPageRoutes.ts`、`deploy/nginx/`、`deploy/container/nginx.conf`、`server-rs/crates/pingora-gateway/src/main.rs`。 + +## Jenkins Job UI 参数会被 SCM Jenkinsfile 覆盖 + +- 现象:在 Jenkins Job 页面给 `MIGRATION_BOOTSTRAP_SECRET_CREDENTIAL_ID` 配了默认值,下一次加载 Declarative Pipeline 后又变空或恢复旧描述;04:00 Full Job 还可能因默认选择 `pause-after-stdb` 且 approvers 为空而失败。 +- 原因:这些 Job 使用 Pipeline script from SCM,`parameters {}` 和 `triggers {}` 会作为 Job property 回写现场配置;只改 UI 不是持久修复。构建编排如果不显式关闭下游 `PUBLISH_AFTER_BUILD`,还会受下游默认值漂移影响。 +- 处理:credential ID 和参数默认值写回三个 Jenkinsfile;仅供开发使用的 dev 定时 Full Job 默认 `STDB_API_ROLLOUT_MODE=normal`,三路 Build 调用显式传 `PUBLISH_AFTER_BUILD=false`,再由 Full Job 统一按 Stdb → API → Web 发布。Secret 原文只放 Jenkins Secret File,旧 Secret Text 保留给 Import / Export。 +- 验证:推送后让 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: 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 仍会失败;需要改数据时应确保后端仍运行,不能把维护页绕过误当作服务可用性保证。 diff --git a/docs/technical/【开发运维】Pingora独立网关试点-2026-06-11.md b/docs/technical/【开发运维】Pingora独立网关试点-2026-06-11.md index 9756a2fa7..13f8f6d7e 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`。 +维护模式下,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 / 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 66a61d9bf..73056ea46 100644 --- a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md +++ b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md @@ -69,6 +69,8 @@ lease 过期后不代表任务一定再次执行:claim transaction 只有在 ` 外部生成任务摘要投影与历史 payload 维护使用 `npm run spacetime:external-generation:maintain -- ...`,且只能由已授权 migration operator 的 SpacetimeDB CLI 登录态执行。脚本默认 dry-run、每次只处理一批,绝不自动循环全表;`--apply` 才写入。先发布包含 `external_generation_job_summary` 与 cursor 索引的 SpacetimeDB 模块,在维护模式内对事故时间以前的编辑器终态任务执行小批 dry-run,例如 `npm run spacetime:external-generation:maintain -- --database --server-url --limit 5 --completed-before-micros `;核对 `matched_count`、`before_bytes`、`after_bytes` 和 `inline_media_count` 后,保持本批输入 cursor 不变并追加 `--apply` 重跑同一批,即使最后一批 `has_more = false`,只要 dry-run 仍有 `matched_count` / `selected_count` 也必须 apply;只有 apply 成功后才使用它返回的 `next_cursor_job_id` 继续。B-tree cursor 的选择阶段最多反序列化 `limit + 1` 行,apply 会再按主键逐条读取选中行但不会同时保留整批 payload;如怀疑存在单行异常巨型历史 JSON,先用 `--limit 1`。payload 压缩硬限制 `source_module = editor-canvas`;终态压缩完成后,用 `--backfill-summaries` 先 dry-run、再 `--apply` 分批补齐仍缺失的活动任务或无内联媒体历史任务摘要,直到 `has_more = false`,最后再切换使用 summary procedure 的 api-server。Stdb 构建 artifact 和完整 release 包都必须包含 `scripts/spacetime-maintain-external-generation-jobs.mjs` 与 `scripts/spacetime-migration-common.mjs`。首次上线不得让 Full Build 从 Stdb 自动直落 API:`STDB_API_ROLLOUT_MODE` 默认 fail-closed 为 `pause-after-stdb`,必须填写受限的 `STDB_API_ROLLOUT_APPROVERS`;Stdb Publish 通过 `KEEP_MAINTENANCE_MODE` 保持维护文件并停止旧 API/controller/worker,暂停点最多等待 4 小时,完成上述维护并确认无后续批次后才由指定审批人放行 API。定时构建缺少审批人时必须在发布前失败,不能静默退回 `normal`;也可分开运行 Stdb publish、维护、API deploy 三个受控 Job。任一批次都不得处理 pending / running payload;不要用 runtime writer、bootstrap secret 或匿名 identity 代替 migration operator,也不要在未核对 dry-run 时直接 apply。 +自 2026-07-11 起,`Genarrative-Full-Build-And-Deploy` 的每日 04:00 timer 默认以 `DEPLOY_TARGET=development`、`STDB_API_ROLLOUT_MODE=normal` 对仅供开发使用的 dev 服务器执行 Stdb → API → Web 完整发布,不进入人工 rollout gate。三个下游 Build 都由 Full Job 显式传 `PUBLISH_AFTER_BUILD=false`,不得依赖下游 Job 默认值或提前各自发布;统一 Build 完成后仍由 Full Job 按固定顺序发布。人工维护窗口才选择 `pause-after-stdb`,且必须配置 `STDB_API_ROLLOUT_APPROVERS`。上文“定时构建缺少审批人时失败”的旧口径不再作为当前 dev 定时发布行为。 + 需要验证“更新 API 不停 worker”和“worker 是否持续消费队列”时,优先使用隔离容器 smoke:`npm run container:worker-smoke -- smoke`。该脚本生成 gitignored 的 `deploy/container/worker-smoke/api-server.env`,启动独立 compose project 与独立 SpacetimeDB,发布当前 `spacetime-module` 后写入 `worker_smoke_unsupported` 测试 job;预期 worker claim 后执行 unsupported 失败分支,再执行 API-only recreate 并确认 worker 容器 ID 不变,最后再次入队验证 API 更新后队列仍可消费。`external_generation_job` 是 private table,脚本通过 worker 日志确认 job_id 被消费,不用 CLI SQL 查询私表。该 smoke 不读取 `.env.local`,也不依赖真实 VectorEngine / OSS 密钥;真实生图链路联调再在本地私有 env 中补齐 provider 配置。worker-smoke 默认把本机 `spacetime` CLI 打成轻量 SpacetimeDB 镜像,避免本机首次 smoke 依赖官方大镜像下载。若容器内 Cargo 拉取 crates.io 依赖不稳定,可用 `npm run container:worker-smoke -- smoke --local-binary` 让容器内 Cargo 复用本机 Cargo 缓存构建当前二进制,再打入 Debian bookworm smoke runtime 临时镜像;可用 `GENARRATIVE_WORKER_SMOKE_LOCAL_BASE_IMAGE` 覆盖运行时基础镜像;若隔离端口或库数据需要重建,追加 `--force`。完成 queue 链路验证时,还要用队列概览 BFF 和单 job 状态接口确认 job 从 queued/running 收敛,并用对应玩法 session/detail 接口确认业务状态同步完成。 本地只做账号/UI smoke 且需要短信登录时,`SMS_AUTH_PROVIDER` 应显式设为 `mock`,并把 `SMS_AUTH_MOCK_VERIFY_CODE` 设为固定值(当前常用 `123456`),再重启 `npm run dev` 或 `npm run dev:api-server`。如果 `.env.local` 还保留 `SMS_AUTH_PROVIDER=aliyun`,`POST /api/auth/phone/login` 用 mock 验证码会稳定报“验证码错误”,不是前端表单问题。真实短信联调再切回 `aliyun` 并重启。 @@ -391,6 +393,8 @@ Pingora current release 自审脚本 `scripts/ops/pingora-current-release-audit. `Genarrative-Stdb-Module-Build` 的 Jenkins 归档产物必须包含 `build//spacetime_module.wasm`、`spacetime_module.wasm.sha256`、`release-manifest.json`、`scripts/deploy/production-stdb-publish.sh`、`scripts/deploy/production-runtime-writer-identity-rotate.mjs`、`scripts/deploy/maintenance-on.sh`、`scripts/deploy/maintenance-off.sh`、`scripts/spacetime-migration-common.mjs` 和 `scripts/database-backup-to-oss.mjs`,不得包含 `migration-bootstrap-secret.txt` 或任何原始 bootstrap secret。`Genarrative-Stdb-Module-Build` 只接受 `MIGRATION_BOOTSTRAP_SECRET_CREDENTIAL_ID` 指向的受保护 Jenkins Secret File:构建 shell 从临时文件读取原始值,强制校验为 64 位十六进制,计算 SHA-256,随后只通过 `GENARRATIVE_SPACETIME_MIGRATION_BOOTSTRAP_SECRET_SHA256` 注入 Rust 编译;WASM 因而只包含摘要,不包含可下载的原文,Stdb `release-manifest.json` 以 `migration_bootstrap_secret_sha256` 记录该非敏感摘要。`Genarrative-Stdb-Module-Publish` 只通过 `copyArtifacts` 复制上述非敏感产物,不在目标机器 checkout Git,并在发布阶段用同一个凭据 ID 再次挂载 Secret File;publish 必须再次校验 64 位十六进制、重算 SHA-256,并与 manifest 的 `migration_bootstrap_secret_sha256` 强制匹配后才可发布。Full Build 必须保证 Stdb Build / Publish 的 `MIGRATION_BOOTSTRAP_SECRET_CREDENTIAL_ID` 完全相同并把同一个 ID 同时透传,不能从构建 artifact 传 secret;ID 不同、manifest 缺摘要或摘要不匹配都必须在发布前失败。 +三个 SCM Jenkinsfile 将 `MIGRATION_BOOTSTRAP_SECRET_CREDENTIAL_ID` 默认固定为 `genarrative-spacetime-bootstrap-secret-dev-file`。Secret File 的原文只存在于 Jenkins Credentials;credential ID、参数默认值和定时 / 发布行为以仓库 Jenkinsfile 为事实源,不能只改 Job UI,因为 Declarative Pipeline 下一次载入会重写参数定义。旧 Secret Text `genarrative-spacetime-bootstrap-secret-dev` 继续保留给 Database Import / Export,不得原地改类型或删除。 + 生产 Stdb publish 固定传 `--delete-data=never --yes=migrate,break-clients`,普通 Stdb Jenkins Job 不提供 `CLEAR_DATABASE`;任何需要删除数据的迁移都必须失败并重新核对 schema 与 artifact,不能在发布路径内切换清库继续。 生产运行时不把 bootstrap secret 明文写进 `/etc/genarrative/*.env`。`api-server.env` 和 worker env 只登记固定 FILE 路径 `GENARRATIVE_SPACETIME_RUNTIME_SERVICE_BOOTSTRAP_SECRET_FILE=/var/lib/genarrative/spacetime/runtime-service-bootstrap-secret.txt`;若检测到明文 `GENARRATIVE_SPACETIME_RUNTIME_SERVICE_BOOTSTRAP_SECRET` 或其他 FILE 路径,Server-Provision / API deploy 必须失败。Full Build 先执行 Stdb publish、后执行 API deploy,因此两段必须透传同一 `API_ENV_FILE` / `WORKER_ENV_FILE`;Stdb Publish 把 Secret File 路径作为 `--migration-bootstrap-secret-file` 传给随包 `production-stdb-publish.sh`。脚本先进入维护模式、按所选模式完成发布前冷备份、校验 checksum 并发布 module;成功后拒绝符号链接目标,把 secret 安装成 `root:genarrative 0440`、目录收紧为 `root:genarrative 0750`,原子补齐 API / worker env 的固定 FILE 配置,再快照并重启发布前为 active 的 API、controller 和 worker。`systemctl is-active` 只有明确返回合法的非 active 状态时才允许跳过,查询错误或 active worker 的 `list-units` 失败都必须保留维护模式并阻断;所有原 active 服务重启后必须重新确认为 `active`。如果 API 原本 active,还必须在 `maintenance-off` 前通过本机 `http://127.0.0.1:8082/healthz` readiness;可用 `--api-health-url` / `GENARRATIVE_STDB_PUBLISH_API_HEALTH_URL` 调整本机 URL,并用 `--api-readiness-timeout-seconds` / `GENARRATIVE_STDB_PUBLISH_API_READINESS_TIMEOUT_SECONDS` 调整超时。这样旧服务器首次 rollout 也不会等到后续 API deploy 才拿到 FILE;只覆盖 secret 文件或只补 env 而不重启都不生效,因为 `AppConfig` 在进程启动时读取 secret。人工执行 `npm run build:production-release -- --component spacetime-module --name ` 且未显式提供 secret / SHA-256 时,原始随机 secret 只写入 gitignored 的 `server-rs/.spacetimedb/build-secrets/.txt`,目录权限 `0700`、文件权限 `0600`,发布包和 `release-manifest.json` 都不收录它;必须把该受保护文件另行交给 publish 阶段。旧 `npm run deploy:rust:remote` Ubuntu 直传入口也使用同一 sidecar 目录,发布包内不含原文;上传模式通过独立 SSH 标准输入把 secret 原子安装到远端发布目录并收紧为 `0600`,`--skip-upload` 时必须单独受保护交付。 本地 dev 的原始值也只能进入 api-server,不得扩散给 Web / Vite,任何控制台、Jenkins 日志、归档或生成 README 都不得输出明文。相关变更至少运行 `bash -n scripts/deploy/production-stdb-publish.sh scripts/deploy/production-api-deploy.sh scripts/deploy-rust-remote.sh scripts/jenkins-server-provision.sh`、`node --check scripts/dev.mjs scripts/check-production-ops-guardrails.mjs`、`npm run check:production-ops`、`npm run check:encoding` 和 `git diff --check`。 @@ -399,6 +403,12 @@ 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` 用作后台放行依据。后台本身的登录与管理员鉴权不变。 + +该规则只绕过网关维护页,不会自动拉起 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。 + 生产 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。 当前 Jenkins / 本机内网 Git 入口固定为 `ssh://git@192.168.35.82:2222/GenarrativeAI/Genarrative.git`,用于 controller、构建节点和本机 Agent 直接拉取仓库,避免绕公网 `git.genarrative.world`。验证时在具备对应 SSH key 和 known_hosts 的环境执行 `git ls-remote ssh://git@192.168.35.82:2222/GenarrativeAI/Genarrative.git HEAD`,应能返回 HEAD。若机器仍保留旧的 `http://genarrative-station/git/GenarrativeAI/Genarrative.git` 或 `http://10.2.0.10/GenarrativeAI/Genarrative.git` 内网入口,只作为历史兼容和排障参考,新流水线不再默认使用。 diff --git a/jenkins/Jenkinsfile.production-full-build-and-deploy b/jenkins/Jenkinsfile.production-full-build-and-deploy index d35d74ba4..96d61d361 100644 --- a/jenkins/Jenkinsfile.production-full-build-and-deploy +++ b/jenkins/Jenkinsfile.production-full-build-and-deploy @@ -24,7 +24,7 @@ pipeline { string(name: 'BUILD_VERSION', defaultValue: '', description: '发布版本号,留空则使用 Jenkins BUILD_NUMBER') booleanParam(name: 'RUN_NPM_CI', defaultValue: true, description: 'Web 构建前是否执行 npm ci') string(name: 'NOTIFICATION_EMAILS', defaultValue: '', description: '本次运行追加通知邮箱;会与 Jenkins Secret Text 凭据 genarrative-notification-emails 合并发送') - string(name: 'MIGRATION_BOOTSTRAP_SECRET_CREDENTIAL_ID', defaultValue: '', description: '必填:Stdb module 构建与发布共用的 Jenkins Secret File 凭据 ID') + string(name: 'MIGRATION_BOOTSTRAP_SECRET_CREDENTIAL_ID', defaultValue: 'genarrative-spacetime-bootstrap-secret-dev-file', description: '必填:Stdb module 构建与发布共用的 Jenkins Secret File 凭据 ID') booleanParam(name: 'INCLUDE_PINGORA_GATEWAY', defaultValue: true, description: 'API release 是否构建、归档并部署 Pingora 影子网关;release 默认必须包含') string(name: 'WEB_BUILD_JOB_NAME', defaultValue: 'Genarrative-Web-Build', description: 'Web 构建流水线作业名') string(name: 'API_BUILD_JOB_NAME', defaultValue: 'Genarrative-Api-Build', description: 'API 构建流水线作业名') @@ -40,7 +40,7 @@ pipeline { string(name: 'SPACETIME_RUN_AS_USER', defaultValue: 'spacetimedb', description: 'Stdb 发布使用的本机用户') string(name: 'API_ENV_FILE', defaultValue: '/etc/genarrative/api-server.env', description: 'API 与 Stdb publish 共用的 api-server 环境文件') string(name: 'WORKER_ENV_FILE', defaultValue: '/etc/genarrative/external-generation-worker.env', description: 'API 与 Stdb publish 共用的 worker 环境文件') - choice(name: 'STDB_API_ROLLOUT_MODE', choices: ['pause-after-stdb', 'normal'], description: '默认 fail-closed 暂停在 Stdb 与 API 之间;确认无需维护时才显式选 normal') + choice(name: 'STDB_API_ROLLOUT_MODE', choices: ['normal', 'pause-after-stdb'], description: '定时任务默认 normal 完整发布 dev;人工维护窗口才选择 pause-after-stdb') string(name: 'STDB_API_ROLLOUT_APPROVERS', defaultValue: '', description: 'pause-after-stdb 必填:允许放行 API 的 Jenkins 用户/组,多个值用逗号分隔') } @@ -107,7 +107,7 @@ pipeline { error('release 部署需要先配置独立 release 部署 agent,并勾选 CONFIRM_RELEASE_DEPLOY_AGENT。当前 Linux 开发/构建/开发部署 agent 不能执行 release 部署。') } if (params.STDB_API_ROLLOUT_MODE == 'pause-after-stdb' && !params.STDB_API_ROLLOUT_APPROVERS?.trim()) { - error('pause-after-stdb 必须填写 STDB_API_ROLLOUT_APPROVERS;定时构建默认在此 fail-closed,不得无人值守发布 Stdb/API。') + error('pause-after-stdb 必须填写 STDB_API_ROLLOUT_APPROVERS。') } } } @@ -129,6 +129,7 @@ pipeline { string(name: 'BUILD_VERSION', value: env.EFFECTIVE_BUILD_VERSION), string(name: 'NOTIFICATION_EMAILS', value: params.NOTIFICATION_EMAILS ?: ''), booleanParam(name: 'RUN_NPM_CI', value: params.RUN_NPM_CI), + booleanParam(name: 'PUBLISH_AFTER_BUILD', value: false), ] env.WEB_BUILD_NUMBER = webRun.number.toString() } @@ -146,6 +147,7 @@ pipeline { string(name: 'BUILD_VERSION', value: env.EFFECTIVE_BUILD_VERSION), string(name: 'NOTIFICATION_EMAILS', value: params.NOTIFICATION_EMAILS ?: ''), booleanParam(name: 'INCLUDE_PINGORA_GATEWAY', value: params.INCLUDE_PINGORA_GATEWAY), + booleanParam(name: 'PUBLISH_AFTER_BUILD', value: false), ] env.API_BUILD_NUMBER = apiRun.number.toString() } @@ -164,6 +166,7 @@ pipeline { string(name: 'NOTIFICATION_EMAILS', value: params.NOTIFICATION_EMAILS ?: ''), string(name: 'MIGRATION_BOOTSTRAP_SECRET_CREDENTIAL_ID', value: params.MIGRATION_BOOTSTRAP_SECRET_CREDENTIAL_ID ?: ''), string(name: 'DATABASE', value: params.DATABASE), + booleanParam(name: 'PUBLISH_AFTER_BUILD', value: false), ] env.STDB_BUILD_NUMBER = stdbRun.number.toString() } diff --git a/jenkins/Jenkinsfile.production-stdb-module-build b/jenkins/Jenkinsfile.production-stdb-module-build index ab793bc76..7eb6971c7 100644 --- a/jenkins/Jenkinsfile.production-stdb-module-build +++ b/jenkins/Jenkinsfile.production-stdb-module-build @@ -25,7 +25,7 @@ pipeline { string(name: 'COMMIT_HASH', defaultValue: '', description: '可选,指定属于 SOURCE_BRANCH 的 Git commit') string(name: 'BUILD_VERSION', defaultValue: '', description: '发布版本号,留空则使用 Jenkins BUILD_NUMBER') string(name: 'NOTIFICATION_EMAILS', defaultValue: '', description: '本次运行追加通知邮箱;会与 Jenkins Secret Text 凭据 genarrative-notification-emails 合并发送') - string(name: 'MIGRATION_BOOTSTRAP_SECRET_CREDENTIAL_ID', defaultValue: '', description: '必填:与生产模块绑定的 Jenkins Secret File 凭据 ID;仅在受控构建和发布阶段短暂挂载') + string(name: 'MIGRATION_BOOTSTRAP_SECRET_CREDENTIAL_ID', defaultValue: 'genarrative-spacetime-bootstrap-secret-dev-file', description: '必填:与生产模块绑定的 Jenkins Secret File 凭据 ID;仅在受控构建和发布阶段短暂挂载') booleanParam(name: 'PUBLISH_AFTER_BUILD', defaultValue: false, description: '构建成功后是否触发 Stdb module 发布') string(name: 'DEPLOY_JOB_NAME', defaultValue: 'Genarrative-Stdb-Module-Publish', description: 'Stdb module 发布流水线作业名') choice(name: 'DEPLOY_TARGET', choices: ['development', 'release'], description: 'PUBLISH_AFTER_BUILD=true 时的逻辑部署目标;development 使用当前 Linux 开发/构建/开发部署 agent') diff --git a/jenkins/Jenkinsfile.production-stdb-module-publish b/jenkins/Jenkinsfile.production-stdb-module-publish index 146bb77f6..892b87e9f 100644 --- a/jenkins/Jenkinsfile.production-stdb-module-publish +++ b/jenkins/Jenkinsfile.production-stdb-module-publish @@ -13,7 +13,7 @@ pipeline { string(name: 'SOURCE_BRANCH', defaultValue: 'master', description: '上游构建源码分支') string(name: 'COMMIT_HASH', defaultValue: '', description: '上游构建源码 commit') string(name: 'NOTIFICATION_EMAILS', defaultValue: '', description: '本次运行追加通知邮箱;会与 Jenkins Secret Text 凭据 genarrative-notification-emails 合并发送') - string(name: 'MIGRATION_BOOTSTRAP_SECRET_CREDENTIAL_ID', defaultValue: '', description: '必填:与目标 wasm 一致的 Jenkins Secret File 凭据 ID;仅在发布时受保护挂载') + string(name: 'MIGRATION_BOOTSTRAP_SECRET_CREDENTIAL_ID', defaultValue: 'genarrative-spacetime-bootstrap-secret-dev-file', description: '必填:与目标 wasm 一致的 Jenkins Secret File 凭据 ID;仅在发布时受保护挂载') string(name: 'BUILD_VERSION', defaultValue: '', description: '待发布版本号') string(name: 'BUILD_JOB_NAME', defaultValue: 'Genarrative-Stdb-Module-Build', description: 'Stdb module 构建流水线作业名') string(name: 'BUILD_NUMBER_TO_DEPLOY', defaultValue: '', description: '要复制归档产物的上游构建号') diff --git a/scripts/check-nginx-spa-routes.mjs b/scripts/check-nginx-spa-routes.mjs index bfbc3a837..d3140a031 100644 --- a/scripts/check-nginx-spa-routes.mjs +++ b/scripts/check-nginx-spa-routes.mjs @@ -10,6 +10,12 @@ const NGINX_PATHS = [ 'deploy/nginx/genarrative-dev-http.conf', 'deploy/container/nginx.conf', ]; +const MAINTENANCE_NGINX_PATHS = [ + 'deploy/nginx/genarrative.conf', + 'deploy/nginx/genarrative-dev-http.conf', +]; +const MAINTENANCE_SNIPPET_PATH = + 'deploy/nginx/snippets/genarrative-maintenance.conf'; const SPA_BLOCK_START = '# BEGIN GENARRATIVE MAIN SPA ROUTES'; const SPA_BLOCK_END = '# END GENARRATIVE MAIN SPA ROUTES'; const UNKNOWN_ROUTE_SAMPLES = [ @@ -154,11 +160,48 @@ function validateNginxRoutes(nginxPath, expectedRoutes) { } } +function validateMaintenanceAdminBypass() { + 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}`); + } + } + + for (const nginxPath of MAINTENANCE_NGINX_PATHS) { + const source = readFileSync(nginxPath, 'utf8'); + for (const fragment of [ + 'geo $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;', + '::1 1;', + 'fc00::/7 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 必须只按内网感知的维护变量拦截。`); + } + } +} + export const expectedMainSpaRoutes = collectExpectedMainSpaRoutes(); for (const nginxPath of NGINX_PATHS) { validateNginxRoutes(nginxPath, expectedMainSpaRoutes); } +validateMaintenanceAdminBypass(); 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 421c394ed..c0075069b 100644 --- a/scripts/check-pingora-gateway-smoke.mjs +++ b/scripts/check-pingora-gateway-smoke.mjs @@ -1150,6 +1150,49 @@ async function runSmokeCases( '维护模式 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 [ + ['/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, + '/admin/settings', + 200, + 'admin-shell', + '维护模式允许内网后台页面', + { headers: internalClientHeaders }, + ); + await expectHttp( + baseUrl, + '/admin/assets/admin.js', + 200, + 'admin asset', + '维护模式允许内网后台静态资源', + { headers: internalClientHeaders }, + ); + await expectHttp( + baseUrl, + '/admin/api/users', + 200, + '"upstream":"api"', + '维护模式允许内网后台 API', + { headers: internalClientHeaders }, + ); + await expectHttp( + baseUrl, + '/', + 503, + 'maintenance', + '维护模式仍拦截内网主站页面', + { headers: internalClientHeaders }, + ); await expectAccessLogContains(accessLogFile, [ 'status=503', 'path=/api/creation-entry/config', diff --git a/scripts/check-production-ops-guardrails.mjs b/scripts/check-production-ops-guardrails.mjs index 6bf64c86e..09e3b359e 100644 --- a/scripts/check-production-ops-guardrails.mjs +++ b/scripts/check-production-ops-guardrails.mjs @@ -202,6 +202,12 @@ const checks = [ includes: 'GENARRATIVE_SPACETIME_MIGRATION_BOOTSTRAP_SECRET_SHA256', reason: 'Stdb Build 只能把 File credential 的 SHA-256 摘要传给 Rust 编译。', }, + { + file: 'jenkins/Jenkinsfile.production-stdb-module-build', + includes: + "string(name: 'MIGRATION_BOOTSTRAP_SECRET_CREDENTIAL_ID', defaultValue: 'genarrative-spacetime-bootstrap-secret-dev-file'", + reason: 'Stdb Build 的 Secret File credential ID 必须由仓库 Jenkinsfile 固定默认值。', + }, { file: 'jenkins/Jenkinsfile.production-stdb-module-build', excludes: 'export GENARRATIVE_SPACETIME_MIGRATION_BOOTSTRAP_SECRET=', @@ -309,6 +315,12 @@ const checks = [ includes: '--worker-env-file "${params.WORKER_ENV_FILE}"', reason: 'Stdb Publish 必须把 worker env 路径传给随包发布脚本。', }, + { + file: 'jenkins/Jenkinsfile.production-stdb-module-publish', + includes: + "string(name: 'MIGRATION_BOOTSTRAP_SECRET_CREDENTIAL_ID', defaultValue: 'genarrative-spacetime-bootstrap-secret-dev-file'", + reason: 'Stdb Publish 的 Secret File credential ID 必须由仓库 Jenkinsfile 固定默认值。', + }, { file: 'jenkins/Jenkinsfile.production-full-build-and-deploy', includes: "string(name: 'WORKER_ENV_FILE', value: params.WORKER_ENV_FILE", @@ -320,6 +332,12 @@ const checks = [ "error('MIGRATION_BOOTSTRAP_SECRET_CREDENTIAL_ID 必须引用受保护的 Jenkins Secret File 凭据。')", reason: '全量构建必须在启动并行子流水线前拒绝缺失的 Secret File 凭据。', }, + { + file: 'jenkins/Jenkinsfile.production-full-build-and-deploy', + includes: + "string(name: 'MIGRATION_BOOTSTRAP_SECRET_CREDENTIAL_ID', defaultValue: 'genarrative-spacetime-bootstrap-secret-dev-file'", + reason: 'Full Build 的 Secret File credential ID 必须由仓库 Jenkinsfile 固定默认值。', + }, { file: 'jenkins/Jenkinsfile.production-full-build-and-deploy', includes: @@ -329,8 +347,8 @@ const checks = [ { file: 'jenkins/Jenkinsfile.production-full-build-and-deploy', includes: - "choice(name: 'STDB_API_ROLLOUT_MODE', choices: ['pause-after-stdb', 'normal']", - reason: 'Full Build 的 Stdb/API 首发门禁必须默认 fail-closed,不能由 cron 自动直落 API。', + "choice(name: 'STDB_API_ROLLOUT_MODE', choices: ['normal', 'pause-after-stdb']", + reason: 'Full Build 的 04:00 定时任务必须默认 normal 完整发布仅供开发使用的 dev 服务器。', }, { file: 'jenkins/Jenkinsfile.production-full-build-and-deploy', @@ -6747,6 +6765,20 @@ for (const check of checks) { } } +const fullPipelineContent = readFileSync( + 'jenkins/Jenkinsfile.production-full-build-and-deploy', + 'utf8', +); +const forcedBuildOnlyCalls = fullPipelineContent.match( + /booleanParam\(name: 'PUBLISH_AFTER_BUILD', value: false\)/gu, +); +if ((forcedBuildOnlyCalls?.length ?? 0) !== 3) { + failed = true; + console.error( + '[check:production-ops] Full Build 必须向 Web、API、Stdb 三个 Build Job 显式传 PUBLISH_AFTER_BUILD=false。', + ); +} + for (const file of nodeEnvFileCommandFiles) { const content = readFileSync(file, 'utf8'); const commandText = content.replace(/\\\r?\n\s*/g, ' '); diff --git a/server-rs/crates/pingora-gateway/src/main.rs b/server-rs/crates/pingora-gateway/src/main.rs index aa5f9f09d..e4872d229 100644 --- a/server-rs/crates/pingora-gateway/src/main.rs +++ b/server-rs/crates/pingora-gateway/src/main.rs @@ -5,7 +5,7 @@ use std::{ io, io::SeekFrom, io::Write, - net::SocketAddr, + net::{IpAddr, SocketAddr}, path::{Path, PathBuf}, sync::{Arc, Mutex}, time::{Duration, Instant, SystemTime, UNIX_EPOCH}, @@ -1192,9 +1192,19 @@ impl ProxyHttp for GenarrativeGateway { apply_configured_body_limit(&mut ctx.route, self.config.max_api_body_bytes); ctx.request_id = resolve_request_id(session); - if self.is_maintenance_enabled() && ctx.route.applies_maintenance_gate() { - respond_maintenance(session, ctx.route.is_api_like(), &self.config.web_root).await?; - return Ok(true); + 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() + }; + if should_apply_maintenance { + respond_maintenance(session, ctx.route.is_api_like(), &self.config.web_root) + .await?; + return Ok(true); + } } if let RouteDecision::Proxy { body_limit, .. } = ctx.route @@ -2790,6 +2800,36 @@ fn client_ip(session: &Session) -> Option { .map(|addr| addr.ip().to_string()) } +fn request_source_ip(session: &Session) -> Option { + let peer_ip = session + .as_downstream() + .client_addr() + .and_then(|addr| addr.as_inet()) + .map(|addr| addr.ip())?; + if peer_ip.is_loopback() + && let Some(real_ip) = + header_value(session, "x-real-ip").and_then(|value| value.trim().parse::().ok()) + { + return Some(real_ip); + } + Some(peer_ip) +} + +fn is_admin_request_path(path: &str) -> bool { + path == "/admin" || path.starts_with("/admin/") +} + +fn is_internal_network_ip(ip: &IpAddr) -> bool { + match ip { + IpAddr::V4(ip) => ip.is_private() || ip.is_loopback() || ip.is_link_local(), + IpAddr::V6(ip) => ip.is_loopback() || ip.is_unique_local() || ip.is_unicast_link_local(), + } +} + +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 append_forwarded_for(session: &Session, client_ip: &str) -> String { session .req_header() @@ -3335,6 +3375,55 @@ mod tests { assert!(should_disable_accel_buffering(&route)); } + #[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", + ] { + 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) + )); + } + 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 + )); + } + #[test] fn normalizes_gateway_hosts_for_matching() { assert_eq!(