dns-guide.js 10 KB

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