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 f6bb69dec0
commit 64db8ef9c3
14 files changed
+3010 -6

No files matched your search

+15
View File
@@ -12,6 +12,7 @@ import * as A from './routes/admin';
import * as H from './routes/human';
import * as E from './routes/editor';
import * as S from './routes/ssl';
import { renewAll } from './lib/certissue';
const router = new Router();
@@ -232,11 +233,25 @@ export default {
// 定时任务:0 * * * * = RSS 轮转抓取(每小时一批,约 3 小时覆盖全部源)
// 17 3 * * * = 评论 GC(限流/验证码/healthz 缓存)
// 10 4 * * * = 证书续期检查(探针判定,≤30 天才签)
async scheduled(event: ScheduledController, env: Env): Promise<void> {
if (event.cron === '0 * * * *') {
await RssApi.scheduled(event, env, undefined as unknown as ExecutionContext);
return;
}
if (event.cron === '10 4 * * *') {
// 证书续期:每天跑一次,但**只有探针查到剩余 ≤30 天才真正签发**。
// 不无条件重签的原因见 lib/certissue.ts 头注释(会白烧 CA 限速额度)。
try {
const outcomes = await renewAll(env, { by: '自动续期' });
for (const o of outcomes) {
console.log(`[certkeeper] ${o.domain}: ${o.ok ? '✓' : '✗'} ${o.reason}`);
}
} catch (e) {
console.error('[certkeeper] scheduled renew failed:', e instanceof Error ? e.message : e);
}
return;
}
try {
await gcRateLimits(env);
await gcCaptcha(env);