Files
blog/架构总览.md
T
zqlit 3eeafa07b2 fix(证书管家): writeapi.usj.cc 证书纳管(相对软链接);pushall 改为先 rebase 再推
用户:「动手 推送吧」—— 动手处理两个有死线的遗留。

一、writeapi.usj.cc 的证书(12 月会断)→ 已解决

现状复核推翻了上一轮的判断:
  · 上一轮记的「它的 ssl_certificate 引用不在 /www/sites/ 树里」是**错的** ——
    /www/conf.d/writeapi.usj.cc.conf 里明确写着
    ssl_certificate /www/sites/writeapi.usj.cc/ssl/fullchain.pem;
  · 真正原因是**它在 1Panel 面板上根本不是一个网站**(面板共 11 个站点,没有它),
    所以 `findWebsite('writeapi.usj.cc')` 必然抛「找不到网站」→
    加进 one_panel_sites 只会让**每次续期都报错**(而且只警告不阻断,长期没人发现)
  · 实测确认:redeploy 时其余 5 个站点正常,只有它失败

解法:**不写一行代码** —— 它与 artalk.usj.cc 本来就同用一张 *.usj.cc 证书,
让它用**相对软链接跟随 artalk** 即可,随续期自动更新:
  ln -sfn ../../artalk.usj.cc/ssl/fullchain.pem /1panel/1panel/www/sites/writeapi.usj.cc/ssl/fullchain.pem

★★ 踩到的坑:软链接**必须相对路径**
  宿主 /1panel/1panel/www/sites/ ↔ 容器内 /www/sites/(1Panel 的 openresty 跑在容器
  `1Panel-openresty-Kmh2`)。第一版写成宿主绝对路径 → **容器侧断链** →
  nginx -s reload **静默失败**(保留旧配置、服务不中断、握手照旧拿旧证书)→
  表面全绿,真实后果是「等某天 nginx 重启,writeapi 的 HTTPS 直接挂」。
  唯一线索是 worker 进程的启动时间没变 —— **所以验收必须看 worker 是否真的重启**。

验收(全部实测):
  替换前 CN=usj.cc    / LiteSSL RSA CA / 2026-12-07(mtime 10-04 21:50)
  替换后 CN=*.usj.cc  / LiteSSL ECC CA / 2027-01-04
  容器内 head -1 → -----BEGIN CERTIFICATE----- / -----BEGIN PRIVATE KEY-----
  预检 syntax is ok / test is successful
  reload → signal process started;**worker 22:51:39 重启**(证明软链接真被读了)
  握手 SNI=writeapi.usj.cc → *.usj.cc / Jan 4 2027;实访 /health → HTTP 200

新增 deploy/cn-certkeeper/src/dump-cert.mjs:把 KV 里(AES-GCM 加密)的证书导成明文 PEM,
补上「KV ↔ 手工 vhost 磁盘文件」之间原先不存在的桥。只读,用完即删(含明文私钥)。

操作层的两个坑(写进文档):
  · 1Panel 文件 API **拒绝写 .mjs**(返回 500「目标路径不存在」,实为可执行扩展名过滤);
    往新建的 root:root 700 目录写也会失败 → 临时脚本用
    `docker exec -i … sh -c 'cat > 路径' < 本地文件` 送
  · cnrun.py(1Panel 计划任务通道)偶发「退出码 0 但零输出」;
    加 `timeout 30 docker exec …` 包一层即稳定,判断状态优先看容器内直接证据

二、配置回退 + 文档更正

  · certkeeper-config.mjs:把 writeapi 从 one_panel_sites 移除,换成一段注释说明
    「它不是 1Panel 站点,别加进来」+ 软链接做法 + 必须相对的警告;
    线上 config.kv 同步回退(sha256 与改动前完全一致 ccac2530…)
  · 更正「3 个手工 vhost」的说法:vaultwarden **是**正常纳管的站点(面板 #13,
    主域名 vw.usj.cc,别名才是 vaultwarden)→ 手工 vhost 实际只剩 writeapi + dnsapi
  · dnsapi.usj.cc 重新定性为**不做**:它是 cn-dns-helper 的反代入口
    (proxy_pass 127.0.0.1:8018,仅 HTTP),去留应与 cn-dns-helper 一起决定,
    不该单独给一个待退役的服务加 HTTPS

