Files
blog/blog-admin/src/lib/dnsprovider.ts
T
zqlit 64db8ef9c3 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 干净
2026-10-06 16:59:37 +08:00

330 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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('');
}