Files
blog/架构总览.md
T
zqlit 1568b150b3 docs: 全量梳理文档结构;补 Gitea 迁移指南 / 架构精简候选;架构总览补证书管家子系统
用户四问:「把项目文件全部理一遍 还有readme文件」「gitea 后面可能还要迁移 帮我写一个迁移文档」
「证书管家做完了吗」「感觉现在架构还是复杂了」

一、文档梳理:根目录 15 个 md → 2 个

根目录只留 README.md(入口)+ 架构总览.md(现状唯一事实源),其余全部归位:

  docs/
  ├── README.md              ← 新增:文档地图(入口 / 当前有效 / 历史归档 三层)
  ├── 证书管家.md             ← 原「函数版证书管家-方案.md」改名
  │                              (它早已是「现状+沿革」文档,标题名不副实 —— 签发早已不在 CF Worker)
  ├── Gitea迁移指南.md        ← 新增
  ├── 架构精简候选.md         ← 新增
  ├── CNB构建落地方案.md / GITEA_SECRETS.md
  └── archive/{平台与选型,功能与修复,主题与内容}/   ← 32 份

- 32 个归档文档统一加 `> 📦 本文档已归档` banner,并指向架构总览
  —— 这个仓库历史文档里全是「权威口径」,混看很容易拿废弃结论当现状
- gitea-backup/ → deploy/gitea/(原名「backup」不准,它是部署包;与其它 deploy 单元并列)
- 新增 md 链接校验(python 脚本,中文路径用 sed 不安全)→ 首轮 18 处断链
  (多一层目录要让 banner 里的相对路径补一个 ../)→ 修正后 0 断链

二、新增 docs/Gitea迁移指南.md(★ 有死线:境外 VPS 11 月到期)

★ 核心价值是「指出迁移面已大幅缩小」:仓库原有的 deploy/gitea/迁移前检查清单.md
(2026-10-04)是为「Gitea + act_runner + 又拍云同步 *整套* 搬迁」写的,**那个前提已经不存在**——
构建归 CNB,runner / upyun-sync / 中转机 / COS 都不需要了。迁移实际只剩「搬数据卷 + 改地址」。

★★ 全文最重要:本仓有 9 处写死了旧地址,按易漏程度排序(1-3 本机,4-5 线上)
  1 本机 .git/config 的 gitea remote
  2 scripts/setup-cnb-remotes.sh 的 GITEA_URL 默认值
  3 deploy/editor-api/bootstrap.sh 的 GITEA_URL 默认值
  4 ★ /srv/editor-api/.env 的 GITEA_URL      ← 漏了会「持续报错但只警告不阻断」,没人发现
  5 ★ /srv/blog 的 gitea remote               ← 同上
  6 docs/GITEA_SECRETS.md
  7-9 架构总览.md / README.md / editor-api/README.md
(不用改:docker-compose.editor.yml、server.mjs、pushall 别名 —— 只引用 remote 名字)

另含:建议新实例改用域名而非 IP(以后搬机器只改 DNS;⚠️ Gitea 28 起只读 ROOT_URL 不读
[server] DOMAIN);rsync 而非 dump(属主必须 uid/gid 1000);切换顺序「先建新的→验证→再拆旧的」;
验证清单强调 `git push gitea --dry-run`;第九节给出「干脆不留这个辅仓」的选项与判断依据。

顺带修掉两个硬伤:
- deploy/gitea/docker-compose.yml 的镜像 tag `1.28.0-rootless` 根本不存在
  (Gitea 28 起去掉 1. 前缀)→ 改 28.0.0-rootless(pin 死,不用 latest)
- deploy/gitea/.gitignore 漏了 backups/(跑一次备份就会把含 secrets 的 dump 写进历史)

三、证书管家核实(结论:已上线运行,2 个遗留)

线上实测:容器 Up (healthy);/preflight 7/7 全过;3 组域名;下次自动续期 04:10。

遗留 ① writeapi.usj.cc 证书没纳管(真问题):nginx 配置在 /www/conf.d/writeapi.usj.cc.conf,
不在 /www/sites/ 管理树里 → 1Panel 的 ssl/upload+sslID 物化碰不到它。
实测仍是 RSA / CN=usj.cc / 到期 2026-12-07,本项目续期不会更新 → 12 月会断。
遗留 ② dnsapi.usj.cc 没上 HTTPS。
(澄清假问题:200181.xyz 公网看到的 LE 证书是 CF 自家边缘证书,与本项目无关)

四、新增 docs/架构精简候选.md(回应「架构还是复杂了」)

结论:复杂度不在件数,在「跨 6 个环境,其中 4 个要自己维护」。国内机 9 个容器里属于本项目的
只有 3 个,能动的只有 2 个。5 个候选 + 建议执行顺序:
  1 停 certimate 容器(工作流已全停用)——先 stop 观察,别急着删
  2 cn-dns-helper 大概率已是遗留(「Worker 侧签发」时代产物;现在签发在国内机且自带 dnsprovider,
    /preflight 显示 CF 凭据可读;Worker 侧 dnsremoted.ts 已无任何引用)
  3 Gitea:迁 or 不留
  4 两套写作前端收敛(本轮不动,先看使用频率)
  5 writeapi.usj.cc 证书纳管(12 月死线)
并明确列出 6 项「必要复杂度,不建议动」。

五、架构总览补齐证书管家子系统(★ 之前完全缺席)

一个完整子系统在「事实源文档」里一个字都没有 —— 这本身就是文档债。补:
- §0 一句话(四→五个子系统)、§1.1 子系统表、§1.2 地址地图两行
- **新增 §5.7 证书管家**:职责划分 / 为什么非要这么分(Worker 免费版 CPU 10ms)/
  三条不能破的红线(Worker 不得签发 · 证书路由只认 Bearer 会话 · 角色必须正着枚举放行)/
  纳管域名表 / 与 certimate 的关系 / 已知遗留
- .cnb.yml 行数 284 → 337(数字漂了);头部更新时间 → 2026-10-06;附录表改指 docs/
- §6 待办:#13(证书遗留)、#14(复杂度盘点)新增;#7 改写为「Gitea 11 月到期,★ 有死线」

