2026-10-06 22:23:53 +08:00
|
|
|
|
# 证书管家
|
2026-10-06 12:46:05 +08:00
|
|
|
|
|
2026-10-06 22:23:53 +08:00
|
|
|
|
> **文档定位**:本文既是**现状说明**(第一~二节 + §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 |
|
2026-10-06 22:54:58 +08:00
|
|
|
|
| **手工 vhost 的证书** | ✅ **已解决**(2026-10-06):`writeapi.usj.cc` 靠**相对软链接跟随 `artalk.usj.cc`**,随续期自动更新 —— 机制与坑见 §9.15 |
|
2026-10-06 22:23:53 +08:00
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
|
|
## 原始方案(Phase 1 形态,保留供对照)
|
|
|
|
|
|
|
|
|
|
|
|
> **当初目标**:用 Cloudflare Worker 取代服务器上的 certimate,
|
2026-10-06 12:46:05 +08:00
|
|
|
|
> 申请 + 续期 + 部署证书(多吉云 CDN + 1Panel),把 certimate 这个服务精简掉。
|
2026-10-06 22:23:53 +08:00
|
|
|
|
> —— ⚠️ 该目标**已被 §9 推翻**:Worker 免费版 CPU 硬顶 10ms/请求,签发跑不动,改由国内机承担。
|
2026-10-06 12:46:05 +08:00
|
|
|
|
|
2026-10-06 22:23:53 +08:00
|
|
|
|
**当时确认的决策(2026-10-06)**
|
2026-10-06 12:46:05 +08:00
|
|
|
|
|
|
|
|
|
|
| 项 | 决定 |
|
|
|
|
|
|
|---|---|
|
|
|
|
|
|
| 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 站点证书实况
|
|
|
|
|
|
|
2026-10-06 13:05:55 +08:00
|
|
|
|
| 站点 | 到期 | 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,无法从外部探测。
|
2026-10-06 12:46:05 +08:00
|
|
|
|
|
|
|
|
|
|
> 证书落地路径:`/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. 邮件通知(成功/失败) │ │
|
|
|
|
|
|
│ └─────────────────────────────────────────────────┘ │
|
|
|
|
|
|
│ │
|
2026-10-06 14:19:01 +08:00
|
|
|
|
│ [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位**) │
|
2026-10-06 12:46:05 +08:00
|
|
|
|
└─────────────────────────────────────────────────────────────────┘
|
|
|
|
|
|
```
|
|
|
|
|
|
|
2026-10-06 14:19:01 +08:00
|
|
|
|
### 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 有的编辑能力,这里一个不少;它没有的(多吉云自动部署),这里还多了。**
|
|
|
|
|
|
|
2026-10-06 12:46:05 +08:00
|
|
|
|
### 2.1 域名配置(KV 存储,可动态加域名)
|
|
|
|
|
|
|
|
|
|
|
|
```jsonc
|
|
|
|
|
|
{
|
2026-10-06 14:19:01 +08:00
|
|
|
|
"version": 1, // ★ 前向兼容:加字段时递增,读取侧按版本迁移
|
|
|
|
|
|
"notify": {
|
|
|
|
|
|
"emails": ["177018615@qq.com"],// 到期/失败提醒收件人(可在后台改)
|
|
|
|
|
|
"daysBefore": 30 // 剩余多少天开始提醒
|
|
|
|
|
|
},
|
2026-10-06 12:46:05 +08:00
|
|
|
|
"domains": [
|
|
|
|
|
|
{
|
|
|
|
|
|
"name": "usj.cc",
|
|
|
|
|
|
"san": ["usj.cc", "*.usj.cc"],
|
2026-10-06 14:19:01 +08:00
|
|
|
|
"dns": "tencent", // 引用凭据名,见下
|
2026-10-06 12:46:05 +08:00
|
|
|
|
"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"]
|
|
|
|
|
|
}
|
|
|
|
|
|
]
|
|
|
|
|
|
}
|
|
|
|
|
|
```
|
|
|
|
|
|
|
2026-10-06 14:19:01 +08:00
|
|
|
|
**凭据单独存**(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)不用碰域名配置;
|
|
|
|
|
|
> 加域名时直接复用已有凭据名,不用重复填密钥。
|
|
|
|
|
|
|
2026-10-06 12:46:05 +08:00
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
|
|
## 三、各环节实现要点
|
|
|
|
|
|
|
|
|
|
|
|
### 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 存储与通知
|
|
|
|
|
|
|
2026-10-06 14:19:01 +08:00
|
|
|
|
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 泄露不至于直接失守。
|
2026-10-06 12:46:05 +08:00
|
|
|
|
- **邮件**:Worker 无 TCP socket,**不能直连 SMTP**。
|
|
|
|
|
|
现有 worker 已用 **Resend**(HTTP API),复用同一套:
|
2026-10-06 14:19:01 +08:00
|
|
|
|
`POST https://api.resend.com/emails`,收件人从 `config.notify.emails` 读(后台可改)
|
2026-10-06 12:46:05 +08:00
|
|
|
|
- **crons**:`[triggers] crons = ["30 4 * * *"]`(UTC 4:30 = 北京 12:30)
|
|
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
|
|
## 四、落地路径(分阶段,可随时叫停)
|
|
|
|
|
|
|
2026-10-06 14:19:01 +08:00
|
|
|
|
### Phase 1 —— 只读监控 + 配置后台(零风险)
|
|
|
|
|
|
|
2026-10-06 12:46:05 +08:00
|
|
|
|
- 部署 Worker,`crons` 每天检查三个域名的证书剩余天数
|
|
|
|
|
|
- 到期 < 30 天 → 发邮件提醒
|
2026-10-06 14:19:01 +08:00
|
|
|
|
- **★ 同时把配置后台做出来**(`/admin` 单页):
|
|
|
|
|
|
- 证书状态列表(剩余天数、签发方、覆盖域名)
|
|
|
|
|
|
- 域名配置的**增删改**(写 KV,但 Phase 1 不参与签发)
|
|
|
|
|
|
- 凭据管理(加密存 KV,只回显尾 4 位)
|
|
|
|
|
|
- **完全不签发、不部署**,纯观察 + 配置。可以先跑两周验证数据准确性。
|
|
|
|
|
|
|
|
|
|
|
|
> ★ 把后台放 Phase 1 的理由:它是**纯读写自己的 KV**,不碰任何外部系统,
|
|
|
|
|
|
> 零风险却能立刻替代 certimate 的日常操作(加域名、改收件人、看状态)。
|
|
|
|
|
|
> 等工作流验证完毕,Phase 2 再让它「动起来」。
|
2026-10-06 12:46:05 +08:00
|
|
|
|
|
|
|
|
|
|
### Phase 2 —— 接管续期(+ 多吉云自动部署)
|
|
|
|
|
|
- 实现 ACME(Let's Encrypt 标准协议优先)
|
|
|
|
|
|
- DNS-01 走腾讯云 + Cloudflare
|
|
|
|
|
|
- 自动部署到**多吉云 CDN**(有正规 API,风险低)
|
|
|
|
|
|
- 邮件通知结果;**1Panel 仍由 certimate 管**
|
2026-10-06 14:19:01 +08:00
|
|
|
|
- 后台新增「手动续期 / 只重部署」按钮
|
2026-10-06 12:46:05 +08:00
|
|
|
|
|
|
|
|
|
|
### 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 | ✅ | ✅(走服务器拉取) |
|
|
|
|
|
|
| 部署又拍云 | ❌(本来就没配) | ❌(放弃) |
|
2026-10-06 14:19:01 +08:00
|
|
|
|
| **加/改域名** | 网页点点 | ✅ **网页点点**(`/admin` 后台,见 §2.0) |
|
|
|
|
|
|
| **改凭据 / 通知收件人** | 网页点点 | ✅ **网页点点** |
|
|
|
|
|
|
| **看执行日志** | ✅ | ✅(`GET /admin/log`) |
|
|
|
|
|
|
| 手动触发续期/重部署 | ✅ | ✅(后台按钮) |
|
|
|
|
|
|
| 凭据存储 | 明文 SQLite | ★ **AES-GCM 加密** |
|
2026-10-06 12:46:05 +08:00
|
|
|
|
| 需要服务器 | **✅ 需要** | ❌ **不需要** |
|
2026-10-06 14:19:01 +08:00
|
|
|
|
| 通知 | 邮件 | 邮件(Resend HTTP) |
|
2026-10-06 12:46:05 +08:00
|
|
|
|
| 成本 | 服务器 | **0**(CF 免费额度) |
|
2026-10-06 14:19:01 +08:00
|
|
|
|
|
|
|
|
|
|
> ★ 结论:**certimate 的编辑能力一项不少**(加域名、改凭据、看日志、手动触发),
|
|
|
|
|
|
> 而且额外多了「凭据加密存储」和「多吉云自动部署」。
|
|
|
|
|
|
> 唯一少的是 certimate 的「可视化工作流编排」(拖拽节点那种)——
|
|
|
|
|
|
> 本项目只有 3 个固定工作流,用配置表单比拖拽更直接,不构成损失。
|
2026-10-06 17:07:29 +08:00
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
|
|
## 八、签发/续期落地实现(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 改动更少、日志只有真实动作才有记录。
|
|
|
|
|
|
|
2026-10-06 18:28:55 +08:00
|
|
|
|
### 8.3 免费版 CPU 限制(★ 结论已在 §9 被实测推翻,保留原文供对照)
|
2026-10-06 17:07:29 +08:00
|
|
|
|
|
|
|
|
|
|
| 项 | 免费版 | 付费版($5/月) |
|
|
|
|
|
|
|---|---|---|
|
|
|
|
|
|
| Worker CPU | **硬顶 10ms** | 默认 30s,可调至 5min |
|
|
|
|
|
|
| `[limits] cpu_ms` 配置 | ★ **直接拒绝部署**(code 100328) | 支持 |
|
2026-10-06 18:28:55 +08:00
|
|
|
|
| ACME 签发 | ⚠️ 擦边(见 §9.2 实测) | ✅ 稳 |
|
|
|
|
|
|
|
|
|
|
|
|
> ★★ **更正(2026-10-06 晚)**:这里原先写「❌ 跑不起来」是**按文档推断、未实测**得出的,
|
|
|
|
|
|
> 属于误判。实测密码学开销只有 0.69~3.3ms,是「擦边」而不是「必然爆」。
|
|
|
|
|
|
> 最终决策与实测数据见 **§9**。
|
2026-10-06 17:07:29 +08:00
|
|
|
|
|
|
|
|
|
|
实测报错:
|
|
|
|
|
|
```
|
|
|
|
|
|
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 18:28:55 +08:00
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
|
|
## 九、★ 架构定案:签发链路搬到国内机(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":[...]}`。
|
|
|
|
|
|
>
|
2026-10-06 22:54:58 +08:00
|
|
|
|
> ⚠️ **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` 一起决策)。
|
2026-10-06 18:28:55 +08:00
|
|
|
|
|
2026-10-06 20:08:28 +08:00
|
|
|
|
### 9.7 ★ 未决冲突:t-t.live 的「两套管理」 → 已定案:本项目接管
|
2026-10-06 18:28:55 +08:00
|
|
|
|
|
|
|
|
|
|
- 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 20:08:28 +08:00
|
|
|
|
> **2026-10-06 晚定案:选 ①** —— 停掉 certd,本项目接管 t-t.live 全部。
|
2026-10-06 18:28:55 +08:00
|
|
|
|
|
2026-10-06 20:08:28 +08:00
|
|
|
|
### 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`,升级后一张证书同时覆盖主域与全部子域) |
|
2026-10-06 22:54:58 +08:00
|
|
|
|
| `dnsapi.usj.cc` 上 HTTPS | **不做**(2026-10-06 重新定性):它其实是 `cn-dns-helper` 的反代入口(`proxy_pass http://127.0.0.1:8018`),而 `cn-dns-helper` 大概率已是遗留(见 `架构精简候选.md` 候选 2)→ **去留应与它一起决定**,不该单独给一个待退役的服务加 HTTPS |
|
2026-10-06 20:08:28 +08:00
|
|
|
|
| 停 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`)
|
|
|
|
|
|
|
2026-10-06 22:54:58 +08:00
|
|
|
|
#### 遗留(已解决):`writeapi.usj.cc` 的手工 vhost 不在本项目的部署面内
|
|
|
|
|
|
|
|
|
|
|
|
> ✅ **2026-10-06 当天解决** —— 解法与踩到的坑见下一节 §9.15。
|
|
|
|
|
|
> 下面保留**当时的诊断**,但请注意其中一处**判断是错的**(已在 §9.15 更正)。
|
2026-10-06 20:08:28 +08:00
|
|
|
|
|
|
|
|
|
|
全网核验时发现 `writeapi.usj.cc` 的 `ssl/fullchain.pem` 仍是 **旧证书**
|
|
|
|
|
|
(`CN=usj.cc` / RSA / LiteSSL RSA CA / 到期 2026-12-07,mtime 2026-10-04 21:50)。
|
|
|
|
|
|
|
2026-10-06 22:54:58 +08:00
|
|
|
|
原因:它是 **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
|
|
|
|
|
|
```
|
2026-10-06 20:08:28 +08:00
|
|
|
|
|