三、pushall 加固:先 fetch + rebase 再推(本次真实撞到的问题)

推 CNB 时被 rejected —— 因为线上写作后台(editor-api)发文章会**直接推 main**,
本机两笔提交与之分叉。这不是异常,是**日常**。原别名直接 push,必然反复撞。

  · 新增 scripts/pushall.sh:fetch → 已在远端之后则 rebase → 推所有远端
    - 冲突时**停在 rebase 中途**交人工(不强推、不丢东西)
    - 工作区不干净时 git 自己会拒绝 rebase(不会吞改动)
    - 任一远端失败**不改判另一个**(主仓失败辅仓照样推),退出码以第一次失败为准
    - ⚠️ 修了自己写的一处疏漏:`if ! cmd; then ec=$?` 的 $? 是**取反后**的结果(0),
      不是 git 的退出码 —— 必须显式 ec=1(靠 set -o pipefail 保证管道退出码不被 sed 掩盖)
  · setup-cnb-remotes.sh 的别名改指向脚本;README 脚本表补这一行

四、文档

  · docs/证书管家.md:速览表「已知遗留」→ 已解决;§9.14 遗留段重写(含纠正自己写错的判据
    「路径像不像不构成判据,要直接查面板站点清单」);**新增 §9.15**
    「手工 vhost 的证书:用相对软链接跟随已纳管站点」(机制 + 路径坑 + 三条验收 + dump-cert 用法)
  · docs/架构精简候选.md:候选 5 标记完成,补上「为什么 A 走不通、B 未采用」;
    执行顺序里划掉它
  · 架构总览.md:§5.7「已知遗留」重写(2 个手工 vhost + vaultwarden 更正);
    §6 待办 #13 改为已解决、#14 标注完成
2026-10-06 22:54:58 +08:00

532 lines
37 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 天后才爆、爆得静默**。
保留的只有三条**纯监控告警**工作流。
**已知遗留**:
- **手工 nginx vhost**(不归 1Panel 站点管理,`/websites/{id}/https` 碰不到)目前**只剩 2 个**:
- `writeapi.usj.cc`(写作后台 API 入口)—— ✅ **已解决**(2026-10-06):证书改用
**相对软链接跟随 `artalk.usj.cc`**(两者本就同用 `*.usj.cc` 那张证书),随续期自动更新。
⚠️ 软链接**必须相对路径**(宿主 `/1panel/1panel/www/…` vs 容器 `/www/…`,绝对路径会在容器内断链,
且断链后 nginx reload 会**静默失败**);验收要看 **worker 进程是否真的重启**,光看握手会被骗。
机制与坑见 [`docs/证书管家.md`](docs/证书管家.md) §9.15
- `dnsapi.usj.cc` —— 其实是 `cn-dns-helper` 的反代入口(`proxy_pass 127.0.0.1:8018`,**仅 HTTP**)。
它的去留应与 `cn-dns-helper` 一起决定,**不单独**给它加 HTTPS(见 [`docs/架构精简候选.md`](docs/架构精简候选.md) 候选 2)
- 注:`vaultwarden` **不是**手工 vhost —— 它是面板 #13 站点(主域名 `vw.usj.cc`),正常纳管。
(本文档早先把它误算作手工 vhost,2026-10-06 更正)
- 动代码前必看 [`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 | ★ **证书管家遗留** | ✅ **① 已解决**(2026-10-06):`writeapi.usj.cc` 是**手工 vhost**(面板 11 个网站里没有它,所以按站点名绑定永远找不到目标)→ 改用**相对软链接跟随 `artalk.usj.cc`**,随续期自动更新。证书从 `CN=usj.cc`/RSA/**2026-12-07** 换成 `CN=*.usj.cc`/ECC/**2027-01-04**,容器内可读、预检通过、**worker 已重启**、`/health` 200;机制与「必须用相对路径」的坑见 [`docs/证书管家.md`](docs/证书管家.md) §9.15。<br>**② `dnsapi.usj.cc` 重新定性为「不做」**:它是 `cn-dns-helper` 的反代入口(`proxy_pass 127.0.0.1:8018`,仅 HTTP),去留应与 `cn-dns-helper` 一起决定。<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 证书纳管~~(✅ 2026-10-06 已完成,见 #13) |
---
## 附:相关文档
文档只有两个入口在根目录(`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 历史。