Files
blog/docs/证书管家.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

58 KiB
Raw Blame History

证书管家

文档定位:本文既是现状说明(第一~二节 + §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
手工 vhost 的证书 ✅ 已解决(2026-10-06):writeapi.usj.cc 靠相对软链接跟随 artalk.usj.cc,随续期自动更新 —— 机制与坑见 §9.15

原始方案(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 件事

  1. freessl.cn API 是否可用(认证 + 申请 + 下载全流程) → 若不行,改 Let's Encrypt 标准 ACME(推荐这条路,更稳)
  2. certimate 里 tencentcloud 凭据的类型 (DNSPod token 还是 CAM 密钥?决定签名实现)
  3. 多吉云 cdn/cert/upload.json 的参数名(private 还是 privary)
  4. 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 凭据缺口:现 cloudflare token 是 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.cc 62 天 / t-t.live 83 天 / 200181.xyz 62 天。

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 cron 10 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":[...]}。

⚠️ 2 个手工建的 nginx vhost(dnsapi.usj.cc / writeapi.usj.cc) 不归 1Panel 站点管理,证书是文件拷贝,与证书库记录解耦。

🔧 2026-10-06 更正:这里原先写的是「3 个」,把 vaultwarden 也算进去了 —— 实测错:vaultwarden 是正常纳管的 1Panel 站点(面板 #13,主域名 vw.usj.cc, 别名才是 vaultwarden),续期时会被正常绑定。 手工 vhost 只剩 writeapi.usj.cc(已用软链接解决,§9.15)与 dnsapi.usj.cc(待随 cn-dns-helper 一起决策)。

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 的新证书。

必须二选一:

  1. 停掉 certd,本项目接管 t-t.live 全部(含 1Panel + 腾讯云 EO)
  2. 本项目放弃 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 不做(2026-10-06 重新定性):它其实是 cn-dns-helper 的反代入口(proxy_pass http://127.0.0.1:8018),而 cn-dns-helper 大概率已是遗留(见 架构精简候选.md 候选 2)→ 去留应与它一起决定,不该单独给一个待退役的服务加 HTTPS
停 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 是一次性的)

两个事实先纠正

  1. 目录地址错了。我们配的是 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。)
  2. 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

过户步骤(都跑在国内机):

  1. 宿主机 python3 抽 data.db 的 acme_accounts 行 → /tmp/litessl-acct.json(600)
  2. docker cp 进容器,容器里 createPrivateKey(pem).export({format:'jwk'}) 转 JWK
  3. 写 certkeeper:acme-account(= {jwk, kid, directoryUrl},就是 getAcmeAccount 认的键位)
  4. 顺带修 litessl 凭据的 directoryUrl 为正确地址 —— 否则 getAcmeAccount 里 saved.directoryUrl === directoryUrl 判不等,会去重新注册
  5. 清掉宿主机/容器里的私钥临时文件

验收:

② 复用已存账户: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.live 5 个站点:PEM 文件 mtime 全前进、端到端 openssl 握手均为 CN=t-t.live / 2027-01-04、nginx -t 通过
  • usj.cc:LiteSSL ECC,SAN usj.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.js md5 与本地一致(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 不在本项目的部署面内

✅ 2026-10-06 当天解决 —— 解法与踩到的坑见下一节 §9.15。 下面保留当时的诊断,但请注意其中一处判断是错的(已在 §9.15 更正)。

全网核验时发现 writeapi.usj.cc 的 ssl/fullchain.pem 仍是 旧证书 (CN=usj.cc / RSA / LiteSSL RSA CA / 到期 2026-12-07,mtime 2026-10-04 21:50)。

原因:它是 1Panel 手工建的 vhost,因此不走 1Panel 的站点管理面 —— /websites/{id}/https 的绑定操作碰不到它,证书与证书库记录解耦。

⚠️ 当时写错的一点:本文档原先记的是「它的 ssl_certificate 引用不在 /www/sites/ 目录树里」 (依据是一次 grep -rn ssl_certificate sites/writeapi.usj.cc/ 没结果)。 实测推翻:/www/conf.d/writeapi.usj.cc.conf 里明确写着 ssl_certificate /www/sites/writeapi.usj.cc/ssl/fullchain.pem; —— 路径在管理树里。 真正的原因是它在 1Panel 面板上根本不是一个站点(面板共 11 个网站,没有它), 所以「按站点名去绑定」这条路径永远找不到目标。

教训:「路径像不像」不构成判据,要直接查面板的站点清单(POST /websites/search)。


9.15 ★ 手工 vhost 的证书:用相对软链接跟随已纳管站点

背景:writeapi.usj.cc(写作后台 post.usj.cc 的 API 入口)是手工 vhost, 不在 1Panel 站点清单里。而它与 artalk.usj.cc 用的是同一张证书 (usj.cc 的 SAN = [usj.cc, *.usj.cc],两者都被覆盖)。

解法 —— 不写一行代码:

# 宿主上执行(路径是宿主视角;容器内是 /www/sites/...)
D=/1panel/1panel/www/sites/writeapi.usj.cc/ssl
ln -sfn ../../artalk.usj.cc/ssl/fullchain.pem "$D/fullchain.pem"
ln -sfn ../../artalk.usj.cc/ssl/privkey.pem   "$D/privkey.pem"

这样 artalk 每次续期被 1Panel 物化新证书时,writeapi 通过软链接自动跟随; 1Panel 绑定 artalk 时本来就会 reload nginx,所以 writeapi 那边也一并生效 —— 零代码、零新增凭据、零额外机制。

★★ 坑:软链接必须用相对路径(写绝对路径会在容器里断链)

1Panel 的 openresty 跑在容器 1Panel-openresty-Kmh2 里,挂载关系是:

宿主 容器内
sites 根 /1panel/1panel/www/sites/ /www/sites/

所以宿主绝对路径 /1panel/1panel/www/sites/artalk.usj.cc/ssl/privkey.pem 在容器里不存在 → 软链接在容器侧断链 → nginx 重启/reload 时读不到证书。

这个坑的隐蔽之处:断链后 nginx 的 -s reload 会静默失败(保留旧配置、服务不中断), 握手看起来一切正常(拿到的还是旧配置里那张内容相同的证书), 只有 worker 进程的启动时间没变这一条线索 —— 真实后果是「等某天 nginx 重启,writeapi 的 HTTPS 直接挂」。

所以验收必须包含这三条(缺一不可):

# 1) 容器内能真正读到(最关键 —— 宿主侧 ls 看不出断链)
docker exec 1Panel-openresty-Kmh2 head -1 /www/sites/writeapi.usj.cc/ssl/privkey.pem
#    应输出 -----BEGIN PRIVATE KEY-----

# 2) 配置预检
docker exec 1Panel-openresty-Kmh2 /usr/local/openresty/bin/openresty -t

# 3) reload 后 **worker 进程确实换了**(只看握手会被"内容相同的旧证书"骗过)
docker exec 1Panel-openresty-Kmh2 /usr/local/openresty/bin/openresty -s reload
ps -eo pid,lstart,args | grep "nginx: worker" | grep -v grep

把锁在 KV 里的证书取出来:src/dump-cert.mjs

证书在数据目录的 KV 里(AES-GCM 加密),手工 vhost 用的是磁盘 .pem,两者原先没有桥。 新增的 deploy/cn-certkeeper/src/dump-cert.mjs 负责这一步(只读,不签发不部署):

# 脚本要先送进容器(1Panel 的文件 API 会拒绝 .mjs,见下)
docker exec -i cn-certkeeper sh -c 'cat > /tmp/dump-cert.mjs' < deploy/cn-certkeeper/src/dump-cert.mjs
docker exec cn-certkeeper node /tmp/dump-cert.mjs usj.cc
# 产物落在 /data/tmp/(宿主 <数据目录>/tmp/)—— ★ 含明文私钥,用完立刻删

两个操作层面的坑:

  • 1Panel 的文件 API 拒绝写 .mjs(/files/save 返回 500「目标路径不存在」)。 这是它的可执行扩展名过滤(防脚本落盘)。同样地,往新建的 root:root 700 目录写也会失败。 临时脚本的可靠送法是 docker exec -i … sh -c 'cat > 路径' < 本地文件。
  • 「命令带 docker exec 时远程执行返回空」:本地 .editor-tmp/cnrun.py(1Panel 计划任务通道) 偶发出现「退出码 0 但零输出」。加 timeout 30 docker exec … 包一层即可稳定复现结果; 判断真实状态时优先看容器内的直接证据,不要只看那一次调用的输出。

验收记录(2026-10-06)

替换前:CN=usj.cc      / LiteSSL RSA CA / 到期 2026-12-07(mtime 10-04 21:50)
替换后:CN=*.usj.cc    / LiteSSL ECC CA / 到期 2027-01-04
软链接:fullchain.pem -> ../../artalk.usj.cc/ssl/fullchain.pem
容器内可读: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 → CN=*.usj.cc,notAfter=Jan 4 2027
实访:https://writeapi.usj.cc/health → HTTP 200