# 证书管家 > **文档定位**:本文既是**现状说明**(第一~二节 + §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:) │ │ │ │ 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:`,密文),域名配置里只写**名字**引用: ```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.` 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 = "," (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 (已有) ``` ### 3.3 部署到多吉云 CDN ```http POST https://api.dogecloud.com/cdn/cert/upload.json note=<备注>&cert=&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' + + ) 1Panel-Timestamp: 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:` | 凭据(腾讯云/多吉云/1Panel/EAB) | ★ **AES-GCM 加密** | | `cert:` | 签发的证书 `{ 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 / 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 | null = null; private importSigningKey(): Promise { if (!this.signingKey) { this.signingKey = (crypto.subtle.importKey( 'jwk', this.account.jwk, { name: 'ECDSA', namedCurve: 'P-256' }, false, ['sign'], ) as Promise).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` → `/a/b/c.kv`;`put` 先写 `.tmp-` 再 `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-.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 的新证书**。 **必须二选一**: 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.` → 固定等 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=` 参数), 这样 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 是一次性的) #### 两个事实先纠正 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()`**:两级匹配(`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 天容差:
`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 不在本项目的部署面内 全网核验时发现 `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 天后到期、且不会随本项目的续期自动更新 → **需要单独纳管**。