329 lines
14 KiB
TypeScript
329 lines
14 KiB
TypeScript
/**
|
||||
|
|
* 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('');
|
|||
|
|
}
|