Files
blog/架构总览.md
T
zqlit 95a1629f54 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

37 KiB
Raw Blame History

优世界博客 · 架构总览

更新时间:2026-10-06 状态:迁移已落地并跑通(四线路全绿,单次发布约 3.5 分钟) 本文档只描述现状;决策期的一次性评估与已完结方案见 docs/archive/。 文档地图见 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)
write/(本地 Windows 前端)
editor-api/(国内机轻量后端,实为线上那条发布入口)
独立 Docker 主机/本机/国内机 可用(两套前端功能重叠)
4 发布基础设施 CNB 流水线(.cnb.yml)+ 又拍云 + 多吉云 + EdgeOne 腾讯云 CNB(托管) 已跑通
5 证书系统 证书管家(自研):ACME 签发 + DNS-01 + 多目标部署(多吉云 / 1Panel) 国内机 Docker cn-certkeeper(签发+部署)
+ 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 推送

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 文件备份」这条定案在容器侧的落点:

# /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 依赖):

# 推荐入口:带失败告警的包装器(计划任务用的就是它)
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 的中文输出混写不会乱。

恢复流程:

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 §9.15
    • dnsapi.usj.cc —— 其实是 cn-dns-helper 的反代入口(proxy_pass 127.0.0.1:8018,仅 HTTP)。 它的去留应与 cn-dns-helper 一起决定,不单独给它加 HTTPS(见 docs/架构精简候选.md 候选 2)
    • 注:vaultwarden 不是手工 vhost —— 它是面板 #13 站点(主域名 vw.usj.cc),正常纳管。 (本文档早先把它误算作手工 vhost,2026-10-06 更正)
  • 动代码前必看 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。⚠️ 那篇第九节还给出了「干脆不留这个辅仓」的选项与判断依据
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 §9.15。
② dnsapi.usj.cc 重新定性为「不做」:它是 cn-dns-helper 的反代入口(proxy_pass 127.0.0.1:8018,仅 HTTP),去留应与 cn-dns-helper 一起决定。
(附带澄清:200181.xyz 公网走 Cloudflare,看到的 LE 证书是 CF 自家的边缘证书,与本项目无关;源站 ssh.200181.xyz 已是本项目签的 *.200181.xyz / 2027-01-04 ✅)
14 架构复杂度盘点 「感觉架构还是复杂了」的正面回应 → 已盘点:跨 6 个环境,其中 4 个要自己维护,这才是复杂度的来源;国内机 9 个容器里属于本项目的只有 3 个,能动的只有 2 个。5 个精简候选 + 建议执行顺序见 docs/架构精简候选.md:certimate 容器可停、cn-dns-helper 大概率是遗留、Gitea 迁 or 不留、两套写作前端收敛、writeapi 证书纳管(✅ 2026-10-06 已完成,见 #13)

附:相关文档

文档只有两个入口在根目录(README.md、本文);其余在 docs/,一次性的评估与已完结方案在 docs/archive/。 完整地图见 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 历史。