/** * DNS 모듈 공통 도구(1.0.5): 이름 정규화, TXT 분할, 오류 객체, 공개 DNS 조회(반영 확인). */ import { Resolver } from 'node:dns/promises'; /** FQDN(끝에 점). 빈 값은 그대로. */ export const fqdn = (name) => { const s = String(name || '').trim().toLowerCase(); return !s ? s : s.endsWith('.') ? s : s + '.'; }; /** 끝 점 없는 소문자 이름(비교용). */ export const bare = (name) => fqdn(name).replace(/\.$/, ''); /** TXT 값을 255자 이하 조각으로 나눕니다(RFC 1035 문자열 한도). */ export const txtChunks = (value) => { const s = String(value ?? ''); const out = []; for (let i = 0; i < Math.max(1, s.length); i += 255) out.push(s.slice(i, i + 255)); return out; }; /** dns_* 코드를 가진 Error. detail 은 화면에 그대로 보입니다. */ export function dnsError(code, detail) { const e = new Error(code); e.detail = String(detail || '').slice(0, 300); return e; } /** * 공개 리졸버(Google 8.8.8.8, Cloudflare 1.1.1.1)로 레코드가 실제로 보이는지 확인합니다. 권위 서버가 아니라 "세상이 보는 값" 기준이라 * DNS 업체에 적용한 직후에는 아직 안 보일 수 있습니다(캐시·전파). 그 경우 state 'wait' 로 알립니다. * @param {object} record { name, type, values[], priority? } * @param {object} [o] resolver: 시험용 대체 객체({ resolveTxt, resolveMx, resolveCname, resolve4 }) * @returns {Promise<{ok:boolean, found:string[], error?:string}>} */ export async function lookup(record, { resolver = null, servers = ['8.8.8.8', '1.1.1.1'] } = {}) { const r = resolver || (() => { const x = new Resolver({ timeout: 4000, tries: 2 }); x.setServers(servers); return x; })(); const name = bare(record.name); try { if (record.type === 'TXT') { const found = (await r.resolveTxt(name)).map((chunks) => chunks.join('')); const ok = record.values.every((v) => found.some((f) => f.replace(/\s+/g, ' ').trim() === String(v).replace(/\s+/g, ' ').trim())); return { ok, found }; } if (record.type === 'MX') { const found = (await r.resolveMx(name)).map((m) => `${m.priority} ${bare(m.exchange)}`); const want = record.values.map((v) => `${record.priority ?? 10} ${bare(v)}`); const ok = want.every((w) => found.includes(w)); return { ok, found }; } if (record.type === 'CNAME') { const found = (await r.resolveCname(name)).map(bare); return { ok: record.values.every((v) => found.includes(bare(v))), found }; } if (record.type === 'A') { const found = await r.resolve4(name); return { ok: record.values.every((v) => found.includes(v)), found }; } return { ok: false, found: [], error: 'unsupported_type' }; } catch (e) { // ENOTFOUND/ENODATA = 아직 없음(전파 대기), 그 밖은 조회 실패 return { ok: false, found: [], error: e.code === 'ENOTFOUND' || e.code === 'ENODATA' ? 'not_found' : e.code || e.message }; } } /** 호스트 이름의 A 레코드와 그 IP 의 PTR(역방향)을 함께 봅니다(Haraka 발신 준비 점검용). */ export async function reverseCheck(hostname, { resolver = null, servers = ['8.8.8.8', '1.1.1.1'] } = {}) { const r = resolver || (() => { const x = new Resolver({ timeout: 4000, tries: 2 }); x.setServers(servers); return x; })(); let ips = []; try { ips = await r.resolve4(bare(hostname)); } catch (e) { return { ok: false, ips: [], ptr: [], error: 'a_' + (e.code || 'failed') }; } const ptr = []; for (const ip of ips) { try { ptr.push(...(await r.reverse(ip)).map(bare)); } catch { /* PTR 없음 */ } } return { ok: ips.length > 0 && ptr.includes(bare(hostname)), ips, ptr }; }