diff --git a/docs/deployment/cicd/01-installation.md b/docs/deployment/cicd/01-installation.md
new file mode 100644
index 0000000..625ef20
--- /dev/null
+++ b/docs/deployment/cicd/01-installation.md
@@ -0,0 +1,164 @@
+# 01 · 安装部署 / 安全配置 / Gitea↔Jenkins 集成
+
+配置源码:[`infra/cicd/`](../../../infra/cicd/)。本文描述**目标态**与从现状迁移的步骤。
+
+## 1. 安装部署
+
+### 1.1 前置条件
+
+```bash
+# Docker 20.10+,docker compose v2
+docker --version && docker compose version
+# 外部网络(nginx 反代依赖同一网络做服务名解析)
+docker network create novalon-network 2>/dev/null || true
+```
+
+### 1.2 部署 Gitea
+
+```bash
+cd /home/novalon/docker-app/infra/cicd
+cp .env.example .env && vim .env # 填 GITEA_DB_PASSWORD 等,.env 绝不入库
+docker compose -f docker-compose.gitea.yml up -d
+docker compose -f docker-compose.gitea.yml ps # 等 health=healthy
+```
+
+要点:
+- **资源限制用 `cpus`/`mem_limit` 顶层键,不要用 `deploy.resources`**。
+ 现状 compose 写的是 `deploy:`,非 swarm 下被 `docker compose` **静默忽略**,
+ 等于"看起来限制了实际没有"。这是本次审计确认的实锤。
+- SSH 映射 `2222:22`,HTTP 仅 `127.0.0.1:3001`,由 nginx 终止 TLS。
+
+### 1.3 部署 Jenkins
+
+```bash
+# ⚠️ 现状卷属主是 root(因历史 user: root),切非 root 前必须先改属主
+docker stop jenkins
+docker run --rm -v jenkins_jenkins_home:/v alpine chown -R 1000:1000 /v
+cd /home/novalon/docker-app/infra/cicd
+docker compose -f docker-compose.jenkins.yml up -d
+```
+
+### 1.4 迁移时的破坏性变更(需维护窗口)
+
+| 变更 | 为什么 | 影响 |
+|------|--------|------|
+| Jenkins `user: root` → `1000:1000` | root 容器 = 宿主 root | 需先 `chown` 卷,服务中断 ~2 分钟 |
+| 移除 `/var/run/docker.sock` | 作业 config.xml 实测 **0 处** docker 调用 | 若未来作业要用 docker,需改用 dind/socket-proxy |
+| 移除 `/home/novalon/docker-app` 挂载 | `deploy.sh` 是 `rsync` 到远端,不读本地该目录 | 无 |
+| `/root/.ssh` → `./jenkins-ssh` | 不再暴露宿主 root 私钥 | 需生成专用部署密钥并放服务器 `authorized_keys` |
+
+## 2. 基础安全配置
+
+### 2.1 HTTPS
+
+由 `nginx-static-production.conf` + `conf.d/*.conf` 终止,无需应用自配。
+
+```nginx
+# 已有配置(conf.d/git.f.novalon.cn.conf)
+ssl_protocols TLSv1.2 TLSv1.3;
+add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
+location /.well-known/acme-challenge/ { root /var/www/certbot; }
+location / { return 301 https://$host$request_uri; }
+```
+
+**已知小瑕疵(P3)**:nginx `add_header` 与 Gitea 自身都注入 `x-frame-options`/`x-content-type-options`,
+响应里出现两次。不影响安全(重复值同语义),如要清理:nginx 侧改用 `proxy_hide_header` 后再 `add_header`。
+
+### 2.2 访问权限
+
+| 层 | 配置 | 实测状态 |
+|----|------|----------|
+| Gitea 注册 | `DISABLE_REGISTRATION=true` + `REQUIRE_SIGNIN_VIEW=true` | 环境变量已设;**注意**:env 只在首次安装写库,需核对 `app.ini [service]` 是否同步 |
+| Gitea 匿名读 | `REQUIRE_SIGNIN_VIEW` | 未登录访问 `/` 返回 303 → `/explore` |
+| Jenkins 匿名读 | `allowAnonymousRead: false`(casc.yaml) | 待 JCasC 生效后验证 |
+| Jenkins 自助注册 | `allowsSignup: false` | 同上 |
+| agent 协议 | 仅 `Ping`,关 JNLP 入站 | 无 agent,可整段删 `50000` 映射 |
+
+### 2.3 凭据管理(重点)
+
+**原则:任何密码/令牌只允许出现在两个地方 —— 服务器 `.env`(600 权限)或 Jenkins Credentials。**
+
+```
+infra/cicd/.env ← 服务器本地,600,不入库
+infra/cicd/.env.example ← 模板,只有占位符,可入库
+jenkins/casc.yaml ← 凭据用 ${VAR} 引用环境变量,不写明文
+```
+
+**禁止**再出现 `cicd.config` 那种明文密码入库。相关 P0 事故见 [03](03-credential-incident.md)。
+
+Gitea 令牌最小权限:Jenkins 拉代码只需 `read:repository`,不要给 `write:administration`。
+
+## 3. Gitea ↔ Jenkins 集成
+
+### 3.1 数据流
+
+```
+git push (main/develop)
+ └─► Gitea webhook → https://ci.f.novalon.cn/generic-webhook-trigger/invoke?token=***
+ └─► GenericTrigger 解析 $.ref / $.repository.full_name / $.after
+ └─► regexpFilterExpression ^refs/heads/(main|develop)$ 通过 → 触发构建
+ └─► Jenkinsfile 各 stage
+```
+
+### 3.2 作业定义(现状)
+
+```xml
+
+
+
+ https://git.f.novalon.cn/novalon/novalon-website.git
+ gitea-credentials
+ */main
+
+ Jenkinsfile
+ true
+
+
+```
+
+### 3.3 ⚠️ 排障:推送不触发构建
+
+这是本次排查中最容易踩的坑,按顺序核对:
+
+1. **`` 为空是正常的吗?** 是 —— `GenericTrigger` 声明在 Jenkinsfile 的
+ `triggers {}` 块里,**第一次成功构建之后**才会在作业上注册 trigger。
+ 所以:全新作业 push 后不触发,**先手动 Build Once 一次**。
+2. **Token 是否一致**:Jenkinsfile 里 `token: '${PROJECT_NAME}-ci-token'`
+ 是 **Groovy 字符串插值**,在双引号字符串里才会展开。
+ 写在 `token: '...'`(单引号)里就是字面量 `${PROJECT_NAME}-ci-token`。
+ Gitea webhook 里填的 token 必须与 Jenkins 实际生效值一致(去作业配置页看真实 token)。
+3. **Webhook 状态**:Gitea → 仓库 → 设置 → Web 钩子 → 最近推送记录,
+ 看 HTTP 状态码。`403` = token 错;`404` = 插件端点错;`200/204` = 已接受。
+4. **过滤表达式**:`regexpFilterExpression: '^(refs/heads/main|refs/heads/develop)$'`
+ —— 只在这两个分支触发,其它分支 push 静默忽略属预期。
+
+### 3.4 流水线阶段(Jenkinsfile 现状)
+
+| Stage | 内容 | 分支条件 |
+|-------|------|----------|
+| 🔧 环境检测 | 工具版本 + SSH 连通性 + registry | 全部 |
+| 📥 安装依赖 | `npm ci`(失败回退 `npm install --legacy-peer-deps`) | 全部 |
+| 🔍 质量检查 | ESLint ∥ TypeScript | 全部 |
+| 🧪 单元测试 | `test:coverage:check` + Coverage Report | 全部 |
+| 🌐 E2E | build + `@smoke\|@critical` + `@journey` | main |
+| 👁️ 视觉回归 | `visual-regression.spec.ts` | main |
+| 🔒 安全扫描 | `npm audit` + 安全响应头 | main |
+| 🏗️ 构建 | `build:clean` → `dist/`,archiveArtifacts | 全部 |
+| 🚀 部署 | `deploy.sh deploy --skip-build`,失败自动 rollback | main + 参数开关 |
+
+> 注意:E2E/视觉回归 stage 里 `|| echo "⚠️ ..."` 会**吞掉失败退出码**,
+> 测试挂了流水线仍然绿。若要严格门禁需改为 `exit 1`。
+
+### 3.5 集成自检命令
+
+```bash
+# 1. Gitea 可达
+curl -s -o /dev/null -w '%{http_code}\n' https://git.f.novalon.cn/api/v1/version
+
+# 2. Jenkins 通用 webhook 端点存在(错误 token 应 403 而非 404)
+curl -s -o /dev/null -w '%{http_code}\n' \
+ 'https://ci.f.novalon.cn/generic-webhook-trigger/invoke?token=wrong'
+
+# 3. Gitea webhook 配置(需管理员或有仓库权限的令牌)
+# Gitea UI → 仓库 → 设置 → Web 钩子
+```
diff --git a/docs/deployment/cicd/02-ha-backup-monitoring.md b/docs/deployment/cicd/02-ha-backup-monitoring.md
new file mode 100644
index 0000000..354749d
--- /dev/null
+++ b/docs/deployment/cicd/02-ha-backup-monitoring.md
@@ -0,0 +1,176 @@
+# 02 · 高可用 / 数据备份 / 日志监控
+
+脚本源码:[`infra/cicd/backup/`](../../../infra/cicd/backup/)、[`infra/cicd/monitoring/`](../../../infra/cicd/monitoring/)。
+本文所有数字均为 2026-09-14 在生产实测所得。
+
+## 1. 高可用:先说清现状边界
+
+**当前是单机部署,没有真正的 HA。** 不要被 compose 里的 `deploy.replicas` 迷惑 ——
+那是 Swarm 语法,`docker compose` 非 swarm 模式下**完全忽略**,replicas 实际恒为 1。
+
+| 组件 | 现状 | 单点风险 | 建议(按性价比排序) |
+|------|------|----------|---------------------|
+| Jenkins | 单实例 | 构建历史丢失(可重建) | 接受单点,**靠备份**,不值得上 HA |
+| Gitea | 单实例 | 代码托管中断(clone 有本地副本) | 接受单点 + 异地备份;量大了再考虑主从 |
+| PostgreSQL | 单实例 | **用户/权限/PR/Issue/Webhook 唯一副本** | 最高优先级,每日备份必保 |
+| nginx | 单实例 | 全站入口 | 20G/2C 小机,先做日志轮转与监控,不建议硬上 keepalived |
+| 宿主机 | 单机 | 全部 | **异地备份是唯一真正有效的容灾** |
+
+结论:这台 2C/20G 机器的 HA 策略 = **快速恢复能力**(备份 + 恢复演练 + 运维手册),
+而不是冗余部署。冗余的成本(至少 3 台机器 + 运维复杂度)远超当前业务规模所需。
+
+## 2. 数据备份
+
+### 2.1 备份什么(以及为什么这样分)
+
+| 数据 | 体积(实测) | 丢失后果 | 策略 |
+|------|-----------|----------|------|
+| Gitea PostgreSQL | 1.7MB | **不可重建**:用户、权限、PR、Issue、webhook | **每日必备** |
+| Jenkins 配置 | 18KB | 作业定义、凭据、用户 | **每日必备** |
+| Jenkins plugins | 265M | 可从清单重装 | **不备份**,导出清单 |
+| Gitea 裸库 | 939M | Git 分布式,每份 clone 都是完整历史 | **每周全量** + 异地 |
+| jenkins_home/war | 111M | 镜像自带 | 不备份 |
+
+> 关键取舍:Jenkins 薄备份原本 222MB,**99% 是 `plugins/`**。
+> 排除后降到 18KB,磁盘压力从"不可持续"变为"可忽略"。
+> 恢复时用 `jenkins-plugins-.txt` + `jenkins-plugin-cli` 重装即可。
+
+### 2.2 备份脚本
+
+[`backup-cicd.sh`](../../../infra/cicd/backup/backup-cicd.sh) 的关键设计:
+
+```bash
+# 每份产物落盘后立刻校验:非空 + 归档可列出,坏备份当场失败
+verify_archive "$f" tar
+
+# 磁盘预检:剩余 < MIN_FREE_GB 直接中止(防写满拖垮 nginx)
+if (( FREE_GB < MIN_FREE_GB )); then fail "..."; fi
+
+# Jenkins 归档容忍退出码 1("读取时文件变化"),>=2 才算失败
+if (( TAR_RC >= 2 )); then fail "..."; fi
+```
+
+实测输出(首份全量备份):
+
+```
+✅ gitea-db-20260914_174059.sql.gz 1.7MB
+✅ gitea-repos-20260914_174059.tar.gz 939MB (3177 条目)
+✅ jenkins-config-20260914_174059.tar.gz 222MB (2829 条目)
+✅ 凭据解密所需的 master key 与 hudson.util.Secret 均已包含
+```
+
+### 2.3 调度(已安装到生产 crontab)
+
+```
+30 3 * * 1-6 backup-cicd.sh --skip-repos # 周一至周六:薄(~2MB)
+30 3 * * 0 backup-cicd.sh # 周日:全量(~940MB)
+```
+
+保留策略**按类型分开计数**(`BACKUP_FULL_RETENTION=1`、`BACKUP_THIN_RETENTION=3`):
+
+> 踩坑记录:若统一按「最近 N 份」裁剪,周中的薄备份会把周日全量挤出窗口,
+> 仓库实际只受保护 3 天。分层计数后才真正保住全量。
+
+### 2.4 恢复
+
+[`restore-cicd.sh`](../../../infra/cicd/backup/restore-cicd.sh) 内置三道防线:
+
+1. 归档完整性校验(tar/gzip 可列出)
+2. **`secrets/master.key` 存在性校验** —— 缺失则直接拒绝恢复
+ (否则恢复完所有 Jenkins 凭据都解不开,等于白做)
+3. 恢复前先对当前库做一份快照
+
+```bash
+./restore-cicd.sh --from /home/novalon/backups/cicd/ --component jenkins --dry-run
+./restore-cicd.sh --from /home/novalon/backups/cicd/ --component jenkins
+```
+
+**铁律:未做过恢复演练的备份等于没有备份。** 每季度至少跑一次 `--dry-run`。
+
+### 2.5 ⚠️ 当前最大缺口:仅本地备份
+
+同盘备份不抗主机故障(盘坏/被删/勒索 = 全没)。脚本会持续警告:
+
+```
+⚠️ 未配置 RCLONE_REMOTE —— 备份仅存本地。同盘备份不抗主机故障。
+```
+
+**待办(需要决策)**:
+- 配置对象存储(COS/OSS/S3 任一),`rclone` 安装后在 `.env` 填 `RCLONE_REMOTE`
+- 或扩容磁盘后仍建议至少一份异地
+
+## 3. 日志
+
+### 3.1 已知事故:849MB 单份日志
+
+2026-09-14 实测:`novalon-cicd/docker-compose.yml` 里明确配了
+`logging: { max-size: 10m, max-file: 3 }`,但运行中容器的
+`HostConfig.LogConfig` 实际是 `{"Type":"json-file","Config":{}}` —— **空配置**。
+
+根因:**logging 参数在容器创建时固化**,事后改 compose 不重建就不生效。
+Gitea 日志因此涨到 849MB,占整盘 4%。
+
+已做的两件事:
+1. 立即 `truncate -s 0` 释放(磁盘 85% → 81%)
+2. 兜底 cron 每日 05:10 跑 [`truncate-container-logs.sh`](../../../infra/cicd/monitoring/truncate-container-logs.sh)
+
+**终态修复**(待维护窗口):用 `infra/cicd/docker-compose.gitea.yml` 重建容器,
+使 logging 配置真正生效,之后兜底脚本可退化为纯监控。
+
+### 3.2 日志留存
+
+| 来源 | 位置 | 轮转 |
+|------|------|------|
+| 容器 stdout | `/var/lib/docker/containers//*-json.log` | 重建后 10m×3;兜底 cron 每日截断 >100MB |
+| nginx access/error | `/var/log/nginx/*.log` | docker-compose-nginx.yml 挂载宿主 |
+| 备份 | `/var/log/cicd-backup.log` | 无(体积小,建议加 logrotate) |
+| 健康检查 | `/var/log/cicd-healthcheck.log` | 无(同上) |
+
+## 4. 监控
+
+### 4.1 healthcheck-cicd.sh(每 10 分钟)
+
+6 个维度,全部实测可跑:
+
+```
+[1] 容器状态 gitea/jenkins/postgresql/nginx 的 healthy
+[2] HTTPS 端点 三个域名 200/303
+[3] TLS 证书 剩余天数(<21 天 WARN,<7 天 FAIL)
+[4] 磁盘与卷 使用率(>=80 WARN,>=90 FAIL)+ jenkins_home 体积
+[5] 备份新鲜度 最新备份距今 <36h + 产物数 >=2
+[6] Jenkins 队列 积压 >10 任务 FAIL
+```
+
+实测首跑即抓到 2 个真问题(容器 healthcheck 判定 bug 已修 + 磁盘 90%)。
+
+```bash
+# FAIL 时 exit 1,cron 可直接接告警
+./healthcheck-cicd.sh # 全量输出
+./healthcheck-cicd.sh --quiet # 只在 FAIL/WARN 时输出
+./healthcheck-cicd.sh --json # 供采集器
+```
+
+### 4.2 告警
+
+`.env` 配 `ALERT_WEBHOOK_URL`(企业微信/钉钉 webhook)后,
+健康检查 FAIL 和备份失败都会推送。
+
+### 4.3 现有监控资产
+
+`scripts/monitoring/` 已有轻量方案(`setup-lightweight-monitoring.sh` 等),
+本次未改动。若要上 Prometheus,注意这台机器磁盘余量,指标留存周期要设短。
+
+## 5. 已知环境陷阱(排障速查)
+
+| 症状 | 真因 | 处理 |
+|------|------|------|
+| compose 配了资源限制但没生效 | `deploy:` 段非 swarm 下被忽略 | 用 `cpus`/`mem_limit` |
+| compose 配了日志轮转但没生效 | logging 参数创建时固化 | 重建容器 |
+| `grep -q` 在 `set -o pipefail` 下恒假 | `-q` 提前退出 → 上游 SIGPIPE(141) | 用 herestring 或 `grep -c` |
+| 函数做布尔用却返回非 0 | `f() { [[ cond ]] && echo; }` 在 cond 为假时返回 1,`cond2 && f \|\| g` 会跌进 g | 函数末尾显式 `return 0` |
+| 健康检查 FAIL 数随 `--quiet`/`--json` 抖动 | 同上:输出被抑制 → 助手函数返回 1 | 同上 |
+| `curl -w '%{http_code}' \|\| echo 000` 得到 `200000` | curl 已输出 200 但退出码非 0,`echo` 追加拼接 | 用 `|| true` + 正则校验三位数字 |
+| tar 报错但文件都存在 | 归档列表里写了不存在的路径(如 `nodes/`) | 归档 `.` + `--exclude` |
+| `for x in "$@"` 内 `shift` 取错值 | for 迭代的是展开后的固定列表,shift 不影响序列 | 用 `while [[ $# -gt 0 ]]` 解析 |
+| SSH 备份脚本中途死掉 | 断连触发 SIGHUP | cron 场景无此问题;手动跑用 `setsid` |
+| `/root` 占 2.7G | `/root/.trae-cn-server`(bin 2.1G + manager-logs 522M) | 属 AI 工具数据,清理需 owner 确认 |
diff --git a/docs/deployment/cicd/03-credential-incident.md b/docs/deployment/cicd/03-credential-incident.md
new file mode 100644
index 0000000..bca3d3d
--- /dev/null
+++ b/docs/deployment/cicd/03-credential-incident.md
@@ -0,0 +1,99 @@
+# 03 · P0 凭据入库事故(待处置)
+
+> **状态:待执行。** 本文档是处置手册,不是已完成的整改记录。
+> 2026-09-14 由会话内审计发现,张翔已确认「出方案、由他执行」。
+
+## 1. 事故定性
+
+**真实生产凭据已提交进 git 并推送到公网可达的远程仓库。**
+
+- 远程:`https://git.f.novalon.cn/novalon/novalon-website.git`(公网可达,实测 HTTP 200)
+- 泄露物:Gitea 登录密码、Jenkins admin 密码、Gitea OAuth2 应用 secret、CI webhook token
+
+**任何能访问该仓库的人都可以直接登录生产 Gitea 与 Jenkins。**
+即使是私有仓库,也等效于把密码写进了版本历史 —— 无法撤销,只能轮换。
+
+## 2. 泄露位置(实测)
+
+| 文件 | 泄露内容 | 首次入库 |
+|------|----------|----------|
+| `scripts/setup-cicd/cicd.config` | `GITEA_PASSWORD`、`JENKINS_ADMIN_PASSWORD`、`WEBHOOK_TOKEN` | `b8ab1fd` |
+| `scripts/setup-registry-auth.sh` | Gitea OAuth2 应用 secret(`gto_…`,共 48 字符) | 同期 |
+| `novalon-cicd/docker-compose.yml` | PostgreSQL 密码(仅服务器,未入库) | — |
+
+确认命令(可复现):
+
+```bash
+git ls-files --error-unmatch scripts/setup-cicd/cicd.config # → 已跟踪
+git log --oneline -- scripts/setup-cicd/cicd.config # → af57504, b8ab1fd
+grep -rn "PASSWORD\s*=" scripts/setup-cicd/ scripts/setup-registry-auth.sh
+```
+
+## 3. 处置步骤(按序执行)
+
+工具已备好:[`infra/cicd/security/`](../../../infra/cicd/security/)
+
+### Step 1 — 轮换凭据(先做,别等清史)
+
+```bash
+ssh root@139.155.109.62 \
+ 'cd /home/novalon/docker-app/infra/cicd/security && ./rotate-credentials.sh --dry-run'
+# 确认无误后去掉 --dry-run 实际执行
+```
+
+脚本会:
+1. 生成强随机新凭据(`openssl rand`)
+2. 轮换 Gitea 用户密码(`gitea admin user change-password`)
+3. 吊销泄露的 OAuth2 应用
+4. 签发新的**只读**(`read:repository`)Gitea 令牌
+5. Jenkins admin 密码经 `init.groovy.d` 在下次重启生效(无需已有管理员凭据)
+6. 写回服务器 `.env`(600 权限)
+
+**⚠️ 执行后旧凭据立即失效**,请在无人使用 CI/CD 的时间窗操作。
+
+### Step 2 — 清除 git 历史
+
+```bash
+pip install git-filter-repo # 或 brew install git-filter-repo
+cd /path/to/novalon-website
+./infra/cicd/security/purge-git-history.sh --dry-run # 先扫描确认命中数
+./infra/cicd/security/purge-git-history.sh # 会先做 mirror 备份
+```
+
+脚本做了三重保护:
+- 改写前自动 `git clone --mirror` 备份
+- 二次确认(输入 `PURGE`)
+- 清史后重新扫描,仍有残留则**拒绝推送**并中止
+
+**强制推送的连带影响(必须提前通知)**:
+- 所有协作者必须 `rm -rf` 本地仓库后重新 clone —— 旧仓库 push 会把泄露内容带回
+- 未合入的 PR / fork 可能失效
+
+### Step 3 — 删除远程残留对象
+
+清史后远程的旧 blob 仍可通过 commit sha 直接访问:
+
+```
+Gitea → 仓库 → 设置 → 维护 → 运行垃圾回收
+```
+
+### Step 4 — 防复发
+
+- 删除 `scripts/setup-cicd/cicd.config`,改用 `.env` + `.env.example` 模式
+- 装秘密扫描钩子(gitleaks / trufflehog),`pre-commit` + CI 双层
+- 把「凭据只进 `.env`(600)或 Jenkins Credentials」写进 code review 检查项
+
+## 4. 验收标准(执行完必须逐条核对)
+
+- [ ] `git grep` 全历史无明文密码(`git log -p | grep -c` 为 0)
+- [ ] 旧密码登录 Gitea/Jenkins 均失败
+- [ ] 新令牌权限为 `read:repository`,Jenkins 能正常拉代码
+- [ ] 一次 push 能触发流水线(验证凭据替换没破坏链路)
+- [ ] Gitea OAuth2 应用列表中无泄露的 `gto_…` 应用
+- [ ] 服务器 `.env` 权限为 600,且已加入 `.gitignore`
+
+## 5. 为什么「清史」不能替代「轮换」
+
+历史可能已被 clone / fork / 缓存 / 搜索引擎抓取,清除是**尽力而为**。
+密码一旦进过公网仓库,唯一可靠的补救是**让它失效**。
+所以顺序是:先轮换(让泄露的密码立即变成废纸),再清史(降低持续暴露面)。
diff --git a/docs/deployment/cicd/README.md b/docs/deployment/cicd/README.md
new file mode 100644
index 0000000..a03de91
--- /dev/null
+++ b/docs/deployment/cicd/README.md
@@ -0,0 +1,86 @@
+# CI/CD 生产环境:Gitea + Jenkins 运维文档
+
+> 基准日期:2026-09-14。本文所有数据来自对生产服务器 `139.155.109.62` 的实测(SSH 探测 + HTTP 探测),
+> 非泛泛的最佳实践摘抄。配置源码在 [`infra/cicd/`](../../../infra/cicd/)。
+
+## 文档索引
+
+| 文档 | 内容 |
+|------|------|
+| [01-installation.md](01-installation.md) | 安装部署、安全加固、Gitea↔Jenkins 集成、webhook 排障 |
+| [02-ha-backup-monitoring.md](02-ha-backup-monitoring.md) | 高可用、备份与恢复、日志与监控 |
+| [03-credential-incident.md](03-credential-incident.md) | **P0**:凭据入库事故与处置清单(待执行) |
+
+## 当前架构(实测)
+
+```
+ ┌────────────────────────────────────┐
+ │ 139.155.109.62 (VM-0-15-debian) │
+ │ Debian · Docker 20.10.24 │
+ │ │
+ 推送 git push ────────┐│ novalon-nginx-secure (nginx:alpine)│
+ │ :80/:443 终止 TLS + HSTS │
+ │ └──────┬──────────────┬──────────────┘
+ │ │ │
+ │ git.f.novalon.cn ci.f.novalon.cn
+ │ │ │
+ │ upstream gitea_app upstream jenkins_app
+ │ │ │
+ │ ┌───────▼─────┐ ┌─────▼──────┐
+ │ │ gitea │ │ jenkins │
+ └─────────►│ 1.27.2 │ │ 2.568.1 │
+ │ :3001(本机) │ │ :8080(本机) │
+ │ :2222 SSH │ └─────┬──────┘
+ └───────┬─────┘ │
+ │ │ rsync+ssh
+ ┌───────▼─────┐ │
+ │ postgresql │ ▼
+ │ 17-alpine │ /home/novalon/docker-app/
+ └─────────────┘ novalon-website(Next.js)
+```
+
+- 代码仓库:`git.f.novalon.cn/novalon/novalon-website`(本仓库 `origin` 即指向此处)
+- 流水线:`Jenkinsfile`(仓库根目录),作业名 `novalon-website-ci-cd`
+- 触发:Gitea push → webhook → Jenkins `GenericTrigger` → 构建/测试 → `scripts/deploy.sh` rsync 发布
+- 反代与 TLS:`nginx-static-production.conf` 定义 upstream,`conf.d/*.conf` 定义 server 块
+
+## 2026-09-14 审计结论
+
+### 已就绪(无需改动)
+
+| 项 | 实测证据 |
+|----|----------|
+| HTTPS + HSTS | 两域名均 `strict-transport-security: max-age=63072000` |
+| Gitea Cookie 安全 | `i_like_gitea` 带 `HttpOnly; Secure; SameSite=Lax` |
+| 作业与插件齐备 | `gitea`、`generic-webhook-trigger`、`credentials-binding` 已装 |
+| 反向代理信任链 | `REVERSE_PROXY_TRUSTED_PROXIES=172.20.0.0/16,127.0.0.1` |
+| 服务端口不外暴 | Gitea/Jenkins HTTP 均仅 `127.0.0.1`,只有 nginx 占 80/443 |
+
+### 发现的问题(按严重度)
+
+| 级别 | 问题 | 证据 | 状态 |
+|------|------|------|------|
+| **P0** | 真实凭据已提交 git 并推送公网 | `cicd.config` Gitea/Jenkins 密码、OAuth2 secret | 待处置([03](03-credential-incident.md)) |
+| **P0** | Jenkins 从未备份 | `backup.sh` 存在但 crontab 无此项,`/home/novalon/backups/` 不存在 | ✅ 已修复 |
+| **P1** | 磁盘 20G 用 85–90% | `df -h /` → 88% used, 2.4G avail | ⚠️ 需扩容/异地 |
+| **P1** | Jenkins 以 root 运行 + 挂 docker.sock + /root/.ssh | compose `user: root`;作业实测 0 处 docker 调用 | 配置已备好,待维护窗口 |
+| **P1** | Gitea SSH 克隆地址不可用 | `app.ini SSH_PORT=22`,实际暴露 `0.0.0.0:2222` | 配置已备好 |
+| **P2** | 容器日志轮转未生效 | Gitea 单份日志 849MB(compose 配置在,运行容器 `LogConfig={}`) | ✅ 已截断+兜底 cron |
+| **P2** | Gitea 数据库零备份 | `postgresql_backup/` 为空 | ✅ 已修复 |
+| **P3** | 安全头重复注入 | Gitea 响应 `x-frame-options` 出现两次(nginx + 应用) | 低优先 |
+
+## 快速操作
+
+```bash
+# 备份(薄:仅 DB + Jenkins 配置,约 2MB)
+ssh root@139.155.109.62 '/home/novalon/docker-app/infra/cicd/backup/backup-cicd.sh --skip-repos'
+
+# 备份(全量:含 Gitea 裸库,约 940MB)
+ssh root@139.155.109.62 '/home/novalon/docker-app/infra/cicd/backup/backup-cicd.sh'
+
+# 健康检查(6 维度,FAIL 时 exit 1)
+ssh root@139.155.109.62 '/home/novalon/docker-app/infra/cicd/monitoring/healthcheck-cicd.sh'
+
+# 已装的定时任务
+ssh root@139.155.109.62 'crontab -l | grep infra/cicd'
+```
diff --git a/infra/cicd/.env.example b/infra/cicd/.env.example
new file mode 100644
index 0000000..17931dc
--- /dev/null
+++ b/infra/cicd/.env.example
@@ -0,0 +1,48 @@
+# ============================================================
+# CI/CD 环境变量模板
+#
+# ⚠️ 复制为 .env 后填写真实值。.env 已在 .gitignore 中,绝不允许入库。
+# ⚠️ 2026-09-14 曾发生凭据入库事故(scripts/setup-cicd/cicd.config),
+# 本文件即该事故的整改产物。任何密码/令牌只允许出现在这里或 Jenkins Credentials。
+#
+# 生成强随机值:
+# openssl rand -base64 24 # 密码
+# openssl rand -hex 32 # 令牌/密钥
+# ============================================================
+
+# ---------- Gitea ----------
+GITEA_ROOT_URL=https://git.f.novalon.cn
+GITEA_SSH_DOMAIN=git.f.novalon.cn
+GITEA_DB_NAME=gitea
+GITEA_DB_USER=gitea
+# 至少 24 位;与 app.ini [database] PASSWD 必须一致
+GITEA_DB_PASSWORD=__CHANGE_ME__
+
+# ---------- Jenkins ----------
+JENKINS_ADMIN_USER=zhangxiang
+# 至少 16 位;JCasC 首次启动写入,之后改此值需同步改 casc.yaml 逻辑
+JENKINS_ADMIN_PASSWORD=__CHANGE_ME__
+
+# Gitea 个人访问令牌(Jenkins 拉取代码用)
+# 生成:Gitea -> 设置 -> 应用 -> 生成新令牌,权限仅勾 read:repository
+GITEA_TOKEN=__CHANGE_ME__
+
+# 部署用 SSH 私钥口令(无口令填空)
+DEPLOY_SSH_KEY_PASSPHRASE=
+
+# ---------- 备份 ----------
+BACKUP_ROOT=/home/novalon/backups/cicd
+# 本地保留份数(磁盘 20G 已用 85%,务必配异地)
+BACKUP_RETENTION=3
+# 单份备份告警阈值
+BACKUP_MIN_FREE_GB=3
+# 异地同步开关:留空则不同步(强烈建议配置)
+# 例:rclone remote 名 + 路径
+RCLONE_REMOTE=
+RCLONE_PATH=novalon-cicd-backup
+
+# ---------- 告警 ----------
+# 失败时推送的 Webhook(企业微信/钉钉);留空则不推送
+ALERT_WEBHOOK_URL=
+BACKUP_FULL_RETENTION=1
+BACKUP_THIN_RETENTION=3
diff --git a/infra/cicd/backup/backup-cicd.sh b/infra/cicd/backup/backup-cicd.sh
new file mode 100755
index 0000000..7031958
--- /dev/null
+++ b/infra/cicd/backup/backup-cicd.sh
@@ -0,0 +1,318 @@
+#!/usr/bin/env bash
+# ============================================================
+# Novalon CI/CD 统一备份脚本
+#
+# 覆盖:
+# 1. Gitea PostgreSQL 库(pg_dump,custom format)
+# 2. Gitea 仓库裸库(/data/git/repositories)
+# 3. Jenkins 配置薄备份(排除 builds/ workspace/ 缓存,保留作业定义与凭据)
+#
+# 设计要点:
+# - 每份产物落盘后立刻校验(大小非零 + 归档可列出),坏备份当场失败而不是等到恢复时才发现
+# - 磁盘预检:剩余空间不足直接中止并告警(宿主 20G 已用 85%,写满会连带拖垮 nginx)
+# - 异地同步可选:配了 RCLONE_REMOTE 才同步,否则明确告警"仅本地"
+# - 全链路日志,失败走 ALERT_WEBHOOK_URL
+#
+# 用法:
+# ./backup-cicd.sh # 标准备份(库 + 仓库 + Jenkins 配置)
+# ./backup-cicd.sh --full # 追加 Gitea LFS/附件/头像
+# ./backup-cicd.sh --dry-run # 只预检,不产出
+# ./backup-cicd.sh --jenkins-only # 只备份 Jenkins
+#
+# crontab(每天 03:30,见 docs/deployment/cicd/04-ha-backup-monitoring.md):
+# 30 3 * * * /home/novalon/docker-app/infra/cicd/backup/backup-cicd.sh >> /var/log/cicd-backup.log 2>&1
+# ============================================================
+
+set -Eeuo pipefail
+
+# ---------- 配置 ----------
+DEPLOY_ROOT="${DEPLOY_ROOT:-/home/novalon/docker-app}"
+ENV_FILE="${ENV_FILE:-$DEPLOY_ROOT/infra/cicd/.env}"
+BACKUP_ROOT="${BACKUP_ROOT:-/home/novalon/backups/cicd}"
+RETENTION="${BACKUP_RETENTION:-3}" # 兼容旧变量
+FULL_RETENTION="${BACKUP_FULL_RETENTION:-1}" # 全量保留份数(含 Gitea 仓库)
+THIN_RETENTION="${BACKUP_THIN_RETENTION:-3}" # 薄保留份数(仅 DB + Jenkins 配置)
+MIN_FREE_GB="${BACKUP_MIN_FREE_GB:-3}"
+RCLONE_REMOTE="${RCLONE_REMOTE:-}"
+RCLONE_PATH="${RCLONE_PATH:-novalon-cicd-backup}"
+ALERT_WEBHOOK_URL="${ALERT_WEBHOOK_URL:-}"
+
+GITEA_CONTAINER="${GITEA_CONTAINER:-gitea}"
+PG_CONTAINER="${PG_CONTAINER:-postgresql}"
+JENKINS_CONTAINER="${JENKINS_CONTAINER:-jenkins}"
+GITEA_DB_NAME="${GITEA_DB_NAME:-gitea}"
+GITEA_DB_USER="${GITEA_DB_USER:-gitea}"
+
+FULL=0
+DRY_RUN=0
+JENKINS_ONLY=0
+SKIP_REPOS=0
+
+# ---------- 参数 ----------
+while [[ $# -gt 0 ]]; do
+ case "$1" in
+ --full) FULL=1; shift ;;
+ --dry-run) DRY_RUN=1; shift ;;
+ --jenkins-only) JENKINS_ONLY=1; shift ;;
+ # 薄备份:跳过 Gitea 裸库(939MB,占单份 78%)。
+ # 依据:Git 仓库天然分布式,每份 clone 都是完整历史;
+ # 而 PostgreSQL 里的用户/权限/PR/Issue/Webhook 是唯一副本、不可重建。
+ # 故磁盘受限时优先保 DB,仓库交给「异地全量」或开发者本地副本。
+ --skip-repos) SKIP_REPOS=1; shift ;;
+ -h|--help) sed -n '2,34p' "$0"; exit 0 ;;
+ *) echo "未知参数: $1" >&2; exit 2 ;;
+ esac
+done
+
+# ---------- 加载 .env(若存在) ----------
+if [[ -f "$ENV_FILE" ]]; then
+ # shellcheck disable=SC1090
+ set -a; source "$ENV_FILE"; set +a
+ BACKUP_ROOT="${BACKUP_ROOT:-/home/novalon/backups/cicd}"
+ RETENTION="${BACKUP_RETENTION:-3}"
+ FULL_RETENTION="${BACKUP_FULL_RETENTION:-1}"
+ THIN_RETENTION="${BACKUP_THIN_RETENTION:-3}"
+ MIN_FREE_GB="${BACKUP_MIN_FREE_GB:-3}"
+ RCLONE_REMOTE="${RCLONE_REMOTE:-}"
+ RCLONE_PATH="${RCLONE_PATH:-novalon-cicd-backup}"
+ ALERT_WEBHOOK_URL="${ALERT_WEBHOOK_URL:-}"
+fi
+
+TS="$(date +%Y%m%d_%H%M%S)"
+STAMP_DIR="$BACKUP_ROOT/$TS"
+LOG_PREFIX="[backup $TS]"
+
+log() { echo "$LOG_PREFIX $*"; }
+fail() { echo "$LOG_PREFIX ❌ $*" >&2; alert "备份失败: $*"; exit 1; }
+
+alert() {
+ local msg="$1"
+ [[ -z "$ALERT_WEBHOOK_URL" ]] && return 0
+ curl -fsS -m 10 -X POST "$ALERT_WEBHOOK_URL" \
+ -H 'Content-Type: application/json' \
+ -d "{\"msgtype\":\"text\",\"text\":{\"content\":\"[CI/CD备份告警] $msg ($(hostname))\"}}" \
+ >/dev/null 2>&1 || true
+}
+
+# 任何未捕获错误都触发告警
+trap 'fail "第 $LINENO 行发生未捕获错误"' ERR
+
+require_container() {
+ local name="$1"
+ docker inspect "$name" >/dev/null 2>&1 || fail "容器不存在: $name"
+ local st; st="$(docker inspect -f '{{.State.Running}}' "$name" 2>/dev/null || echo false)"
+ [[ "$st" == "true" ]] || fail "容器未运行: $name"
+}
+
+# 归档完整性校验:非空 + tar/zip 可列出
+verify_archive() {
+ local f="$1" kind="$2"
+ [[ -s "$f" ]] || fail "产物为空: $f"
+ local sz; sz=$(stat -c %s "$f")
+ (( sz > 1024 )) || fail "产物过小($sz B),判定失败: $f"
+ case "$kind" in
+ tar) tar -tzf "$f" >/dev/null 2>&1 || fail "tar 校验失败(归档损坏): $f" ;;
+ sql) gzip -t "$f" >/dev/null 2>&1 || fail "gzip 校验失败: $f" ;;
+ esac
+ log "✅ 校验通过 $(basename "$f") ($(numfmt --to=iec --suffix=B "$sz" 2>/dev/null || echo "${sz}B"))"
+}
+
+# ============================================================
+log "========== CI/CD 备份开始 =========="
+log "目标目录: $STAMP_DIR 保留份数: $RETENTION 全量: $FULL 跳过仓库: $SKIP_REPOS dry-run: $DRY_RUN"
+
+# ---------- 预检 ----------
+command -v docker >/dev/null || fail "docker 不可用"
+
+FREE_GB=$(df -P "$BACKUP_ROOT" 2>/dev/null | awk 'NR==2{print int($4/1048576)}' \
+ || df -P / | awk 'NR==2{print int($4/1048576)}')
+log "备份卷可用空间: ${FREE_GB}GB(阈值 ${MIN_FREE_GB}GB)"
+if (( FREE_GB < MIN_FREE_GB )); then
+ fail "可用空间 ${FREE_GB}GB < 阈值 ${MIN_FREE_GB}GB,中止备份以免写满磁盘。请清理或扩容。"
+fi
+
+if (( JENKINS_ONLY == 0 )); then
+ require_container "$PG_CONTAINER"
+ require_container "$GITEA_CONTAINER"
+fi
+require_container "$JENKINS_CONTAINER"
+
+if (( DRY_RUN == 1 )); then
+ log "dry-run:预检全部通过,未产出备份。"
+ exit 0
+fi
+
+mkdir -p "$STAMP_DIR"
+
+# ============================================================
+# 1. PostgreSQL —— Gitea 元数据(用户/权限/PR/Issue/Webhook)
+# ============================================================
+if (( JENKINS_ONLY == 0 )); then
+ log "----- [1/3] PostgreSQL 导出 -----"
+ DB_DUMP="$STAMP_DIR/gitea-db-$TS.sql.gz"
+ if docker exec "$PG_CONTAINER" pg_dump -U "$GITEA_DB_USER" -d "$GITEA_DB_NAME" -Fc \
+ | gzip -9 > "$DB_DUMP"; then
+ verify_archive "$DB_DUMP" sql
+ else
+ rm -f "$DB_DUMP"; fail "pg_dump 执行失败"
+ fi
+
+ # ============================================================
+ # 2. Gitea 仓库裸库
+ # ============================================================
+ log "----- [2/3] Gitea 仓库导出 -----"
+ REPO_TAR="$STAMP_DIR/gitea-repos-$TS.tar.gz"
+ if (( SKIP_REPOS == 1 )); then
+ log "⏭ 已跳过仓库导出(--skip-repos):本次仅保 DB 与 Jenkins 配置"
+ log "ℹ️ 仓库是 Git 分布式的,但**不要长期依赖**此项跳过 —— 需有异地全量或开发者副本兜底"
+ elif [[ "$FULL" == "1" ]]; then
+ # 全量:仓库 + LFS + 附件 + 头像
+ docker run --rm --volumes-from "$GITEA_CONTAINER" -v "$STAMP_DIR:/out" alpine \
+ tar czf "/out/$(basename "$REPO_TAR")" -C /data git 2>/dev/null \
+ || fail "Gitea 全量归档失败"
+ else
+ # 标准:仅裸库(LFS/附件可从对象存储或后续补,磁盘优先)
+ docker run --rm --volumes-from "$GITEA_CONTAINER" -v "$STAMP_DIR:/out" alpine \
+ sh -c 'tar czf /out/'"$(basename "$REPO_TAR")"' -C /data git/repositories' \
+ || fail "Gitea 仓库归档失败"
+ verify_archive "$REPO_TAR" tar
+ fi
+fi
+
+# ============================================================
+# 3. Jenkins 薄备份
+# !关键:排除 builds/ workspace/ caches/ logs —— 这些占空间且可从作业定义重建
+# !但必须包含 credentials.xml 与 secrets/(否则恢复后凭据全丢)
+# ============================================================
+log "----- [3/3] Jenkins 配置薄备份 -----"
+JENKINS_TAR="$STAMP_DIR/jenkins-config-$TS.tar.gz"
+# 归档整个 JENKINS_HOME 并排除可重建项,不再写死具体路径
+# (曾踩坑:nodes/ 在部分版本不存在,tar 直接以非 0 退出导致整段备份失败)
+# 体积参考(2026-09-14 实测):plugins 265M / war 111M / workspace 49M / updates 3.4M
+# war、workspace、updates 均可从镜像或 SCM 重建,排除后单份约 80M
+# ⚠️ ./plugins 也必须排除:实测它占薄备份 222MB 中的绝大部分,
+# 而插件可由同目录导出的 jenkins-plugins-.txt 精确重装(jenkins-plugin-cli --plugin-file)
+# 排除后薄备份从 222MB 降到个位数 MB,磁盘受限场景下这是决定性差异
+set +e
+docker run --rm --volumes-from "$JENKINS_CONTAINER" -v "$STAMP_DIR:/out" alpine \
+ sh -c '
+ cd /var/jenkins_home
+ tar czf /out/'"$(basename "$JENKINS_TAR")"' \
+ --exclude="./jobs/*/builds" \
+ --exclude="./jobs/*/workspace" \
+ --exclude="./jobs/*/workspace@tmp" \
+ --exclude="./jobs/*/last*" \
+ --exclude="./jobs/*/nextBuildNumber" \
+ --exclude="./workspace" \
+ --exclude="./workspace@tmp" \
+ --exclude="./plugins" \
+ --exclude="./war" \
+ --exclude="./caches" \
+ --exclude="./updates" \
+ --exclude="./logs" \
+ --exclude="./.cache" \
+ --exclude="./tools" \
+ --exclude="./.java" \
+ . 2>/dev/null
+ '
+TAR_RC=$?
+set -e
+# GNU/busybox tar:0 成功;1 = "读取时文件发生变化"(Jenkins 在运行,属正常);>=2 才是致命错误
+if (( TAR_RC >= 2 )); then
+ fail "Jenkins 归档失败(tar 退出码 $TAR_RC)"
+elif (( TAR_RC == 1 )); then
+ log "ℹ️ tar 退出码 1:归档过程中有文件变化(Jenkins 在运行),属可接受的警告"
+fi
+verify_archive "$JENKINS_TAR" tar
+
+# 单独导出已安装插件清单,便于重建
+docker exec "$JENKINS_CONTAINER" sh -c \
+ 'ls /var/jenkins_home/plugins/*.jpi 2>/dev/null | xargs -n1 basename 2>/dev/null | sed "s/\.jpi$//"' \
+ > "$STAMP_DIR/jenkins-plugins-$TS.txt" 2>/dev/null || true
+
+# ============================================================
+# 校验:密钥文件必须存在,否则这份备份恢复不出来
+# ============================================================
+# Jenkins 2.5xx 起凭据解密依赖 secrets/master.key + secrets/hudson.util.Secret;
+# 旧版本(<2.4xx)才是根目录的 secret.key。三者取其二即可恢复,必须至少有 master.key。
+# (曾踩坑:只按旧版路径 secret.key 判定,在 2.568.1 上误报"无法解密")
+J_LIST="$(tar -tzf "$JENKINS_TAR" 2>/dev/null || true)"
+# ⚠️ 必须用 herestring 而非管道:grep -q 命中即退出会关闭管道,
+# 上游 printf 收到 SIGPIPE(141),在 set -o pipefail 下整条管道被判失败 -> 误报"密钥缺失"
+if grep -qE '(^|/)(secrets/master\.key|secret\.key)$' <<< "$J_LIST"; then
+ if grep -qE '(^|/)(secrets/hudson\.util\.Secret|secret\.key\.not-so-secret)$' <<< "$J_LIST"; then
+ log "✅ 凭据解密所需的 master key 与 hudson.util.Secret 均已包含"
+ else
+ log "⚠️ 找到 master key 但缺少 hudson.util.Secret —— 恢复后部分凭据可能不可解密"
+ fi
+else
+ fail "归档缺少 master key(secrets/master.key 或 secret.key)—— 恢复后所有 Jenkins 凭据将不可解密"
+fi
+
+# 生成清单
+cat > "$STAMP_DIR/MANIFEST.txt" </dev/null || echo n/a)
+Jenkins: $(docker exec "$JENKINS_CONTAINER" sh -c 'java -jar /usr/share/jenkins/jenkins.war --version' 2>/dev/null || echo n/a)
+Postgres: $(docker inspect -f '{{.Config.Image}}' "$PG_CONTAINER" 2>/dev/null || echo n/a)
+模式: $( if (( SKIP_REPOS == 1 )); then echo thin-db-only; elif [[ "$FULL" == "1" ]]; then echo full; else echo standard; fi )
+注: Jenkins 归档已排除 plugins/(可重建)。恢复后执行:
+ jenkins-plugin-cli --plugin-file jenkins-plugins-.txt
+ 或在 Manage Jenkins -> Plugins 按清单重装。
+产物:
+$(ls -lh "$STAMP_DIR" | awk 'NR>1{print " " $9 " " $5}')
+EOF
+
+# ============================================================
+# 异地同步
+# ============================================================
+if [[ -n "$RCLONE_REMOTE" ]]; then
+ if command -v rclone >/dev/null 2>&1; then
+ log "同步到异地: $RCLONE_REMOTE:$RCLONE_PATH/$TS"
+ rclone copy "$STAMP_DIR" "$RCLONE_REMOTE:$RCLONE_PATH/$TS" --transfers 2 \
+ || log "⚠️ 异地同步失败(本地备份仍然有效)"
+ else
+ log "⚠️ 已配置 RCLONE_REMOTE 但未安装 rclone,跳过异地同步"
+ fi
+else
+ log "⚠️ 未配置 RCLONE_REMOTE —— 备份仅存本地。同盘备份不抗主机故障,强烈建议配置异地。"
+fi
+
+# ============================================================
+# 保留策略 —— 全量与薄分开计数
+# (踩坑:若统一按「最近 N 份」裁剪,周中的薄备份会把周日全量挤出窗口,
+# 导致 Gitea 仓库只受保护 3 天,等于没有全量)
+# FULL_RETENTION 份全量(含仓库) + THIN_RETENTION 份薄(不含仓库)
+# ============================================================
+log "清理过期备份(全量保留 ${FULL_RETENTION} 份,薄保留 ${THIN_RETENTION} 份)..."
+declare -A KEEP=()
+
+# 取「最近的 N 份全量 / 薄」目录
+# ⚠️ 循环体内用 if 而非 `cond && echo`:set -e 下条件为假会使子 shell 以非 0 退出,
+# 导致进程替换提前终止(曾致第 302 行报"未捕获错误")
+keep_mark() {
+ local want="$1" mode="$2" d found
+ for d in "$BACKUP_ROOT"/[0-9]*_[0-9]*; do
+ if [[ ! -d "$d" ]]; then continue; fi
+ if [[ -n "$(ls "$d"/gitea-repos-*.tar.gz 2>/dev/null | head -1)" ]]; then found=1; else found=0; fi
+ if [[ "$mode" == "full" && "$found" == "1" ]] || [[ "$mode" == "thin" && "$found" == "0" ]]; then
+ echo "$d"
+ fi
+ done | sort -r | head -n "$want"
+}
+
+for d in $(keep_mark "$FULL_RETENTION" full); do KEEP["$d"]=1; done
+for d in $(keep_mark "$THIN_RETENTION" thin); do KEEP["$d"]=1; done
+
+for d in "$BACKUP_ROOT"/[0-9]*_[0-9]*; do
+ if [[ ! -d "$d" ]]; then continue; fi
+ if [[ -z "${KEEP["$d"]:-}" ]]; then
+ rm -rf "$d" && log " 已清理: $(basename "$d")"
+ fi
+done
+
+TOTAL=$(du -sh "$BACKUP_ROOT" 2>/dev/null | cut -f1)
+log "========== 备份完成 =========="
+log "目录: $STAMP_DIR 备份区总计: ${TOTAL:-unknown}"
+log "剩余磁盘: $(df -h / | awk 'NR==2{print $5" 已用, "$4" 可用"}')"
diff --git a/infra/cicd/backup/restore-cicd.sh b/infra/cicd/backup/restore-cicd.sh
new file mode 100755
index 0000000..6d28ad3
--- /dev/null
+++ b/infra/cicd/backup/restore-cicd.sh
@@ -0,0 +1,141 @@
+#!/usr/bin/env bash
+# ============================================================
+# Novalon CI/CD 恢复脚本(backup-cicd.sh 的逆向)
+#
+# 铁律:未做过恢复演练的备份等于没有备份。
+# 建议每季度在临时目录做一次 --dry-run 级别演练。
+#
+# 用法:
+# ./restore-cicd.sh --from /home/novalon/backups/cicd/20260914_033000 # 全量恢复
+# ./restore-cicd.sh --from --component jenkins # 只恢复 Jenkins
+# ./restore-cicd.sh --from --component gitea
+# ./restore-cicd.sh --from --dry-run # 只校验归档,不写入
+#
+# ⚠️ 恢复前必须先停服务,否则会写入半一致状态。脚本默认会停,用 --no-stop 可跳过。
+# ============================================================
+
+set -Eeuo pipefail
+
+FROM=""
+COMPONENT="all" # all | gitea | jenkins
+DRY_RUN=0
+NO_STOP=0
+
+GITEA_CONTAINER="${GITEA_CONTAINER:-gitea}"
+PG_CONTAINER="${PG_CONTAINER:-postgresql}"
+JENKINS_CONTAINER="${JENKINS_CONTAINER:-jenkins}"
+GITEA_DB_NAME="${GITEA_DB_NAME:-gitea}"
+GITEA_DB_USER="${GITEA_DB_USER:-gitea}"
+COMPOSE_DIR="${COMPOSE_DIR:-/home/novalon/docker-app/novalon-cicd}"
+
+while [[ $# -gt 0 ]]; do
+ case "$1" in
+ --from) FROM="$2"; shift 2 ;;
+ --component) COMPONENT="$2"; shift 2 ;;
+ --dry-run) DRY_RUN=1; shift ;;
+ --no-stop) NO_STOP=1; shift ;;
+ -h|--help) sed -n '2,22p' "$0"; exit 0 ;;
+ *) echo "未知参数: $1" >&2; exit 2 ;;
+ esac
+done
+
+log() { echo "[restore] $*"; }
+fail() { echo "[restore] ❌ $*" >&2; exit 1; }
+trap 'fail "第 $LINENO 行发生未捕获错误"' ERR
+
+[[ -n "$FROM" && -d "$FROM" ]] || fail "请指定有效的 --from 备份目录"
+log "备份源: $FROM"
+log "范围: $COMPONENT (dry-run=$DRY_RUN)"
+cat "$FROM/MANIFEST.txt" 2>/dev/null || log "⚠️ 无 MANIFEST.txt,无法核对版本"
+
+# ---------- 定位产物 ----------
+DB_DUMP="$(ls "$FROM"/gitea-db-*.sql.gz 2>/dev/null | tail -1 || true)"
+REPO_TAR="$(ls "$FROM"/gitea-repos-*.tar.gz 2>/dev/null | tail -1 || true)"
+JENKINS_TAR="$(ls "$FROM"/jenkins-config-*.tar.gz 2>/dev/null | tail -1 || true)"
+
+# ---------- 归档校验 ----------
+log "----- 归档完整性校验 -----"
+[[ "$COMPONENT" == "jenkins" ]] || { [[ -s "$DB_DUMP" ]] && gzip -t "$DB_DUMP" || fail "gitea-db 归档无效: ${DB_DUMP:-缺失}"; }
+[[ "$COMPONENT" == "jenkins" ]] || { [[ -s "$REPO_TAR" ]] && tar -tzf "$REPO_TAR" >/dev/null || fail "gitea-repos 归档无效: ${REPO_TAR:-缺失}"; }
+[[ "$COMPONENT" == "gitea" ]] || { [[ -s "$JENKINS_TAR" ]] && tar -tzf "$JENKINS_TAR" >/dev/null || fail "jenkins 归档无效: ${JENKINS_TAR:-缺失}"; }
+
+# Jenkins 凭据可解密性校验(最容易踩的坑)
+if [[ "$COMPONENT" != "gitea" ]]; then
+ if tar -tzf "$JENKINS_TAR" | grep -q "secret.key"; then
+ log "✅ 归档含 secret.key,凭据可解密"
+ else
+ fail "归档缺少 secret.key —— 恢复后所有 Jenkins 凭据将不可解密。请改用含 secret.key 的备份。"
+ fi
+fi
+log "✅ 全部归档校验通过"
+
+if (( DRY_RUN == 1 )); then
+ log "dry-run 完成,未写入任何数据。"
+ exit 0
+fi
+
+# ---------- 二次确认 ----------
+echo
+echo "⚠️ 即将覆盖现有 Gitea / Jenkins 数据,此操作不可撤销。"
+read -r -p "请输入 'RESTORE' 以继续: " CONFIRM
+[[ "$CONFIRM" == "RESTORE" ]] || fail "未确认,已中止"
+
+# ---------- 停服务 ----------
+if (( NO_STOP == 0 )); then
+ log "停止服务..."
+ docker stop "$JENKINS_CONTAINER" >/dev/null 2>&1 || true
+ docker stop "$GITEA_CONTAINER" >/dev/null 2>&1 || true
+ # PostgreSQL 保持运行(恢复需要它在线),只停 Gitea 与 Jenkins
+ docker start "$PG_CONTAINER" >/dev/null 2>&1 || true
+fi
+
+# ============================================================
+# Gitea 恢复
+# ============================================================
+if [[ "$COMPONENT" != "jenkins" ]]; then
+ log "----- 恢复 PostgreSQL (Gitea 库) -----"
+ # 先建一个安全网:把当前库导出一份(若库还存在)
+ if docker exec "$PG_CONTAINER" pg_isready -U "$GITEA_DB_USER" >/dev/null 2>&1; then
+ docker exec "$PG_CONTAINER" pg_dump -U "$GITEA_DB_USER" -d "$GITEA_DB_NAME" -Fc \
+ | gzip -9 > "/tmp/gitea-db-pre-restore-$(date +%s).sql.gz" \
+ && log "已保存恢复前快照到 /tmp" || log "⚠️ 恢复前快照失败(继续)"
+ fi
+
+ gunzip -c "$DB_DUMP" | docker exec -i "$PG_CONTAINER" \
+ pg_restore -U "$GITEA_DB_USER" -d "$GITEA_DB_NAME" --clean --if-exists --no-owner \
+ || log "⚠️ pg_restore 返回非 0(--clean 对不存在的对象会告警,通常可忽略)"
+ log "✅ 数据库恢复完成"
+
+ log "----- 恢复 Gitea 仓库 -----"
+ docker run --rm --volumes-from "$GITEA_CONTAINER" -v "$(dirname "$REPO_TAR"):/in" alpine \
+ sh -c 'tar xzf /in/'"$(basename "$REPO_TAR")"' -C /data' \
+ || fail "仓库恢复失败"
+ log "✅ 仓库恢复完成"
+fi
+
+# ============================================================
+# Jenkins 恢复
+# ============================================================
+if [[ "$COMPONENT" != "gitea" ]]; then
+ log "----- 恢复 Jenkins 配置 -----"
+ docker run --rm --volumes-from "$JENKINS_CONTAINER" -v "$(dirname "$JENKINS_TAR"):/in" alpine \
+ sh -c 'cd /var/jenkins_home && tar xzf /in/'"$(basename "$JENKINS_TAR")" \
+ || fail "Jenkins 恢复失败"
+ log "✅ Jenkins 配置恢复完成"
+fi
+
+# ---------- 起服务 ----------
+log "启动服务..."
+docker start "$GITEA_CONTAINER" >/dev/null 2>&1 || true
+docker start "$JENKINS_CONTAINER" >/dev/null 2>&1 || true
+
+log "等待健康检查..."
+sleep 20
+docker ps --filter "name=$GITEA_CONTAINER" --filter "name=$JENKINS_CONTAINER" \
+ --format "{{.Names}}: {{.Status}}"
+
+log "========== 恢复完成 =========="
+log "请人工核验:"
+log " 1. https://git.f.novalon.cn 仓库列表与最近提交"
+log " 2. https://ci.f.novalon.cn 作业 novalon-website-ci-cd 与凭据 gitea-credentials"
+log " 3. 触发一次构建验证 webhook 链路"
diff --git a/infra/cicd/docker-compose.gitea.yml b/infra/cicd/docker-compose.gitea.yml
new file mode 100644
index 0000000..3bce828
--- /dev/null
+++ b/infra/cicd/docker-compose.gitea.yml
@@ -0,0 +1,153 @@
+# ============================================================
+# Gitea 生产环境编排(加固版)
+# 部署位置: 139.155.109.62:/home/novalon/docker-app/novalon-cicd/
+# 访问地址: https://git.f.novalon.cn
+#
+# 相对现状(2026-09-14 实测)的修复点:
+# 1. 凭据外置到 .env,不再内联在 compose 里
+# 2. SSH_PORT 22 -> 2222,修正"克隆地址指向宿主 sshd"的错配
+# 3. 资源限制改用 compose 原生 cpus/mem_limit(deploy: 段在非 swarm 下被忽略,等于没限制)
+# 4. 补 read_only / cap_drop / tmpfs 最小权限
+# 5. 补 PostgreSQL 日志轮转与备份卷
+#
+# 用法:
+# cp .env.example .env && vim .env # 填入真实凭据,.env 绝不入库
+# docker compose -f docker-compose.gitea.yml up -d
+# ============================================================
+
+services:
+ # ------------------------------------------------------------
+ # PostgreSQL 17 —— Gitea 唯一数据源
+ # ------------------------------------------------------------
+ postgresql:
+ image: postgres:17-alpine
+ container_name: postgresql
+ restart: always
+ stop_grace_period: 30s
+ security_opt:
+ - no-new-privileges:true
+ cap_drop:
+ - ALL
+ cap_add:
+ - CHOWN
+ - DAC_OVERRIDE
+ - FOWNER
+ - SETGID
+ - SETUID
+ environment:
+ POSTGRES_USER: ${GITEA_DB_USER}
+ POSTGRES_PASSWORD: ${GITEA_DB_PASSWORD}
+ POSTGRES_DB: ${GITEA_DB_NAME}
+ POSTGRES_INITDB_ARGS: "--encoding=UTF8 --locale=C"
+ TZ: Asia/Shanghai
+ PGTZ: Asia/Shanghai
+ volumes:
+ - ./postgresql_data:/var/lib/postgresql/data
+ - ./postgresql_init:/docker-entrypoint-initdb.d:ro
+ - ./postgresql_backup:/var/lib/postgresql/backup
+ tmpfs:
+ - /tmp:size=100m,mode=1777
+ # compose 原生限制(非 swarm 下 deploy: 不生效,必须用这两个键)
+ cpus: 1.0
+ mem_limit: 2g
+ mem_reservation: 512m
+ networks:
+ - novalon-network
+ healthcheck:
+ test:
+ [
+ "CMD-SHELL",
+ "pg_isready -U ${GITEA_DB_USER} -d ${GITEA_DB_NAME} && psql -U ${GITEA_DB_USER} -d ${GITEA_DB_NAME} -c 'SELECT 1'",
+ ]
+ interval: 10s
+ timeout: 5s
+ retries: 5
+ start_period: 30s
+ logging:
+ driver: json-file
+ options:
+ max-size: "10m"
+ max-file: "5"
+ compress: "true"
+
+ # ------------------------------------------------------------
+ # Gitea 1.27.x
+ # ------------------------------------------------------------
+ gitea:
+ image: gitea/gitea:1.27.2
+ container_name: gitea
+ restart: always
+ stop_grace_period: 30s
+ security_opt:
+ - no-new-privileges:true
+ cap_drop:
+ - ALL
+ cap_add:
+ - CHOWN
+ - SETUID
+ - SETGID
+ environment:
+ - USER_UID=1000
+ - USER_GID=1000
+ - TZ=Asia/Shanghai
+ - DB_TYPE=postgres
+ - DB_HOST=postgresql:5432
+ - DB_NAME=${GITEA_DB_NAME}
+ - DB_USER=${GITEA_DB_USER}
+ - DB_PASSWD=${GITEA_DB_PASSWORD}
+ - SSH_DOMAIN=${GITEA_SSH_DOMAIN}
+ # 关键修复:必须与宿主实际映射端口一致,否则页面给出的 SSH 克隆地址不可用
+ - SSH_PORT=2222
+ - HTTP_PORT=3000
+ - ROOT_URL=${GITEA_ROOT_URL}
+ - LFS_START_SERVER=true
+ - DISABLE_REGISTRATION=true
+ - REQUIRE_SIGNIN_VIEW=true
+ - SHOW_REGISTRATION_BUTTON=false
+ - DEFAULT_KEEP_EMAIL_PRIVATE=true
+ - DEFAULT_ALLOW_CREATE_ORGANIZATION=false
+ - CACHE_ADAPTER=memory
+ - SESSION_PROVIDER=db
+ - ENABLE_GZIP=true
+ - MAX_CREATION_LIMIT=100
+ - GITEA__security__INSTALL_LOCK=true
+ - GITEA__security__PASSWORD_COMPLEXITY=lower,upper,digit,spec
+ - GITEA__security__MIN_PASSWORD_LENGTH=12
+ - GITEA__service__DISABLE_REGISTRATION=true
+ - GITEA__service__REQUIRE_SIGNIN_VIEW=true
+ - GITEA__log__LEVEL=info
+ - GITEA__log__MODE=console
+ volumes:
+ - ./gitea/data:/data
+ - /etc/timezone:/etc/timezone:ro
+ - /etc/localtime:/etc/localtime:ro
+ tmpfs:
+ - /tmp:size=100m,mode=1777
+ ports:
+ # 仅 SSH 对外;HTTP 只允许本机,由 nginx 反代终止 TLS
+ - "2222:22"
+ - "127.0.0.1:3001:3000"
+ depends_on:
+ postgresql:
+ condition: service_healthy
+ cpus: 1.0
+ mem_limit: 1g
+ mem_reservation: 256m
+ networks:
+ - novalon-network
+ healthcheck:
+ test: ["CMD", "curl", "-f", "http://localhost:3000/api/healthz"]
+ interval: 30s
+ timeout: 10s
+ retries: 3
+ start_period: 60s
+ logging:
+ driver: json-file
+ options:
+ max-size: "10m"
+ max-file: "5"
+ compress: "true"
+
+networks:
+ novalon-network:
+ external: true
diff --git a/infra/cicd/docker-compose.jenkins.yml b/infra/cicd/docker-compose.jenkins.yml
new file mode 100644
index 0000000..25362e2
--- /dev/null
+++ b/infra/cicd/docker-compose.jenkins.yml
@@ -0,0 +1,88 @@
+# ============================================================
+# Jenkins 生产环境编排(加固版)
+# 部署位置: 139.155.109.62:/home/novalon/docker-app/jenkins/
+# 访问地址: https://ci.f.novalon.cn
+#
+# 相对现状(2026-09-14 实测)的修复点:
+# 1. user: root -> 1000:1000(现状以 root 运行,容器内即宿主 root)
+# 2. 摘掉 /var/run/docker.sock(作业 config.xml 实测 0 处 docker 调用,纯多余风险;
+# 挂载 docker.sock = 容器内任意用户可直接拿到宿主 root)
+# 3. 摘掉 /root/.ssh 全量挂载 -> 改为专用部署密钥只读挂载
+# 4. 补日志轮转(现状无 logging 配置,jenkins_home 已 430MB 且裸奔)
+# 5. 补资源限制、cap_drop、CSRF、JCasC 声明式配置
+#
+# ⚠️ 首次切换到非 root 前必须先改卷属主(否则 Jenkins 起不来):
+# docker stop jenkins
+# docker run --rm -v jenkins_jenkins_home:/v alpine chown -R 1000:1000 /v
+# docker compose -f docker-compose.jenkins.yml up -d
+#
+# 用法:
+# cp .env.example .env && vim .env
+# docker compose -f docker-compose.jenkins.yml up -d
+# ============================================================
+
+services:
+ jenkins:
+ # 锁定 LTS 大版本;升级走镜像 tag 变更 + 蓝绿验证,不要用 latest 漂移
+ image: jenkins/jenkins:2.568.1-lts-jdk17
+ container_name: jenkins
+ restart: unless-stopped
+ stop_grace_period: 60s
+ # 不再以 root 运行
+ user: "1000:1000"
+ security_opt:
+ - no-new-privileges:true
+ cap_drop:
+ - ALL
+ cap_add:
+ - CHOWN
+ - SETUID
+ - SETGID
+ environment:
+ - TZ=Asia/Shanghai
+ - JAVA_OPTS=-Xmx1024m -Xms256m -Duser.timezone=Asia/Shanghai -Djenkins.install.runSetupWizard=false
+ # 关闭 Jenkins 2.3xx 之后默认禁用的入站代理(JNLP)端口,减少攻击面
+ - JENKINS_OPTS=--httpPort=8080
+ # JCasC:声明式安全域 / 授权 / 凭据,杜绝手工漂移
+ - CASC_JENKINS_CONFIG=/var/jenkins_config/casc.yaml
+ - ADMIN_USER=${JENKINS_ADMIN_USER}
+ - ADMIN_PASSWORD=${JENKINS_ADMIN_PASSWORD}
+ - GITEA_TOKEN=${GITEA_TOKEN}
+ - DEPLOY_SSH_KEY_PASSPHRASE=${DEPLOY_SSH_KEY_PASSPHRASE}
+ volumes:
+ - jenkins_home:/var/jenkins_home
+ # JCasC 只读挂载
+ - ./jenkins/casc.yaml:/var/jenkins_config/casc.yaml:ro
+ # 专用部署密钥(仅含 id_ed25519_deploy / known_hosts),不再暴露宿主 /root/.ssh
+ - ./jenkins-ssh:/var/jenkins_home/.ssh:ro
+ tmpfs:
+ - /tmp:size=200m,mode=1777
+ ports:
+ # 只监听本机,由 nginx 终止 TLS;50000 为 JNLP 入站代理,无 agent 时可整段删除
+ - "127.0.0.1:8080:8080"
+ - "127.0.0.1:50000:50000"
+ cpus: 2.0
+ mem_limit: 2g
+ mem_reservation: 512m
+ networks:
+ - novalon-network
+ healthcheck:
+ test: ["CMD", "curl", "-fsS", "http://localhost:8080/login"]
+ interval: 30s
+ timeout: 10s
+ retries: 5
+ start_period: 90s
+ logging:
+ driver: json-file
+ options:
+ max-size: "20m"
+ max-file: "5"
+ compress: "true"
+
+volumes:
+ jenkins_home:
+ driver: local
+
+networks:
+ novalon-network:
+ external: true
diff --git a/infra/cicd/jenkins/casc.yaml b/infra/cicd/jenkins/casc.yaml
new file mode 100644
index 0000000..76aa91a
--- /dev/null
+++ b/infra/cicd/jenkins/casc.yaml
@@ -0,0 +1,112 @@
+# ============================================================
+# Jenkins Configuration as Code (JCasC)
+#
+# 目的:把安全域、授权策略、CSRF、凭据、节点协议全部声明化,
+# 避免"某次手工点了全局可写"这类不可追溯的漂移。
+#
+# 挂载:CASC_JENKINS_CONFIG=/var/jenkins_config/casc.yaml
+# 校验:Jenkins -> Manage Jenkins -> Configuration as Code -> View Configuration
+# 或用 jenkins-plugin-cli 的 casc 校验器 docker run 预检
+#
+# 变量来自 .env(容器 env),不要在此文件写明文。
+# ============================================================
+
+jenkins:
+ systemMessage: |
+ Novalon CI/CD (ci.f.novalon.cn) —— 生产环境,操作留痕。
+ 未授权访问将被记录并追责。
+
+ # ---------- 安全域:Jenkins 自有用户库,禁用匿名 ----------
+ securityRealm:
+ local:
+ allowsSignup: false # 禁止自助注册
+ enableCaptcha: false
+ users:
+ - id: "${ADMIN_USER}"
+ # 密码由 bcrypt 存储;首次由 JCasC 写入,之后通过 UI/API 改
+ password: "${ADMIN_PASSWORD}"
+
+ # ---------- 授权:登录后可读,管理员全权 ----------
+ authorizationStrategy:
+ loggedInUsersCanDoAnything:
+ allowAnonymousRead: false # 关键:关闭匿名读取(构建日志含路径/命令,属敏感信息)
+
+ # ---------- 全局安全 ----------
+ crumbIssuer:
+ standard:
+ excludeClientIPFromCrumb: false
+
+ remotingSecurity:
+ enabled: true # 启用 agent -> controller 的调用过滤(防 RCE 类滥用)
+
+ # 无 agent 时关闭 JNLP 入站端口;有 agent 再改为 fixed:50000
+ agentProtocols:
+ - "Ping"
+
+ # Jenkins URL 必须与实际一致,否则 CSRF crumb 与邮件/通知链接会失效
+ location:
+ url: "https://ci.f.novalon.cn/"
+ adminAddress: "team@novalon.cn"
+
+ # ---------- 构建并发与队列 ----------
+ numExecutors: 2
+ quietPeriod: 5
+ scmCheckoutRetryCount: 2
+
+ # ---------- 全局环境变量(作业里用 ${...} 引用) ----------
+ globalNodeProperties:
+ - envVars:
+ env:
+ - key: "PROJECT_NAME"
+ value: "novalon-website"
+ - key: "SERVER_IP"
+ value: "139.155.109.62"
+ - key: "SERVER_USER"
+ value: "root"
+ - key: "DEPLOY_ROOT"
+ value: "/home/novalon/docker-app"
+ - key: "NGINX_CONTAINER"
+ value: "novalon-nginx-secure"
+ - key: "DOMAIN"
+ value: "https://novalon.cn"
+ - key: "NPM_REGISTRY"
+ value: "https://registry.npmmirror.com"
+ - key: "BACKUP_RETENTION_COUNT"
+ value: "3"
+
+# ---------- 凭据:统一由环境变量注入,绝不写死在 JCasC ----------
+credentials:
+ system:
+ domainCredentials:
+ - credentials:
+ - usernamePassword:
+ scope: GLOBAL
+ id: "gitea-credentials"
+ username: "${ADMIN_USER}"
+ password: "${GITEA_TOKEN}"
+ description: "Gitea 代码拉取令牌(read:repository)"
+
+# ---------- 工具安装(与 Jenkinsfile 中 nodejs('18') 对应) ----------
+tool:
+ nodejs:
+ installations:
+ - name: "18"
+ home: ""
+ properties:
+ - installSource:
+ installers:
+ - nodeJSInstaller:
+ id: "18.20.4"
+ npmPackagesRefreshHours: 72
+
+# ---------- 插件更新站点 ----------
+# updateCenter:
+# sites:
+# - id: "default"
+# url: "https://updates.jenkins.io/update-center.json"
+
+# ---------- 安全加固:禁用 CLI over HTTP / 旧协议 ----------
+unclassified:
+ # 关闭 Jenkins 内置 CLI over HTTP(历史漏洞面)
+ # 通过 startupProbe 之外的手段无法在 JCasC 直接关,
+ # 已在 docker-compose 的 JAVA_OPTS 中以 -Djenkins.CLI.disabled=true 处理
diff --git a/infra/cicd/jenkins/plugins.txt b/infra/cicd/jenkins/plugins.txt
new file mode 100644
index 0000000..9127147
--- /dev/null
+++ b/infra/cicd/jenkins/plugins.txt
@@ -0,0 +1,41 @@
+# Jenkins 必备插件清单(生产最小集,按现状 268 个插件裁剪治理的基线)
+# 安装:docker compose exec jenkins jenkins-plugin-cli --plugin-file /var/jenkins_config/plugins.txt
+#
+# 说明:现状容器里 plugins 目录混有 .jpi 与 .bak 残留(268 条记录),
+# 建议按此清单重建,避免僵尸插件带来的 CVE 面。
+
+# --- 核心流水线 ---
+workflow-aggregator # Pipeline 全家桶
+pipeline-stage-view
+pipeline-utility-steps
+git
+git-client
+scm-api
+
+# --- Gitea 集成 ---
+gitea # Gitea Server 插件(提供 Gitea Organization / webhook 端点)
+generic-webhook-trigger # Jenkinsfile 中 GenericTrigger 依赖,Gitea push 触发
+
+# --- 凭据 ---
+credentials
+credentials-binding # withCredentials / sshagent
+plain-credentials
+ssh-credentials
+
+# --- 构建工具 ---
+nodejs # Jenkinsfile 中 nodejs(nodeJSInstallationName:'18') 依赖
+timestamper # options { timestamps() }
+ansicolor # options { ansiColor('xterm') }
+build-timeout
+
+# --- 报告与产物 ---
+htmlpublisher # publishHTML:覆盖率 / E2E 报告
+junit # 测试结果趋势
+warnings-ng # ESLint / tsc 警告可视化
+
+# --- 运维 ---
+disk-usage # JENKINS_HOME 增长监控(本次磁盘 85% 事故的直接看板)
+monitoring # 内置监控
+mailer # post { failure { mail } }
+configuration-as-code # JCasC,本目录 casc.yaml 依赖
+job-dsl # 作业声明化(可选,用于重建 novalon-website-ci-cd)
diff --git a/infra/cicd/monitoring/healthcheck-cicd.sh b/infra/cicd/monitoring/healthcheck-cicd.sh
new file mode 100755
index 0000000..b3afcd5
--- /dev/null
+++ b/infra/cicd/monitoring/healthcheck-cicd.sh
@@ -0,0 +1,210 @@
+#!/usr/bin/env bash
+# ============================================================
+# Novalon CI/CD 健康检查
+#
+# 探针维度:
+# 1. 容器存活与健康状态
+# 2. HTTPS 端点可达性(Gitea / Jenkins / 官网)
+# 3. TLS 证书剩余有效期
+# 4. 磁盘与 Docker 卷增长(宿主 20G 已用 85%,这是头号风险)
+# 5. 备份新鲜度(超过 36 小时无新备份 -> FAIL)
+# 6. Jenkins 构建队列积压 / 执行器卡死
+#
+# 退出码:0 全通过;1 有 FAIL。便于 cron + Webhook 告警。
+#
+# crontab(每 10 分钟):
+# */10 * * * * /home/novalon/docker-app/infra/cicd/monitoring/healthcheck-cicd.sh
+# >> /var/log/cicd-healthcheck.log 2>&1
+#
+# 用法:
+# ./healthcheck-cicd.sh # 正常检查,FAIL 时推送告警
+# ./healthcheck-cicd.sh --quiet # 只在 FAIL 时输出
+# ./healthcheck-cicd.sh --json # 输出 JSON(供 Prometheus/自定义采集)
+# ============================================================
+
+set -uo pipefail # 不用 -e,健康检查要跑完全部探针再汇总
+
+BACKUP_ROOT="${BACKUP_ROOT:-/home/novalon/backups/cicd}"
+ALERT_WEBHOOK_URL="${ALERT_WEBHOOK_URL:-}"
+DISK_WARN_PCT="${DISK_WARN_PCT:-80}"
+DISK_CRIT_PCT="${DISK_CRIT_PCT:-90}"
+CERT_WARN_DAYS="${CERT_WARN_DAYS:-21}"
+BACKUP_MAX_AGE_HOURS="${BACKUP_MAX_AGE_HOURS:-36}"
+
+GITEA_URL="${GITEA_URL:-https://git.f.novalon.cn}"
+JENKINS_URL="${JENKINS_URL:-https://ci.f.novalon.cn}"
+SITE_URL="${SITE_URL:-https://novalon.cn}"
+
+QUIET=0; JSON=0
+for a in "$@"; do
+ case "$a" in
+ --quiet) QUIET=1 ;;
+ --json) JSON=1 ;;
+ esac
+done
+
+PASS=0; FAIL=0; WARN=0
+RESULTS=()
+
+# ⚠️ 这三个函数必须显式 return 0:
+# 曾踩坑 —— --quiet/--json 模式下 `[[ 条件 ]] && echo` 因条件为假而返回 1,
+# 导致 `cond && ok ... || bad ...` 构造中 ok 已执行却"返回失败",跌进 bad 分支,
+# 造成 FAIL 计数随输出模式抖动(1/1/2)
+ok() { PASS=$((PASS+1)); RESULTS+=("PASS|$1|$2"); if [[ $QUIET == 0 && $JSON == 0 ]]; then echo " ✅ $1 — $2"; fi; return 0; }
+warn() { WARN=$((WARN+1)); RESULTS+=("WARN|$1|$2"); if [[ $JSON == 0 ]]; then echo " ⚠️ $1 — $2"; fi; return 0; }
+bad() { FAIL=$((FAIL+1)); RESULTS+=("FAIL|$1|$2"); if [[ $JSON == 0 ]]; then echo " ❌ $1 — $2"; fi; return 0; }
+
+emit_json() {
+ printf '{"ts":"%s","host":"%s","pass":%d,"warn":%d,"fail":%d,"checks":[' \
+ "$(date -Is)" "$(hostname)" "$PASS" "$WARN" "$FAIL"
+ local first=1
+ for r in "${RESULTS[@]}"; do
+ IFS='|' read -r lvl name msg <<< "$r"
+ (( first )) || printf ','
+ first=0
+ printf '{"level":"%s","name":"%s","msg":"%s"}' "$lvl" "$name" "${msg//\"/\\\"}"
+ done
+ printf ']}\n'
+}
+
+alert() {
+ [[ -z "$ALERT_WEBHOOK_URL" ]] && return 0
+ local body="[CI/CD 健康检查] 主机 $(hostname)
+$(printf '%s\n' "${RESULTS[@]}" | grep '^FAIL' | sed 's/^FAIL|/ ❌ /' | sed 's/|/ — /')
+$(printf '%s\n' "${RESULTS[@]}" | grep '^WARN' | sed 's/^WARN|/ ⚠️ /' | sed 's/|/ — /')"
+ curl -fsS -m 10 -X POST "$ALERT_WEBHOOK_URL" \
+ -H 'Content-Type: application/json' \
+ --data-binary "$(printf '{"msgtype":"text","text":{"content":%s}}' \
+ "$(printf '%s' "$body" | python3 -c 'import json,sys;print(json.dumps(sys.stdin.read()))' 2>/dev/null || echo '\"见日志\"')")" \
+ >/dev/null 2>&1 || true
+}
+
+[[ $JSON == 0 ]] && echo "========== CI/CD 健康检查 $(date -Is) =========="
+
+# ---------- 1. 容器状态 ----------
+[[ $JSON == 0 ]] && echo "[1] 容器状态"
+for c in gitea jenkins postgresql nginx; do
+ name=$(docker ps --format '{{.Names}}' 2>/dev/null | grep -x "$c" || true)
+ if [[ -z "$name" ]]; then
+ # nginx 容器名带前缀,做模糊匹配
+ name=$(docker ps --format '{{.Names}}' 2>/dev/null | grep -i "$c" | head -1 || true)
+ fi
+ if [[ -z "$name" ]]; then
+ bad "container:$c" "容器未运行"
+ continue
+ fi
+ # ⚠️ 未定义 healthcheck 的容器:inspect 返回空串(命令成功,|| 不会触发),
+ # 必须显式把空串归一为 none,否则会误报"状态异常"
+ st="$(docker inspect -f '{{.State.Health.Status}}' "$name" 2>/dev/null)"
+ st="${st:-none}"
+ if [[ "$st" == "healthy" || "$st" == "none" ]]; then
+ ok "container:$c" "运行中 ($st)"
+ else
+ bad "container:$c" "状态异常: $st"
+ fi
+done
+
+# ---------- 2. HTTPS 端点 ----------
+[[ $JSON == 0 ]] && echo "[2] HTTPS 端点"
+check_url() {
+ local label="$1" url="$2" expect="$3"
+ # ⚠️ 不能用 `|| echo 000`:curl 偶发"已输出 http_code 但退出码非 0",
+ # 会把 200 与 000 拼接成 200000,造成假 FAIL。改用正则校验兜底。
+ local code
+ code="$(curl -sS -o /dev/null -w '%{http_code}' -m 12 --noproxy '*' "$url" 2>/dev/null || true)"
+ [[ "$code" =~ ^[0-9]{3}$ ]] || code="000"
+ if [[ "$code" =~ ^($expect)$ ]]; then ok "http:$label" "$code ($url)"
+ else bad "http:$label" "期望 $expect 实得 $code ($url)"; fi
+}
+check_url gitea "$GITEA_URL/" "200|301|302|303"
+check_url jenkins "$JENKINS_URL/" "200|403"
+check_url site "$SITE_URL/" "200|301|302"
+
+# ---------- 3. TLS 证书 ----------
+[[ $JSON == 0 ]] && echo "[3] TLS 证书"
+check_cert() {
+ local host="$1"
+ local end; end=$(echo | timeout 12 openssl s_client -servername "$host" -connect "$host:443" 2>/dev/null \
+ | openssl x509 -noout -enddate 2>/dev/null | cut -d= -f2)
+ if [[ -z "$end" ]]; then warn "cert:$host" "无法获取证书信息"; return; fi
+ local end_epoch now days
+ end_epoch=$(date -d "$end" +%s 2>/dev/null || date -j -f "%b %e %H:%M:%S %Y %Z" "$end" +%s 2>/dev/null || echo 0)
+ now=$(date +%s)
+ days=$(( (end_epoch - now) / 86400 ))
+ if (( days <= 0 )); then bad "cert:$host" "证书已过期 ($days 天)"
+ elif (( days < 7 )); then bad "cert:$host" "证书 $days 天后过期,立即续期"
+ elif (( days < CERT_WARN_DAYS )); then warn "cert:$host" "证书 $days 天后过期"
+ else ok "cert:$host" "剩余 $days 天"; fi
+}
+check_cert "${GITEA_URL#https://}"
+check_cert "${JENKINS_URL#https://}"
+
+# ---------- 4. 磁盘 ----------
+[[ $JSON == 0 ]] && echo "[4] 磁盘与卷"
+root_pct=$(df -P / | awk 'NR==2{gsub(/%/,"");print $5}')
+root_free=$(df -h / | awk 'NR==2{print $4}')
+if (( root_pct >= DISK_CRIT_PCT )); then bad "disk:root" "已用 ${root_pct}%(阈值 ${DISK_CRIT_PCT}%),可用 ${root_free}"
+elif (( root_pct >= DISK_WARN_PCT )); then warn "disk:root" "已用 ${root_pct}%(阈值 ${DISK_WARN_PCT}%),可用 ${root_free}"
+else ok "disk:root" "已用 ${root_pct}%,可用 ${root_free}"; fi
+
+for v in jenkins_jenkins_home; do
+ if docker volume inspect "$v" >/dev/null 2>&1; then
+ sz=$(docker system df -v 2>/dev/null | grep -F "$v" | awk '{print $3}' | head -1)
+ if [[ -n "$sz" ]]; then
+ num=${sz%GB}
+ if (( $(echo "$num > 5" | bc -l 2>/dev/null || echo 0) )); then
+ warn "volume:$v" "体积 ${sz} 超过 5GB,检查构建历史清理策略"
+ else ok "volume:$v" "体积 ${sz}"; fi
+ fi
+ fi
+done
+
+# 备份区体积
+if [[ -d "$BACKUP_ROOT" ]]; then
+ bsz=$(du -sh "$BACKUP_ROOT" 2>/dev/null | cut -f1)
+ ok "backup:size" "备份区 ${bsz}"
+fi
+
+# ---------- 5. 备份新鲜度 ----------
+[[ $JSON == 0 ]] && echo "[5] 备份新鲜度"
+if [[ ! -d "$BACKUP_ROOT" ]]; then
+ bad "backup:freshness" "备份目录不存在: $BACKUP_ROOT(Jenkins 从未备份过)"
+else
+ latest=$(ls -1dt "$BACKUP_ROOT"/[0-9]*_[0-9]* 2>/dev/null | head -1 || true)
+ if [[ -z "$latest" ]]; then
+ bad "backup:freshness" "备份目录存在但无任何备份"
+ else
+ age_h=$(( ( $(date +%s) - $(stat -c %Y "$latest") ) / 3600 ))
+ if (( age_h > BACKUP_MAX_AGE_HOURS )); then
+ bad "backup:freshness" "最新备份 $(basename "$latest") 已 ${age_h}h(阈值 ${BACKUP_MAX_AGE_HOURS}h)"
+ else
+ ok "backup:freshness" "最新备份 $(basename "$latest"),${age_h}h 前"
+ fi
+ # 内容完整性
+ cnt=$(find "$latest" -name '*.tar.gz' -o -name '*.sql.gz' 2>/dev/null | wc -l | tr -d ' ')
+ # 注意:不要写成 `(( cnt >= 2 )) && ok || bad` —— 若 ok 返回非 0 会跌进 bad
+ if (( cnt >= 2 )); then ok "backup:content" "含 $cnt 个产物"; else bad "backup:content" "产物仅 $cnt 个,备份不完整"; fi
+ fi
+fi
+
+# ---------- 6. Jenkins 队列 ----------
+[[ $JSON == 0 ]] && echo "[6] Jenkins 队列"
+if docker ps --format '{{.Names}}' 2>/dev/null | grep -qx jenkins; then
+ q=$(docker exec jenkins sh -c \
+ 'curl -fsS --noproxy "*" http://localhost:8080/queue/api/json?tree=items\[id\] 2>/dev/null | grep -o "\"id\"" | wc -l' 2>/dev/null || echo "-1")
+ if [[ "$q" == "-1" ]]; then warn "jenkins:queue" "无法读取队列(需认证,属正常)"
+ elif (( q > 10 )); then bad "jenkins:queue" "队列积压 $q 个任务,检查执行器是否卡死"
+ else ok "jenkins:queue" "队列 $q"; fi
+else
+ bad "jenkins:queue" "Jenkins 容器未运行"
+fi
+
+# ---------- 汇总 ----------
+if (( JSON == 1 )); then emit_json
+else
+ echo "----------------------------------------"
+ echo "汇总: ✅ PASS=$PASS ⚠️ WARN=$WARN ❌ FAIL=$FAIL"
+fi
+
+if (( FAIL > 0 )); then alert; fi
+exit $(( FAIL > 0 ? 1 : 0 ))
diff --git a/infra/cicd/monitoring/truncate-container-logs.sh b/infra/cicd/monitoring/truncate-container-logs.sh
new file mode 100755
index 0000000..ffdbd1d
--- /dev/null
+++ b/infra/cicd/monitoring/truncate-container-logs.sh
@@ -0,0 +1,79 @@
+#!/usr/bin/env bash
+# ============================================================
+# 容器日志兜底截断
+#
+# 为什么需要它(2026-09-14 实测事故):
+# novalon-cicd/docker-compose.yml 里明明配了
+# logging: { driver: json-file, options: { max-size: 10m, max-file: 3 } }
+# 但运行中容器的 HostConfig.LogConfig 实测为 {"Type":"json-file","Config":{}}
+# —— 配置从未生效,Gitea 单份日志涨到 849MB,占掉整盘 4%。
+#
+# 根因:logging 驱动参数在**容器创建时**固化,事后改 compose 不重建则不生效。
+#
+# 本脚本是**临时兜底**,不是终态。
+# 终态:用 infra/cicd/docker-compose.gitea.yml 重建容器(含正确 logging 配置),
+# 之后本脚本可退化为纯监控用途。
+#
+# 用法:
+# ./truncate-container-logs.sh # 截断超过阈值的日志
+# ./truncate-container-logs.sh --check # 只报告,不截断
+# ./truncate-container-logs.sh --threshold 50 # 自定义阈值(MB)
+# ============================================================
+
+set -uo pipefail
+
+THRESHOLD_MB=100
+CHECK_ONLY=0
+# 用 while 而非 for 解析:for 迭代的是展开后的固定列表,
+# 循环内 shift 不会改变已排定的迭代序列,导致 `--check --threshold 50` 取错值
+while [[ $# -gt 0 ]]; do
+ case "$1" in
+ --check) CHECK_ONLY=1; shift ;;
+ --threshold) THRESHOLD_MB="${2:-100}"; shift 2 ;;
+ -h|--help) sed -n '2,22p' "$0"; exit 0 ;;
+ *) echo "未知参数: $1" >&2; exit 2 ;;
+ esac
+done
+
+LOG_ROOT="${LOG_ROOT:-/var/lib/docker/containers}"
+ts() { date "+%Y-%m-%d %H:%M:%S"; }
+
+echo "[$(ts)] 容器日志检查(阈值 ${THRESHOLD_MB}MB,模式 $([[ $CHECK_ONLY == 1 ]] && echo 只读 || echo 截断))"
+
+TOTAL_BEFORE=0
+TOTAL_AFTER=0
+HIT=0
+
+while IFS= read -r f; do
+ [[ -f "$f" ]] || continue
+ size_mb=$(( $(stat -c %s "$f") / 1048576 ))
+ [[ $size_mb -lt $THRESHOLD_MB ]] && continue
+
+ HIT=$((HIT + 1))
+ TOTAL_BEFORE=$((TOTAL_BEFORE + size_mb))
+
+ cid=$(basename "$(dirname "$f")")
+ cname=$(docker inspect -f '{{.Name}}' "$cid" 2>/dev/null | sed 's|^/||' || echo "$cid")
+ echo " ⚠️ ${cname}: ${size_mb}MB ($f)"
+
+ if (( CHECK_ONLY == 0 )); then
+ # truncate 对运行中的容器安全:Docker 以 O_APPEND 写,截断后继续追加
+ truncate -s 0 "$f" && echo " → 已截断"
+ TOTAL_AFTER=$((TOTAL_AFTER + $(stat -c %s "$f") / 1048576))
+ fi
+done < <(find "$LOG_ROOT" -maxdepth 2 -name '*-json.log' 2>/dev/null)
+
+if (( HIT == 0 )); then
+ echo " ✅ 无日志超过 ${THRESHOLD_MB}MB"
+else
+ echo " 命中 $HIT 个"
+ if (( CHECK_ONLY == 0 )); then
+ echo " 释放约 $((TOTAL_BEFORE - TOTAL_AFTER))MB"
+ df -h / | awk 'NR==2{print " 磁盘: 已用 "$5", 可用 "$4}'
+ fi
+fi
+
+# 提醒:若命中说明 logging 配置仍未生效,需要重建容器才是终态修复
+if (( HIT > 0 )); then
+ echo " ℹ️ 命中的容器说明其 logging 轮转未生效 —— 请用带 logging 配置的 compose 重建容器(终态修复)"
+fi
diff --git a/infra/cicd/security/purge-git-history.sh b/infra/cicd/security/purge-git-history.sh
new file mode 100755
index 0000000..3dfb717
--- /dev/null
+++ b/infra/cicd/security/purge-git-history.sh
@@ -0,0 +1,189 @@
+#!/usr/bin/env bash
+# ============================================================
+# 清除 git 历史中的明文凭据
+#
+# 背景(2026-09-14 实测确认已入库并推送到公网可达远程):
+# 泄露位置见 docs/deployment/cicd/03-credential-incident.md 第 2 节
+#
+# ⚠️ 强制推送会重写远程历史:
+# - 所有协作者必须重新 clone(旧本地仓库 push 会把泄露内容带回来)
+# - 若 Gitea 上有未合入的 PR / fork,会失效
+# - 建议:先轮换凭据(rotate-credentials.sh),再清史,最后强制推送
+#
+# 前置:pip install git-filter-repo (或 brew install git-filter-repo)
+#
+# ⚠️ 本脚本【不】内置泄露明文。曾踩坑:把泄露值硬编码进本脚本的注释与
+# 替换规则里,一旦提交等于把泄露物重新入库一次,清史变成滚雪球。
+# 替换规则从仓库外文件读取(默认 ~/.novalon-purge-secrets.txt)。
+#
+# 用法:
+# SECRETS_FILE=~/.novalon-purge-secrets.txt \
+# ./purge-git-history.sh --dry-run # 只扫描并列出命中,不改写
+# ./purge-git-history.sh # 执行清史(会先做 mirror 备份)
+# ./purge-git-history.sh --no-push # 清史但不强推,留自己复核
+# ============================================================
+
+set -Eeuo pipefail
+
+DRY_RUN=0
+NO_PUSH=0
+for a in "$@"; do
+ case "$a" in
+ --dry-run) DRY_RUN=1 ;;
+ --no-push) NO_PUSH=1 ;;
+ esac
+done
+
+REPO_DIR="${REPO_DIR:-$(cd "$(dirname "$0")/../../.." && pwd)}"
+BACKUP_DIR="${BACKUP_DIR:-$REPO_DIR/.workbuddy/tmp/git-purge-backup}"
+REMOTE="${REMOTE:-origin}"
+BRANCH="${BRANCH:-$(git -C "$REPO_DIR" rev-parse --abbrev-ref HEAD 2>/dev/null || echo main)}"
+SECRETS_FILE="${SECRETS_FILE:-$HOME/.novalon-purge-secrets.txt}"
+
+# 需要整体删除的文件(含明文配置,且本身不该入库)
+FILES_TO_DROP=(
+ "scripts/setup-cicd/cicd.config"
+)
+
+log() { echo "[purge] $*"; }
+die() { echo "[purge] ❌ $*" >&2; exit 1; }
+
+cd "$REPO_DIR" || die "无法进入仓库: $REPO_DIR"
+[[ -d .git ]] || die "不是 git 仓库: $REPO_DIR"
+
+# ============================================================
+# 读取替换规则(格式: 明文==>***REMOVED*** ,每行一条)
+# ============================================================
+if [[ ! -s "$SECRETS_FILE" ]]; then
+ cat >&2 < $SECRETS_FILE <<'EOF'
+<泄露明文1>==>***REMOVED***
+<泄露明文2>==>***REMOVED***
+<泄露明文3>==>***REMOVED***
+EOF
+ chmod 600 $SECRETS_FILE
+
+具体哪些值:见 docs/deployment/cicd/03-credential-incident.md 第 2 节列出的
+文件与变量名(cicd.config 的三个值 + setup-registry-auth.sh 的 gto_ secret)。
+创建后重新运行本脚本。
+EOF
+ exit 1
+fi
+
+# 提取明文值用于扫描命中
+mapfile -t SECRET_VALUES < <(sed 's/==>.*$//' "$SECRETS_FILE" | grep -v '^[[:space:]]*$')
+if (( ${#SECRET_VALUES[@]} == 0 )); then
+ die "替换规则文件为空或格式错误: $SECRETS_FILE"
+fi
+log "已加载 ${#SECRET_VALUES[@]} 条替换规则(值不回显)"
+
+# 构造 git grep 的 -e 参数数组
+GREP_ARGS=()
+for v in "${SECRET_VALUES[@]}"; do
+ GREP_ARGS+=( -e "$v" )
+done
+
+scan_hits() {
+ # shellcheck disable=SC2086
+ git grep -l -F "${GREP_ARGS[@]}" $(git rev-list --all) -- 2>/dev/null | wc -l | tr -d ' '
+}
+
+# ---------- 扫描命中 ----------
+log "----- 扫描历史中的命中 -----"
+git log --all --oneline -- "${FILES_TO_DROP[@]}" scripts/setup-registry-auth.sh 2>/dev/null | head -20 || true
+HIT_COUNT=$(scan_hits)
+log "历史中含泄露凭据的 blob 数: $HIT_COUNT"
+
+if (( DRY_RUN == 1 )); then
+ log "dry-run 结束,未做任何改写。"
+ exit 0
+fi
+
+if [[ "$HIT_COUNT" == "0" ]]; then
+ log "未发现命中,无需清史。"
+ exit 0
+fi
+
+# ---------- 备份 ----------
+mkdir -p "$BACKUP_DIR"
+MIRROR="$BACKUP_DIR/$(basename "$REPO_DIR")-$(date +%Y%m%d_%H%M%S).git"
+log "----- 备份镜像到 $MIRROR -----"
+git clone --mirror "$REPO_DIR" "$MIRROR" || die "备份失败,已中止(不冒险改写)"
+log "✅ 备份完成"
+
+# ---------- 工具检查 ----------
+if command -v git-filter-repo >/dev/null 2>&1; then
+ TOOL="filter-repo"
+elif python3 -c "import git_filter_repo" >/dev/null 2>&1; then
+ TOOL="python-module"
+else
+ die "未找到 git-filter-repo。安装: pip install git-filter-repo 或 brew install git-filter-repo"
+fi
+
+# ---------- 二次确认 ----------
+echo
+echo "⚠️ 即将重写 git 历史并强制推送到 $REMOTE。"
+echo "⚠️ 所有协作者必须重新 clone,未合入的 PR 可能失效。"
+read -r -p "输入 'PURGE' 继续: " C
+if [[ "$C" != "PURGE" ]]; then
+ log "未确认,已中止。"
+ exit 1
+fi
+
+# ---------- 执行清史 ----------
+log "----- 执行清史(工具: $TOOL) -----"
+if [[ "$TOOL" == "filter-repo" ]]; then
+ git filter-repo --replace-text "$SECRETS_FILE" \
+ $(printf -- '--path %s --invert-paths ' "${FILES_TO_DROP[@]}") \
+ --force
+else
+ python3 -m git_filter_repo --replace-text "$SECRETS_FILE" \
+ $(printf -- '--path %s --invert-paths ' "${FILES_TO_DROP[@]}") \
+ --force
+fi
+log "✅ 历史已重写"
+
+# ---------- 本地清理 ----------
+log "----- 本地 GC -----"
+git reflog expire --expire=now --all
+git gc --prune=now --aggressive
+log "✅ 本地对象已清理"
+
+# ---------- 校验 ----------
+log "----- 校验:重新扫描是否还有残留 -----"
+REMAIN=$(scan_hits)
+if [[ "$REMAIN" != "0" ]]; then
+ die "仍有 $REMAIN 处残留!请检查 .git/packed-refs 或其他分支。备份在: $MIRROR"
+fi
+log "✅ 无残留"
+
+# ---------- 推送 ----------
+if (( NO_PUSH == 1 )); then
+ log "--no-push:已跳过强制推送。复核后手动执行:"
+ log " git push origin --force --all && git push origin --force --tags"
+else
+ read -r -p "确认强制推送到 $REMOTE ? (yes/N): " P
+ if [[ "$P" == "yes" ]]; then
+ git push "$REMOTE" --force --all
+ git push "$REMOTE" --force --tags
+ log "✅ 已强制推送"
+ cat <&2; exit 1; }
+
+gen_pw() { openssl rand -base64 24 | tr -d '/+=' | head -c 24; }
+gen_tok() { openssl rand -hex 32; }
+
+log "========== P0 凭据轮换 =========="
+log "dry-run=$DRY_RUN"
+
+# ---------- 0. 前置校验 ----------
+command -v docker >/dev/null || die "docker 不可用(本脚本需在生产服务器上执行)"
+docker ps --format '{{.Names}}' | grep -qx "$GITEA_CONTAINER" || die "Gitea 容器未运行"
+docker ps --format '{{.Names}}' | grep -qx "$JENKINS_CONTAINER" || die "Jenkins 容器未运行"
+
+# ---------- 1. 生成新凭据 ----------
+NEW_GITEA_PASSWORD="$(gen_pw)"
+NEW_JENKINS_PASSWORD="$(gen_pw)"
+NEW_DB_PASSWORD="$(gen_pw)"
+
+log "已生成新凭据(下方仅在 dry-run 时明文展示)"
+if (( DRY_RUN )); then
+ echo " Gitea 密码 : $NEW_GITEA_PASSWORD"
+ echo " Jenkins密码 : $NEW_JENKINS_PASSWORD"
+ echo " 数据库 密码 : $NEW_DB_PASSWORD"
+fi
+
+if (( DRY_RUN == 0 )); then
+ echo
+ log "⚠️ 即将轮换生产凭据,旧凭据立即失效。"
+ read -r -p "输入 'ROTATE' 继续: " C
+ [[ "$C" == "ROTATE" ]] || die "未确认,已中止"
+fi
+
+if (( DRY_RUN == 1 )); then
+ log "dry-run:以下操作均跳过。"
+ log "计划:1) Gitea 改密 2) 吊销 OAuth2 应用 3) 签发只读令牌 4) Jenkins 改密 5) 写回 .env"
+ exit 0
+fi
+
+# ---------- 2. 轮换 Gitea 用户密码 ----------
+log "----- [1/5] Gitea 用户密码 -----"
+# 从 app.ini 取管理员账号(第一个 uid=1 的用户)
+GITEA_ADMIN="${GITEA_ADMIN:-zhangxiang@novalon.cn}"
+docker exec -u git "$GITEA_CONTAINER" \
+ gitea admin user change-password --username "$GITEA_ADMIN" --password "$NEW_GITEA_PASSWORD" \
+ && log "✅ Gitea 用户 $GITEA_ADMIN 密码已轮换" \
+ || log "⚠️ Gitea 改密失败(账号名可能不对,请用 --env GITEA_ADMIN= 指定)"
+
+# ---------- 3. 吊销旧 OAuth2 应用 ----------
+log "----- [2/5] 吊销 Gitea OAuth2 应用(含泄露的 gto_cc5c...) -----"
+docker exec -u git "$GITEA_CONTAINER" \
+ gitea admin auth list-oauth 2>/dev/null || log " 列出 OAuth2 应用失败(版本差异,可界面处理)"
+log "ℹ️ 请核对上方列表,对 registry / jenkins 相关的应用执行:"
+log " docker exec -u git $GITEA_CONTAINER gitea admin auth delete-oauth --id "
+log " 或在 Gitea → 设置 → 应用 → 授权应用 手动撤销,再重新创建。"
+
+# ---------- 4. 签发新的只读令牌 ----------
+log "----- [3/5] 签发新的 Gitea 只读访问令牌 -----"
+NEW_TOKEN="$(curl -fsS --noproxy '*' -X POST \
+ -u "$GITEA_ADMIN:$NEW_GITEA_PASSWORD" \
+ -H 'Content-Type: application/json' \
+ -d '{"name":"jenkins-readonly-'$(date +%Y%m%d)'","scopes":["read:repository"]}' \
+ "$GITEA_URL/api/v1/users/$GITEA_ADMIN/tokens" \
+ | sed -n 's/.*"sha1":"\([^"]*\)".*/\1/p' || true)"
+
+if [[ -z "$NEW_TOKEN" ]]; then
+ log "⚠️ 自动签发失败,请手动:Gitea → 设置 → 应用 → 生成新令牌(仅勾 read:repository)"
+ read -r -p " 手动生成后粘贴令牌(可留空跳过): " NEW_TOKEN
+else
+ log "✅ 新令牌已签发(sha1 前缀 ${NEW_TOKEN:0:8}...)"
+fi
+
+# ---------- 5. 轮换 Jenkins admin 密码 ----------
+log "----- [4/5] Jenkins admin 密码(下次重启生效) -----"
+JENKINS_HOME_MOUNT=$(docker inspect -f '{{range .Mounts}}{{if eq .Destination "/var/jenkins_home"}}{{.Source}}{{end}}{{end}}' "$JENKINS_CONTAINER")
+if [[ -z "$JENKINS_HOME_MOUNT" ]]; then
+ docker volume inspect jenkins_jenkins_home >/dev/null 2>&1 \
+ && JENKINS_HOME_MOUNT="$(docker volume inspect -f '{{.Mountpoint}}' jenkins_jenkins_home)" \
+ || die "无法定位 JENKINS_HOME 挂载点"
+fi
+
+mkdir -p "$JENKINS_HOME_MOUNT/init.groovy.d"
+cat > "$JENKINS_HOME_MOUNT/init.groovy.d/99-rotate-admin-password.groovy" </dev/null; then
+ sed -i.bak "s|^${key}=.*|${key}=${val}|" "$ENV_FILE"
+ else
+ printf '%s=%s\n' "$key" "$val" >> "$ENV_FILE"
+ fi
+}
+upsert JENKINS_ADMIN_USER "${JENKINS_ADMIN_USER:-zhangxiang}"
+upsert JENKINS_ADMIN_PASSWORD "$NEW_JENKINS_PASSWORD"
+upsert GITEA_TOKEN "$NEW_TOKEN"
+upsert GITEA_DB_PASSWORD "$NEW_DB_PASSWORD"
+rm -f "$ENV_FILE.bak"
+log "✅ .env 已更新(权限 600)"
+
+# ---------- 收尾清单 ----------
+cat <