modul-x / app /validator /dns_check.py
FortyDN Dev
v3.0.0: fully autonomous engine — remove MillionVerifier, add DoH DNS worker, AI risk scorer (HF PRO, circuit breaker), i18n, normalized verdicts, remote SMTP worker
de745bf
Raw History Blame Contribute Delete
9.03 kB
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