feat(ssl): ACME 自动签发与自动续期,实现证书全生命周期闭环

证书管家此前只做「探针」(查剩余天数),现补齐签发+部署两个环节,
参照 certimate(MIT)的 DNS-01 流程自行实现,不再依赖闭源 certd。

新增(纯 WebCrypto,零 npm 依赖):
- lib/acme.ts        ACME v2 客户端:ES256 JWS(原始 r||s)、RFC7638
                     thumbprint、EAB、badNonce 重试、DNS-01、手写 DER CSR
- lib/dnsprovider.ts DNS-01 适配:DNSPod(TC3-HMAC-SHA256)、Cloudflare
- lib/deployer.ts    部署适配:多吉云 CDN、1Panel 站点(幂等换证书)
- lib/certissue.ts   编排:探针判剩余天数 → 注册/复用账户 → 签发 → 落库
                     → 逐目标部署;RENEW_BEFORE_DAYS=30
- routes/ssl.ts      新增 POST /ssl/issue、GET /ssl/renew-check、
                     POST /ssl/selfcheck(环境自检,只读不签发)
- index.ts + cron    每日 04:10 自动续期检查;cpu_ms 提到 60s

与 certimate 的差异:certimate 每个 workflow 每天无条件重跑,
这里改为先探针查剩余天数、低于阈值才签,省 CA 限速额度。

实测修正(易误判,勿回退):
- 多吉云 bind 参数是 {id, domain},非 {cert_id}(用假 id 对照实验确认:
  cert_id 回「域名不存在」= 参数被无视)
- 多吉云上传私钥字段是 private;列域名用 /cdn/domain/list.json
- 1Panel 必须用 /api/v2/(v1 返回 HTTP 200 但正文是 HTML 停用页)
- 1Panel HTTPS 配置字段是 SSL(大写),写错会导致每次续期都重绑
- LiteSSL ACME 目录须带 /v2:acme.trustasia.com/acme/v2/directory

测试:selftest-acme 16/16(CSR 过 openssl 验签、JWS 过 Node crypto 验签)、
selftest-deploy 18/18、selftest:ssl 117/117、UI 全过、tsc 干净
This commit is contained in:
zqlit committed 2026-10-06 16:59:37 +08:00
1 parent 432cf5e398
commit 43dbc8b75f
14 files changed
+3010 -6

No files matched your search

+40 -2
View File
@@ -56,6 +56,27 @@ const MOCK = {
notAfter: Date.now() + 62 * DAY, notBefore: Date.now() - 28 * DAY,
daysLeft: 62, notAfterText: '2026-12-07 06:59:59',
},
// 续期判定(只读)
'/api/v2/ssl/renew-check': {
threshold: 30,
items: [
{ domain: 'usj.cc', host: 'usj.cc', source: 'live', daysLeft: 62, action: 'ok', threshold: 30, issuer: 'TrustAsia Technologies, Inc.', error: null },
{ domain: 't-t.live', host: 't-t.live', source: 'live', daysLeft: 83, action: 'ok', threshold: 30, issuer: "Let's Encrypt", error: null },
{ domain: '200181.xyz', host: '200181.xyz', source: 'live', daysLeft: 19, action: 'renew', threshold: 30, issuer: 'LiteSSL', error: null },
],
},
// 环境自检(只读)
'/api/v2/ssl/selfcheck': {
threshold: 30,
summary: { total: 5, failed: 0 },
items: [
{ name: 'CA litessl', kind: 'ca', ok: true, detail: '目录可达,EAB 已配对' },
{ name: 'DNS tencent-usj', kind: 'dns', ok: true, detail: 'tencentcloud 凭据可用,能读取 usj.cc 的 TXT(现存 0 条)' },
{ name: 'DNS cloudflare', kind: 'dns', ok: true, detail: 'cloudflare 凭据可用,能读取 200181.xyz 的 TXT(现存 0 条)' },
{ name: '部署 dogecloud', kind: 'deploy', ok: true, detail: '多吉云凭据可用' },
{ name: '部署 1panel', kind: 'deploy', ok: true, detail: '1Panel 凭据可用,站点 usj.cc 已找到' },
],
},
'/api/v2/ssl/access': {
items: [
{ name: 'tencent-usj', type: 'tencentcloud', note: '小赵腾讯云', fields: { secretId: 'AKID********3f2a', secretKey: 'Qk9Y****gAAA' } },
@@ -229,6 +250,23 @@ try {
t('「实测」按钮存在', view.includes('ssl-probe'));
t('「登记」按钮存在', view.includes('ssl-import'));
t('「+ 域名」按钮存在', view.includes('ssl-add-domain'));
// 本轮新增的三个动作
t('★ 「环境自检」按钮存在', view.includes('ssl-selfcheck'));
t('★ 「该续期了吗」按钮存在', view.includes('ssl-renew-check'));
t('★ 每行有「签发」按钮', view.includes('data-act="ssl-issue"'));
// ---------- 环境自检弹窗(本轮新增)
{
const before = String(await ev(`document.querySelector('#modal')?.innerHTML || ''`));
await ev(`(() => { const b = document.querySelector('button[data-act="ssl-selfcheck"]'); if (b) b.click(); })()`);
await sleep(1500);
const m = String(await ev(`document.querySelector('#modal')?.innerHTML || ''`));
t('★ 自检弹窗打开', m !== before && m.includes('环境自检'), m.slice(0, 80));
t('★ 自检列出各项', m.includes('CA') && m.includes('DNS') && m.includes('部署'));
t('★ 自检说明「不会签发证书」', m.includes('不会签发证书'));
await ev(`(() => { const b = document.querySelector('#modal button[data-act="modal-close"]'); if (b) b.click(); })()`);
await sleep(400);
}
// ---------- 凭据区
await sleep(600);
@@ -302,8 +340,8 @@ try {
await sleep(3000);
await ev(`(() => { const b = [...document.querySelectorAll('#tabs .tab')].find(x => x.textContent.includes('证书管家')); if (b) b.click(); })()`);
await sleep(2000);
// 点一次「实测」——本轮改的就是探测链路,把结果弹窗截进画面才看得见。
await ev(`(() => { const b = document.querySelector('button[data-act="ssl-probe"]'); if (b) b.click(); })()`);
// 点一次「环境自检」——本轮新增的核心能力,把结果弹窗截进画面。
await ev(`(() => { const b = document.querySelector('button[data-act="ssl-selfcheck"]'); if (b) b.click(); })()`);
await sleep(2000);
await ev(`document.documentElement.setAttribute('data-theme','light')`);
await sleep(500);