]> git.ipfire.org Git - thirdparty/dnspython.git/commitdiff
Add basic DDR support. (#919)
authorBob Halley <halley@dnspython.org>
Fri, 7 Apr 2023 13:44:22 +0000 (06:44 -0700)
committerGitHub <noreply@github.com>
Fri, 7 Apr 2023 13:44:22 +0000 (06:44 -0700)
* Add basic DDR support.

13 files changed:
dns/_asyncbackend.py
dns/_asyncio_backend.py
dns/_ddr.py [new file with mode: 0644]
dns/_trio_backend.py
dns/asyncresolver.py
dns/nameserver.py
dns/rdtypes/svcbbase.py
dns/resolver.py
doc/async-resolver-functions.rst
doc/resolver-functions.rst
doc/whatsnew.rst
examples/ddr.py [new file with mode: 0644]
tests/test_ddr.py [new file with mode: 0644]

index 7fd4926b9b2ce9fe913950ea33bc974875f20e7c..cebcbdfd46c178b7a0c0766b442b7fed346ec85d 100644 (file)
@@ -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
 
index bce6e4d3ac10bcceec57e2885148d02e30838995..4a26d7c182d9690db4a4d7b2f04bb1db0c92e452 100644 (file)
@@ -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 (file)
index 0000000..c212489
--- /dev/null
@@ -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
index 3652195ce85beae11ea7ad09b91e1d6d295dab5a..761992301eacc46813b329d5cdd9c6d6e73ec22d 100644 (file)
@@ -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
index 7b18b32cb182d451b5684c99803a9b42a0504631..aa8af7cdfe4c052b8ded3880a58b6b66c9c5c788 100644 (file)
@@ -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,
index a6727710228ecc478bf19ed6091ac5d8930a90e0..b0824657cf23f643d879820c33aa5de969906e30 100644 (file)
@@ -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
 
index 8d6fb1c6c2b7b7476904b7ef0ef5abc7181d1411..ba5b53d2cb7d0e25d0437b2193f0aae4a3324f26 100644 (file)
@@ -34,6 +34,7 @@ class ParamKey(dns.enum.IntEnum):
     IPV4HINT = 4
     ECH = 5
     IPV6HINT = 6
+    DOHPATH = 7
 
     @classmethod
     def _maximum(cls):
index 61d00523b4bf0aa46e585bb367d808ff4d98c576..5788c95a02ae2ff3cfbb233fb073bc9f05fa1745 100644 (file)
@@ -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,
index 5d128fecd9dc48546f9388cfee882004f40d6ccd..c79d58128f4baa3f43b446fa8d8d638a2be9bcfc 100644 (file)
@@ -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
index edb136c3fdf8f2012fbfd16bbfa40b9102610e40..0399a0b97d0d63be3196ef709264a89cbd13c24b 100644 (file)
@@ -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
index d6502cb57ae55ee277336cf00fc659ee2f6c281b..de5a2fc8546bf51f57d82c79dcaade06b0bb8b88 100644 (file)
@@ -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 (file)
index 0000000..a8ecd9a
--- /dev/null
@@ -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 (file)
index 0000000..ce38d0e
--- /dev/null
@@ -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())