From b04c0f563c64a0cfaef68eb1ba61436da09e445e Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Tue, 7 Jul 2026 11:50:19 +0200 Subject: [PATCH] network: proxy DNSSEC requests if a proxy is configured and enabled adding ConfigVar NETWORK_PROXY_DOH_ENDPOINT (only used for OpenAlias/DNSSEC lookup for now), defaulting to cloudflare's DoH endpoint. --- electrum/dnssec.py | 142 ++++++++++++++++++++++++++++----- electrum/network.py | 9 ++- electrum/payment_identifier.py | 2 - electrum/simple_config.py | 6 ++ 4 files changed, 136 insertions(+), 23 deletions(-) diff --git a/electrum/dnssec.py b/electrum/dnssec.py index 2415317d71af..af79f60fa902 100644 --- a/electrum/dnssec.py +++ b/electrum/dnssec.py @@ -36,8 +36,11 @@ import dns.name import dns.asyncquery import dns.dnssec +import dns.exception import dns.message import dns.asyncresolver +import dns.resolver +import dns.rcode import dns.rdatatype import dns.rdtypes.ANY.NS import dns.rdtypes.ANY.CNAME @@ -54,14 +57,17 @@ import dns.rdtypes.IN.AAAA from .logging import get_logger -from typing import Tuple +from typing import Tuple, Optional, TYPE_CHECKING + +if TYPE_CHECKING: + from .network import ProxySettings _logger = get_logger(__name__) # hard-coded trust anchors (root KSKs) -trust_anchors = [ +TRUST_ANCHORS = [ # KSK-2017: dns.rrset.from_text('.', 1 , 'IN', 'DNSKEY', '257 3 8 AwEAAaz/tAm8yTn4Mfeh5eyI96WSVexTBAvkMgJzkKTOiW1vkIbzxeF3+/4RgWOq7HrxRixHlFlExOLAJr5emLvN7SWXgnLh4+B5xQlNVz8Og8kvArMtNROxVQuCaSnIDdD5LKyWbRd2n9WGe2R8PzgCmr3EgVLrjyBxWezF0jLHwVN8efS3rCj/EWgvIWgb9tarpVUDK/b58Da+sqqls3eNbuv7pr+eoZG+SrDK6nWeL3c6H5Apxz7LjVc1uTIdsIXxuOLYA4/ilBmSVIzuDWfdRUfhHdY6+cn8HFRm+2hM8AnXGXws9555KrUB5qihylGa8subX2Nn6UwNR1AkUTV74bU='), # KSK-2010: @@ -69,9 +75,84 @@ ] -async def _check_query(ns, sub, _type, keys) -> dns.rrset.RRset: +# public fallback nameserver, used only if the system/DHCP resolver cannot be determined +# (e.g. no /etc/resolv.conf, some mobile setups). Google Public DNS. +FALLBACK_NAMESERVER = '8.8.8.8' + + +class DNSTransport: + """Delegate that decides how a single DNS query message is sent and answered. + + The DNSSEC logic downstream is transport-agnostic: it builds query messages and + hands them to a transport.. + """ + + async def send(self, q: dns.message.Message) -> dns.message.Message: + raise NotImplementedError + + +class LocalTransport(DNSTransport): + """Plain DNS to the system (DHCP-provided) nameserver over UDP, falling back to + TCP on truncation (DNSSEC responses are large and routinely get truncated). + Used when no proxy is configured.""" + + def __init__(self, nameserver: Optional[str] = None): + self.nameserver = nameserver or _system_nameserver() + + async def send(self, q: dns.message.Message) -> dns.message.Message: + response, _used_tcp = await dns.asyncquery.udp_with_fallback(q, self.nameserver, timeout=5) + return response + + +class DoHTransport(DNSTransport): + """DNS-over-HTTPS, tunneled through the proxy via Network.async_send_http_on_proxy + (aiohttp + SOCKS, with rdns so the DoH hostname is resolved through the proxy). + Used when a proxy is configured: SOCKS cannot carry UDP, its RESOLVE command cannot + transport TXT records, nor the DNSSEC records we need for verification. We run real + DNS over an HTTPS stream through the proxy instead.""" + + def __init__(self, doh_endpoint: str): + self.doh_endpoint = doh_endpoint + + async def send(self, q: dns.message.Message) -> dns.message.Message: + from .network import Network + + async def on_finish(resp): + resp.raise_for_status() + return await resp.read() + + raw = await Network.async_send_http_on_proxy( + 'post', self.doh_endpoint, + body=q.to_wire(), + headers={ + 'content-type': 'application/dns-message', + 'accept': 'application/dns-message', + }, + on_finish=on_finish, + timeout=5, + ) + return dns.message.from_wire(raw) + + +def _system_nameserver() -> str: + # the DHCP/OS-provided resolver(s), as dnspython reads them from the system config + try: + nameservers = dns.resolver.get_default_resolver().nameservers + except Exception as e: + _logger.info(f"could not determine system nameserver, using fallback: {e!r}") + nameservers = None + return nameservers[0] if nameservers else FALLBACK_NAMESERVER + + +def _make_transport(proxy: Optional['ProxySettings']) -> DNSTransport: + if proxy is not None and proxy.enabled and proxy.doh_endpoint: + return DoHTransport(proxy.doh_endpoint) + return LocalTransport() + + +async def _check_query(transport: DNSTransport, sub, _type, keys) -> dns.rrset.RRset: q = dns.message.make_query(sub, _type, want_dnssec=True) - response = await dns.asyncquery.tcp(q, ns, timeout=5) + response = await transport.send(q) assert response.rcode() == 0, 'No answer' answer = response.answer assert len(answer) != 0, ('No DNS record found', sub, _type) @@ -88,13 +169,13 @@ async def _check_query(ns, sub, _type, keys) -> dns.rrset.RRset: return rrset -async def _get_and_validate(ns, url, _type) -> dns.rrset.RRset: +async def _get_and_validate(transport: DNSTransport, url, _type) -> dns.rrset.RRset: # get trusted root key root_rrset = None - for dnskey_rr in trust_anchors: + for dnskey_rr in TRUST_ANCHORS: try: # Check if there is a valid signature for the root dnskey - root_rrset = await _check_query(ns, '', dns.rdatatype.DNSKEY, {dns.name.root: dnskey_rr}) + root_rrset = await _check_query(transport, '', dns.rdatatype.DNSKEY, {dns.name.root: dnskey_rr}) break except dns.dnssec.ValidationFailure: # It's OK as long as one key validates @@ -109,16 +190,16 @@ async def _get_and_validate(ns, url, _type) -> dns.rrset.RRset: name = dns.name.from_text(sub) # If server is authoritative, don't fetch DNSKEY query = dns.message.make_query(sub, dns.rdatatype.NS) - response = await dns.asyncquery.udp(query, ns, 3) + response = await transport.send(query) assert response.rcode() == dns.rcode.NOERROR, "query error" rrset = response.authority[0] if len(response.authority) > 0 else response.answer[0] rr = rrset[0] if rr.rdtype == dns.rdatatype.SOA: continue # get DNSKEY (self-signed) - rrset = await _check_query(ns, sub, dns.rdatatype.DNSKEY, None) + rrset = await _check_query(transport, sub, dns.rdatatype.DNSKEY, None) # get DS (signed by parent) - ds_rrset = await _check_query(ns, sub, dns.rdatatype.DS, keys) + ds_rrset = await _check_query(transport, sub, dns.rdatatype.DS, keys) # verify that a signed DS validates DNSKEY for ds in ds_rrset: for dnskey in rrset: @@ -134,27 +215,48 @@ async def _get_and_validate(ns, url, _type) -> dns.rrset.RRset: # set key for next iteration keys = {name: rrset} # get TXT record (signed by zone) - rrset = await _check_query(ns, url, _type, keys) + rrset = await _check_query(transport, url, _type, keys) return rrset -async def query(url: str, rtype: dns.rdatatype.RdataType) -> Tuple[dns.rrset.RRset, bool]: +async def _resolve_unvalidated(transport: DNSTransport, url, rtype) -> dns.rrset.RRset: + """Plain (non-DNSSEC) resolution, over the same transport so a proxied user does + not leak DNS via the system resolver.""" + response = await transport.send(dns.message.make_query(url, rtype)) + if response.rcode() != dns.rcode.NOERROR: + raise dns.exception.DNSException(f"query error: rcode={response.rcode()}") + for rrset in response.answer: + if rrset.rdtype == rtype: + return rrset + raise dns.exception.DNSException(f"no {dns.rdatatype.to_text(rtype)} answer for {url!r}") + + +async def query( + url: str, + rtype: dns.rdatatype.RdataType, + *, + proxy: Optional['ProxySettings'] = None, +) -> Tuple[dns.rrset.RRset, bool]: """Try to do DNS resolution, including DNSSEC. 'validated' shows whether the DNSSEC checks passed. DNS is completely INSECURE without DNSSEC, so the caller must carefully consider whether the response can be used for anything if validated=False. + + The transport is chosen from the proxy settings: without a proxy we query the system + (DHCP-provided) nameserver directly over UDP/TCP; with a proxy we run DNS-over-HTTPS + tunneled through it (SOCKS cannot carry UDP, and Tor's RESOLVE extension supports + neither TXT nor DNSSEC). The DoH endpoint is taken from ProxySettings.doh_endpoint. """ - # FIXME this method is not using the network proxy. (although the proxy might not support UDP?) - # if the proxy is a Tor SOCKS proxy, we could use Tor's "RESOLVE" extension command. - # (see https://spec.torproject.org/socks-extensions.html) - # 8.8.8.8 is Google's public DNS server - nameservers = ['8.8.8.8'] - ns = nameservers[0] + if proxy is None: + from .network import Network + network = Network.get_instance() + proxy = network.proxy if network else None + transport = _make_transport(proxy) try: - out = await _get_and_validate(ns, url, rtype) + out = await _get_and_validate(transport, url, rtype) validated = True except Exception as e: log_level = logging.WARNING if isinstance(e, ImportError) else logging.INFO _logger.log(log_level, f"DNSSEC error: {repr(e)}") - out = await dns.asyncresolver.resolve(url, rtype) + out = await _resolve_unvalidated(transport, url, rtype) validated = False return out, validated diff --git a/electrum/network.py b/electrum/network.py index c74a0af36752..880a30f5ea01 100644 --- a/electrum/network.py +++ b/electrum/network.py @@ -179,6 +179,7 @@ def __init__(self): self.port = '' self.user = None self.password = None + self.doh_endpoint = '' def set_defaults(self): self.__init__() # call __init__ for default values @@ -223,7 +224,8 @@ def to_dict(self): 'host': self.host, 'port': self.port, 'user': self.user, - 'password': self.password + 'password': self.password, + 'doh_endpoint': self.doh_endpoint, } @classmethod @@ -233,6 +235,7 @@ def from_config(cls, config: 'SimpleConfig') -> 'ProxySettings': config.NETWORK_PROXY, config.NETWORK_PROXY_USER, config.NETWORK_PROXY_PASSWORD ) proxy.enabled = config.NETWORK_PROXY_ENABLED + proxy.doh_endpoint = config.NETWORK_PROXY_DOH_ENDPOINT return proxy @classmethod @@ -244,6 +247,7 @@ def from_dict(cls, d: dict) -> 'ProxySettings': proxy.port = d.get('port', proxy.port) proxy.user = d.get('user', proxy.user) proxy.password = d.get('password', proxy.password) + proxy.doh_endpoint = d.get('doh_endpoint', proxy.doh_endpoint) return proxy @classmethod @@ -265,6 +269,9 @@ async def detect_task(finished: Callable[[str | None, int | None], None]): cls.probe_fut = asyncio.run_coroutine_threadsafe(detect_task(on_finished), util.get_asyncio_loop()) def __eq__(self, other): + # note: doh_endpoint is intentionally excluded; this equality gates network + # restarts (see Network._set_proxy / set_parameters), and the DoH endpoint + # only affects DNSSEC lookups, not the network transport/interfaces. return self.enabled == other.enabled \ and self.mode == other.mode \ and self.host == other.host \ diff --git a/electrum/payment_identifier.py b/electrum/payment_identifier.py index aed2124540f0..7fe8a2f1ed7a 100644 --- a/electrum/payment_identifier.py +++ b/electrum/payment_identifier.py @@ -319,8 +319,6 @@ async def _do_resolve(self, *, on_finished: Callable[['PaymentIdentifier'], None openalias_task = asyncio.create_task(self.resolve_openalias(openalias_key)) # prefers lnurl over openalias if both are available - # FIXME resolving openalias disregards the network proxy and leaks our IP to - # the DNS server. (see dnssec.query) lnurl = lightning_address_to_url(self.emaillike) if self.emaillike else None if lnurl is not None and (lnurl_result := await try_resolve_lnurlpay(lnurl)): openalias_task.cancel() diff --git a/electrum/simple_config.py b/electrum/simple_config.py index cf76ffa3f409..bf577f03379d 100644 --- a/electrum/simple_config.py +++ b/electrum/simple_config.py @@ -641,6 +641,12 @@ def __setattr__(self, name, value): NETWORK_PROXY_USER = ConfigVar('proxy_user', default=None, type_=str) NETWORK_PROXY_PASSWORD = ConfigVar('proxy_password', default=None, type_=str) NETWORK_PROXY_ENABLED = ConfigVar('enable_proxy', default=lambda config: config.NETWORK_PROXY not in [None, "none"], type_=bool) + NETWORK_PROXY_DOH_ENDPOINT = ConfigVar( + 'proxy_doh_endpoint', default="https://cloudflare-dns.com/dns-query", type_=str, + short_desc=lambda: _('DNS-over-HTTPS server'), + long_desc=lambda: ( + _('DNS-over-HTTPS resolver used for DNSSEC lookups (e.g. OpenAlias) when a proxy is configured.')), + ) NETWORK_SERVER = ConfigVar('server', default=None, type_=str) NETWORK_NOONION = ConfigVar('noonion', default=False, type_=bool) NETWORK_OFFLINE = ConfigVar('offline', default=False, type_=bool)