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:
1 parent
f6bb69dec0
commit
64db8ef9c3
14 files changed
+3010
-6
No files matched your search
@@ -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('');
|
||||
}
|
||||
Reference in new issue
Block a user