dns-providers.js 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575
  1. import crypto from 'node:crypto';
  2. const CLOUDFLARE_API = 'https://api.cloudflare.com/client/v4';
  3. const ALIYUN_ENDPOINT = 'https://alidns.aliyuncs.com/';
  4. const TENCENT_ENDPOINT = 'https://dnspod.tencentcloudapi.com';
  5. export async function testDnsCredential(credential) {
  6. try {
  7. const provider = createProvider(credential);
  8. const result = await provider.test();
  9. return { ok: true, provider: credential.provider, detail: result };
  10. } catch (error) {
  11. return { ok: false, provider: credential.provider, error: error.message };
  12. }
  13. }
  14. export async function applyDnsSetup(domain, credential, guide) {
  15. const provider = createProvider(credential);
  16. const records = (guide.records || []).filter((record) => ['verification', 'dkim', 'spf', 'dmarc'].includes(record.key));
  17. const results = [];
  18. for (const record of records) {
  19. const zoneName = effectiveZoneName(credential, domain, record);
  20. if (zoneName && !isHostInZone(record.host, zoneName)) {
  21. results.push(outOfZoneResult(record, zoneName));
  22. continue;
  23. }
  24. try {
  25. const result = await provider.upsert(record, domain);
  26. results.push({ key: record.key, host: record.host, type: record.type, ok: true, detail: result });
  27. } catch (error) {
  28. results.push({ key: record.key, host: record.host, type: record.type, ok: false, error: error.message });
  29. }
  30. }
  31. return {
  32. ok: results.every((result) => result.ok),
  33. provider: credential.provider,
  34. appliedAt: new Date().toISOString(),
  35. results
  36. };
  37. }
  38. function createProvider(credential) {
  39. if (credential.provider === 'cloudflare') return new CloudflareProvider(credential);
  40. if (credential.provider === 'aliyun') return new AliyunProvider(credential);
  41. if (credential.provider === 'dnspod') return new DnspodProvider(credential);
  42. throw new Error('DNS 服务商不支持。');
  43. }
  44. class CloudflareProvider {
  45. constructor(credential) {
  46. this.credential = credential;
  47. this.credentials = credential.credentials || {};
  48. this.zoneName = credential.zoneName;
  49. this.ttl = credential.defaultTtl || 600;
  50. this.zoneIdCache = new Map();
  51. }
  52. async test() {
  53. const zoneId = await this.zoneId();
  54. const zone = await this.request(`/zones/${zoneId}`);
  55. return zone.result?.name || this.zoneName || zoneId;
  56. }
  57. async upsert(record, domain) {
  58. const zoneId = await this.zoneId(record, domain);
  59. const existing = await this.listRecords(zoneId, record);
  60. const match = pickExisting(record, existing);
  61. const payload = {
  62. type: record.type,
  63. name: record.host,
  64. content: record.value,
  65. ttl: this.ttl,
  66. proxied: false
  67. };
  68. if (match) {
  69. await this.request(`/zones/${zoneId}/dns_records/${match.id}`, {
  70. method: 'PUT',
  71. body: payload
  72. });
  73. await this.deleteExtras(zoneId, existing, match, record);
  74. return 'updated';
  75. }
  76. try {
  77. await this.request(`/zones/${zoneId}/dns_records`, { method: 'POST', body: payload });
  78. } catch (error) {
  79. if (/identical record already exists/i.test(error.message)) return 'unchanged';
  80. throw error;
  81. }
  82. await this.deleteExtras(zoneId, existing, null, record);
  83. return 'created';
  84. }
  85. async deleteExtras(zoneId, records, kept, desired) {
  86. if (!['spf', 'dmarc'].includes(desired.key)) return;
  87. const extras = records.filter((record) => record.id !== kept?.id && recordMatchesKind(desired, record.content));
  88. for (const record of extras) await this.request(`/zones/${zoneId}/dns_records/${record.id}`, { method: 'DELETE' });
  89. }
  90. async listRecords(zoneId, record) {
  91. const records = [];
  92. let page = 1;
  93. let totalPages = 1;
  94. do {
  95. const params = new URLSearchParams({
  96. type: record.type,
  97. 'name.exact': record.host,
  98. match: 'all',
  99. page: String(page),
  100. per_page: '100'
  101. });
  102. const response = await this.request(`/zones/${zoneId}/dns_records?${params}`);
  103. records.push(
  104. ...(response.result || []).filter((item) => item.type === record.type && sameDnsName(item.name, record.host))
  105. );
  106. totalPages = Number(response.result_info?.total_pages || page);
  107. page += 1;
  108. } while (page <= totalPages);
  109. return records;
  110. }
  111. async zoneId(record, domain) {
  112. const targetZoneName = await this.resolveZoneName(record, domain);
  113. if (this.credentials.zoneId && (!targetZoneName || sameZone(targetZoneName, this.zoneName))) {
  114. return this.credentials.zoneId;
  115. }
  116. if (!targetZoneName) throw new Error('Cloudflare 需要 zoneName、zoneId 或发信域名。');
  117. return this.lookupZoneId(targetZoneName);
  118. }
  119. async resolveZoneName(record, domain) {
  120. const host = record?.host || '';
  121. const domainName = domain?.domain || '';
  122. const candidates = uniqueZoneCandidates([
  123. this.zoneName,
  124. ...zoneCandidates(domainName || host)
  125. ]).filter((candidate) => !host || isHostInZone(host, candidate));
  126. for (const candidate of candidates) {
  127. const zoneId = await this.lookupZoneId(candidate, { optional: true });
  128. if (zoneId) return candidate;
  129. }
  130. return this.zoneName || domainName;
  131. }
  132. async lookupZoneId(zoneName, { optional = false } = {}) {
  133. const cleanZone = normalizeZoneName(zoneName);
  134. if (!cleanZone) return '';
  135. if (this.zoneIdCache.has(cleanZone)) return this.zoneIdCache.get(cleanZone);
  136. const response = await this.request(`/zones?name=${encodeURIComponent(cleanZone)}`);
  137. const zone = response.result?.[0];
  138. if (!zone?.id) {
  139. if (optional) {
  140. this.zoneIdCache.set(cleanZone, '');
  141. return '';
  142. }
  143. throw new Error(`Cloudflare 未找到 Zone ${cleanZone}。`);
  144. }
  145. this.zoneIdCache.set(cleanZone, zone.id);
  146. return zone.id;
  147. }
  148. async request(path, options = {}) {
  149. if (!this.credentials.apiToken) throw new Error('Cloudflare API Token 不能为空。');
  150. const response = await fetch(`${CLOUDFLARE_API}${path}`, {
  151. method: options.method || 'GET',
  152. headers: {
  153. Authorization: `Bearer ${this.credentials.apiToken}`,
  154. 'Content-Type': 'application/json'
  155. },
  156. body: options.body ? JSON.stringify(options.body) : undefined
  157. });
  158. const data = await response.json().catch(() => ({}));
  159. if (!response.ok || data.success === false) {
  160. const message = data.errors?.map((error) => error.message).join('; ') || `Cloudflare HTTP ${response.status}`;
  161. throw new Error(message);
  162. }
  163. return data;
  164. }
  165. }
  166. class AliyunProvider {
  167. constructor(credential) {
  168. this.credential = credential;
  169. this.credentials = credential.credentials || {};
  170. this.zoneName = credential.zoneName;
  171. this.ttl = credential.defaultTtl || 600;
  172. }
  173. async test() {
  174. const response = await this.request('DescribeDomainRecords', { DomainName: this.zoneName, PageSize: 1 });
  175. return response.DomainRecords?.Record?.length >= 0 ? this.zoneName : 'ok';
  176. }
  177. async upsert(record, domain) {
  178. const zoneName = await this.resolveZoneName(record, domain);
  179. const rr = relativeName(record.host, zoneName);
  180. const existing = await this.listRecords(zoneName, record.type, rr);
  181. const match = pickExisting(record, existing);
  182. const params = {
  183. RR: rr,
  184. Type: record.type,
  185. Value: record.value,
  186. TTL: this.ttl
  187. };
  188. if (match) {
  189. await this.request('UpdateDomainRecord', { ...params, RecordId: match.id });
  190. await this.deleteExtras(zoneName, existing, match, record);
  191. return 'updated';
  192. }
  193. await this.request('AddDomainRecord', { DomainName: zoneName, ...params });
  194. await this.deleteExtras(zoneName, existing, null, record);
  195. return 'created';
  196. }
  197. async deleteExtras(zoneName, records, kept, desired) {
  198. if (!['spf', 'dmarc'].includes(desired.key)) return;
  199. const extras = records.filter((record) => record.id !== kept?.id && recordMatchesKind(desired, record.value));
  200. for (const record of extras) await this.request('DeleteDomainRecord', { RecordId: record.id });
  201. }
  202. async listRecords(zoneName, type, rr) {
  203. const response = await this.request('DescribeDomainRecords', {
  204. DomainName: zoneName,
  205. RRKeyWord: rr === '@' ? '' : rr,
  206. TypeKeyWord: type,
  207. PageSize: 100
  208. });
  209. return (response.DomainRecords?.Record || [])
  210. .filter((record) => record.RR === rr && record.Type === type)
  211. .map((record) => ({
  212. id: String(record.RecordId),
  213. type: record.Type,
  214. name: record.RR,
  215. value: record.Value
  216. }));
  217. }
  218. async resolveZoneName(record, domain) {
  219. const host = record?.host || '';
  220. const candidates = uniqueZoneCandidates([
  221. this.zoneName,
  222. ...zoneCandidates(domain?.domain || host)
  223. ]).filter((candidate) => isHostInZone(host, candidate));
  224. for (const candidate of candidates) {
  225. const rr = relativeName(host, candidate);
  226. try {
  227. await this.listRecords(candidate, record.type, rr);
  228. return candidate;
  229. } catch (error) {
  230. if (!isAliyunZoneMissingError(error)) throw error;
  231. }
  232. }
  233. return this.zoneName;
  234. }
  235. async request(action, params) {
  236. if (!this.zoneName) throw new Error('阿里云 DNS 需要 zoneName。');
  237. if (!this.credentials.accessKeyId || !this.credentials.accessKeySecret) {
  238. throw new Error('阿里云 AccessKeyId 和 AccessKeySecret 不能为空。');
  239. }
  240. const common = {
  241. Action: action,
  242. Version: '2015-01-09',
  243. Format: 'JSON',
  244. AccessKeyId: this.credentials.accessKeyId,
  245. SignatureMethod: 'HMAC-SHA1',
  246. Timestamp: new Date().toISOString(),
  247. SignatureVersion: '1.0',
  248. SignatureNonce: crypto.randomUUID()
  249. };
  250. const signed = signAliyun({ ...common, ...params }, this.credentials.accessKeySecret);
  251. const response = await fetch(`${ALIYUN_ENDPOINT}?${signed}`);
  252. const data = await response.json().catch(() => ({}));
  253. if (!response.ok || data.Code) {
  254. const error = new Error(data.Message || data.Code || `Aliyun HTTP ${response.status}`);
  255. error.code = data.Code || '';
  256. throw error;
  257. }
  258. return data;
  259. }
  260. }
  261. class DnspodProvider {
  262. constructor(credential) {
  263. this.credential = credential;
  264. this.credentials = credential.credentials || {};
  265. this.zoneName = credential.zoneName;
  266. this.ttl = credential.defaultTtl || 600;
  267. }
  268. async test() {
  269. await this.request('DescribeRecordList', { Domain: this.zoneName, Limit: 1 });
  270. return this.zoneName;
  271. }
  272. async upsert(record, domain) {
  273. const zoneName = await this.resolveZoneName(record, domain);
  274. const subDomain = relativeName(record.host, zoneName);
  275. const existing = await this.listRecords(zoneName, record.type, subDomain);
  276. const match = pickExisting(record, existing);
  277. const params = {
  278. Domain: zoneName,
  279. SubDomain: subDomain,
  280. RecordType: record.type,
  281. RecordLine: '默认',
  282. Value: record.value,
  283. TTL: this.ttl
  284. };
  285. if (match) {
  286. await this.request('ModifyRecord', { ...params, RecordId: Number(match.id) });
  287. await this.deleteExtras(zoneName, existing, match, record);
  288. return 'updated';
  289. }
  290. await this.request('CreateRecord', params);
  291. await this.deleteExtras(zoneName, existing, null, record);
  292. return 'created';
  293. }
  294. async deleteExtras(zoneName, records, kept, desired) {
  295. if (!['spf', 'dmarc'].includes(desired.key)) return;
  296. const extras = records.filter((record) => record.id !== kept?.id && recordMatchesKind(desired, record.value));
  297. for (const record of extras) {
  298. await this.request('DeleteRecord', { Domain: zoneName, RecordId: Number(record.id) });
  299. }
  300. }
  301. async listRecords(zoneName, type, subDomain) {
  302. const response = await this.request('DescribeRecordList', {
  303. Domain: zoneName,
  304. Subdomain: subDomain,
  305. RecordType: type,
  306. Limit: 100
  307. });
  308. return (response.RecordList || [])
  309. .filter((record) => record.Name === subDomain && record.Type === type)
  310. .map((record) => ({
  311. id: String(record.RecordId),
  312. type: record.Type,
  313. name: record.Name,
  314. value: record.Value
  315. }));
  316. }
  317. async resolveZoneName(record, domain) {
  318. const host = record?.host || '';
  319. const candidates = uniqueZoneCandidates([
  320. this.zoneName,
  321. ...zoneCandidates(domain?.domain || host)
  322. ]).filter((candidate) => isHostInZone(host, candidate));
  323. for (const candidate of candidates) {
  324. const subDomain = relativeName(host, candidate);
  325. try {
  326. await this.listRecords(candidate, record.type, subDomain);
  327. return candidate;
  328. } catch (error) {
  329. if (!isDnsPodZoneMissingError(error)) throw error;
  330. }
  331. }
  332. return this.zoneName;
  333. }
  334. async request(action, payload) {
  335. if (!this.zoneName) throw new Error('腾讯云 DNSPod 需要 zoneName。');
  336. if (!this.credentials.secretId || !this.credentials.secretKey) throw new Error('腾讯云 SecretId 和 SecretKey 不能为空。');
  337. const timestamp = Math.floor(Date.now() / 1000);
  338. const body = JSON.stringify(payload);
  339. const headers = signTencent({
  340. action,
  341. body,
  342. secretId: this.credentials.secretId,
  343. secretKey: this.credentials.secretKey,
  344. timestamp
  345. });
  346. const response = await fetch(TENCENT_ENDPOINT, {
  347. method: 'POST',
  348. headers,
  349. body
  350. });
  351. const data = await response.json().catch(() => ({}));
  352. if (!response.ok || data.Response?.Error) {
  353. const error = new Error(data.Response?.Error?.Message || `Tencent Cloud HTTP ${response.status}`);
  354. error.code = data.Response?.Error?.Code || '';
  355. if (action === 'DescribeRecordList' && isDnsPodEmptyRecordListError(error)) {
  356. return { RecordList: [] };
  357. }
  358. throw error;
  359. }
  360. return data.Response;
  361. }
  362. }
  363. function pickExisting(desired, existing) {
  364. if (desired.key === 'spf' || desired.key === 'dmarc') {
  365. return existing.find((record) => recordMatchesKind(desired, record.content || record.value));
  366. }
  367. return existing.find((record) => normalizeValue(record.content || record.value) === normalizeValue(desired.value)) || existing[0] || null;
  368. }
  369. function recordMatchesKind(desired, value) {
  370. const normalized = normalizeValue(value);
  371. if (desired.key === 'spf') return /^v=spf1(?:\s|$)/i.test(normalized);
  372. if (desired.key === 'dmarc') return /^v=DMARC1(?:;|\s|$)/i.test(normalized);
  373. return normalizeValue(value) === normalizeValue(desired.value);
  374. }
  375. function normalizeValue(value) {
  376. return unquoteTxtValue(String(value || '').replace(/\s+/g, ' ').trim());
  377. }
  378. function unquoteTxtValue(value) {
  379. if (value.length < 2 || !value.startsWith('"') || !value.endsWith('"')) return value;
  380. return value.slice(1, -1).replace(/\\"/g, '"');
  381. }
  382. function sameDnsName(left, right) {
  383. return normalizeZoneName(left) === normalizeZoneName(right);
  384. }
  385. function relativeName(host, zoneName) {
  386. const cleanHost = String(host || '').replace(/\.$/, '').toLowerCase();
  387. const cleanZone = String(zoneName || '').replace(/\.$/, '').toLowerCase();
  388. if (!cleanZone) throw new Error('DNS 凭据缺少 zoneName。');
  389. if (cleanHost === cleanZone) return '@';
  390. if (cleanHost.endsWith(`.${cleanZone}`)) return cleanHost.slice(0, -cleanZone.length - 1) || '@';
  391. throw new Error(`记录 ${host} 不在 DNS Zone ${zoneName} 下。`);
  392. }
  393. function isHostInZone(host, zoneName) {
  394. const cleanHost = String(host || '').replace(/\.$/, '').toLowerCase();
  395. const cleanZone = String(zoneName || '').replace(/\.$/, '').toLowerCase();
  396. return Boolean(cleanHost && cleanZone && (cleanHost === cleanZone || cleanHost.endsWith(`.${cleanZone}`)));
  397. }
  398. function effectiveZoneName(credential, domain, record) {
  399. if (domain?.domain) return domain.domain;
  400. if (credential.provider !== 'cloudflare') return credential.zoneName || '';
  401. const configuredZone = credential.zoneName || '';
  402. if (configuredZone && isHostInZone(record.host, configuredZone)) return configuredZone;
  403. return domain?.domain || configuredZone;
  404. }
  405. function sameZone(left, right) {
  406. return normalizeZoneName(left) === normalizeZoneName(right);
  407. }
  408. function normalizeZoneName(value) {
  409. return String(value || '').replace(/\.$/, '').toLowerCase();
  410. }
  411. function zoneCandidates(name) {
  412. const clean = normalizeZoneName(name);
  413. const parts = clean.split('.').filter(Boolean);
  414. const candidates = [];
  415. for (let index = 0; index <= parts.length - 2; index += 1) {
  416. candidates.push(parts.slice(index).join('.'));
  417. }
  418. return candidates;
  419. }
  420. function uniqueZoneCandidates(candidates) {
  421. const seen = new Set();
  422. const output = [];
  423. for (const candidate of candidates) {
  424. const clean = normalizeZoneName(candidate);
  425. if (!clean || seen.has(clean)) continue;
  426. seen.add(clean);
  427. output.push(clean);
  428. }
  429. return output;
  430. }
  431. function isDnsPodZoneMissingError(error) {
  432. const code = String(error?.code || '');
  433. const message = String(error?.message || '');
  434. return /NoDataOfRecord|ResourceNotFound|DomainNotExists|InvalidParameter\.Domain/i.test(code)
  435. || /domain not found|domain does not exist|域名.*(不存在|没有)|没有.*域名/i.test(message);
  436. }
  437. function isDnsPodEmptyRecordListError(error) {
  438. const code = String(error?.code || '');
  439. const message = String(error?.message || '');
  440. return /RecordListEmpty/i.test(code)
  441. || /记录列表为空|record list.*empty|empty record list/i.test(message);
  442. }
  443. function isAliyunZoneMissingError(error) {
  444. const code = String(error?.code || '');
  445. const message = String(error?.message || '');
  446. return /InvalidDomainName|DomainRecordNotBelongToUser|DomainNotExists|DomainNameNotFound/i.test(code)
  447. || /domain not found|domain does not exist|域名.*(不存在|没有)|没有.*域名/i.test(message);
  448. }
  449. function outOfZoneResult(record, zoneName) {
  450. const base = {
  451. key: record.key,
  452. host: record.host,
  453. type: record.type
  454. };
  455. if (record.key === 'sender-a' && record.status === 'ok') {
  456. return {
  457. ...base,
  458. ok: true,
  459. skipped: true,
  460. detail: `发信主机不在 ${zoneName} Zone 下,已跳过;当前 A 记录已正确解析。`
  461. };
  462. }
  463. return {
  464. ...base,
  465. ok: false,
  466. skipped: true,
  467. error: `记录 ${record.host} 不在 DNS Zone ${zoneName} 下,请绑定正确的 DNS API 或手动配置。`
  468. };
  469. }
  470. function signAliyun(params, accessKeySecret) {
  471. const encoded = Object.keys(params)
  472. .sort()
  473. .map((key) => `${percentEncode(key)}=${percentEncode(params[key])}`)
  474. .join('&');
  475. const stringToSign = `GET&%2F&${percentEncode(encoded)}`;
  476. const signature = crypto
  477. .createHmac('sha1', `${accessKeySecret}&`)
  478. .update(stringToSign)
  479. .digest('base64');
  480. return `${encoded}&Signature=${percentEncode(signature)}`;
  481. }
  482. function percentEncode(value) {
  483. return encodeURIComponent(String(value))
  484. .replace(/\+/g, '%20')
  485. .replace(/\*/g, '%2A')
  486. .replace(/%7E/g, '~');
  487. }
  488. function signTencent({ action, body, secretId, secretKey, timestamp }) {
  489. const service = 'dnspod';
  490. const host = 'dnspod.tencentcloudapi.com';
  491. const date = new Date(timestamp * 1000).toISOString().slice(0, 10);
  492. const hashedPayload = sha256(body, 'hex');
  493. const canonicalRequest = [
  494. 'POST',
  495. '/',
  496. '',
  497. `content-type:application/json; charset=utf-8\nhost:${host}\n`,
  498. 'content-type;host',
  499. hashedPayload
  500. ].join('\n');
  501. const credentialScope = `${date}/${service}/tc3_request`;
  502. const stringToSign = [
  503. 'TC3-HMAC-SHA256',
  504. String(timestamp),
  505. credentialScope,
  506. sha256(canonicalRequest, 'hex')
  507. ].join('\n');
  508. const secretDate = hmac(`TC3${secretKey}`, date);
  509. const secretService = hmac(secretDate, service);
  510. const secretSigning = hmac(secretService, 'tc3_request');
  511. const signature = hmac(secretSigning, stringToSign, 'hex');
  512. return {
  513. Authorization: `TC3-HMAC-SHA256 Credential=${secretId}/${credentialScope}, SignedHeaders=content-type;host, Signature=${signature}`,
  514. 'Content-Type': 'application/json; charset=utf-8',
  515. Host: host,
  516. 'X-TC-Action': action,
  517. 'X-TC-Version': '2021-03-23',
  518. 'X-TC-Timestamp': String(timestamp),
  519. 'X-TC-Region': 'ap-guangzhou'
  520. };
  521. }
  522. function sha256(value, encoding) {
  523. return crypto.createHash('sha256').update(value).digest(encoding);
  524. }
  525. function hmac(key, value, encoding) {
  526. return crypto.createHmac('sha256', key).update(value).digest(encoding);
  527. }