diff options
author | Bob Halley <halley@dnspython.org> | 2023-04-07 06:44:22 -0700 |
---|---|---|
committer | GitHub <noreply@github.com> | 2023-04-07 06:44:22 -0700 |
commit | 7017bb55565df81aec30a5e9f86b2b193b0315e4 (patch) | |
tree | 5cbc2bcf91eb6ef3945350a8a6e569d56937599f | |
parent | 579974f5522fdce4a5a8a125daa25cc4db23c4b3 (diff) | |
download | dnspython-7017bb55565df81aec30a5e9f86b2b193b0315e4.tar.gz |
Add basic DDR support. (#919)
* Add basic DDR support.
-rw-r--r-- | dns/_asyncbackend.py | 3 | ||||
-rw-r--r-- | dns/_asyncio_backend.py | 6 | ||||
-rw-r--r-- | dns/_ddr.py | 155 | ||||
-rw-r--r-- | dns/_trio_backend.py | 11 | ||||
-rw-r--r-- | dns/asyncresolver.py | 42 | ||||
-rw-r--r-- | dns/nameserver.py | 6 | ||||
-rw-r--r-- | dns/rdtypes/svcbbase.py | 1 | ||||
-rw-r--r-- | dns/resolver.py | 45 | ||||
-rw-r--r-- | doc/async-resolver-functions.rst | 1 | ||||
-rw-r--r-- | doc/resolver-functions.rst | 1 | ||||
-rw-r--r-- | doc/whatsnew.rst | 6 | ||||
-rw-r--r-- | examples/ddr.py | 42 | ||||
-rw-r--r-- | tests/test_ddr.py | 43 |
13 files changed, 361 insertions, 1 deletions
diff --git a/dns/_asyncbackend.py b/dns/_asyncbackend.py index 7fd4926..cebcbdf 100644 --- a/dns/_asyncbackend.py +++ b/dns/_asyncbackend.py @@ -35,6 +35,9 @@ class Socket: # pragma: no cover async def getsockname(self): raise NotImplementedError + async def getpeercert(self, timeout): + raise NotImplementedError + async def __aenter__(self): return self diff --git a/dns/_asyncio_backend.py b/dns/_asyncio_backend.py index bce6e4d..4a26d7c 100644 --- a/dns/_asyncio_backend.py +++ b/dns/_asyncio_backend.py @@ -85,6 +85,9 @@ class DatagramSocket(dns._asyncbackend.DatagramSocket): async def getsockname(self): return self.transport.get_extra_info("sockname") + async def getpeercert(self, timeout): + raise NotImplementedError + class StreamSocket(dns._asyncbackend.StreamSocket): def __init__(self, af, reader, writer): @@ -112,6 +115,9 @@ class StreamSocket(dns._asyncbackend.StreamSocket): async def getsockname(self): return self.writer.get_extra_info("sockname") + async def getpeercert(self, timeout): + return self.writer.get_extra_info("peercert") + try: import anyio diff --git a/dns/_ddr.py b/dns/_ddr.py new file mode 100644 index 0000000..c212489 --- /dev/null +++ b/dns/_ddr.py @@ -0,0 +1,155 @@ +# Copyright (C) Dnspython Contributors, see LICENSE for text of ISC license +# +# Support for Discovery of Designated Resolvers + +from urllib.parse import urlparse +import socket +import time + +import dns.asyncbackend +import dns.inet +import dns.name +import dns.nameserver +import dns.query +import dns.rdtypes.svcbbase + + +# The special name of the local resolver when using DDR +_local_resolver_name = dns.name.from_text("_dns.resolver.arpa") + + +# +# Processing is split up into I/O independent and I/O dependent parts to +# make supporting sync and async versions easy. +# + + +class _SVCBInfo: + def __init__(self, bootstrap_address, port, hostname, nameservers): + self.bootstrap_address = bootstrap_address + self.port = port + self.hostname = hostname + self.nameservers = nameservers + + def ddr_check_certificate(self, cert): + """Verify that the _SVCBInfo's address is in the cert's subjectAltName (SAN)""" + for name, value in cert["subjectAltName"]: + if name == "IP Address" and value == self.bootstrap_address: + return True + return False + + def make_tls_context(self): + ssl = dns.query.ssl + ctx = ssl.create_default_context() + ctx.minimum_version = ssl.TLSVersion.TLSv1_2 + return ctx + + def ddr_tls_check_sync(self, lifetime): + ctx = self.make_tls_context() + expiration = time.time() + lifetime + with socket.create_connection( + (self.bootstrap_address, self.port), lifetime + ) as s: + with ctx.wrap_socket(s, server_hostname=self.hostname) as ts: + ts.settimeout(dns.query._remaining(expiration)) + ts.do_handshake() + cert = ts.getpeercert() + return self.ddr_check_certificate(cert) + + async def ddr_tls_check_async(self, lifetime, backend=None): + if backend is None: + backend = dns.asyncbackend.get_default_backend() + ctx = self.make_tls_context() + expiration = time.time() + lifetime + async with await backend.make_socket( + dns.inet.af_for_address(self.bootstrap_address), + socket.SOCK_STREAM, + 0, + None, + (self.bootstrap_address, self.port), + lifetime, + ctx, + self.hostname, + ) as ts: + cert = await ts.getpeercert(dns.query._remaining(expiration)) + return self.ddr_check_certificate(cert) + + +def _extract_nameservers_from_svcb(answer): + bootstrap_address = answer.nameserver + if not dns.inet.is_address(bootstrap_address): + return [] + infos = [] + for rr in answer.rrset.processing_order(): + nameservers = [] + param = rr.params.get(dns.rdtypes.svcbbase.ParamKey.ALPN) + if param is None: + continue + alpns = set(param.ids) + host = rr.target.to_text(omit_final_dot=True) + port = None + param = rr.params.get(dns.rdtypes.svcbbase.ParamKey.PORT) + if param is not None: + port = param.port + # For now we ignore address hints and address resolution and always use the + # bootstrap address + if b"h2" in alpns: + param = rr.params.get(dns.rdtypes.svcbbase.ParamKey.DOHPATH) + if param is None or not param.value.endswith(b"{?dns}"): + continue + path = param.value[:-6].decode() + if not path.startswith("/"): + path = "/" + path + if port is None: + port = 443 + url = f"https://{host}:{port}{path}" + # check the URL + try: + urlparse(url) + nameservers.append(dns.nameserver.DoHNameserver(url, bootstrap_address)) + except Exception: + # continue processing other ALPN types + pass + if b"dot" in alpns: + if port is None: + port = 853 + nameservers.append( + dns.nameserver.DoTNameserver(bootstrap_address, port, host) + ) + if b"doq" in alpns: + if port is None: + port = 853 + nameservers.append( + dns.nameserver.DoQNameserver(bootstrap_address, port, True, host) + ) + if len(nameservers) > 0: + infos.append(_SVCBInfo(bootstrap_address, port, host, nameservers)) + return infos + + +def _get_nameservers_sync(answer, lifetime): + """Return a list of TLS-validated resolver nameservers extracted from an SVCB + answer.""" + nameservers = [] + infos = _extract_nameservers_from_svcb(answer) + for info in infos: + try: + if info.ddr_tls_check_sync(lifetime): + nameservers.extend(info.nameservers) + except Exception: + pass + return nameservers + + +async def _get_nameservers_async(answer, lifetime): + """Return a list of TLS-validated resolver nameservers extracted from an SVCB + answer.""" + nameservers = [] + infos = _extract_nameservers_from_svcb(answer) + for info in infos: + try: + if await info.ddr_tls_check_async(lifetime): + nameservers.extend(info.nameservers) + except Exception: + pass + return nameservers diff --git a/dns/_trio_backend.py b/dns/_trio_backend.py index 3652195..7619923 100644 --- a/dns/_trio_backend.py +++ b/dns/_trio_backend.py @@ -50,6 +50,9 @@ class DatagramSocket(dns._asyncbackend.DatagramSocket): async def getsockname(self): return self.socket.getsockname() + async def getpeercert(self, timeout): + raise NotImplementedError + class StreamSocket(dns._asyncbackend.StreamSocket): def __init__(self, family, stream, tls=False): @@ -82,6 +85,14 @@ class StreamSocket(dns._asyncbackend.StreamSocket): else: return self.stream.socket.getsockname() + async def getpeercert(self, timeout): + if self.tls: + with _maybe_timeout(timeout): + await self.stream.do_handshake() + return self.stream.getpeercert() + else: + raise NotImplementedError + try: import httpx diff --git a/dns/asyncresolver.py b/dns/asyncresolver.py index 7b18b32..aa8af7c 100644 --- a/dns/asyncresolver.py +++ b/dns/asyncresolver.py @@ -24,6 +24,7 @@ import time import dns.asyncbackend import dns.asyncquery +import dns._ddr import dns.exception import dns.name import dns.query @@ -226,6 +227,37 @@ class Resolver(dns.resolver.BaseResolver): canonical_name = e.canonical_name return canonical_name + async def try_ddr(self, lifetime: float = 5.0) -> None: + """Try to update the resolver's nameservers using Discovery of Designated + Resolvers (DDR). If successful, the resolver will subsequently use + DNS-over-HTTPS or DNS-over-TLS for future queries. + + *lifetime*, a float, is the maximum time to spend attempting DDR. The default + is 5 seconds. + + If the SVCB query is successful and results in a non-empty list of nameservers, + then the resolver's nameservers are set to the returned servers in priority + order. + + The current implementation does not use any address hints from the SVCB record, + nor does it resolve addresses for the SCVB target name, rather it assumes that + the bootstrap nameserver will always be one of the addresses and uses it. + A future revision to the code may offer fuller support. The code verifies that + the bootstrap nameserver is in the Subject Alternative Name field of the + TLS certficate. + """ + try: + expiration = time.time() + lifetime + answer = await self.resolve( + dns._ddr._local_resolver_name, "svcb", lifetime=lifetime + ) + timeout = dns.query._remaining(expiration) + nameservers = await dns._ddr._get_nameservers_async(answer, timeout) + if len(nameservers) > 0: + self.nameservers = nameservers + except Exception: + pass + default_resolver = None @@ -318,6 +350,16 @@ async def canonical_name(name: Union[dns.name.Name, str]) -> dns.name.Name: return await get_default_resolver().canonical_name(name) +async def try_ddr(timeout: float = 5.0) -> None: + """Try to update the default resolver's nameservers using Discovery of Designated + Resolvers (DDR). If successful, the resolver will subsequently use + DNS-over-HTTPS or DNS-over-TLS for future queries. + + See :py:func:`dns.resolver.Resolver.try_ddr` for more information. + """ + return await get_default_resolver().try_ddr(timeout) + + async def zone_for_name( name: Union[dns.name.Name, str], rdclass: dns.rdataclass.RdataClass = dns.rdataclass.IN, diff --git a/dns/nameserver.py b/dns/nameserver.py index a672771..b082465 100644 --- a/dns/nameserver.py +++ b/dns/nameserver.py @@ -16,6 +16,9 @@ class Nameserver: def __str__(self): raise NotImplementedError + def kind(self) -> str: + raise NotImplementedError + def is_always_max_size(self) -> bool: raise NotImplementedError @@ -161,6 +164,9 @@ class DoHNameserver(Nameserver): self.url = url self.bootstrap_address = bootstrap_address + def kind(self): + return "DoH" + def is_always_max_size(self) -> bool: return True diff --git a/dns/rdtypes/svcbbase.py b/dns/rdtypes/svcbbase.py index 8d6fb1c..ba5b53d 100644 --- a/dns/rdtypes/svcbbase.py +++ b/dns/rdtypes/svcbbase.py @@ -34,6 +34,7 @@ class ParamKey(dns.enum.IntEnum): IPV4HINT = 4 ECH = 5 IPV6HINT = 6 + DOHPATH = 7 @classmethod def _maximum(cls): diff --git a/dns/resolver.py b/dns/resolver.py index 61d0052..5788c95 100644 --- a/dns/resolver.py +++ b/dns/resolver.py @@ -21,13 +21,14 @@ from typing import Any, Dict, Iterator, List, Optional, Tuple, Union from urllib.parse import urlparse import contextlib +import random import socket import sys import threading import time -import random import warnings +import dns._ddr import dns.exception import dns.edns import dns.flags @@ -41,6 +42,7 @@ import dns.query import dns.rcode import dns.rdataclass import dns.rdatatype +import dns.rdtypes.svcbbase import dns.reversename import dns.tsig @@ -1486,6 +1488,37 @@ class Resolver(BaseResolver): # pylint: enable=redefined-outer-name + def try_ddr(self, lifetime: float = 5.0) -> None: + """Try to update the resolver's nameservers using Discovery of Designated + Resolvers (DDR). If successful, the resolver will subsequently use + DNS-over-HTTPS or DNS-over-TLS for future queries. + + *lifetime*, a float, is the maximum time to spend attempting DDR. The default + is 5 seconds. + + If the SVCB query is successful and results in a non-empty list of nameservers, + then the resolver's nameservers are set to the returned servers in priority + order. + + The current implementation does not use any address hints from the SVCB record, + nor does it resolve addresses for the SCVB target name, rather it assumes that + the bootstrap nameserver will always be one of the addresses and uses it. + A future revision to the code may offer fuller support. The code verifies that + the bootstrap nameserver is in the Subject Alternative Name field of the + TLS certficate. + """ + try: + expiration = time.time() + lifetime + answer = self.resolve( + dns._ddr._local_resolver_name, "SVCB", lifetime=lifetime + ) + timeout = dns.query._remaining(expiration) + nameservers = dns._ddr._get_nameservers_sync(answer, timeout) + if len(nameservers) > 0: + self.nameservers = nameservers + except Exception: + pass + #: The default resolver. default_resolver: Optional[Resolver] = None @@ -1608,6 +1641,16 @@ def canonical_name(name: Union[dns.name.Name, str]) -> dns.name.Name: return get_default_resolver().canonical_name(name) +def try_ddr(lifetime: float = 5.0) -> None: + """Try to update the default resolver's nameservers using Discovery of Designated + Resolvers (DDR). If successful, the resolver will subsequently use + DNS-over-HTTPS or DNS-over-TLS for future queries. + + See :py:func:`dns.resolver.Resolver.try_ddr` for more information. + """ + return get_default_resolver().try_ddr(lifetime) + + def zone_for_name( name: Union[dns.name.Name, str], rdclass: dns.rdataclass.RdataClass = dns.rdataclass.IN, diff --git a/doc/async-resolver-functions.rst b/doc/async-resolver-functions.rst index 5d128fe..c79d581 100644 --- a/doc/async-resolver-functions.rst +++ b/doc/async-resolver-functions.rst @@ -7,6 +7,7 @@ Asynchronous Resolver Functions .. autofunction:: dns.asyncresolver.resolve_address .. autofunction:: dns.asyncresolver.resolve_name .. autofunction:: dns.asyncresolver.canonical_name +.. autofunction:: dns.asyncresolver.try_ddr .. autofunction:: dns.asyncresolver.zone_for_name .. autodata:: dns.asyncresolver.default_resolver .. autofunction:: dns.asyncresolver.get_default_resolver diff --git a/doc/resolver-functions.rst b/doc/resolver-functions.rst index edb136c..0399a0b 100644 --- a/doc/resolver-functions.rst +++ b/doc/resolver-functions.rst @@ -7,6 +7,7 @@ Resolver Functions and The Default Resolver .. autofunction:: dns.resolver.resolve_address .. autofunction:: dns.resolver.resolve_name .. autofunction:: dns.resolver.canonical_name +.. autofunction:: dns.resolver.try_ddr .. autofunction:: dns.resolver.zone_for_name .. autofunction:: dns.resolver.query .. autodata:: dns.resolver.default_resolver diff --git a/doc/whatsnew.rst b/doc/whatsnew.rst index d6502cb..de5a2fc 100644 --- a/doc/whatsnew.rst +++ b/doc/whatsnew.rst @@ -20,6 +20,12 @@ What's New in dnspython * DNSSEC zone signing with NSEC records is now supported. Thank you very much (again!) Jakob Schlyter! +* The resolver and async resolver now have the ``try_ddr()`` method, which will try to + use Discovery of Designated Resolvers (DDR) to upgrade the connection from the stub + resolver to the recursive server so that it uses DNS-over-HTTPS, DNS-over-TLS, or + DNS-over-QUIC. This feature is currently experimental as the standard is still in + draft stage, although the DDR has been deployed already. + * Curio support has been removed. 2.3.0 diff --git a/examples/ddr.py b/examples/ddr.py new file mode 100644 index 0000000..a8ecd9a --- /dev/null +++ b/examples/ddr.py @@ -0,0 +1,42 @@ +#!/usr/bin/env python3 + +# Using Discovery of Designated Resolvers (synchronous I/O) + +import dns.resolver + +res = dns.resolver.Resolver(configure=False) +res.nameservers = ["1.1.1.1"] +# Invoke try_ddr() to attempt to upgrade the connection via DDR +res.try_ddr() +# Do a sample resolution +for rr in res.resolve("www.google.com", "A"): + print(rr.address) +# Note that the nameservers have been upgraded +print(res.nameservers) + + +# Using Discovery of Designated Resolvers (asynchronous I/O) + +# We show using asyncio, but if you comment out asyncio lines +# and uncomment the trio lines, it will work with trio too. + +import asyncio + +# import trio + +import dns.asyncresolver + + +async def amain(): + res = dns.asyncresolver.Resolver(configure=False) + res.nameservers = ["8.8.8.8"] + await res.try_ddr() + + for rr in await res.resolve("www.google.com", "A"): + print(rr.address) + + print(res.nameservers) + + +asyncio.run(amain()) +# trio.run(amain) diff --git a/tests/test_ddr.py b/tests/test_ddr.py new file mode 100644 index 0000000..ce38d0e --- /dev/null +++ b/tests/test_ddr.py @@ -0,0 +1,43 @@ +# Copyright (C) Dnspython Contributors, see LICENSE for text of ISC license + +import asyncio +import time + +import pytest + +import dns.asyncbackend +import dns.asyncresolver +import dns.resolver +import dns.nameserver + +import tests.util + + +@pytest.mark.skipif( + not tests.util.is_internet_reachable(), reason="Internet not reachable" +) +def test_basic_ddr_sync(): + for nameserver in ["1.1.1.1", "8.8.8.8"]: + res = dns.resolver.Resolver(configure=False) + res.nameservers = [nameserver] + res.try_ddr() + for nameserver in res.nameservers: + assert isinstance(nameserver, dns.nameserver.Nameserver) + assert nameserver.kind() != "Do53" + + +@pytest.mark.skipif( + not tests.util.is_internet_reachable(), reason="Internet not reachable" +) +def test_basic_ddr_async(): + async def run(): + dns.asyncbackend._default_backend = None + for nameserver in ["1.1.1.1", "8.8.8.8"]: + res = dns.asyncresolver.Resolver(configure=False) + res.nameservers = [nameserver] + await res.try_ddr() + for nameserver in res.nameservers: + assert isinstance(nameserver, dns.nameserver.Nameserver) + assert nameserver.kind() != "Do53" + + asyncio.run(run()) |