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

+329
View File
@@ -0,0 +1,329 @@
/**
* DNS 提供商适配层 —— DNS-01 验证时增删 TXT 记录。
*
* 目前支持两家(覆盖三组域名的实际情况):
* · tencentcloud → DNSPod(usj.cc / t-t.live 的 DNS 都在腾讯云)
* · cloudflare → Cloudflare DNS(200181.xyz)
*
* ★ 两家的签名方式差异很大,这里各自封装成一个 `DnsProvider` 接口:
* addTxt(name, value) / delTxt(name, value) / listTxt(name)
*
* ★ 腾讯云走的是它统一网关的 TC3-HMAC-SHA256 签名(v3),不是 DNSPod 老版
* 的 secretId + 明文签名。参数全在 JSON body 里,签名串要覆盖
* `POST\n/\n\ncontent-type:...\nhost:...\n\ncontent-type;host\n<sha256(body)>`。
*/
import type { AccessRecord } from './certstore';
export interface DnsProvider {
/** 域名类型标签,报错时带上 */
readonly kind: string;
/** 写一条 TXT */
addTxt(fqdn: string, value: string): Promise<void>;
/** 删一条 TXT(值要匹配,避免删掉同名的其它记录) */
delTxt(fqdn: string, value: string): Promise<void>;
/** 列出某个名字下现有的 TXT 值(用于查重/清理残留) */
listTxt(fqdn: string): Promise<string[]>;
}
// ==================================================================== 工具
/**
* 把 FQDN 拆成「记录名 + 根域名」。
*
* ★ 为什么不能简单地按「最后两段」切:usj.cc 是两段,但 t-t.live 也是两段,
* 而如果以后接入 xxx.com.cn 这种,最后两段就是错的。这里用**已知根域名列表**
* 反查:从最长后缀开始匹配,命中即止。
*/
export function splitHost(fqdn: string, roots: string[]): { sub: string; root: string } {
const h = fqdn.replace(/\.$/, '').toLowerCase();
const sorted = [...roots].map((r) => r.toLowerCase()).sort((a, b) => b.length - a.length);
for (const r of sorted) {
if (h === r) return { sub: '@', root: r };
if (h.endsWith('.' + r)) return { sub: h.slice(0, -(r.length + 1)), root: r };
}
// 没匹配上就用最后两段兜底(多数情况也对)
const parts = h.split('.');
if (parts.length <= 2) return { sub: '@', root: h };
return { sub: parts.slice(0, -2).join('.'), root: parts.slice(-2).join('.') };
}
// ==================================================================== 腾讯云 DNSPod
const TC_HOST = 'dnspod.tencentcloudapi.com';
const TC_SERVICE = 'dnspod';
const TC_VERSION = '2021-03-23';
/** 腾讯云 TC3-HMAC-SHA256 签名(https://cloud.tencent.com/document/api/1427/56174) */
async function tc3Sign(
secretId: string,
secretKey: string,
payload: string,
action: string,
): Promise<Record<string, string>> {
const timestamp = Math.floor(Date.now() / 1000);
const date = new Date(timestamp * 1000).toISOString().slice(0, 10);
// ① 规范请求串
const canonicalHeaders = `content-type:application/json; charset=utf-8\nhost:${TC_HOST}\n`;
const signedHeaders = 'content-type;host';
const hashedPayload = await sha256Hex(payload);
const canonicalRequest = ['POST', '/', '', canonicalHeaders, signedHeaders, hashedPayload].join('\n');
// ② 待签字符串
const credentialScope = `${date}/${TC_SERVICE}/tc3_request`;
const hashedCanonical = await sha256Hex(canonicalRequest);
const stringToSign = ['TC3-HMAC-SHA256', String(timestamp), credentialScope, hashedCanonical].join('\n');
// ③ 逐层派生签名密钥
const kDate = await hmacSha256(enc(`TC3${secretKey}`), date);
const kService = await hmacSha256(kDate, TC_SERVICE);
const kSigning = await hmacSha256(kService, 'tc3_request');
const signature = bufToHex(await hmacSha256(kSigning, stringToSign));
const authorization =
`TC3-HMAC-SHA256 Credential=${secretId}/${credentialScope}, ` +
`SignedHeaders=${signedHeaders}, Signature=${signature}`;
return {
Authorization: authorization,
'Content-Type': 'application/json; charset=utf-8',
Host: TC_HOST,
'X-TC-Action': action,
'X-TC-Version': TC_VERSION,
'X-TC-Timestamp': String(timestamp),
};
}
export class TencentDns implements DnsProvider {
readonly kind = 'tencentcloud';
private roots: string[] = [];
constructor(
private readonly secretId: string,
private readonly secretKey: string,
) {}
private async call<T>(action: string, payload: Record<string, unknown>): Promise<T> {
const body = JSON.stringify(payload);
const headers = await tc3Sign(this.secretId, this.secretKey, body, action);
const r = await fetch(`https://${TC_HOST}/`, { method: 'POST', headers, body });
const text = await r.text();
let d: { Response?: { Error?: { Code?: string; Message?: string }; [k: string]: unknown } };
try {
d = JSON.parse(text);
} catch {
throw new Error(`DNSPod 返回非 JSON(HTTP ${r.status}):${text.slice(0, 200)}`);
}
const err = d.Response?.Error;
if (err) throw new Error(`DNSPod ${action} 失败:${err.Code || ''} ${err.Message || ''}`);
return d.Response as T;
}
/** 根域名列表(缓存在实例上)—— 需要它才能把 fqdn 拆成 sub + root */
private async loadRoots(): Promise<string[]> {
if (this.roots.length) return this.roots;
const res = await this.call<{ DomainList?: { Name: string }[] }>('DescribeDomainList', {});
this.roots = (res.DomainList || []).map((d) => d.Name);
return this.roots;
}
async addTxt(fqdn: string, value: string): Promise<void> {
const roots = await this.loadRoots();
const { sub, root } = splitHost(fqdn, roots);
await this.call('CreateRecord', {
Domain: root,
SubDomain: sub === '@' ? '@' : sub,
RecordType: 'TXT',
RecordLine: '默认',
Value: value,
TTL: 600,
});
}
async delTxt(fqdn: string, value: string): Promise<void> {
const roots = await this.loadRoots();
const { sub, root } = splitHost(fqdn, roots);
// 先查同名同值的记录 id,再逐条删 —— 直接删整个名字会误伤别的平台写的
const res = await this.call<{ RecordList?: { RecordId: number; Value: string }[] }>('DescribeRecordList', {
Domain: root,
Subdomain: sub === '@' ? '@' : sub,
RecordType: 'TXT',
});
for (const rec of res.RecordList || []) {
// DNSPod 会把值里的 `"` 转义保留,比较前统一去引号
if (rec.Value.replace(/^"|"$/g, '') === value) {
await this.call('DeleteRecord', { Domain: root, RecordId: rec.RecordId });
}
}
}
async listTxt(fqdn: string): Promise<string[]> {
const roots = await this.loadRoots();
const { sub, root } = splitHost(fqdn, roots);
const res = await this.call<{ RecordList?: { Value: string }[] }>('DescribeRecordList', {
Domain: root,
Subdomain: sub === '@' ? '@' : sub,
RecordType: 'TXT',
});
return (res.RecordList || []).map((r) => r.Value.replace(/^"|"$/g, ''));
}
}
// ==================================================================== Cloudflare
export class CloudflareDns implements DnsProvider {
readonly kind = 'cloudflare';
constructor(
private readonly apiToken: string,
/**
* 可选的固定 Zone ID。
*
* ★ 为什么需要这个(2026-10-06 实测踩到):Cloudflare 的 API Token 可以
* 被限制成**「只能操作某几个 zone」**,这种 token 调 `GET /zones`
* (不带 name)会返回 **200 + 空数组**,而不是报错 —— 看权限「没问题」,
* 但 `zoneIdFor()` 永远查不到 zone,DNS-01 就会一直失败。
* 配上固定 Zone ID 就能绕开列举这一步(`/zones/<id>/dns_records` 只要
* 该 zone 在授权范围内就能用)。
*/
private readonly fixedZoneId?: string,
private readonly fixedZoneName?: string,
) {}
private async call<T>(path: string, init: RequestInit = {}): Promise<T> {
const r = await fetch(`https://api.cloudflare.com/client/v4${path}`, {
...init,
headers: {
Authorization: `Bearer ${this.apiToken}`,
'Content-Type': 'application/json',
...(init.headers || {}),
},
});
const text = await r.text();
let d: { success?: boolean; errors?: { code?: number; message?: string }[]; result?: unknown };
try {
d = JSON.parse(text);
} catch {
throw new Error(`Cloudflare 返回非 JSON(HTTP ${r.status}):${text.slice(0, 200)}`);
}
if (!d.success) {
const errs = d.errors || [];
const msg = errs.map((e) => `${e.code ?? ''} ${e.message ?? ''}`.trim()).join('; ');
// ★ 403 在 DNS 这条路上几乎只有两个原因,直接说清楚省得来回猜
if (r.status === 403) {
throw new Error(
`Cloudflare 拒绝访问(403):${msg || '权限不足'} —— ` +
`该 Token 缺少「Zone → DNS → Edit」权限,或未把域名加入 Token 的 Zone Resources`,
);
}
throw new Error(`Cloudflare 调用失败(HTTP ${r.status}):${msg}`);
}
return d.result as T;
}
/** 从 fqdn 里找出属于本 token 的 zone id */
private async zoneIdFor(fqdn: string): Promise<{ zoneId: string; root: string }> {
// ① 配置里写死了 zone id → 直接用,连列举都省了
if (this.fixedZoneId) {
return { zoneId: this.fixedZoneId, root: this.fixedZoneName || fqdn };
}
const parts = fqdn.replace(/\.$/, '').toLowerCase().split('.');
// ② 从最长后缀往短试:先试 a.b.c.com,再 b.c.com,再 c.com
for (let i = 0; i < parts.length - 1; i++) {
const guess = parts.slice(i).join('.');
const zones = await this.call<{ id: string; name: string }[]>(`/zones?name=${encodeURIComponent(guess)}`);
if (zones && zones.length) return { zoneId: zones[0].id, root: zones[0].name };
}
// ③ 走到这里通常是「token 被限制到具体 zone」或「缺 Zone:Read」——
// 两者都表现为「列举返回空」,但处置完全不同,所以把话写全。
throw new Error(
`Cloudflare 里找不到 ${fqdn} 所属的 Zone。` +
`常见原因:① Token 缺少「Zone → Zone → Read」权限(列举会是空数组而不报错);` +
`② Token 的 Zone Resources 没包含这个域名;` +
`③ 该域名的 DNS 并不托管在这个 Cloudflare 账号下。` +
`若不确定,可在凭据里直接填 zoneId 跳过列举。`,
);
}
async addTxt(fqdn: string, value: string): Promise<void> {
const { zoneId } = await this.zoneIdFor(fqdn);
await this.call(`/zones/${zoneId}/dns_records`, {
method: 'POST',
body: JSON.stringify({ type: 'TXT', name: fqdn, content: value, ttl: 120 }),
});
}
async delTxt(fqdn: string, value: string): Promise<void> {
const { zoneId } = await this.zoneIdFor(fqdn);
const recs = await this.call<{ id: string; content: string }[]>(
`/zones/${zoneId}/dns_records?type=TXT&name=${encodeURIComponent(fqdn)}`,
);
for (const rec of recs || []) {
// CF 会自己加引号,比较时剥掉
if (rec.content.replace(/^"|"$/g, '') === value) {
await this.call(`/zones/${zoneId}/dns_records/${rec.id}`, { method: 'DELETE' });
}
}
}
async listTxt(fqdn: string): Promise<string[]> {
const { zoneId } = await this.zoneIdFor(fqdn);
const recs = await this.call<{ content: string }[]>(
`/zones/${zoneId}/dns_records?type=TXT&name=${encodeURIComponent(fqdn)}`,
);
return (recs || []).map((r) => r.content.replace(/^"|"$/g, ''));
}
}
// ==================================================================== 工厂
/**
* 按凭据记录造一个 DNS 客户端。
* ★ 只认识 tencentcloud / cloudflare 两类;其余类型抛错而不是静默返回 null ——
* 静默返回会让「配置错了」表现为「验证一直 pending 到超时」,排查起来很痛苦。
*/
export function makeDnsProvider(rec: AccessRecord): DnsProvider {
const t = String(rec.type || '');
if (t === 'tencentcloud') {
const id = String(rec.secretId || '');
const key = String(rec.secretKey || '');
if (!id || !key) throw new Error('腾讯云凭据缺少 secretId / secretKey');
return new TencentDns(id, key);
}
if (t === 'cloudflare') {
const token = String(rec.apiToken || '');
if (!token) throw new Error('Cloudflare 凭据缺少 apiToken');
// zoneId / zoneName 可选;填了就不去列举 zone(见 CloudflareDns 构造器注释)
return new CloudflareDns(
token,
rec.zoneId ? String(rec.zoneId) : undefined,
rec.zoneName ? String(rec.zoneName) : undefined,
);
}
throw new Error(`DNS-01 不支持凭据类型「${t}」(目前只支持 tencentcloud / cloudflare)`);
}
// ==================================================================== crypto 小工具
const enc = (s: string) => new TextEncoder().encode(s);
async function sha256Hex(s: string): Promise<string> {
return bufToHex(await crypto.subtle.digest('SHA-256', enc(s)));
}
async function hmacSha256(key: ArrayBuffer | Uint8Array, data: string): Promise<ArrayBuffer> {
// ★ TS 5.7 起 Uint8Array 是泛型(Uint8Array<ArrayBufferLike>),
// 而 crypto.subtle.importKey 要 ArrayBufferView<ArrayBuffer>。
// 统一转成 Uint8Array 再取 .buffer(保证是 ArrayBuffer,不是 SharedArrayBuffer)。
const bytes = key instanceof Uint8Array ? key : new Uint8Array(key);
const raw = new Uint8Array(bytes).buffer as ArrayBuffer;
const k = await crypto.subtle.importKey('raw', raw, { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']);
return crypto.subtle.sign('HMAC', k, enc(data));
}
function bufToHex(buf: ArrayBuffer): string {
return [...new Uint8Array(buf)].map((b) => b.toString(16).padStart(2, '0')).join('');
}