补齐Jenkins凭据与维护后台通道
固化三个Stdb流水线的Secret File凭据默认值 将04:00开发服定时发布默认切换为normal 显式关闭三个下游Build Job的自动发布 允许内网来源在维护模式下访问后台完整链路 保持公网后台与普通业务路由的维护阻断 补齐Nginx、Pingora回归门禁与运维文档
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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。
|
||||
|
||||
@@ -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 仍会失败;需要改数据时应确保后端仍运行,不能把维护页绕过误当作服务可用性保证。
|
||||
|
||||
@@ -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`,避免直连后错误客户端把静态入口当作可写接口。
|
||||
|
||||
@@ -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 <database> --server-url <url> --limit 5 --completed-before-micros <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/<version>/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 <version>` 且未显式提供 secret / SHA-256 时,原始随机 secret 只写入 gitignored 的 `server-rs/.spacetimedb/build-secrets/<version>.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,也不再默认使用 `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。
|
||||
|
||||
当前 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。若机器仍保留旧的 `https://git.genarrative.world/git/GenarrativeAI/Genarrative.git` 或 `http://10.2.0.10/GenarrativeAI/Genarrative.git` 内网入口,只作为历史兼容和排障参考,新流水线不再默认使用。
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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: '要复制归档产物的上游构建号')
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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, ' ');
|
||||
|
||||
@@ -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<String> {
|
||||
.map(|addr| addr.ip().to_string())
|
||||
}
|
||||
|
||||
fn request_source_ip(session: &Session) -> Option<IpAddr> {
|
||||
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::<IpAddr>().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!(
|
||||
|
||||
Reference in New Issue
Block a user