/** * 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`。 */ import type { AccessRecord } from './certstore'; export interface DnsProvider { /** 域名类型标签,报错时带上 */ readonly kind: string; /** 写一条 TXT */ addTxt(fqdn: string, value: string): Promise; /** 删一条 TXT(值要匹配,避免删掉同名的其它记录) */ delTxt(fqdn: string, value: string): Promise; /** 列出某个名字下现有的 TXT 值(用于查重/清理残留) */ listTxt(fqdn: string): Promise; } // ==================================================================== 工具 /** * 把 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> { 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(action: string, payload: Record): Promise { 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 { 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 { 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 { 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 { 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//dns_records` 只要 * 该 zone 在授权范围内就能用)。 */ private readonly fixedZoneId?: string, private readonly fixedZoneName?: string, ) {} private async call(path: string, init: RequestInit = {}): Promise { 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 { 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 { 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 { 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 { return bufToHex(await crypto.subtle.digest('SHA-256', enc(s))); } async function hmacSha256(key: ArrayBuffer | Uint8Array, data: string): Promise { // ★ TS 5.7 起 Uint8Array 是泛型(Uint8Array), // 而 crypto.subtle.importKey 要 ArrayBufferView。 // 统一转成 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(''); }