用户四问:「把项目文件全部理一遍 还有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 迁移死线」两节;证书管家节补实测与遗留。
53 KiB
证书管家
文档定位:本文既是现状说明(第一~二节 + §9.13),也是决策沿革(§8~§9, 含踩坑与实测数据)。要了解当前架构看前半部分;要改代码前先看 §8.4 / §9.9 / §9.11 / §9.12 这几节「易误判、勿回退」的坑。
⚠️ 标题曾叫「函数版证书管家(CF Worker)实施方案」—— 那是 Phase 1 的形态。 2026-10-06 晚定案后,签发与部署已搬到国内机容器,Worker 只剩只读监控与后台。 沿革见 §9.1 与 §5.6;现行部署单元
deploy/cn-certkeeper/。
当前状态速览(2026-10-06 收工)
| 项 | 现状 |
|---|---|
| 签发 + 部署 | 国内机 Docker 容器 cn-certkeeper(只绑 127.0.0.1:8019),每日 04:10 自动续期 |
CF Worker(blog-admin/) |
只读:监控 / 后台 / 探针。/ssl/issue 已改 501 硬拒绝,证书 cron 已移除 |
| 纳管域名 | usj.cc / t-t.live / 200181.xyz 三组(事实源 blog-admin/tools/certkeeper-config.mjs) |
| 部署目标 | 多吉云 CDN + 1Panel(119.29.215.187:3721) |
| CA | LiteSSL / freessl.cn(EAB 继承 certimate 账户,见 §9.10) |
| 自测 | 125/125 通过;npm run typecheck 零错误 |
| 数据目录 | 容器内 /data,7 条凭据(AES-GCM)+ 1 条 config |
| 已知遗留 | writeapi.usj.cc 等 3 个手工 vhost 不在站点管理面内(§9.14 末节)—— 证书是文件拷贝,需单独纳管 |
原始方案(Phase 1 形态,保留供对照)
当初目标:用 Cloudflare Worker 取代服务器上的 certimate, 申请 + 续期 + 部署证书(多吉云 CDN + 1Panel),把 certimate 这个服务精简掉。 —— ⚠️ 该目标已被 §9 推翻:Worker 免费版 CPU 硬顶 10ms/请求,签发跑不动,改由国内机承担。
当时确认的决策(2026-10-06)
| 项 | 决定 |
|---|---|
| CA | 继续用 LiteSSL / freessl.cn(与现状一致,90 天免费) |
| DNS-01 | 对接各家 DNS API,不改 DNS(腾讯云 DNSPod + Cloudflare) |
| 部署目标 | 多吉云 CDN + 1Panel(放弃又拍云) |
| 1Panel 接入 | 直连公网 119.29.215.187:3721 |
| 覆盖域名 | usj.cc / t-t.live / 200181.xyz(三组,多域名) |
一、现状盘点(从服务器上的 certimate 导出)
1.1 三条「申请工作流」(2026-10-06 从 certimate SQLite 导出)
| 工作流 | cron | 域名 | CA | DNS | 部署 | 算法 |
|---|---|---|---|---|---|---|
| 优世界证书申请 | 10 12 * * * |
*.usj.cc;usj.cc |
litessl |
tencentcloud-dns | dogecloud-cdn + 1panel | RSA2048 |
| 团团证书申请 | 05 12 * * * |
t-t.live;*.t-t.live |
litessl |
tencentcloud-dns | 1panel + tencentcloud-eo | RSA2048 |
| 200181.xyz 证书申请 | 17 12 * * * |
*.200181.xyz;200181.xyz |
litessl |
cloudflare | 1panel | RSA2048 |
另有 4 条「过期预警」工作流(0 12 * * *,纯监控发信):
优世界 / 伍比贰 / 优世界artalk评论 / 团团。
★ 与你的要求完全吻合:多吉云 + 1Panel。又拍云本来就没在这套工作流里。 ★
tencentcloud-eo(腾讯云边缘安全加速)是 t-t.live 独有的第 3 个部署目标, 函数版第一版可不做(保持 1Panel 即可)。
1.2 1Panel 站点证书实况
| 站点 | 到期 | SAN | 签发方 |
|---|---|---|---|
artalk/openlist/openwrt/wifi/writeapi.usj.cc、vaultwarden |
Dec 7 2026 | usj.cc,*.usj.cc |
LiteSSL (TrustAsia) |
certd/pl/pwd/www.t-t.live、t-t.live |
Dec 27 2026 ✅ | *.t-t.live,t-t.live |
Let's Encrypt |
ssh.200181.xyz |
Nov 23 2026 | 200181.xyz,*.200181.xyz |
— |
★ 2026-10-06 实测修正:t-t.live 组已续期成功(原快照记 Oct 9,现签发的 新证书是 Let's Encrypt
Sep 28 → Dec 27,三个子域共用同一张)。 usj.cc 组确认为 Dec 7;ssh.200181.xyz未对外暴露 443,无法从外部探测。
证书落地路径:
/1panel/1panel/www/sites/<域名>/ssl/fullchain.pem
1.3 可复用的凭据(2026-10-06 从 certimate access 表导出)
| 用途 | 凭据名 | 结构 | 说明 |
|---|---|---|---|
| 多吉云 API | 多吉云账户 | {accessKey, secretKey} |
零新增,已有同款 AK/SK |
| 腾讯云 DNS | 小赵腾讯云 / 团团腾讯云 | {secretId: AKID..., secretKey} |
CAM 密钥 → TC3-HMAC-SHA256 签名 |
| Cloudflare | CloudFare账户 | {apiToken: ALTpx...} |
200181.xyz 的 DNS + 部署 |
| LiteSSL | freessl.cn | {eabKid, eabHmacKey(base64)} |
★ ACME EAB 凭据,不是私有 API |
| ZeroSSL | zerossl | {eabKid, eabHmacKey(base64)} |
同上,可作 CA 备选 |
| 1Panel | 团团服务器面板 | {apiKey: muuiR..., apiVersion: v2, serverUrl} |
服务器面板 API |
| 1Panel | 本地 | {apiKey: 41mRF..., apiVersion: v2, serverUrl} |
本地面板 API |
| 邮件 | 小赵的邮件通知 | {smtpHost, smtpPort:465, username, password} |
★ SMTP,Worker 用不了(无 TCP) |
⚠️ SMTP 在 Worker 里不可用 —— Cloudflare Workers 没有 TCP socket。 现有 artalk-cf worker 已改用 Resend HTTP API,证书管家复用同一套即可。
1.4 ★ 1Panel 的 IP 白名单 —— 实测没有拦外网
1panel-core 监听: *:3721 (所有接口)
外网访问测试: http://119.29.215.187:3721/api/v2/dashboard/base
→ HTTP 401(未授权,而非连接拒绝)
grep securityEntrance/allowIP → 空(未设白名单)
→ Worker 直连 119.29.215.187:3721 技术上可行,只需 API Key。
但这意味着面板端口 3721 对公网开放(靠 token 鉴权)。 方案 A(服务器侧拉取)仍更安全,见 §3.4。
二、架构设计
┌──────────────── Cloudflare Worker: cert-keeper ────────────────┐
│ │
│ [crons] 每天 12:00 一行:检查所有域名的证书剩余天数 │
│ ↓ │
│ ┌─────────────────────────────────────────────────┐ │
│ │ 1. 到期检查 剩余 > 30 天 → 直接退出(不打扰) │ │
│ │ 2. ACME 申请 对新域名/即将到期域名走 DNS-01 │ │
│ │ · freessl.cn API(litessl,与现状一致) │ │
│ │ 3. 得证书 → 存 KV(cert:<domain>) │ │
│ │ 4. 部署: │ │
│ │ a) 多吉云 CDN /cdn/cert/upload + bind │ │
│ │ b) 1Panel API 直连 119.29.215.187:3721 │ │
│ │ 5. 邮件通知(成功/失败) │ │
│ └─────────────────────────────────────────────────┘ │
│ │
│ [HTTP 后台 /admin] ★ 全部带鉴权 │
│ —— 只读 —— │
│ GET /admin/cert/list 证书状态(剩余天数/签发方/覆盖域名)│
│ GET /admin/config/get 读当前域名配置 │
│ GET /admin/log 最近若干次执行日志 │
│ —— 写(★ 替代 certimate 的点点点界面)—— │
│ POST /admin/config/save 保存域名配置(整份 JSON,带校验) │
│ POST /admin/cert/check 手动触发检查(可指定域名) │
│ POST /admin/cert/renew 强制续期某个域名 │
│ POST /admin/cert/deploy 只重跑部署(不重新签发) │
│ POST /admin/access/save 保存/更新凭据(AK/SK 等,加密存储) │
│ GET /admin/access/list 凭据列表(**只回显名称+尾4位**) │
└─────────────────────────────────────────────────────────────────┘
2.0 ★ 配置编辑能力(为什么必须有)
certimate 的核心价值不只是「自动续期」,还有能点点点的配置界面。 既然要把它精简掉,这个能力必须补上,否则每次加域名/改部署目标都要去手改 KV, 比 certimate 还难用。
设计原则:配置即数据,全部走 API 读写,配一个单页后台。
| 能力 | 实现 |
|---|---|
| 查看/新增/删除域名 | GET/POST /admin/config/* 读写 KV 里的 config |
| 每个域名的 SAN、DNS 渠道、部署目标 | 表单编辑,保存前做** Schema 校验** |
| 凭据(腾讯云 AK/SK、多吉云、1Panel、EAB) | POST /admin/access/save,加密后存 KV |
| 手动触发(检查/续期/重部署) | 三个 POST 接口,页面上按钮直连 |
| 执行日志 | GET /admin/log,环形缓冲最后 N 条 |
鉴权:复用 artalk-cf 现成的会话令牌方案(ADMIN_PASSWORD + 签名 token),
后台路径挂 /admin,与公开接口隔离。
凭据安全(★ 重要):
- 存 KV 前用
TOKEN_SECRET做 AES-GCM 加密,KV 里落的是密文 GET /admin/access/list只回显名称和尾 4 位(如AKID****3f2a), 绝不把明文吐回浏览器;要改就整条重填- 所有写接口校验
Origin+ 会话,防 CSRF
配置的前向兼容:config JSON 带 version 字段。
未来加字段(如新的 DNS 渠道)时,读取侧按 version 做迁移,老配置不会读挂。
★ 一句话:certimate 有的编辑能力,这里一个不少;它没有的(多吉云自动部署),这里还多了。
2.1 域名配置(KV 存储,可动态加域名)
{
"version": 1, // ★ 前向兼容:加字段时递增,读取侧按版本迁移
"notify": {
"emails": ["177018615@qq.com"],// 到期/失败提醒收件人(可在后台改)
"daysBefore": 30 // 剩余多少天开始提醒
},
"domains": [
{
"name": "usj.cc",
"san": ["usj.cc", "*.usj.cc"],
"dns": "tencent", // 引用凭据名,见下
"deploy": ["dogecloud", "1panel"],
"dogecloud_domains": ["usj.cc"], // 绑到多吉云 CDN 的域名
"1panel_sites": ["artalk.usj.cc", "openlist.usj.cc", "..."]
},
{
"name": "t-t.live",
"san": ["t-t.live", "*.t-t.live"],
"dns": "tencent",
"deploy": ["1panel"],
"1panel_sites": ["t-t.live", "www.t-t.live", "certd.t-t.live", "..."]
},
{
"name": "200181.xyz",
"san": ["200181.xyz", "*.200181.xyz"],
"dns": "cloudflare",
"deploy": ["1panel"],
"1panel_sites": ["ssh.200181.xyz"]
}
]
}
凭据单独存(KV access:<name>,密文),域名配置里只写名字引用:
// KV: access:tencent-usj (AES-GCM 加密后存)
{ "type": "tencentcloud", "secretId": "AKID...", "secretKey": "..." }
// KV: access:dogecloud
{ "type": "dogecloud", "accessKey": "...", "secretKey": "..." }
// KV: access:1panel
{ "type": "1panel", "apiKey": "...", "serverUrl": "http://119.29.215.187:3721" }
// KV: access:litessl (ACME EAB,不是私有 API)
{ "type": "acme-eab", "directoryUrl": "https://...", "eabKid": "...", "eabHmacKey": "..." }
★ 拆开的好处:改凭据(如换 API Key)不用碰域名配置; 加域名时直接复用已有凭据名,不用重复填密钥。
三、各环节实现要点
3.1 ACME 申请(LiteSSL / freessl.cn)—— ★ 已确认是标准 ACME + EAB
关键发现(2026-10-06 从 certimate 凭据表导出):
certimate 里 freessl.cn 凭据的字段是 eabKid + eabHmacKey,
这是 ACME 的 External Account Binding,不是 freessl 私有 API。
freessl.cn 凭据: { eabKid: "T2G-o***", eabHmacKey: "Qk9Yg***"(base64) }
zerossl 凭据: { eabKid: "I5tkZ***", eabHmacKey: "FHnpZ***"(base64) }
→ 说明 certimate 走的是标准 ACME 协议(RFC 8555),只是 CA 端要求 EAB 绑定。
这大幅降低实现难度:
| 步骤 | 实现 |
|---|---|
| 目录 | GET https://acme.trustasia.com/acme/directory(LiteSSL 的 ACME 端点,需实测确认) |
| 账号 | newAccount,带 EAB:eab = { kid, hmacKey } |
| 签名 | JWS + ES256/RS256,用 WebCrypto(CF Worker 原生支持) |
| 挑战 | DNS-01(写入 _acme-challenge.<domain> TXT) |
| 完毕 | finalize → 轮询 → 下载 fullchain.pem |
好消息:既然是标准 ACME,开源参考
manalejandro/cf-acme、PIKACHUIM/CFWorkerACME可以几乎直接复用,主要工作变成「加 EAB 支持」+「换成 LiteSSL 目录地址」。 且 CA 可做成「可切换」(LiteSSL / ZeroSSL / Let's Encrypt 同一个实现,只换 directory + EAB)。
仍需实测确认的:LiteSSL 的 ACME directory URL。可从 certimate 的
workflow.graphContent 里 grep 出来(那里存了 CA 配置)。
3.2 DNS-01 验证
腾讯云 DNSPod(usj.cc / t-t.live):
POST https://dnsapi.cn/Record.Create # 新建 TXT
POST https://dnsapi.cn/Record.Delete # 删除 TXT
认证:login_token = "<ID>,<Token>" (dnspod 老 API,简单)
需确认 certimate 里那两条 tencentcloud 凭据用的是 DNSPod token 还是 CAM 密钥。 若是 CAM(TC3-HMAC-SHA256 签名),则有现成的签名实现可复用。
Cloudflare(200181.xyz):
POST /client/v4/zones/:zone_id/dns_records # 建 TXT
DELETE /client/v4/zones/:zone_id/dns_records/:id
认证:Bearer <CLOUDFLARE_API_TOKEN>(已有)
3.3 部署到多吉云 CDN
POST https://api.dogecloud.com/cdn/cert/upload.json
note=<备注>&cert=<fullchain>&private=<私钥>
→ data.id(证书ID)
POST https://api.dogecloud.com/cdn/cert/bind.json
id=<证书ID>&domain=usj.cc
认证:HMAC-SHA1 签 apiPath + "\n" + body,与现有 scripts/refresh_cdn.js 完全一致 ——
可以把它拆出来复用代码。
多吉云证书 API 参数名注意:官方文档写
privary(疑似笔误),实际要传private。 上手前需实测一次确认。
3.4 部署到 1Panel(直连 119.29.215.187:3721)
1Panel v2 API 认证:
1Panel-Token: md5('1panel' + <APIKey> + <unix秒>)
1Panel-Timestamp: <unix秒>
Base: http://119.29.215.187:3721/api/v2
⚠️ 最大障碍:IP 白名单。 1Panel 服务端逐层校验「时间戳 → token → IP 白名单」。 Cloudflare Worker 的出口 IP 是海量动态 IP,无法加白名单。
可选解法(按推荐序):
| 方案 | 说明 | 评价 |
|---|---|---|
| A. 服务器侧拉取(推荐) | Worker 只存证书到 KV;服务器上一个 cron 脚本 curl 拉最新证书 + 重载 nginx。不需要开放 1Panel 端口给公网 |
✅ 最安全,绕开白名单 |
| B. 逆向拿 Worker 出口 IP | 不可行(CF 出口 IP 段巨大且变动) | ❌ |
| C. 自建小代理 | 在服务器上跑一个 30 行的反代,Worker → 代理 → 1Panel | ⚠️ 等于 A 但更绕 |
| D. 关闭 1Panel IP 白名单 | 直接放开面板端口给公网 | ❌ 危险,不建议 |
推荐 A:既能满足「函数版管家」的自动化意图,又不必把面板暴露到公网。 服务器上只需一个
cert-sync.sh(wg 或 SSH 进来装一次), 效果与 certimate 的 1panel deploy 完全等价。
3.5 存储与通知
KV 键位规划:
| Key | 内容 | 是否加密 |
|---|---|---|
config |
域名配置 + 通知设置(含 version) |
否(无敏感信息) |
access:<name> |
凭据(腾讯云/多吉云/1Panel/EAB) | ★ AES-GCM 加密 |
cert:<domain> |
签发的证书 { cert, key, expireAt, updatedAt } |
★ AES-GCM 加密 |
log:latest |
最近 N 条执行记录(环形缓冲) | 否 |
- 加密:用
TOKEN_SECRET派生密钥,crypto.subtle做 AES-GCM。 私钥落到 KV 必须是密文 —— 万一 KV 泄露不至于直接失守。 - 邮件:Worker 无 TCP socket,不能直连 SMTP。
现有 worker 已用 Resend(HTTP API),复用同一套:
POST https://api.resend.com/emails,收件人从config.notify.emails读(后台可改) - crons:
[triggers] crons = ["30 4 * * *"](UTC 4:30 = 北京 12:30)
四、落地路径(分阶段,可随时叫停)
Phase 1 —— 只读监控 + 配置后台(零风险)
- 部署 Worker,
crons每天检查三个域名的证书剩余天数 - 到期 < 30 天 → 发邮件提醒
- ★ 同时把配置后台做出来(
/admin单页):- 证书状态列表(剩余天数、签发方、覆盖域名)
- 域名配置的增删改(写 KV,但 Phase 1 不参与签发)
- 凭据管理(加密存 KV,只回显尾 4 位)
- 完全不签发、不部署,纯观察 + 配置。可以先跑两周验证数据准确性。
★ 把后台放 Phase 1 的理由:它是纯读写自己的 KV,不碰任何外部系统, 零风险却能立刻替代 certimate 的日常操作(加域名、改收件人、看状态)。 等工作流验证完毕,Phase 2 再让它「动起来」。
Phase 2 —— 接管续期(+ 多吉云自动部署)
- 实现 ACME(Let's Encrypt 标准协议优先)
- DNS-01 走腾讯云 + Cloudflare
- 自动部署到多吉云 CDN(有正规 API,风险低)
- 邮件通知结果;1Panel 仍由 certimate 管
- 后台新增「手动续期 / 只重部署」按钮
Phase 3 —— 接管 1Panel + 关停 certimate
- 服务器加
cert-sync.sh(从 Worker 拉证书 + reload) - 观察一个完整续期周期(90 天)
- 确认无误 → 关停 certimate 容器
五、开工前必须确认/实测的 4 件事
- freessl.cn API 是否可用(认证 + 申请 + 下载全流程) → 若不行,改 Let's Encrypt 标准 ACME(推荐这条路,更稳)
- certimate 里 tencentcloud 凭据的类型 (DNSPod token 还是 CAM 密钥?决定签名实现)
- 多吉云
cdn/cert/upload.json的参数名(private还是privary) - 1Panel 侧走「服务器拉取」还是「直连面板」(强烈建议前者)
六、现成可参考的开源实现
| 项目 | 用途 |
|---|---|
manalejandro/cf-acme |
CF Workers + KV,用 WebCrypto 实现标准 ACME,DNS-01 自动申请 |
PIKACHUIM/CFWorkerACME |
CF Worker 上的 SSL 申请代理,支持 LE / ZeroSSL / GTS |
Menci/deploy-certificate-to-upyun |
又拍云部署(证明其无公开 API,故本方案放弃又拍云) |
本项目 scripts/refresh_cdn.js |
多吉云 HMAC-SHA1 签名,直接复用 |
七、与 certimate 的能力对照
| 能力 | certimate(现状) | 函数版管家 |
|---|---|---|
| 申请证书 | ✅ | ✅ |
| DNS-01 | ✅ 腾讯云 + CF | ✅ 同 |
| 部署多吉云 | ✅ | ✅ |
| 部署 1Panel | ✅ | ✅(走服务器拉取) |
| 部署又拍云 | ❌(本来就没配) | ❌(放弃) |
| 加/改域名 | 网页点点 | ✅ 网页点点(/admin 后台,见 §2.0) |
| 改凭据 / 通知收件人 | 网页点点 | ✅ 网页点点 |
| 看执行日志 | ✅ | ✅(GET /admin/log) |
| 手动触发续期/重部署 | ✅ | ✅(后台按钮) |
| 凭据存储 | 明文 SQLite | ★ AES-GCM 加密 |
| 需要服务器 | ✅ 需要 | ❌ 不需要 |
| 通知 | 邮件 | 邮件(Resend HTTP) |
| 成本 | 服务器 | 0(CF 免费额度) |
★ 结论:certimate 的编辑能力一项不少(加域名、改凭据、看日志、手动触发), 而且额外多了「凭据加密存储」和「多吉云自动部署」。 唯一少的是 certimate 的「可视化工作流编排」(拖拽节点那种)—— 本项目只有 3 个固定工作流,用配置表单比拖拽更直接,不构成损失。
八、签发/续期落地实现(2026-10-06 完成并上线)
8.1 模块划分(全部纯 WebCrypto,零 npm 依赖)
| 文件 | 职责 | 参照 certimate |
|---|---|---|
src/lib/acme.ts |
ACME v2 客户端:ES256 JWS、RFC7638 thumbprint、EAB、badNonce 重试、DNS-01、手写 DER 的 CSR | internal/certacme |
src/lib/dnsprovider.ts |
DNS-01 适配:DNSPod(TC3-HMAC-SHA256)、Cloudflare | pkg/sdk3rd/* |
src/lib/deployer.ts |
部署适配:多吉云 CDN、1Panel 站点(幂等换证书) | deployers/* |
src/lib/certissue.ts |
编排:探针判天数 → 账户注册/复用 → 签发 → 落库 → 逐目标部署 | workflow |
8.2 与 certimate 的关键差异:探针优先
certimate 每个 workflow 挂 cron,每天无条件重跑整条流水线。
本项目改为:先探针查证书剩余天数,低于 RENEW_BEFORE_DAYS(30 天)才真正签发。
好处:省 CA 限速额度(Let's Encrypt / LiteSSL 都有周限额)、DNS 改动更少、日志只有真实动作才有记录。
8.3 免费版 CPU 限制(★ 结论已在 §9 被实测推翻,保留原文供对照)
| 项 | 免费版 | 付费版($5/月) |
|---|---|---|
| Worker CPU | 硬顶 10ms | 默认 30s,可调至 5min |
[limits] cpu_ms 配置 |
★ 直接拒绝部署(code 100328) | 支持 |
| ACME 签发 | ⚠️ 擦边(见 §9.2 实测) | ✅ 稳 |
★★ 更正(2026-10-06 晚):这里原先写「❌ 跑不起来」是按文档推断、未实测得出的, 属于误判。实测密码学开销只有 0.69~3.3ms,是「擦边」而不是「必然爆」。 最终决策与实测数据见 §9。
实测报错:
X [ERROR] A request to the Cloudflare API (.../workers/scripts/artalk-cf/versions) failed.
CPU limits are not supported for the Free plan. [code: 100328]
注意是整个部署失败,不是「忽略该字段」——所以 Free plan 下 [limits] 必须保持注释。
(探针 / 环境自检 / 手动查询这些轻量功能在免费版完全可用。)
HTTP 请求 duration 本身无硬限制,签发的耗时主要花在 sleep 等 DNS 传播 + 轮询上,
不计 CPU——所以升级 Paid 后,这套架构是成立的。
8.4 实测踩坑(易误判,勿回退)
- 多吉云
bind参数是{id, domain},不是cert_id。用假 id 999999 做对照实验确认:cert_id回「域名不存在」(参数被无视)、id回「指定证书不存在」(参数生效)。 - 多吉云上传私钥字段名是
private(pri/key/privateKey均报「私钥格式错误」); 列域名用/cdn/domain/list.json(/cdn/domain.json回400 domain 格式错误)。 - LiteSSL ACME 目录必须带
/v2:https://acme.trustasia.com/acme/v2/directory(少/v2直接 404);meta.externalAccountRequired: true强制 EAB。 - 1Panel 必须用
/api/v2/。v1 的表现极具欺骗性:HTTP 200 但正文是 HTML 停用页 (Access Temporarily Unavailable)——看似限流,实为 v1 已停用。 - 1Panel HTTPS 配置字段是
SSL(大写),写错不报错,只是cur?.ssl?.id === sslId永假 → 每次续期都重绑、短暂 reload nginx。 - CSR 的
extensionRequest只能套三层 SEQ:a0 <len> / 30 / 06 09…090e / 31 …多套一层会让 OpenSSL 拿 SAN 的 OID 当 attribute type 查表,报wrong tag ... Field=object, Type=X509_ATTRIBUTE(这个 bug 会让所有签发失败)。
8.5 上线记录
- Worker version_id
1899e229-ca59-486a-a758-a6e2cbec0919,路由api.200181.xyz - cron 三条:
17 3 * * *(评论 GC)、0 * * * *(RSS)、10 4 * * *(证书续期) - KV 已写入 8 条:7 条凭据(AES-GCM)+ 1 条
certkeeper:config(3 组域名、阈值 30 天、收件人) - 新路由已注册上线的铁证:
GET /api/v2/ssl/renew-check→ 403(鉴权拦截), 而瞎编路径/api/v2/ssl/__nope→ 404。403 vs 404 是最干净的「路由存在性」证明。
8.6 待办
- 1Panel API 白名单:代码侧已按官方 SDK 写好,但线上调用被「Access Temporarily Unavailable」拦。
1Panel 开了「安全登录」(入口
/project),需在 面板 → 设置 → API 接口确认已启用, 并把 Cloudflare Worker 出口 IP 加入白名单,否则 1panel 部署目标会失败。 (环境自检/ssl/selfcheck会如实报出这一点。) - Cloudflare DNS 凭据缺口:现
cloudflaretoken 是 Workers/KV/D1 专用,GET /zones回 200 + 空数组、dns_records直连 403 →200181.xyz无法走 DNS-01。 需到 Cloudflare 重新生成含Zone → DNS → Edit且覆盖200181.xyz的 token。 - 真实端到端签发验证(需 Paid plan + 用户批准):会真写 DNS TXT、消耗一次 CA 配额、重绑站点。
九、★ 架构定案:签发链路搬到国内机(2026-10-06 晚)
9.1 决策:B + C 组合,拒绝 CF 付费
用户拍板:不买 CF Paid($5/月 ≈ ¥36/月),改走组合方案:
| 方案 | 内容 |
|---|---|
| B | Worker 留在免费版,代码优化(缓存 CryptoKey,把 CPU 压进 10ms) |
| C | 「签发 + 部署」整条链路搬到国内机的 Docker 容器里 |
理由:¥36/月 已经比这台本来就在跑的国内机(腾讯云广州)贵,且国内机本来就常驻、 Docker 管理成本几乎为零,没必要为一道 10ms 的紧箍咒额外付费。
附带收益:国内机能直接访问 1Panel API 与各站点探针,绕开了 Worker 最大的两个坑 ——「CF 出口 IP 拿不到 1Panel 白名单」与「Worker 无 TCP 探针」。 实测国内机探针能直接读到证书正文(Worker 做不到):
usj.cc62 天 /t-t.live83 天 /200181.xyz62 天。
9.2 ★ 实测数据(推翻 8.3 的旧结论)
本机 Win32 上 process.cpuUsage() 采样粒度太粗(200ms 忙循环只报 46ms),且
crypto.subtle 走线程池不计入主线程 —— 必须改用「大循环 + process.hrtime.bigint() 墙钟」。
| 操作 | 耗时 |
|---|---|
| EC P-256 keygen | 83 µs |
| ECDSA sign + SHA256 | 41 µs |
| SHA-256 digest | 3.2 µs |
importKey('jwk') |
126 µs |
sign(已缓存 CryptoKey) |
84 µs |
importKey + sign(未缓存) |
252 µs |
一次签发约 1012 次 JWS:优化前 ≈3.3ms → 优化后 ≈1.4ms(乘 23 倍保守系数:
6.610ms vs 2.84.2ms)。免费版 10ms 是「能压进去但没余量」。
9.3 优化:acme.ts 缓存 CryptoKey
src/lib/acme.ts 原实现每次 JWS 都 importKey。改为缓存 Promise:
private signingKey: Promise<CryptoKey> | null = null;
private importSigningKey(): Promise<CryptoKey> {
if (!this.signingKey) {
this.signingKey = (crypto.subtle.importKey(
'jwk', this.account.jwk, { name: 'ECDSA', namedCurve: 'P-256' }, false, ['sign'],
) as Promise<CryptoKey>).catch((e) => { this.signingKey = null; throw e; });
}
return this.signingKey; // 存 Promise,并发时只 import 一次
}
存 Promise 而非 CryptoKey:并发调用时只真正 import 一次; 失败清缓存避免永久 reject。实测:单次签发 importKey 12 → 1 次。
9.4 新增部署单元 deploy/cn-certkeeper/(Docker 管理)
| 文件 | 作用 |
|---|---|
Dockerfile |
node:22-alpine + 阿里云 apk 源 + ca-certificates tzdata;COPY lib/ + COPY src/ |
docker-compose.yml |
只绑 127.0.0.1:8019;TZ=Asia/Shanghai;HOST=0.0.0.0(容器内必须,否则端口映射进不来) |
src/kv-file.mjs |
文件系统版 KVNamespace(照 KV 接口面:get/put/delete/list),键位 a:b:c → <dir>/a/b/c.kv;put 先写 .tmp-<pid> 再 renameSync(原子,防半截密文) |
src/serve.mjs |
服务本体:withLock() 并发闸 + scheduleDaily() 自递归 setTimeout + 带鉴权 HTTP 接口 |
lib/* |
直接复用 blog-admin/src/lib/ 编译产物(acme/deployer/dnsprovider/certissue/certvault…) |
接口:/health(免鉴权) /status /renew(POST) /renew-check /preflight /log /data
关键设计:
- 同一把
TOKEN_SECRET→ certvault 的 AES-GCM 密文两边可互解,Worker 与国内机共享 KV 数据 acme.ts只用 Node 20+ 全局 API(crypto/fetch/btoa/Request/Response/TextEncoder) → 可直接跑在 Node,无需改代码preflight逐目标体检,必须读ping()返回的{ok,error,hint,detail}对象, 不能当字符串(否则显示[object Object],更糟的是把ok:false记成通过)
9.5 部署与验收记录
- 镜像
cn-certkeeper:local(241MB),容器Up (healthy),127.0.0.1:8019 - 数据目录 8 个
.kv就位(7 凭据 + 1 config) - 容器内 preflight 7/7 全过:litessl/zerossl 目录可达 + EAB 正确 · tencent-usj/tencent-tt 可读 · cloudflare→200181.xyz 可读 · dogecloud 3 个 CDN 域名 · 1panel-cn API 可达(容器访问 1Panel 未被白名单挡)
- 本地端到端 mock 测试 22/22 全过(FileKV + 凭据解密 + ACME 注册 + 签发 + DNSPod 写/删 TXT + 落库 + 日志)
- 定时:
RENEW_HOUR=4/RENEW_MINUTE=10(对齐原 Worker cron10 4 * * *)
9.6 ★ 清理 1Panel 过期垃圾证书(2026-10-06)
删掉 6 条 certimate 早期遗留的过期证书(删除前已导出全量记录含私钥到
secrets-backup/1panel-ssl-backup-<ts>.json,可回滚):
| id | 域名 | 到期 |
|---|---|---|
| 4 | 200181.xyz | 2026-05-24 |
| 5 | *.usj.cc | 2026-05-11 |
| 6 | *.usj.cc | 2026-07-12 |
| 7 | *.200181.xyz | 2026-07-24 |
| 8 | *.usj.cc | 2026-09-11 |
| 9 | 200181.xyz | 2026-09-24 |
保留(在用):id 11 usj.cc(5 站)· id 10 200181.xyz(ssh.200181.xyz)· id 2 *.t-t.live(5 站)。
结果:证书库 9 条 → 3 条,所有站点 HTTPS 正常。
判据用证书记录自带的
websites字段(1Panel 自己算的引用关系), 比遍历/websites/{id}/https更权威。删除接口POST /websites/ssl/del {"ids":[...]}。⚠️ 3 个手工建的 nginx vhost(
dnsapi.usj.cc/writeapi.usj.cc/vaultwarden) 不归 1Panel 站点管理,证书是文件拷贝(指纹与 id 11 一致),与证书库记录解耦。
9.7 ★ 未决冲突:t-t.live 的「两套管理」 → 已定案:本项目接管
- 1Panel 证书库
id 2 *.t-t.live记录的是旧证书(到期 2026-10-09) - 但对外 实际服务的是 certd 直接写进 nginx 的新证书(Let's Encrypt,到期 2026-12-27)
- → certd 绕过 1Panel 证书库改 nginx。若本项目也部署 t-t.live 的 1panel 目标,
会把 1Panel 库里的旧证书物化到站点
ssl/,覆盖掉 certd 的新证书。
必须二选一:
- 停掉 certd,本项目接管 t-t.live 全部(含 1Panel + 腾讯云 EO)
- 本项目放弃 t-t.live 的 1panel 部署目标,只留 usj.cc / 200181.xyz
2026-10-06 晚定案:选 ① —— 停掉 certd,本项目接管 t-t.live 全部。
9.7b ★ 接管时查清的真实现状(推翻 9.7 的部分描述)
实地排查后,事实和上面的推测不一样,记下来免得后人再绕:
| 项 | 原先以为 | 实测真相 |
|---|---|---|
| 谁在管 t-t.live | 一个 certd |
这台机器上没有 certd 进程/容器;真正的竞争者是 certimate 容器(1Panel-certimate-OKCO,7 个工作流每天 12:00 起跑) |
certd.t-t.live 是什么 |
certd 本体 | 只是个 nginx vhost → 127.0.0.1:6000 → frps 隧道到别处;6000/7000 是 frp 端口,与证书无关 |
| 源站证书到期 | 2026-12-27 | 2026-10-09(只剩 3 天)。5 个站点 ssl/ 目录里的文件 mtime 全是 2026-07-11 21:28:03,之后再没被更新过 |
| 公网看到的证书 | = 源站证书 | 不是。公网走腾讯云 EO 边缘加速(apex 是 CNAME → t-t.live.eo.dnse2.com),边缘那张是 LE 12-27 |
9.7c ★★ 根因:apex 上的 CNAME 让 lego 拿不到权威 NS
certimate 的「团团证书申请工作流」2026-10-06 04:05 执行失败(usj.cc / 200181.xyz 同日都成功):
failed to obtain certificate: resolver: one or more domains had a problem:
[*.t-t.live: dns01: time limit exceeded: last error:
authoritative nameservers: [zone=t-t.live.] could not determine authoritative nameservers]
实测 dig:
t-t.live. 60 IN CNAME t-t.live.eo.dnse2.com. ← apex 是一条 CNAME
dig +short NS t-t.live → t-t.live.eo.dnse2.com. ← 问 NS 也回 CNAME!
t-t.live 用了腾讯云 EO 的 “CNAME 接入”(apex 直接 CNAME 到 EO),
DNSPod 的权威服务器对 apex 的 任何查询都返回那条 CNAME。
lego 的 FindZoneByFqdn 是「从 _acme-challenge.t-t.live 往上问 NS」——
走到 apex 拿到的是 CNAME 而非 NS,于是判定「拿不到权威 NS」→ 传播检查超时 → 放弃。
usj.cc(NS =duncan.dnspod.net/brady.dnspod.net)和200181.xyz(NS =jobs/martha.ns.cloudflare.com)的 apex 都没有 CNAME, 所以它们从没撞上这个问题 —— 这也解释了「为什么只有 t-t.live 年年失败」。
★ 我们的实现天然不受影响:acme.ts 不做 NS 查询,而是
「用 DNSPod API 写字面量 _acme-challenge.<domain> → 固定等 30s → 通知 CA 校验 → 轮询」。
CA 自己查 TXT 时走的是正常解析路径(_acme-challenge 是独立名字,不被 apex 的 CNAME 影响)。
9.7d ★ 附带发现:探针必须量「源站」而不是「公网」
certprobe.probeTls 原本只按域名做公网握手。对挂在 CDN / 边缘加速后面的域名,
量到的是边缘证书 —— 后果分两种,都很坏:
| 域名 | 公网探到 | 源站实际 | 后果 |
|---|---|---|---|
t-t.live |
83 天(EO 的 LE 证书) | 4 天 | 续期判定永远「还很新」→ 源站证书悄悄过期,站点挂掉 |
200181.xyz |
62 天(Cloudflare 代理证书) | 48 天(1Panel,TrustAsia) | 续期被推迟约 2 周,余量被吃掉 |
ssh.200181.xyz |
DNS 不解析(无公网 A 记录) | 48 天 | 探针报 ENOTFOUND → daysLeft=null → 判「必须续」→ 每天尝试续期 |
修复:给 DomainConfig 加两个字段,属可选、向后兼容(不填行为不变):
| 字段 | 作用 |
|---|---|
probe_connect |
探针连到哪个地址(填源站 IP)。SNI 仍是域名,所以源站 nginx 能按 server_name 选到对的 vhost |
probe_sni |
探针发出去的 SNI。源站上未必有与域名同名的站点 —— 例如 1Panel 里没有 usj.cc 这个网站(它只是证书名),直接拿 usj.cc 当 SNI 会落到默认 server、拿到别的证书 |
三个域名都配上了源站直连:
usj.cc { probe_connect: '119.29.215.187', probe_sni: 'artalk.usj.cc' } // 面板无 usj.cc 站点
t-t.live { probe_connect: '119.29.215.187', probe_sni: 't-t.live' }
200181.xyz { probe_connect: '119.29.215.187', probe_sni: 'ssh.200181.xyz' } // 面板站点名是子域
实测(本机真 Node 跑编译产物):
t-t.live 公网 → 83 天 / 源站 → 4 天 ← 修复生效,差 79 天
artalk.usj.cc 公网 → 62 天 / 源站 → 62 天 ← 本来就对
ssh.200181.xyz 公网 → 解析失败 / 源站 → 48 天 ← 修复了「天天误判要续期」
同一能力也补进了国内机 editor-api 的 /ssl-probe(新增 &connect=<IP> 参数),
这样 Worker 侧的只读页面也能显示正确的剩余天数。
9.8 待决 → 2026-10-06 晚全部定案
| 事项 | 定案 |
|---|---|
Worker 的续期 cron 10 4 * * * |
停掉。wrangler.toml 的 crons 改为 ["17 3 * * *", "0 * * * *"];index.ts 里整个 renew 分支删除 |
Worker 的签发入口 POST /ssl/issue |
改成 501 硬拒绝,响应里给出「去国内机执行」的 curl 命令;后台按钮文案改成「签发/续期(在国内机)」 |
usj.cc 的 SAN 升级为 usj.cc;*.usj.cc |
接受(线上原本是单名 CN=usj.cc,升级后一张证书同时覆盖主域与全部子域) |
dnsapi.usj.cc 上 HTTPS |
仍未做;cn-certkeeper 只绑 127.0.0.1:8019,暂不走反代 |
| 停 certd / 接管 t-t.live | certd 根本不存在,真正的竞争者是 certimate 容器;处理方式见 §9.10 |
9.9 ★★ 上线前修掉的两个 ACME 客户端真实 bug(只有打真 CA 才会现形)
这两个 bug 从写完到上线一直藏着,原因是:账户注册从来没成功过
(certkeeper:acme-account 这个键一直是空的),所以协议层代码一行都没真跑过。
本地自测 117 项全过 —— 因为它们覆盖的是鉴权矩阵 / 配置校验 / 加密往返 / 脱敏,
不覆盖 ACME 协议交互。
Bug ① protected.jwk 里带着私钥 → 403 newAccount JWS signature is invalid
| 症状 | LiteSSL newAccount 回 403 {"detail":"newAccount JWS signature is invalid"} |
| 迷惑点 | 本地验签是通过的 —— 拿 JWK 的 x/y 造公钥验 raw r‖s,结果 true。报错文案把人往「签名算法错了」上带 |
| 真因 | 账户密钥是 exportKey('jwk', privateKey) 出来的,含 d / key_ops / ext。我们把它原样塞进了 protected.jwk:{"key_ops":["sign"],"ext":true,"kty":"EC","x":"…","y":"…","crv":"P-256","d":"…"} |
| 规范 | RFC 8555 §6.2:jwk 字段必须是公钥;§7.3.4:EAB 内层 payload 同样是公钥;RFC 7517 §6.2.1:EC 公钥只定义 crv/kty/x/y |
| 修复 | 新增 publicJwk(),把 JWK 裁剪成该密钥类型定义的成员;protectedHeader() 与 EAB 的 innerPayload 都走它 |
| 附带 | 这也是安全修复 —— 私钥标量 d 不该发给 CA |
排查手法:写了个自包含脚本,走真实编译产物的代码路径生成 JWS, 再用公钥本地验签。本地过 → 密码学没错,问题在 JWK 的语义/内容。 这一步把「签名算法错」和「JWK 内容不被接受」一刀切开,省掉大量瞎猜。
Bug ② POST-as-GET 的「空 payload」被编成了 IiI → 400 Expected JWS payload message
| 症状 | LiteSSL 读账户资源 / 授权 / 订单时回 400 {"detail":"Expected JWS payload message"} |
| 真因 | postAsGet() 调 post(url, '') → b64uJson('') = base64url(JSON 的 "") = IiI,服务端解出来是两个字符 ",不是空 |
| 规范 | RFC 8555 §6.3:POST-as-GET 的 payload 必须是零长度的八位字节串。既不是 JSON null(那是「让服务端删字段」),也不是 JSON "" |
| 修复 | postAsGet() 改成传 undefined;signJws() 里 payload === undefined 走空串分支(p = '') |
| 为什么以前没炸 | ZeroSSL 容忍 IiI(实测 POST-as-GET 回 200 valid),LiteSSL 严格。换 CA 才把它照出来 |
修复后实测:postAsGet 发出去的 payload = "",解码长度 0 字节。
9.10 ★★ LiteSSL 账户接管(EAB 是一次性的)
两个事实先纠正
- 目录地址错了。我们配的是
https://acme.trustasia.com/acme/v2/directory, 而 certimate 数据库里真实用的是https://acme.litessl.com/acme/v2/directory。 打错的目录会回403 externalAccountBinding kid is not valid—— EAB kid 是绑定到具体 CA 目录/账户的,地址不对,kid 自然不认。 (另有两种写法…/v2/DV90/directory是 CertCloud 系的,走 404。) - EAB 只能用一次。换到正确目录后回的是
400 externalAccountRequired {"detail":"External account binding has already been used"}—— certimate 在 2026-02 已经用这条 EAB 注册过账户了。
处置:继承 certimate 的账户(而不是再要一条新 EAB)
「接管」最干净的做法就是把它已经注册好的账户过户过来 —— 同一个 CA 账户, 不必打扰用户去 FreeSSL 控制台新建 EAB。
从 certimate data.db 的 acme_accounts 表取(ca='litessl' 只有一条):
kid = https://acme.litessl.com/acme/v2/acct/NjAuzljPMeTgYARuIduk3w
私钥 = -----BEGIN EC PRIVATE KEY-----(SEC1,P-256)
email = imql@qq.com
过户步骤(都跑在国内机):
- 宿主机
python3抽data.db的acme_accounts行 →/tmp/litessl-acct.json(600) docker cp进容器,容器里createPrivateKey(pem).export({format:'jwk'})转 JWK- 写
certkeeper:acme-account(={jwk, kid, directoryUrl},就是getAcmeAccount认的键位) - 顺带修
litessl凭据的directoryUrl为正确地址 —— 否则getAcmeAccount里saved.directoryUrl === directoryUrl判不等,会去重新注册 - 清掉宿主机/容器里的私钥临时文件
验收:
② 复用已存账户:kid = https://acme.litessl.com/acme/v2/acct/NjAuzljPMeTgYARuIduk3w
③ 用 kid 签名访问账户资源:HTTP 200,status=valid
为此给容器加了个常驻诊断工具
deploy/cn-certkeeper/src/selfcheck-acme.mjs: 只跑「读目录 → 注册/复用账户 → POST-as-GET 验 kid」,不签发、不写 DNS、不部署。 以后再遇到「续期不动」,先跑它,一刀切开是 CA 账户层还是 DNS/部署层。docker exec cn-certkeeper node src/selfcheck-acme.mjs --reuse # 验已存账户 docker exec cn-certkeeper node src/selfcheck-acme.mjs --ca zerossl # 换 CA 对照
9.11 ★★ 定案:ssl/update 不物化站点文件,ssl/upload+sslID 才会
OnePanelDeployer.deploy() 的幂等捷径是:读 /websites/{id}/https,
若 cur.enable && cur.sslId === sslId 就跳过绑定(避免多余的 nginx reload)。
风险在于:站点 ssl/{fullchain,privkey}.pem 是 1Panel 从证书库物化出来的,
如果「改记录内容」不会重新物化,那么第二次续期就会静默失败 ——
库里证书是新的,站点文件还是旧的。
实测(id=13,5 个 t-t.live 站点)
| 调用 | 返回 | 5 个站点 ssl/*.pem 的 mtime / md5 |
|---|---|---|
POST /websites/ssl/update(证书内容原样回传) |
200 success | 一个都没变 |
POST /websites/ssl/upload + sslID=13(>0) |
200 success | 全部刷新 |
结论:1Panel 的 ssl/update 会静默空转 —— 只改库记录的元数据,
不碰站点目录下的 PEM。空转还不可见,因为接口返回 success。
三个接口的正确用途(别混)
| 接口 | 用途 | 陷阱 |
|---|---|---|
POST /websites/ssl/update |
改 ACME 申请设置(domains / autoRenew / DNS 账户) | 结构体 WebsiteSSLUpdate 没有 certificate/privateKey 字段,传了被静默丢弃;domains 只从 otherDomains 取,不传就清空;还会顺带把 autoRenew 置 false |
POST /websites/ssl/upload + sslID > 0 |
换证书内容(原地更新) | 走 Upload():SSLID>0 → 取记录 → 用 PrivateKey/Certificate 覆盖 → 重算 ExpireDate/StartDate/Type/PrimaryDomain/domains → UpdateSSLConfig() → 重新物化站点文件 |
POST /websites/ssl/upload(不带 sslID) |
新建一条记录 | 会造重复记录 |
★ 副作用:Upload() 把 primaryDomain 重算成证书的第一个 SAN(cert.DNSNames[0])。
SAN 顺序一变 primaryDomain 就漂移(#11 从 usj.cc 变 *.usj.cc)
→ 匹配记录时必须补 domains 兜底,否则每次续期都新建一条重复记录。
落地改动
deployer.ts:
uploadSsl()改用ssl/upload+sslID原地更新,返回{ id, replaced }; 旧记录的description带回,新建后回查库补 id- 新增
matchSslRecord<T>():两级匹配(primaryDomain优先,domains兜底),多命中取到期最晚 deploy()幂等判定加!replaced;replaced为真时强制重绑以刷新ssl/文件:if (cur.enable && cur.sslId === sslId && !replaced) { /* 跳过 */ } if (replaced && cur.enable && cur.sslId === sslId) { log(`1Panel:网站 ${key} 指向的证书 #${sslId} 内容刚被更新,强制重绑以刷新 ssl/ 文件`); }
9.12 ★★ 本轮又修掉的 5 个静默失败(2026-10-06 深夜)
这五个的共同特征:接口返回 200 / 逻辑不报错,但结果没生效。 全部只有「真打线上、真去比对产物」才会现形。
① POST /websites/{id}/https 的字段名是 websiteSSLId,不是 sslId
| 症状 | 换证书时回 HTTP 200 + code 500「服务错误: record not found」,整个 t-t.live 部署被阻断 |
| 迷惑点 | 报错文案是 DB 层(record not found),完全指不到参数上 |
| 真因 | dto/request/website.go 里 WebsiteHTTPSOp{ WebsiteSSLID uint \json:"websiteSSLId"` }。发 sslId→ Go **静默忽略** → 零值0→websiteSSLRepo.GetFirst(WithByID(0))` → not found |
| 验证 | 对照实验:sslId=13 → 500 / websiteSSLId=13 → 200 code=200 |
| 修复 | 改字段名 |
② ssl/update 换不了证书内容 → 见 §9.11
③ 多吉云「复用」判据只比域名集合 → 续期静默空转
| 症状 | 明明刚签了新证书,多吉云那边不复用,每次都重传;反过来更糟的情况是「第一次之后再不复用新证书」 |
| 真因链 | 多吉云 list 接口不返回 PEM,只能比域名集 → 只看域名集就永远认为「已覆盖」;但我们的 cert.notAfter 因为 ④ 退化成了「签发时刻 + 90 天」,与对方 expire 差 1~2 小时,比对永远为假 |
| 修复 | ① parsePemInfo 修好(见 ④);② 判据带上到期时间,放 1 天容差:return theirs > 0 && theirs >= cert.notAfter - 86_400_000; |
| 验证 | 多吉云:已有覆盖 usj.cc, www.usj.cc, artalk.usj.cc 的证书 #41963(到期不早于本次),复用 |
④ parsePemInfo() 对「完整链」返回 {}
| 症状 | rec.expireAt 静默退化成调用方兜底值「签发时刻 + 90 天」 |
| 真因 | 旧实现把 PEM 里各段 base64 拼接后一次 atob。中间段尾部的 = 填充出现在字符串中段 → atob 抛错 → 整函数返回 {}。而完整链(叶 + 中间)恰好是部署器最常拿到的形态 |
| 修复 | 只取第一段(叶证书)解析:blocks = pem.match(/-----BEGIN CERTIFICATE-----[\s\S]*?-----END CERTIFICATE-----/g),blocks[0] 解不出来就 return {} |
| 附带 | 新增 derLen() / parseSanFromDer():精确定位 SAN 扩展 OID 2.5.29.17 再读 [2] dNSName,替掉原来的字节扫描启发式 |
| 回归 | 自测新增 [11] 节 8 项断言,样本是 openssl 现场生成、写死内联 base64 的叶+中间证书(两段都以 = 结尾,正是 bug 现场),含精确值断言 notAfter === 1799063386000、SAN === ["leaf.test.example","*.leaf.test.example"]、以及「链解析不混入中间证书主体名」 |
⑤ 400 authorization must be pending —— 是竞态,不是逻辑错
| 症状 | DNS-01 挑战通知偶尔回 400 authorization must be pending(200181.xyz 上连中两次) |
| 试过没用 | 「先读状态再 POST」挡不住毫秒级窗口 —— 读的时候还是 pending,POST 到的时候服务端已置位 |
| 修复 | POST 侧做幂等容错:只认这一句错误,吞掉后交由 pollAuthz 定论 |
| 验证 | 重试成功,notAfter: 1799056799000 |
try {
await this.post(p.chalUrl, {});
} catch (e) {
const m = e instanceof Error ? e.message : String(e);
if (!/authorization must be pending/i.test(m)) throw e;
this.log(`${p.label} 通知验证时状态已翻过 pending(竞态),改由轮询定论`);
}
9.13 本轮最终状态(2026-10-06 收工)
1Panel 证书库:5 条 → 3 条,全部在用
| id | primaryDomain | domains | CA / 算法 | 到期 | 绑定站点 |
|---|---|---|---|---|---|
#13 |
t-t.live |
*.t-t.live |
LiteSSL ECC | 2027-01-04 | 5 |
#11 |
*.usj.cc |
usj.cc |
LiteSSL ECC | 2027-01-04 | 5 |
#10 |
*.200181.xyz |
200181.xyz |
LiteSSL ECC | 2027-01-04 | 1 |
删掉的是 #12(重复空壳)与 #2(2026-10-09 到期的旧证书)。
全网域只读核验(POST /renew-check):
| 域名 | 探针 | subject | TLS | daysLeft | needRenew |
|---|---|---|---|---|---|
t-t.live |
119.29.215.187(SNI) | t-t.live |
TLSv1.3 | 90 | false |
usj.cc |
119.29.215.187(SNI) | *.usj.cc |
TLSv1.3 | 90 | false |
200181.xyz |
ssh.200181.xyz |
— | — | 90 | false |
其它
t-t.live5 个站点:PEM 文件 mtime 全前进、端到端 openssl 握手均为CN=t-t.live/2027-01-04、nginx -t通过usj.cc:LiteSSL ECC,SANusj.cc + *.usj.cc;多吉云#41963(algo=ECDSA 256)绑 3 个 CDN 域名; 公网 CDN 与源站直连握手均已验证(usj.cc/www.usj.cc均回CN=*.usj.cc/ 2027-01-04)- certimate 工作流清查与处置见 §9.14
- Worker 侧
crons = ["17 3 * * *", "0 * * * *"](证书 cron 已消失),certIssue改 501,renewAll归零; 线上admin.jsmd5 与本地一致(60c1452407580413b104e64038ab8b24) - 自测 125/125 通过(含 §9.12 ④ 的 8 项 PEM 解析回归);
npm run typecheck零错误 - 新增 4 个常驻工具:
renew-one.mjs(单域签发+部署)、rollback-dogecloud.mjs(多吉云应急回退)、txt-inspect.mjs(TXT 残留盘点/清理)、selfcheck-acme.mjs(CA 账户层诊断)
9.14 ★★ certimate 工作流清查(只停「会签发并部署」的)
「本项目的 deployer 已经接管部署」这件事,只有在没有第二个写者时才成立。 清查 certimate 全部 7 条工作流后发现:除 t-t.live 外,usj.cc 与 200181.xyz 的两条申请工作流也一直开着。
| id | 名称 | 改前 | 处置 | 说明 |
|---|---|---|---|---|
bsmqgpygir8l01s |
优世界证书申请工作流 | 1 | → 0 | litessl 签 *.usj.cc;usj.cc RSA2048 → dogecloud-cdn + 1panel |
xhydhxamrkyfpkj |
200181.xyz证书申请工作流 | 1 | → 0 | litessl 签 *.200181.xyz;200181.xyz RSA2048(cloudflare DNS)→ 1panel |
bokgwzrapy30ko3 |
团团证书申请工作流 | 0 | 保持 | t-t.live,上一轮已停 |
3u6mx7yuelexnjd |
团团ssl证书过期预警 | 0 | 保持 | 上一轮已停 |
ss7skufa40n48ua |
优世界ssl证书过期预警 | 1 | 保留 | 纯监控 usj.cc,≤30 天发邮件,不写任何东西 |
xtq2q2eudcuwtr2 |
优世界artalk评论ssl证书过期预警 | 1 | 保留 | 纯监控 artalk.usj.cc,≤30 天发邮件 |
6f1b67j7y54qm1x |
伍比贰ssl证书过期预警 | 1 | 保留 | 监控 5b2.cn(非本项目域名),≤15 天发邮件 |
为什么必须停掉那两条
它们的 bizApply 配置里有两把「迟早会开火」的枪:
"skipBeforeExpiryDays": 30, // 距到期 >30 天就跳过 → 现在 90 天,暂时不动
"skipOnLastSucceeded": true // 上一次成功就不重复
所以今天到未来约 60 天它静默无害;但一旦进入「到期前 30 天」窗口,它会:
自己签一张 RSA2048 的 *.usj.cc;usj.cc → 推到 dogecloud + 1panel → 把我们刚切好的 ECC 证书覆盖回去,
并与本项目的续期互相打架。这种「60 天后才爆、且爆得静默」的竞争必须提前拆掉。
处置方式
沿用上一轮验证过的姿势(PocketBase 必须先停容器,否则 WAL 回写覆盖):
docker stop 1Panel-certimate-OKCO
# 合并 WAL
sqlite3 data.db "PRAGMA wal_checkpoint(TRUNCATE);"
# 备份 → 改 enabled → 复核
cp data.db data.db.bak-$(date +%Y%m%d-%H%M%S)
# update workflow set enabled=0 where id in (...)
docker start 1Panel-certimate-OKCO
改库脚本带 名称守卫(id 与 name 必须同时对上,否则拒绝执行), 避免以后 id 漂移时误停别的工作流。
验收:容器重启 25s 后复核,两条仍为 enabled=0,其余五条状态不变。
回滚:
sqlite3 data.db "update workflow set enabled=1 where id='bsmqgpygir8l01s';"(库在/1panel/1panel/apps/certimate/certimate/data/data.db,同目录留了.bak-20261006-200609)
遗留:writeapi.usj.cc 的手工 vhost 不在本项目的部署面内
全网核验时发现 writeapi.usj.cc 的 ssl/fullchain.pem 仍是 旧证书
(CN=usj.cc / RSA / LiteSSL RSA CA / 到期 2026-12-07,mtime 2026-10-04 21:50)。
原因:它是 1Panel 手工建的 vhost,nginx 的 ssl_certificate 引用不在 /www/sites/ 目录树里
(grep -rn ssl_certificate sites/writeapi.usj.cc/ 无结果),因此不走站点管理,
/websites/{id}/https 的绑定操作碰不到它 —— 证书是文件拷贝,与证书库记录解耦。
功能上没问题(该证书 SAN 含 *.usj.cc,能覆盖 writeapi.usj.cc),但:
算法仍是 RSA、62 天后到期、且不会随本项目的续期自动更新 → 需要单独纳管。