import asyncio import dns.resolver import dns.asyncresolver import dns.exception from ..config import settings from ..models import DNSResult from .doh_resolver import resolve_mx_dual_cached from .i18n import to_punycode DKIM_SELECTORS = ['default', 'google', 'selector1', 'selector2', 'dkim', 'k1'] def build_resolver(timeout: float) -> dns.asyncresolver.Resolver: """Resolver that never trusts a broken system resolver config. Diagnosed live: the host's resolver list held three stale gateway IPs (192.168.3.1 / 192.168.1.1 / 172.20.10.1) from old networks. dnspython tried each in turn, each timing out, so EVERY lookup burned the full lifetime and returned a timeout โ€” even for domains whose MX answered in 56 ms via a public resolver. That produced 42s requests and mass false-invalid verdicts. """ resolver = dns.asyncresolver.Resolver(configure=not settings.dns_nameservers) if settings.dns_nameservers: resolver.nameservers = list(settings.dns_nameservers) resolver.timeout = timeout resolver.lifetime = timeout return resolver async def check_dns(domain: str, timeout: int = 5) -> DNSResult: """ Layer 2 & 3: Async DNS + MX Record validation. Also checks SPF, DMARC and DKIM authentication records. The MX gate runs first over DoH (free public resolvers, dual-query with a consistency check โ€” see doh_resolver.py); the legacy dnspython path is the automatic fallback when DoH is disabled or unreachable. SPF/DMARC/DKIM are informational TXT lookups and stay on the dnspython path. The authentication lookups are independent of one another and are issued concurrently: the DKIM selector list alone is six sequential lookups, which multiplied every request's latency when run one after another. """ # ---- MX gate over DoH (spec ยง3.2), legacy path as fallback ------------- if settings.doh_enabled: doh = await resolve_mx_dual_cached(domain, timeout) status = doh.get("status") if status in {"ok", "nxdomain", "no_mail"}: if status == "nxdomain": return DNSResult( domain_exists=False, has_mx_records=False, error=doh.get("error") or "Domain does not exist (NXDOMAIN)", error_code="nxdomain", ) if status == "no_mail": return DNSResult( domain_exists=True, has_mx_records=False, error=doh.get("error") or "Domain has no MX, A, or AAAA records", error_code="no_mail_records", ) # status == "ok": finish the record with the informational TXT layer. resolver = build_resolver(timeout) ascii_domain = to_punycode(domain) has_spf, spf_record = await _lookup_spf(resolver, ascii_domain) (has_dmarc, dmarc_record), (has_dkim, _) = await asyncio.gather( _lookup_dmarc(resolver, ascii_domain), _lookup_dkim(resolver, ascii_domain), ) return DNSResult( domain_exists=True, has_mx_records=bool(doh.get("mx_records")), mx_records=doh.get("mx_records") or [], a_fallback=bool(doh.get("a_fallback")), has_spf=has_spf, spf_record=spf_record, has_dmarc=has_dmarc, dmarc_record=dmarc_record, has_dkim=has_dkim, dns_inconsistent=doh.get("consistent") is False, mx_source="doh:dual" if doh.get("consistent") is not None else "doh:primary", ) # DoH errored โ†’ fall through to the legacy resolver path. return await _check_dns_legacy(domain, timeout) async def _check_dns_legacy(domain: str, timeout: int = 5) -> DNSResult: """Legacy dnspython path (UDP/53 against configured public resolvers).""" resolver = build_resolver(timeout) # ---- MX Records -------------------------------------------------- mx_records = [] domain_exists = False has_mx = False a_fallback = False try: mx_answers = await resolver.resolve(domain, 'MX') has_mx = True domain_exists = True for r in sorted(mx_answers, key=lambda x: x.preference): mx_records.append({ "host": str(r.exchange).rstrip('.'), "priority": r.preference }) except dns.resolver.NXDOMAIN: return DNSResult( domain_exists=False, has_mx_records=False, error="Domain does not exist (NXDOMAIN)", error_code="nxdomain", ) except dns.resolver.NoAnswer: # No MX -> try A/AAAA fallback per RFC try: for record_type in ('A', 'AAAA'): try: await resolver.resolve(domain, record_type) domain_exists = True a_fallback = True mx_records.append({"host": domain, "priority": 0}) break except dns.resolver.NoAnswer: continue except dns.resolver.NXDOMAIN: return DNSResult( domain_exists=False, has_mx_records=False, error="Domain does not exist (NXDOMAIN)", error_code="nxdomain", ) except dns.exception.Timeout: return DNSResult( domain_exists=False, has_mx_records=False, error="DNS address lookup timed out", error_code="timeout", ) except dns.exception.DNSException as e: return DNSResult( domain_exists=False, has_mx_records=False, error=f"DNS address lookup failed: {e.__class__.__name__}", error_code="resolver_error", ) if not domain_exists: # An MX NODATA response confirms the owner name exists, even when # it has no address record to which SMTP can be routed. domain_exists = True except dns.exception.Timeout: return DNSResult( domain_exists=False, has_mx_records=False, error="DNS timeout", error_code="timeout", ) except dns.exception.DNSException as e: return DNSResult( domain_exists=False, has_mx_records=False, error=f"DNS lookup failed: {e.__class__.__name__}", error_code="resolver_error", ) if not has_mx and not a_fallback: return DNSResult( domain_exists=True, has_mx_records=False, error="Domain has no MX, A, or AAAA records", error_code="no_mail_records", ) # ---- SPF / DMARC / DKIM (independent of each other -> concurrent) ---- has_spf, spf_record = await _lookup_spf(resolver, domain) (has_dmarc, dmarc_record), (has_dkim, _) = await asyncio.gather( _lookup_dmarc(resolver, domain), _lookup_dkim(resolver, domain), ) return DNSResult( domain_exists=domain_exists, has_mx_records=has_mx, mx_records=mx_records, a_fallback=a_fallback, has_spf=has_spf, spf_record=spf_record, has_dmarc=has_dmarc, dmarc_record=dmarc_record, has_dkim=has_dkim ) async def _lookup_spf(resolver, domain: str) -> tuple[bool, str | None]: try: txt_answers = await resolver.resolve(domain, 'TXT') for record in txt_answers: txt = b''.join(record.strings).decode('utf-8', errors='replace') if txt.startswith('v=spf1'): return True, txt except Exception: pass return False, None async def _lookup_dmarc(resolver, domain: str) -> tuple[bool, str | None]: try: answers = await resolver.resolve(f"_dmarc.{domain}", 'TXT') for record in answers: txt = b''.join(record.strings).decode('utf-8', errors='replace') if 'DMARC1' in txt: return True, txt except Exception: pass return False, None async def _lookup_dkim(resolver, domain: str) -> tuple[bool, str | None]: """Probe the known selectors concurrently, first hit wins.""" async def probe(selector: str): try: answers = await resolver.resolve(f"{selector}._domainkey.{domain}", 'TXT') for record in answers: txt = b''.join(record.strings).decode('utf-8', errors='replace') if 'DKIM1' in txt or 'k=rsa' in txt or 'p=' in txt: return txt except Exception: return None return None results = await asyncio.gather(*(probe(s) for s in DKIM_SELECTORS)) for txt in results: if txt: return True, txt return False, None