From daa38b8337b8d0392794ea523feac6a27ab8ca2b Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Sun, 29 Mar 2026 22:37:47 +0000
Subject: [PATCH 1/3] Initial plan
From 30007d21d6f78f80b5bf5ea9793d313b40ca7a60 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Sun, 29 Mar 2026 22:44:18 +0000
Subject: [PATCH 2/3] 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>
---
backend/app/api/api_v1/endpoints/domains.py | 48 +++++--
backend/app/services/dns_resolver.py | 75 +++++++++-
backend/app/templates/domain_details.html | 80 +++++++++--
backend/app/tests/test_dns_endpoints.py | 91 +++++++++++++
backend/app/tests/test_dns_resolver.py | 144 ++++++++++++++++++++
5 files changed, 416 insertions(+), 22 deletions(-)
diff --git a/backend/app/api/api_v1/endpoints/domains.py b/backend/app/api/api_v1/endpoints/domains.py
index f65d43d..9a1dc76 100644
--- a/backend/app/api/api_v1/endpoints/domains.py
+++ b/backend/app/api/api_v1/endpoints/domains.py
@@ -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,
)
)
diff --git a/backend/app/services/dns_resolver.py b/backend/app/services/dns_resolver.py
index c290b0d..0dd1d84 100644
--- a/backend/app/services/dns_resolver.py
+++ b/backend/app/services/dns_resolver.py
@@ -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).
diff --git a/backend/app/templates/domain_details.html b/backend/app/templates/domain_details.html
index 12d39f9..7bd23c5 100644
--- a/backend/app/templates/domain_details.html
+++ b/backend/app/templates/domain_details.html
@@ -234,18 +234,19 @@
{% call table() %}
{% call thead() %}
{% call tr() %}
- {% call th() %}Source IP{% endcall %}
+ {% call th() %}Source IP / Hostname{% endcall %}
{% call th() %}Total Emails{% endcall %}
{% call th() %}SPF{% endcall %}
{% call th() %}DKIM{% endcall %}
{% call th() %}DMARC{% endcall %}
{% call th() %}Disposition{% endcall %}
+ {% call th() %}Fix{% endcall %}
{% endcall %}
{% endcall %}
{% call tbody() %}
SPF Fix Suggestion
+ Add the following mechanism to your SPF TXT record to authorise this server:
+
+ Example:
-
@@ -253,39 +254,60 @@
{% call tr() %}
{% call td() %}
-
+
+
+
+
+
SPF Fix Suggestion
- Add the following mechanism to your SPF TXT record to authorise this server: + Add the following mechanism to your SPF TXT record to authorize this server: