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
+38 -10
View File
@@ -1,4 +1,5 @@
import asyncio
import ipaddress
import logging
from datetime import datetime, timezone
from typing import Any, Dict, List, Optional
@@ -85,6 +86,8 @@ class SourceEntry(BaseModel):
dkim: str
dmarc: str
disposition: str
hostname: Optional[str] = None
spf_fix_hint: Optional[str] = None
class DomainReportsResponse(BaseModel):
@@ -440,7 +443,8 @@ async def get_domain_sources(
days: int = Query(30, title="Number of days to look back"),
):
"""
Get sending sources for a specific domain
Get sending sources for a specific domain, including reverse-DNS hostnames
and SPF fix hints for sources that fail authentication.
"""
store = ReportStore.get_instance()
domains = store.get_domains()
@@ -454,20 +458,44 @@ async def get_domain_sources(
# Get sending sources for this domain
sources = store.get_domain_sources(domain_id, days=days)
provider = get_default_provider()
async def _safe_ptr(ip: str) -> Optional[str]:
"""Perform a PTR lookup with a short timeout; return None on any failure."""
try:
return await asyncio.wait_for(provider.lookup_ptr(ip), timeout=3.0)
except Exception:
return None
ips = [s.get("source_ip", "unknown") for s in sources]
hostnames = await asyncio.gather(*[_safe_ptr(ip) for ip in ips])
source_entries = []
for source in sources:
for source, hostname in zip(sources, hostnames):
ip = source.get("source_ip", "unknown")
spf_result = source.get("spf_result", "unknown")
dkim_result = source.get("dkim_result", "unknown")
# Build a copy-paste SPF mechanism for IPs that fail SPF
spf_fix_hint: Optional[str] = None
if spf_result == "fail":
try:
addr = ipaddress.ip_address(ip)
prefix = "ip6" if isinstance(addr, ipaddress.IPv6Address) else "ip4"
spf_fix_hint = f"{prefix}:{ip}"
except ValueError:
pass
source_entries.append(
SourceEntry(
ip=source.get("source_ip", "unknown"),
ip=ip,
count=source.get("count", 0),
spf=source.get("spf_result", "unknown"),
dkim=source.get("dkim_result", "unknown"),
dmarc=(
"pass"
if source.get("spf_result") == "pass" or source.get("dkim_result") == "pass"
else "fail"
),
spf=spf_result,
dkim=dkim_result,
dmarc=("pass" if spf_result == "pass" or dkim_result == "pass" else "fail"),
disposition=source.get("disposition", "none"),
hostname=hostname,
spf_fix_hint=spf_fix_hint,
)
)