六、记忆

.workbuddy/memory/MEMORY.md 新增「文档结构」「Gitea 迁移死线」两节;证书管家节补实测与遗留。
2026-10-06 22:27:36 +08:00

524 lines
36 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 优世界博客 · 架构总览
> 更新时间:2026-10-06
> 状态:**迁移已落地并跑通**(四线路全绿,单次发布约 3.5 分钟)
> 本文档只描述**现状**;决策期的一次性评估与已完结方案见 [`docs/archive/`](docs/archive/)。
> 文档地图见 [`docs/README.md`](docs/README.md)。
---
## 0. 一句话
**五个子系统**,一条 **3 步**的托管发布链路。构建与发布已从「自建 Gitea + act_runner + 广州中转机」迁到
**腾讯云 CNB(cnb.cool)** —— 发布链路上**需要自己运维的机器从 3 台降到 0 台**
(另有一台国内机跑写作后台与证书签发,但都不在构建链路里)。
---
## 1. 全景
### 1.1 五个子系统
| # | 子系统 | 技术栈 | 跑在哪 | 状态 |
|---|---|---|---|---|
| **1** | **内容系统** | Hugo 0.128.2 extended + Ying 主题,138 篇 md;产物约 3200 文件 / 424MB | 产物分发到 3 个 CDN | 正常 |
| **2** | **评论系统** | `blog-admin/` = artalk-cf(Artalk v2 兼容服务端)+ RSS 机器人 | Cloudflare Workers + D1 + KV → `api.200181.xyz` | ✅ 零服务器零运维 |
| **3** | **写作系统** | `write-server/`(Next.js 16,`post.usj.cc`)<br>`write/`(本地 Windows 前端)<br>`editor-api/`(国内机轻量后端,实为线上那条发布入口) | 独立 Docker 主机/本机/国内机 | 可用(两套前端功能重叠) |
| **4** | **发布基础设施** | **CNB 流水线**(`.cnb.yml`)+ 又拍云 + 多吉云 + EdgeOne | 腾讯云 CNB(**托管**) | **已跑通** |
| **5** | **证书系统** | 证书管家(自研):ACME 签发 + DNS-01 + 多目标部署(多吉云 / 1Panel) | **国内机 Docker `cn-certkeeper`**(签发+部署)<br>+ CF Worker(**只读**:监控/后台/探针) | ✅ 已上线,每日 04:10 自动续期(详见 §5.7) |
### 1.2 服务与地址地图
| 角色 | 地址 / 位置 | 说明 |
|---|---|---|
| **代码主仓** | `cnb.cool/zqlit/blog` | CNB,**私有**;构建由它触发;唯一 fetch 源 |
| **代码辅仓** | 自建 Gitea `23.254.236.47:3001/zqlit/blog` | 只推不拉,纯代码留档;**不受第三方平台规则约束**(见 §5.2) |
| **写作后台** | 国内机 `119.29.215.187` → `127.0.0.1:8017`(容器 `editor-api`) | 轻量 Node 后端(零 npm 依赖),经 CF Worker 转发;发布时**推 `origin`(CNB) + `gitea`**(见 §5.2) |
| **异地备份** | 中兴 F50 上的 OpenList(`/本地/备份/blog-bundle/`) | **备选**异地备份:整仓加密 bundle,计划任务每天 03:30 自动跑,**不依赖任何 git 服务**(见 §5.6) |
| **构建 + 发布** | CNB 流水线(腾讯云国内节点,4 核 8G) | 托管,0 元(免费额度内) |
| **密钥仓库** | `cnb.cool/zqlit/blog-secrets` | CNB「密钥仓库」类型,10 项变量经 `imports` 注入 |
| **境内源站** | 又拍云对象存储 | 由 CNB 国内节点**直传**(原为 `if: false` 的死代码) |
| **国内加速** | 多吉云 CDN | 回源又拍云 |
| **境外线路** | EdgeOne Pages(项目 `hugo-blog`,`--area overseas`) | 腾讯 EdgeOne 国际站 |
| **评论后端** | `api.200181.xyz` | Cloudflare Workers |
| **证书签发** | 国内机 `119.29.215.187` → `127.0.0.1:8019`(容器 `cn-certkeeper`) | ACME 签发 + DNS-01 + 多吉云/1Panel 部署;`deploy/cn-certkeeper/`,每日 04:10 续期 |
| **证书只读面** | `api.200181.xyz/api/v2/ssl*` | CF Worker 上的监控 / 后台 / 探针,**只读**(`/ssl/issue` 已改 501 硬拒绝,见 §5.7) |
| **通知** | QQ 邮件(`imql@qq.com`) | 已收敛为**单一通道**;同时承担备份失败告警(§5.6) |
> 关键点:境内、境外仍是**两条独立线路**,但**由同一条流水线一次推完** ——
> 这是本次迁移最大的结构性改善。
---
## 2. 一次发布的完整旅程(3 步)
```
① 写作 write-server(网页) 或 write/(本地 Windows)
│
② git push git pushall = git push origin main ; git push gitea main
│ ├─ origin → cnb.cool/zqlit/blog (**主仓**,触发构建)
│ └─ gitea → 23.254.236.47:3001/zqlit/blog(自建 Gitea,代码同步辅仓)
│ ★ 备选异地备份不走 git 远端 —— 由本机计划任务每天 03:30
│ 跑整仓加密 bundle 传到 F50 上的 OpenList(§5.6)
▼
③ CNB 流水线(国内节点,约 3.5 分钟,7 个 stage 顺序执行)
├─ 1. Hugo 构建 草稿隐藏预处理 → hugo --minify --gc → 算构建哈希
├─ 2. 同步到又拍云 upx sync(境内直传,非阻断但如实上报)
├─ 3. 刷新又拍云 CDN purge 首页 + sitemap/rss/archives/posts 等固定入口
├─ 4. 刷新多吉云 CDN node scripts/refresh_cdn.js
├─ 5. 部署 EdgeOne edgeone pages deploy --area overseas
├─ 6. 上报部署状态 POST api.200181.xyz/api/deploy-status
└─ 7. 邮件通知 成功 → stages 末尾;失败 → failStages
```
**对照**:迁移前是 **7 步,中间有 3 个环节是你自己运维的服务器**。
> **两条推送入口,同一份语义**:上面第 ② 步是**本机**的 `git pushall`;
> **线上写作后台**(国内机 `editor-api` 容器)走的是同一套 ——
> `PUSH_REMOTES=origin,gitea`,在 `/srv/blog` 工作区里逐个远端推,
> **主仓(CNB)成功即算发布成功**,辅仓(Gitea)失败只警告不阻断。
> 两边都要遵守同一条铁律:**任何提交都必须先落到 CNB** ——
> pull 源只有 `origin`,只推 gitea 的提交后台看不见(见 §5.2)。
---
## 3. 流水线细节(`.cnb.yml`,337 行)
| 触发 | 条件 | 说明 |
|---|---|---|
| push | 推送到 `main` | 主链路 |
| crontab | `0 9 * * *`(Asia/Shanghai) | 每天 09:00,与 push **共用同一组 stage**(YAML 锚点) |
| 机制 | 实现 |
|---|---|
| 构建镜像 | `deploy/Dockerfile`,hugo 二进制**随仓库携带**(`bin/linux/hugo`,linux/amd64) |
| ⚠️ docker 上下文 | `by: [bin/linux/hugo]` —— CNB 的 docker build **只看得见 Dockerfile + by 列出的文件**,漏写会报 `not found` |
| 密钥注入 | `imports: https://cnb.cool/zqlit/blog-secrets/-/blob/main/secrets.yml` |
| stage 间传状态 | 落盘 `.ci_status`(原版靠 `needs.*.outputs`) |
| 并发控制 | `lock.cancel-in-progress`(对应原 `concurrency`) |
| 非阻断 | 又拍云三步 + 状态上报 + 邮件用 `allowFailure: true`,失败仍写状态如实上报 |
| 资源 | `cpus: 4`(4 核 8G) |
---
## 4. 迁移前后对比
| 维度 | 迁移前(Gitea 自建) | 迁移后(CNB) |
|---|---|---|
| 发布链路环节 | **7 步** | **3 步** |
| 自维护机器 / 服务 | **3 台**(Gitea 主机 + act_runner + 广州中转机) | **0**(构建托管) |
| 流水线定义 | GitHub Actions **1169 行** | `.cnb.yml` **284 行** |
| 产物传递 | 打 `tar.zst` → artifact → 中转机跨境下载 | 同一 pipeline **共享工作目录**,无需传递 |
| 又拍云直传 | `if: false`(境外 runner 必挂,**从未跑通**) | **已跑通**(国内节点直传) |
| COS 中转层 | 需要(存储 + 流量账单) | **已砍** |
| `github.server_url` 兼容分支 | **8 处** | **0** |
| 通知 | 3 套(TG / 飞书 / 邮件),约 400 行 | **1 套**(邮件) |
| 图片优化 + 反向 commit | CI 内每轮白跑(15 张老图处理不掉) | **未迁**(如需保留,照搬 `scripts/optimize_images.js`) |
| 构建环境 | 境外 VPS | 腾讯云**国内节点** 4 核 8G |
| 费用 | VPS 月租 | **0 元**(100GiB 仓库 + 160 核时/月) |
| 单次发布耗时 | — | **约 3.5 分钟** |
> 原链路里那些补丁(COS 中转 / `sync.sh` 每分钟轮询 / artifact 打包 / 兼容分支)
> **唯一根因是「runner 在境外」**。CNB 节点在国内,这一整层随之消失。
---
## 5. 运维要点
### 5.1 推送
```bash
git pushall # CNB 与 Gitea 都推 —— 发布用这个
git push origin main # 只推 CNB(触发构建)
git push gitea main # 只推 Gitea 辅仓
```
`git pushall` 的实际定义:
```
!git push origin main; ec=$?; git remote | grep -qx gitea && git push gitea main; exit $ec
```
**本仓有两个 git 远端:`origin`(CNB 主仓)+ `gitea`(自建 Gitea 代码同步辅仓)。**
演变路径:GitHub 双推 →(GitHub 被 AUP 清空)→ 一度只剩 `origin` → 现在回到双远端。
注意 Gitee 从头到尾**没真正建起来过**(卡体积门槛,见 §5.3),别把它记成一代。
三条设计上的取舍:
- **不用「一个 remote 挂多个 pushurl」** —— 实测 git 是「顺序推、遇错即停」,
第一个失败后面的都不推,备份意义就没了。所以用「独立 remote + 别名」。
- 别名用 `;` 串联而非 `&&`,正是为了让一段失败不影响另一段(主仓挂了辅仓照样推);
再 `exit $ec` 把退出码还给主仓,免得辅仓成功掩盖主仓失败。
- gitea 那一段带 `git remote | grep -qx gitea` 前置判断 ——
没配辅仓时静默跳过,而不是白报一行「'gitea' does not appear to be a git repository」。
### 5.2 远端
| remote | 地址 | 角色 | fetch | push |
|---|---|---|---|---|
| `origin` | `https://cnb.cool/zqlit/blog.git` | **主仓**(CNB) | ✅ 唯一 fetch 源 | ✅ 触发 CNB 构建 |
| `gitea` | `http://23.254.236.47:3001/zqlit/blog.git` | **代码同步辅仓**(自建,Gitea 28.0.0) | ⛔ 只推不拉 | ✅ 代码留档,失败不阻断 |
- **Gitea 凭据编在远端 URL 里**(`http://zqlit:<密码>@…`):容器/脚本环境没有交互终端、
也没有可用的凭据助手,这是让 `git push gitea` 免交互跑通的唯一简单写法。
明文只落在本机 `.git/config`(以及服务器上容器内的同名文件),**不进仓库、不随之推送**。
⚠️ 口令里若含 `#` 或 `&` 会被拼 URL 的 `sed` 吃掉 —— 那种情况请改用访问令牌。
- 自建 Gitea **不参与构建**:`.github/` 已删、Actions 早已停用,它纯粹是代码副本。
当前版本 `28.0.0`,走 HTTP + 3001 端口,本机直连即可(**不需要**广州中转机,
与当初「本机跑不通 git smart HTTP」的旧结论不同 —— 那次是沙箱代理的问题)。
- 接入时的同步基线:Gitea 停在 `ed38f938`(2026-10-04),落后 57 个提交,
且是本地 HEAD 的**祖先** → 一次 fast-forward 就追平,**无需强推、不丢历史**。
**线上 editor-api 容器的推送目标**(2026-10-06 起,国内机 `119.29.215.187`)
—— 「CNB 主仓 + Gitea 备份仓 + OpenList 文件备份」这条定案在容器侧的落点:
```ini
# /srv/editor-api/.env
PUSH_REMOTES=origin,gitea
```
- 容器挂载 `/srv`,仓库工作区 `/srv/blog`,同一份 `.git/config` 里挂着 `origin`(CNB) 与 `gitea`
- 发布语义(`editor-api/src/git.mjs`):**逐个远端推,主仓成功即算发布成功**,
辅仓失败只在返回里带一条 warning、不阻断 —— 所以 Gitea 挂了不会让文章发不出去
- **pull 源永远只有 `origin`**:所以在别处**只推了 gitea** 的提交,后台是看不见的
(`git pull` 不会从它拉),下次发布会因 non-fast-forward 被拒。
→ **铁律:任何提交都必须先落到 CNB。**
- 国内机实测可直连自建 Gitea(`200` / 0.38s),**不需要中转机**
- 验证方式(改完必做):`curl -s http://127.0.0.1:8017/health` 看 `remotes` 字段,
容器日志里也有一行 `[editor-api] 分支 main 推送远端 origin, gitea`;
写权限用「推临时分支 → `ls-remote` 确认 → 删分支」实测,别用 `--dry-run`(会因落后误报)
已移除的远端(改动前的完整配置快照留在 `.workbuddy-backup/git-remotes.*.txt`):
| remote | 地址 | 移除于 | 原因 |
|---|---|---|---|
| `gh` | `github.com/zqlit/blog` | 2026-10-06 | 账号被标记、仓库被按 AUP 清空(见下),**别再往回加** |
| `gitee` | (从未真正建立过) | — | 两条硬门槛过不去(§5.3) |
> ⚠️ **GitHub 退役记录(2026-10-06)**
>
> 起因:GitHub 账号被平台标记,随后仓库被按 AUP 合规条款清空 ——
> 远端留下一条**孤立提交**(无父提交):
>
> ```
> c21d5669 2026-10-06 20:08:32 +0800 zqlit <zqlit@users.noreply.github.com>
> chore: remove repository content (AUP compliance)
> ```
>
> 同时远端 `main` 的历史与本地**完全分叉**(两边只共享 2024-07-11 的 `Initial commit`,
> 之后每个提交 SHA 都不同;远端那份是 989 提交、剥离了 `.env`/私钥/大二进制的变体)。
> 教训:**别把「备份」寄托在会对内容做合规处置的平台上**;
> 且一旦某次历史被重写,SHA 从此再也对不上。
>
> 本项目随后清掉了 GitHub 的**全部**痕迹:remote、推送别名、发布链路里 4 处硬编码
> (`bootstrap.sh` / `docker-compose.editor.yml` / `server.mjs` / `README.md`)、
> 以及 CI 定义本身(整个 `.github/` 目录,1268 行)。
### 5.3 远端与备份的分工(2026-10-06)
**结论:git 辅仓用「自建 Gitea」,备份用「整仓加密 bundle → OpenList」。两者都要,各管一段。**
| 层 | 载体 | 保住什么 | 依赖 |
|---|---|---|---|
| 主仓 `origin` | CNB | 源码 + 触发构建 | CNB 平台 |
| 代码辅仓 `gitea` | 自建 `23.254.236.47:3001` | 随时 `clone` 回来、按提交追溯 | **自己的机器** |
| 离线备份 | F50/OpenList 上的加密 bundle(§5.6) | 完整历史 + 所有对象,平台全挂也能恢复 | 家里的局域网 |
两层的区别不是「多一份」,而是**失效模式不同**:辅仓是**在线、可增量、可浏览**的副本,
但仍在别人的磁盘上;bundle 是**离线、离线介质、单一文件**的全量快照,
连 git 服务都没有也能恢复。所以辅仓不能替 bundle,bundle 也不如辅仓顺手。
**为什么不选「另一家托管平台」当辅仓**(Gitee / 云效 Codeup / GitLab):
把副本放到**另一家同样性质的托管平台**上,只是把鸡蛋从左边口袋挪到右边口袋 ——
GitHub 那次 AUP 清空就是活证。自建 Gitea 是唯一「不受第三方规则约束」的在线选项,
它早在建站初期就在跑,本就该留着。
下面这些实测数据保留下来 —— 下次再冒出「换一家托管平台当辅仓」的念头时,
这几张表就是答案。
体积分布(HEAD,实测 457.4 MB / 2509 文件):
| 目录 | 体积 | 说明 |
|---|---|---|
| `content/`(含 `posts/`) | ≈ 308 MB | 博客正文与媒体(mp4/mp3/大图),**搬不走** |
| `bin/linux/hugo` | **83.1 MB** | CNB 构建用的 hugo 二进制,**可移出 git** |
| `static/` | ≈ 38 MB | 站点静态资源 |
| `themes/` | 20.3 MB | Ying 主题 |
| 其余全部 | ≈ 8 MB | 代码、文档、脚本 |
**Gitee 的两条硬约束 —— 都不满足:**
| 约束 | Gitee 免费版 | 本仓库当前 | 判定 |
|---|---|---|---|
| 单仓库容量 | **≤ 500 MB** | `.git` **620 MB** | ❌ 超 24% |
| 单文件大小 | **≤ 50 MB** | `bin/linux/hugo` **83.1 MB** | ❌ **必删** |
| 用户总仓库容量 | 5 GB | 620 MB | ✅ |
| 私有仓协作人数 | 5 人 | 个人 | ✅ 无关 |
`bin/linux/hugo` 是**硬门槛**:**不管怎么瘦身历史,只要它还跟踪在 HEAD 里,Gitee 一律拒收**。
→ 移出后 HEAD ≈ **374 MB**,才有一份余量。
**对照:阿里云效 Codeup 基础版 —— 两条硬约束一条都不存在:**
| 项 | Gitee 免费版 | 云效 Codeup 基础版 |
|---|---|---|
| 单库容量 | ≤ 500 MB | **Git 5 GiB + LFS 5 GiB** |
| 单文件 | ≤ 50 MB | **Web 50 MB / 命令行 200 MB** |
| 620 MB 的 `.git` | 超 24%,推不上 | 占 **12.1%** ✅ |
| 83.1 MB 的 hugo | 超 66%,一律拒收 | 占 **41.6%** ✅ |
| 价格 | 免费 | 0 元/人/年,不限人数、不限仓库数 |
→ 若选 Codeup,`bin/linux/hugo` **不必出库**,`deploy/Dockerfile` 与 `.cnb.yml` 的 `by:`
字段**一行都不用改**(首推 620 MB 也在其 60 分钟推送超时内)。
注意两点:① 默认「中心组织」**只支持阿里云 RAM 账号登录、且必须绑钉钉**,
不接受的可选 2025-12 上线的「地域组织」(华东2上海,支持自建账号密码);
② 基础版无「删库保护」,容量到 90% 提醒、**超限直接禁止写操作**(连删文件都做不了)。
已有的阿里云 OSS AccessKey **不能**用来建 Codeup 仓库 —— git 推送走独立的克隆账号密码或 SSH Key。
> ★ **这些都只是「换一个篮子」,不是容灾。**
> CNB 与 Gitee/Codeup 同属国内大区;GitHub/GitLab 又执行同一套合规逻辑
> (本轮 GitHub 被按 AUP 清空即为例证)。
> 所以**托管平台这条路一个都没采用** —— 在线辅仓回到**自建 Gitea**(§5.2,
> 不受第三方规则约束),另加一层不依赖任何 git 服务的离线备份(§5.6)。
**顺带一个副作用**:`bin/linux/hugo`「出库」这件事**不用做了**。
那个 83.1 MB 二进制之所以成为问题,只因为它卡在 Gitee 的单文件 50 MB 上限上。
既然不走 Gitee,`deploy/Dockerfile` 的取 hugo 方式与 `.cnb.yml` 的 `by:` 字段
**一行都不用改**。
### 5.4 凭据
- 令牌存**仓库外**:`~/.workbuddy/secrets/cnb-token`
- 本仓 `credential.helper` 指向一个自定义脚本;`.git/config` **无明文**
- ⚠️ 本机全局 helper 是 **GCM**(会弹窗、对第三方 HTTPS 远端还会挂死);wincred 对 cnb.cool 有过「幽灵记录」删不掉 → 故用自定义 helper 绕开
### 5.5 密钥
- 修改密钥 → 编辑 CNB 密钥仓库的 `secrets.yml`(**网页编辑,禁 clone**)
- 本地 `cnb-secrets.yml` 只是粘贴草稿(已 gitignore,**不入库**)
- ⚠️ CNB 的 `imports` 是**一层映射**(key 就是变量名本身);又拍云服务名在 CNB 里必须叫
**`UPYUN_SERVICE`**(旧 Gitea 里叫 `UPYUN_BUCKET`,照抄会「变量未定义」)
### 5.6 整仓离线备份(中兴 F50 / OpenList)★ 备选异地备份
> **定位**:2026-10-06 起这一层是**备选**异地备份 —— 在线那一路已有自建 Gitea 代码辅仓(§5.2)。
> 但它的地位并没有因此变轻:**它是唯一一层「不依赖任何 git 服务」的备份**,
> 平台全挂、账号被封、服务器被回收都影响不到它。所以它的**可靠性**与**失败可见性**
> 依然是最要紧的:一旦悄悄失效,能挡住「平台级事故」的就只剩它。
> 失败告警因此是必备件,不是加分项。
**目标介质**:中兴 F50 5G CPE 内置 256 GB 存储,上面跑 OpenList
(`http://192.168.0.1:5244`,WebDAV 端点**必须带 `/dav` 前缀**)。7×24 常开、走局域网、
**不依赖任何 git 平台账号** —— 这是它比「再选一个代码托管平台」更值钱的地方。
**存放位置**:`/本地/备份/blog-bundle/` —— **专属子目录,不是 `/本地/备份/` 根下**。
根目录由用户自己在用(放着 `github-zqlit-*`、`local-repos-*` 等手工备份),
把文件混进去既容易被误删,也让「清理旧份」多一层风险。
(早期版本确实直接写在根目录下,实测发现文件已被清掉,故改为子目录隔离。)
**为什么是 bundle 而不是直接推 git**:WebDAV 不支持原子的 rename/lock,
把 bare repo 挂上去直接 `git push` 会让对象写坏 —— 表面成功、实际随机损坏,
可能几个月后才发现。bundle 是**单文件顺序写**,没有这个问题。
**为什么加密**:本仓库历史里含 `.env`、TLS 私钥、`docs/GITEA_SECRETS.md`。
介质是一台随身设备的内部存储,明文等于把密钥放在可能丢失 / 刷机 / 送修的设备上。
OpenList 的登录只保护**访问通道**,不保护**存储介质** —— F50 丢了拆开就能读。
**为什么不用 gpg**:本机 gpg 2.4.9 在 Windows 下已损坏(反复 `removing stale lockfile`,
node spawn 直接 `EBUSY`)。改用 Node 内置 `crypto` 的 **AES-256-GCM**:
零外部依赖、带认证标签(能检测篡改/截断)、可流式处理 600 MB 不爆内存。
**★ 打包前必须先 `git fetch --all`**(2026-10-06 补,此前是个静默漏洞):
`git bundle create --all` 取的是**本地已知**的 ref —— 其中 `refs/remotes/origin/main`
停在上一次 fetch/pull 的位置。而这份备份跑在**家里那台机器**上,它的工作区
并不会随写作后台(editor-api)的发布自动更新。不 fetch 就打 bundle,等于
**把过期的快照当备份**:后台最近发的文章一篇都不在里面,而且**备份照样报"成功"**。
→ 所以现在先 fetch 再打包;**fetch 失败不致命**(离线也得出得来备份),
只降级为「用本地已有 ref 打包」并在日志里显著告警、失败邮件里也点名这一条。
跳过用 `--no-fetch`。
日志里那句 `快照 origin/main = <sha> <日期> <标题>` 是这份备份的"封面" ——
恢复时第一件要确认的就是它,别只看"备份成功"。
**加密文件布局**:`magic(8) | salt(16) | iv(12) | 密文(...) | GCM tag(16)`,
密钥由 `scrypt(N=32768, r=8, p=1)` 从口令派生。
**用法**(`scripts/backup-bundle.mjs`,零 npm 依赖):
```bash
# 推荐入口:带失败告警的包装器(计划任务用的就是它)
node scripts/backup-run.mjs # = backup-bundle 的默认参数
node scripts/backup-run.mjs --verify --keep 7 # 计划任务的默认组合
node scripts/backup-run.mjs --dry --notify-success # 试跑并强制发一封成功邮件
# 底层脚本(backup-run.mjs 就是转发到它)
node scripts/backup-bundle.mjs # fetch + 加密 + 上传 + 比对字节数
node scripts/backup-bundle.mjs --verify # 额外下载回来比对 sha256(端到端最可靠)
node scripts/backup-bundle.mjs --dry # 只打包加密,不上传
node scripts/backup-bundle.mjs --no-fetch # 跳过 fetch(离线/调试)
node scripts/backup-bundle.mjs --keep 7 # 远端保留最近 7 份(默认值)
node scripts/backup-bundle.mjs --list # 列出远端现有备份
node scripts/backup-bundle.mjs --decrypt <文件> [--out x.bundle] # 恢复用
```
**配置**(按序查找,先找到生效):环境变量 → `~/.openlist-backup.env`
→ `.workbuddy-backup/openlist-backup.env`(**已在 .gitignore 内**)。
| 键 | 用途 |
|---|---|
| `OPENLIST_URL` / `OPENLIST_USER` / `OPENLIST_PASS` | OpenList 地址与账号 |
| `OPENLIST_DIR` | 远端目录,当前 `/本地/备份/blog-bundle` |
| `BACKUP_PASSPHRASE` | bundle 加密口令 |
| `SMTP_HOST` / `SMTP_PORT` / `SMTP_USER` / `SMTP_PASS` / `SMTP_TO` | 失败告警发信(复用 CNB 流水线那套 QQ 邮箱授权码) |
> ⚠️ `BACKUP_PASSPHRASE` 是恢复 bundle 的唯一钥匙,**必须另存进密码管理器**。
> 它不构成单点:CNB 主仓与自建 Gitea 辅仓都还是明文副本,口令丢了只是少一份备份。
**失败告警**(`scripts/backup-run.mjs`)—— 这是「离线那一层」的必备件。
跑完备份后,退出码非 0 就通过 `scripts/send_mail.js` 发一封告警邮件
(主题带 `★`,正文附日志尾部与常见原因清单)。成功时默认**不发**(避免每天一封的邮件疲劳),
加 `--notify-success` 才发。
> 为什么非做不可:「每天自动跑」的任务最典型的失败模式恰恰是**静默**的 ——
> F50 被带出门、换了网段、OpenList 重启后没起来、WebDAV 口令改过……
> 没人会主动发现,直到真要用备份那天。
> 发信逻辑独立于备份逻辑:**发信失败不改判备份的退出码**,通知不该掩盖真正的故障。
**定时任务**:Windows 计划任务 `Blog-BundleBackup`,每天 **03:30(本地)**
执行 `scripts/backup-task.cmd`(默认 `--verify --keep 7`)。
设置:`InteractiveToken` + `LeastPrivilege`、`StartWhenAvailable`(错过就补跑)、
`ExecutionTimeLimit=PT1H`、`MultipleInstances=IgnoreNew`(防重入)。
日志追加到 `.workbuddy-backup/logs/backup.log`,超 5 MB 自动轮转。
> 保留 **7 份 = 一周窗口**(每份 605.5 MB ≈ 4.2 GB,F50 有 256 GB)。
> 原本是 3 份;定位成「离线那一层备份」后放宽 —— 空间不值钱,回溯窗口值钱。
★ `backup-task.cmd` **内容必须全 ASCII**:cmd.exe 按**当前代码页**(zh-CN 是 GBK)
解析批处理文件,而 node 输出 UTF-8;UTF-8 中文注释会吞掉 CR/LF、
把下一行当命令执行(实测踩到:一条 `rem` 被当命令跑了)。
ASCII 是 UTF-8 子集,纯英文注释与 node 的中文输出混写不会乱。
**恢复流程**:
```bash
node scripts/backup-bundle.mjs --decrypt blog-<时间戳>.bundle.enc --out blog.bundle
git bundle verify blog.bundle # 应报 "records a complete history"
git clone blog.bundle blog # 或 git fetch blog.bundle 'refs/*:refs/*'
```
**实测数据**(2026-10-06):
| 阶段 | 耗时 |
|---|---|
| `git bundle create --all` | 6.8 ~ 9.9 s(605.5 MB) |
| AES-256-GCM 加密 | 1.3 ~ 2.9 s(体积不变) |
| WebDAV 上传 | 19.2 ~ 20.6 s → **29.4 ~ 31.6 MB/s** |
| 下载回读 + sha256 比对 | ≈ 30 s,**一致** |
端到端退出码 0;恢复链路已演练(解密 → `git bundle verify` → 1008 提交完整一致);
失败告警的邮件链路也已实测(发出一封「备份成功」验证信到 `imql@qq.com`)。
**覆盖范围(够到哪儿、够不到哪儿)—— 别把「备份成功」当成"什么都备了"**
| 内容 | 在不在这一层里 |
|---|---|
| 博客正文与附件 `content/**` | ✅ 在 —— 打包的是**仓库全量对象** |
| 通过写作后台(editor-api)发布的文章 | ✅ 在 —— 前提是**打包前 fetch 成功**(见上,这正是加 fetch 的原因) |
| `.env` / TLS 私钥等历史里的敏感文件 | ✅ 在(**这才需要加密**) |
| 国内机 `/srv/editor-api/{.env,docker-compose.yml}` | ❌ **不在** —— 仓库外,且国内机够不到家里的 OpenList |
最后一行是**已知缺口**,但影响可控:那几个文件是**可重建的**,不是不可替代的数据 ——
`docker-compose.yml` 由 `deploy/editor-api/bootstrap.sh` 生成(在仓库里),
`EDITOR_TOKEN` 丢了就重新生成并同步到 Worker,`ADMIN_PASS` / Gitea 口令都是可轮换的。
真正不可再生的只有 **git 历史(含 content 全量)**,而它已经在里面了。
(若哪天想连这几个文件也一起备,走 1Panel API 抓回来塞进同一份上传即可 —— 家庭机抓得到国内机。)
---
### 5.7 证书管家(子系统 5)
**职责划分 —— 一句话:签发与部署在国内机,Worker 只有只读。**
| 层 | 职责 | 跑在哪 |
|---|---|---|
| 签发 + 部署 | ACME(LiteSSL,EAB 继承 certimate 账户)· DNS-01 · 写多吉云 CDN · 写 1Panel | **国内机 Docker `cn-certkeeper`**,只绑 `127.0.0.1:8019`,每日 **04:10** 自动续期 |
| 只读 | 监控 / 后台页面 / 探针 | CF Worker(`blog-admin/`),路由 `/api/v2/ssl*` |
**为什么非要这么分**:CF Worker **免费版 CPU 硬顶 10ms/请求**(cron 同样 10ms),
签发要跑十来次 JWS 签名,**根本跑不动**;而 `[limits] cpu_ms` 在免费版会让**整个部署失败**(code 100328)。
用户否决了 CF Paid($5/月 ≈ ¥36/月 —— 比这台本来就在跑的国内机还贵)。
**三条不能破的红线**:
1. **Worker 不得签发** —— `POST /ssl/issue` 已改成 **501 硬拒绝**,证书 cron 已从 `wrangler.toml` 移除
2. **证书路由只认 Bearer 会话**,绝不用 `isAdminRequest` —— 后者有 Artalk 老客户端兜底
(`?name=<管理员名>&email=<管理员邮箱>` 即视为管理员),而这俩是**公开信息**。
一旦混用,任何人拼 query 就能读走 TLS 私钥。
3. **角色判定必须正着枚举放行**(`role === 'admin' || role === 'ssl'`),
禁止写成 `role !== 'editor'` —— 以后新增角色会**静默获得私钥权限**。
(角色体系见 `blog-admin/`;`ssl` 与 `editor` **平级、互不包含**)
**纳管域名**(唯一事实源 `blog-admin/tools/certkeeper-config.mjs`):
| 域名 | 部署目标 | 备注 |
|---|---|---|
| `usj.cc` | 多吉云 CDN + 1Panel | SAN 为 `usj.cc;*.usj.cc` |
| `t-t.live` | 1Panel(5 个站点) | 本项目**已接管**,certimate 的竞争工作流已停用 |
| `200181.xyz` | 1Panel | |
**与 certimate 的关系**:certimate 容器仍在机器上,但**凡「会签发并部署」的工作流都已停用**
(`enabled=0`)—— 否则它会用 RSA 证书覆盖本项目签的 ECC 证书,且**60 天后才爆、爆得静默**。
保留的只有三条**纯监控告警**工作流。
**已知遗留**:
- `writeapi.usj.cc` / `dnsapi.usj.cc` / `vaultwarden` 三个**手工 nginx vhost 不归 1Panel 站点管理** ——
证书是**文件拷贝**,与证书库记录解耦,`/websites/{id}/https` 碰不到它们 → 需单独纳管
- 动代码前必看 [`docs/证书管家.md`](docs/证书管家.md) 的 **§8.4 / §9.9 / §9.11 / §9.12** 四节
「易误判、勿回退」的坑(ACME 客户端 bug、CSR 三层 SEQ、`ssl/update` 不物化站点文件、
1Panel HTTPS 字段名是大写 `SSL` 等)
---
## 6. 遗留待办
| # | 项 | 说明 |
|---|---|---|
| 1 | `gh` remote(GitHub) | ✅ **已移除**(2026-10-06)。GitHub 账号被标记、仓库被 AUP 清空 —— **GitHub 已彻底退出本项目**:远端、推送别名、发布链路 4 处硬编码、CI 定义全部清干净 |
| 2 | **远端与备份定案** | ✅ **已定案**(2026-10-06):**CNB 主仓 + 自建 Gitea 代码辅仓 + OpenList 离线 bundle**(§5.2/§5.3/§5.6)。自建 Gitea 不受第三方规则约束,是唯一「平台出事也带不走」的在线副本;离线 bundle 保完整历史。选型依据(Gitee 两道硬门槛、Codeup 对照)保留在 §5.3 备查 |
| 3 | `bin/linux/hugo` 出库 | ✅ **不必做**。那个 83.1MB 二进制只卡 Gitee 的单文件上限;既然不走 Gitee,`deploy/Dockerfile` 的取 hugo 方式与 `.cnb.yml` 的 `by:` 字段一行都不用改 |
| 4 | `gitea` remote | ✅ **已恢复**(2026-10-06):作为代码同步辅仓重新挂上,`pushall` 恢复双推(`origin` + `gitea`)。接入时 Gitea 停在 `ed38f938`,一次 fast-forward 追平(+57 提交),**未强推、未丢历史**。发布链路 5 处默认值同步改回 `origin,gitea`(`bootstrap.sh` / `docker-compose.editor.yml` / `server.mjs` / `README.md` / `setup-cnb-remotes.sh`)。**线上 editor-api 容器也已切双推**(见 #12) |
| 5 | GitHub 侧旧 workflow | ✅ **已删除**(2026-10-06)。整个 `.github/` 目录移除(`deploy.yml` 1169 行 + `aliyun-backup.yml` + `cleanup.yml`),共 −1284 行 —— **GitHub 已不再是任何环节的依赖**。旧实现可在 git 历史中查 |
| 6 | 令牌 scope | 缺 `repo-cnb-history:r`(读构建日志);补上后 agent 可自行排错 |
| 7 | ★ **Gitea 主机到期** | **2026 年 11 月**,境外 VPS `23.254.236.47` 到期 —— 迁移是**有死线的待办**,不是可选项。现在迁移面已大幅缩小(**只需搬 Gitea 本体,runner / upyun-sync / 中转机都不需要**,因为构建已在 CNB),完整步骤与「本仓 9 处写死旧地址」的清单见 [`docs/Gitea迁移指南.md`](docs/Gitea迁移指南.md)。⚠️ 那篇第九节还给出了「干脆不留这个辅仓」的选项与判断依据 |
| 8 | `README.md` | ✅ 已更新(去 GitHub 化 + 补上备份脚本说明) |
| 9 | **敏感文件仍在跟踪中** | `.env`、`docs/GITEA_SECRETS.md`、`write-server/nginx/ssl/privkey.pem`、`content/posts/2024/.../setup-secrets.png` —— 若还要推任何新平台,先 `git rm --cached` 并轮换凭据。⚠️ 当初「不改写历史」的唯一顾虑是备份会分叉;**GitHub 那份已消失,这个顾虑没有了** → `git filter-repo` 现在是可行窗口(代价:全部提交 SHA 改变)。离线 bundle 已加密(§5.6),不受此影响 |
| 10 | **整仓离线备份** | ✅ **已上线**(2026-10-06),现为**备选**异地备份(在线那一路是自建 Gitea 辅仓),见 §5.6。计划任务 `Blog-BundleBackup` 每天 03:30 跑,端到端已验证;失败会发告警邮件(已实测)。**2026-10-06 补**:打包前加 `git fetch --all` —— 原来备的是「本机已知 ref」的快照,后台新发的文章可能一份都不在里面,且备份照样报成功(§5.6)。待补三件安全项:把 `BACKUP_PASSPHRASE` 抄进密码管理器;给 OpenList **改掉 admin 密码 + 开两步验证**(现 `otp: false`,且旧密码已出现在对话里);确认 5244 端口**未暴露到公网** |
| 11 | **F50 存储目录的使用约定** | `/本地/` 下的 `刷机` / `系统` / `资料` / `软件` / `驱动` / `备份` 都是用户自己在管的类别。**本项目的 bundle 只写 `备份/blog-bundle/` 子目录**,不与用户手工备份(`github-zqlit-*`、`local-repos-*`)混放 |
| 12 | **editor-api 容器的三层留存** | ✅ **已落地**(2026-10-06,国内机 `119.29.215.187`):`PUSH_REMOTES=origin,gitea`(CNB 主仓 + 自建 Gitea 备份仓),文件层走 §5.6 的加密 bundle。实测:容器内推 `origin` ✅ / 推 `gitea` ✅(临时分支推完即删),`/health` 返回 `remotes:["origin","gitea"]`,三处 `main` 对齐 `448b898e`。接入时 `/srv/blog` 落后 10 个提交,一次 fast-forward 追平 |
| 13 | ★ **证书管家的两个遗留** | 见 §5.7。**① `writeapi.usj.cc` 的证书没纳管**(真问题,会到期):它的 nginx 配置在 `/www/conf.d/writeapi.usj.cc.conf` —— **不在 `/www/sites/` 管理树里**,所以 1Panel 的 `ssl/upload`+`sslID` 物化时碰不到它。现状实测:`CN=usj.cc` / **RSA** / 到期 **2026-12-07**,而本项目续期**不会更新它** → 12 月会断。**② `dnsapi.usj.cc` 没上 HTTPS**(`ssl/` 目录空、源站握手失败)。<br>(附带澄清:`200181.xyz` 公网走 Cloudflare,看到的 LE 证书是 **CF 自家的边缘证书**,与本项目无关;源站 `ssh.200181.xyz` 已是本项目签的 `*.200181.xyz` / 2027-01-04 ✅) |
| 14 | **架构复杂度盘点** | 「感觉架构还是复杂了」的正面回应 → 已盘点:**跨 6 个环境,其中 4 个要自己维护**,这才是复杂度的来源;国内机 9 个容器里属于本项目的只有 3 个,能动的只有 2 个。**5 个精简候选 + 建议执行顺序**见 [`docs/架构精简候选.md`](docs/架构精简候选.md):certimate 容器可停、`cn-dns-helper` 大概率是遗留、Gitea 迁 or 不留、两套写作前端收敛、writeapi 证书纳管 |
---
## 附:相关文档
文档只有两个入口在根目录(`README.md`、本文);其余在 `docs/`,一次性的评估与已完结方案在 `docs/archive/`。
完整地图见 [`docs/README.md`](docs/README.md)。
| 文档 | 内容 |
|---|---|
| `docs/证书管家.md` | ★ 证书管家(签发在国内机,Worker 只读):现状 + 决策沿革 + 勿回退的坑 |
| `docs/Gitea迁移指南.md` | ★ 自建 Gitea 搬迁时**必须同步改的全部位置** + 验证清单 |
| `docs/架构精简候选.md` | 复杂度盘点 + 5 个可精简候选(待决策,含收益/代价/风险) |
| `docs/CNB构建落地方案.md` | 迁 CNB 的实施方案与实测数据 |
| `docs/GITEA_SECRETS.md` | Gitea / 相关凭据清单(**含明文,勿外传**) |
| `docs/archive/` | 决策期评估与已完结方案(Gitee / EdgeOne / ESA / 精简方案 / COS 下线 / 评论修复 / 诊断报告 …) |
**证据出处(现行)**:`.cnb.yml`(284 行)、`deploy/Dockerfile`、`bin/linux/hugo`、
`scripts/send_mail.js`、`scripts/refresh_cdn.js`、`scripts/setup-cnb-remotes.sh`、
`scripts/backup-bundle.mjs`、`scripts/backup-run.mjs`、`scripts/backup-task.cmd`。
原 GitHub Actions 流水线(`.github/workflows/deploy.yml`,1169 行)已于 2026-10-06 删除,
需要查请翻 git 历史。