Add DNS over HTTPS and DNS over TLS support via the nameservers option (#886)

Each entry in the existing nameservers option now selects its own
transport (#880): an IP address means plain DNS on port 53 exactly as
before, an https:// URL means DoH, and tls://ip[:port][#hostname] means
DoT, with the optional #hostname naming the TLS certificate identity
(systemd-resolved syntax). Forms can be mixed in one list, and no new
configuration option is involved.

DoH queries go through a shared per-process httpx client passed to
dns.query.https as session=, which is what makes them honor HTTP_PROXY/
HTTPS_PROXY/NO_PROXY and SSL_CERT_FILE — the motivating proxy-only
corporate network case. dnspython's stock DoH path cannot do this: it
builds its httpx client around a custom transport, and httpx only reads
proxy environment variables when no transport is supplied
(allow_env_proxies = trust_env and transport is None). The client is
rebuilt when the PID changes so fork-based worker pools never share a
parent's sockets.

The dnspython requirement becomes dnspython[doh]>=2.7.0 — the extra
supplies the httpx/h2 floors DoH needs, and 2.7.0 is the floor verified
against the dns.nameserver and dns.query.https(session=...) APIs used.

The startup DNS pre-flight check now exercises whichever transports are
configured, so a malformed DoH/DoT entry raises ConfigurationError
before any mailbox work begins. Malformed tls:// entries — including
the plausible slash-for-# typo tls://9.9.9.9/dns.quad9.net, which would
otherwise silently drop the certificate identity — are rejected at
configuration time naming the entry.

Verified live: DoH A/PTR queries against Cloudflare and DoT against
Quad9 (tls://9.9.9.9#dns.quad9.net), plus a full CLI run over a sample
report with encrypted-DNS-only nameservers.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Sean Whalen
2026-08-28 15:03:51 -04:00
committed by GitHub
co-authored by Claude Fable 5
parent ffdb220af9
commit cd864f4cfc
7 changed files with 538 additions and 7 deletions
+53
View File
@@ -5747,6 +5747,59 @@ class TestParseConfigGeneral(unittest.TestCase):
self.assertEqual(opts.dns_retries, 2)
self.assertEqual(opts.nameservers, ["1.1.1.1", "8.8.8.8"])
def test_general_nameservers_accept_doh_and_dot_entries(self):
"""A nameservers list may mix DNS over HTTPS URLs and DNS over TLS
entries with plain IP addresses (issue #880); _parse_config splits
and strips them like any other list value, and hands them to the DNS
pre-flight check (mocked here so no network is needed)."""
from parsedmarc.cli import _parse_config
cp = _config_with(
"general",
{
"dns_test_address": "1.1.1.1",
"dns_timeout": "5.0",
"nameservers": (
"https://cloudflare-dns.com/dns-query, tls://9.9.9.9#dns.quad9.net"
),
},
)
opts = _opts()
with patch(
"parsedmarc.cli.get_reverse_dns", return_value="one.one.one.one"
) as mock_reverse_dns:
_parse_config(cp, opts)
self.assertEqual(
opts.nameservers,
["https://cloudflare-dns.com/dns-query", "tls://9.9.9.9#dns.quad9.net"],
)
self.assertEqual(
mock_reverse_dns.call_args.kwargs["nameservers"], opts.nameservers
)
def test_general_nameservers_malformed_dot_entry_fails_pre_flight(self):
"""A malformed DNS over TLS nameservers entry raises a
ConfigurationError at startup, before any mailbox work begins. No
mocking is needed: the entry mapper rejects tls://not-an-ip with a
ValueError before any query is sent (the pre-flight path passes no
cache, so nothing can short-circuit it), and _parse_config wraps
any pre-flight failure in a ConfigurationError."""
from parsedmarc.cli import ConfigurationError, _parse_config
cp = _config_with(
"general",
{
"dns_test_address": "1.1.1.1",
"dns_timeout": "2.0",
"nameservers": "tls://not-an-ip",
},
)
opts = _opts()
with self.assertRaises(ConfigurationError) as ctx:
_parse_config(cp, opts)
self.assertIn("pre-flight", str(ctx.exception))
self.assertIn("tls://not-an-ip", str(ctx.exception))
def test_general_normalize_timespan_threshold(self):
from parsedmarc.cli import _parse_config