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

1"""DNS provider detection and WHOIS lookup module. 

2 

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""" 

11 

12import logging 

13import re 

14from dataclasses import dataclass 

15from functools import lru_cache 

16from typing import TypedDict 

17 

18import dns.resolver 

19import dns.exception 

20 

21logger = logging.getLogger(__name__) 

22 

23 

24# ===== Provider Definitions ===== 

25 

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 

32 

33 

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"), 

43 

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"), 

56 

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"), 

69 

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"), 

78 

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"), 

87 

88 # Additional registrars 

89 "register.com": DnsProvider("Register.com", "Globe", "#0066CC"), 

90 "rgnameserver": DnsProvider("Register.com", "Globe", "#0066CC"), 

91} 

92 

93 

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 

100 

101 

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"), 

112 

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"), 

126 

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"), 

137 

138 # ISP email 

139 "yahoodns.net": MailProvider("Yahoo Mail", "Mail", "#6001D2"), 

140 "icloud.com": MailProvider("iCloud Mail", "Mail", "#000000"), 

141 

142 # Custom/self-hosted indicators 

143 "in-addr.arpa": MailProvider("Self-hosted", "Server", "#666666"), 

144} 

145 

146 

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 

154 

155 

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} 

174 

175 

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} 

181 

182 # Mail 

183 mx_records: list[dict] # [{priority, host}] 

184 mail_provider: dict | None # {name, icon, color} 

185 

186 # TXT Records 

187 spf: str | None 

188 dmarc: str | None 

189 dkim_selectors: list[str] 

190 verifications: list[dict] # [{service, icon, color}] 

191 

192 # WHOIS 

193 registrar: str | None 

194 creation_date: str | None 

195 expiration_date: str | None 

196 

197 # Security 

198 dnssec: bool | None 

199 

200 # Meta 

201 error: str | None 

202 cached: bool 

203 

204 

205def get_nameservers(domain: str, timeout: float = 5.0) -> list[str]: 

206 """ 

207 Query NS records for a domain. 

208 

209 Args: 

210 domain: Domain name to query 

211 timeout: DNS query timeout in seconds 

212 

213 Returns: 

214 List of nameserver hostnames (lowercased) 

215 

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 

223 

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:] 

228 

229 # Try to get NS records - may need to look up parent domain 

230 parts = lookup_domain.split('.') 

231 last_error = None 

232 

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}") 

247 

248 if last_error: 

249 raise dns.resolver.NXDOMAIN(last_error) 

250 raise dns.resolver.NoAnswer(f"No NS records found for {lookup_domain}") 

251 

252 

253def detect_provider(nameservers: list[str]) -> DnsProvider | None: 

254 """ 

255 Detect DNS provider from nameserver hostnames. 

256 

257 Args: 

258 nameservers: List of nameserver hostnames 

259 

260 Returns: 

261 DnsProvider if matched, None otherwise 

262 """ 

263 if not nameservers: 

264 return None 

265 

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 

272 

273 return None 

274 

275 

276def get_mx_records(domain: str, timeout: float = 5.0) -> list[dict]: 

277 """ 

278 Query MX records for a domain. 

279 

280 Args: 

281 domain: Domain name to query 

282 timeout: DNS query timeout in seconds 

283 

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 

291 

292 # Strip www. for MX lookup 

293 lookup_domain = domain.lower() 

294 if lookup_domain.startswith("www."): 

295 lookup_domain = lookup_domain[4:] 

296 

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 [] 

313 

314 

315def detect_mail_provider(mx_records: list[dict]) -> MailProvider | None: 

316 """ 

317 Detect mail provider from MX records. 

318 

319 Args: 

320 mx_records: List of MX records with {priority, host} 

321 

322 Returns: 

323 MailProvider if matched, None otherwise 

324 """ 

325 if not mx_records: 

326 return None 

327 

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 

334 

335 return None 

336 

337 

338def get_txt_records(domain: str, timeout: float = 5.0) -> list[str]: 

339 """ 

340 Query TXT records for a domain. 

341 

342 Args: 

343 domain: Domain name to query 

344 timeout: DNS query timeout in seconds 

345 

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 

353 

354 # Strip www. for TXT lookup 

355 lookup_domain = domain.lower() 

356 if lookup_domain.startswith("www."): 

357 lookup_domain = lookup_domain[4:] 

358 

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 [] 

372 

373 

374def get_dmarc_record(domain: str, timeout: float = 5.0) -> str | None: 

375 """ 

