""" DNS resolver service for DMARC, SPF, DKIM, and PTR record lookups. Provides an extensible provider architecture so that DNS data can be fetched either via the system resolver (dnspython) or via the Cloudflare DNS API for future Cloudflare integration. """ import asyncio import ipaddress import logging from abc import ABC, abstractmethod from dataclasses import dataclass, field from typing import Any, Dict, List, Optional, Tuple logger = logging.getLogger(__name__) def _sanitize_for_log(value: str) -> str: """Remove newline and carriage-return characters to prevent log injection.""" return value.replace("\r", "").replace("\n", "") def _ip_to_arpa_name(ip: str) -> str: """Convert an IP address string to its reverse-DNS ARPA lookup name. E.g. ``"1.2.3.4"`` → ``"4.3.2.1.in-addr.arpa"`` ``"2001:db8::1"`` → ``"...ip6.arpa"`` Raises ``ValueError`` for invalid IP address strings. """ addr = ipaddress.ip_address(ip) if isinstance(addr, ipaddress.IPv4Address): parts = ip.split(".") return ".".join(reversed(parts)) + ".in-addr.arpa" # IPv6: expand, strip colons, reverse nibbles expanded = addr.exploded.replace(":", "") return ".".join(reversed(expanded)) + ".ip6.arpa" # Well-known DKIM selectors tried when no selectors are configured COMMON_DKIM_SELECTORS: List[str] = [ "default", "google", "mail", "selector1", "selector2", "dkim", "k1", "key1", "mta", "email", "smtp", "s1", "s2", "pm", "mandrill", "sendgrid", ] # Seconds to wait for a single DNS query before giving up DNS_TIMEOUT: float = 5.0 @dataclass class DomainDNSResult: """Aggregated DNS authentication record results for one domain.""" dmarc: bool = False dmarc_record: Optional[str] = None spf: bool = False spf_record: Optional[str] = None dkim: bool = False # All selectors that resolved to a valid DKIM record (may be multiple) dkim_selectors: List[str] = field(default_factory=list) dkim_record: Optional[str] = None # Track which selectors were tried so callers can surface this information selectors_checked: List[str] = field(default_factory=list) class BaseDNSProvider(ABC): """ Abstract base class for DNS providers. Subclasses implement ``lookup_txt`` and inherit the higher-level helper methods for DMARC, SPF, and DKIM checks so that provider-specific differences stay confined to a single method. """ @abstractmethod async def lookup_txt(self, name: str) -> List[str]: """Return TXT record strings for *name*. Raises ``LookupError`` on failure (NXDOMAIN, timeout, network error etc.). Returns an empty list when the name exists but has no TXT records. """ # ------------------------------------------------------------------ # High-level record checks built on top of lookup_txt # ------------------------------------------------------------------ async def check_dmarc(self, domain: str) -> Tuple[bool, Optional[str]]: """Return *(found, record_string)* for the domain's DMARC TXT record.""" try: records = await self.lookup_txt(f"_dmarc.{domain}") for record in records: if record.lower().startswith("v=dmarc1"): return True, record except LookupError as exc: logger.debug("DMARC lookup failed for %s: %s", _sanitize_for_log(domain), exc) return False, None async def check_spf(self, domain: str) -> Tuple[bool, Optional[str]]: """Return *(found, record_string)* for the domain's SPF TXT record.""" try: records = await self.lookup_txt(domain) for record in records: if record.lower().startswith("v=spf1"): return True, record except LookupError as exc: logger.debug("SPF lookup failed for %s: %s", _sanitize_for_log(domain), exc) return False, None async def lookup_ptr(self, ip: str) -> Optional[str]: """Return the PTR (reverse DNS) hostname for *ip*, or ``None`` if unavailable. The base implementation always returns ``None``. Concrete providers override this to perform an actual DNS PTR lookup so that existing test doubles (which only implement ``lookup_txt``) keep working without modification. """ return None async def check_dkim( self, domain: str, selectors: List[str] ) -> Tuple[bool, List[str], Optional[str]]: """Return *(found, matching_selectors, first_record_string)* for all working DKIM selectors. All selectors in *selectors* are checked and every one that resolves to a valid DKIM TXT record is collected. The boolean is ``True`` when at least one selector resolved. *first_record_string* is the record text for the first matching selector (useful for display purposes). """ matching_selectors: List[str] = [] first_record: Optional[str] = None for selector in selectors: try: records = await self.lookup_txt(f"{selector}._domainkey.{domain}") for record in records: if "v=dkim1" in record.lower() or "p=" in record.lower(): matching_selectors.append(selector) if first_record is None: first_record = record break except LookupError as exc: logger.debug( "DKIM lookup failed for selector=%s domain=%s: %s", selector, _sanitize_for_log(domain), exc, ) return bool(matching_selectors), matching_selectors, first_record async def check_domain( self, domain: str, selectors: Optional[List[str]] = None ) -> DomainDNSResult: """Run DMARC, SPF, and DKIM checks concurrently for *domain*. *selectors* are tried first; common well-known selectors are appended as a fallback so that a domain with no explicitly configured selectors can still be verified. """ # Deduplicate while preserving priority order (manual selectors first) all_selectors: List[str] = list(selectors or []) for s in COMMON_DKIM_SELECTORS: if s not in all_selectors: all_selectors.append(s) dmarc_coro = self.check_dmarc(domain) spf_coro = self.check_spf(domain) dkim_coro = self.check_dkim(domain, all_selectors) (dmarc_ok, dmarc_record), (spf_ok, spf_record), (dkim_ok, dkim_sels, dkim_record) = ( await asyncio.gather(dmarc_coro, spf_coro, dkim_coro) ) return DomainDNSResult( dmarc=dmarc_ok, dmarc_record=dmarc_record, spf=spf_ok, spf_record=spf_record, dkim=dkim_ok, dkim_selectors=dkim_sels, dkim_record=dkim_record, selectors_checked=all_selectors, ) class SystemDNSProvider(BaseDNSProvider): """DNS provider that resolves records via the system resolver using dnspython.""" async def lookup_txt(self, name: str) -> List[str]: """Resolve TXT records using dnspython's async resolver.""" # Import here so the module can be imported even if dnspython is absent # (tests can mock this method directly without needing the library). import dns.asyncresolver # type: ignore[import] import dns.exception # type: ignore[import] try: answers = await dns.asyncresolver.resolve( name, "TXT", lifetime=DNS_TIMEOUT, raise_on_no_answer=False ) result: List[str] = [] if answers: for rdata in answers: for string in rdata.strings: result.append(string.decode("utf-8", errors="replace")) return result except dns.exception.DNSException as exc: raise LookupError(f"TXT lookup failed for {name}: {exc}") from exc async def lookup_ptr(self, ip: str) -> Optional[str]: """Resolve a PTR record for *ip* via the system resolver.""" import dns.asyncresolver # type: ignore[import] import dns.exception # type: ignore[import] try: ptr_name = _ip_to_arpa_name(ip) answers = await dns.asyncresolver.resolve( ptr_name, "PTR", lifetime=DNS_TIMEOUT, raise_on_no_answer=False ) if answers: for rdata in answers: return str(rdata).rstrip(".") except (dns.exception.DNSException, ValueError): pass return None class CloudflareDNSProvider(BaseDNSProvider): """DNS provider using Cloudflare DoH and, when configured, the REST API. Public DNS lookups continue to use Cloudflare's DNS-over-HTTPS endpoint. If an API token is supplied, the provider can also discover account zones and read managed DNS records directly from the Cloudflare REST API. """ #: Cloudflare DNS-over-HTTPS endpoint (JSON wire format) CLOUDFLARE_DOH_URL: str = "https://cloudflare-dns.com/dns-query" #: Cloudflare REST API base URL CLOUDFLARE_API_BASE: str = "https://api.cloudflare.com/client/v4" def __init__( self, api_token: Optional[str] = None, zone_id: Optional[str] = None, ) -> None: """ Parameters ---------- api_token: Cloudflare API token. Required for zone discovery and managed DNS record reads; not needed for read-only DoH lookups. zone_id: Optional Cloudflare zone identifier used as a preferred zone. """ self.api_token = api_token self.zone_id = zone_id def _auth_headers(self) -> Dict[str, str]: if not self.api_token: raise LookupError("Cloudflare API token is not configured") return { "Authorization": f"Bearer {self.api_token}", "Accept": "application/json", } async def _api_get( self, path: str, *, params: Optional[Dict[str, Any]] = None, ) -> Dict[str, Any]: """Call Cloudflare's REST API and return the decoded response.""" import httpx # type: ignore[import] url = f"{self.CLOUDFLARE_API_BASE}{path}" try: async with httpx.AsyncClient() as client: response = await client.get( url, params=params, headers=self._auth_headers(), timeout=DNS_TIMEOUT, ) response.raise_for_status() data = response.json() except (httpx.RequestError, httpx.HTTPStatusError, httpx.TimeoutException) as exc: raise LookupError(f"Cloudflare API request failed for {path}: {exc}") from exc if not data.get("success", False): errors = data.get("errors") or [] message = "; ".join(str(error.get("message", error)) for error in errors[:3]) raise LookupError(message or f"Cloudflare API request failed for {path}") return data async def list_zones(self) -> List[Dict[str, Any]]: """Return all zones visible to the configured Cloudflare API token.""" zones: List[Dict[str, Any]] = [] page = 1 while True: data = await self._api_get( "/zones", params={"page": page, "per_page": 50, "status": "active"}, ) result = data.get("result") or [] if not isinstance(result, list): return zones zones.extend(result) info = data.get("result_info") or {} total_pages = int(info.get("total_pages") or 1) if page >= total_pages: return zones page += 1 async def find_zone_for_domain(self, domain: str) -> Optional[Dict[str, Any]]: """Return the best matching Cloudflare zone for *domain*.""" zones = await self.list_zones() domain_lc = domain.rstrip(".").lower() matches = [ zone for zone in zones if isinstance(zone.get("name"), str) and ( domain_lc == zone["name"].lower() or domain_lc.endswith(f".{zone['name'].lower()}") ) ] if not matches: return None return sorted(matches, key=lambda zone: len(zone.get("name", "")), reverse=True)[0] async def list_dns_records( self, *, zone_id: Optional[str] = None, name: Optional[str] = None, record_type: Optional[str] = None, ) -> List[Dict[str, Any]]: """Return DNS records for a Cloudflare zone.""" resolved_zone_id = zone_id or self.zone_id if not resolved_zone_id: raise LookupError("Cloudflare zone ID is not configured") records: List[Dict[str, Any]] = [] page = 1 while True: params: Dict[str, Any] = {"page": page, "per_page": 100} if name: params["name"] = name if record_type: params["type"] = record_type data = await self._api_get( f"/zones/{resolved_zone_id}/dns_records", params=params, ) result = data.get("result") or [] if not isinstance(result, list): return records records.extend(result) info = data.get("result_info") or {} total_pages = int(info.get("total_pages") or 1) if page >= total_pages: return records page += 1 async def lookup_txt(self, name: str) -> List[str]: """Resolve TXT records via Cloudflare's DoH endpoint (JSON format).""" import httpx # type: ignore[import] params = {"name": name, "type": "TXT"} headers = {"Accept": "application/dns-json"} try: async with httpx.AsyncClient() as client: response = await client.get( self.CLOUDFLARE_DOH_URL, params=params, headers=headers, timeout=DNS_TIMEOUT, ) response.raise_for_status() data = response.json() records: List[str] = [] for answer in data.get("Answer", []): if answer.get("type") == 16: # TXT record type # Cloudflare wraps TXT values in double-quotes txt = answer.get("data", "").strip('"') records.append(txt) return records except (httpx.RequestError, httpx.HTTPStatusError, httpx.TimeoutException) as exc: raise LookupError(f"Cloudflare DoH lookup failed for {name}: {exc}") from exc async def lookup_ptr(self, ip: str) -> Optional[str]: """Resolve a PTR record for *ip* via Cloudflare's DoH endpoint.""" import httpx # type: ignore[import] try: ptr_name = _ip_to_arpa_name(ip) except ValueError: return None params = {"name": ptr_name, "type": "PTR"} headers = {"Accept": "application/dns-json"} try: async with httpx.AsyncClient() as client: response = await client.get( self.CLOUDFLARE_DOH_URL, params=params, headers=headers, timeout=DNS_TIMEOUT, ) response.raise_for_status() data = response.json() for answer in data.get("Answer", []): if answer.get("type") == 12: # PTR record type return answer.get("data", "").rstrip(".") except (httpx.RequestError, httpx.HTTPStatusError, httpx.TimeoutException): pass return None def _decrypt_setting_value(value: Optional[str]) -> Optional[str]: if not value: return value try: from app.core.credential_encryption import decrypt_secret return decrypt_secret(value) except Exception: return value def _setting_value(db: Any, key: str) -> Optional[str]: if db is None: return None try: from app.models.setting import Setting row = db.query(Setting).filter(Setting.key == key).first() return row.value if row is not None else None except Exception: return None def get_default_provider(db: Any = None) -> BaseDNSProvider: """Return the configured default DNS provider.""" resolver = (_setting_value(db, "dns.resolver") or "").strip().lower() if resolver == "cloudflare": from app.core.config import get_settings settings = get_settings() api_token = _decrypt_setting_value(_setting_value(db, "cloudflare.api_token")) zone_id = _setting_value(db, "cloudflare.zone_id") return CloudflareDNSProvider( api_token=api_token or settings.CLOUDFLARE_API_TOKEN, zone_id=zone_id or settings.CLOUDFLARE_ZONE_ID, ) return SystemDNSProvider() def extract_dmarc_policy(dmarc_record: Optional[str]) -> Optional[str]: """Parse the *p=* tag from a DMARC TXT record string. Returns the policy value (e.g. ``"none"``, ``"quarantine"``, ``"reject"``) or ``None`` if the record is absent or unparsable. """ if not dmarc_record: return None for part in dmarc_record.split(";"): part = part.strip() if part.lower().startswith("p="): return part[2:].strip().lower() return None