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

1077 lines
58 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 证书管家
> **文档定位**:本文既是**现状说明**(第一~二节 + §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 存储,可动态加域名)
```jsonc
{
"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>`,密文),域名配置里只写**名字**引用:
```jsonc
// 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
```http
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** |
一次签发约 10~12 次 JWS:**优化前 ≈3.3ms → 优化后 ≈1.4ms**(乘 2~3 倍保守系数:
6.6~10ms vs 2.8~4.2ms)。免费版 10ms 是「能压进去但没余量」。
### 9.3 优化:acme.ts 缓存 CryptoKey
`src/lib/acme.ts` 原实现**每次 JWS 都 `importKey`**。改为缓存 Promise:
```ts
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、拿到别的证书 |
三个域名都配上了源站直连:
```jsonc
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`:<br>`{"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/部署层。
> ```bash
> 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/` 文件:
```ts
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 天容差:<br>`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` |
```ts
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 回写覆盖):
```bash
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]`,两者都被覆盖)。
**解法 —— 不写一行代码**:
```bash
# 宿主上执行(路径是宿主视角;容器内是 /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 直接挂**」。
**所以验收必须包含这三条**(缺一不可):
```bash
# 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` 负责这一步(只读,不签发不部署):
```bash
# 脚本要先送进容器(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
```