Coverage for src / idx_api / dns_providers.py: 20%
272 statements
« prev ^ index » next coverage.py v7.13.1, created at 2025-12-28 11:09 -0700
« prev ^ index » next coverage.py v7.13.1, created at 2025-12-28 11:09 -0700
1"""DNS provider detection and WHOIS lookup module.
3This module provides functionality to:
41. Query nameserver (NS) records for a domain
52. Detect the DNS hosting provider based on NS hostnames
63. Query MX records to detect mail provider
74. Analyze TXT records for SPF, DKIM, DMARC, and service verifications
85. Fetch WHOIS information (registrar, dates)
96. Cache results for performance
10"""
12import logging
13import re
14from dataclasses import dataclass
15from functools import lru_cache
16from typing import TypedDict
18import dns.resolver
19import dns.exception
21logger = logging.getLogger(__name__)
24# ===== Provider Definitions =====
26@dataclass
27class DnsProvider:
28 """DNS hosting provider information."""
29 name: str
30 icon: str # Lucide icon name
31 color: str # Hex color for UI
34# Map of NS hostname patterns to provider info
35# Pattern matching is case-insensitive and checks if the NS hostname contains the pattern
36DNS_PROVIDERS: dict[str, DnsProvider] = {
37 # Major cloud providers
38 "cloudflare": DnsProvider("Cloudflare", "Cloud", "#F38020"),
39 "awsdns": DnsProvider("AWS Route 53", "Server", "#FF9900"),
40 "azure-dns": DnsProvider("Azure DNS", "Cloud", "#0078D4"),
41 "googledomains": DnsProvider("Google Domains", "Globe", "#4285F4"),
42 "google.com": DnsProvider("Google Cloud DNS", "Globe", "#4285F4"),
44 # Domain registrars
45 "godaddy": DnsProvider("GoDaddy", "Globe", "#1BDBDB"),
46 "domaincontrol": DnsProvider("GoDaddy", "Globe", "#1BDBDB"),
47 "namecheap": DnsProvider("Namecheap", "Globe", "#DE3723"),
48 "registrar-servers": DnsProvider("Namecheap", "Globe", "#DE3723"),
49 "name.com": DnsProvider("Name.com", "Globe", "#3B82F6"),
50 "hover": DnsProvider("Hover", "Globe", "#4A90D9"),
51 "gandi": DnsProvider("Gandi", "Globe", "#75C035"),
52 "dreamhost": DnsProvider("DreamHost", "Globe", "#0073AA"),
53 "porkbun": DnsProvider("Porkbun", "Globe", "#F472B6"),
54 "dynadot": DnsProvider("Dynadot", "Globe", "#0066CC"),
55 "namesilo": DnsProvider("NameSilo", "Globe", "#0099CC"),
57 # Hosting providers with DNS
58 "digitalocean": DnsProvider("DigitalOcean", "Droplet", "#0080FF"),
59 "linode": DnsProvider("Linode", "Server", "#00B050"),
60 "vultr": DnsProvider("Vultr", "Server", "#007BFC"),
61 "hetzner": DnsProvider("Hetzner", "Server", "#D50C2D"),
62 "ovh": DnsProvider("OVH", "Server", "#123F6D"),
63 "bluehost": DnsProvider("Bluehost", "Globe", "#003B73"),
64 "hostgator": DnsProvider("HostGator", "Globe", "#F16722"),
65 "siteground": DnsProvider("SiteGround", "Globe", "#72B839"),
66 "ionos": DnsProvider("IONOS", "Globe", "#003D8F"),
67 "1and1": DnsProvider("IONOS", "Globe", "#003D8F"),
68 "hostinger": DnsProvider("Hostinger", "Globe", "#6C2EB9"),
70 # Website platforms with DNS
71 "netlify": DnsProvider("Netlify", "Zap", "#00C7B7"),
72 "vercel": DnsProvider("Vercel", "Triangle", "#000000"),
73 "squarespace": DnsProvider("Squarespace", "Layout", "#000000"),
74 "wix": DnsProvider("Wix", "Layout", "#0C6EFC"),
75 "shopify": DnsProvider("Shopify", "ShoppingBag", "#96BF48"),
76 "wordpress": DnsProvider("WordPress.com", "Layout", "#21759B"),
77 "webflow": DnsProvider("Webflow", "Layout", "#4353FF"),
79 # DNS-specific services
80 "dnsimple": DnsProvider("DNSimple", "Layers", "#1D70B8"),
81 "dnsmadeeasy": DnsProvider("DNS Made Easy", "Layers", "#0080FF"),
82 "nsone.net": DnsProvider("NS1", "Layers", "#00C7B7"), # NS1's actual domain
83 "ultradns": DnsProvider("UltraDNS", "Layers", "#FF6600"),
84 "easydns": DnsProvider("easyDNS", "Layers", "#2E8540"),
85 "dyn.com": DnsProvider("Dyn (Oracle)", "Layers", "#C74634"),
86 "constellix": DnsProvider("Constellix", "Layers", "#00A99D"),
88 # Additional registrars
89 "register.com": DnsProvider("Register.com", "Globe", "#0066CC"),
90 "rgnameserver": DnsProvider("Register.com", "Globe", "#0066CC"),
91}
94@dataclass
95class MailProvider:
96 """Email hosting provider information."""
97 name: str
98 icon: str # Lucide icon name
99 color: str # Hex color for UI
102# Map of MX hostname patterns to mail provider info
103MAIL_PROVIDERS: dict[str, MailProvider] = {
104 # Major email providers
105 "google.com": MailProvider("Google Workspace", "Mail", "#EA4335"),
106 "googlemail.com": MailProvider("Google Workspace", "Mail", "#EA4335"),
107 "outlook.com": MailProvider("Microsoft 365", "Mail", "#0078D4"),
108 "protection.outlook.com": MailProvider("Microsoft 365", "Mail", "#0078D4"),
109 "pphosted.com": MailProvider("Proofpoint", "Shield", "#00A1E0"),
110 "mimecast.com": MailProvider("Mimecast", "Shield", "#00A1E0"),
111 "barracuda": MailProvider("Barracuda", "Shield", "#00A651"),
113 # Business email services
114 "zoho.com": MailProvider("Zoho Mail", "Mail", "#F9A825"),
115 "zohomail": MailProvider("Zoho Mail", "Mail", "#F9A825"),
116 "protonmail.ch": MailProvider("ProtonMail", "Lock", "#6D4AFF"),
117 "proton.me": MailProvider("ProtonMail", "Lock", "#6D4AFF"),
118 "fastmail": MailProvider("Fastmail", "Mail", "#3A75C4"),
119 "messagingengine.com": MailProvider("Fastmail", "Mail", "#3A75C4"),
120 "tutanota": MailProvider("Tutanota", "Lock", "#840010"),
121 "mailgun.org": MailProvider("Mailgun", "Send", "#F06B66"),
122 "sendgrid.net": MailProvider("SendGrid", "Send", "#1A82E2"),
123 "postmarkapp.com": MailProvider("Postmark", "Send", "#FFDE00"),
124 "mailchimp.com": MailProvider("Mailchimp", "Send", "#FFE01B"),
125 "amazonses.com": MailProvider("Amazon SES", "Send", "#FF9900"),
127 # Hosting providers with email
128 "secureserver.net": MailProvider("GoDaddy Email", "Mail", "#1BDBDB"),
129 "emailsrvr.com": MailProvider("Rackspace Email", "Mail", "#C62828"),
130 "dreamhost.com": MailProvider("DreamHost Email", "Mail", "#0073AA"),
131 "hostinger": MailProvider("Hostinger Email", "Mail", "#6C2EB9"),
132 "bluehost": MailProvider("Bluehost Email", "Mail", "#003B73"),
133 "ionos": MailProvider("IONOS Email", "Mail", "#003D8F"),
134 "1and1": MailProvider("IONOS Email", "Mail", "#003D8F"),
135 "ovh.net": MailProvider("OVH Email", "Mail", "#123F6D"),
136 "titan.email": MailProvider("Titan Email", "Mail", "#1155CC"),
138 # ISP email
139 "yahoodns.net": MailProvider("Yahoo Mail", "Mail", "#6001D2"),
140 "icloud.com": MailProvider("iCloud Mail", "Mail", "#000000"),
142 # Custom/self-hosted indicators
143 "in-addr.arpa": MailProvider("Self-hosted", "Server", "#666666"),
144}
147@dataclass
148class TxtRecordInfo:
149 """Parsed TXT record information."""
150 spf: str | None # SPF record content
151 dmarc: str | None # DMARC record content
152 dkim_selectors: list[str] # Known DKIM selectors found
153 verifications: list[dict] # Service verification records
156# Known service verification patterns
157SERVICE_VERIFICATIONS = {
158 r"google-site-verification[=:]": {"service": "Google Search Console", "icon": "Search", "color": "#4285F4"},
159 r"MS=": {"service": "Microsoft 365", "icon": "Building2", "color": "#0078D4"},
160 r"facebook-domain-verification=": {"service": "Facebook", "icon": "Facebook", "color": "#1877F2"},
161 r"apple-domain-verification=": {"service": "Apple", "icon": "Apple", "color": "#000000"},
162 r"adobe-idp-site-verification=": {"service": "Adobe", "icon": "Palette", "color": "#FF0000"},
163 r"atlassian-domain-verification=": {"service": "Atlassian", "icon": "Trello", "color": "#0052CC"},
164 r"docusign=": {"service": "DocuSign", "icon": "FileSignature", "color": "#FFCC00"},
165 r"stripe-verification=": {"service": "Stripe", "icon": "CreditCard", "color": "#635BFF"},
166 r"_github-pages-challenge-": {"service": "GitHub Pages", "icon": "Github", "color": "#181717"},
167 r"hubspot-developer-verification=": {"service": "HubSpot", "icon": "Building", "color": "#FF7A59"},
168 r"mailchimp-": {"service": "Mailchimp", "icon": "Send", "color": "#FFE01B"},
169 r"webexdomainverification": {"service": "Webex", "icon": "Video", "color": "#00BCF2"},
170 r"zoom-domain-verification=": {"service": "Zoom", "icon": "Video", "color": "#2D8CFF"},
171 r"slack-domain-verification=": {"service": "Slack", "icon": "MessageSquare", "color": "#4A154B"},
172 r"miro-verification=": {"service": "Miro", "icon": "Layout", "color": "#FFD02F"},
173}
176class DnsInfoResult(TypedDict, total=False):
177 """Result of DNS info lookup."""
178 # DNS Provider
179 nameservers: list[str]
180 provider: dict | None # {name, icon, color}
182 # Mail
183 mx_records: list[dict] # [{priority, host}]
184 mail_provider: dict | None # {name, icon, color}
186 # TXT Records
187 spf: str | None
188 dmarc: str | None
189 dkim_selectors: list[str]
190 verifications: list[dict] # [{service, icon, color}]
192 # WHOIS
193 registrar: str | None
194 creation_date: str | None
195 expiration_date: str | None
197 # Security
198 dnssec: bool | None
200 # Meta
201 error: str | None
202 cached: bool
205def get_nameservers(domain: str, timeout: float = 5.0) -> list[str]:
206 """
207 Query NS records for a domain.
209 Args:
210 domain: Domain name to query
211 timeout: DNS query timeout in seconds
213 Returns:
214 List of nameserver hostnames (lowercased)
216 Raises:
217 dns.exception.DNSException: On DNS lookup failure
218 """
219 resolver = dns.resolver.Resolver()
220 resolver.nameservers = ['8.8.8.8', '1.1.1.1', '8.8.4.4'] # Use public DNS
221 resolver.timeout = timeout
222 resolver.lifetime = timeout * 2
224 # Strip any leading www. for NS lookup
225 lookup_domain = domain.lower()
226 if lookup_domain.startswith("www."):
227 lookup_domain = lookup_domain[4:]
229 # Try to get NS records - may need to look up parent domain
230 parts = lookup_domain.split('.')
231 last_error = None
233 for i in range(len(parts) - 1):
234 try_domain = '.'.join(parts[i:])
235 try:
236 answers = resolver.resolve(try_domain, 'NS')
237 return [str(rdata.target).rstrip('.').lower() for rdata in answers]
238 except dns.resolver.NXDOMAIN:
239 # Domain doesn't exist at this level, try parent
240 last_error = f"Domain '{try_domain}' not found"
241 continue
242 except dns.resolver.NoAnswer:
243 # No NS records at this level, try parent
244 continue
245 except dns.exception.Timeout:
246 raise dns.exception.Timeout(f"DNS query timed out for {try_domain}")
248 if last_error:
249 raise dns.resolver.NXDOMAIN(last_error)
250 raise dns.resolver.NoAnswer(f"No NS records found for {lookup_domain}")
253def detect_provider(nameservers: list[str]) -> DnsProvider | None:
254 """
255 Detect DNS provider from nameserver hostnames.
257 Args:
258 nameservers: List of nameserver hostnames
260 Returns:
261 DnsProvider if matched, None otherwise
262 """
263 if not nameservers:
264 return None
266 # Check each nameserver against our patterns
267 for ns in nameservers:
268 ns_lower = ns.lower()
269 for pattern, provider in DNS_PROVIDERS.items():
270 if pattern in ns_lower:
271 return provider
273 return None
276def get_mx_records(domain: str, timeout: float = 5.0) -> list[dict]:
277 """
278 Query MX records for a domain.
280 Args:
281 domain: Domain name to query
282 timeout: DNS query timeout in seconds
284 Returns:
285 List of MX records as {priority: int, host: str}
286 """
287 resolver = dns.resolver.Resolver()
288 resolver.nameservers = ['8.8.8.8', '1.1.1.1', '8.8.4.4']
289 resolver.timeout = timeout
290 resolver.lifetime = timeout * 2
292 # Strip www. for MX lookup
293 lookup_domain = domain.lower()
294 if lookup_domain.startswith("www."):
295 lookup_domain = lookup_domain[4:]
297 try:
298 answers = resolver.resolve(lookup_domain, 'MX')
299 mx_records = []
300 for rdata in answers:
301 mx_records.append({
302 "priority": rdata.preference,
303 "host": str(rdata.exchange).rstrip('.').lower(),
304 })
305 # Sort by priority
306 mx_records.sort(key=lambda x: x["priority"])
307 return mx_records
308 except (dns.resolver.NXDOMAIN, dns.resolver.NoAnswer, dns.exception.Timeout):
309 return []
310 except Exception as e:
311 logger.debug(f"MX lookup failed for {domain}: {e}")
312 return []
315def detect_mail_provider(mx_records: list[dict]) -> MailProvider | None:
316 """
317 Detect mail provider from MX records.
319 Args:
320 mx_records: List of MX records with {priority, host}
322 Returns:
323 MailProvider if matched, None otherwise
324 """
325 if not mx_records:
326 return None
328 # Check each MX host against our patterns
329 for mx in mx_records:
330 host = mx.get("host", "").lower()
331 for pattern, provider in MAIL_PROVIDERS.items():
332 if pattern in host:
333 return provider
335 return None
338def get_txt_records(domain: str, timeout: float = 5.0) -> list[str]:
339 """
340 Query TXT records for a domain.
342 Args:
343 domain: Domain name to query
344 timeout: DNS query timeout in seconds
346 Returns:
347 List of TXT record values
348 """
349 resolver = dns.resolver.Resolver()
350 resolver.nameservers = ['8.8.8.8', '1.1.1.1', '8.8.4.4']
351 resolver.timeout = timeout
352 resolver.lifetime = timeout * 2
354 # Strip www. for TXT lookup
355 lookup_domain = domain.lower()
356 if lookup_domain.startswith("www."):
357 lookup_domain = lookup_domain[4:]
359 try:
360 answers = resolver.resolve(lookup_domain, 'TXT')
361 txt_records = []
362 for rdata in answers:
363 # TXT records may be split into multiple strings
364 txt_value = ''.join([s.decode() if isinstance(s, bytes) else s for s in rdata.strings])
365 txt_records.append(txt_value)
366 return txt_records
367 except (dns.resolver.NXDOMAIN, dns.resolver.NoAnswer, dns.exception.Timeout):
368 return []
369 except Exception as e:
370 logger.debug(f"TXT lookup failed for {domain}: {e}")
371 return []
374def get_dmarc_record(domain: str, timeout: float = 5.0) -> str | None:
375 """
376 Query DMARC record for a domain (_dmarc subdomain).
378 Args:
379 domain: Domain name to query
380 timeout: DNS query timeout in seconds
382 Returns:
383 DMARC record value or None
384 """
385 resolver = dns.resolver.Resolver()
386 resolver.nameservers = ['8.8.8.8', '1.1.1.1', '8.8.4.4']
387 resolver.timeout = timeout
388 resolver.lifetime = timeout * 2
390 # Strip www. for DMARC lookup
391 lookup_domain = domain.lower()
392 if lookup_domain.startswith("www."):
393 lookup_domain = lookup_domain[4:]
395 dmarc_domain = f"_dmarc.{lookup_domain}"
397 try:
398 answers = resolver.resolve(dmarc_domain, 'TXT')
399 for rdata in answers:
400 txt_value = ''.join([s.decode() if isinstance(s, bytes) else s for s in rdata.strings])
401 if txt_value.startswith("v=DMARC1"):
402 return txt_value
403 return None
404 except (dns.resolver.NXDOMAIN, dns.resolver.NoAnswer, dns.exception.Timeout):
405 return None
406 except Exception as e:
407 logger.debug(f"DMARC lookup failed for {domain}: {e}")
408 return None
411def analyze_txt_records(txt_records: list[str], dmarc: str | None) -> dict:
412 """
413 Analyze TXT records for SPF, DKIM hints, and service verifications.
415 Args:
416 txt_records: List of TXT record values
417 dmarc: DMARC record value (from _dmarc subdomain)
419 Returns:
420 Dict with spf, dmarc, dkim_selectors, verifications
421 """
422 result = {
423 "spf": None,
424 "dmarc": dmarc,
425 "dkim_selectors": [],
426 "verifications": [],
427 }
429 seen_verifications = set()
431 for txt in txt_records:
432 # Check for SPF
433 if txt.startswith("v=spf1"):
434 result["spf"] = txt
436 # Check for service verifications
437 for pattern, service_info in SERVICE_VERIFICATIONS.items():
438 if re.search(pattern, txt, re.IGNORECASE):
439 service_name = service_info["service"]
440 if service_name not in seen_verifications:
441 seen_verifications.add(service_name)
442 result["verifications"].append({
443 "service": service_name,
444 "icon": service_info["icon"],
445 "color": service_info["color"],
446 })
448 # Extract DKIM selectors from SPF includes (common patterns)
449 if result["spf"]:
450 spf = result["spf"]
451 # Look for common DKIM-related includes that hint at email providers
452 dkim_hints = []
453 if "google.com" in spf or "googlemail.com" in spf:
454 dkim_hints.append("google")
455 if "outlook.com" in spf or "protection.outlook.com" in spf:
456 dkim_hints.append("selector1._domainkey")
457 dkim_hints.append("selector2._domainkey")
458 if "amazonses.com" in spf:
459 dkim_hints.append("amazonses")
460 if "sendgrid.net" in spf:
461 dkim_hints.append("s1._domainkey")
462 dkim_hints.append("s2._domainkey")
463 if "mailchimp" in spf:
464 dkim_hints.append("k1._domainkey")
465 result["dkim_selectors"] = dkim_hints
467 return result
470def get_whois_info(domain: str) -> dict:
471 """
472 Fetch WHOIS information for a domain.
474 Args:
475 domain: Domain name to look up
477 Returns:
478 Dict with registrar, creation_date, expiration_date
479 """
480 result = {
481 "registrar": None,
482 "creation_date": None,
483 "expiration_date": None,
484 }
486 try:
487 import whois
489 # Strip www. for WHOIS lookup
490 lookup_domain = domain.lower()
491 if lookup_domain.startswith("www."):
492 lookup_domain = lookup_domain[4:]
494 w = whois.whois(lookup_domain)
496 if w:
497 result["registrar"] = w.registrar if hasattr(w, 'registrar') else None
499 # Handle dates (may be list or single value)
500 if hasattr(w, 'creation_date'):
501 cd = w.creation_date
502 if isinstance(cd, list):
503 cd = cd[0] if cd else None
504 if cd:
505 result["creation_date"] = cd.isoformat() if hasattr(cd, 'isoformat') else str(cd)
507 if hasattr(w, 'expiration_date'):
508 ed = w.expiration_date
509 if isinstance(ed, list):
510 ed = ed[0] if ed else None
511 if ed:
512 result["expiration_date"] = ed.isoformat() if hasattr(ed, 'isoformat') else str(ed)
514 except Exception as e:
515 logger.warning(f"WHOIS lookup failed for {domain}: {e}")
517 return result
520def check_dnssec(domain: str) -> bool | None:
521 """
522 Check if DNSSEC is enabled for a domain.
524 Args:
525 domain: Domain to check
527 Returns:
528 True if DNSSEC enabled, False if not, None if unable to determine
529 """
530 try:
531 resolver = dns.resolver.Resolver()
532 resolver.nameservers = ['8.8.8.8', '1.1.1.1']
533 resolver.timeout = 5
534 resolver.lifetime = 10
536 # Strip www. for lookup
537 lookup_domain = domain.lower()
538 if lookup_domain.startswith("www."):
539 lookup_domain = lookup_domain[4:]
541 # Try to get DNSKEY records - presence indicates DNSSEC
542 try:
543 resolver.resolve(lookup_domain, 'DNSKEY')
544 return True
545 except dns.resolver.NoAnswer:
546 return False
547 except dns.resolver.NXDOMAIN:
548 return None
550 except Exception as e:
551 logger.debug(f"DNSSEC check failed for {domain}: {e}")
552 return None
555# Cache for DNS info lookups (256 entries, ~30 min effective TTL)
556_dns_cache: dict[str, DnsInfoResult] = {}
557_DNS_CACHE_MAX_SIZE = 256
560def get_dns_info(domain: str, refresh: bool = False) -> DnsInfoResult:
561 """
562 Get comprehensive DNS information for a domain.
564 Combines NS lookup, provider detection, WHOIS, and DNSSEC check.
565 Results are cached for performance.
567 Args:
568 domain: Domain to look up
569 refresh: If True, bypass cache and fetch fresh data
571 Returns:
572 DnsInfoResult with all gathered information
573 """
574 global _dns_cache
576 # Normalize domain
577 domain = domain.lower().strip()
578 if domain.startswith("www."):
579 domain = domain[4:]
581 # Check cache unless refresh requested
582 if not refresh and domain in _dns_cache:
583 cached_result = _dns_cache[domain].copy()
584 cached_result["cached"] = True
585 return cached_result
587 result: DnsInfoResult = {
588 # DNS Provider
589 "nameservers": [],
590 "provider": None,
591 # Mail
592 "mx_records": [],
593 "mail_provider": None,
594 # TXT Records
595 "spf": None,
596 "dmarc": None,
597 "dkim_selectors": [],
598 "verifications": [],
599 # WHOIS
600 "registrar": None,
601 "creation_date": None,
602 "expiration_date": None,
603 # Security
604 "dnssec": None,
605 # Meta
606 "error": None,
607 "cached": False,
608 }
610 try:
611 # Get nameservers
612 nameservers = get_nameservers(domain)
613 result["nameservers"] = nameservers
615 # Detect DNS provider
616 provider = detect_provider(nameservers)
617 if provider:
618 result["provider"] = {
619 "name": provider.name,
620 "icon": provider.icon,
621 "color": provider.color,
622 }
624 # Get MX records and detect mail provider
625 mx_records = get_mx_records(domain)
626 result["mx_records"] = mx_records
627 mail_provider = detect_mail_provider(mx_records)
628 if mail_provider:
629 result["mail_provider"] = {
630 "name": mail_provider.name,
631 "icon": mail_provider.icon,
632 "color": mail_provider.color,
633 }
635 # Get TXT records and analyze
636 txt_records = get_txt_records(domain)
637 dmarc = get_dmarc_record(domain)
638 txt_analysis = analyze_txt_records(txt_records, dmarc)
639 result["spf"] = txt_analysis["spf"]
640 result["dmarc"] = txt_analysis["dmarc"]
641 result["dkim_selectors"] = txt_analysis["dkim_selectors"]
642 result["verifications"] = txt_analysis["verifications"]
644 # Get WHOIS info (best effort)
645 whois_info = get_whois_info(domain)
646 result["registrar"] = whois_info.get("registrar")
647 result["creation_date"] = whois_info.get("creation_date")
648 result["expiration_date"] = whois_info.get("expiration_date")
650 # Check DNSSEC (best effort)
651 result["dnssec"] = check_dnssec(domain)
653 # Cache successful results
654 if len(_dns_cache) >= _DNS_CACHE_MAX_SIZE:
655 # Simple eviction: remove oldest entry
656 oldest_key = next(iter(_dns_cache))
657 del _dns_cache[oldest_key]
658 _dns_cache[domain] = result.copy()
660 except dns.resolver.NXDOMAIN:
661 result["error"] = f"Domain '{domain}' does not exist"
662 except dns.exception.Timeout:
663 result["error"] = "DNS query timed out"
664 except Exception as e:
665 logger.error(f"DNS info lookup failed for {domain}: {e}")
666 result["error"] = str(e)
668 return result
671def clear_dns_cache(domain: str | None = None):
672 """
673 Clear DNS cache.
675 Args:
676 domain: If provided, only clear cache for this domain.
677 If None, clear entire cache.
678 """
679 global _dns_cache
681 if domain:
682 domain = domain.lower().strip()
683 if domain.startswith("www."):
684 domain = domain[4:]
685 _dns_cache.pop(domain, None)
686 else:
687 _dns_cache.clear()