376 Query DMARC record for a domain (_dmarc subdomain). 

377 

378 Args: 

379 domain: Domain name to query 

380 timeout: DNS query timeout in seconds 

381 

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 

389 

390 # Strip www. for DMARC lookup 

391 lookup_domain = domain.lower() 

392 if lookup_domain.startswith("www."): 

393 lookup_domain = lookup_domain[4:] 

394 

395 dmarc_domain = f"_dmarc.{lookup_domain}" 

396 

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 

409 

410 

411def analyze_txt_records(txt_records: list[str], dmarc: str | None) -> dict: 

412 """ 

413 Analyze TXT records for SPF, DKIM hints, and service verifications. 

414 

415 Args: 

416 txt_records: List of TXT record values 

417 dmarc: DMARC record value (from _dmarc subdomain) 

418 

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 } 

428 

429 seen_verifications = set() 

430 

431 for txt in txt_records: 

432 # Check for SPF 

433 if txt.startswith("v=spf1"): 

434 result["spf"] = txt 

435 

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 }) 

447 

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 

466 

467 return result 

468 

469 

470def get_whois_info(domain: str) -> dict: 

471 """ 

472 Fetch WHOIS information for a domain. 

473 

474 Args: 

475 domain: Domain name to look up 

476 

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 } 

485 

486 try: 

487 import whois 

488 

489 # Strip www. for WHOIS lookup 

490 lookup_domain = domain.lower() 

491 if lookup_domain.startswith("www."): 

492 lookup_domain = lookup_domain[4:] 

493 

494 w = whois.whois(lookup_domain) 

495 

496 if w: 

497 result["registrar"] = w.registrar if hasattr(w, 'registrar') else None 

498 

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) 

506 

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) 

513 

514 except Exception as e: 

515 logger.warning(f"WHOIS lookup failed for {domain}: {e}") 

516 

517 return result 

518 

519 

520def check_dnssec(domain: str) -> bool | None: 

521 """ 

522 Check if DNSSEC is enabled for a domain. 

523 

524 Args: 

525 domain: Domain to check 

526 

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 

535 

536 # Strip www. for lookup 

537 lookup_domain = domain.lower() 

538 if lookup_domain.startswith("www."): 

539 lookup_domain = lookup_domain[4:] 

540 

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 

549 

550 except Exception as e: 

551 logger.debug(f"DNSSEC check failed for {domain}: {e}") 

552 return None 

553 

554 

555# Cache for DNS info lookups (256 entries, ~30 min effective TTL) 

556_dns_cache: dict[str, DnsInfoResult] = {} 

557_DNS_CACHE_MAX_SIZE = 256 

558 

559 

560def get_dns_info(domain: str, refresh: bool = False) -> DnsInfoResult: 

561 """ 

562 Get comprehensive DNS information for a domain. 

563 

564 Combines NS lookup, provider detection, WHOIS, and DNSSEC check. 

565 Results are cached for performance. 

566 

567 Args: 

568 domain: Domain to look up 

569 refresh: If True, bypass cache and fetch fresh data 

570 

571 Returns: 

572 DnsInfoResult with all gathered information 

573 """ 

574 global _dns_cache 

575 

576 # Normalize domain 

577 domain = domain.lower().strip() 

578 if domain.startswith("www."): 

579 domain = domain[4:] 

580 

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 

586 

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 } 

609 

610 try: 

611 # Get nameservers 

612 nameservers = get_nameservers(domain) 

613 result["nameservers"] = nameservers 

614 

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 } 

623 

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 } 

634 

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"] 

643 

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") 

649 

650 # Check DNSSEC (best effort) 

651 result["dnssec"] = check_dnssec(domain) 

652 

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() 

659 

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) 

667 

668 return result 

669 

670 

671def clear_dns_cache(domain: str | None = None): 

672 """ 

673 Clear DNS cache. 

674 

675 Args: 

676 domain: If provided, only clear cache for this domain. 

677 If None, clear entire cache. 

678 """ 

679 global _dns_cache 

680 

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()