feat: add rDNS hostname, SPF fix hints and auth tooltips to domain sources view

- dns_resolver.py: add _ip_to_arpa_name() helper and lookup_ptr() to
  BaseDNSProvider (no-op default), SystemDNSProvider (dnspython PTR),
  and CloudflareDNSProvider (DoH PTR type=12)
- domains.py: extend SourceEntry with hostname + spf_fix_hint; update
  get_domain_sources to run async PTR lookups and generate ip4:/ip6:
  SPF mechanism hints for failing IPs
- domain_details.html: show rDNS hostname below IP in sources table;
  add DaisyUI tooltip explaining each auth result; add "Fix SPF" popover
  with copy-paste mechanism for IPs that fail SPF
- tests: 15 new tests covering _ip_to_arpa_name, SystemDNSProvider/
  CloudflareDNSProvider PTR lookup, and sources endpoint hostname +
  fix-hint fields

Agent-Logs-Url: https://github.com/christianlouis/dmarq/sessions/9eaa7749-047c-46bd-8bc0-2851ea02ffe4

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot]
2026-03-29 22:44:18 +00:00
parent daa38b8337
commit 30007d21d6
5 changed files with 416 additions and 22 deletions
+74 -1
View File
@@ -1,5 +1,5 @@
"""
DNS resolver service for DMARC, SPF, and DKIM record lookups.
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
@@ -7,6 +7,7 @@ future Cloudflare integration.
"""
import asyncio
import ipaddress
import logging
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
@@ -20,6 +21,23 @@ def _sanitize_for_log(value: str) -> str:
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",
@@ -103,6 +121,16 @@ class BaseDNSProvider(ABC):
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, Optional[str], Optional[str]]:
@@ -180,6 +208,23 @@ class SystemDNSProvider(BaseDNSProvider):
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's DNS-over-HTTPS (DoH) endpoint.
@@ -246,6 +291,34 @@ class CloudflareDNSProvider(BaseDNSProvider):
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 get_default_provider() -> BaseDNSProvider:
"""Return the default DNS provider (system resolver).