dns-guide.js 9.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337
  1. import dns from 'node:dns';
  2. import { buildDkimRecord } from './dkim.js';
  3. const resolver = new dns.promises.Resolver();
  4. resolver.setServers(
  5. String(process.env.DNS_RESOLVERS || '1.1.1.1,8.8.8.8')
  6. .split(',')
  7. .map((server) => server.trim())
  8. .filter(Boolean)
  9. );
  10. export async function buildDnsGuide(domain) {
  11. const live = await readLiveDns(domain);
  12. const requiredSpf = buildRequiredSpfMechanisms(domain);
  13. const spf = mergeSpfRecords(live.rootTxt.filter(isSpfRecord), requiredSpf);
  14. const dmarc = mergeDmarcRecord(live.dmarcTxt.find(isDmarcRecord), domain);
  15. const verificationValue = `mailhub-verification=${domain.verificationToken}`;
  16. const dkimValue = buildDkimRecord(domain.dkimPublic);
  17. const records = [
  18. {
  19. key: 'verification',
  20. label: '域名验证',
  21. host: `_mailhub.${domain.domain}`,
  22. type: 'TXT',
  23. value: verificationValue,
  24. status: containsTxt(live.verificationTxt, verificationValue) ? 'ok' : 'missing'
  25. },
  26. {
  27. key: 'dkim',
  28. label: 'DKIM',
  29. host: `${domain.selector}._domainkey.${domain.domain}`,
  30. type: 'TXT',
  31. value: dkimValue,
  32. status: containsTxt(live.dkimTxt, dkimValue) ? 'ok' : 'missing'
  33. },
  34. {
  35. key: 'spf',
  36. label: 'SPF',
  37. host: domain.domain,
  38. type: 'TXT',
  39. value: spf.recommended,
  40. status: spf.ok ? 'ok' : 'warn',
  41. current: spf.current,
  42. warnings: spf.warnings
  43. },
  44. {
  45. key: 'dmarc',
  46. label: 'DMARC',
  47. host: `_dmarc.${domain.domain}`,
  48. type: 'TXT',
  49. value: dmarc.recommended,
  50. status: dmarc.ok ? 'ok' : 'warn',
  51. current: dmarc.current,
  52. warnings: dmarc.warnings
  53. },
  54. {
  55. key: 'sender-a',
  56. label: '平台发信主机 A',
  57. host: domain.senderHost,
  58. type: 'A',
  59. value: domain.sendingIp,
  60. managed: true,
  61. status: live.senderA.includes(domain.sendingIp) ? 'ok' : 'warn',
  62. current: live.senderA.join(', '),
  63. warnings: live.senderA.includes(domain.sendingIp)
  64. ? []
  65. : [`${domain.senderHost} 当前未解析到 ${domain.sendingIp},这是平台发信主机,请联系管理员检查。`]
  66. },
  67. {
  68. key: 'ptr',
  69. label: 'PTR 反向解析',
  70. host: domain.sendingIp,
  71. type: 'PTR',
  72. value: domain.senderHost,
  73. status: live.ptr.includes(domain.senderHost) ? 'ok' : 'warn',
  74. current: live.ptr.join(', '),
  75. warnings: live.ptr.includes(domain.senderHost)
  76. ? []
  77. : ['PTR 需要在云服务器或 IP 服务商控制台设置,普通 DNS 控制台通常不能修改。']
  78. }
  79. ];
  80. const okKeys = new Set(records.filter((record) => record.status === 'ok').map((record) => record.key));
  81. const verified = okKeys.has('verification') && okKeys.has('dkim') && okKeys.has('spf') && okKeys.has('dmarc');
  82. return {
  83. checkedAt: new Date().toISOString(),
  84. verified,
  85. records,
  86. live,
  87. requiredSpf,
  88. optionalRecords: buildOptionalRecords(domain),
  89. warnings: collectWarnings(records, spf, dmarc, live)
  90. };
  91. }
  92. async function readLiveDns(domain) {
  93. const [
  94. rootTxt,
  95. verificationTxt,
  96. dkimTxt,
  97. dmarcTxt,
  98. senderA,
  99. ptr
  100. ] = await Promise.all([
  101. resolveTxt(domain.domain),
  102. resolveTxt(`_mailhub.${domain.domain}`),
  103. resolveTxt(`${domain.selector}._domainkey.${domain.domain}`),
  104. resolveTxt(`_dmarc.${domain.domain}`),
  105. resolve4(domain.senderHost),
  106. resolvePtr(domain.sendingIp)
  107. ]);
  108. return {
  109. rootTxt,
  110. verificationTxt,
  111. dkimTxt,
  112. dmarcTxt,
  113. senderA,
  114. ptr
  115. };
  116. }
  117. async function resolveTxt(name) {
  118. try {
  119. const rows = await resolver.resolveTxt(name);
  120. return rows.map((parts) => parts.join(''));
  121. } catch (error) {
  122. if (['ENODATA', 'ENOTFOUND', 'SERVFAIL', 'ETIMEOUT'].includes(error.code)) return [];
  123. return [`DNS lookup failed: ${error.code || error.message}`];
  124. }
  125. }
  126. async function resolve4(name) {
  127. try {
  128. return await resolver.resolve4(name);
  129. } catch {
  130. return [];
  131. }
  132. }
  133. async function resolvePtr(ip) {
  134. try {
  135. return await resolver.reverse(ip);
  136. } catch {
  137. return [];
  138. }
  139. }
  140. function containsTxt(records, expected) {
  141. return records.some((record) => normalizeTxt(record) === normalizeTxt(expected));
  142. }
  143. function normalizeTxt(value) {
  144. return String(value).replace(/\s+/g, ' ').trim();
  145. }
  146. function isSpfRecord(value) {
  147. return /^v=spf1(?:\s|$)/i.test(value.trim());
  148. }
  149. function isDmarcRecord(value) {
  150. return /^v=DMARC1(?:;|\s|$)/i.test(value.trim());
  151. }
  152. function buildRequiredSpfMechanisms(domain) {
  153. const mechanisms = [];
  154. if (domain.sendingIp) mechanisms.push(`ip4:${domain.sendingIp}`);
  155. if (domain.senderHost) mechanisms.push(`a:${domain.senderHost}`);
  156. mechanisms.push(...splitMechanisms(domain.spfExtra));
  157. return uniqueMechanisms(mechanisms);
  158. }
  159. function splitMechanisms(value) {
  160. return String(value || '')
  161. .split(/[\s,]+/)
  162. .map((item) => item.trim())
  163. .filter(Boolean);
  164. }
  165. export function mergeSpfRecords(existingRecords, requiredMechanisms) {
  166. const warnings = [];
  167. const current = existingRecords.map(normalizeTxt);
  168. if (current.length > 1) {
  169. warnings.push('当前域名存在多条 SPF TXT,收件方会判定 SPF permerror;需要合并为一条。');
  170. }
  171. if (current.length === 0) {
  172. const recommended = `v=spf1 ${requiredMechanisms.join(' ')} ~all`.replace(/\s+/g, ' ').trim();
  173. warnings.push('当前没有 SPF 记录。');
  174. return {
  175. current,
  176. recommended,
  177. ok: false,
  178. warnings: withLookupWarning(warnings, recommended)
  179. };
  180. }
  181. const parsed = current.map(parseSpf);
  182. const mechanisms = [];
  183. for (const record of parsed) mechanisms.push(...record.mechanisms);
  184. mechanisms.push(...requiredMechanisms);
  185. const all = parsed.find((record) => record.all === '-all')?.all
  186. || parsed.find((record) => record.all === '~all')?.all
  187. || parsed.find((record) => record.all === '?all')?.all
  188. || '~all';
  189. const recommended = `v=spf1 ${uniqueMechanisms(mechanisms).join(' ')} ${all}`
  190. .replace(/\s+/g, ' ')
  191. .trim();
  192. const ok = current.length === 1
  193. && normalizeTxt(current[0]) === recommended
  194. && requiredMechanisms.every((mechanism) => hasMechanism(current[0], mechanism));
  195. return {
  196. current,
  197. recommended,
  198. ok,
  199. warnings: withLookupWarning(warnings, recommended)
  200. };
  201. }
  202. function parseSpf(record) {
  203. const tokens = normalizeTxt(record).split(/\s+/).slice(1);
  204. const mechanisms = [];
  205. let all = '~all';
  206. for (const token of tokens) {
  207. if (/^[+\-~?]?all$/i.test(token)) {
  208. all = token;
  209. } else if (token) {
  210. mechanisms.push(token);
  211. }
  212. }
  213. return { mechanisms, all };
  214. }
  215. function uniqueMechanisms(items) {
  216. const seen = new Set();
  217. const output = [];
  218. for (const item of items) {
  219. const normalized = normalizeMechanism(item);
  220. if (!normalized || seen.has(normalized)) continue;
  221. seen.add(normalized);
  222. output.push(item.replace(/^\+/, ''));
  223. }
  224. return output;
  225. }
  226. function normalizeMechanism(item) {
  227. return String(item || '').trim().replace(/^\+/, '').toLowerCase();
  228. }
  229. function hasMechanism(record, mechanism) {
  230. const normalized = normalizeMechanism(mechanism);
  231. return parseSpf(record).mechanisms.some((item) => normalizeMechanism(item) === normalized);
  232. }
  233. function withLookupWarning(warnings, spf) {
  234. const lookupCount = (spf.match(/\b(include|a|mx|ptr|exists|redirect)[=:]?/g) || []).length;
  235. if (lookupCount > 10) {
  236. return [...warnings, `SPF DNS 查询项约为 ${lookupCount} 个,超过 10 个会失败;建议减少 include 或改用专用子域。`];
  237. }
  238. if (lookupCount >= 8) {
  239. return [...warnings, `SPF DNS 查询项约为 ${lookupCount} 个,接近 10 个上限。`];
  240. }
  241. return warnings;
  242. }
  243. export function mergeDmarcRecord(existingRecord, domain) {
  244. const current = existingRecord ? normalizeTxt(existingRecord) : '';
  245. const warnings = [];
  246. const tags = parseDmarc(current);
  247. if (!current) warnings.push('当前没有 DMARC 记录。');
  248. tags.set('v', 'DMARC1');
  249. tags.set('p', domain.dmarcPolicy || tags.get('p') || 'none');
  250. tags.set('adkim', tags.get('adkim') || 's');
  251. tags.set('aspf', tags.get('aspf') || 's');
  252. tags.set('pct', tags.get('pct') || '100');
  253. const rua = domain.dmarcRua || tags.get('rua') || `mailto:dmarc@${domain.domain}`;
  254. if (rua) tags.set('rua', rua);
  255. const order = ['v', 'p', 'rua', 'ruf', 'adkim', 'aspf', 'pct', 'fo'];
  256. const recommended = [
  257. ...order.filter((key) => tags.has(key)).map((key) => `${key}=${tags.get(key)}`),
  258. ...[...tags.entries()]
  259. .filter(([key]) => !order.includes(key))
  260. .map(([key, value]) => `${key}=${value}`)
  261. ].join('; ');
  262. return {
  263. current: current ? [current] : [],
  264. recommended,
  265. ok: current === recommended,
  266. warnings
  267. };
  268. }
  269. function parseDmarc(record) {
  270. const tags = new Map();
  271. for (const part of String(record || '').split(';')) {
  272. const [key, ...rest] = part.trim().split('=');
  273. if (!key || !rest.length) continue;
  274. tags.set(key.toLowerCase(), rest.join('=').trim());
  275. }
  276. return tags;
  277. }
  278. function buildOptionalRecords(domain) {
  279. return [
  280. {
  281. label: 'TLS-RPT',
  282. host: `_smtp._tls.${domain.domain}`,
  283. type: 'TXT',
  284. value: `v=TLSRPTv1; rua=mailto:tlsrpt@${domain.domain}`
  285. },
  286. {
  287. label: 'MTA-STS',
  288. host: `_mta-sts.${domain.domain}`,
  289. type: 'TXT',
  290. value: 'v=STSv1; id=2026070701'
  291. },
  292. {
  293. label: 'BIMI',
  294. host: `default._bimi.${domain.domain}`,
  295. type: 'TXT',
  296. value: `v=BIMI1; l=https://${domain.domain}/bimi.svg`
  297. }
  298. ];
  299. }
  300. function collectWarnings(records, spf, dmarc, live) {
  301. const warnings = [
  302. ...records.flatMap((record) => record.warnings || []),
  303. ...spf.warnings,
  304. ...dmarc.warnings
  305. ];
  306. if (live.rootTxt.filter(isSpfRecord).length > 1) {
  307. warnings.push('SPF 必须只有一条 TXT;不要新增第二条 v=spf1。');
  308. }
  309. return [...new Set(warnings)];
  310. }