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 Download app/validator/dns_check.py from sitcod3/modul-x: direct link, hf CLI and curl.
- Browser
- Download file 9.03 kB
-
https://huggingface.co/spaces/sitcod3/modul-x/resolve/main/app/validator/dns_check.py
- Command line
-
hf download hf://spaces/sitcod3/modul-x/app/validator/dns_check.py
-
curl -L -o dns_check.py https://huggingface.co/spaces/sitcod3/modul-x/resolve/main/app/validator/dns_check.py
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 | |