about summary refs log tree commit diff
path: root/.venv/lib/python3.12/site-packages/dns
diff options
context:
space:
mode:
Diffstat (limited to '.venv/lib/python3.12/site-packages/dns')
-rw-r--r--.venv/lib/python3.12/site-packages/dns/__init__.py70
-rw-r--r--.venv/lib/python3.12/site-packages/dns/_asyncbackend.py100
-rw-r--r--.venv/lib/python3.12/site-packages/dns/_asyncio_backend.py275
-rw-r--r--.venv/lib/python3.12/site-packages/dns/_ddr.py154
-rw-r--r--.venv/lib/python3.12/site-packages/dns/_features.py95
-rw-r--r--.venv/lib/python3.12/site-packages/dns/_immutable_ctx.py76
-rw-r--r--.venv/lib/python3.12/site-packages/dns/_trio_backend.py253
-rw-r--r--.venv/lib/python3.12/site-packages/dns/asyncbackend.py101
-rw-r--r--.venv/lib/python3.12/site-packages/dns/asyncquery.py913
-rw-r--r--.venv/lib/python3.12/site-packages/dns/asyncresolver.py475
-rw-r--r--.venv/lib/python3.12/site-packages/dns/dnssec.py1247
-rw-r--r--.venv/lib/python3.12/site-packages/dns/dnssecalgs/__init__.py121
-rw-r--r--.venv/lib/python3.12/site-packages/dns/dnssecalgs/base.py89
-rw-r--r--.venv/lib/python3.12/site-packages/dns/dnssecalgs/cryptography.py68
-rw-r--r--.venv/lib/python3.12/site-packages/dns/dnssecalgs/dsa.py106
-rw-r--r--.venv/lib/python3.12/site-packages/dns/dnssecalgs/ecdsa.py97
-rw-r--r--.venv/lib/python3.12/site-packages/dns/dnssecalgs/eddsa.py70
-rw-r--r--.venv/lib/python3.12/site-packages/dns/dnssecalgs/rsa.py124
-rw-r--r--.venv/lib/python3.12/site-packages/dns/dnssectypes.py71
-rw-r--r--.venv/lib/python3.12/site-packages/dns/e164.py116
-rw-r--r--.venv/lib/python3.12/site-packages/dns/edns.py572
-rw-r--r--.venv/lib/python3.12/site-packages/dns/entropy.py130
-rw-r--r--.venv/lib/python3.12/site-packages/dns/enum.py116
-rw-r--r--.venv/lib/python3.12/site-packages/dns/exception.py169
-rw-r--r--.venv/lib/python3.12/site-packages/dns/flags.py123
-rw-r--r--.venv/lib/python3.12/site-packages/dns/grange.py72
-rw-r--r--.venv/lib/python3.12/site-packages/dns/immutable.py68
-rw-r--r--.venv/lib/python3.12/site-packages/dns/inet.py197
-rw-r--r--.venv/lib/python3.12/site-packages/dns/ipv4.py77
-rw-r--r--.venv/lib/python3.12/site-packages/dns/ipv6.py217
-rw-r--r--.venv/lib/python3.12/site-packages/dns/message.py1933
-rw-r--r--.venv/lib/python3.12/site-packages/dns/name.py1284
-rw-r--r--.venv/lib/python3.12/site-packages/dns/namedict.py109
-rw-r--r--.venv/lib/python3.12/site-packages/dns/nameserver.py363
-rw-r--r--.venv/lib/python3.12/site-packages/dns/node.py359
-rw-r--r--.venv/lib/python3.12/site-packages/dns/opcode.py117
-rw-r--r--.venv/lib/python3.12/site-packages/dns/py.typed0
-rw-r--r--.venv/lib/python3.12/site-packages/dns/query.py1665
-rw-r--r--.venv/lib/python3.12/site-packages/dns/quic/__init__.py80
-rw-r--r--.venv/lib/python3.12/site-packages/dns/quic/_asyncio.py267
-rw-r--r--.venv/lib/python3.12/site-packages/dns/quic/_common.py339
-rw-r--r--.venv/lib/python3.12/site-packages/dns/quic/_sync.py295
-rw-r--r--.venv/lib/python3.12/site-packages/dns/quic/_trio.py246
-rw-r--r--.venv/lib/python3.12/site-packages/dns/rcode.py168
-rw-r--r--.venv/lib/python3.12/site-packages/dns/rdata.py911
-rw-r--r--.venv/lib/python3.12/site-packages/dns/rdataclass.py118
-rw-r--r--.venv/lib/python3.12/site-packages/dns/rdataset.py512
-rw-r--r--.venv/lib/python3.12/site-packages/dns/rdatatype.py336
-rw-r--r--.venv/lib/python3.12/site-packages/dns/rdtypes/ANY/AFSDB.py45
-rw-r--r--.venv/lib/python3.12/site-packages/dns/rdtypes/ANY/AMTRELAY.py91
-rw-r--r--.venv/lib/python3.12/site-packages/dns/rdtypes/ANY/AVC.py26
-rw-r--r--.venv/lib/python3.12/site-packages/dns/rdtypes/ANY/CAA.py71
-rw-r--r--.venv/lib/python3.12/site-packages/dns/rdtypes/ANY/CDNSKEY.py33
-rw-r--r--.venv/lib/python3.12/site-packages/dns/rdtypes/ANY/CDS.py29
-rw-r--r--.venv/lib/python3.12/site-packages/dns/rdtypes/ANY/CERT.py116
-rw-r--r--.venv/lib/python3.12/site-packages/dns/rdtypes/ANY/CNAME.py28
-rw-r--r--.venv/lib/python3.12/site-packages/dns/rdtypes/ANY/CSYNC.py68
-rw-r--r--.venv/lib/python3.12/site-packages/dns/rdtypes/ANY/DLV.py24
-rw-r--r--.venv/lib/python3.12/site-packages/dns/rdtypes/ANY/DNAME.py27
-rw-r--r--.venv/lib/python3.12/site-packages/dns/rdtypes/ANY/DNSKEY.py33
-rw-r--r--.venv/lib/python3.12/site-packages/dns/rdtypes/ANY/DS.py24
-rw-r--r--.venv/lib/python3.12/site-packages/dns/rdtypes/ANY/EUI48.py30
-rw-r--r--.venv/lib/python3.12/site-packages/dns/rdtypes/ANY/EUI64.py30
-rw-r--r--.venv/lib/python3.12/site-packages/dns/rdtypes/ANY/GPOS.py126
-rw-r--r--.venv/lib/python3.12/site-packages/dns/rdtypes/ANY/HINFO.py64
-rw-r--r--.venv/lib/python3.12/site-packages/dns/rdtypes/ANY/HIP.py85
-rw-r--r--.venv/lib/python3.12/site-packages/dns/rdtypes/ANY/ISDN.py78
-rw-r--r--.venv/lib/python3.12/site-packages/dns/rdtypes/ANY/L32.py41
-rw-r--r--.venv/lib/python3.12/site-packages/dns/rdtypes/ANY/L64.py47
-rw-r--r--.venv/lib/python3.12/site-packages/dns/rdtypes/ANY/LOC.py353
-rw-r--r--.venv/lib/python3.12/site-packages/dns/rdtypes/ANY/LP.py42
-rw-r--r--.venv/lib/python3.12/site-packages/dns/rdtypes/ANY/MX.py24
-rw-r--r--.venv/lib/python3.12/site-packages/dns/rdtypes/ANY/NID.py47
-rw-r--r--.venv/lib/python3.12/site-packages/dns/rdtypes/ANY/NINFO.py26
-rw-r--r--.venv/lib/python3.12/site-packages/dns/rdtypes/ANY/NS.py24
-rw-r--r--.venv/lib/python3.12/site-packages/dns/rdtypes/ANY/NSEC.py67
-rw-r--r--.venv/lib/python3.12/site-packages/dns/rdtypes/ANY/NSEC3.py126
-rw-r--r--.venv/lib/python3.12/site-packages/dns/rdtypes/ANY/NSEC3PARAM.py69
-rw-r--r--.venv/lib/python3.12/site-packages/dns/rdtypes/ANY/OPENPGPKEY.py53
-rw-r--r--.venv/lib/python3.12/site-packages/dns/rdtypes/ANY/OPT.py77
-rw-r--r--.venv/lib/python3.12/site-packages/dns/rdtypes/ANY/PTR.py24
-rw-r--r--.venv/lib/python3.12/site-packages/dns/rdtypes/ANY/RESINFO.py24
-rw-r--r--.venv/lib/python3.12/site-packages/dns/rdtypes/ANY/RP.py58
-rw-r--r--.venv/lib/python3.12/site-packages/dns/rdtypes/ANY/RRSIG.py157
-rw-r--r--.venv/lib/python3.12/site-packages/dns/rdtypes/ANY/RT.py24
-rw-r--r--.venv/lib/python3.12/site-packages/dns/rdtypes/ANY/SMIMEA.py9
-rw-r--r--.venv/lib/python3.12/site-packages/dns/rdtypes/ANY/SOA.py86
-rw-r--r--.venv/lib/python3.12/site-packages/dns/rdtypes/ANY/SPF.py26
-rw-r--r--.venv/lib/python3.12/site-packages/dns/rdtypes/ANY/SSHFP.py68
-rw-r--r--.venv/lib/python3.12/site-packages/dns/rdtypes/ANY/TKEY.py142
-rw-r--r--.venv/lib/python3.12/site-packages/dns/rdtypes/ANY/TLSA.py9
-rw-r--r--.venv/lib/python3.12/site-packages/dns/rdtypes/ANY/TSIG.py160
-rw-r--r--.venv/lib/python3.12/site-packages/dns/rdtypes/ANY/TXT.py24
-rw-r--r--.venv/lib/python3.12/site-packages/dns/rdtypes/ANY/URI.py79
-rw-r--r--.venv/lib/python3.12/site-packages/dns/rdtypes/ANY/WALLET.py9
-rw-r--r--.venv/lib/python3.12/site-packages/dns/rdtypes/ANY/X25.py57
-rw-r--r--.venv/lib/python3.12/site-packages/dns/rdtypes/ANY/ZONEMD.py66
-rw-r--r--.venv/lib/python3.12/site-packages/dns/rdtypes/ANY/__init__.py70
-rw-r--r--.venv/lib/python3.12/site-packages/dns/rdtypes/CH/A.py59
-rw-r--r--.venv/lib/python3.12/site-packages/dns/rdtypes/CH/__init__.py22
-rw-r--r--.venv/lib/python3.12/site-packages/dns/rdtypes/IN/A.py51
-rw-r--r--.venv/lib/python3.12/site-packages/dns/rdtypes/IN/AAAA.py51
-rw-r--r--.venv/lib/python3.12/site-packages/dns/rdtypes/IN/APL.py150
-rw-r--r--.venv/lib/python3.12/site-packages/dns/rdtypes/IN/DHCID.py54
-rw-r--r--.venv/lib/python3.12/site-packages/dns/rdtypes/IN/HTTPS.py9
-rw-r--r--.venv/lib/python3.12/site-packages/dns/rdtypes/IN/IPSECKEY.py91
-rw-r--r--.venv/lib/python3.12/site-packages/dns/rdtypes/IN/KX.py24
-rw-r--r--.venv/lib/python3.12/site-packages/dns/rdtypes/IN/NAPTR.py110
-rw-r--r--.venv/lib/python3.12/site-packages/dns/rdtypes/IN/NSAP.py60
-rw-r--r--.venv/lib/python3.12/site-packages/dns/rdtypes/IN/NSAP_PTR.py24
-rw-r--r--.venv/lib/python3.12/site-packages/dns/rdtypes/IN/PX.py73
-rw-r--r--.venv/lib/python3.12/site-packages/dns/rdtypes/IN/SRV.py75
-rw-r--r--.venv/lib/python3.12/site-packages/dns/rdtypes/IN/SVCB.py9
-rw-r--r--.venv/lib/python3.12/site-packages/dns/rdtypes/IN/WKS.py100
-rw-r--r--.venv/lib/python3.12/site-packages/dns/rdtypes/IN/__init__.py35
-rw-r--r--.venv/lib/python3.12/site-packages/dns/rdtypes/__init__.py33
-rw-r--r--.venv/lib/python3.12/site-packages/dns/rdtypes/dnskeybase.py87
-rw-r--r--.venv/lib/python3.12/site-packages/dns/rdtypes/dsbase.py85
-rw-r--r--.venv/lib/python3.12/site-packages/dns/rdtypes/euibase.py70
-rw-r--r--.venv/lib/python3.12/site-packages/dns/rdtypes/mxbase.py87
-rw-r--r--.venv/lib/python3.12/site-packages/dns/rdtypes/nsbase.py63
-rw-r--r--.venv/lib/python3.12/site-packages/dns/rdtypes/svcbbase.py585
-rw-r--r--.venv/lib/python3.12/site-packages/dns/rdtypes/tlsabase.py71
-rw-r--r--.venv/lib/python3.12/site-packages/dns/rdtypes/txtbase.py106
-rw-r--r--.venv/lib/python3.12/site-packages/dns/rdtypes/util.py257
-rw-r--r--.venv/lib/python3.12/site-packages/dns/renderer.py346
-rw-r--r--.venv/lib/python3.12/site-packages/dns/resolver.py2053
-rw-r--r--.venv/lib/python3.12/site-packages/dns/reversename.py105
-rw-r--r--.venv/lib/python3.12/site-packages/dns/rrset.py285
-rw-r--r--.venv/lib/python3.12/site-packages/dns/serial.py118
-rw-r--r--.venv/lib/python3.12/site-packages/dns/set.py308
-rw-r--r--.venv/lib/python3.12/site-packages/dns/tokenizer.py708
-rw-r--r--.venv/lib/python3.12/site-packages/dns/transaction.py649
-rw-r--r--.venv/lib/python3.12/site-packages/dns/tsig.py352
-rw-r--r--.venv/lib/python3.12/site-packages/dns/tsigkeyring.py68
-rw-r--r--.venv/lib/python3.12/site-packages/dns/ttl.py92
-rw-r--r--.venv/lib/python3.12/site-packages/dns/update.py386
-rw-r--r--.venv/lib/python3.12/site-packages/dns/version.py58
-rw-r--r--.venv/lib/python3.12/site-packages/dns/versioned.py318
-rw-r--r--.venv/lib/python3.12/site-packages/dns/win32util.py242
-rw-r--r--.venv/lib/python3.12/site-packages/dns/wire.py89
-rw-r--r--.venv/lib/python3.12/site-packages/dns/xfr.py343
-rw-r--r--.venv/lib/python3.12/site-packages/dns/zone.py1434
-rw-r--r--.venv/lib/python3.12/site-packages/dns/zonefile.py744
-rw-r--r--.venv/lib/python3.12/site-packages/dns/zonetypes.py37
145 files changed, 29756 insertions, 0 deletions
diff --git a/.venv/lib/python3.12/site-packages/dns/__init__.py b/.venv/lib/python3.12/site-packages/dns/__init__.py
new file mode 100644
index 00000000..a4249b9e
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/dns/__init__.py
@@ -0,0 +1,70 @@
+# Copyright (C) Dnspython Contributors, see LICENSE for text of ISC license
+
+# Copyright (C) 2003-2007, 2009, 2011 Nominum, Inc.
+#
+# Permission to use, copy, modify, and distribute this software and its
+# documentation for any purpose with or without fee is hereby granted,
+# provided that the above copyright notice and this permission notice
+# appear in all copies.
+#
+# THE SOFTWARE IS PROVIDED "AS IS" AND NOMINUM DISCLAIMS ALL WARRANTIES
+# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL NOMINUM BE LIABLE FOR
+# ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+# ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT
+# OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+
+"""dnspython DNS toolkit"""
+
+__all__ = [
+    "asyncbackend",
+    "asyncquery",
+    "asyncresolver",
+    "dnssec",
+    "dnssecalgs",
+    "dnssectypes",
+    "e164",
+    "edns",
+    "entropy",
+    "exception",
+    "flags",
+    "immutable",
+    "inet",
+    "ipv4",
+    "ipv6",
+    "message",
+    "name",
+    "namedict",
+    "node",
+    "opcode",
+    "query",
+    "quic",
+    "rcode",
+    "rdata",
+    "rdataclass",
+    "rdataset",
+    "rdatatype",
+    "renderer",
+    "resolver",
+    "reversename",
+    "rrset",
+    "serial",
+    "set",
+    "tokenizer",
+    "transaction",
+    "tsig",
+    "tsigkeyring",
+    "ttl",
+    "rdtypes",
+    "update",
+    "version",
+    "versioned",
+    "wire",
+    "xfr",
+    "zone",
+    "zonetypes",
+    "zonefile",
+]
+
+from dns.version import version as __version__  # noqa
diff --git a/.venv/lib/python3.12/site-packages/dns/_asyncbackend.py b/.venv/lib/python3.12/site-packages/dns/_asyncbackend.py
new file mode 100644
index 00000000..f6760fd0
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/dns/_asyncbackend.py
@@ -0,0 +1,100 @@
+# Copyright (C) Dnspython Contributors, see LICENSE for text of ISC license
+
+# This is a nullcontext for both sync and async.  3.7 has a nullcontext,
+# but it is only for sync use.
+
+
+class NullContext:
+    def __init__(self, enter_result=None):
+        self.enter_result = enter_result
+
+    def __enter__(self):
+        return self.enter_result
+
+    def __exit__(self, exc_type, exc_value, traceback):
+        pass
+
+    async def __aenter__(self):
+        return self.enter_result
+
+    async def __aexit__(self, exc_type, exc_value, traceback):
+        pass
+
+
+# These are declared here so backends can import them without creating
+# circular dependencies with dns.asyncbackend.
+
+
+class Socket:  # pragma: no cover
+    def __init__(self, family: int, type: int):
+        self.family = family
+        self.type = type
+
+    async def close(self):
+        pass
+
+    async def getpeername(self):
+        raise NotImplementedError
+
+    async def getsockname(self):
+        raise NotImplementedError
+
+    async def getpeercert(self, timeout):
+        raise NotImplementedError
+
+    async def __aenter__(self):
+        return self
+
+    async def __aexit__(self, exc_type, exc_value, traceback):
+        await self.close()
+
+
+class DatagramSocket(Socket):  # pragma: no cover
+    async def sendto(self, what, destination, timeout):
+        raise NotImplementedError
+
+    async def recvfrom(self, size, timeout):
+        raise NotImplementedError
+
+
+class StreamSocket(Socket):  # pragma: no cover
+    async def sendall(self, what, timeout):
+        raise NotImplementedError
+
+    async def recv(self, size, timeout):
+        raise NotImplementedError
+
+
+class NullTransport:
+    async def connect_tcp(self, host, port, timeout, local_address):
+        raise NotImplementedError
+
+
+class Backend:  # pragma: no cover
+    def name(self):
+        return "unknown"
+
+    async def make_socket(
+        self,
+        af,
+        socktype,
+        proto=0,
+        source=None,
+        destination=None,
+        timeout=None,
+        ssl_context=None,
+        server_hostname=None,
+    ):
+        raise NotImplementedError
+
+    def datagram_connection_required(self):
+        return False
+
+    async def sleep(self, interval):
+        raise NotImplementedError
+
+    def get_transport_class(self):
+        raise NotImplementedError
+
+    async def wait_for(self, awaitable, timeout):
+        raise NotImplementedError
diff --git a/.venv/lib/python3.12/site-packages/dns/_asyncio_backend.py b/.venv/lib/python3.12/site-packages/dns/_asyncio_backend.py
new file mode 100644
index 00000000..6ab168de
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/dns/_asyncio_backend.py
@@ -0,0 +1,275 @@
+# Copyright (C) Dnspython Contributors, see LICENSE for text of ISC license
+
+"""asyncio library query support"""
+
+import asyncio
+import socket
+import sys
+
+import dns._asyncbackend
+import dns._features
+import dns.exception
+import dns.inet
+
+_is_win32 = sys.platform == "win32"
+
+
+def _get_running_loop():
+    try:
+        return asyncio.get_running_loop()
+    except AttributeError:  # pragma: no cover
+        return asyncio.get_event_loop()
+
+
+class _DatagramProtocol:
+    def __init__(self):
+        self.transport = None
+        self.recvfrom = None
+
+    def connection_made(self, transport):
+        self.transport = transport
+
+    def datagram_received(self, data, addr):
+        if self.recvfrom and not self.recvfrom.done():
+            self.recvfrom.set_result((data, addr))
+
+    def error_received(self, exc):  # pragma: no cover
+        if self.recvfrom and not self.recvfrom.done():
+            self.recvfrom.set_exception(exc)
+
+    def connection_lost(self, exc):
+        if self.recvfrom and not self.recvfrom.done():
+            if exc is None:
+                # EOF we triggered.  Is there a better way to do this?
+                try:
+                    raise EOFError("EOF")
+                except EOFError as e:
+                    self.recvfrom.set_exception(e)
+            else:
+                self.recvfrom.set_exception(exc)
+
+    def close(self):
+        self.transport.close()
+
+
+async def _maybe_wait_for(awaitable, timeout):
+    if timeout is not None:
+        try:
+            return await asyncio.wait_for(awaitable, timeout)
+        except asyncio.TimeoutError:
+            raise dns.exception.Timeout(timeout=timeout)
+    else:
+        return await awaitable
+
+
+class DatagramSocket(dns._asyncbackend.DatagramSocket):
+    def __init__(self, family, transport, protocol):
+        super().__init__(family, socket.SOCK_DGRAM)
+        self.transport = transport
+        self.protocol = protocol
+
+    async def sendto(self, what, destination, timeout):  # pragma: no cover
+        # no timeout for asyncio sendto
+        self.transport.sendto(what, destination)
+        return len(what)
+
+    async def recvfrom(self, size, timeout):
+        # ignore size as there's no way I know to tell protocol about it
+        done = _get_running_loop().create_future()
+        try:
+            assert self.protocol.recvfrom is None
+            self.protocol.recvfrom = done
+            await _maybe_wait_for(done, timeout)
+            return done.result()
+        finally:
+            self.protocol.recvfrom = None
+
+    async def close(self):
+        self.protocol.close()
+
+    async def getpeername(self):
+        return self.transport.get_extra_info("peername")
+
+    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):
+        super().__init__(af, socket.SOCK_STREAM)
+        self.reader = reader
+        self.writer = writer
+
+    async def sendall(self, what, timeout):
+        self.writer.write(what)
+        return await _maybe_wait_for(self.writer.drain(), timeout)
+
+    async def recv(self, size, timeout):
+        return await _maybe_wait_for(self.reader.read(size), timeout)
+
+    async def close(self):
+        self.writer.close()
+
+    async def getpeername(self):
+        return self.writer.get_extra_info("peername")
+
+    async def getsockname(self):
+        return self.writer.get_extra_info("sockname")
+
+    async def getpeercert(self, timeout):
+        return self.writer.get_extra_info("peercert")
+
+
+if dns._features.have("doh"):
+    import anyio
+    import httpcore
+    import httpcore._backends.anyio
+    import httpx
+
+    _CoreAsyncNetworkBackend = httpcore.AsyncNetworkBackend
+    _CoreAnyIOStream = httpcore._backends.anyio.AnyIOStream
+
+    from dns.query import _compute_times, _expiration_for_this_attempt, _remaining
+
+    class _NetworkBackend(_CoreAsyncNetworkBackend):
+        def __init__(self, resolver, local_port, bootstrap_address, family):
+            super().__init__()
+            self._local_port = local_port
+            self._resolver = resolver
+            self._bootstrap_address = bootstrap_address
+            self._family = family
+            if local_port != 0:
+                raise NotImplementedError(
+                    "the asyncio transport for HTTPX cannot set the local port"
+                )
+
+        async def connect_tcp(
+            self, host, port, timeout, local_address, socket_options=None
+        ):  # pylint: disable=signature-differs
+            addresses = []
+            _, expiration = _compute_times(timeout)
+            if dns.inet.is_address(host):
+                addresses.append(host)
+            elif self._bootstrap_address is not None:
+                addresses.append(self._bootstrap_address)
+            else:
+                timeout = _remaining(expiration)
+                family = self._family
+                if local_address:
+                    family = dns.inet.af_for_address(local_address)
+                answers = await self._resolver.resolve_name(
+                    host, family=family, lifetime=timeout
+                )
+                addresses = answers.addresses()
+            for address in addresses:
+                try:
+                    attempt_expiration = _expiration_for_this_attempt(2.0, expiration)
+                    timeout = _remaining(attempt_expiration)
+                    with anyio.fail_after(timeout):
+                        stream = await anyio.connect_tcp(
+                            remote_host=address,
+                            remote_port=port,
+                            local_host=local_address,
+                        )
+                    return _CoreAnyIOStream(stream)
+                except Exception:
+                    pass
+            raise httpcore.ConnectError
+
+        async def connect_unix_socket(
+            self, path, timeout, socket_options=None
+        ):  # pylint: disable=signature-differs
+            raise NotImplementedError
+
+        async def sleep(self, seconds):  # pylint: disable=signature-differs
+            await anyio.sleep(seconds)
+
+    class _HTTPTransport(httpx.AsyncHTTPTransport):
+        def __init__(
+            self,
+            *args,
+            local_port=0,
+            bootstrap_address=None,
+            resolver=None,
+            family=socket.AF_UNSPEC,
+            **kwargs,
+        ):
+            if resolver is None and bootstrap_address is None:
+                # pylint: disable=import-outside-toplevel,redefined-outer-name
+                import dns.asyncresolver
+
+                resolver = dns.asyncresolver.Resolver()
+            super().__init__(*args, **kwargs)
+            self._pool._network_backend = _NetworkBackend(
+                resolver, local_port, bootstrap_address, family
+            )
+
+else:
+    _HTTPTransport = dns._asyncbackend.NullTransport  # type: ignore
+
+
+class Backend(dns._asyncbackend.Backend):
+    def name(self):
+        return "asyncio"
+
+    async def make_socket(
+        self,
+        af,
+        socktype,
+        proto=0,
+        source=None,
+        destination=None,
+        timeout=None,
+        ssl_context=None,
+        server_hostname=None,
+    ):
+        loop = _get_running_loop()
+        if socktype == socket.SOCK_DGRAM:
+            if _is_win32 and source is None:
+                # Win32 wants explicit binding before recvfrom().  This is the
+                # proper fix for [#637].
+                source = (dns.inet.any_for_af(af), 0)
+            transport, protocol = await loop.create_datagram_endpoint(
+                _DatagramProtocol,
+                source,
+                family=af,
+                proto=proto,
+                remote_addr=destination,
+            )
+            return DatagramSocket(af, transport, protocol)
+        elif socktype == socket.SOCK_STREAM:
+            if destination is None:
+                # This shouldn't happen, but we check to make code analysis software
+                # happier.
+                raise ValueError("destination required for stream sockets")
+            (r, w) = await _maybe_wait_for(
+                asyncio.open_connection(
+                    destination[0],
+                    destination[1],
+                    ssl=ssl_context,
+                    family=af,
+                    proto=proto,
+                    local_addr=source,
+                    server_hostname=server_hostname,
+                ),
+                timeout,
+            )
+            return StreamSocket(af, r, w)
+        raise NotImplementedError(
+            "unsupported socket " + f"type {socktype}"
+        )  # pragma: no cover
+
+    async def sleep(self, interval):
+        await asyncio.sleep(interval)
+
+    def datagram_connection_required(self):
+        return False
+
+    def get_transport_class(self):
+        return _HTTPTransport
+
+    async def wait_for(self, awaitable, timeout):
+        return await _maybe_wait_for(awaitable, timeout)
diff --git a/.venv/lib/python3.12/site-packages/dns/_ddr.py b/.venv/lib/python3.12/site-packages/dns/_ddr.py
new file mode 100644
index 00000000..bf5c11eb
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/dns/_ddr.py
@@ -0,0 +1,154 @@
+# Copyright (C) Dnspython Contributors, see LICENSE for text of ISC license
+#
+# Support for Discovery of Designated Resolvers
+
+import socket
+import time
+from urllib.parse import urlparse
+
+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/.venv/lib/python3.12/site-packages/dns/_features.py b/.venv/lib/python3.12/site-packages/dns/_features.py
new file mode 100644
index 00000000..fa6d4955
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/dns/_features.py
@@ -0,0 +1,95 @@
+# Copyright (C) Dnspython Contributors, see LICENSE for text of ISC license
+
+import importlib.metadata
+import itertools
+import string
+from typing import Dict, List, Tuple
+
+
+def _tuple_from_text(version: str) -> Tuple:
+    text_parts = version.split(".")
+    int_parts = []
+    for text_part in text_parts:
+        digit_prefix = "".join(
+            itertools.takewhile(lambda x: x in string.digits, text_part)
+        )
+        try:
+            int_parts.append(int(digit_prefix))
+        except Exception:
+            break
+    return tuple(int_parts)
+
+
+def _version_check(
+    requirement: str,
+) -> bool:
+    """Is the requirement fulfilled?
+
+    The requirement must be of the form
+
+        package>=version
+    """
+    package, minimum = requirement.split(">=")
+    try:
+        version = importlib.metadata.version(package)
+        # This shouldn't happen, but it apparently can.
+        if version is None:
+            return False
+    except Exception:
+        return False
+    t_version = _tuple_from_text(version)
+    t_minimum = _tuple_from_text(minimum)
+    if t_version < t_minimum:
+        return False
+    return True
+
+
+_cache: Dict[str, bool] = {}
+
+
+def have(feature: str) -> bool:
+    """Is *feature* available?
+
+    This tests if all optional packages needed for the
+    feature are available and recent enough.
+
+    Returns ``True`` if the feature is available,
+    and ``False`` if it is not or if metadata is
+    missing.
+    """
+    value = _cache.get(feature)
+    if value is not None:
+        return value
+    requirements = _requirements.get(feature)
+    if requirements is None:
+        # we make a cache entry here for consistency not performance
+        _cache[feature] = False
+        return False
+    ok = True
+    for requirement in requirements:
+        if not _version_check(requirement):
+            ok = False
+            break
+    _cache[feature] = ok
+    return ok
+
+
+def force(feature: str, enabled: bool) -> None:
+    """Force the status of *feature* to be *enabled*.
+
+    This method is provided as a workaround for any cases
+    where importlib.metadata is ineffective, or for testing.
+    """
+    _cache[feature] = enabled
+
+
+_requirements: Dict[str, List[str]] = {
+    ### BEGIN generated requirements
+    "dnssec": ["cryptography>=43"],
+    "doh": ["httpcore>=1.0.0", "httpx>=0.26.0", "h2>=4.1.0"],
+    "doq": ["aioquic>=1.0.0"],
+    "idna": ["idna>=3.7"],
+    "trio": ["trio>=0.23"],
+    "wmi": ["wmi>=1.5.1"],
+    ### END generated requirements
+}
diff --git a/.venv/lib/python3.12/site-packages/dns/_immutable_ctx.py b/.venv/lib/python3.12/site-packages/dns/_immutable_ctx.py
new file mode 100644
index 00000000..ae7a33bf
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/dns/_immutable_ctx.py
@@ -0,0 +1,76 @@
+# Copyright (C) Dnspython Contributors, see LICENSE for text of ISC license
+
+# This implementation of the immutable decorator requires python >=
+# 3.7, and is significantly more storage efficient when making classes
+# with slots immutable.  It's also faster.
+
+import contextvars
+import inspect
+
+_in__init__ = contextvars.ContextVar("_immutable_in__init__", default=False)
+
+
+class _Immutable:
+    """Immutable mixin class"""
+
+    # We set slots to the empty list to say "we don't have any attributes".
+    # We do this so that if we're mixed in with a class with __slots__, we
+    # don't cause a __dict__ to be added which would waste space.
+
+    __slots__ = ()
+
+    def __setattr__(self, name, value):
+        if _in__init__.get() is not self:
+            raise TypeError("object doesn't support attribute assignment")
+        else:
+            super().__setattr__(name, value)
+
+    def __delattr__(self, name):
+        if _in__init__.get() is not self:
+            raise TypeError("object doesn't support attribute assignment")
+        else:
+            super().__delattr__(name)
+
+
+def _immutable_init(f):
+    def nf(*args, **kwargs):
+        previous = _in__init__.set(args[0])
+        try:
+            # call the actual __init__
+            f(*args, **kwargs)
+        finally:
+            _in__init__.reset(previous)
+
+    nf.__signature__ = inspect.signature(f)
+    return nf
+
+
+def immutable(cls):
+    if _Immutable in cls.__mro__:
+        # Some ancestor already has the mixin, so just make sure we keep
+        # following the __init__ protocol.
+        cls.__init__ = _immutable_init(cls.__init__)
+        if hasattr(cls, "__setstate__"):
+            cls.__setstate__ = _immutable_init(cls.__setstate__)
+        ncls = cls
+    else:
+        # Mixin the Immutable class and follow the __init__ protocol.
+        class ncls(_Immutable, cls):
+            # We have to do the __slots__ declaration here too!
+            __slots__ = ()
+
+            @_immutable_init
+            def __init__(self, *args, **kwargs):
+                super().__init__(*args, **kwargs)
+
+            if hasattr(cls, "__setstate__"):
+
+                @_immutable_init
+                def __setstate__(self, *args, **kwargs):
+                    super().__setstate__(*args, **kwargs)
+
+        # make ncls have the same name and module as cls
+        ncls.__name__ = cls.__name__
+        ncls.__qualname__ = cls.__qualname__
+        ncls.__module__ = cls.__module__
+    return ncls
diff --git a/.venv/lib/python3.12/site-packages/dns/_trio_backend.py b/.venv/lib/python3.12/site-packages/dns/_trio_backend.py
new file mode 100644
index 00000000..0ed904dd
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/dns/_trio_backend.py
@@ -0,0 +1,253 @@
+# Copyright (C) Dnspython Contributors, see LICENSE for text of ISC license
+
+"""trio async I/O library query support"""
+
+import socket
+
+import trio
+import trio.socket  # type: ignore
+
+import dns._asyncbackend
+import dns._features
+import dns.exception
+import dns.inet
+
+if not dns._features.have("trio"):
+    raise ImportError("trio not found or too old")
+
+
+def _maybe_timeout(timeout):
+    if timeout is not None:
+        return trio.move_on_after(timeout)
+    else:
+        return dns._asyncbackend.NullContext()
+
+
+# for brevity
+_lltuple = dns.inet.low_level_address_tuple
+
+# pylint: disable=redefined-outer-name
+
+
+class DatagramSocket(dns._asyncbackend.DatagramSocket):
+    def __init__(self, sock):
+        super().__init__(sock.family, socket.SOCK_DGRAM)
+        self.socket = sock
+
+    async def sendto(self, what, destination, timeout):
+        with _maybe_timeout(timeout):
+            if destination is None:
+                return await self.socket.send(what)
+            else:
+                return await self.socket.sendto(what, destination)
+        raise dns.exception.Timeout(
+            timeout=timeout
+        )  # pragma: no cover  lgtm[py/unreachable-statement]
+
+    async def recvfrom(self, size, timeout):
+        with _maybe_timeout(timeout):
+            return await self.socket.recvfrom(size)
+        raise dns.exception.Timeout(timeout=timeout)  # lgtm[py/unreachable-statement]
+
+    async def close(self):
+        self.socket.close()
+
+    async def getpeername(self):
+        return self.socket.getpeername()
+
+    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):
+        super().__init__(family, socket.SOCK_STREAM)
+        self.stream = stream
+        self.tls = tls
+
+    async def sendall(self, what, timeout):
+        with _maybe_timeout(timeout):
+            return await self.stream.send_all(what)
+        raise dns.exception.Timeout(timeout=timeout)  # lgtm[py/unreachable-statement]
+
+    async def recv(self, size, timeout):
+        with _maybe_timeout(timeout):
+            return await self.stream.receive_some(size)
+        raise dns.exception.Timeout(timeout=timeout)  # lgtm[py/unreachable-statement]
+
+    async def close(self):
+        await self.stream.aclose()
+
+    async def getpeername(self):
+        if self.tls:
+            return self.stream.transport_stream.socket.getpeername()
+        else:
+            return self.stream.socket.getpeername()
+
+    async def getsockname(self):
+        if self.tls:
+            return self.stream.transport_stream.socket.getsockname()
+        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
+
+
+if dns._features.have("doh"):
+    import httpcore
+    import httpcore._backends.trio
+    import httpx
+
+    _CoreAsyncNetworkBackend = httpcore.AsyncNetworkBackend
+    _CoreTrioStream = httpcore._backends.trio.TrioStream
+
+    from dns.query import _compute_times, _expiration_for_this_attempt, _remaining
+
+    class _NetworkBackend(_CoreAsyncNetworkBackend):
+        def __init__(self, resolver, local_port, bootstrap_address, family):
+            super().__init__()
+            self._local_port = local_port
+            self._resolver = resolver
+            self._bootstrap_address = bootstrap_address
+            self._family = family
+
+        async def connect_tcp(
+            self, host, port, timeout, local_address, socket_options=None
+        ):  # pylint: disable=signature-differs
+            addresses = []
+            _, expiration = _compute_times(timeout)
+            if dns.inet.is_address(host):
+                addresses.append(host)
+            elif self._bootstrap_address is not None:
+                addresses.append(self._bootstrap_address)
+            else:
+                timeout = _remaining(expiration)
+                family = self._family
+                if local_address:
+                    family = dns.inet.af_for_address(local_address)
+                answers = await self._resolver.resolve_name(
+                    host, family=family, lifetime=timeout
+                )
+                addresses = answers.addresses()
+            for address in addresses:
+                try:
+                    af = dns.inet.af_for_address(address)
+                    if local_address is not None or self._local_port != 0:
+                        source = (local_address, self._local_port)
+                    else:
+                        source = None
+                    destination = (address, port)
+                    attempt_expiration = _expiration_for_this_attempt(2.0, expiration)
+                    timeout = _remaining(attempt_expiration)
+                    sock = await Backend().make_socket(
+                        af, socket.SOCK_STREAM, 0, source, destination, timeout
+                    )
+                    return _CoreTrioStream(sock.stream)
+                except Exception:
+                    continue
+            raise httpcore.ConnectError
+
+        async def connect_unix_socket(
+            self, path, timeout, socket_options=None
+        ):  # pylint: disable=signature-differs
+            raise NotImplementedError
+
+        async def sleep(self, seconds):  # pylint: disable=signature-differs
+            await trio.sleep(seconds)
+
+    class _HTTPTransport(httpx.AsyncHTTPTransport):
+        def __init__(
+            self,
+            *args,
+            local_port=0,
+            bootstrap_address=None,
+            resolver=None,
+            family=socket.AF_UNSPEC,
+            **kwargs,
+        ):
+            if resolver is None and bootstrap_address is None:
+                # pylint: disable=import-outside-toplevel,redefined-outer-name
+                import dns.asyncresolver
+
+                resolver = dns.asyncresolver.Resolver()
+            super().__init__(*args, **kwargs)
+            self._pool._network_backend = _NetworkBackend(
+                resolver, local_port, bootstrap_address, family
+            )
+
+else:
+    _HTTPTransport = dns._asyncbackend.NullTransport  # type: ignore
+
+
+class Backend(dns._asyncbackend.Backend):
+    def name(self):
+        return "trio"
+
+    async def make_socket(
+        self,
+        af,
+        socktype,
+        proto=0,
+        source=None,
+        destination=None,
+        timeout=None,
+        ssl_context=None,
+        server_hostname=None,
+    ):
+        s = trio.socket.socket(af, socktype, proto)
+        stream = None
+        try:
+            if source:
+                await s.bind(_lltuple(source, af))
+            if socktype == socket.SOCK_STREAM or destination is not None:
+                connected = False
+                with _maybe_timeout(timeout):
+                    await s.connect(_lltuple(destination, af))
+                    connected = True
+                if not connected:
+                    raise dns.exception.Timeout(
+                        timeout=timeout
+                    )  # lgtm[py/unreachable-statement]
+        except Exception:  # pragma: no cover
+            s.close()
+            raise
+        if socktype == socket.SOCK_DGRAM:
+            return DatagramSocket(s)
+        elif socktype == socket.SOCK_STREAM:
+            stream = trio.SocketStream(s)
+            tls = False
+            if ssl_context:
+                tls = True
+                try:
+                    stream = trio.SSLStream(
+                        stream, ssl_context, server_hostname=server_hostname
+                    )
+                except Exception:  # pragma: no cover
+                    await stream.aclose()
+                    raise
+            return StreamSocket(af, stream, tls)
+        raise NotImplementedError(
+            "unsupported socket " + f"type {socktype}"
+        )  # pragma: no cover
+
+    async def sleep(self, interval):
+        await trio.sleep(interval)
+
+    def get_transport_class(self):
+        return _HTTPTransport
+
+    async def wait_for(self, awaitable, timeout):
+        with _maybe_timeout(timeout):
+            return await awaitable
+        raise dns.exception.Timeout(
+            timeout=timeout
+        )  # pragma: no cover  lgtm[py/unreachable-statement]
diff --git a/.venv/lib/python3.12/site-packages/dns/asyncbackend.py b/.venv/lib/python3.12/site-packages/dns/asyncbackend.py
new file mode 100644
index 00000000..0ec58b06
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/dns/asyncbackend.py
@@ -0,0 +1,101 @@
+# Copyright (C) Dnspython Contributors, see LICENSE for text of ISC license
+
+from typing import Dict
+
+import dns.exception
+
+# pylint: disable=unused-import
+from dns._asyncbackend import (  # noqa: F401  lgtm[py/unused-import]
+    Backend,
+    DatagramSocket,
+    Socket,
+    StreamSocket,
+)
+
+# pylint: enable=unused-import
+
+_default_backend = None
+
+_backends: Dict[str, Backend] = {}
+
+# Allow sniffio import to be disabled for testing purposes
+_no_sniffio = False
+
+
+class AsyncLibraryNotFoundError(dns.exception.DNSException):
+    pass
+
+
+def get_backend(name: str) -> Backend:
+    """Get the specified asynchronous backend.
+
+    *name*, a ``str``, the name of the backend.  Currently the "trio"
+    and "asyncio" backends are available.
+
+    Raises NotImplementedError if an unknown backend name is specified.
+    """
+    # pylint: disable=import-outside-toplevel,redefined-outer-name
+    backend = _backends.get(name)
+    if backend:
+        return backend
+    if name == "trio":
+        import dns._trio_backend
+
+        backend = dns._trio_backend.Backend()
+    elif name == "asyncio":
+        import dns._asyncio_backend
+
+        backend = dns._asyncio_backend.Backend()
+    else:
+        raise NotImplementedError(f"unimplemented async backend {name}")
+    _backends[name] = backend
+    return backend
+
+
+def sniff() -> str:
+    """Attempt to determine the in-use asynchronous I/O library by using
+    the ``sniffio`` module if it is available.
+
+    Returns the name of the library, or raises AsyncLibraryNotFoundError
+    if the library cannot be determined.
+    """
+    # pylint: disable=import-outside-toplevel
+    try:
+        if _no_sniffio:
+            raise ImportError
+        import sniffio
+
+        try:
+            return sniffio.current_async_library()
+        except sniffio.AsyncLibraryNotFoundError:
+            raise AsyncLibraryNotFoundError("sniffio cannot determine async library")
+    except ImportError:
+        import asyncio
+
+        try:
+            asyncio.get_running_loop()
+            return "asyncio"
+        except RuntimeError:
+            raise AsyncLibraryNotFoundError("no async library detected")
+
+
+def get_default_backend() -> Backend:
+    """Get the default backend, initializing it if necessary."""
+    if _default_backend:
+        return _default_backend
+
+    return set_default_backend(sniff())
+
+
+def set_default_backend(name: str) -> Backend:
+    """Set the default backend.
+
+    It's not normally necessary to call this method, as
+    ``get_default_backend()`` will initialize the backend
+    appropriately in many cases.  If ``sniffio`` is not installed, or
+    in testing situations, this function allows the backend to be set
+    explicitly.
+    """
+    global _default_backend
+    _default_backend = get_backend(name)
+    return _default_backend
diff --git a/.venv/lib/python3.12/site-packages/dns/asyncquery.py b/.venv/lib/python3.12/site-packages/dns/asyncquery.py
new file mode 100644
index 00000000..efad0fd7
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/dns/asyncquery.py
@@ -0,0 +1,913 @@
+# Copyright (C) Dnspython Contributors, see LICENSE for text of ISC license
+
+# Copyright (C) 2003-2017 Nominum, Inc.
+#
+# Permission to use, copy, modify, and distribute this software and its
+# documentation for any purpose with or without fee is hereby granted,
+# provided that the above copyright notice and this permission notice
+# appear in all copies.
+#
+# THE SOFTWARE IS PROVIDED "AS IS" AND NOMINUM DISCLAIMS ALL WARRANTIES
+# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL NOMINUM BE LIABLE FOR
+# ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+# ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT
+# OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+
+"""Talk to a DNS server."""
+
+import base64
+import contextlib
+import random
+import socket
+import struct
+import time
+import urllib.parse
+from typing import Any, Dict, Optional, Tuple, Union, cast
+
+import dns.asyncbackend
+import dns.exception
+import dns.inet
+import dns.message
+import dns.name
+import dns.quic
+import dns.rcode
+import dns.rdataclass
+import dns.rdatatype
+import dns.transaction
+from dns._asyncbackend import NullContext
+from dns.query import (
+    BadResponse,
+    HTTPVersion,
+    NoDOH,
+    NoDOQ,
+    UDPMode,
+    _check_status,
+    _compute_times,
+    _make_dot_ssl_context,
+    _matches_destination,
+    _remaining,
+    have_doh,
+    ssl,
+)
+
+if have_doh:
+    import httpx
+
+# for brevity
+_lltuple = dns.inet.low_level_address_tuple
+
+
+def _source_tuple(af, address, port):
+    # Make a high level source tuple, or return None if address and port
+    # are both None
+    if address or port:
+        if address is None:
+            if af == socket.AF_INET:
+                address = "0.0.0.0"
+            elif af == socket.AF_INET6:
+                address = "::"
+            else:
+                raise NotImplementedError(f"unknown address family {af}")
+        return (address, port)
+    else:
+        return None
+
+
+def _timeout(expiration, now=None):
+    if expiration is not None:
+        if not now:
+            now = time.time()
+        return max(expiration - now, 0)
+    else:
+        return None
+
+
+async def send_udp(
+    sock: dns.asyncbackend.DatagramSocket,
+    what: Union[dns.message.Message, bytes],
+    destination: Any,
+    expiration: Optional[float] = None,
+) -> Tuple[int, float]:
+    """Send a DNS message to the specified UDP socket.
+
+    *sock*, a ``dns.asyncbackend.DatagramSocket``.
+
+    *what*, a ``bytes`` or ``dns.message.Message``, the message to send.
+
+    *destination*, a destination tuple appropriate for the address family
+    of the socket, specifying where to send the query.
+
+    *expiration*, a ``float`` or ``None``, the absolute time at which
+    a timeout exception should be raised.  If ``None``, no timeout will
+    occur.  The expiration value is meaningless for the asyncio backend, as
+    asyncio's transport sendto() never blocks.
+
+    Returns an ``(int, float)`` tuple of bytes sent and the sent time.
+    """
+
+    if isinstance(what, dns.message.Message):
+        what = what.to_wire()
+    sent_time = time.time()
+    n = await sock.sendto(what, destination, _timeout(expiration, sent_time))
+    return (n, sent_time)
+
+
+async def receive_udp(
+    sock: dns.asyncbackend.DatagramSocket,
+    destination: Optional[Any] = None,
+    expiration: Optional[float] = None,
+    ignore_unexpected: bool = False,
+    one_rr_per_rrset: bool = False,
+    keyring: Optional[Dict[dns.name.Name, dns.tsig.Key]] = None,
+    request_mac: Optional[bytes] = b"",
+    ignore_trailing: bool = False,
+    raise_on_truncation: bool = False,
+    ignore_errors: bool = False,
+    query: Optional[dns.message.Message] = None,
+) -> Any:
+    """Read a DNS message from a UDP socket.
+
+    *sock*, a ``dns.asyncbackend.DatagramSocket``.
+
+    See :py:func:`dns.query.receive_udp()` for the documentation of the other
+    parameters, and exceptions.
+
+    Returns a ``(dns.message.Message, float, tuple)`` tuple of the received message, the
+    received time, and the address where the message arrived from.
+    """
+
+    wire = b""
+    while True:
+        (wire, from_address) = await sock.recvfrom(65535, _timeout(expiration))
+        if not _matches_destination(
+            sock.family, from_address, destination, ignore_unexpected
+        ):
+            continue
+        received_time = time.time()
+        try:
+            r = dns.message.from_wire(
+                wire,
+                keyring=keyring,
+                request_mac=request_mac,
+                one_rr_per_rrset=one_rr_per_rrset,
+                ignore_trailing=ignore_trailing,
+                raise_on_truncation=raise_on_truncation,
+            )
+        except dns.message.Truncated as e:
+            # See the comment in query.py for details.
+            if (
+                ignore_errors
+                and query is not None
+                and not query.is_response(e.message())
+            ):
+                continue
+            else:
+                raise
+        except Exception:
+            if ignore_errors:
+                continue
+            else:
+                raise
+        if ignore_errors and query is not None and not query.is_response(r):
+            continue
+        return (r, received_time, from_address)
+
+
+async def udp(
+    q: dns.message.Message,
+    where: str,
+    timeout: Optional[float] = None,
+    port: int = 53,
+    source: Optional[str] = None,
+    source_port: int = 0,
+    ignore_unexpected: bool = False,
+    one_rr_per_rrset: bool = False,
+    ignore_trailing: bool = False,
+    raise_on_truncation: bool = False,
+    sock: Optional[dns.asyncbackend.DatagramSocket] = None,
+    backend: Optional[dns.asyncbackend.Backend] = None,
+    ignore_errors: bool = False,
+) -> dns.message.Message:
+    """Return the response obtained after sending a query via UDP.
+
+    *sock*, a ``dns.asyncbackend.DatagramSocket``, or ``None``,
+    the socket to use for the query.  If ``None``, the default, a
+    socket is created.  Note that if a socket is provided, the
+    *source*, *source_port*, and *backend* are ignored.
+
+    *backend*, a ``dns.asyncbackend.Backend``, or ``None``.  If ``None``,
+    the default, then dnspython will use the default backend.
+
+    See :py:func:`dns.query.udp()` for the documentation of the other
+    parameters, exceptions, and return type of this method.
+    """
+    wire = q.to_wire()
+    (begin_time, expiration) = _compute_times(timeout)
+    af = dns.inet.af_for_address(where)
+    destination = _lltuple((where, port), af)
+    if sock:
+        cm: contextlib.AbstractAsyncContextManager = NullContext(sock)
+    else:
+        if not backend:
+            backend = dns.asyncbackend.get_default_backend()
+        stuple = _source_tuple(af, source, source_port)
+        if backend.datagram_connection_required():
+            dtuple = (where, port)
+        else:
+            dtuple = None
+        cm = await backend.make_socket(af, socket.SOCK_DGRAM, 0, stuple, dtuple)
+    async with cm as s:
+        await send_udp(s, wire, destination, expiration)
+        (r, received_time, _) = await receive_udp(
+            s,
+            destination,
+            expiration,
+            ignore_unexpected,
+            one_rr_per_rrset,
+            q.keyring,
+            q.mac,
+            ignore_trailing,
+            raise_on_truncation,
+            ignore_errors,
+            q,
+        )
+        r.time = received_time - begin_time
+        # We don't need to check q.is_response() if we are in ignore_errors mode
+        # as receive_udp() will have checked it.
+        if not (ignore_errors or q.is_response(r)):
+            raise BadResponse
+        return r
+
+
+async def udp_with_fallback(
+    q: dns.message.Message,
+    where: str,
+    timeout: Optional[float] = None,
+    port: int = 53,
+    source: Optional[str] = None,
+    source_port: int = 0,
+    ignore_unexpected: bool = False,
+    one_rr_per_rrset: bool = False,
+    ignore_trailing: bool = False,
+    udp_sock: Optional[dns.asyncbackend.DatagramSocket] = None,
+    tcp_sock: Optional[dns.asyncbackend.StreamSocket] = None,
+    backend: Optional[dns.asyncbackend.Backend] = None,
+    ignore_errors: bool = False,
+) -> Tuple[dns.message.Message, bool]:
+    """Return the response to the query, trying UDP first and falling back
+    to TCP if UDP results in a truncated response.
+
+    *udp_sock*, a ``dns.asyncbackend.DatagramSocket``, or ``None``,
+    the socket to use for the UDP query.  If ``None``, the default, a
+    socket is created.  Note that if a socket is provided the *source*,
+    *source_port*, and *backend* are ignored for the UDP query.
+
+    *tcp_sock*, a ``dns.asyncbackend.StreamSocket``, or ``None``, the
+    socket to use for the TCP query.  If ``None``, the default, a
+    socket is created.  Note that if a socket is provided *where*,
+    *source*, *source_port*, and *backend*  are ignored for the TCP query.
+
+    *backend*, a ``dns.asyncbackend.Backend``, or ``None``.  If ``None``,
+    the default, then dnspython will use the default backend.
+
+    See :py:func:`dns.query.udp_with_fallback()` for the documentation
+    of the other parameters, exceptions, and return type of this
+    method.
+    """
+    try:
+        response = await udp(
+            q,
+            where,
+            timeout,
+            port,
+            source,
+            source_port,
+            ignore_unexpected,
+            one_rr_per_rrset,
+            ignore_trailing,
+            True,
+            udp_sock,
+            backend,
+            ignore_errors,
+        )
+        return (response, False)
+    except dns.message.Truncated:
+        response = await tcp(
+            q,
+            where,
+            timeout,
+            port,
+            source,
+            source_port,
+            one_rr_per_rrset,
+            ignore_trailing,
+            tcp_sock,
+            backend,
+        )
+        return (response, True)
+
+
+async def send_tcp(
+    sock: dns.asyncbackend.StreamSocket,
+    what: Union[dns.message.Message, bytes],
+    expiration: Optional[float] = None,
+) -> Tuple[int, float]:
+    """Send a DNS message to the specified TCP socket.
+
+    *sock*, a ``dns.asyncbackend.StreamSocket``.
+
+    See :py:func:`dns.query.send_tcp()` for the documentation of the other
+    parameters, exceptions, and return type of this method.
+    """
+
+    if isinstance(what, dns.message.Message):
+        tcpmsg = what.to_wire(prepend_length=True)
+    else:
+        # copying the wire into tcpmsg is inefficient, but lets us
+        # avoid writev() or doing a short write that would get pushed
+        # onto the net
+        tcpmsg = len(what).to_bytes(2, "big") + what
+    sent_time = time.time()
+    await sock.sendall(tcpmsg, _timeout(expiration, sent_time))
+    return (len(tcpmsg), sent_time)
+
+
+async def _read_exactly(sock, count, expiration):
+    """Read the specified number of bytes from stream.  Keep trying until we
+    either get the desired amount, or we hit EOF.
+    """
+    s = b""
+    while count > 0:
+        n = await sock.recv(count, _timeout(expiration))
+        if n == b"":
+            raise EOFError("EOF")
+        count = count - len(n)
+        s = s + n
+    return s
+
+
+async def receive_tcp(
+    sock: dns.asyncbackend.StreamSocket,
+    expiration: Optional[float] = None,
+    one_rr_per_rrset: bool = False,
+    keyring: Optional[Dict[dns.name.Name, dns.tsig.Key]] = None,
+    request_mac: Optional[bytes] = b"",
+    ignore_trailing: bool = False,
+) -> Tuple[dns.message.Message, float]:
+    """Read a DNS message from a TCP socket.
+
+    *sock*, a ``dns.asyncbackend.StreamSocket``.
+
+    See :py:func:`dns.query.receive_tcp()` for the documentation of the other
+    parameters, exceptions, and return type of this method.
+    """
+
+    ldata = await _read_exactly(sock, 2, expiration)
+    (l,) = struct.unpack("!H", ldata)
+    wire = await _read_exactly(sock, l, expiration)
+    received_time = time.time()
+    r = dns.message.from_wire(
+        wire,
+        keyring=keyring,
+        request_mac=request_mac,
+        one_rr_per_rrset=one_rr_per_rrset,
+        ignore_trailing=ignore_trailing,
+    )
+    return (r, received_time)
+
+
+async def tcp(
+    q: dns.message.Message,
+    where: str,
+    timeout: Optional[float] = None,
+    port: int = 53,
+    source: Optional[str] = None,
+    source_port: int = 0,
+    one_rr_per_rrset: bool = False,
+    ignore_trailing: bool = False,
+    sock: Optional[dns.asyncbackend.StreamSocket] = None,
+    backend: Optional[dns.asyncbackend.Backend] = None,
+) -> dns.message.Message:
+    """Return the response obtained after sending a query via TCP.
+
+    *sock*, a ``dns.asyncbacket.StreamSocket``, or ``None``, the
+    socket to use for the query.  If ``None``, the default, a socket
+    is created.  Note that if a socket is provided
+    *where*, *port*, *source*, *source_port*, and *backend* are ignored.
+
+    *backend*, a ``dns.asyncbackend.Backend``, or ``None``.  If ``None``,
+    the default, then dnspython will use the default backend.
+
+    See :py:func:`dns.query.tcp()` for the documentation of the other
+    parameters, exceptions, and return type of this method.
+    """
+
+    wire = q.to_wire()
+    (begin_time, expiration) = _compute_times(timeout)
+    if sock:
+        # Verify that the socket is connected, as if it's not connected,
+        # it's not writable, and the polling in send_tcp() will time out or
+        # hang forever.
+        await sock.getpeername()
+        cm: contextlib.AbstractAsyncContextManager = NullContext(sock)
+    else:
+        # These are simple (address, port) pairs, not family-dependent tuples
+        # you pass to low-level socket code.
+        af = dns.inet.af_for_address(where)
+        stuple = _source_tuple(af, source, source_port)
+        dtuple = (where, port)
+        if not backend:
+            backend = dns.asyncbackend.get_default_backend()
+        cm = await backend.make_socket(
+            af, socket.SOCK_STREAM, 0, stuple, dtuple, timeout
+        )
+    async with cm as s:
+        await send_tcp(s, wire, expiration)
+        (r, received_time) = await receive_tcp(
+            s, expiration, one_rr_per_rrset, q.keyring, q.mac, ignore_trailing
+        )
+        r.time = received_time - begin_time
+        if not q.is_response(r):
+            raise BadResponse
+        return r
+
+
+async def tls(
+    q: dns.message.Message,
+    where: str,
+    timeout: Optional[float] = None,
+    port: int = 853,
+    source: Optional[str] = None,
+    source_port: int = 0,
+    one_rr_per_rrset: bool = False,
+    ignore_trailing: bool = False,
+    sock: Optional[dns.asyncbackend.StreamSocket] = None,
+    backend: Optional[dns.asyncbackend.Backend] = None,
+    ssl_context: Optional[ssl.SSLContext] = None,
+    server_hostname: Optional[str] = None,
+    verify: Union[bool, str] = True,
+) -> dns.message.Message:
+    """Return the response obtained after sending a query via TLS.
+
+    *sock*, an ``asyncbackend.StreamSocket``, or ``None``, the socket
+    to use for the query.  If ``None``, the default, a socket is
+    created.  Note that if a socket is provided, it must be a
+    connected SSL stream socket, and *where*, *port*,
+    *source*, *source_port*, *backend*, *ssl_context*, and *server_hostname*
+    are ignored.
+
+    *backend*, a ``dns.asyncbackend.Backend``, or ``None``.  If ``None``,
+    the default, then dnspython will use the default backend.
+
+    See :py:func:`dns.query.tls()` for the documentation of the other
+    parameters, exceptions, and return type of this method.
+    """
+    (begin_time, expiration) = _compute_times(timeout)
+    if sock:
+        cm: contextlib.AbstractAsyncContextManager = NullContext(sock)
+    else:
+        if ssl_context is None:
+            ssl_context = _make_dot_ssl_context(server_hostname, verify)
+        af = dns.inet.af_for_address(where)
+        stuple = _source_tuple(af, source, source_port)
+        dtuple = (where, port)
+        if not backend:
+            backend = dns.asyncbackend.get_default_backend()
+        cm = await backend.make_socket(
+            af,
+            socket.SOCK_STREAM,
+            0,
+            stuple,
+            dtuple,
+            timeout,
+            ssl_context,
+            server_hostname,
+        )
+    async with cm as s:
+        timeout = _timeout(expiration)
+        response = await tcp(
+            q,
+            where,
+            timeout,
+            port,
+            source,
+            source_port,
+            one_rr_per_rrset,
+            ignore_trailing,
+            s,
+            backend,
+        )
+        end_time = time.time()
+        response.time = end_time - begin_time
+        return response
+
+
+def _maybe_get_resolver(
+    resolver: Optional["dns.asyncresolver.Resolver"],
+) -> "dns.asyncresolver.Resolver":
+    # We need a separate method for this to avoid overriding the global
+    # variable "dns" with the as-yet undefined local variable "dns"
+    # in https().
+    if resolver is None:
+        # pylint: disable=import-outside-toplevel,redefined-outer-name
+        import dns.asyncresolver
+
+        resolver = dns.asyncresolver.Resolver()
+    return resolver
+
+
+async def https(
+    q: dns.message.Message,
+    where: str,
+    timeout: Optional[float] = None,
+    port: int = 443,
+    source: Optional[str] = None,
+    source_port: int = 0,  # pylint: disable=W0613
+    one_rr_per_rrset: bool = False,
+    ignore_trailing: bool = False,
+    client: Optional["httpx.AsyncClient"] = None,
+    path: str = "/dns-query",
+    post: bool = True,
+    verify: Union[bool, str] = True,
+    bootstrap_address: Optional[str] = None,
+    resolver: Optional["dns.asyncresolver.Resolver"] = None,
+    family: int = socket.AF_UNSPEC,
+    http_version: HTTPVersion = HTTPVersion.DEFAULT,
+) -> dns.message.Message:
+    """Return the response obtained after sending a query via DNS-over-HTTPS.
+
+    *client*, a ``httpx.AsyncClient``.  If provided, the client to use for
+    the query.
+
+    Unlike the other dnspython async functions, a backend cannot be provided
+    in this function because httpx always auto-detects the async backend.
+
+    See :py:func:`dns.query.https()` for the documentation of the other
+    parameters, exceptions, and return type of this method.
+    """
+
+    try:
+        af = dns.inet.af_for_address(where)
+    except ValueError:
+        af = None
+    if af is not None and dns.inet.is_address(where):
+        if af == socket.AF_INET:
+            url = f"https://{where}:{port}{path}"
+        elif af == socket.AF_INET6:
+            url = f"https://[{where}]:{port}{path}"
+    else:
+        url = where
+
+    extensions = {}
+    if bootstrap_address is None:
+        # pylint: disable=possibly-used-before-assignment
+        parsed = urllib.parse.urlparse(url)
+        if parsed.hostname is None:
+            raise ValueError("no hostname in URL")
+        if dns.inet.is_address(parsed.hostname):
+            bootstrap_address = parsed.hostname
+            extensions["sni_hostname"] = parsed.hostname
+        if parsed.port is not None:
+            port = parsed.port
+
+    if http_version == HTTPVersion.H3 or (
+        http_version == HTTPVersion.DEFAULT and not have_doh
+    ):
+        if bootstrap_address is None:
+            resolver = _maybe_get_resolver(resolver)
+            assert parsed.hostname is not None  # for mypy
+            answers = await resolver.resolve_name(parsed.hostname, family)
+            bootstrap_address = random.choice(list(answers.addresses()))
+        return await _http3(
+            q,
+            bootstrap_address,
+            url,
+            timeout,
+            port,
+            source,
+            source_port,
+            one_rr_per_rrset,
+            ignore_trailing,
+            verify=verify,
+            post=post,
+        )
+
+    if not have_doh:
+        raise NoDOH  # pragma: no cover
+    # pylint: disable=possibly-used-before-assignment
+    if client and not isinstance(client, httpx.AsyncClient):
+        raise ValueError("session parameter must be an httpx.AsyncClient")
+    # pylint: enable=possibly-used-before-assignment
+
+    wire = q.to_wire()
+    headers = {"accept": "application/dns-message"}
+
+    h1 = http_version in (HTTPVersion.H1, HTTPVersion.DEFAULT)
+    h2 = http_version in (HTTPVersion.H2, HTTPVersion.DEFAULT)
+
+    backend = dns.asyncbackend.get_default_backend()
+
+    if source is None:
+        local_address = None
+        local_port = 0
+    else:
+        local_address = source
+        local_port = source_port
+
+    if client:
+        cm: contextlib.AbstractAsyncContextManager = NullContext(client)
+    else:
+        transport = backend.get_transport_class()(
+            local_address=local_address,
+            http1=h1,
+            http2=h2,
+            verify=verify,
+            local_port=local_port,
+            bootstrap_address=bootstrap_address,
+            resolver=resolver,
+            family=family,
+        )
+
+        cm = httpx.AsyncClient(http1=h1, http2=h2, verify=verify, transport=transport)
+
+    async with cm as the_client:
+        # see https://tools.ietf.org/html/rfc8484#section-4.1.1 for DoH
+        # GET and POST examples
+        if post:
+            headers.update(
+                {
+                    "content-type": "application/dns-message",
+                    "content-length": str(len(wire)),
+                }
+            )
+            response = await backend.wait_for(
+                the_client.post(
+                    url,
+                    headers=headers,
+                    content=wire,
+                    extensions=extensions,
+                ),
+                timeout,
+            )
+        else:
+            wire = base64.urlsafe_b64encode(wire).rstrip(b"=")
+            twire = wire.decode()  # httpx does a repr() if we give it bytes
+            response = await backend.wait_for(
+                the_client.get(
+                    url,
+                    headers=headers,
+                    params={"dns": twire},
+                    extensions=extensions,
+                ),
+                timeout,
+            )
+
+    # see https://tools.ietf.org/html/rfc8484#section-4.2.1 for info about DoH
+    # status codes
+    if response.status_code < 200 or response.status_code > 299:
+        raise ValueError(
+            f"{where} responded with status code {response.status_code}"
+            f"\nResponse body: {response.content!r}"
+        )
+    r = dns.message.from_wire(
+        response.content,
+        keyring=q.keyring,
+        request_mac=q.request_mac,
+        one_rr_per_rrset=one_rr_per_rrset,
+        ignore_trailing=ignore_trailing,
+    )
+    r.time = response.elapsed.total_seconds()
+    if not q.is_response(r):
+        raise BadResponse
+    return r
+
+
+async def _http3(
+    q: dns.message.Message,
+    where: str,
+    url: str,
+    timeout: Optional[float] = None,
+    port: int = 853,
+    source: Optional[str] = None,
+    source_port: int = 0,
+    one_rr_per_rrset: bool = False,
+    ignore_trailing: bool = False,
+    verify: Union[bool, str] = True,
+    backend: Optional[dns.asyncbackend.Backend] = None,
+    hostname: Optional[str] = None,
+    post: bool = True,
+) -> dns.message.Message:
+    if not dns.quic.have_quic:
+        raise NoDOH("DNS-over-HTTP3 is not available.")  # pragma: no cover
+
+    url_parts = urllib.parse.urlparse(url)
+    hostname = url_parts.hostname
+    if url_parts.port is not None:
+        port = url_parts.port
+
+    q.id = 0
+    wire = q.to_wire()
+    (cfactory, mfactory) = dns.quic.factories_for_backend(backend)
+
+    async with cfactory() as context:
+        async with mfactory(
+            context, verify_mode=verify, server_name=hostname, h3=True
+        ) as the_manager:
+            the_connection = the_manager.connect(where, port, source, source_port)
+            (start, expiration) = _compute_times(timeout)
+            stream = await the_connection.make_stream(timeout)
+            async with stream:
+                # note that send_h3() does not need await
+                stream.send_h3(url, wire, post)
+                wire = await stream.receive(_remaining(expiration))
+                _check_status(stream.headers(), where, wire)
+            finish = time.time()
+        r = dns.message.from_wire(
+            wire,
+            keyring=q.keyring,
+            request_mac=q.request_mac,
+            one_rr_per_rrset=one_rr_per_rrset,
+            ignore_trailing=ignore_trailing,
+        )
+    r.time = max(finish - start, 0.0)
+    if not q.is_response(r):
+        raise BadResponse
+    return r
+
+
+async def quic(
+    q: dns.message.Message,
+    where: str,
+    timeout: Optional[float] = None,
+    port: int = 853,
+    source: Optional[str] = None,
+    source_port: int = 0,
+    one_rr_per_rrset: bool = False,
+    ignore_trailing: bool = False,
+    connection: Optional[dns.quic.AsyncQuicConnection] = None,
+    verify: Union[bool, str] = True,
+    backend: Optional[dns.asyncbackend.Backend] = None,
+    hostname: Optional[str] = None,
+    server_hostname: Optional[str] = None,
+) -> dns.message.Message:
+    """Return the response obtained after sending an asynchronous query via
+    DNS-over-QUIC.
+
+    *backend*, a ``dns.asyncbackend.Backend``, or ``None``.  If ``None``,
+    the default, then dnspython will use the default backend.
+
+    See :py:func:`dns.query.quic()` for the documentation of the other
+    parameters, exceptions, and return type of this method.
+    """
+
+    if not dns.quic.have_quic:
+        raise NoDOQ("DNS-over-QUIC is not available.")  # pragma: no cover
+
+    if server_hostname is not None and hostname is None:
+        hostname = server_hostname
+
+    q.id = 0
+    wire = q.to_wire()
+    the_connection: dns.quic.AsyncQuicConnection
+    if connection:
+        cfactory = dns.quic.null_factory
+        mfactory = dns.quic.null_factory
+        the_connection = connection
+    else:
+        (cfactory, mfactory) = dns.quic.factories_for_backend(backend)
+
+    async with cfactory() as context:
+        async with mfactory(
+            context,
+            verify_mode=verify,
+            server_name=server_hostname,
+        ) as the_manager:
+            if not connection:
+                the_connection = the_manager.connect(where, port, source, source_port)
+            (start, expiration) = _compute_times(timeout)
+            stream = await the_connection.make_stream(timeout)
+            async with stream:
+                await stream.send(wire, True)
+                wire = await stream.receive(_remaining(expiration))
+            finish = time.time()
+        r = dns.message.from_wire(
+            wire,
+            keyring=q.keyring,
+            request_mac=q.request_mac,
+            one_rr_per_rrset=one_rr_per_rrset,
+            ignore_trailing=ignore_trailing,
+        )
+    r.time = max(finish - start, 0.0)
+    if not q.is_response(r):
+        raise BadResponse
+    return r
+
+
+async def _inbound_xfr(
+    txn_manager: dns.transaction.TransactionManager,
+    s: dns.asyncbackend.Socket,
+    query: dns.message.Message,
+    serial: Optional[int],
+    timeout: Optional[float],
+    expiration: float,
+) -> Any:
+    """Given a socket, does the zone transfer."""
+    rdtype = query.question[0].rdtype
+    is_ixfr = rdtype == dns.rdatatype.IXFR
+    origin = txn_manager.from_wire_origin()
+    wire = query.to_wire()
+    is_udp = s.type == socket.SOCK_DGRAM
+    if is_udp:
+        udp_sock = cast(dns.asyncbackend.DatagramSocket, s)
+        await udp_sock.sendto(wire, None, _timeout(expiration))
+    else:
+        tcp_sock = cast(dns.asyncbackend.StreamSocket, s)
+        tcpmsg = struct.pack("!H", len(wire)) + wire
+        await tcp_sock.sendall(tcpmsg, expiration)
+    with dns.xfr.Inbound(txn_manager, rdtype, serial, is_udp) as inbound:
+        done = False
+        tsig_ctx = None
+        while not done:
+            (_, mexpiration) = _compute_times(timeout)
+            if mexpiration is None or (
+                expiration is not None and mexpiration > expiration
+            ):
+                mexpiration = expiration
+            if is_udp:
+                timeout = _timeout(mexpiration)
+                (rwire, _) = await udp_sock.recvfrom(65535, timeout)
+            else:
+                ldata = await _read_exactly(tcp_sock, 2, mexpiration)
+                (l,) = struct.unpack("!H", ldata)
+                rwire = await _read_exactly(tcp_sock, l, mexpiration)
+            r = dns.message.from_wire(
+                rwire,
+                keyring=query.keyring,
+                request_mac=query.mac,
+                xfr=True,
+                origin=origin,
+                tsig_ctx=tsig_ctx,
+                multi=(not is_udp),
+                one_rr_per_rrset=is_ixfr,
+            )
+            done = inbound.process_message(r)
+            yield r
+            tsig_ctx = r.tsig_ctx
+        if query.keyring and not r.had_tsig:
+            raise dns.exception.FormError("missing TSIG")
+
+
+async def inbound_xfr(
+    where: str,
+    txn_manager: dns.transaction.TransactionManager,
+    query: Optional[dns.message.Message] = None,
+    port: int = 53,
+    timeout: Optional[float] = None,
+    lifetime: Optional[float] = None,
+    source: Optional[str] = None,
+    source_port: int = 0,
+    udp_mode: UDPMode = UDPMode.NEVER,
+    backend: Optional[dns.asyncbackend.Backend] = None,
+) -> None:
+    """Conduct an inbound transfer and apply it via a transaction from the
+    txn_manager.
+
+    *backend*, a ``dns.asyncbackend.Backend``, or ``None``.  If ``None``,
+    the default, then dnspython will use the default backend.
+
+    See :py:func:`dns.query.inbound_xfr()` for the documentation of
+    the other parameters, exceptions, and return type of this method.
+    """
+    if query is None:
+        (query, serial) = dns.xfr.make_query(txn_manager)
+    else:
+        serial = dns.xfr.extract_serial_from_query(query)
+    af = dns.inet.af_for_address(where)
+    stuple = _source_tuple(af, source, source_port)
+    dtuple = (where, port)
+    if not backend:
+        backend = dns.asyncbackend.get_default_backend()
+    (_, expiration) = _compute_times(lifetime)
+    if query.question[0].rdtype == dns.rdatatype.IXFR and udp_mode != UDPMode.NEVER:
+        s = await backend.make_socket(
+            af, socket.SOCK_DGRAM, 0, stuple, dtuple, _timeout(expiration)
+        )
+        async with s:
+            try:
+                async for _ in _inbound_xfr(
+                    txn_manager, s, query, serial, timeout, expiration
+                ):
+                    pass
+                return
+            except dns.xfr.UseTCP:
+                if udp_mode == UDPMode.ONLY:
+                    raise
+
+    s = await backend.make_socket(
+        af, socket.SOCK_STREAM, 0, stuple, dtuple, _timeout(expiration)
+    )
+    async with s:
+        async for _ in _inbound_xfr(txn_manager, s, query, serial, timeout, expiration):
+            pass
diff --git a/.venv/lib/python3.12/site-packages/dns/asyncresolver.py b/.venv/lib/python3.12/site-packages/dns/asyncresolver.py
new file mode 100644
index 00000000..8f5e062a
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/dns/asyncresolver.py
@@ -0,0 +1,475 @@
+# Copyright (C) Dnspython Contributors, see LICENSE for text of ISC license
+
+# Copyright (C) 2003-2017 Nominum, Inc.
+#
+# Permission to use, copy, modify, and distribute this software and its
+# documentation for any purpose with or without fee is hereby granted,
+# provided that the above copyright notice and this permission notice
+# appear in all copies.
+#
+# THE SOFTWARE IS PROVIDED "AS IS" AND NOMINUM DISCLAIMS ALL WARRANTIES
+# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL NOMINUM BE LIABLE FOR
+# ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+# ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT
+# OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+
+"""Asynchronous DNS stub resolver."""
+
+import socket
+import time
+from typing import Any, Dict, List, Optional, Union
+
+import dns._ddr
+import dns.asyncbackend
+import dns.asyncquery
+import dns.exception
+import dns.name
+import dns.query
+import dns.rdataclass
+import dns.rdatatype
+import dns.resolver  # lgtm[py/import-and-import-from]
+
+# import some resolver symbols for brevity
+from dns.resolver import NXDOMAIN, NoAnswer, NoRootSOA, NotAbsolute
+
+# for indentation purposes below
+_udp = dns.asyncquery.udp
+_tcp = dns.asyncquery.tcp
+
+
+class Resolver(dns.resolver.BaseResolver):
+    """Asynchronous DNS stub resolver."""
+
+    async def resolve(
+        self,
+        qname: Union[dns.name.Name, str],
+        rdtype: Union[dns.rdatatype.RdataType, str] = dns.rdatatype.A,
+        rdclass: Union[dns.rdataclass.RdataClass, str] = dns.rdataclass.IN,
+        tcp: bool = False,
+        source: Optional[str] = None,
+        raise_on_no_answer: bool = True,
+        source_port: int = 0,
+        lifetime: Optional[float] = None,
+        search: Optional[bool] = None,
+        backend: Optional[dns.asyncbackend.Backend] = None,
+    ) -> dns.resolver.Answer:
+        """Query nameservers asynchronously to find the answer to the question.
+
+        *backend*, a ``dns.asyncbackend.Backend``, or ``None``.  If ``None``,
+        the default, then dnspython will use the default backend.
+
+        See :py:func:`dns.resolver.Resolver.resolve()` for the
+        documentation of the other parameters, exceptions, and return
+        type of this method.
+        """
+
+        resolution = dns.resolver._Resolution(
+            self, qname, rdtype, rdclass, tcp, raise_on_no_answer, search
+        )
+        if not backend:
+            backend = dns.asyncbackend.get_default_backend()
+        start = time.time()
+        while True:
+            (request, answer) = resolution.next_request()
+            # Note we need to say "if answer is not None" and not just
+            # "if answer" because answer implements __len__, and python
+            # will call that.  We want to return if we have an answer
+            # object, including in cases where its length is 0.
+            if answer is not None:
+                # cache hit!
+                return answer
+            assert request is not None  # needed for type checking
+            done = False
+            while not done:
+                (nameserver, tcp, backoff) = resolution.next_nameserver()
+                if backoff:
+                    await backend.sleep(backoff)
+                timeout = self._compute_timeout(start, lifetime, resolution.errors)
+                try:
+                    response = await nameserver.async_query(
+                        request,
+                        timeout=timeout,
+                        source=source,
+                        source_port=source_port,
+                        max_size=tcp,
+                        backend=backend,
+                    )
+                except Exception as ex:
+                    (_, done) = resolution.query_result(None, ex)
+                    continue
+                (answer, done) = resolution.query_result(response, None)
+                # Note we need to say "if answer is not None" and not just
+                # "if answer" because answer implements __len__, and python
+                # will call that.  We want to return if we have an answer
+                # object, including in cases where its length is 0.
+                if answer is not None:
+                    return answer
+
+    async def resolve_address(
+        self, ipaddr: str, *args: Any, **kwargs: Any
+    ) -> dns.resolver.Answer:
+        """Use an asynchronous resolver to run a reverse query for PTR
+        records.
+
+        This utilizes the resolve() method to perform a PTR lookup on the
+        specified IP address.
+
+        *ipaddr*, a ``str``, the IPv4 or IPv6 address you want to get
+        the PTR record for.
+
+        All other arguments that can be passed to the resolve() function
+        except for rdtype and rdclass are also supported by this
+        function.
+
+        """
+        # We make a modified kwargs for type checking happiness, as otherwise
+        # we get a legit warning about possibly having rdtype and rdclass
+        # in the kwargs more than once.
+        modified_kwargs: Dict[str, Any] = {}
+        modified_kwargs.update(kwargs)
+        modified_kwargs["rdtype"] = dns.rdatatype.PTR
+        modified_kwargs["rdclass"] = dns.rdataclass.IN
+        return await self.resolve(
+            dns.reversename.from_address(ipaddr), *args, **modified_kwargs
+        )
+
+    async def resolve_name(
+        self,
+        name: Union[dns.name.Name, str],
+        family: int = socket.AF_UNSPEC,
+        **kwargs: Any,
+    ) -> dns.resolver.HostAnswers:
+        """Use an asynchronous resolver to query for address records.
+
+        This utilizes the resolve() method to perform A and/or AAAA lookups on
+        the specified name.
+
+        *qname*, a ``dns.name.Name`` or ``str``, the name to resolve.
+
+        *family*, an ``int``, the address family.  If socket.AF_UNSPEC
+        (the default), both A and AAAA records will be retrieved.
+
+        All other arguments that can be passed to the resolve() function
+        except for rdtype and rdclass are also supported by this
+        function.
+        """
+        # We make a modified kwargs for type checking happiness, as otherwise
+        # we get a legit warning about possibly having rdtype and rdclass
+        # in the kwargs more than once.
+        modified_kwargs: Dict[str, Any] = {}
+        modified_kwargs.update(kwargs)
+        modified_kwargs.pop("rdtype", None)
+        modified_kwargs["rdclass"] = dns.rdataclass.IN
+
+        if family == socket.AF_INET:
+            v4 = await self.resolve(name, dns.rdatatype.A, **modified_kwargs)
+            return dns.resolver.HostAnswers.make(v4=v4)
+        elif family == socket.AF_INET6:
+            v6 = await self.resolve(name, dns.rdatatype.AAAA, **modified_kwargs)
+            return dns.resolver.HostAnswers.make(v6=v6)
+        elif family != socket.AF_UNSPEC:
+            raise NotImplementedError(f"unknown address family {family}")
+
+        raise_on_no_answer = modified_kwargs.pop("raise_on_no_answer", True)
+        lifetime = modified_kwargs.pop("lifetime", None)
+        start = time.time()
+        v6 = await self.resolve(
+            name,
+            dns.rdatatype.AAAA,
+            raise_on_no_answer=False,
+            lifetime=self._compute_timeout(start, lifetime),
+            **modified_kwargs,
+        )
+        # Note that setting name ensures we query the same name
+        # for A as we did for AAAA.  (This is just in case search lists
+        # are active by default in the resolver configuration and
+        # we might be talking to a server that says NXDOMAIN when it
+        # wants to say NOERROR no data.
+        name = v6.qname
+        v4 = await self.resolve(
+            name,
+            dns.rdatatype.A,
+            raise_on_no_answer=False,
+            lifetime=self._compute_timeout(start, lifetime),
+            **modified_kwargs,
+        )
+        answers = dns.resolver.HostAnswers.make(
+            v6=v6, v4=v4, add_empty=not raise_on_no_answer
+        )
+        if not answers:
+            raise NoAnswer(response=v6.response)
+        return answers
+
+    # pylint: disable=redefined-outer-name
+
+    async def canonical_name(self, name: Union[dns.name.Name, str]) -> dns.name.Name:
+        """Determine the canonical name of *name*.
+
+        The canonical name is the name the resolver uses for queries
+        after all CNAME and DNAME renamings have been applied.
+
+        *name*, a ``dns.name.Name`` or ``str``, the query name.
+
+        This method can raise any exception that ``resolve()`` can
+        raise, other than ``dns.resolver.NoAnswer`` and
+        ``dns.resolver.NXDOMAIN``.
+
+        Returns a ``dns.name.Name``.
+        """
+        try:
+            answer = await self.resolve(name, raise_on_no_answer=False)
+            canonical_name = answer.canonical_name
+        except dns.resolver.NXDOMAIN as e:
+            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
+
+
+def get_default_resolver() -> Resolver:
+    """Get the default asynchronous resolver, initializing it if necessary."""
+    if default_resolver is None:
+        reset_default_resolver()
+    assert default_resolver is not None
+    return default_resolver
+
+
+def reset_default_resolver() -> None:
+    """Re-initialize default asynchronous resolver.
+
+    Note that the resolver configuration (i.e. /etc/resolv.conf on UNIX
+    systems) will be re-read immediately.
+    """
+
+    global default_resolver
+    default_resolver = Resolver()
+
+
+async def resolve(
+    qname: Union[dns.name.Name, str],
+    rdtype: Union[dns.rdatatype.RdataType, str] = dns.rdatatype.A,
+    rdclass: Union[dns.rdataclass.RdataClass, str] = dns.rdataclass.IN,
+    tcp: bool = False,
+    source: Optional[str] = None,
+    raise_on_no_answer: bool = True,
+    source_port: int = 0,
+    lifetime: Optional[float] = None,
+    search: Optional[bool] = None,
+    backend: Optional[dns.asyncbackend.Backend] = None,
+) -> dns.resolver.Answer:
+    """Query nameservers asynchronously to find the answer to the question.
+
+    This is a convenience function that uses the default resolver
+    object to make the query.
+
+    See :py:func:`dns.asyncresolver.Resolver.resolve` for more
+    information on the parameters.
+    """
+
+    return await get_default_resolver().resolve(
+        qname,
+        rdtype,
+        rdclass,
+        tcp,
+        source,
+        raise_on_no_answer,
+        source_port,
+        lifetime,
+        search,
+        backend,
+    )
+
+
+async def resolve_address(
+    ipaddr: str, *args: Any, **kwargs: Any
+) -> dns.resolver.Answer:
+    """Use a resolver to run a reverse query for PTR records.
+
+    See :py:func:`dns.asyncresolver.Resolver.resolve_address` for more
+    information on the parameters.
+    """
+
+    return await get_default_resolver().resolve_address(ipaddr, *args, **kwargs)
+
+
+async def resolve_name(
+    name: Union[dns.name.Name, str], family: int = socket.AF_UNSPEC, **kwargs: Any
+) -> dns.resolver.HostAnswers:
+    """Use a resolver to asynchronously query for address records.
+
+    See :py:func:`dns.asyncresolver.Resolver.resolve_name` for more
+    information on the parameters.
+    """
+
+    return await get_default_resolver().resolve_name(name, family, **kwargs)
+
+
+async def canonical_name(name: Union[dns.name.Name, str]) -> dns.name.Name:
+    """Determine the canonical name of *name*.
+
+    See :py:func:`dns.resolver.Resolver.canonical_name` for more
+    information on the parameters and possible exceptions.
+    """
+
+    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,
+    tcp: bool = False,
+    resolver: Optional[Resolver] = None,
+    backend: Optional[dns.asyncbackend.Backend] = None,
+) -> dns.name.Name:
+    """Find the name of the zone which contains the specified name.
+
+    See :py:func:`dns.resolver.Resolver.zone_for_name` for more
+    information on the parameters and possible exceptions.
+    """
+
+    if isinstance(name, str):
+        name = dns.name.from_text(name, dns.name.root)
+    if resolver is None:
+        resolver = get_default_resolver()
+    if not name.is_absolute():
+        raise NotAbsolute(name)
+    while True:
+        try:
+            answer = await resolver.resolve(
+                name, dns.rdatatype.SOA, rdclass, tcp, backend=backend
+            )
+            assert answer.rrset is not None
+            if answer.rrset.name == name:
+                return name
+            # otherwise we were CNAMEd or DNAMEd and need to look higher
+        except (NXDOMAIN, NoAnswer):
+            pass
+        try:
+            name = name.parent()
+        except dns.name.NoParent:  # pragma: no cover
+            raise NoRootSOA
+
+
+async def make_resolver_at(
+    where: Union[dns.name.Name, str],
+    port: int = 53,
+    family: int = socket.AF_UNSPEC,
+    resolver: Optional[Resolver] = None,
+) -> Resolver:
+    """Make a stub resolver using the specified destination as the full resolver.
+
+    *where*, a ``dns.name.Name`` or ``str`` the domain name or IP address of the
+    full resolver.
+
+    *port*, an ``int``, the port to use.  If not specified, the default is 53.
+
+    *family*, an ``int``, the address family to use.  This parameter is used if
+    *where* is not an address.  The default is ``socket.AF_UNSPEC`` in which case
+    the first address returned by ``resolve_name()`` will be used, otherwise the
+    first address of the specified family will be used.
+
+    *resolver*, a ``dns.asyncresolver.Resolver`` or ``None``, the resolver to use for
+    resolution of hostnames.  If not specified, the default resolver will be used.
+
+    Returns a ``dns.resolver.Resolver`` or raises an exception.
+    """
+    if resolver is None:
+        resolver = get_default_resolver()
+    nameservers: List[Union[str, dns.nameserver.Nameserver]] = []
+    if isinstance(where, str) and dns.inet.is_address(where):
+        nameservers.append(dns.nameserver.Do53Nameserver(where, port))
+    else:
+        answers = await resolver.resolve_name(where, family)
+        for address in answers.addresses():
+            nameservers.append(dns.nameserver.Do53Nameserver(address, port))
+    res = dns.asyncresolver.Resolver(configure=False)
+    res.nameservers = nameservers
+    return res
+
+
+async def resolve_at(
+    where: Union[dns.name.Name, str],
+    qname: Union[dns.name.Name, str],
+    rdtype: Union[dns.rdatatype.RdataType, str] = dns.rdatatype.A,
+    rdclass: Union[dns.rdataclass.RdataClass, str] = dns.rdataclass.IN,
+    tcp: bool = False,
+    source: Optional[str] = None,
+    raise_on_no_answer: bool = True,
+    source_port: int = 0,
+    lifetime: Optional[float] = None,
+    search: Optional[bool] = None,
+    backend: Optional[dns.asyncbackend.Backend] = None,
+    port: int = 53,
+    family: int = socket.AF_UNSPEC,
+    resolver: Optional[Resolver] = None,
+) -> dns.resolver.Answer:
+    """Query nameservers to find the answer to the question.
+
+    This is a convenience function that calls ``dns.asyncresolver.make_resolver_at()``
+    to make a resolver, and then uses it to resolve the query.
+
+    See ``dns.asyncresolver.Resolver.resolve`` for more information on the resolution
+    parameters, and ``dns.asyncresolver.make_resolver_at`` for information about the
+    resolver parameters *where*, *port*, *family*, and *resolver*.
+
+    If making more than one query, it is more efficient to call
+    ``dns.asyncresolver.make_resolver_at()`` and then use that resolver for the queries
+    instead of calling ``resolve_at()`` multiple times.
+    """
+    res = await make_resolver_at(where, port, family, resolver)
+    return await res.resolve(
+        qname,
+        rdtype,
+        rdclass,
+        tcp,
+        source,
+        raise_on_no_answer,
+        source_port,
+        lifetime,
+        search,
+        backend,
+    )
diff --git a/.venv/lib/python3.12/site-packages/dns/dnssec.py b/.venv/lib/python3.12/site-packages/dns/dnssec.py
new file mode 100644
index 00000000..b69d0a12
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/dns/dnssec.py
@@ -0,0 +1,1247 @@
+# Copyright (C) Dnspython Contributors, see LICENSE for text of ISC license
+
+# Copyright (C) 2003-2017 Nominum, Inc.
+#
+# Permission to use, copy, modify, and distribute this software and its
+# documentation for any purpose with or without fee is hereby granted,
+# provided that the above copyright notice and this permission notice
+# appear in all copies.
+#
+# THE SOFTWARE IS PROVIDED "AS IS" AND NOMINUM DISCLAIMS ALL WARRANTIES
+# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL NOMINUM BE LIABLE FOR
+# ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+# ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT
+# OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+
+"""Common DNSSEC-related functions and constants."""
+
+
+import base64
+import contextlib
+import functools
+import hashlib
+import struct
+import time
+from datetime import datetime
+from typing import Callable, Dict, List, Optional, Set, Tuple, Union, cast
+
+import dns._features
+import dns.exception
+import dns.name
+import dns.node
+import dns.rdata
+import dns.rdataclass
+import dns.rdataset
+import dns.rdatatype
+import dns.rrset
+import dns.transaction
+import dns.zone
+from dns.dnssectypes import Algorithm, DSDigest, NSEC3Hash
+from dns.exception import (  # pylint: disable=W0611
+    AlgorithmKeyMismatch,
+    DeniedByPolicy,
+    UnsupportedAlgorithm,
+    ValidationFailure,
+)
+from dns.rdtypes.ANY.CDNSKEY import CDNSKEY
+from dns.rdtypes.ANY.CDS import CDS
+from dns.rdtypes.ANY.DNSKEY import DNSKEY
+from dns.rdtypes.ANY.DS import DS
+from dns.rdtypes.ANY.NSEC import NSEC, Bitmap
+from dns.rdtypes.ANY.NSEC3PARAM import NSEC3PARAM
+from dns.rdtypes.ANY.RRSIG import RRSIG, sigtime_to_posixtime
+from dns.rdtypes.dnskeybase import Flag
+
+PublicKey = Union[
+    "GenericPublicKey",
+    "rsa.RSAPublicKey",
+    "ec.EllipticCurvePublicKey",
+    "ed25519.Ed25519PublicKey",
+    "ed448.Ed448PublicKey",
+]
+
+PrivateKey = Union[
+    "GenericPrivateKey",
+    "rsa.RSAPrivateKey",
+    "ec.EllipticCurvePrivateKey",
+    "ed25519.Ed25519PrivateKey",
+    "ed448.Ed448PrivateKey",
+]
+
+RRsetSigner = Callable[[dns.transaction.Transaction, dns.rrset.RRset], None]
+
+
+def algorithm_from_text(text: str) -> Algorithm:
+    """Convert text into a DNSSEC algorithm value.
+
+    *text*, a ``str``, the text to convert to into an algorithm value.
+
+    Returns an ``int``.
+    """
+
+    return Algorithm.from_text(text)
+
+
+def algorithm_to_text(value: Union[Algorithm, int]) -> str:
+    """Convert a DNSSEC algorithm value to text
+
+    *value*, a ``dns.dnssec.Algorithm``.
+
+    Returns a ``str``, the name of a DNSSEC algorithm.
+    """
+
+    return Algorithm.to_text(value)
+
+
+def to_timestamp(value: Union[datetime, str, float, int]) -> int:
+    """Convert various format to a timestamp"""
+    if isinstance(value, datetime):
+        return int(value.timestamp())
+    elif isinstance(value, str):
+        return sigtime_to_posixtime(value)
+    elif isinstance(value, float):
+        return int(value)
+    elif isinstance(value, int):
+        return value
+    else:
+        raise TypeError("Unsupported timestamp type")
+
+
+def key_id(key: Union[DNSKEY, CDNSKEY]) -> int:
+    """Return the key id (a 16-bit number) for the specified key.
+
+    *key*, a ``dns.rdtypes.ANY.DNSKEY.DNSKEY``
+
+    Returns an ``int`` between 0 and 65535
+    """
+
+    rdata = key.to_wire()
+    assert rdata is not None  # for mypy
+    if key.algorithm == Algorithm.RSAMD5:
+        return (rdata[-3] << 8) + rdata[-2]
+    else:
+        total = 0
+        for i in range(len(rdata) // 2):
+            total += (rdata[2 * i] << 8) + rdata[2 * i + 1]
+        if len(rdata) % 2 != 0:
+            total += rdata[len(rdata) - 1] << 8
+        total += (total >> 16) & 0xFFFF
+        return total & 0xFFFF
+
+
+class Policy:
+    def __init__(self):
+        pass
+
+    def ok_to_sign(self, _: DNSKEY) -> bool:  # pragma: no cover
+        return False
+
+    def ok_to_validate(self, _: DNSKEY) -> bool:  # pragma: no cover
+        return False
+
+    def ok_to_create_ds(self, _: DSDigest) -> bool:  # pragma: no cover
+        return False
+
+    def ok_to_validate_ds(self, _: DSDigest) -> bool:  # pragma: no cover
+        return False
+
+
+class SimpleDeny(Policy):
+    def __init__(self, deny_sign, deny_validate, deny_create_ds, deny_validate_ds):
+        super().__init__()
+        self._deny_sign = deny_sign
+        self._deny_validate = deny_validate
+        self._deny_create_ds = deny_create_ds
+        self._deny_validate_ds = deny_validate_ds
+
+    def ok_to_sign(self, key: DNSKEY) -> bool:
+        return key.algorithm not in self._deny_sign
+
+    def ok_to_validate(self, key: DNSKEY) -> bool:
+        return key.algorithm not in self._deny_validate
+
+    def ok_to_create_ds(self, algorithm: DSDigest) -> bool:
+        return algorithm not in self._deny_create_ds
+
+    def ok_to_validate_ds(self, algorithm: DSDigest) -> bool:
+        return algorithm not in self._deny_validate_ds
+
+
+rfc_8624_policy = SimpleDeny(
+    {Algorithm.RSAMD5, Algorithm.DSA, Algorithm.DSANSEC3SHA1, Algorithm.ECCGOST},
+    {Algorithm.RSAMD5, Algorithm.DSA, Algorithm.DSANSEC3SHA1},
+    {DSDigest.NULL, DSDigest.SHA1, DSDigest.GOST},
+    {DSDigest.NULL},
+)
+
+allow_all_policy = SimpleDeny(set(), set(), set(), set())
+
+
+default_policy = rfc_8624_policy
+
+
+def make_ds(
+    name: Union[dns.name.Name, str],
+    key: dns.rdata.Rdata,
+    algorithm: Union[DSDigest, str],
+    origin: Optional[dns.name.Name] = None,
+    policy: Optional[Policy] = None,
+    validating: bool = False,
+) -> DS:
+    """Create a DS record for a DNSSEC key.
+
+    *name*, a ``dns.name.Name`` or ``str``, the owner name of the DS record.
+
+    *key*, a ``dns.rdtypes.ANY.DNSKEY.DNSKEY`` or ``dns.rdtypes.ANY.DNSKEY.CDNSKEY``,
+    the key the DS is about.
+
+    *algorithm*, a ``str`` or ``int`` specifying the hash algorithm.
+    The currently supported hashes are "SHA1", "SHA256", and "SHA384". Case
+    does not matter for these strings.
+
+    *origin*, a ``dns.name.Name`` or ``None``.  If *key* is a relative name,
+    then it will be made absolute using the specified origin.
+
+    *policy*, a ``dns.dnssec.Policy`` or ``None``.  If ``None``, the default policy,
+    ``dns.dnssec.default_policy`` is used; this policy defaults to that of RFC 8624.
+
+    *validating*, a ``bool``.  If ``True``, then policy is checked in
+    validating mode, i.e. "Is it ok to validate using this digest algorithm?".
+    Otherwise the policy is checked in creating mode, i.e. "Is it ok to create a DS with
+    this digest algorithm?".
+
+    Raises ``UnsupportedAlgorithm`` if the algorithm is unknown.
+
+    Raises ``DeniedByPolicy`` if the algorithm is denied by policy.
+
+    Returns a ``dns.rdtypes.ANY.DS.DS``
+    """
+
+    if policy is None:
+        policy = default_policy
+    try:
+        if isinstance(algorithm, str):
+            algorithm = DSDigest[algorithm.upper()]
+    except Exception:
+        raise UnsupportedAlgorithm(f'unsupported algorithm "{algorithm}"')
+    if validating:
+        check = policy.ok_to_validate_ds
+    else:
+        check = policy.ok_to_create_ds
+    if not check(algorithm):
+        raise DeniedByPolicy
+    if not isinstance(key, (DNSKEY, CDNSKEY)):
+        raise ValueError("key is not a DNSKEY/CDNSKEY")
+    if algorithm == DSDigest.SHA1:
+        dshash = hashlib.sha1()
+    elif algorithm == DSDigest.SHA256:
+        dshash = hashlib.sha256()
+    elif algorithm == DSDigest.SHA384:
+        dshash = hashlib.sha384()
+    else:
+        raise UnsupportedAlgorithm(f'unsupported algorithm "{algorithm}"')
+
+    if isinstance(name, str):
+        name = dns.name.from_text(name, origin)
+    wire = name.canonicalize().to_wire()
+    kwire = key.to_wire(origin=origin)
+    assert wire is not None and kwire is not None  # for mypy
+    dshash.update(wire)
+    dshash.update(kwire)
+    digest = dshash.digest()
+
+    dsrdata = struct.pack("!HBB", key_id(key), key.algorithm, algorithm) + digest
+    ds = dns.rdata.from_wire(
+        dns.rdataclass.IN, dns.rdatatype.DS, dsrdata, 0, len(dsrdata)
+    )
+    return cast(DS, ds)
+
+
+def make_cds(
+    name: Union[dns.name.Name, str],
+    key: dns.rdata.Rdata,
+    algorithm: Union[DSDigest, str],
+    origin: Optional[dns.name.Name] = None,
+) -> CDS:
+    """Create a CDS record for a DNSSEC key.
+
+    *name*, a ``dns.name.Name`` or ``str``, the owner name of the DS record.
+
+    *key*, a ``dns.rdtypes.ANY.DNSKEY.DNSKEY`` or ``dns.rdtypes.ANY.DNSKEY.CDNSKEY``,
+    the key the DS is about.
+
+    *algorithm*, a ``str`` or ``int`` specifying the hash algorithm.
+    The currently supported hashes are "SHA1", "SHA256", and "SHA384". Case
+    does not matter for these strings.
+
+    *origin*, a ``dns.name.Name`` or ``None``.  If *key* is a relative name,
+    then it will be made absolute using the specified origin.
+
+    Raises ``UnsupportedAlgorithm`` if the algorithm is unknown.
+
+    Returns a ``dns.rdtypes.ANY.DS.CDS``
+    """
+
+    ds = make_ds(name, key, algorithm, origin)
+    return CDS(
+        rdclass=ds.rdclass,
+        rdtype=dns.rdatatype.CDS,
+        key_tag=ds.key_tag,
+        algorithm=ds.algorithm,
+        digest_type=ds.digest_type,
+        digest=ds.digest,
+    )
+
+
+def _find_candidate_keys(
+    keys: Dict[dns.name.Name, Union[dns.rdataset.Rdataset, dns.node.Node]], rrsig: RRSIG
+) -> Optional[List[DNSKEY]]:
+    value = keys.get(rrsig.signer)
+    if isinstance(value, dns.node.Node):
+        rdataset = value.get_rdataset(dns.rdataclass.IN, dns.rdatatype.DNSKEY)
+    else:
+        rdataset = value
+    if rdataset is None:
+        return None
+    return [
+        cast(DNSKEY, rd)
+        for rd in rdataset
+        if rd.algorithm == rrsig.algorithm
+        and key_id(rd) == rrsig.key_tag
+        and (rd.flags & Flag.ZONE) == Flag.ZONE  # RFC 4034 2.1.1
+        and rd.protocol == 3  # RFC 4034 2.1.2
+    ]
+
+
+def _get_rrname_rdataset(
+    rrset: Union[dns.rrset.RRset, Tuple[dns.name.Name, dns.rdataset.Rdataset]],
+) -> Tuple[dns.name.Name, dns.rdataset.Rdataset]:
+    if isinstance(rrset, tuple):
+        return rrset[0], rrset[1]
+    else:
+        return rrset.name, rrset
+
+
+def _validate_signature(sig: bytes, data: bytes, key: DNSKEY) -> None:
+    # pylint: disable=possibly-used-before-assignment
+    public_cls = get_algorithm_cls_from_dnskey(key).public_cls
+    try:
+        public_key = public_cls.from_dnskey(key)
+    except ValueError:
+        raise ValidationFailure("invalid public key")
+    public_key.verify(sig, data)
+
+
+def _validate_rrsig(
+    rrset: Union[dns.rrset.RRset, Tuple[dns.name.Name, dns.rdataset.Rdataset]],
+    rrsig: RRSIG,
+    keys: Dict[dns.name.Name, Union[dns.node.Node, dns.rdataset.Rdataset]],
+    origin: Optional[dns.name.Name] = None,
+    now: Optional[float] = None,
+    policy: Optional[Policy] = None,
+) -> None:
+    """Validate an RRset against a single signature rdata, throwing an
+    exception if validation is not successful.
+
+    *rrset*, the RRset to validate.  This can be a
+    ``dns.rrset.RRset`` or a (``dns.name.Name``, ``dns.rdataset.Rdataset``)
+    tuple.
+
+    *rrsig*, a ``dns.rdata.Rdata``, the signature to validate.
+
+    *keys*, the key dictionary, used to find the DNSKEY associated
+    with a given name.  The dictionary is keyed by a
+    ``dns.name.Name``, and has ``dns.node.Node`` or
+    ``dns.rdataset.Rdataset`` values.
+
+    *origin*, a ``dns.name.Name`` or ``None``, the origin to use for relative
+    names.
+
+    *now*, a ``float`` or ``None``, the time, in seconds since the epoch, to
+    use as the current time when validating.  If ``None``, the actual current
+    time is used.
+
+    *policy*, a ``dns.dnssec.Policy`` or ``None``.  If ``None``, the default policy,
+    ``dns.dnssec.default_policy`` is used; this policy defaults to that of RFC 8624.
+
+    Raises ``ValidationFailure`` if the signature is expired, not yet valid,
+    the public key is invalid, the algorithm is unknown, the verification
+    fails, etc.
+
+    Raises ``UnsupportedAlgorithm`` if the algorithm is recognized by
+    dnspython but not implemented.
+    """
+
+    if policy is None:
+        policy = default_policy
+
+    candidate_keys = _find_candidate_keys(keys, rrsig)
+    if candidate_keys is None:
+        raise ValidationFailure("unknown key")
+
+    if now is None:
+        now = time.time()
+    if rrsig.expiration < now:
+        raise ValidationFailure("expired")
+    if rrsig.inception > now:
+        raise ValidationFailure("not yet valid")
+
+    data = _make_rrsig_signature_data(rrset, rrsig, origin)
+
+    # pylint: disable=possibly-used-before-assignment
+    for candidate_key in candidate_keys:
+        if not policy.ok_to_validate(candidate_key):
+            continue
+        try:
+            _validate_signature(rrsig.signature, data, candidate_key)
+            return
+        except (InvalidSignature, ValidationFailure):
+            # this happens on an individual validation failure
+            continue
+    # nothing verified -- raise failure:
+    raise ValidationFailure("verify failure")
+
+
+def _validate(
+    rrset: Union[dns.rrset.RRset, Tuple[dns.name.Name, dns.rdataset.Rdataset]],
+    rrsigset: Union[dns.rrset.RRset, Tuple[dns.name.Name, dns.rdataset.Rdataset]],
+    keys: Dict[dns.name.Name, Union[dns.node.Node, dns.rdataset.Rdataset]],
+    origin: Optional[dns.name.Name] = None,
+    now: Optional[float] = None,
+    policy: Optional[Policy] = None,
+) -> None:
+    """Validate an RRset against a signature RRset, throwing an exception
+    if none of the signatures validate.
+
+    *rrset*, the RRset to validate.  This can be a
+    ``dns.rrset.RRset`` or a (``dns.name.Name``, ``dns.rdataset.Rdataset``)
+    tuple.
+
+    *rrsigset*, the signature RRset.  This can be a
+    ``dns.rrset.RRset`` or a (``dns.name.Name``, ``dns.rdataset.Rdataset``)
+    tuple.
+
+    *keys*, the key dictionary, used to find the DNSKEY associated
+    with a given name.  The dictionary is keyed by a
+    ``dns.name.Name``, and has ``dns.node.Node`` or
+    ``dns.rdataset.Rdataset`` values.
+
+    *origin*, a ``dns.name.Name``, the origin to use for relative names;
+    defaults to None.
+
+    *now*, an ``int`` or ``None``, the time, in seconds since the epoch, to
+    use as the current time when validating.  If ``None``, the actual current
+    time is used.
+
+    *policy*, a ``dns.dnssec.Policy`` or ``None``.  If ``None``, the default policy,
+    ``dns.dnssec.default_policy`` is used; this policy defaults to that of RFC 8624.
+
+    Raises ``ValidationFailure`` if the signature is expired, not yet valid,
+    the public key is invalid, the algorithm is unknown, the verification
+    fails, etc.
+    """
+
+    if policy is None:
+        policy = default_policy
+
+    if isinstance(origin, str):
+        origin = dns.name.from_text(origin, dns.name.root)
+
+    if isinstance(rrset, tuple):
+        rrname = rrset[0]
+    else:
+        rrname = rrset.name
+
+    if isinstance(rrsigset, tuple):
+        rrsigname = rrsigset[0]
+        rrsigrdataset = rrsigset[1]
+    else:
+        rrsigname = rrsigset.name
+        rrsigrdataset = rrsigset
+
+    rrname = rrname.choose_relativity(origin)
+    rrsigname = rrsigname.choose_relativity(origin)
+    if rrname != rrsigname:
+        raise ValidationFailure("owner names do not match")
+
+    for rrsig in rrsigrdataset:
+        if not isinstance(rrsig, RRSIG):
+            raise ValidationFailure("expected an RRSIG")
+        try:
+            _validate_rrsig(rrset, rrsig, keys, origin, now, policy)
+            return
+        except (ValidationFailure, UnsupportedAlgorithm):
+            pass
+    raise ValidationFailure("no RRSIGs validated")
+
+
+def _sign(
+    rrset: Union[dns.rrset.RRset, Tuple[dns.name.Name, dns.rdataset.Rdataset]],
+    private_key: PrivateKey,
+    signer: dns.name.Name,
+    dnskey: DNSKEY,
+    inception: Optional[Union[datetime, str, int, float]] = None,
+    expiration: Optional[Union[datetime, str, int, float]] = None,
+    lifetime: Optional[int] = None,
+    verify: bool = False,
+    policy: Optional[Policy] = None,
+    origin: Optional[dns.name.Name] = None,
+    deterministic: bool = True,
+) -> RRSIG:
+    """Sign RRset using private key.
+
+    *rrset*, the RRset to validate.  This can be a
+    ``dns.rrset.RRset`` or a (``dns.name.Name``, ``dns.rdataset.Rdataset``)
+    tuple.
+
+    *private_key*, the private key to use for signing, a
+    ``cryptography.hazmat.primitives.asymmetric`` private key class applicable
+    for DNSSEC.
+
+    *signer*, a ``dns.name.Name``, the Signer's name.
+
+    *dnskey*, a ``DNSKEY`` matching ``private_key``.
+
+    *inception*, a ``datetime``, ``str``, ``int``, ``float`` or ``None``, the
+    signature inception time.  If ``None``, the current time is used.  If a ``str``, the
+    format is "YYYYMMDDHHMMSS" or alternatively the number of seconds since the UNIX
+    epoch in text form; this is the same the RRSIG rdata's text form.
+    Values of type `int` or `float` are interpreted as seconds since the UNIX epoch.
+
+    *expiration*, a ``datetime``, ``str``, ``int``, ``float`` or ``None``, the signature
+    expiration time.  If ``None``, the expiration time will be the inception time plus
+    the value of the *lifetime* parameter.  See the description of *inception* above
+    for how the various parameter types are interpreted.
+
+    *lifetime*, an ``int`` or ``None``, the signature lifetime in seconds.  This
+    parameter is only meaningful if *expiration* is ``None``.
+
+    *verify*, a ``bool``.  If set to ``True``, the signer will verify signatures
+    after they are created; the default is ``False``.
+
+    *policy*, a ``dns.dnssec.Policy`` or ``None``.  If ``None``, the default policy,
+    ``dns.dnssec.default_policy`` is used; this policy defaults to that of RFC 8624.
+
+    *origin*, a ``dns.name.Name`` or ``None``.  If ``None``, the default, then all
+    names in the rrset (including its owner name) must be absolute; otherwise the
+    specified origin will be used to make names absolute when signing.
+
+    *deterministic*, a ``bool``. If ``True``, the default, use deterministic
+    (reproducible) signatures when supported by the algorithm used for signing.
+    Currently, this only affects ECDSA.
+
+    Raises ``DeniedByPolicy`` if the signature is denied by policy.
+    """
+
+    if policy is None:
+        policy = default_policy
+    if not policy.ok_to_sign(dnskey):
+        raise DeniedByPolicy
+
+    if isinstance(rrset, tuple):
+        rdclass = rrset[1].rdclass
+        rdtype = rrset[1].rdtype
+        rrname = rrset[0]
+        original_ttl = rrset[1].ttl
+    else:
+        rdclass = rrset.rdclass
+        rdtype = rrset.rdtype
+        rrname = rrset.name
+        original_ttl = rrset.ttl
+
+    if inception is not None:
+        rrsig_inception = to_timestamp(inception)
+    else:
+        rrsig_inception = int(time.time())
+
+    if expiration is not None:
+        rrsig_expiration = to_timestamp(expiration)
+    elif lifetime is not None:
+        rrsig_expiration = rrsig_inception + lifetime
+    else:
+        raise ValueError("expiration or lifetime must be specified")
+
+    # Derelativize now because we need a correct labels length for the
+    # rrsig_template.
+    if origin is not None:
+        rrname = rrname.derelativize(origin)
+    labels = len(rrname) - 1
+
+    # Adjust labels appropriately for wildcards.
+    if rrname.is_wild():
+        labels -= 1
+
+    rrsig_template = RRSIG(
+        rdclass=rdclass,
+        rdtype=dns.rdatatype.RRSIG,
+        type_covered=rdtype,
+        algorithm=dnskey.algorithm,
+        labels=labels,
+        original_ttl=original_ttl,
+        expiration=rrsig_expiration,
+        inception=rrsig_inception,
+        key_tag=key_id(dnskey),
+        signer=signer,
+        signature=b"",
+    )
+
+    data = dns.dnssec._make_rrsig_signature_data(rrset, rrsig_template, origin)
+
+    # pylint: disable=possibly-used-before-assignment
+    if isinstance(private_key, GenericPrivateKey):
+        signing_key = private_key
+    else:
+        try:
+            private_cls = get_algorithm_cls_from_dnskey(dnskey)
+            signing_key = private_cls(key=private_key)
+        except UnsupportedAlgorithm:
+            raise TypeError("Unsupported key algorithm")
+
+    signature = signing_key.sign(data, verify, deterministic)
+
+    return cast(RRSIG, rrsig_template.replace(signature=signature))
+
+
+def _make_rrsig_signature_data(
+    rrset: Union[dns.rrset.RRset, Tuple[dns.name.Name, dns.rdataset.Rdataset]],
+    rrsig: RRSIG,
+    origin: Optional[dns.name.Name] = None,
+) -> bytes:
+    """Create signature rdata.
+
+    *rrset*, the RRset to sign/validate.  This can be a
+    ``dns.rrset.RRset`` or a (``dns.name.Name``, ``dns.rdataset.Rdataset``)
+    tuple.
+
+    *rrsig*, a ``dns.rdata.Rdata``, the signature to validate, or the
+    signature template used when signing.
+
+    *origin*, a ``dns.name.Name`` or ``None``, the origin to use for relative
+    names.
+
+    Raises ``UnsupportedAlgorithm`` if the algorithm is recognized by
+    dnspython but not implemented.
+    """
+
+    if isinstance(origin, str):
+        origin = dns.name.from_text(origin, dns.name.root)
+
+    signer = rrsig.signer
+    if not signer.is_absolute():
+        if origin is None:
+            raise ValidationFailure("relative RR name without an origin specified")
+        signer = signer.derelativize(origin)
+
+    # For convenience, allow the rrset to be specified as a (name,
+    # rdataset) tuple as well as a proper rrset
+    rrname, rdataset = _get_rrname_rdataset(rrset)
+
+    data = b""
+    wire = rrsig.to_wire(origin=signer)
+    assert wire is not None  # for mypy
+    data += wire[:18]
+    data += rrsig.signer.to_digestable(signer)
+
+    # Derelativize the name before considering labels.
+    if not rrname.is_absolute():
+        if origin is None:
+            raise ValidationFailure("relative RR name without an origin specified")
+        rrname = rrname.derelativize(origin)
+
+    name_len = len(rrname)
+    if rrname.is_wild() and rrsig.labels != name_len - 2:
+        raise ValidationFailure("wild owner name has wrong label length")
+    if name_len - 1 < rrsig.labels:
+        raise ValidationFailure("owner name longer than RRSIG labels")
+    elif rrsig.labels < name_len - 1:
+        suffix = rrname.split(rrsig.labels + 1)[1]
+        rrname = dns.name.from_text("*", suffix)
+    rrnamebuf = rrname.to_digestable()
+    rrfixed = struct.pack("!HHI", rdataset.rdtype, rdataset.rdclass, rrsig.original_ttl)
+    rdatas = [rdata.to_digestable(origin) for rdata in rdataset]
+    for rdata in sorted(rdatas):
+        data += rrnamebuf
+        data += rrfixed
+        rrlen = struct.pack("!H", len(rdata))
+        data += rrlen
+        data += rdata
+
+    return data
+
+
+def _make_dnskey(
+    public_key: PublicKey,
+    algorithm: Union[int, str],
+    flags: int = Flag.ZONE,
+    protocol: int = 3,
+) -> DNSKEY:
+    """Convert a public key to DNSKEY Rdata
+
+    *public_key*, a ``PublicKey`` (``GenericPublicKey`` or
+    ``cryptography.hazmat.primitives.asymmetric``) to convert.
+
+    *algorithm*, a ``str`` or ``int`` specifying the DNSKEY algorithm.
+
+    *flags*: DNSKEY flags field as an integer.
+
+    *protocol*: DNSKEY protocol field as an integer.
+
+    Raises ``ValueError`` if the specified key algorithm parameters are not
+    unsupported, ``TypeError`` if the key type is unsupported,
+    `UnsupportedAlgorithm` if the algorithm is unknown and
+    `AlgorithmKeyMismatch` if the algorithm does not match the key type.
+
+    Return DNSKEY ``Rdata``.
+    """
+
+    algorithm = Algorithm.make(algorithm)
+
+    # pylint: disable=possibly-used-before-assignment
+    if isinstance(public_key, GenericPublicKey):
+        return public_key.to_dnskey(flags=flags, protocol=protocol)
+    else:
+        public_cls = get_algorithm_cls(algorithm).public_cls
+        return public_cls(key=public_key).to_dnskey(flags=flags, protocol=protocol)
+
+
+def _make_cdnskey(
+    public_key: PublicKey,
+    algorithm: Union[int, str],
+    flags: int = Flag.ZONE,
+    protocol: int = 3,
+) -> CDNSKEY:
+    """Convert a public key to CDNSKEY Rdata
+
+    *public_key*, the public key to convert, a
+    ``cryptography.hazmat.primitives.asymmetric`` public key class applicable
+    for DNSSEC.
+
+    *algorithm*, a ``str`` or ``int`` specifying the DNSKEY algorithm.
+
+    *flags*: DNSKEY flags field as an integer.
+
+    *protocol*: DNSKEY protocol field as an integer.
+
+    Raises ``ValueError`` if the specified key algorithm parameters are not
+    unsupported, ``TypeError`` if the key type is unsupported,
+    `UnsupportedAlgorithm` if the algorithm is unknown and
+    `AlgorithmKeyMismatch` if the algorithm does not match the key type.
+
+    Return CDNSKEY ``Rdata``.
+    """
+
+    dnskey = _make_dnskey(public_key, algorithm, flags, protocol)
+
+    return CDNSKEY(
+        rdclass=dnskey.rdclass,
+        rdtype=dns.rdatatype.CDNSKEY,
+        flags=dnskey.flags,
+        protocol=dnskey.protocol,
+        algorithm=dnskey.algorithm,
+        key=dnskey.key,
+    )
+
+
+def nsec3_hash(
+    domain: Union[dns.name.Name, str],
+    salt: Optional[Union[str, bytes]],
+    iterations: int,
+    algorithm: Union[int, str],
+) -> str:
+    """
+    Calculate the NSEC3 hash, according to
+    https://tools.ietf.org/html/rfc5155#section-5
+
+    *domain*, a ``dns.name.Name`` or ``str``, the name to hash.
+
+    *salt*, a ``str``, ``bytes``, or ``None``, the hash salt.  If a
+    string, it is decoded as a hex string.
+
+    *iterations*, an ``int``, the number of iterations.
+
+    *algorithm*, a ``str`` or ``int``, the hash algorithm.
+    The only defined algorithm is SHA1.
+
+    Returns a ``str``, the encoded NSEC3 hash.
+    """
+
+    b32_conversion = str.maketrans(
+        "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567", "0123456789ABCDEFGHIJKLMNOPQRSTUV"
+    )
+
+    try:
+        if isinstance(algorithm, str):
+            algorithm = NSEC3Hash[algorithm.upper()]
+    except Exception:
+        raise ValueError("Wrong hash algorithm (only SHA1 is supported)")
+
+    if algorithm != NSEC3Hash.SHA1:
+        raise ValueError("Wrong hash algorithm (only SHA1 is supported)")
+
+    if salt is None:
+        salt_encoded = b""
+    elif isinstance(salt, str):
+        if len(salt) % 2 == 0:
+            salt_encoded = bytes.fromhex(salt)
+        else:
+            raise ValueError("Invalid salt length")
+    else:
+        salt_encoded = salt
+
+    if not isinstance(domain, dns.name.Name):
+        domain = dns.name.from_text(domain)
+    domain_encoded = domain.canonicalize().to_wire()
+    assert domain_encoded is not None
+
+    digest = hashlib.sha1(domain_encoded + salt_encoded).digest()
+    for _ in range(iterations):
+        digest = hashlib.sha1(digest + salt_encoded).digest()
+
+    output = base64.b32encode(digest).decode("utf-8")
+    output = output.translate(b32_conversion)
+
+    return output
+
+
+def make_ds_rdataset(
+    rrset: Union[dns.rrset.RRset, Tuple[dns.name.Name, dns.rdataset.Rdataset]],
+    algorithms: Set[Union[DSDigest, str]],
+    origin: Optional[dns.name.Name] = None,
+) -> dns.rdataset.Rdataset:
+    """Create a DS record from DNSKEY/CDNSKEY/CDS.
+
+    *rrset*, the RRset to create DS Rdataset for.  This can be a
+    ``dns.rrset.RRset`` or a (``dns.name.Name``, ``dns.rdataset.Rdataset``)
+    tuple.
+
+    *algorithms*, a set of ``str`` or ``int`` specifying the hash algorithms.
+    The currently supported hashes are "SHA1", "SHA256", and "SHA384". Case
+    does not matter for these strings. If the RRset is a CDS, only digest
+    algorithms matching algorithms are accepted.
+
+    *origin*, a ``dns.name.Name`` or ``None``.  If `key` is a relative name,
+    then it will be made absolute using the specified origin.
+
+    Raises ``UnsupportedAlgorithm`` if any of the algorithms are unknown and
+    ``ValueError`` if the given RRset is not usable.
+
+    Returns a ``dns.rdataset.Rdataset``
+    """
+
+    rrname, rdataset = _get_rrname_rdataset(rrset)
+
+    if rdataset.rdtype not in (
+        dns.rdatatype.DNSKEY,
+        dns.rdatatype.CDNSKEY,
+        dns.rdatatype.CDS,
+    ):
+        raise ValueError("rrset not a DNSKEY/CDNSKEY/CDS")
+
+    _algorithms = set()
+    for algorithm in algorithms:
+        try:
+            if isinstance(algorithm, str):
+                algorithm = DSDigest[algorithm.upper()]
+        except Exception:
+            raise UnsupportedAlgorithm(f'unsupported algorithm "{algorithm}"')
+        _algorithms.add(algorithm)
+
+    if rdataset.rdtype == dns.rdatatype.CDS:
+        res = []
+        for rdata in cds_rdataset_to_ds_rdataset(rdataset):
+            if rdata.digest_type in _algorithms:
+                res.append(rdata)
+        if len(res) == 0:
+            raise ValueError("no acceptable CDS rdata found")
+        return dns.rdataset.from_rdata_list(rdataset.ttl, res)
+
+    res = []
+    for algorithm in _algorithms:
+        res.extend(dnskey_rdataset_to_cds_rdataset(rrname, rdataset, algorithm, origin))
+    return dns.rdataset.from_rdata_list(rdataset.ttl, res)
+
+
+def cds_rdataset_to_ds_rdataset(
+    rdataset: dns.rdataset.Rdataset,
+) -> dns.rdataset.Rdataset:
+    """Create a CDS record from DS.
+
+    *rdataset*, a ``dns.rdataset.Rdataset``, to create DS Rdataset for.
+
+    Raises ``ValueError`` if the rdataset is not CDS.
+
+    Returns a ``dns.rdataset.Rdataset``
+    """
+
+    if rdataset.rdtype != dns.rdatatype.CDS:
+        raise ValueError("rdataset not a CDS")
+    res = []
+    for rdata in rdataset:
+        res.append(
+            CDS(
+                rdclass=rdata.rdclass,
+                rdtype=dns.rdatatype.DS,
+                key_tag=rdata.key_tag,
+                algorithm=rdata.algorithm,
+                digest_type=rdata.digest_type,
+                digest=rdata.digest,
+            )
+        )
+    return dns.rdataset.from_rdata_list(rdataset.ttl, res)
+
+
+def dnskey_rdataset_to_cds_rdataset(
+    name: Union[dns.name.Name, str],
+    rdataset: dns.rdataset.Rdataset,
+    algorithm: Union[DSDigest, str],
+    origin: Optional[dns.name.Name] = None,
+) -> dns.rdataset.Rdataset:
+    """Create a CDS record from DNSKEY/CDNSKEY.
+
+    *name*, a ``dns.name.Name`` or ``str``, the owner name of the CDS record.
+
+    *rdataset*, a ``dns.rdataset.Rdataset``, to create DS Rdataset for.
+
+    *algorithm*, a ``str`` or ``int`` specifying the hash algorithm.
+    The currently supported hashes are "SHA1", "SHA256", and "SHA384". Case
+    does not matter for these strings.
+
+    *origin*, a ``dns.name.Name`` or ``None``.  If `key` is a relative name,
+    then it will be made absolute using the specified origin.
+
+    Raises ``UnsupportedAlgorithm`` if the algorithm is unknown or
+    ``ValueError`` if the rdataset is not DNSKEY/CDNSKEY.
+
+    Returns a ``dns.rdataset.Rdataset``
+    """
+
+    if rdataset.rdtype not in (dns.rdatatype.DNSKEY, dns.rdatatype.CDNSKEY):
+        raise ValueError("rdataset not a DNSKEY/CDNSKEY")
+    res = []
+    for rdata in rdataset:
+        res.append(make_cds(name, rdata, algorithm, origin))
+    return dns.rdataset.from_rdata_list(rdataset.ttl, res)
+
+
+def dnskey_rdataset_to_cdnskey_rdataset(
+    rdataset: dns.rdataset.Rdataset,
+) -> dns.rdataset.Rdataset:
+    """Create a CDNSKEY record from DNSKEY.
+
+    *rdataset*, a ``dns.rdataset.Rdataset``, to create CDNSKEY Rdataset for.
+
+    Returns a ``dns.rdataset.Rdataset``
+    """
+
+    if rdataset.rdtype != dns.rdatatype.DNSKEY:
+        raise ValueError("rdataset not a DNSKEY")
+    res = []
+    for rdata in rdataset:
+        res.append(
+            CDNSKEY(
+                rdclass=rdataset.rdclass,
+                rdtype=rdataset.rdtype,
+                flags=rdata.flags,
+                protocol=rdata.protocol,
+                algorithm=rdata.algorithm,
+                key=rdata.key,
+            )
+        )
+    return dns.rdataset.from_rdata_list(rdataset.ttl, res)
+
+
+def default_rrset_signer(
+    txn: dns.transaction.Transaction,
+    rrset: dns.rrset.RRset,
+    signer: dns.name.Name,
+    ksks: List[Tuple[PrivateKey, DNSKEY]],
+    zsks: List[Tuple[PrivateKey, DNSKEY]],
+    inception: Optional[Union[datetime, str, int, float]] = None,
+    expiration: Optional[Union[datetime, str, int, float]] = None,
+    lifetime: Optional[int] = None,
+    policy: Optional[Policy] = None,
+    origin: Optional[dns.name.Name] = None,
+    deterministic: bool = True,
+) -> None:
+    """Default RRset signer"""
+
+    if rrset.rdtype in set(
+        [
+            dns.rdatatype.RdataType.DNSKEY,
+            dns.rdatatype.RdataType.CDS,
+            dns.rdatatype.RdataType.CDNSKEY,
+        ]
+    ):
+        keys = ksks
+    else:
+        keys = zsks
+
+    for private_key, dnskey in keys:
+        rrsig = dns.dnssec.sign(
+            rrset=rrset,
+            private_key=private_key,
+            dnskey=dnskey,
+            inception=inception,
+            expiration=expiration,
+            lifetime=lifetime,
+            signer=signer,
+            policy=policy,
+            origin=origin,
+            deterministic=deterministic,
+        )
+        txn.add(rrset.name, rrset.ttl, rrsig)
+
+
+def sign_zone(
+    zone: dns.zone.Zone,
+    txn: Optional[dns.transaction.Transaction] = None,
+    keys: Optional[List[Tuple[PrivateKey, DNSKEY]]] = None,
+    add_dnskey: bool = True,
+    dnskey_ttl: Optional[int] = None,
+    inception: Optional[Union[datetime, str, int, float]] = None,
+    expiration: Optional[Union[datetime, str, int, float]] = None,
+    lifetime: Optional[int] = None,
+    nsec3: Optional[NSEC3PARAM] = None,
+    rrset_signer: Optional[RRsetSigner] = None,
+    policy: Optional[Policy] = None,
+    deterministic: bool = True,
+) -> None:
+    """Sign zone.
+
+    *zone*, a ``dns.zone.Zone``, the zone to sign.
+
+    *txn*, a ``dns.transaction.Transaction``, an optional transaction to use for
+    signing.
+
+    *keys*, a list of (``PrivateKey``, ``DNSKEY``) tuples, to use for signing. KSK/ZSK
+    roles are assigned automatically if the SEP flag is used, otherwise all RRsets are
+    signed by all keys.
+
+    *add_dnskey*, a ``bool``.  If ``True``, the default, all specified DNSKEYs are
+    automatically added to the zone on signing.
+
+    *dnskey_ttl*, a``int``, specifies the TTL for DNSKEY RRs. If not specified the TTL
+    of the existing DNSKEY RRset used or the TTL of the SOA RRset.
+
+    *inception*, a ``datetime``, ``str``, ``int``, ``float`` or ``None``, the signature
+    inception time.  If ``None``, the current time is used.  If a ``str``, the format is
+    "YYYYMMDDHHMMSS" or alternatively the number of seconds since the UNIX epoch in text
+    form; this is the same the RRSIG rdata's text form. Values of type `int` or `float`
+    are interpreted as seconds since the UNIX epoch.
+
+    *expiration*, a ``datetime``, ``str``, ``int``, ``float`` or ``None``, the signature
+    expiration time.  If ``None``, the expiration time will be the inception time plus
+    the value of the *lifetime* parameter.  See the description of *inception* above for
+    how the various parameter types are interpreted.
+
+    *lifetime*, an ``int`` or ``None``, the signature lifetime in seconds.  This
+    parameter is only meaningful if *expiration* is ``None``.
+
+    *nsec3*, a ``NSEC3PARAM`` Rdata, configures signing using NSEC3. Not yet
+    implemented.
+
+    *rrset_signer*, a ``Callable``, an optional function for signing RRsets. The
+    function requires two arguments: transaction and RRset. If the not specified,
+    ``dns.dnssec.default_rrset_signer`` will be used.
+
+    *deterministic*, a ``bool``. If ``True``, the default, use deterministic
+    (reproducible) signatures when supported by the algorithm used for signing.
+    Currently, this only affects ECDSA.
+
+    Returns ``None``.
+    """
+
+    ksks = []
+    zsks = []
+
+    # if we have both KSKs and ZSKs, split by SEP flag. if not, sign all
+    # records with all keys
+    if keys:
+        for key in keys:
+            if key[1].flags & Flag.SEP:
+                ksks.append(key)
+            else:
+                zsks.append(key)
+        if not ksks:
+            ksks = keys
+        if not zsks:
+            zsks = keys
+    else:
+        keys = []
+
+    if txn:
+        cm: contextlib.AbstractContextManager = contextlib.nullcontext(txn)
+    else:
+        cm = zone.writer()
+
+    if zone.origin is None:
+        raise ValueError("no zone origin")
+
+    with cm as _txn:
+        if add_dnskey:
+            if dnskey_ttl is None:
+                dnskey = _txn.get(zone.origin, dns.rdatatype.DNSKEY)
+                if dnskey:
+                    dnskey_ttl = dnskey.ttl
+                else:
+                    soa = _txn.get(zone.origin, dns.rdatatype.SOA)
+                    dnskey_ttl = soa.ttl
+            for _, dnskey in keys:
+                _txn.add(zone.origin, dnskey_ttl, dnskey)
+
+        if nsec3:
+            raise NotImplementedError("Signing with NSEC3 not yet implemented")
+        else:
+            _rrset_signer = rrset_signer or functools.partial(
+                default_rrset_signer,
+                signer=zone.origin,
+                ksks=ksks,
+                zsks=zsks,
+                inception=inception,
+                expiration=expiration,
+                lifetime=lifetime,
+                policy=policy,
+                origin=zone.origin,
+                deterministic=deterministic,
+            )
+            return _sign_zone_nsec(zone, _txn, _rrset_signer)
+
+
+def _sign_zone_nsec(
+    zone: dns.zone.Zone,
+    txn: dns.transaction.Transaction,
+    rrset_signer: Optional[RRsetSigner] = None,
+) -> None:
+    """NSEC zone signer"""
+
+    def _txn_add_nsec(
+        txn: dns.transaction.Transaction,
+        name: dns.name.Name,
+        next_secure: Optional[dns.name.Name],
+        rdclass: dns.rdataclass.RdataClass,
+        ttl: int,
+        rrset_signer: Optional[RRsetSigner] = None,
+    ) -> None:
+        """NSEC zone signer helper"""
+        mandatory_types = set(
+            [dns.rdatatype.RdataType.RRSIG, dns.rdatatype.RdataType.NSEC]
+        )
+        node = txn.get_node(name)
+        if node and next_secure:
+            types = (
+                set([rdataset.rdtype for rdataset in node.rdatasets]) | mandatory_types
+            )
+            windows = Bitmap.from_rdtypes(list(types))
+            rrset = dns.rrset.from_rdata(
+                name,
+                ttl,
+                NSEC(
+                    rdclass=rdclass,
+                    rdtype=dns.rdatatype.RdataType.NSEC,
+                    next=next_secure,
+                    windows=windows,
+                ),
+            )
+            txn.add(rrset)
+            if rrset_signer:
+                rrset_signer(txn, rrset)
+
+    rrsig_ttl = zone.get_soa().minimum
+    delegation = None
+    last_secure = None
+
+    for name in sorted(txn.iterate_names()):
+        if delegation and name.is_subdomain(delegation):
+            # names below delegations are not secure
+            continue
+        elif txn.get(name, dns.rdatatype.NS) and name != zone.origin:
+            # inside delegation
+            delegation = name
+        else:
+            # outside delegation
+            delegation = None
+
+        if rrset_signer:
+            node = txn.get_node(name)
+            if node:
+                for rdataset in node.rdatasets:
+                    if rdataset.rdtype == dns.rdatatype.RRSIG:
+                        # do not sign RRSIGs
+                        continue
+                    elif delegation and rdataset.rdtype != dns.rdatatype.DS:
+                        # do not sign delegations except DS records
+                        continue
+                    else:
+                        rrset = dns.rrset.from_rdata(name, rdataset.ttl, *rdataset)
+                        rrset_signer(txn, rrset)
+
+        # We need "is not None" as the empty name is False because its length is 0.
+        if last_secure is not None:
+            _txn_add_nsec(txn, last_secure, name, zone.rdclass, rrsig_ttl, rrset_signer)
+        last_secure = name
+
+    if last_secure:
+        _txn_add_nsec(
+            txn, last_secure, zone.origin, zone.rdclass, rrsig_ttl, rrset_signer
+        )
+
+
+def _need_pyca(*args, **kwargs):
+    raise ImportError(
+        "DNSSEC validation requires python cryptography"
+    )  # pragma: no cover
+
+
+if dns._features.have("dnssec"):
+    from cryptography.exceptions import InvalidSignature
+    from cryptography.hazmat.primitives.asymmetric import dsa  # pylint: disable=W0611
+    from cryptography.hazmat.primitives.asymmetric import ec  # pylint: disable=W0611
+    from cryptography.hazmat.primitives.asymmetric import ed448  # pylint: disable=W0611
+    from cryptography.hazmat.primitives.asymmetric import rsa  # pylint: disable=W0611
+    from cryptography.hazmat.primitives.asymmetric import (  # pylint: disable=W0611
+        ed25519,
+    )
+
+    from dns.dnssecalgs import (  # pylint: disable=C0412
+        get_algorithm_cls,
+        get_algorithm_cls_from_dnskey,
+    )
+    from dns.dnssecalgs.base import GenericPrivateKey, GenericPublicKey
+
+    validate = _validate  # type: ignore
+    validate_rrsig = _validate_rrsig  # type: ignore
+    sign = _sign
+    make_dnskey = _make_dnskey
+    make_cdnskey = _make_cdnskey
+    _have_pyca = True
+else:  # pragma: no cover
+    validate = _need_pyca
+    validate_rrsig = _need_pyca
+    sign = _need_pyca
+    make_dnskey = _need_pyca
+    make_cdnskey = _need_pyca
+    _have_pyca = False
+
+### BEGIN generated Algorithm constants
+
+RSAMD5 = Algorithm.RSAMD5
+DH = Algorithm.DH
+DSA = Algorithm.DSA
+ECC = Algorithm.ECC
+RSASHA1 = Algorithm.RSASHA1
+DSANSEC3SHA1 = Algorithm.DSANSEC3SHA1
+RSASHA1NSEC3SHA1 = Algorithm.RSASHA1NSEC3SHA1
+RSASHA256 = Algorithm.RSASHA256
+RSASHA512 = Algorithm.RSASHA512
+ECCGOST = Algorithm.ECCGOST
+ECDSAP256SHA256 = Algorithm.ECDSAP256SHA256
+ECDSAP384SHA384 = Algorithm.ECDSAP384SHA384
+ED25519 = Algorithm.ED25519
+ED448 = Algorithm.ED448
+INDIRECT = Algorithm.INDIRECT
+PRIVATEDNS = Algorithm.PRIVATEDNS
+PRIVATEOID = Algorithm.PRIVATEOID
+
+### END generated Algorithm constants
diff --git a/.venv/lib/python3.12/site-packages/dns/dnssecalgs/__init__.py b/.venv/lib/python3.12/site-packages/dns/dnssecalgs/__init__.py
new file mode 100644
index 00000000..602367e3
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/dns/dnssecalgs/__init__.py
@@ -0,0 +1,121 @@
+from typing import Dict, Optional, Tuple, Type, Union
+
+import dns.name
+from dns.dnssecalgs.base import GenericPrivateKey
+from dns.dnssectypes import Algorithm
+from dns.exception import UnsupportedAlgorithm
+from dns.rdtypes.ANY.DNSKEY import DNSKEY
+
+if dns._features.have("dnssec"):
+    from dns.dnssecalgs.dsa import PrivateDSA, PrivateDSANSEC3SHA1
+    from dns.dnssecalgs.ecdsa import PrivateECDSAP256SHA256, PrivateECDSAP384SHA384
+    from dns.dnssecalgs.eddsa import PrivateED448, PrivateED25519
+    from dns.dnssecalgs.rsa import (
+        PrivateRSAMD5,
+        PrivateRSASHA1,
+        PrivateRSASHA1NSEC3SHA1,
+        PrivateRSASHA256,
+        PrivateRSASHA512,
+    )
+
+    _have_cryptography = True
+else:
+    _have_cryptography = False
+
+AlgorithmPrefix = Optional[Union[bytes, dns.name.Name]]
+
+algorithms: Dict[Tuple[Algorithm, AlgorithmPrefix], Type[GenericPrivateKey]] = {}
+if _have_cryptography:
+    # pylint: disable=possibly-used-before-assignment
+    algorithms.update(
+        {
+            (Algorithm.RSAMD5, None): PrivateRSAMD5,
+            (Algorithm.DSA, None): PrivateDSA,
+            (Algorithm.RSASHA1, None): PrivateRSASHA1,
+            (Algorithm.DSANSEC3SHA1, None): PrivateDSANSEC3SHA1,
+            (Algorithm.RSASHA1NSEC3SHA1, None): PrivateRSASHA1NSEC3SHA1,
+            (Algorithm.RSASHA256, None): PrivateRSASHA256,
+            (Algorithm.RSASHA512, None): PrivateRSASHA512,
+            (Algorithm.ECDSAP256SHA256, None): PrivateECDSAP256SHA256,
+            (Algorithm.ECDSAP384SHA384, None): PrivateECDSAP384SHA384,
+            (Algorithm.ED25519, None): PrivateED25519,
+            (Algorithm.ED448, None): PrivateED448,
+        }
+    )
+
+
+def get_algorithm_cls(
+    algorithm: Union[int, str], prefix: AlgorithmPrefix = None
+) -> Type[GenericPrivateKey]:
+    """Get Private Key class from Algorithm.
+
+    *algorithm*, a ``str`` or ``int`` specifying the DNSKEY algorithm.
+
+    Raises ``UnsupportedAlgorithm`` if the algorithm is unknown.
+
+    Returns a ``dns.dnssecalgs.GenericPrivateKey``
+    """
+    algorithm = Algorithm.make(algorithm)
+    cls = algorithms.get((algorithm, prefix))
+    if cls:
+        return cls
+    raise UnsupportedAlgorithm(
+        f'algorithm "{Algorithm.to_text(algorithm)}" not supported by dnspython'
+    )
+
+
+def get_algorithm_cls_from_dnskey(dnskey: DNSKEY) -> Type[GenericPrivateKey]:
+    """Get Private Key class from DNSKEY.
+
+    *dnskey*, a ``DNSKEY`` to get Algorithm class for.
+
+    Raises ``UnsupportedAlgorithm`` if the algorithm is unknown.
+
+    Returns a ``dns.dnssecalgs.GenericPrivateKey``
+    """
+    prefix: AlgorithmPrefix = None
+    if dnskey.algorithm == Algorithm.PRIVATEDNS:
+        prefix, _ = dns.name.from_wire(dnskey.key, 0)
+    elif dnskey.algorithm == Algorithm.PRIVATEOID:
+        length = int(dnskey.key[0])
+        prefix = dnskey.key[0 : length + 1]
+    return get_algorithm_cls(dnskey.algorithm, prefix)
+
+
+def register_algorithm_cls(
+    algorithm: Union[int, str],
+    algorithm_cls: Type[GenericPrivateKey],
+    name: Optional[Union[dns.name.Name, str]] = None,
+    oid: Optional[bytes] = None,
+) -> None:
+    """Register Algorithm Private Key class.
+
+    *algorithm*, a ``str`` or ``int`` specifying the DNSKEY algorithm.
+
+    *algorithm_cls*: A `GenericPrivateKey` class.
+
+    *name*, an optional ``dns.name.Name`` or ``str``, for for PRIVATEDNS algorithms.
+
+    *oid*: an optional BER-encoded `bytes` for PRIVATEOID algorithms.
+
+    Raises ``ValueError`` if a name or oid is specified incorrectly.
+    """
+    if not issubclass(algorithm_cls, GenericPrivateKey):
+        raise TypeError("Invalid algorithm class")
+    algorithm = Algorithm.make(algorithm)
+    prefix: AlgorithmPrefix = None
+    if algorithm == Algorithm.PRIVATEDNS:
+        if name is None:
+            raise ValueError("Name required for PRIVATEDNS algorithms")
+        if isinstance(name, str):
+            name = dns.name.from_text(name)
+        prefix = name
+    elif algorithm == Algorithm.PRIVATEOID:
+        if oid is None:
+            raise ValueError("OID required for PRIVATEOID algorithms")
+        prefix = bytes([len(oid)]) + oid
+    elif name:
+        raise ValueError("Name only supported for PRIVATEDNS algorithm")
+    elif oid:
+        raise ValueError("OID only supported for PRIVATEOID algorithm")
+    algorithms[(algorithm, prefix)] = algorithm_cls
diff --git a/.venv/lib/python3.12/site-packages/dns/dnssecalgs/base.py b/.venv/lib/python3.12/site-packages/dns/dnssecalgs/base.py
new file mode 100644
index 00000000..752ee480
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/dns/dnssecalgs/base.py
@@ -0,0 +1,89 @@
+from abc import ABC, abstractmethod  # pylint: disable=no-name-in-module
+from typing import Any, Optional, Type
+
+import dns.rdataclass
+import dns.rdatatype
+from dns.dnssectypes import Algorithm
+from dns.exception import AlgorithmKeyMismatch
+from dns.rdtypes.ANY.DNSKEY import DNSKEY
+from dns.rdtypes.dnskeybase import Flag
+
+
+class GenericPublicKey(ABC):
+    algorithm: Algorithm
+
+    @abstractmethod
+    def __init__(self, key: Any) -> None:
+        pass
+
+    @abstractmethod
+    def verify(self, signature: bytes, data: bytes) -> None:
+        """Verify signed DNSSEC data"""
+
+    @abstractmethod
+    def encode_key_bytes(self) -> bytes:
+        """Encode key as bytes for DNSKEY"""
+
+    @classmethod
+    def _ensure_algorithm_key_combination(cls, key: DNSKEY) -> None:
+        if key.algorithm != cls.algorithm:
+            raise AlgorithmKeyMismatch
+
+    def to_dnskey(self, flags: int = Flag.ZONE, protocol: int = 3) -> DNSKEY:
+        """Return public key as DNSKEY"""
+        return DNSKEY(
+            rdclass=dns.rdataclass.IN,
+            rdtype=dns.rdatatype.DNSKEY,
+            flags=flags,
+            protocol=protocol,
+            algorithm=self.algorithm,
+            key=self.encode_key_bytes(),
+        )
+
+    @classmethod
+    @abstractmethod
+    def from_dnskey(cls, key: DNSKEY) -> "GenericPublicKey":
+        """Create public key from DNSKEY"""
+
+    @classmethod
+    @abstractmethod
+    def from_pem(cls, public_pem: bytes) -> "GenericPublicKey":
+        """Create public key from PEM-encoded SubjectPublicKeyInfo as specified
+        in RFC 5280"""
+
+    @abstractmethod
+    def to_pem(self) -> bytes:
+        """Return public-key as PEM-encoded SubjectPublicKeyInfo as specified
+        in RFC 5280"""
+
+
+class GenericPrivateKey(ABC):
+    public_cls: Type[GenericPublicKey]
+
+    @abstractmethod
+    def __init__(self, key: Any) -> None:
+        pass
+
+    @abstractmethod
+    def sign(
+        self,
+        data: bytes,
+        verify: bool = False,
+        deterministic: bool = True,
+    ) -> bytes:
+        """Sign DNSSEC data"""
+
+    @abstractmethod
+    def public_key(self) -> "GenericPublicKey":
+        """Return public key instance"""
+
+    @classmethod
+    @abstractmethod
+    def from_pem(
+        cls, private_pem: bytes, password: Optional[bytes] = None
+    ) -> "GenericPrivateKey":
+        """Create private key from PEM-encoded PKCS#8"""
+
+    @abstractmethod
+    def to_pem(self, password: Optional[bytes] = None) -> bytes:
+        """Return private key as PEM-encoded PKCS#8"""
diff --git a/.venv/lib/python3.12/site-packages/dns/dnssecalgs/cryptography.py b/.venv/lib/python3.12/site-packages/dns/dnssecalgs/cryptography.py
new file mode 100644
index 00000000..5a31a812
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/dns/dnssecalgs/cryptography.py
@@ -0,0 +1,68 @@
+from typing import Any, Optional, Type
+
+from cryptography.hazmat.primitives import serialization
+
+from dns.dnssecalgs.base import GenericPrivateKey, GenericPublicKey
+from dns.exception import AlgorithmKeyMismatch
+
+
+class CryptographyPublicKey(GenericPublicKey):
+    key: Any = None
+    key_cls: Any = None
+
+    def __init__(self, key: Any) -> None:  # pylint: disable=super-init-not-called
+        if self.key_cls is None:
+            raise TypeError("Undefined private key class")
+        if not isinstance(  # pylint: disable=isinstance-second-argument-not-valid-type
+            key, self.key_cls
+        ):
+            raise AlgorithmKeyMismatch
+        self.key = key
+
+    @classmethod
+    def from_pem(cls, public_pem: bytes) -> "GenericPublicKey":
+        key = serialization.load_pem_public_key(public_pem)
+        return cls(key=key)
+
+    def to_pem(self) -> bytes:
+        return self.key.public_bytes(
+            encoding=serialization.Encoding.PEM,
+            format=serialization.PublicFormat.SubjectPublicKeyInfo,
+        )
+
+
+class CryptographyPrivateKey(GenericPrivateKey):
+    key: Any = None
+    key_cls: Any = None
+    public_cls: Type[CryptographyPublicKey]
+
+    def __init__(self, key: Any) -> None:  # pylint: disable=super-init-not-called
+        if self.key_cls is None:
+            raise TypeError("Undefined private key class")
+        if not isinstance(  # pylint: disable=isinstance-second-argument-not-valid-type
+            key, self.key_cls
+        ):
+            raise AlgorithmKeyMismatch
+        self.key = key
+
+    def public_key(self) -> "CryptographyPublicKey":
+        return self.public_cls(key=self.key.public_key())
+
+    @classmethod
+    def from_pem(
+        cls, private_pem: bytes, password: Optional[bytes] = None
+    ) -> "GenericPrivateKey":
+        key = serialization.load_pem_private_key(private_pem, password=password)
+        return cls(key=key)
+
+    def to_pem(self, password: Optional[bytes] = None) -> bytes:
+        encryption_algorithm: serialization.KeySerializationEncryption
+        if password:
+            encryption_algorithm = serialization.BestAvailableEncryption(password)
+        else:
+            encryption_algorithm = serialization.NoEncryption()
+        return self.key.private_bytes(
+            encoding=serialization.Encoding.PEM,
+            format=serialization.PrivateFormat.PKCS8,
+            encryption_algorithm=encryption_algorithm,
+        )
diff --git a/.venv/lib/python3.12/site-packages/dns/dnssecalgs/dsa.py b/.venv/lib/python3.12/site-packages/dns/dnssecalgs/dsa.py
new file mode 100644
index 00000000..adca3def
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/dns/dnssecalgs/dsa.py
@@ -0,0 +1,106 @@
+import struct
+
+from cryptography.hazmat.backends import default_backend
+from cryptography.hazmat.primitives import hashes
+from cryptography.hazmat.primitives.asymmetric import dsa, utils
+
+from dns.dnssecalgs.cryptography import CryptographyPrivateKey, CryptographyPublicKey
+from dns.dnssectypes import Algorithm
+from dns.rdtypes.ANY.DNSKEY import DNSKEY
+
+
+class PublicDSA(CryptographyPublicKey):
+    key: dsa.DSAPublicKey
+    key_cls = dsa.DSAPublicKey
+    algorithm = Algorithm.DSA
+    chosen_hash = hashes.SHA1()
+
+    def verify(self, signature: bytes, data: bytes) -> None:
+        sig_r = signature[1:21]
+        sig_s = signature[21:]
+        sig = utils.encode_dss_signature(
+            int.from_bytes(sig_r, "big"), int.from_bytes(sig_s, "big")
+        )
+        self.key.verify(sig, data, self.chosen_hash)
+
+    def encode_key_bytes(self) -> bytes:
+        """Encode a public key per RFC 2536, section 2."""
+        pn = self.key.public_numbers()
+        dsa_t = (self.key.key_size // 8 - 64) // 8
+        if dsa_t > 8:
+            raise ValueError("unsupported DSA key size")
+        octets = 64 + dsa_t * 8
+        res = struct.pack("!B", dsa_t)
+        res += pn.parameter_numbers.q.to_bytes(20, "big")
+        res += pn.parameter_numbers.p.to_bytes(octets, "big")
+        res += pn.parameter_numbers.g.to_bytes(octets, "big")
+        res += pn.y.to_bytes(octets, "big")
+        return res
+
+    @classmethod
+    def from_dnskey(cls, key: DNSKEY) -> "PublicDSA":
+        cls._ensure_algorithm_key_combination(key)
+        keyptr = key.key
+        (t,) = struct.unpack("!B", keyptr[0:1])
+        keyptr = keyptr[1:]
+        octets = 64 + t * 8
+        dsa_q = keyptr[0:20]
+        keyptr = keyptr[20:]
+        dsa_p = keyptr[0:octets]
+        keyptr = keyptr[octets:]
+        dsa_g = keyptr[0:octets]
+        keyptr = keyptr[octets:]
+        dsa_y = keyptr[0:octets]
+        return cls(
+            key=dsa.DSAPublicNumbers(  # type: ignore
+                int.from_bytes(dsa_y, "big"),
+                dsa.DSAParameterNumbers(
+                    int.from_bytes(dsa_p, "big"),
+                    int.from_bytes(dsa_q, "big"),
+                    int.from_bytes(dsa_g, "big"),
+                ),
+            ).public_key(default_backend()),
+        )
+
+
+class PrivateDSA(CryptographyPrivateKey):
+    key: dsa.DSAPrivateKey
+    key_cls = dsa.DSAPrivateKey
+    public_cls = PublicDSA
+
+    def sign(
+        self,
+        data: bytes,
+        verify: bool = False,
+        deterministic: bool = True,
+    ) -> bytes:
+        """Sign using a private key per RFC 2536, section 3."""
+        public_dsa_key = self.key.public_key()
+        if public_dsa_key.key_size > 1024:
+            raise ValueError("DSA key size overflow")
+        der_signature = self.key.sign(data, self.public_cls.chosen_hash)
+        dsa_r, dsa_s = utils.decode_dss_signature(der_signature)
+        dsa_t = (public_dsa_key.key_size // 8 - 64) // 8
+        octets = 20
+        signature = (
+            struct.pack("!B", dsa_t)
+            + int.to_bytes(dsa_r, length=octets, byteorder="big")
+            + int.to_bytes(dsa_s, length=octets, byteorder="big")
+        )
+        if verify:
+            self.public_key().verify(signature, data)
+        return signature
+
+    @classmethod
+    def generate(cls, key_size: int) -> "PrivateDSA":
+        return cls(
+            key=dsa.generate_private_key(key_size=key_size),
+        )
+
+
+class PublicDSANSEC3SHA1(PublicDSA):
+    algorithm = Algorithm.DSANSEC3SHA1
+
+
+class PrivateDSANSEC3SHA1(PrivateDSA):
+    public_cls = PublicDSANSEC3SHA1
diff --git a/.venv/lib/python3.12/site-packages/dns/dnssecalgs/ecdsa.py b/.venv/lib/python3.12/site-packages/dns/dnssecalgs/ecdsa.py
new file mode 100644
index 00000000..86d5764c
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/dns/dnssecalgs/ecdsa.py
@@ -0,0 +1,97 @@
+from cryptography.hazmat.backends import default_backend
+from cryptography.hazmat.primitives import hashes
+from cryptography.hazmat.primitives.asymmetric import ec, utils
+
+from dns.dnssecalgs.cryptography import CryptographyPrivateKey, CryptographyPublicKey
+from dns.dnssectypes import Algorithm
+from dns.rdtypes.ANY.DNSKEY import DNSKEY
+
+
+class PublicECDSA(CryptographyPublicKey):
+    key: ec.EllipticCurvePublicKey
+    key_cls = ec.EllipticCurvePublicKey
+    algorithm: Algorithm
+    chosen_hash: hashes.HashAlgorithm
+    curve: ec.EllipticCurve
+    octets: int
+
+    def verify(self, signature: bytes, data: bytes) -> None:
+        sig_r = signature[0 : self.octets]
+        sig_s = signature[self.octets :]
+        sig = utils.encode_dss_signature(
+            int.from_bytes(sig_r, "big"), int.from_bytes(sig_s, "big")
+        )
+        self.key.verify(sig, data, ec.ECDSA(self.chosen_hash))
+
+    def encode_key_bytes(self) -> bytes:
+        """Encode a public key per RFC 6605, section 4."""
+        pn = self.key.public_numbers()
+        return pn.x.to_bytes(self.octets, "big") + pn.y.to_bytes(self.octets, "big")
+
+    @classmethod
+    def from_dnskey(cls, key: DNSKEY) -> "PublicECDSA":
+        cls._ensure_algorithm_key_combination(key)
+        ecdsa_x = key.key[0 : cls.octets]
+        ecdsa_y = key.key[cls.octets : cls.octets * 2]
+        return cls(
+            key=ec.EllipticCurvePublicNumbers(
+                curve=cls.curve,
+                x=int.from_bytes(ecdsa_x, "big"),
+                y=int.from_bytes(ecdsa_y, "big"),
+            ).public_key(default_backend()),
+        )
+
+
+class PrivateECDSA(CryptographyPrivateKey):
+    key: ec.EllipticCurvePrivateKey
+    key_cls = ec.EllipticCurvePrivateKey
+    public_cls = PublicECDSA
+
+    def sign(
+        self,
+        data: bytes,
+        verify: bool = False,
+        deterministic: bool = True,
+    ) -> bytes:
+        """Sign using a private key per RFC 6605, section 4."""
+        algorithm = ec.ECDSA(
+            self.public_cls.chosen_hash, deterministic_signing=deterministic
+        )
+        der_signature = self.key.sign(data, algorithm)
+        dsa_r, dsa_s = utils.decode_dss_signature(der_signature)
+        signature = int.to_bytes(
+            dsa_r, length=self.public_cls.octets, byteorder="big"
+        ) + int.to_bytes(dsa_s, length=self.public_cls.octets, byteorder="big")
+        if verify:
+            self.public_key().verify(signature, data)
+        return signature
+
+    @classmethod
+    def generate(cls) -> "PrivateECDSA":
+        return cls(
+            key=ec.generate_private_key(
+                curve=cls.public_cls.curve, backend=default_backend()
+            ),
+        )
+
+
+class PublicECDSAP256SHA256(PublicECDSA):
+    algorithm = Algorithm.ECDSAP256SHA256
+    chosen_hash = hashes.SHA256()
+    curve = ec.SECP256R1()
+    octets = 32
+
+
+class PrivateECDSAP256SHA256(PrivateECDSA):
+    public_cls = PublicECDSAP256SHA256
+
+
+class PublicECDSAP384SHA384(PublicECDSA):
+    algorithm = Algorithm.ECDSAP384SHA384
+    chosen_hash = hashes.SHA384()
+    curve = ec.SECP384R1()
+    octets = 48
+
+
+class PrivateECDSAP384SHA384(PrivateECDSA):
+    public_cls = PublicECDSAP384SHA384
diff --git a/.venv/lib/python3.12/site-packages/dns/dnssecalgs/eddsa.py b/.venv/lib/python3.12/site-packages/dns/dnssecalgs/eddsa.py
new file mode 100644
index 00000000..604bcbfe
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/dns/dnssecalgs/eddsa.py
@@ -0,0 +1,70 @@
+from typing import Type
+
+from cryptography.hazmat.primitives import serialization
+from cryptography.hazmat.primitives.asymmetric import ed448, ed25519
+
+from dns.dnssecalgs.cryptography import CryptographyPrivateKey, CryptographyPublicKey
+from dns.dnssectypes import Algorithm
+from dns.rdtypes.ANY.DNSKEY import DNSKEY
+
+
+class PublicEDDSA(CryptographyPublicKey):
+    def verify(self, signature: bytes, data: bytes) -> None:
+        self.key.verify(signature, data)
+
+    def encode_key_bytes(self) -> bytes:
+        """Encode a public key per RFC 8080, section 3."""
+        return self.key.public_bytes(
+            encoding=serialization.Encoding.Raw, format=serialization.PublicFormat.Raw
+        )
+
+    @classmethod
+    def from_dnskey(cls, key: DNSKEY) -> "PublicEDDSA":
+        cls._ensure_algorithm_key_combination(key)
+        return cls(
+            key=cls.key_cls.from_public_bytes(key.key),
+        )
+
+
+class PrivateEDDSA(CryptographyPrivateKey):
+    public_cls: Type[PublicEDDSA]
+
+    def sign(
+        self,
+        data: bytes,
+        verify: bool = False,
+        deterministic: bool = True,
+    ) -> bytes:
+        """Sign using a private key per RFC 8080, section 4."""
+        signature = self.key.sign(data)
+        if verify:
+            self.public_key().verify(signature, data)
+        return signature
+
+    @classmethod
+    def generate(cls) -> "PrivateEDDSA":
+        return cls(key=cls.key_cls.generate())
+
+
+class PublicED25519(PublicEDDSA):
+    key: ed25519.Ed25519PublicKey
+    key_cls = ed25519.Ed25519PublicKey
+    algorithm = Algorithm.ED25519
+
+
+class PrivateED25519(PrivateEDDSA):
+    key: ed25519.Ed25519PrivateKey
+    key_cls = ed25519.Ed25519PrivateKey
+    public_cls = PublicED25519
+
+
+class PublicED448(PublicEDDSA):
+    key: ed448.Ed448PublicKey
+    key_cls = ed448.Ed448PublicKey
+    algorithm = Algorithm.ED448
+
+
+class PrivateED448(PrivateEDDSA):
+    key: ed448.Ed448PrivateKey
+    key_cls = ed448.Ed448PrivateKey
+    public_cls = PublicED448
diff --git a/.venv/lib/python3.12/site-packages/dns/dnssecalgs/rsa.py b/.venv/lib/python3.12/site-packages/dns/dnssecalgs/rsa.py
new file mode 100644
index 00000000..27537aad
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/dns/dnssecalgs/rsa.py
@@ -0,0 +1,124 @@
+import math
+import struct
+
+from cryptography.hazmat.backends import default_backend
+from cryptography.hazmat.primitives import hashes
+from cryptography.hazmat.primitives.asymmetric import padding, rsa
+
+from dns.dnssecalgs.cryptography import CryptographyPrivateKey, CryptographyPublicKey
+from dns.dnssectypes import Algorithm
+from dns.rdtypes.ANY.DNSKEY import DNSKEY
+
+
+class PublicRSA(CryptographyPublicKey):
+    key: rsa.RSAPublicKey
+    key_cls = rsa.RSAPublicKey
+    algorithm: Algorithm
+    chosen_hash: hashes.HashAlgorithm
+
+    def verify(self, signature: bytes, data: bytes) -> None:
+        self.key.verify(signature, data, padding.PKCS1v15(), self.chosen_hash)
+
+    def encode_key_bytes(self) -> bytes:
+        """Encode a public key per RFC 3110, section 2."""
+        pn = self.key.public_numbers()
+        _exp_len = math.ceil(int.bit_length(pn.e) / 8)
+        exp = int.to_bytes(pn.e, length=_exp_len, byteorder="big")
+        if _exp_len > 255:
+            exp_header = b"\0" + struct.pack("!H", _exp_len)
+        else:
+            exp_header = struct.pack("!B", _exp_len)
+        if pn.n.bit_length() < 512 or pn.n.bit_length() > 4096:
+            raise ValueError("unsupported RSA key length")
+        return exp_header + exp + pn.n.to_bytes((pn.n.bit_length() + 7) // 8, "big")
+
+    @classmethod
+    def from_dnskey(cls, key: DNSKEY) -> "PublicRSA":
+        cls._ensure_algorithm_key_combination(key)
+        keyptr = key.key
+        (bytes_,) = struct.unpack("!B", keyptr[0:1])
+        keyptr = keyptr[1:]
+        if bytes_ == 0:
+            (bytes_,) = struct.unpack("!H", keyptr[0:2])
+            keyptr = keyptr[2:]
+        rsa_e = keyptr[0:bytes_]
+        rsa_n = keyptr[bytes_:]
+        return cls(
+            key=rsa.RSAPublicNumbers(
+                int.from_bytes(rsa_e, "big"), int.from_bytes(rsa_n, "big")
+            ).public_key(default_backend())
+        )
+
+
+class PrivateRSA(CryptographyPrivateKey):
+    key: rsa.RSAPrivateKey
+    key_cls = rsa.RSAPrivateKey
+    public_cls = PublicRSA
+    default_public_exponent = 65537
+
+    def sign(
+        self,
+        data: bytes,
+        verify: bool = False,
+        deterministic: bool = True,
+    ) -> bytes:
+        """Sign using a private key per RFC 3110, section 3."""
+        signature = self.key.sign(data, padding.PKCS1v15(), self.public_cls.chosen_hash)
+        if verify:
+            self.public_key().verify(signature, data)
+        return signature
+
+    @classmethod
+    def generate(cls, key_size: int) -> "PrivateRSA":
+        return cls(
+            key=rsa.generate_private_key(
+                public_exponent=cls.default_public_exponent,
+                key_size=key_size,
+                backend=default_backend(),
+            )
+        )
+
+
+class PublicRSAMD5(PublicRSA):
+    algorithm = Algorithm.RSAMD5
+    chosen_hash = hashes.MD5()
+
+
+class PrivateRSAMD5(PrivateRSA):
+    public_cls = PublicRSAMD5
+
+
+class PublicRSASHA1(PublicRSA):
+    algorithm = Algorithm.RSASHA1
+    chosen_hash = hashes.SHA1()
+
+
+class PrivateRSASHA1(PrivateRSA):
+    public_cls = PublicRSASHA1
+
+
+class PublicRSASHA1NSEC3SHA1(PublicRSA):
+    algorithm = Algorithm.RSASHA1NSEC3SHA1
+    chosen_hash = hashes.SHA1()
+
+
+class PrivateRSASHA1NSEC3SHA1(PrivateRSA):
+    public_cls = PublicRSASHA1NSEC3SHA1
+
+
+class PublicRSASHA256(PublicRSA):
+    algorithm = Algorithm.RSASHA256
+    chosen_hash = hashes.SHA256()
+
+
+class PrivateRSASHA256(PrivateRSA):
+    public_cls = PublicRSASHA256
+
+
+class PublicRSASHA512(PublicRSA):
+    algorithm = Algorithm.RSASHA512
+    chosen_hash = hashes.SHA512()
+
+
+class PrivateRSASHA512(PrivateRSA):
+    public_cls = PublicRSASHA512
diff --git a/.venv/lib/python3.12/site-packages/dns/dnssectypes.py b/.venv/lib/python3.12/site-packages/dns/dnssectypes.py
new file mode 100644
index 00000000..02131e0a
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/dns/dnssectypes.py
@@ -0,0 +1,71 @@
+# Copyright (C) Dnspython Contributors, see LICENSE for text of ISC license
+
+# Copyright (C) 2003-2017 Nominum, Inc.
+#
+# Permission to use, copy, modify, and distribute this software and its
+# documentation for any purpose with or without fee is hereby granted,
+# provided that the above copyright notice and this permission notice
+# appear in all copies.
+#
+# THE SOFTWARE IS PROVIDED "AS IS" AND NOMINUM DISCLAIMS ALL WARRANTIES
+# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL NOMINUM BE LIABLE FOR
+# ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+# ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT
+# OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+
+"""Common DNSSEC-related types."""
+
+# This is a separate file to avoid import circularity between dns.dnssec and
+# the implementations of the DS and DNSKEY types.
+
+import dns.enum
+
+
+class Algorithm(dns.enum.IntEnum):
+    RSAMD5 = 1
+    DH = 2
+    DSA = 3
+    ECC = 4
+    RSASHA1 = 5
+    DSANSEC3SHA1 = 6
+    RSASHA1NSEC3SHA1 = 7
+    RSASHA256 = 8
+    RSASHA512 = 10
+    ECCGOST = 12
+    ECDSAP256SHA256 = 13
+    ECDSAP384SHA384 = 14
+    ED25519 = 15
+    ED448 = 16
+    INDIRECT = 252
+    PRIVATEDNS = 253
+    PRIVATEOID = 254
+
+    @classmethod
+    def _maximum(cls):
+        return 255
+
+
+class DSDigest(dns.enum.IntEnum):
+    """DNSSEC Delegation Signer Digest Algorithm"""
+
+    NULL = 0
+    SHA1 = 1
+    SHA256 = 2
+    GOST = 3
+    SHA384 = 4
+
+    @classmethod
+    def _maximum(cls):
+        return 255
+
+
+class NSEC3Hash(dns.enum.IntEnum):
+    """NSEC3 hash algorithm"""
+
+    SHA1 = 1
+
+    @classmethod
+    def _maximum(cls):
+        return 255
diff --git a/.venv/lib/python3.12/site-packages/dns/e164.py b/.venv/lib/python3.12/site-packages/dns/e164.py
new file mode 100644
index 00000000..453736d4
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/dns/e164.py
@@ -0,0 +1,116 @@
+# Copyright (C) Dnspython Contributors, see LICENSE for text of ISC license
+
+# Copyright (C) 2006-2017 Nominum, Inc.
+#
+# Permission to use, copy, modify, and distribute this software and its
+# documentation for any purpose with or without fee is hereby granted,
+# provided that the above copyright notice and this permission notice
+# appear in all copies.
+#
+# THE SOFTWARE IS PROVIDED "AS IS" AND NOMINUM DISCLAIMS ALL WARRANTIES
+# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL NOMINUM BE LIABLE FOR
+# ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+# ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT
+# OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+
+"""DNS E.164 helpers."""
+
+from typing import Iterable, Optional, Union
+
+import dns.exception
+import dns.name
+import dns.resolver
+
+#: The public E.164 domain.
+public_enum_domain = dns.name.from_text("e164.arpa.")
+
+
+def from_e164(
+    text: str, origin: Optional[dns.name.Name] = public_enum_domain
+) -> dns.name.Name:
+    """Convert an E.164 number in textual form into a Name object whose
+    value is the ENUM domain name for that number.
+
+    Non-digits in the text are ignored, i.e. "16505551212",
+    "+1.650.555.1212" and "1 (650) 555-1212" are all the same.
+
+    *text*, a ``str``, is an E.164 number in textual form.
+
+    *origin*, a ``dns.name.Name``, the domain in which the number
+    should be constructed.  The default is ``e164.arpa.``.
+
+    Returns a ``dns.name.Name``.
+    """
+
+    parts = [d for d in text if d.isdigit()]
+    parts.reverse()
+    return dns.name.from_text(".".join(parts), origin=origin)
+
+
+def to_e164(
+    name: dns.name.Name,
+    origin: Optional[dns.name.Name] = public_enum_domain,
+    want_plus_prefix: bool = True,
+) -> str:
+    """Convert an ENUM domain name into an E.164 number.
+
+    Note that dnspython does not have any information about preferred
+    number formats within national numbering plans, so all numbers are
+    emitted as a simple string of digits, prefixed by a '+' (unless
+    *want_plus_prefix* is ``False``).
+
+    *name* is a ``dns.name.Name``, the ENUM domain name.
+
+    *origin* is a ``dns.name.Name``, a domain containing the ENUM
+    domain name.  The name is relativized to this domain before being
+    converted to text.  If ``None``, no relativization is done.
+
+    *want_plus_prefix* is a ``bool``.  If True, add a '+' to the beginning of
+    the returned number.
+
+    Returns a ``str``.
+
+    """
+    if origin is not None:
+        name = name.relativize(origin)
+    dlabels = [d for d in name.labels if d.isdigit() and len(d) == 1]
+    if len(dlabels) != len(name.labels):
+        raise dns.exception.SyntaxError("non-digit labels in ENUM domain name")
+    dlabels.reverse()
+    text = b"".join(dlabels)
+    if want_plus_prefix:
+        text = b"+" + text
+    return text.decode()
+
+
+def query(
+    number: str,
+    domains: Iterable[Union[dns.name.Name, str]],
+    resolver: Optional[dns.resolver.Resolver] = None,
+) -> dns.resolver.Answer:
+    """Look for NAPTR RRs for the specified number in the specified domains.
+
+    e.g. lookup('16505551212', ['e164.dnspython.org.', 'e164.arpa.'])
+
+    *number*, a ``str`` is the number to look for.
+
+    *domains* is an iterable containing ``dns.name.Name`` values.
+
+    *resolver*, a ``dns.resolver.Resolver``, is the resolver to use.  If
+    ``None``, the default resolver is used.
+    """
+
+    if resolver is None:
+        resolver = dns.resolver.get_default_resolver()
+    e_nx = dns.resolver.NXDOMAIN()
+    for domain in domains:
+        if isinstance(domain, str):
+            domain = dns.name.from_text(domain)
+        qname = dns.e164.from_e164(number, domain)
+        try:
+            return resolver.resolve(qname, "NAPTR")
+        except dns.resolver.NXDOMAIN as e:
+            e_nx += e
+    raise e_nx
diff --git a/.venv/lib/python3.12/site-packages/dns/edns.py b/.venv/lib/python3.12/site-packages/dns/edns.py
new file mode 100644
index 00000000..f7d9ff99
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/dns/edns.py
@@ -0,0 +1,572 @@
+# Copyright (C) Dnspython Contributors, see LICENSE for text of ISC license
+
+# Copyright (C) 2009-2017 Nominum, Inc.
+#
+# Permission to use, copy, modify, and distribute this software and its
+# documentation for any purpose with or without fee is hereby granted,
+# provided that the above copyright notice and this permission notice
+# appear in all copies.
+#
+# THE SOFTWARE IS PROVIDED "AS IS" AND NOMINUM DISCLAIMS ALL WARRANTIES
+# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL NOMINUM BE LIABLE FOR
+# ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+# ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT
+# OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+
+"""EDNS Options"""
+
+import binascii
+import math
+import socket
+import struct
+from typing import Any, Dict, Optional, Union
+
+import dns.enum
+import dns.inet
+import dns.rdata
+import dns.wire
+
+
+class OptionType(dns.enum.IntEnum):
+    #: NSID
+    NSID = 3
+    #: DAU
+    DAU = 5
+    #: DHU
+    DHU = 6
+    #: N3U
+    N3U = 7
+    #: ECS (client-subnet)
+    ECS = 8
+    #: EXPIRE
+    EXPIRE = 9
+    #: COOKIE
+    COOKIE = 10
+    #: KEEPALIVE
+    KEEPALIVE = 11
+    #: PADDING
+    PADDING = 12
+    #: CHAIN
+    CHAIN = 13
+    #: EDE (extended-dns-error)
+    EDE = 15
+    #: REPORTCHANNEL
+    REPORTCHANNEL = 18
+
+    @classmethod
+    def _maximum(cls):
+        return 65535
+
+
+class Option:
+    """Base class for all EDNS option types."""
+
+    def __init__(self, otype: Union[OptionType, str]):
+        """Initialize an option.
+
+        *otype*, a ``dns.edns.OptionType``, is the option type.
+        """
+        self.otype = OptionType.make(otype)
+
+    def to_wire(self, file: Optional[Any] = None) -> Optional[bytes]:
+        """Convert an option to wire format.
+
+        Returns a ``bytes`` or ``None``.
+
+        """
+        raise NotImplementedError  # pragma: no cover
+
+    def to_text(self) -> str:
+        raise NotImplementedError  # pragma: no cover
+
+    @classmethod
+    def from_wire_parser(cls, otype: OptionType, parser: "dns.wire.Parser") -> "Option":
+        """Build an EDNS option object from wire format.
+
+        *otype*, a ``dns.edns.OptionType``, is the option type.
+
+        *parser*, a ``dns.wire.Parser``, the parser, which should be
+        restructed to the option length.
+
+        Returns a ``dns.edns.Option``.
+        """
+        raise NotImplementedError  # pragma: no cover
+
+    def _cmp(self, other):
+        """Compare an EDNS option with another option of the same type.
+
+        Returns < 0 if < *other*, 0 if == *other*, and > 0 if > *other*.
+        """
+        wire = self.to_wire()
+        owire = other.to_wire()
+        if wire == owire:
+            return 0
+        if wire > owire:
+            return 1
+        return -1
+
+    def __eq__(self, other):
+        if not isinstance(other, Option):
+            return False
+        if self.otype != other.otype:
+            return False
+        return self._cmp(other) == 0
+
+    def __ne__(self, other):
+        if not isinstance(other, Option):
+            return True
+        if self.otype != other.otype:
+            return True
+        return self._cmp(other) != 0
+
+    def __lt__(self, other):
+        if not isinstance(other, Option) or self.otype != other.otype:
+            return NotImplemented
+        return self._cmp(other) < 0
+
+    def __le__(self, other):
+        if not isinstance(other, Option) or self.otype != other.otype:
+            return NotImplemented
+        return self._cmp(other) <= 0
+
+    def __ge__(self, other):
+        if not isinstance(other, Option) or self.otype != other.otype:
+            return NotImplemented
+        return self._cmp(other) >= 0
+
+    def __gt__(self, other):
+        if not isinstance(other, Option) or self.otype != other.otype:
+            return NotImplemented
+        return self._cmp(other) > 0
+
+    def __str__(self):
+        return self.to_text()
+
+
+class GenericOption(Option):  # lgtm[py/missing-equals]
+    """Generic Option Class
+
+    This class is used for EDNS option types for which we have no better
+    implementation.
+    """
+
+    def __init__(self, otype: Union[OptionType, str], data: Union[bytes, str]):
+        super().__init__(otype)
+        self.data = dns.rdata.Rdata._as_bytes(data, True)
+
+    def to_wire(self, file: Optional[Any] = None) -> Optional[bytes]:
+        if file:
+            file.write(self.data)
+            return None
+        else:
+            return self.data
+
+    def to_text(self) -> str:
+        return "Generic %d" % self.otype
+
+    @classmethod
+    def from_wire_parser(
+        cls, otype: Union[OptionType, str], parser: "dns.wire.Parser"
+    ) -> Option:
+        return cls(otype, parser.get_remaining())
+
+
+class ECSOption(Option):  # lgtm[py/missing-equals]
+    """EDNS Client Subnet (ECS, RFC7871)"""
+
+    def __init__(self, address: str, srclen: Optional[int] = None, scopelen: int = 0):
+        """*address*, a ``str``, is the client address information.
+
+        *srclen*, an ``int``, the source prefix length, which is the
+        leftmost number of bits of the address to be used for the
+        lookup.  The default is 24 for IPv4 and 56 for IPv6.
+
+        *scopelen*, an ``int``, the scope prefix length.  This value
+        must be 0 in queries, and should be set in responses.
+        """
+
+        super().__init__(OptionType.ECS)
+        af = dns.inet.af_for_address(address)
+
+        if af == socket.AF_INET6:
+            self.family = 2
+            if srclen is None:
+                srclen = 56
+            address = dns.rdata.Rdata._as_ipv6_address(address)
+            srclen = dns.rdata.Rdata._as_int(srclen, 0, 128)
+            scopelen = dns.rdata.Rdata._as_int(scopelen, 0, 128)
+        elif af == socket.AF_INET:
+            self.family = 1
+            if srclen is None:
+                srclen = 24
+            address = dns.rdata.Rdata._as_ipv4_address(address)
+            srclen = dns.rdata.Rdata._as_int(srclen, 0, 32)
+            scopelen = dns.rdata.Rdata._as_int(scopelen, 0, 32)
+        else:  # pragma: no cover   (this will never happen)
+            raise ValueError("Bad address family")
+
+        assert srclen is not None
+        self.address = address
+        self.srclen = srclen
+        self.scopelen = scopelen
+
+        addrdata = dns.inet.inet_pton(af, address)
+        nbytes = int(math.ceil(srclen / 8.0))
+
+        # Truncate to srclen and pad to the end of the last octet needed
+        # See RFC section 6
+        self.addrdata = addrdata[:nbytes]
+        nbits = srclen % 8
+        if nbits != 0:
+            last = struct.pack("B", ord(self.addrdata[-1:]) & (0xFF << (8 - nbits)))
+            self.addrdata = self.addrdata[:-1] + last
+
+    def to_text(self) -> str:
+        return f"ECS {self.address}/{self.srclen} scope/{self.scopelen}"
+
+    @staticmethod
+    def from_text(text: str) -> Option:
+        """Convert a string into a `dns.edns.ECSOption`
+
+        *text*, a `str`, the text form of the option.
+
+        Returns a `dns.edns.ECSOption`.
+
+        Examples:
+
+        >>> import dns.edns
+        >>>
+        >>> # basic example
+        >>> dns.edns.ECSOption.from_text('1.2.3.4/24')
+        >>>
+        >>> # also understands scope
+        >>> dns.edns.ECSOption.from_text('1.2.3.4/24/32')
+        >>>
+        >>> # IPv6
+        >>> dns.edns.ECSOption.from_text('2001:4b98::1/64/64')
+        >>>
+        >>> # it understands results from `dns.edns.ECSOption.to_text()`
+        >>> dns.edns.ECSOption.from_text('ECS 1.2.3.4/24/32')
+        """
+        optional_prefix = "ECS"
+        tokens = text.split()
+        ecs_text = None
+        if len(tokens) == 1:
+            ecs_text = tokens[0]
+        elif len(tokens) == 2:
+            if tokens[0] != optional_prefix:
+                raise ValueError(f'could not parse ECS from "{text}"')
+            ecs_text = tokens[1]
+        else:
+            raise ValueError(f'could not parse ECS from "{text}"')
+        n_slashes = ecs_text.count("/")
+        if n_slashes == 1:
+            address, tsrclen = ecs_text.split("/")
+            tscope = "0"
+        elif n_slashes == 2:
+            address, tsrclen, tscope = ecs_text.split("/")
+        else:
+            raise ValueError(f'could not parse ECS from "{text}"')
+        try:
+            scope = int(tscope)
+        except ValueError:
+            raise ValueError("invalid scope " + f'"{tscope}": scope must be an integer')
+        try:
+            srclen = int(tsrclen)
+        except ValueError:
+            raise ValueError(
+                "invalid srclen " + f'"{tsrclen}": srclen must be an integer'
+            )
+        return ECSOption(address, srclen, scope)
+
+    def to_wire(self, file: Optional[Any] = None) -> Optional[bytes]:
+        value = (
+            struct.pack("!HBB", self.family, self.srclen, self.scopelen) + self.addrdata
+        )
+        if file:
+            file.write(value)
+            return None
+        else:
+            return value
+
+    @classmethod
+    def from_wire_parser(
+        cls, otype: Union[OptionType, str], parser: "dns.wire.Parser"
+    ) -> Option:
+        family, src, scope = parser.get_struct("!HBB")
+        addrlen = int(math.ceil(src / 8.0))
+        prefix = parser.get_bytes(addrlen)
+        if family == 1:
+            pad = 4 - addrlen
+            addr = dns.ipv4.inet_ntoa(prefix + b"\x00" * pad)
+        elif family == 2:
+            pad = 16 - addrlen
+            addr = dns.ipv6.inet_ntoa(prefix + b"\x00" * pad)
+        else:
+            raise ValueError("unsupported family")
+
+        return cls(addr, src, scope)
+
+
+class EDECode(dns.enum.IntEnum):
+    OTHER = 0
+    UNSUPPORTED_DNSKEY_ALGORITHM = 1
+    UNSUPPORTED_DS_DIGEST_TYPE = 2
+    STALE_ANSWER = 3
+    FORGED_ANSWER = 4
+    DNSSEC_INDETERMINATE = 5
+    DNSSEC_BOGUS = 6
+    SIGNATURE_EXPIRED = 7
+    SIGNATURE_NOT_YET_VALID = 8
+    DNSKEY_MISSING = 9
+    RRSIGS_MISSING = 10
+    NO_ZONE_KEY_BIT_SET = 11
+    NSEC_MISSING = 12
+    CACHED_ERROR = 13
+    NOT_READY = 14
+    BLOCKED = 15
+    CENSORED = 16
+    FILTERED = 17
+    PROHIBITED = 18
+    STALE_NXDOMAIN_ANSWER = 19
+    NOT_AUTHORITATIVE = 20
+    NOT_SUPPORTED = 21
+    NO_REACHABLE_AUTHORITY = 22
+    NETWORK_ERROR = 23
+    INVALID_DATA = 24
+
+    @classmethod
+    def _maximum(cls):
+        return 65535
+
+
+class EDEOption(Option):  # lgtm[py/missing-equals]
+    """Extended DNS Error (EDE, RFC8914)"""
+
+    _preserve_case = {"DNSKEY", "DS", "DNSSEC", "RRSIGs", "NSEC", "NXDOMAIN"}
+
+    def __init__(self, code: Union[EDECode, str], text: Optional[str] = None):
+        """*code*, a ``dns.edns.EDECode`` or ``str``, the info code of the
+        extended error.
+
+        *text*, a ``str`` or ``None``, specifying additional information about
+        the error.
+        """
+
+        super().__init__(OptionType.EDE)
+
+        self.code = EDECode.make(code)
+        if text is not None and not isinstance(text, str):
+            raise ValueError("text must be string or None")
+        self.text = text
+
+    def to_text(self) -> str:
+        output = f"EDE {self.code}"
+        if self.code in EDECode:
+            desc = EDECode.to_text(self.code)
+            desc = " ".join(
+                word if word in self._preserve_case else word.title()
+                for word in desc.split("_")
+            )
+            output += f" ({desc})"
+        if self.text is not None:
+            output += f": {self.text}"
+        return output
+
+    def to_wire(self, file: Optional[Any] = None) -> Optional[bytes]:
+        value = struct.pack("!H", self.code)
+        if self.text is not None:
+            value += self.text.encode("utf8")
+
+        if file:
+            file.write(value)
+            return None
+        else:
+            return value
+
+    @classmethod
+    def from_wire_parser(
+        cls, otype: Union[OptionType, str], parser: "dns.wire.Parser"
+    ) -> Option:
+        code = EDECode.make(parser.get_uint16())
+        text = parser.get_remaining()
+
+        if text:
+            if text[-1] == 0:  # text MAY be null-terminated
+                text = text[:-1]
+            btext = text.decode("utf8")
+        else:
+            btext = None
+
+        return cls(code, btext)
+
+
+class NSIDOption(Option):
+    def __init__(self, nsid: bytes):
+        super().__init__(OptionType.NSID)
+        self.nsid = nsid
+
+    def to_wire(self, file: Any = None) -> Optional[bytes]:
+        if file:
+            file.write(self.nsid)
+            return None
+        else:
+            return self.nsid
+
+    def to_text(self) -> str:
+        if all(c >= 0x20 and c <= 0x7E for c in self.nsid):
+            # All ASCII printable, so it's probably a string.
+            value = self.nsid.decode()
+        else:
+            value = binascii.hexlify(self.nsid).decode()
+        return f"NSID {value}"
+
+    @classmethod
+    def from_wire_parser(
+        cls, otype: Union[OptionType, str], parser: dns.wire.Parser
+    ) -> Option:
+        return cls(parser.get_remaining())
+
+
+class CookieOption(Option):
+    def __init__(self, client: bytes, server: bytes):
+        super().__init__(dns.edns.OptionType.COOKIE)
+        self.client = client
+        self.server = server
+        if len(client) != 8:
+            raise ValueError("client cookie must be 8 bytes")
+        if len(server) != 0 and (len(server) < 8 or len(server) > 32):
+            raise ValueError("server cookie must be empty or between 8 and 32 bytes")
+
+    def to_wire(self, file: Any = None) -> Optional[bytes]:
+        if file:
+            file.write(self.client)
+            if len(self.server) > 0:
+                file.write(self.server)
+            return None
+        else:
+            return self.client + self.server
+
+    def to_text(self) -> str:
+        client = binascii.hexlify(self.client).decode()
+        if len(self.server) > 0:
+            server = binascii.hexlify(self.server).decode()
+        else:
+            server = ""
+        return f"COOKIE {client}{server}"
+
+    @classmethod
+    def from_wire_parser(
+        cls, otype: Union[OptionType, str], parser: dns.wire.Parser
+    ) -> Option:
+        return cls(parser.get_bytes(8), parser.get_remaining())
+
+
+class ReportChannelOption(Option):
+    # RFC 9567
+    def __init__(self, agent_domain: dns.name.Name):
+        super().__init__(OptionType.REPORTCHANNEL)
+        self.agent_domain = agent_domain
+
+    def to_wire(self, file: Any = None) -> Optional[bytes]:
+        return self.agent_domain.to_wire(file)
+
+    def to_text(self) -> str:
+        return "REPORTCHANNEL " + self.agent_domain.to_text()
+
+    @classmethod
+    def from_wire_parser(
+        cls, otype: Union[OptionType, str], parser: dns.wire.Parser
+    ) -> Option:
+        return cls(parser.get_name())
+
+
+_type_to_class: Dict[OptionType, Any] = {
+    OptionType.ECS: ECSOption,
+    OptionType.EDE: EDEOption,
+    OptionType.NSID: NSIDOption,
+    OptionType.COOKIE: CookieOption,
+    OptionType.REPORTCHANNEL: ReportChannelOption,
+}
+
+
+def get_option_class(otype: OptionType) -> Any:
+    """Return the class for the specified option type.
+
+    The GenericOption class is used if a more specific class is not
+    known.
+    """
+
+    cls = _type_to_class.get(otype)
+    if cls is None:
+        cls = GenericOption
+    return cls
+
+
+def option_from_wire_parser(
+    otype: Union[OptionType, str], parser: "dns.wire.Parser"
+) -> Option:
+    """Build an EDNS option object from wire format.
+
+    *otype*, an ``int``, is the option type.
+
+    *parser*, a ``dns.wire.Parser``, the parser, which should be
+    restricted to the option length.
+
+    Returns an instance of a subclass of ``dns.edns.Option``.
+    """
+    otype = OptionType.make(otype)
+    cls = get_option_class(otype)
+    return cls.from_wire_parser(otype, parser)
+
+
+def option_from_wire(
+    otype: Union[OptionType, str], wire: bytes, current: int, olen: int
+) -> Option:
+    """Build an EDNS option object from wire format.
+
+    *otype*, an ``int``, is the option type.
+
+    *wire*, a ``bytes``, is the wire-format message.
+
+    *current*, an ``int``, is the offset in *wire* of the beginning
+    of the rdata.
+
+    *olen*, an ``int``, is the length of the wire-format option data
+
+    Returns an instance of a subclass of ``dns.edns.Option``.
+    """
+    parser = dns.wire.Parser(wire, current)
+    with parser.restrict_to(olen):
+        return option_from_wire_parser(otype, parser)
+
+
+def register_type(implementation: Any, otype: OptionType) -> None:
+    """Register the implementation of an option type.
+
+    *implementation*, a ``class``, is a subclass of ``dns.edns.Option``.
+
+    *otype*, an ``int``, is the option type.
+    """
+
+    _type_to_class[otype] = implementation
+
+
+### BEGIN generated OptionType constants
+
+NSID = OptionType.NSID
+DAU = OptionType.DAU
+DHU = OptionType.DHU
+N3U = OptionType.N3U
+ECS = OptionType.ECS
+EXPIRE = OptionType.EXPIRE
+COOKIE = OptionType.COOKIE
+KEEPALIVE = OptionType.KEEPALIVE
+PADDING = OptionType.PADDING
+CHAIN = OptionType.CHAIN
+EDE = OptionType.EDE
+REPORTCHANNEL = OptionType.REPORTCHANNEL
+
+### END generated OptionType constants
diff --git a/.venv/lib/python3.12/site-packages/dns/entropy.py b/.venv/lib/python3.12/site-packages/dns/entropy.py
new file mode 100644
index 00000000..4dcdc627
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/dns/entropy.py
@@ -0,0 +1,130 @@
+# Copyright (C) Dnspython Contributors, see LICENSE for text of ISC license
+
+# Copyright (C) 2009-2017 Nominum, Inc.
+#
+# Permission to use, copy, modify, and distribute this software and its
+# documentation for any purpose with or without fee is hereby granted,
+# provided that the above copyright notice and this permission notice
+# appear in all copies.
+#
+# THE SOFTWARE IS PROVIDED "AS IS" AND NOMINUM DISCLAIMS ALL WARRANTIES
+# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL NOMINUM BE LIABLE FOR
+# ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+# ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT
+# OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+
+import hashlib
+import os
+import random
+import threading
+import time
+from typing import Any, Optional
+
+
+class EntropyPool:
+    # This is an entropy pool for Python implementations that do not
+    # have a working SystemRandom.  I'm not sure there are any, but
+    # leaving this code doesn't hurt anything as the library code
+    # is used if present.
+
+    def __init__(self, seed: Optional[bytes] = None):
+        self.pool_index = 0
+        self.digest: Optional[bytearray] = None
+        self.next_byte = 0
+        self.lock = threading.Lock()
+        self.hash = hashlib.sha1()
+        self.hash_len = 20
+        self.pool = bytearray(b"\0" * self.hash_len)
+        if seed is not None:
+            self._stir(seed)
+            self.seeded = True
+            self.seed_pid = os.getpid()
+        else:
+            self.seeded = False
+            self.seed_pid = 0
+
+    def _stir(self, entropy: bytes) -> None:
+        for c in entropy:
+            if self.pool_index == self.hash_len:
+                self.pool_index = 0
+            b = c & 0xFF
+            self.pool[self.pool_index] ^= b
+            self.pool_index += 1
+
+    def stir(self, entropy: bytes) -> None:
+        with self.lock:
+            self._stir(entropy)
+
+    def _maybe_seed(self) -> None:
+        if not self.seeded or self.seed_pid != os.getpid():
+            try:
+                seed = os.urandom(16)
+            except Exception:  # pragma: no cover
+                try:
+                    with open("/dev/urandom", "rb", 0) as r:
+                        seed = r.read(16)
+                except Exception:
+                    seed = str(time.time()).encode()
+            self.seeded = True
+            self.seed_pid = os.getpid()
+            self.digest = None
+            seed = bytearray(seed)
+            self._stir(seed)
+
+    def random_8(self) -> int:
+        with self.lock:
+            self._maybe_seed()
+            if self.digest is None or self.next_byte == self.hash_len:
+                self.hash.update(bytes(self.pool))
+                self.digest = bytearray(self.hash.digest())
+                self._stir(self.digest)
+                self.next_byte = 0
+            value = self.digest[self.next_byte]
+            self.next_byte += 1
+        return value
+
+    def random_16(self) -> int:
+        return self.random_8() * 256 + self.random_8()
+
+    def random_32(self) -> int:
+        return self.random_16() * 65536 + self.random_16()
+
+    def random_between(self, first: int, last: int) -> int:
+        size = last - first + 1
+        if size > 4294967296:
+            raise ValueError("too big")
+        if size > 65536:
+            rand = self.random_32
+            max = 4294967295
+        elif size > 256:
+            rand = self.random_16
+            max = 65535
+        else:
+            rand = self.random_8
+            max = 255
+        return first + size * rand() // (max + 1)
+
+
+pool = EntropyPool()
+
+system_random: Optional[Any]
+try:
+    system_random = random.SystemRandom()
+except Exception:  # pragma: no cover
+    system_random = None
+
+
+def random_16() -> int:
+    if system_random is not None:
+        return system_random.randrange(0, 65536)
+    else:
+        return pool.random_16()
+
+
+def between(first: int, last: int) -> int:
+    if system_random is not None:
+        return system_random.randrange(first, last + 1)
+    else:
+        return pool.random_between(first, last)
diff --git a/.venv/lib/python3.12/site-packages/dns/enum.py b/.venv/lib/python3.12/site-packages/dns/enum.py
new file mode 100644
index 00000000..71461f17
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/dns/enum.py
@@ -0,0 +1,116 @@
+# Copyright (C) Dnspython Contributors, see LICENSE for text of ISC license
+
+# Copyright (C) 2003-2017 Nominum, Inc.
+#
+# Permission to use, copy, modify, and distribute this software and its
+# documentation for any purpose with or without fee is hereby granted,
+# provided that the above copyright notice and this permission notice
+# appear in all copies.
+#
+# THE SOFTWARE IS PROVIDED "AS IS" AND NOMINUM DISCLAIMS ALL WARRANTIES
+# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL NOMINUM BE LIABLE FOR
+# ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+# ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT
+# OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+
+import enum
+from typing import Type, TypeVar, Union
+
+TIntEnum = TypeVar("TIntEnum", bound="IntEnum")
+
+
+class IntEnum(enum.IntEnum):
+    @classmethod
+    def _missing_(cls, value):
+        cls._check_value(value)
+        val = int.__new__(cls, value)
+        val._name_ = cls._extra_to_text(value, None) or f"{cls._prefix()}{value}"
+        val._value_ = value
+        return val
+
+    @classmethod
+    def _check_value(cls, value):
+        max = cls._maximum()
+        if not isinstance(value, int):
+            raise TypeError
+        if value < 0 or value > max:
+            name = cls._short_name()
+            raise ValueError(f"{name} must be an int between >= 0 and <= {max}")
+
+    @classmethod
+    def from_text(cls: Type[TIntEnum], text: str) -> TIntEnum:
+        text = text.upper()
+        try:
+            return cls[text]
+        except KeyError:
+            pass
+        value = cls._extra_from_text(text)
+        if value:
+            return value
+        prefix = cls._prefix()
+        if text.startswith(prefix) and text[len(prefix) :].isdigit():
+            value = int(text[len(prefix) :])
+            cls._check_value(value)
+            try:
+                return cls(value)
+            except ValueError:
+                return value
+        raise cls._unknown_exception_class()
+
+    @classmethod
+    def to_text(cls: Type[TIntEnum], value: int) -> str:
+        cls._check_value(value)
+        try:
+            text = cls(value).name
+        except ValueError:
+            text = None
+        text = cls._extra_to_text(value, text)
+        if text is None:
+            text = f"{cls._prefix()}{value}"
+        return text
+
+    @classmethod
+    def make(cls: Type[TIntEnum], value: Union[int, str]) -> TIntEnum:
+        """Convert text or a value into an enumerated type, if possible.
+
+        *value*, the ``int`` or ``str`` to convert.
+
+        Raises a class-specific exception if a ``str`` is provided that
+        cannot be converted.
+
+        Raises ``ValueError`` if the value is out of range.
+
+        Returns an enumeration from the calling class corresponding to the
+        value, if one is defined, or an ``int`` otherwise.
+        """
+
+        if isinstance(value, str):
+            return cls.from_text(value)
+        cls._check_value(value)
+        return cls(value)
+
+    @classmethod
+    def _maximum(cls):
+        raise NotImplementedError  # pragma: no cover
+
+    @classmethod
+    def _short_name(cls):
+        return cls.__name__.lower()
+
+    @classmethod
+    def _prefix(cls):
+        return ""
+
+    @classmethod
+    def _extra_from_text(cls, text):  # pylint: disable=W0613
+        return None
+
+    @classmethod
+    def _extra_to_text(cls, value, current_text):  # pylint: disable=W0613
+        return current_text
+
+    @classmethod
+    def _unknown_exception_class(cls):
+        return ValueError
diff --git a/.venv/lib/python3.12/site-packages/dns/exception.py b/.venv/lib/python3.12/site-packages/dns/exception.py
new file mode 100644
index 00000000..223f2d68
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/dns/exception.py
@@ -0,0 +1,169 @@
+# Copyright (C) Dnspython Contributors, see LICENSE for text of ISC license
+
+# Copyright (C) 2003-2017 Nominum, Inc.
+#
+# Permission to use, copy, modify, and distribute this software and its
+# documentation for any purpose with or without fee is hereby granted,
+# provided that the above copyright notice and this permission notice
+# appear in all copies.
+#
+# THE SOFTWARE IS PROVIDED "AS IS" AND NOMINUM DISCLAIMS ALL WARRANTIES
+# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL NOMINUM BE LIABLE FOR
+# ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+# ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT
+# OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+
+"""Common DNS Exceptions.
+
+Dnspython modules may also define their own exceptions, which will
+always be subclasses of ``DNSException``.
+"""
+
+
+from typing import Optional, Set
+
+
+class DNSException(Exception):
+    """Abstract base class shared by all dnspython exceptions.
+
+    It supports two basic modes of operation:
+
+    a) Old/compatible mode is used if ``__init__`` was called with
+    empty *kwargs*.  In compatible mode all *args* are passed
+    to the standard Python Exception class as before and all *args* are
+    printed by the standard ``__str__`` implementation.  Class variable
+    ``msg`` (or doc string if ``msg`` is ``None``) is returned from ``str()``
+    if *args* is empty.
+
+    b) New/parametrized mode is used if ``__init__`` was called with
+    non-empty *kwargs*.
+    In the new mode *args* must be empty and all kwargs must match
+    those set in class variable ``supp_kwargs``. All kwargs are stored inside
+    ``self.kwargs`` and used in a new ``__str__`` implementation to construct
+    a formatted message based on the ``fmt`` class variable, a ``string``.
+
+    In the simplest case it is enough to override the ``supp_kwargs``
+    and ``fmt`` class variables to get nice parametrized messages.
+    """
+
+    msg: Optional[str] = None  # non-parametrized message
+    supp_kwargs: Set[str] = set()  # accepted parameters for _fmt_kwargs (sanity check)
+    fmt: Optional[str] = None  # message parametrized with results from _fmt_kwargs
+
+    def __init__(self, *args, **kwargs):
+        self._check_params(*args, **kwargs)
+        if kwargs:
+            # This call to a virtual method from __init__ is ok in our usage
+            self.kwargs = self._check_kwargs(**kwargs)  # lgtm[py/init-calls-subclass]
+            self.msg = str(self)
+        else:
+            self.kwargs = dict()  # defined but empty for old mode exceptions
+        if self.msg is None:
+            # doc string is better implicit message than empty string
+            self.msg = self.__doc__
+        if args:
+            super().__init__(*args)
+        else:
+            super().__init__(self.msg)
+
+    def _check_params(self, *args, **kwargs):
+        """Old exceptions supported only args and not kwargs.
+
+        For sanity we do not allow to mix old and new behavior."""
+        if args or kwargs:
+            assert bool(args) != bool(
+                kwargs
+            ), "keyword arguments are mutually exclusive with positional args"
+
+    def _check_kwargs(self, **kwargs):
+        if kwargs:
+            assert (
+                set(kwargs.keys()) == self.supp_kwargs
+            ), f"following set of keyword args is required: {self.supp_kwargs}"
+        return kwargs
+
+    def _fmt_kwargs(self, **kwargs):
+        """Format kwargs before printing them.
+
+        Resulting dictionary has to have keys necessary for str.format call
+        on fmt class variable.
+        """
+        fmtargs = {}
+        for kw, data in kwargs.items():
+            if isinstance(data, (list, set)):
+                # convert list of <someobj> to list of str(<someobj>)
+                fmtargs[kw] = list(map(str, data))
+                if len(fmtargs[kw]) == 1:
+                    # remove list brackets [] from single-item lists
+                    fmtargs[kw] = fmtargs[kw].pop()
+            else:
+                fmtargs[kw] = data
+        return fmtargs
+
+    def __str__(self):
+        if self.kwargs and self.fmt:
+            # provide custom message constructed from keyword arguments
+            fmtargs = self._fmt_kwargs(**self.kwargs)
+            return self.fmt.format(**fmtargs)
+        else:
+            # print *args directly in the same way as old DNSException
+            return super().__str__()
+
+
+class FormError(DNSException):
+    """DNS message is malformed."""
+
+
+class SyntaxError(DNSException):
+    """Text input is malformed."""
+
+
+class UnexpectedEnd(SyntaxError):
+    """Text input ended unexpectedly."""
+
+
+class TooBig(DNSException):
+    """The DNS message is too big."""
+
+
+class Timeout(DNSException):
+    """The DNS operation timed out."""
+
+    supp_kwargs = {"timeout"}
+    fmt = "The DNS operation timed out after {timeout:.3f} seconds"
+
+    # We do this as otherwise mypy complains about unexpected keyword argument
+    # idna_exception
+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+
+
+class UnsupportedAlgorithm(DNSException):
+    """The DNSSEC algorithm is not supported."""
+
+
+class AlgorithmKeyMismatch(UnsupportedAlgorithm):
+    """The DNSSEC algorithm is not supported for the given key type."""
+
+
+class ValidationFailure(DNSException):
+    """The DNSSEC signature is invalid."""
+
+
+class DeniedByPolicy(DNSException):
+    """Denied by DNSSEC policy."""
+
+
+class ExceptionWrapper:
+    def __init__(self, exception_class):
+        self.exception_class = exception_class
+
+    def __enter__(self):
+        return self
+
+    def __exit__(self, exc_type, exc_val, exc_tb):
+        if exc_type is not None and not isinstance(exc_val, self.exception_class):
+            raise self.exception_class(str(exc_val)) from exc_val
+        return False
diff --git a/.venv/lib/python3.12/site-packages/dns/flags.py b/.venv/lib/python3.12/site-packages/dns/flags.py
new file mode 100644
index 00000000..4c60be13
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/dns/flags.py
@@ -0,0 +1,123 @@
+# Copyright (C) Dnspython Contributors, see LICENSE for text of ISC license
+
+# Copyright (C) 2001-2017 Nominum, Inc.
+#
+# Permission to use, copy, modify, and distribute this software and its
+# documentation for any purpose with or without fee is hereby granted,
+# provided that the above copyright notice and this permission notice
+# appear in all copies.
+#
+# THE SOFTWARE IS PROVIDED "AS IS" AND NOMINUM DISCLAIMS ALL WARRANTIES
+# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL NOMINUM BE LIABLE FOR
+# ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+# ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT
+# OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+
+"""DNS Message Flags."""
+
+import enum
+from typing import Any
+
+# Standard DNS flags
+
+
+class Flag(enum.IntFlag):
+    #: Query Response
+    QR = 0x8000
+    #: Authoritative Answer
+    AA = 0x0400
+    #: Truncated Response
+    TC = 0x0200
+    #: Recursion Desired
+    RD = 0x0100
+    #: Recursion Available
+    RA = 0x0080
+    #: Authentic Data
+    AD = 0x0020
+    #: Checking Disabled
+    CD = 0x0010
+
+
+# EDNS flags
+
+
+class EDNSFlag(enum.IntFlag):
+    #: DNSSEC answer OK
+    DO = 0x8000
+
+
+def _from_text(text: str, enum_class: Any) -> int:
+    flags = 0
+    tokens = text.split()
+    for t in tokens:
+        flags |= enum_class[t.upper()]
+    return flags
+
+
+def _to_text(flags: int, enum_class: Any) -> str:
+    text_flags = []
+    for k, v in enum_class.__members__.items():
+        if flags & v != 0:
+            text_flags.append(k)
+    return " ".join(text_flags)
+
+
+def from_text(text: str) -> int:
+    """Convert a space-separated list of flag text values into a flags
+    value.
+
+    Returns an ``int``
+    """
+
+    return _from_text(text, Flag)
+
+
+def to_text(flags: int) -> str:
+    """Convert a flags value into a space-separated list of flag text
+    values.
+
+    Returns a ``str``.
+    """
+
+    return _to_text(flags, Flag)
+
+
+def edns_from_text(text: str) -> int:
+    """Convert a space-separated list of EDNS flag text values into a EDNS
+    flags value.
+
+    Returns an ``int``
+    """
+
+    return _from_text(text, EDNSFlag)
+
+
+def edns_to_text(flags: int) -> str:
+    """Convert an EDNS flags value into a space-separated list of EDNS flag
+    text values.
+
+    Returns a ``str``.
+    """
+
+    return _to_text(flags, EDNSFlag)
+
+
+### BEGIN generated Flag constants
+
+QR = Flag.QR
+AA = Flag.AA
+TC = Flag.TC
+RD = Flag.RD
+RA = Flag.RA
+AD = Flag.AD
+CD = Flag.CD
+
+### END generated Flag constants
+
+### BEGIN generated EDNSFlag constants
+
+DO = EDNSFlag.DO
+
+### END generated EDNSFlag constants
diff --git a/.venv/lib/python3.12/site-packages/dns/grange.py b/.venv/lib/python3.12/site-packages/dns/grange.py
new file mode 100644
index 00000000..a967ca41
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/dns/grange.py
@@ -0,0 +1,72 @@
+# Copyright (C) Dnspython Contributors, see LICENSE for text of ISC license
+
+# Copyright (C) 2012-2017 Nominum, Inc.
+#
+# Permission to use, copy, modify, and distribute this software and its
+# documentation for any purpose with or without fee is hereby granted,
+# provided that the above copyright notice and this permission notice
+# appear in all copies.
+#
+# THE SOFTWARE IS PROVIDED "AS IS" AND NOMINUM DISCLAIMS ALL WARRANTIES
+# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL NOMINUM BE LIABLE FOR
+# ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+# ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT
+# OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+
+"""DNS GENERATE range conversion."""
+
+from typing import Tuple
+
+import dns
+
+
+def from_text(text: str) -> Tuple[int, int, int]:
+    """Convert the text form of a range in a ``$GENERATE`` statement to an
+    integer.
+
+    *text*, a ``str``, the textual range in ``$GENERATE`` form.
+
+    Returns a tuple of three ``int`` values ``(start, stop, step)``.
+    """
+
+    start = -1
+    stop = -1
+    step = 1
+    cur = ""
+    state = 0
+    # state   0   1   2
+    #         x - y / z
+
+    if text and text[0] == "-":
+        raise dns.exception.SyntaxError("Start cannot be a negative number")
+
+    for c in text:
+        if c == "-" and state == 0:
+            start = int(cur)
+            cur = ""
+            state = 1
+        elif c == "/":
+            stop = int(cur)
+            cur = ""
+            state = 2
+        elif c.isdigit():
+            cur += c
+        else:
+            raise dns.exception.SyntaxError(f"Could not parse {c}")
+
+    if state == 0:
+        raise dns.exception.SyntaxError("no stop value specified")
+    elif state == 1:
+        stop = int(cur)
+    else:
+        assert state == 2
+        step = int(cur)
+
+    assert step >= 1
+    assert start >= 0
+    if start > stop:
+        raise dns.exception.SyntaxError("start must be <= stop")
+
+    return (start, stop, step)
diff --git a/.venv/lib/python3.12/site-packages/dns/immutable.py b/.venv/lib/python3.12/site-packages/dns/immutable.py
new file mode 100644
index 00000000..36b0362c
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/dns/immutable.py
@@ -0,0 +1,68 @@
+# Copyright (C) Dnspython Contributors, see LICENSE for text of ISC license
+
+import collections.abc
+from typing import Any, Callable
+
+from dns._immutable_ctx import immutable
+
+
+@immutable
+class Dict(collections.abc.Mapping):  # lgtm[py/missing-equals]
+    def __init__(
+        self,
+        dictionary: Any,
+        no_copy: bool = False,
+        map_factory: Callable[[], collections.abc.MutableMapping] = dict,
+    ):
+        """Make an immutable dictionary from the specified dictionary.
+
+        If *no_copy* is `True`, then *dictionary* will be wrapped instead
+        of copied.  Only set this if you are sure there will be no external
+        references to the dictionary.
+        """
+        if no_copy and isinstance(dictionary, collections.abc.MutableMapping):
+            self._odict = dictionary
+        else:
+            self._odict = map_factory()
+            self._odict.update(dictionary)
+        self._hash = None
+
+    def __getitem__(self, key):
+        return self._odict.__getitem__(key)
+
+    def __hash__(self):  # pylint: disable=invalid-hash-returned
+        if self._hash is None:
+            h = 0
+            for key in sorted(self._odict.keys()):
+                h ^= hash(key)
+            object.__setattr__(self, "_hash", h)
+        # this does return an int, but pylint doesn't figure that out
+        return self._hash
+
+    def __len__(self):
+        return len(self._odict)
+
+    def __iter__(self):
+        return iter(self._odict)
+
+
+def constify(o: Any) -> Any:
+    """
+    Convert mutable types to immutable types.
+    """
+    if isinstance(o, bytearray):
+        return bytes(o)
+    if isinstance(o, tuple):
+        try:
+            hash(o)
+            return o
+        except Exception:
+            return tuple(constify(elt) for elt in o)
+    if isinstance(o, list):
+        return tuple(constify(elt) for elt in o)
+    if isinstance(o, dict):
+        cdict = dict()
+        for k, v in o.items():
+            cdict[k] = constify(v)
+        return Dict(cdict, True)
+    return o
diff --git a/.venv/lib/python3.12/site-packages/dns/inet.py b/.venv/lib/python3.12/site-packages/dns/inet.py
new file mode 100644
index 00000000..4a03f996
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/dns/inet.py
@@ -0,0 +1,197 @@
+# Copyright (C) Dnspython Contributors, see LICENSE for text of ISC license
+
+# Copyright (C) 2003-2017 Nominum, Inc.
+#
+# Permission to use, copy, modify, and distribute this software and its
+# documentation for any purpose with or without fee is hereby granted,
+# provided that the above copyright notice and this permission notice
+# appear in all copies.
+#
+# THE SOFTWARE IS PROVIDED "AS IS" AND NOMINUM DISCLAIMS ALL WARRANTIES
+# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL NOMINUM BE LIABLE FOR
+# ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+# ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT
+# OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+
+"""Generic Internet address helper functions."""
+
+import socket
+from typing import Any, Optional, Tuple
+
+import dns.ipv4
+import dns.ipv6
+
+# We assume that AF_INET and AF_INET6 are always defined.  We keep
+# these here for the benefit of any old code (unlikely though that
+# is!).
+AF_INET = socket.AF_INET
+AF_INET6 = socket.AF_INET6
+
+
+def inet_pton(family: int, text: str) -> bytes:
+    """Convert the textual form of a network address into its binary form.
+
+    *family* is an ``int``, the address family.
+
+    *text* is a ``str``, the textual address.
+
+    Raises ``NotImplementedError`` if the address family specified is not
+    implemented.
+
+    Returns a ``bytes``.
+    """
+
+    if family == AF_INET:
+        return dns.ipv4.inet_aton(text)
+    elif family == AF_INET6:
+        return dns.ipv6.inet_aton(text, True)
+    else:
+        raise NotImplementedError
+
+
+def inet_ntop(family: int, address: bytes) -> str:
+    """Convert the binary form of a network address into its textual form.
+
+    *family* is an ``int``, the address family.
+
+    *address* is a ``bytes``, the network address in binary form.
+
+    Raises ``NotImplementedError`` if the address family specified is not
+    implemented.
+
+    Returns a ``str``.
+    """
+
+    if family == AF_INET:
+        return dns.ipv4.inet_ntoa(address)
+    elif family == AF_INET6:
+        return dns.ipv6.inet_ntoa(address)
+    else:
+        raise NotImplementedError
+
+
+def af_for_address(text: str) -> int:
+    """Determine the address family of a textual-form network address.
+
+    *text*, a ``str``, the textual address.
+
+    Raises ``ValueError`` if the address family cannot be determined
+    from the input.
+
+    Returns an ``int``.
+    """
+
+    try:
+        dns.ipv4.inet_aton(text)
+        return AF_INET
+    except Exception:
+        try:
+            dns.ipv6.inet_aton(text, True)
+            return AF_INET6
+        except Exception:
+            raise ValueError
+
+
+def is_multicast(text: str) -> bool:
+    """Is the textual-form network address a multicast address?
+
+    *text*, a ``str``, the textual address.
+
+    Raises ``ValueError`` if the address family cannot be determined
+    from the input.
+
+    Returns a ``bool``.
+    """
+
+    try:
+        first = dns.ipv4.inet_aton(text)[0]
+        return first >= 224 and first <= 239
+    except Exception:
+        try:
+            first = dns.ipv6.inet_aton(text, True)[0]
+            return first == 255
+        except Exception:
+            raise ValueError
+
+
+def is_address(text: str) -> bool:
+    """Is the specified string an IPv4 or IPv6 address?
+
+    *text*, a ``str``, the textual address.
+
+    Returns a ``bool``.
+    """
+
+    try:
+        dns.ipv4.inet_aton(text)
+        return True
+    except Exception:
+        try:
+            dns.ipv6.inet_aton(text, True)
+            return True
+        except Exception:
+            return False
+
+
+def low_level_address_tuple(
+    high_tuple: Tuple[str, int], af: Optional[int] = None
+) -> Any:
+    """Given a "high-level" address tuple, i.e.
+    an (address, port) return the appropriate "low-level" address tuple
+    suitable for use in socket calls.
+
+    If an *af* other than ``None`` is provided, it is assumed the
+    address in the high-level tuple is valid and has that af.  If af
+    is ``None``, then af_for_address will be called.
+    """
+    address, port = high_tuple
+    if af is None:
+        af = af_for_address(address)
+    if af == AF_INET:
+        return (address, port)
+    elif af == AF_INET6:
+        i = address.find("%")
+        if i < 0:
+            # no scope, shortcut!
+            return (address, port, 0, 0)
+        # try to avoid getaddrinfo()
+        addrpart = address[:i]
+        scope = address[i + 1 :]
+        if scope.isdigit():
+            return (addrpart, port, 0, int(scope))
+        try:
+            return (addrpart, port, 0, socket.if_nametoindex(scope))
+        except AttributeError:  # pragma: no cover  (we can't really test this)
+            ai_flags = socket.AI_NUMERICHOST
+            ((*_, tup), *_) = socket.getaddrinfo(address, port, flags=ai_flags)
+            return tup
+    else:
+        raise NotImplementedError(f"unknown address family {af}")
+
+
+def any_for_af(af):
+    """Return the 'any' address for the specified address family."""
+    if af == socket.AF_INET:
+        return "0.0.0.0"
+    elif af == socket.AF_INET6:
+        return "::"
+    raise NotImplementedError(f"unknown address family {af}")
+
+
+def canonicalize(text: str) -> str:
+    """Verify that *address* is a valid text form IPv4 or IPv6 address and return its
+    canonical text form.  IPv6 addresses with scopes are rejected.
+
+    *text*, a ``str``, the address in textual form.
+
+    Raises ``ValueError`` if the text is not valid.
+    """
+    try:
+        return dns.ipv6.canonicalize(text)
+    except Exception:
+        try:
+            return dns.ipv4.canonicalize(text)
+        except Exception:
+            raise ValueError
diff --git a/.venv/lib/python3.12/site-packages/dns/ipv4.py b/.venv/lib/python3.12/site-packages/dns/ipv4.py
new file mode 100644
index 00000000..65ee69c0
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/dns/ipv4.py
@@ -0,0 +1,77 @@
+# Copyright (C) Dnspython Contributors, see LICENSE for text of ISC license
+
+# Copyright (C) 2003-2017 Nominum, Inc.
+#
+# Permission to use, copy, modify, and distribute this software and its
+# documentation for any purpose with or without fee is hereby granted,
+# provided that the above copyright notice and this permission notice
+# appear in all copies.
+#
+# THE SOFTWARE IS PROVIDED "AS IS" AND NOMINUM DISCLAIMS ALL WARRANTIES
+# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL NOMINUM BE LIABLE FOR
+# ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+# ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT
+# OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+
+"""IPv4 helper functions."""
+
+import struct
+from typing import Union
+
+import dns.exception
+
+
+def inet_ntoa(address: bytes) -> str:
+    """Convert an IPv4 address in binary form to text form.
+
+    *address*, a ``bytes``, the IPv4 address in binary form.
+
+    Returns a ``str``.
+    """
+
+    if len(address) != 4:
+        raise dns.exception.SyntaxError
+    return "%u.%u.%u.%u" % (address[0], address[1], address[2], address[3])
+
+
+def inet_aton(text: Union[str, bytes]) -> bytes:
+    """Convert an IPv4 address in text form to binary form.
+
+    *text*, a ``str`` or ``bytes``, the IPv4 address in textual form.
+
+    Returns a ``bytes``.
+    """
+
+    if not isinstance(text, bytes):
+        btext = text.encode()
+    else:
+        btext = text
+    parts = btext.split(b".")
+    if len(parts) != 4:
+        raise dns.exception.SyntaxError
+    for part in parts:
+        if not part.isdigit():
+            raise dns.exception.SyntaxError
+        if len(part) > 1 and part[0] == ord("0"):
+            # No leading zeros
+            raise dns.exception.SyntaxError
+    try:
+        b = [int(part) for part in parts]
+        return struct.pack("BBBB", *b)
+    except Exception:
+        raise dns.exception.SyntaxError
+
+
+def canonicalize(text: Union[str, bytes]) -> str:
+    """Verify that *address* is a valid text form IPv4 address and return its
+    canonical text form.
+
+    *text*, a ``str`` or ``bytes``, the IPv4 address in textual form.
+
+    Raises ``dns.exception.SyntaxError`` if the text is not valid.
+    """
+    # Note that inet_aton() only accepts canonial form, but we still run through
+    # inet_ntoa() to ensure the output is a str.
+    return dns.ipv4.inet_ntoa(dns.ipv4.inet_aton(text))
diff --git a/.venv/lib/python3.12/site-packages/dns/ipv6.py b/.venv/lib/python3.12/site-packages/dns/ipv6.py
new file mode 100644
index 00000000..4dd1d1ca
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/dns/ipv6.py
@@ -0,0 +1,217 @@
+# Copyright (C) Dnspython Contributors, see LICENSE for text of ISC license
+
+# Copyright (C) 2003-2017 Nominum, Inc.
+#
+# Permission to use, copy, modify, and distribute this software and its
+# documentation for any purpose with or without fee is hereby granted,
+# provided that the above copyright notice and this permission notice
+# appear in all copies.
+#
+# THE SOFTWARE IS PROVIDED "AS IS" AND NOMINUM DISCLAIMS ALL WARRANTIES
+# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL NOMINUM BE LIABLE FOR
+# ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+# ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT
+# OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+
+"""IPv6 helper functions."""
+
+import binascii
+import re
+from typing import List, Union
+
+import dns.exception
+import dns.ipv4
+
+_leading_zero = re.compile(r"0+([0-9a-f]+)")
+
+
+def inet_ntoa(address: bytes) -> str:
+    """Convert an IPv6 address in binary form to text form.
+
+    *address*, a ``bytes``, the IPv6 address in binary form.
+
+    Raises ``ValueError`` if the address isn't 16 bytes long.
+    Returns a ``str``.
+    """
+
+    if len(address) != 16:
+        raise ValueError("IPv6 addresses are 16 bytes long")
+    hex = binascii.hexlify(address)
+    chunks = []
+    i = 0
+    l = len(hex)
+    while i < l:
+        chunk = hex[i : i + 4].decode()
+        # strip leading zeros.  we do this with an re instead of
+        # with lstrip() because lstrip() didn't support chars until
+        # python 2.2.2
+        m = _leading_zero.match(chunk)
+        if m is not None:
+            chunk = m.group(1)
+        chunks.append(chunk)
+        i += 4
+    #
+    # Compress the longest subsequence of 0-value chunks to ::
+    #
+    best_start = 0
+    best_len = 0
+    start = -1
+    last_was_zero = False
+    for i in range(8):
+        if chunks[i] != "0":
+            if last_was_zero:
+                end = i
+                current_len = end - start
+                if current_len > best_len:
+                    best_start = start
+                    best_len = current_len
+                last_was_zero = False
+        elif not last_was_zero:
+            start = i
+            last_was_zero = True
+    if last_was_zero:
+        end = 8
+        current_len = end - start
+        if current_len > best_len:
+            best_start = start
+            best_len = current_len
+    if best_len > 1:
+        if best_start == 0 and (best_len == 6 or best_len == 5 and chunks[5] == "ffff"):
+            # We have an embedded IPv4 address
+            if best_len == 6:
+                prefix = "::"
+            else:
+                prefix = "::ffff:"
+            thex = prefix + dns.ipv4.inet_ntoa(address[12:])
+        else:
+            thex = (
+                ":".join(chunks[:best_start])
+                + "::"
+                + ":".join(chunks[best_start + best_len :])
+            )
+    else:
+        thex = ":".join(chunks)
+    return thex
+
+
+_v4_ending = re.compile(rb"(.*):(\d+\.\d+\.\d+\.\d+)$")
+_colon_colon_start = re.compile(rb"::.*")
+_colon_colon_end = re.compile(rb".*::$")
+
+
+def inet_aton(text: Union[str, bytes], ignore_scope: bool = False) -> bytes:
+    """Convert an IPv6 address in text form to binary form.
+
+    *text*, a ``str`` or ``bytes``, the IPv6 address in textual form.
+
+    *ignore_scope*, a ``bool``.  If ``True``, a scope will be ignored.
+    If ``False``, the default, it is an error for a scope to be present.
+
+    Returns a ``bytes``.
+    """
+
+    #
+    # Our aim here is not something fast; we just want something that works.
+    #
+    if not isinstance(text, bytes):
+        btext = text.encode()
+    else:
+        btext = text
+
+    if ignore_scope:
+        parts = btext.split(b"%")
+        l = len(parts)
+        if l == 2:
+            btext = parts[0]
+        elif l > 2:
+            raise dns.exception.SyntaxError
+
+    if btext == b"":
+        raise dns.exception.SyntaxError
+    elif btext.endswith(b":") and not btext.endswith(b"::"):
+        raise dns.exception.SyntaxError
+    elif btext.startswith(b":") and not btext.startswith(b"::"):
+        raise dns.exception.SyntaxError
+    elif btext == b"::":
+        btext = b"0::"
+    #
+    # Get rid of the icky dot-quad syntax if we have it.
+    #
+    m = _v4_ending.match(btext)
+    if m is not None:
+        b = dns.ipv4.inet_aton(m.group(2))
+        btext = (
+            f"{m.group(1).decode()}:{b[0]:02x}{b[1]:02x}:{b[2]:02x}{b[3]:02x}"
+        ).encode()
+    #
+    # Try to turn '::<whatever>' into ':<whatever>'; if no match try to
+    # turn '<whatever>::' into '<whatever>:'
+    #
+    m = _colon_colon_start.match(btext)
+    if m is not None:
+        btext = btext[1:]
+    else:
+        m = _colon_colon_end.match(btext)
+        if m is not None:
+            btext = btext[:-1]
+    #
+    # Now canonicalize into 8 chunks of 4 hex digits each
+    #
+    chunks = btext.split(b":")
+    l = len(chunks)
+    if l > 8:
+        raise dns.exception.SyntaxError
+    seen_empty = False
+    canonical: List[bytes] = []
+    for c in chunks:
+        if c == b"":
+            if seen_empty:
+                raise dns.exception.SyntaxError
+            seen_empty = True
+            for _ in range(0, 8 - l + 1):
+                canonical.append(b"0000")
+        else:
+            lc = len(c)
+            if lc > 4:
+                raise dns.exception.SyntaxError
+            if lc != 4:
+                c = (b"0" * (4 - lc)) + c
+            canonical.append(c)
+    if l < 8 and not seen_empty:
+        raise dns.exception.SyntaxError
+    btext = b"".join(canonical)
+
+    #
+    # Finally we can go to binary.
+    #
+    try:
+        return binascii.unhexlify(btext)
+    except (binascii.Error, TypeError):
+        raise dns.exception.SyntaxError
+
+
+_mapped_prefix = b"\x00" * 10 + b"\xff\xff"
+
+
+def is_mapped(address: bytes) -> bool:
+    """Is the specified address a mapped IPv4 address?
+
+    *address*, a ``bytes`` is an IPv6 address in binary form.
+
+    Returns a ``bool``.
+    """
+
+    return address.startswith(_mapped_prefix)
+
+
+def canonicalize(text: Union[str, bytes]) -> str:
+    """Verify that *address* is a valid text form IPv6 address and return its
+    canonical text form.  Addresses with scopes are rejected.
+
+    *text*, a ``str`` or ``bytes``, the IPv6 address in textual form.
+
+    Raises ``dns.exception.SyntaxError`` if the text is not valid.
+    """
+    return dns.ipv6.inet_ntoa(dns.ipv6.inet_aton(text))
diff --git a/.venv/lib/python3.12/site-packages/dns/message.py b/.venv/lib/python3.12/site-packages/dns/message.py
new file mode 100644
index 00000000..e978a0a2
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/dns/message.py
@@ -0,0 +1,1933 @@
+# Copyright (C) Dnspython Contributors, see LICENSE for text of ISC license
+
+# Copyright (C) 2001-2017 Nominum, Inc.
+#
+# Permission to use, copy, modify, and distribute this software and its
+# documentation for any purpose with or without fee is hereby granted,
+# provided that the above copyright notice and this permission notice
+# appear in all copies.
+#
+# THE SOFTWARE IS PROVIDED "AS IS" AND NOMINUM DISCLAIMS ALL WARRANTIES
+# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL NOMINUM BE LIABLE FOR
+# ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+# ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT
+# OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+
+"""DNS Messages"""
+
+import contextlib
+import enum
+import io
+import time
+from typing import Any, Dict, List, Optional, Tuple, Union, cast
+
+import dns.edns
+import dns.entropy
+import dns.enum
+import dns.exception
+import dns.flags
+import dns.name
+import dns.opcode
+import dns.rcode
+import dns.rdata
+import dns.rdataclass
+import dns.rdatatype
+import dns.rdtypes.ANY.OPT
+import dns.rdtypes.ANY.TSIG
+import dns.renderer
+import dns.rrset
+import dns.tsig
+import dns.ttl
+import dns.wire
+
+
+class ShortHeader(dns.exception.FormError):
+    """The DNS packet passed to from_wire() is too short."""
+
+
+class TrailingJunk(dns.exception.FormError):
+    """The DNS packet passed to from_wire() has extra junk at the end of it."""
+
+
+class UnknownHeaderField(dns.exception.DNSException):
+    """The header field name was not recognized when converting from text
+    into a message."""
+
+
+class BadEDNS(dns.exception.FormError):
+    """An OPT record occurred somewhere other than
+    the additional data section."""
+
+
+class BadTSIG(dns.exception.FormError):
+    """A TSIG record occurred somewhere other than the end of
+    the additional data section."""
+
+
+class UnknownTSIGKey(dns.exception.DNSException):
+    """A TSIG with an unknown key was received."""
+
+
+class Truncated(dns.exception.DNSException):
+    """The truncated flag is set."""
+
+    supp_kwargs = {"message"}
+
+    # We do this as otherwise mypy complains about unexpected keyword argument
+    # idna_exception
+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+
+    def message(self):
+        """As much of the message as could be processed.
+
+        Returns a ``dns.message.Message``.
+        """
+        return self.kwargs["message"]
+
+
+class NotQueryResponse(dns.exception.DNSException):
+    """Message is not a response to a query."""
+
+
+class ChainTooLong(dns.exception.DNSException):
+    """The CNAME chain is too long."""
+
+
+class AnswerForNXDOMAIN(dns.exception.DNSException):
+    """The rcode is NXDOMAIN but an answer was found."""
+
+
+class NoPreviousName(dns.exception.SyntaxError):
+    """No previous name was known."""
+
+
+class MessageSection(dns.enum.IntEnum):
+    """Message sections"""
+
+    QUESTION = 0
+    ANSWER = 1
+    AUTHORITY = 2
+    ADDITIONAL = 3
+
+    @classmethod
+    def _maximum(cls):
+        return 3
+
+
+class MessageError:
+    def __init__(self, exception: Exception, offset: int):
+        self.exception = exception
+        self.offset = offset
+
+
+DEFAULT_EDNS_PAYLOAD = 1232
+MAX_CHAIN = 16
+
+IndexKeyType = Tuple[
+    int,
+    dns.name.Name,
+    dns.rdataclass.RdataClass,
+    dns.rdatatype.RdataType,
+    Optional[dns.rdatatype.RdataType],
+    Optional[dns.rdataclass.RdataClass],
+]
+IndexType = Dict[IndexKeyType, dns.rrset.RRset]
+SectionType = Union[int, str, List[dns.rrset.RRset]]
+
+
+class Message:
+    """A DNS message."""
+
+    _section_enum = MessageSection
+
+    def __init__(self, id: Optional[int] = None):
+        if id is None:
+            self.id = dns.entropy.random_16()
+        else:
+            self.id = id
+        self.flags = 0
+        self.sections: List[List[dns.rrset.RRset]] = [[], [], [], []]
+        self.opt: Optional[dns.rrset.RRset] = None
+        self.request_payload = 0
+        self.pad = 0
+        self.keyring: Any = None
+        self.tsig: Optional[dns.rrset.RRset] = None
+        self.request_mac = b""
+        self.xfr = False
+        self.origin: Optional[dns.name.Name] = None
+        self.tsig_ctx: Optional[Any] = None
+        self.index: IndexType = {}
+        self.errors: List[MessageError] = []
+        self.time = 0.0
+        self.wire: Optional[bytes] = None
+
+    @property
+    def question(self) -> List[dns.rrset.RRset]:
+        """The question section."""
+        return self.sections[0]
+
+    @question.setter
+    def question(self, v):
+        self.sections[0] = v
+
+    @property
+    def answer(self) -> List[dns.rrset.RRset]:
+        """The answer section."""
+        return self.sections[1]
+
+    @answer.setter
+    def answer(self, v):
+        self.sections[1] = v
+
+    @property
+    def authority(self) -> List[dns.rrset.RRset]:
+        """The authority section."""
+        return self.sections[2]
+
+    @authority.setter
+    def authority(self, v):
+        self.sections[2] = v
+
+    @property
+    def additional(self) -> List[dns.rrset.RRset]:
+        """The additional data section."""
+        return self.sections[3]
+
+    @additional.setter
+    def additional(self, v):
+        self.sections[3] = v
+
+    def __repr__(self):
+        return "<DNS message, ID " + repr(self.id) + ">"
+
+    def __str__(self):
+        return self.to_text()
+
+    def to_text(
+        self,
+        origin: Optional[dns.name.Name] = None,
+        relativize: bool = True,
+        **kw: Dict[str, Any],
+    ) -> str:
+        """Convert the message to text.
+
+        The *origin*, *relativize*, and any other keyword
+        arguments are passed to the RRset ``to_wire()`` method.
+
+        Returns a ``str``.
+        """
+
+        s = io.StringIO()
+        s.write("id %d\n" % self.id)
+        s.write(f"opcode {dns.opcode.to_text(self.opcode())}\n")
+        s.write(f"rcode {dns.rcode.to_text(self.rcode())}\n")
+        s.write(f"flags {dns.flags.to_text(self.flags)}\n")
+        if self.edns >= 0:
+            s.write(f"edns {self.edns}\n")
+            if self.ednsflags != 0:
+                s.write(f"eflags {dns.flags.edns_to_text(self.ednsflags)}\n")
+            s.write("payload %d\n" % self.payload)
+        for opt in self.options:
+            s.write(f"option {opt.to_text()}\n")
+        for name, which in self._section_enum.__members__.items():
+            s.write(f";{name}\n")
+            for rrset in self.section_from_number(which):
+                s.write(rrset.to_text(origin, relativize, **kw))
+                s.write("\n")
+        #
+        # We strip off the final \n so the caller can print the result without
+        # doing weird things to get around eccentricities in Python print
+        # formatting
+        #
+        return s.getvalue()[:-1]
+
+    def __eq__(self, other):
+        """Two messages are equal if they have the same content in the
+        header, question, answer, and authority sections.
+
+        Returns a ``bool``.
+        """
+
+        if not isinstance(other, Message):
+            return False
+        if self.id != other.id:
+            return False
+        if self.flags != other.flags:
+            return False
+        for i, section in enumerate(self.sections):
+            other_section = other.sections[i]
+            for n in section:
+                if n not in other_section:
+                    return False
+            for n in other_section:
+                if n not in section:
+                    return False
+        return True
+
+    def __ne__(self, other):
+        return not self.__eq__(other)
+
+    def is_response(self, other: "Message") -> bool:
+        """Is *other*, also a ``dns.message.Message``, a response to this
+        message?
+
+        Returns a ``bool``.
+        """
+
+        if (
+            other.flags & dns.flags.QR == 0
+            or self.id != other.id
+            or dns.opcode.from_flags(self.flags) != dns.opcode.from_flags(other.flags)
+        ):
+            return False
+        if other.rcode() in {
+            dns.rcode.FORMERR,
+            dns.rcode.SERVFAIL,
+            dns.rcode.NOTIMP,
+            dns.rcode.REFUSED,
+        }:
+            # We don't check the question section in these cases if
+            # the other question section is empty, even though they
+            # still really ought to have a question section.
+            if len(other.question) == 0:
+                return True
+        if dns.opcode.is_update(self.flags):
+            # This is assuming the "sender doesn't include anything
+            # from the update", but we don't care to check the other
+            # case, which is that all the sections are returned and
+            # identical.
+            return True
+        for n in self.question:
+            if n not in other.question:
+                return False
+        for n in other.question:
+            if n not in self.question:
+                return False
+        return True
+
+    def section_number(self, section: List[dns.rrset.RRset]) -> int:
+        """Return the "section number" of the specified section for use
+        in indexing.
+
+        *section* is one of the section attributes of this message.
+
+        Raises ``ValueError`` if the section isn't known.
+
+        Returns an ``int``.
+        """
+
+        for i, our_section in enumerate(self.sections):
+            if section is our_section:
+                return self._section_enum(i)
+        raise ValueError("unknown section")
+
+    def section_from_number(self, number: int) -> List[dns.rrset.RRset]:
+        """Return the section list associated with the specified section
+        number.
+
+        *number* is a section number `int` or the text form of a section
+        name.
+
+        Raises ``ValueError`` if the section isn't known.
+
+        Returns a ``list``.
+        """
+
+        section = self._section_enum.make(number)
+        return self.sections[section]
+
+    def find_rrset(
+        self,
+        section: SectionType,
+        name: dns.name.Name,
+        rdclass: dns.rdataclass.RdataClass,
+        rdtype: dns.rdatatype.RdataType,
+        covers: dns.rdatatype.RdataType = dns.rdatatype.NONE,
+        deleting: Optional[dns.rdataclass.RdataClass] = None,
+        create: bool = False,
+        force_unique: bool = False,
+        idna_codec: Optional[dns.name.IDNACodec] = None,
+    ) -> dns.rrset.RRset:
+        """Find the RRset with the given attributes in the specified section.
+
+        *section*, an ``int`` section number, a ``str`` section name, or one of
+        the section attributes of this message.  This specifies the
+        the section of the message to search.  For example::
+
+            my_message.find_rrset(my_message.answer, name, rdclass, rdtype)
+            my_message.find_rrset(dns.message.ANSWER, name, rdclass, rdtype)
+            my_message.find_rrset("ANSWER", name, rdclass, rdtype)
+
+        *name*, a ``dns.name.Name`` or ``str``, the name of the RRset.
+
+        *rdclass*, an ``int`` or ``str``, the class of the RRset.
+
+        *rdtype*, an ``int`` or ``str``, the type of the RRset.
+
+        *covers*, an ``int`` or ``str``, the covers value of the RRset.
+        The default is ``dns.rdatatype.NONE``.
+
+        *deleting*, an ``int``, ``str``, or ``None``, the deleting value of the
+        RRset.  The default is ``None``.
+
+        *create*, a ``bool``.  If ``True``, create the RRset if it is not found.
+        The created RRset is appended to *section*.
+
+        *force_unique*, a ``bool``.  If ``True`` and *create* is also ``True``,
+        create a new RRset regardless of whether a matching RRset exists
+        already.  The default is ``False``.  This is useful when creating
+        DDNS Update messages, as order matters for them.
+
+        *idna_codec*, a ``dns.name.IDNACodec``, specifies the IDNA
+        encoder/decoder.  If ``None``, the default IDNA 2003 encoder/decoder
+        is used.
+
+        Raises ``KeyError`` if the RRset was not found and create was
+        ``False``.
+
+        Returns a ``dns.rrset.RRset object``.
+        """
+
+        if isinstance(section, int):
+            section_number = section
+            section = self.section_from_number(section_number)
+        elif isinstance(section, str):
+            section_number = self._section_enum.from_text(section)
+            section = self.section_from_number(section_number)
+        else:
+            section_number = self.section_number(section)
+        if isinstance(name, str):
+            name = dns.name.from_text(name, idna_codec=idna_codec)
+        rdtype = dns.rdatatype.RdataType.make(rdtype)
+        rdclass = dns.rdataclass.RdataClass.make(rdclass)
+        covers = dns.rdatatype.RdataType.make(covers)
+        if deleting is not None:
+            deleting = dns.rdataclass.RdataClass.make(deleting)
+        key = (section_number, name, rdclass, rdtype, covers, deleting)
+        if not force_unique:
+            if self.index is not None:
+                rrset = self.index.get(key)
+                if rrset is not None:
+                    return rrset
+            else:
+                for rrset in section:
+                    if rrset.full_match(name, rdclass, rdtype, covers, deleting):
+                        return rrset
+        if not create:
+            raise KeyError
+        rrset = dns.rrset.RRset(name, rdclass, rdtype, covers, deleting)
+        section.append(rrset)
+        if self.index is not None:
+            self.index[key] = rrset
+        return rrset
+
+    def get_rrset(
+        self,
+        section: SectionType,
+        name: dns.name.Name,
+        rdclass: dns.rdataclass.RdataClass,
+        rdtype: dns.rdatatype.RdataType,
+        covers: dns.rdatatype.RdataType = dns.rdatatype.NONE,
+        deleting: Optional[dns.rdataclass.RdataClass] = None,
+        create: bool = False,
+        force_unique: bool = False,
+        idna_codec: Optional[dns.name.IDNACodec] = None,
+    ) -> Optional[dns.rrset.RRset]:
+        """Get the RRset with the given attributes in the specified section.
+
+        If the RRset is not found, None is returned.
+
+        *section*, an ``int`` section number, a ``str`` section name, or one of
+        the section attributes of this message.  This specifies the
+        the section of the message to search.  For example::
+
+            my_message.get_rrset(my_message.answer, name, rdclass, rdtype)
+            my_message.get_rrset(dns.message.ANSWER, name, rdclass, rdtype)
+            my_message.get_rrset("ANSWER", name, rdclass, rdtype)
+
+        *name*, a ``dns.name.Name`` or ``str``, the name of the RRset.
+
+        *rdclass*, an ``int`` or ``str``, the class of the RRset.
+
+        *rdtype*, an ``int`` or ``str``, the type of the RRset.
+
+        *covers*, an ``int`` or ``str``, the covers value of the RRset.
+        The default is ``dns.rdatatype.NONE``.
+
+        *deleting*, an ``int``, ``str``, or ``None``, the deleting value of the
+        RRset.  The default is ``None``.
+
+        *create*, a ``bool``.  If ``True``, create the RRset if it is not found.
+        The created RRset is appended to *section*.
+
+        *force_unique*, a ``bool``.  If ``True`` and *create* is also ``True``,
+        create a new RRset regardless of whether a matching RRset exists
+        already.  The default is ``False``.  This is useful when creating
+        DDNS Update messages, as order matters for them.
+
+        *idna_codec*, a ``dns.name.IDNACodec``, specifies the IDNA
+        encoder/decoder.  If ``None``, the default IDNA 2003 encoder/decoder
+        is used.
+
+        Returns a ``dns.rrset.RRset object`` or ``None``.
+        """
+
+        try:
+            rrset = self.find_rrset(
+                section,
+                name,
+                rdclass,
+                rdtype,
+                covers,
+                deleting,
+                create,
+                force_unique,
+                idna_codec,
+            )
+        except KeyError:
+            rrset = None
+        return rrset
+
+    def section_count(self, section: SectionType) -> int:
+        """Returns the number of records in the specified section.
+
+        *section*, an ``int`` section number, a ``str`` section name, or one of
+        the section attributes of this message.  This specifies the
+        the section of the message to count.  For example::
+
+            my_message.section_count(my_message.answer)
+            my_message.section_count(dns.message.ANSWER)
+            my_message.section_count("ANSWER")
+        """
+
+        if isinstance(section, int):
+            section_number = section
+            section = self.section_from_number(section_number)
+        elif isinstance(section, str):
+            section_number = self._section_enum.from_text(section)
+            section = self.section_from_number(section_number)
+        else:
+            section_number = self.section_number(section)
+        count = sum(max(1, len(rrs)) for rrs in section)
+        if section_number == MessageSection.ADDITIONAL:
+            if self.opt is not None:
+                count += 1
+            if self.tsig is not None:
+                count += 1
+        return count
+
+    def _compute_opt_reserve(self) -> int:
+        """Compute the size required for the OPT RR, padding excluded"""
+        if not self.opt:
+            return 0
+        # 1 byte for the root name, 10 for the standard RR fields
+        size = 11
+        # This would be more efficient if options had a size() method, but we won't
+        # worry about that for now.  We also don't worry if there is an existing padding
+        # option, as it is unlikely and probably harmless, as the worst case is that we
+        # may add another, and this seems to be legal.
+        for option in self.opt[0].options:
+            wire = option.to_wire()
+            # We add 4 here to account for the option type and length
+            size += len(wire) + 4
+        if self.pad:
+            # Padding will be added, so again add the option type and length.
+            size += 4
+        return size
+
+    def _compute_tsig_reserve(self) -> int:
+        """Compute the size required for the TSIG RR"""
+        # This would be more efficient if TSIGs had a size method, but we won't
+        # worry about for now.  Also, we can't really cope with the potential
+        # compressibility of the TSIG owner name, so we estimate with the uncompressed
+        # size.  We will disable compression when TSIG and padding are both is active
+        # so that the padding comes out right.
+        if not self.tsig:
+            return 0
+        f = io.BytesIO()
+        self.tsig.to_wire(f)
+        return len(f.getvalue())
+
+    def to_wire(
+        self,
+        origin: Optional[dns.name.Name] = None,
+        max_size: int = 0,
+        multi: bool = False,
+        tsig_ctx: Optional[Any] = None,
+        prepend_length: bool = False,
+        prefer_truncation: bool = False,
+        **kw: Dict[str, Any],
+    ) -> bytes:
+        """Return a string containing the message in DNS compressed wire
+        format.
+
+        Additional keyword arguments are passed to the RRset ``to_wire()``
+        method.
+
+        *origin*, a ``dns.name.Name`` or ``None``, the origin to be appended
+        to any relative names.  If ``None``, and the message has an origin
+        attribute that is not ``None``, then it will be used.
+
+        *max_size*, an ``int``, the maximum size of the wire format
+        output; default is 0, which means "the message's request
+        payload, if nonzero, or 65535".
+
+        *multi*, a ``bool``, should be set to ``True`` if this message is
+        part of a multiple message sequence.
+
+        *tsig_ctx*, a ``dns.tsig.HMACTSig`` or ``dns.tsig.GSSTSig`` object, the
+        ongoing TSIG context, used when signing zone transfers.
+
+        *prepend_length*, a ``bool``, should be set to ``True`` if the caller
+        wants the message length prepended to the message itself.  This is
+        useful for messages sent over TCP, TLS (DoT), or QUIC (DoQ).
+
+        *prefer_truncation*, a ``bool``, should be set to ``True`` if the caller
+        wants the message to be truncated if it would otherwise exceed the
+        maximum length.  If the truncation occurs before the additional section,
+        the TC bit will be set.
+
+        Raises ``dns.exception.TooBig`` if *max_size* was exceeded.
+
+        Returns a ``bytes``.
+        """
+
+        if origin is None and self.origin is not None:
+            origin = self.origin
+        if max_size == 0:
+            if self.request_payload != 0:
+                max_size = self.request_payload
+            else:
+                max_size = 65535
+        if max_size < 512:
+            max_size = 512
+        elif max_size > 65535:
+            max_size = 65535
+        r = dns.renderer.Renderer(self.id, self.flags, max_size, origin)
+        opt_reserve = self._compute_opt_reserve()
+        r.reserve(opt_reserve)
+        tsig_reserve = self._compute_tsig_reserve()
+        r.reserve(tsig_reserve)
+        try:
+            for rrset in self.question:
+                r.add_question(rrset.name, rrset.rdtype, rrset.rdclass)
+            for rrset in self.answer:
+                r.add_rrset(dns.renderer.ANSWER, rrset, **kw)
+            for rrset in self.authority:
+                r.add_rrset(dns.renderer.AUTHORITY, rrset, **kw)
+            for rrset in self.additional:
+                r.add_rrset(dns.renderer.ADDITIONAL, rrset, **kw)
+        except dns.exception.TooBig:
+            if prefer_truncation:
+                if r.section < dns.renderer.ADDITIONAL:
+                    r.flags |= dns.flags.TC
+            else:
+                raise
+        r.release_reserved()
+        if self.opt is not None:
+            r.add_opt(self.opt, self.pad, opt_reserve, tsig_reserve)
+        r.write_header()
+        if self.tsig is not None:
+            (new_tsig, ctx) = dns.tsig.sign(
+                r.get_wire(),
+                self.keyring,
+                self.tsig[0],
+                int(time.time()),
+                self.request_mac,
+                tsig_ctx,
+                multi,
+            )
+            self.tsig.clear()
+            self.tsig.add(new_tsig)
+            r.add_rrset(dns.renderer.ADDITIONAL, self.tsig)
+            r.write_header()
+            if multi:
+                self.tsig_ctx = ctx
+        wire = r.get_wire()
+        self.wire = wire
+        if prepend_length:
+            wire = len(wire).to_bytes(2, "big") + wire
+        return wire
+
+    @staticmethod
+    def _make_tsig(
+        keyname, algorithm, time_signed, fudge, mac, original_id, error, other
+    ):
+        tsig = dns.rdtypes.ANY.TSIG.TSIG(
+            dns.rdataclass.ANY,
+            dns.rdatatype.TSIG,
+            algorithm,
+            time_signed,
+            fudge,
+            mac,
+            original_id,
+            error,
+            other,
+        )
+        return dns.rrset.from_rdata(keyname, 0, tsig)
+
+    def use_tsig(
+        self,
+        keyring: Any,
+        keyname: Optional[Union[dns.name.Name, str]] = None,
+        fudge: int = 300,
+        original_id: Optional[int] = None,
+        tsig_error: int = 0,
+        other_data: bytes = b"",
+        algorithm: Union[dns.name.Name, str] = dns.tsig.default_algorithm,
+    ) -> None:
+        """When sending, a TSIG signature using the specified key
+        should be added.
+
+        *key*, a ``dns.tsig.Key`` is the key to use.  If a key is specified,
+        the *keyring* and *algorithm* fields are not used.
+
+        *keyring*, a ``dict``, ``callable`` or ``dns.tsig.Key``, is either
+        the TSIG keyring or key to use.
+
+        The format of a keyring dict is a mapping from TSIG key name, as
+        ``dns.name.Name`` to ``dns.tsig.Key`` or a TSIG secret, a ``bytes``.
+        If a ``dict`` *keyring* is specified but a *keyname* is not, the key
+        used will be the first key in the *keyring*.  Note that the order of
+        keys in a dictionary is not defined, so applications should supply a
+        keyname when a ``dict`` keyring is used, unless they know the keyring
+        contains only one key.  If a ``callable`` keyring is specified, the
+        callable will be called with the message and the keyname, and is
+        expected to return a key.
+
+        *keyname*, a ``dns.name.Name``, ``str`` or ``None``, the name of
+        this TSIG key to use; defaults to ``None``.  If *keyring* is a
+        ``dict``, the key must be defined in it.  If *keyring* is a
+        ``dns.tsig.Key``, this is ignored.
+
+        *fudge*, an ``int``, the TSIG time fudge.
+
+        *original_id*, an ``int``, the TSIG original id.  If ``None``,
+        the message's id is used.
+
+        *tsig_error*, an ``int``, the TSIG error code.
+
+        *other_data*, a ``bytes``, the TSIG other data.
+
+        *algorithm*, a ``dns.name.Name`` or ``str``, the TSIG algorithm to use.  This is
+        only used if *keyring* is a ``dict``, and the key entry is a ``bytes``.
+        """
+
+        if isinstance(keyring, dns.tsig.Key):
+            key = keyring
+            keyname = key.name
+        elif callable(keyring):
+            key = keyring(self, keyname)
+        else:
+            if isinstance(keyname, str):
+                keyname = dns.name.from_text(keyname)
+            if keyname is None:
+                keyname = next(iter(keyring))
+            key = keyring[keyname]
+            if isinstance(key, bytes):
+                key = dns.tsig.Key(keyname, key, algorithm)
+        self.keyring = key
+        if original_id is None:
+            original_id = self.id
+        self.tsig = self._make_tsig(
+            keyname,
+            self.keyring.algorithm,
+            0,
+            fudge,
+            b"\x00" * dns.tsig.mac_sizes[self.keyring.algorithm],
+            original_id,
+            tsig_error,
+            other_data,
+        )
+
+    @property
+    def keyname(self) -> Optional[dns.name.Name]:
+        if self.tsig:
+            return self.tsig.name
+        else:
+            return None
+
+    @property
+    def keyalgorithm(self) -> Optional[dns.name.Name]:
+        if self.tsig:
+            return self.tsig[0].algorithm
+        else:
+            return None
+
+    @property
+    def mac(self) -> Optional[bytes]:
+        if self.tsig:
+            return self.tsig[0].mac
+        else:
+            return None
+
+    @property
+    def tsig_error(self) -> Optional[int]:
+        if self.tsig:
+            return self.tsig[0].error
+        else:
+            return None
+
+    @property
+    def had_tsig(self) -> bool:
+        return bool(self.tsig)
+
+    @staticmethod
+    def _make_opt(flags=0, payload=DEFAULT_EDNS_PAYLOAD, options=None):
+        opt = dns.rdtypes.ANY.OPT.OPT(payload, dns.rdatatype.OPT, options or ())
+        return dns.rrset.from_rdata(dns.name.root, int(flags), opt)
+
+    def use_edns(
+        self,
+        edns: Optional[Union[int, bool]] = 0,
+        ednsflags: int = 0,
+        payload: int = DEFAULT_EDNS_PAYLOAD,
+        request_payload: Optional[int] = None,
+        options: Optional[List[dns.edns.Option]] = None,
+        pad: int = 0,
+    ) -> None:
+        """Configure EDNS behavior.
+
+        *edns*, an ``int``, is the EDNS level to use.  Specifying ``None``, ``False``,
+        or ``-1`` means "do not use EDNS", and in this case the other parameters are
+        ignored.  Specifying ``True`` is equivalent to specifying 0, i.e. "use EDNS0".
+
+        *ednsflags*, an ``int``, the EDNS flag values.
+
+        *payload*, an ``int``, is the EDNS sender's payload field, which is the maximum
+        size of UDP datagram the sender can handle.  I.e. how big a response to this
+        message can be.
+
+        *request_payload*, an ``int``, is the EDNS payload size to use when sending this
+        message.  If not specified, defaults to the value of *payload*.
+
+        *options*, a list of ``dns.edns.Option`` objects or ``None``, the EDNS options.
+
+        *pad*, a non-negative ``int``.  If 0, the default, do not pad; otherwise add
+        padding bytes to make the message size a multiple of *pad*.  Note that if
+        padding is non-zero, an EDNS PADDING option will always be added to the
+        message.
+        """
+
+        if edns is None or edns is False:
+            edns = -1
+        elif edns is True:
+            edns = 0
+        if edns < 0:
+            self.opt = None
+            self.request_payload = 0
+        else:
+            # make sure the EDNS version in ednsflags agrees with edns
+            ednsflags &= 0xFF00FFFF
+            ednsflags |= edns << 16
+            if options is None:
+                options = []
+            self.opt = self._make_opt(ednsflags, payload, options)
+            if request_payload is None:
+                request_payload = payload
+            self.request_payload = request_payload
+            if pad < 0:
+                raise ValueError("pad must be non-negative")
+            self.pad = pad
+
+    @property
+    def edns(self) -> int:
+        if self.opt:
+            return (self.ednsflags & 0xFF0000) >> 16
+        else:
+            return -1
+
+    @property
+    def ednsflags(self) -> int:
+        if self.opt:
+            return self.opt.ttl
+        else:
+            return 0
+
+    @ednsflags.setter
+    def ednsflags(self, v):
+        if self.opt:
+            self.opt.ttl = v
+        elif v:
+            self.opt = self._make_opt(v)
+
+    @property
+    def payload(self) -> int:
+        if self.opt:
+            return self.opt[0].payload
+        else:
+            return 0
+
+    @property
+    def options(self) -> Tuple:
+        if self.opt:
+            return self.opt[0].options
+        else:
+            return ()
+
+    def want_dnssec(self, wanted: bool = True) -> None:
+        """Enable or disable 'DNSSEC desired' flag in requests.
+
+        *wanted*, a ``bool``.  If ``True``, then DNSSEC data is
+        desired in the response, EDNS is enabled if required, and then
+        the DO bit is set.  If ``False``, the DO bit is cleared if
+        EDNS is enabled.
+        """
+
+        if wanted:
+            self.ednsflags |= dns.flags.DO
+        elif self.opt:
+            self.ednsflags &= ~int(dns.flags.DO)
+
+    def rcode(self) -> dns.rcode.Rcode:
+        """Return the rcode.
+
+        Returns a ``dns.rcode.Rcode``.
+        """
+        return dns.rcode.from_flags(int(self.flags), int(self.ednsflags))
+
+    def set_rcode(self, rcode: dns.rcode.Rcode) -> None:
+        """Set the rcode.
+
+        *rcode*, a ``dns.rcode.Rcode``, is the rcode to set.
+        """
+        (value, evalue) = dns.rcode.to_flags(rcode)
+        self.flags &= 0xFFF0
+        self.flags |= value
+        self.ednsflags &= 0x00FFFFFF
+        self.ednsflags |= evalue
+
+    def opcode(self) -> dns.opcode.Opcode:
+        """Return the opcode.
+
+        Returns a ``dns.opcode.Opcode``.
+        """
+        return dns.opcode.from_flags(int(self.flags))
+
+    def set_opcode(self, opcode: dns.opcode.Opcode) -> None:
+        """Set the opcode.
+
+        *opcode*, a ``dns.opcode.Opcode``, is the opcode to set.
+        """
+        self.flags &= 0x87FF
+        self.flags |= dns.opcode.to_flags(opcode)
+
+    def get_options(self, otype: dns.edns.OptionType) -> List[dns.edns.Option]:
+        """Return the list of options of the specified type."""
+        return [option for option in self.options if option.otype == otype]
+
+    def extended_errors(self) -> List[dns.edns.EDEOption]:
+        """Return the list of Extended DNS Error (EDE) options in the message"""
+        return cast(List[dns.edns.EDEOption], self.get_options(dns.edns.OptionType.EDE))
+
+    def _get_one_rr_per_rrset(self, value):
+        # What the caller picked is fine.
+        return value
+
+    # pylint: disable=unused-argument
+
+    def _parse_rr_header(self, section, name, rdclass, rdtype):
+        return (rdclass, rdtype, None, False)
+
+    # pylint: enable=unused-argument
+
+    def _parse_special_rr_header(self, section, count, position, name, rdclass, rdtype):
+        if rdtype == dns.rdatatype.OPT:
+            if (
+                section != MessageSection.ADDITIONAL
+                or self.opt
+                or name != dns.name.root
+            ):
+                raise BadEDNS
+        elif rdtype == dns.rdatatype.TSIG:
+            if (
+                section != MessageSection.ADDITIONAL
+                or rdclass != dns.rdatatype.ANY
+                or position != count - 1
+            ):
+                raise BadTSIG
+        return (rdclass, rdtype, None, False)
+
+
+class ChainingResult:
+    """The result of a call to dns.message.QueryMessage.resolve_chaining().
+
+    The ``answer`` attribute is the answer RRSet, or ``None`` if it doesn't
+    exist.
+
+    The ``canonical_name`` attribute is the canonical name after all
+    chaining has been applied (this is the same name as ``rrset.name`` in cases
+    where rrset is not ``None``).
+
+    The ``minimum_ttl`` attribute is the minimum TTL, i.e. the TTL to
+    use if caching the data.  It is the smallest of all the CNAME TTLs
+    and either the answer TTL if it exists or the SOA TTL and SOA
+    minimum values for negative answers.
+
+    The ``cnames`` attribute is a list of all the CNAME RRSets followed to
+    get to the canonical name.
+    """
+
+    def __init__(
+        self,
+        canonical_name: dns.name.Name,
+        answer: Optional[dns.rrset.RRset],
+        minimum_ttl: int,
+        cnames: List[dns.rrset.RRset],
+    ):
+        self.canonical_name = canonical_name
+        self.answer = answer
+        self.minimum_ttl = minimum_ttl
+        self.cnames = cnames
+
+
+class QueryMessage(Message):
+    def resolve_chaining(self) -> ChainingResult:
+        """Follow the CNAME chain in the response to determine the answer
+        RRset.
+
+        Raises ``dns.message.NotQueryResponse`` if the message is not
+        a response.
+
+        Raises ``dns.message.ChainTooLong`` if the CNAME chain is too long.
+
+        Raises ``dns.message.AnswerForNXDOMAIN`` if the rcode is NXDOMAIN
+        but an answer was found.
+
+        Raises ``dns.exception.FormError`` if the question count is not 1.
+
+        Returns a ChainingResult object.
+        """
+        if self.flags & dns.flags.QR == 0:
+            raise NotQueryResponse
+        if len(self.question) != 1:
+            raise dns.exception.FormError
+        question = self.question[0]
+        qname = question.name
+        min_ttl = dns.ttl.MAX_TTL
+        answer = None
+        count = 0
+        cnames = []
+        while count < MAX_CHAIN:
+            try:
+                answer = self.find_rrset(
+                    self.answer, qname, question.rdclass, question.rdtype
+                )
+                min_ttl = min(min_ttl, answer.ttl)
+                break
+            except KeyError:
+                if question.rdtype != dns.rdatatype.CNAME:
+                    try:
+                        crrset = self.find_rrset(
+                            self.answer, qname, question.rdclass, dns.rdatatype.CNAME
+                        )
+                        cnames.append(crrset)
+                        min_ttl = min(min_ttl, crrset.ttl)
+                        for rd in crrset:
+                            qname = rd.target
+                            break
+                        count += 1
+                        continue
+                    except KeyError:
+                        # Exit the chaining loop
+                        break
+                else:
+                    # Exit the chaining loop
+                    break
+        if count >= MAX_CHAIN:
+            raise ChainTooLong
+        if self.rcode() == dns.rcode.NXDOMAIN and answer is not None:
+            raise AnswerForNXDOMAIN
+        if answer is None:
+            # Further minimize the TTL with NCACHE.
+            auname = qname
+            while True:
+                # Look for an SOA RR whose owner name is a superdomain
+                # of qname.
+                try:
+                    srrset = self.find_rrset(
+                        self.authority, auname, question.rdclass, dns.rdatatype.SOA
+                    )
+                    min_ttl = min(min_ttl, srrset.ttl, srrset[0].minimum)
+                    break
+                except KeyError:
+                    try:
+                        auname = auname.parent()
+                    except dns.name.NoParent:
+                        break
+        return ChainingResult(qname, answer, min_ttl, cnames)
+
+    def canonical_name(self) -> dns.name.Name:
+        """Return the canonical name of the first name in the question
+        section.
+
+        Raises ``dns.message.NotQueryResponse`` if the message is not
+        a response.
+
+        Raises ``dns.message.ChainTooLong`` if the CNAME chain is too long.
+
+        Raises ``dns.message.AnswerForNXDOMAIN`` if the rcode is NXDOMAIN
+        but an answer was found.
+
+        Raises ``dns.exception.FormError`` if the question count is not 1.
+        """
+        return self.resolve_chaining().canonical_name
+
+
+def _maybe_import_update():
+    # We avoid circular imports by doing this here.  We do it in another
+    # function as doing it in _message_factory_from_opcode() makes "dns"
+    # a local symbol, and the first line fails :)
+
+    # pylint: disable=redefined-outer-name,import-outside-toplevel,unused-import
+    import dns.update  # noqa: F401
+
+
+def _message_factory_from_opcode(opcode):
+    if opcode == dns.opcode.QUERY:
+        return QueryMessage
+    elif opcode == dns.opcode.UPDATE:
+        _maybe_import_update()
+        return dns.update.UpdateMessage
+    else:
+        return Message
+
+
+class _WireReader:
+    """Wire format reader.
+
+    parser: the binary parser
+    message: The message object being built
+    initialize_message: Callback to set message parsing options
+    question_only: Are we only reading the question?
+    one_rr_per_rrset: Put each RR into its own RRset?
+    keyring: TSIG keyring
+    ignore_trailing: Ignore trailing junk at end of request?
+    multi: Is this message part of a multi-message sequence?
+    DNS dynamic updates.
+    continue_on_error: try to extract as much information as possible from
+    the message, accumulating MessageErrors in the *errors* attribute instead of
+    raising them.
+    """
+
+    def __init__(
+        self,
+        wire,
+        initialize_message,
+        question_only=False,
+        one_rr_per_rrset=False,
+        ignore_trailing=False,
+        keyring=None,
+        multi=False,
+        continue_on_error=False,
+    ):
+        self.parser = dns.wire.Parser(wire)
+        self.message = None
+        self.initialize_message = initialize_message
+        self.question_only = question_only
+        self.one_rr_per_rrset = one_rr_per_rrset
+        self.ignore_trailing = ignore_trailing
+        self.keyring = keyring
+        self.multi = multi
+        self.continue_on_error = continue_on_error
+        self.errors = []
+
+    def _get_question(self, section_number, qcount):
+        """Read the next *qcount* records from the wire data and add them to
+        the question section.
+        """
+        assert self.message is not None
+        section = self.message.sections[section_number]
+        for _ in range(qcount):
+            qname = self.parser.get_name(self.message.origin)
+            (rdtype, rdclass) = self.parser.get_struct("!HH")
+            (rdclass, rdtype, _, _) = self.message._parse_rr_header(
+                section_number, qname, rdclass, rdtype
+            )
+            self.message.find_rrset(
+                section, qname, rdclass, rdtype, create=True, force_unique=True
+            )
+
+    def _add_error(self, e):
+        self.errors.append(MessageError(e, self.parser.current))
+
+    def _get_section(self, section_number, count):
+        """Read the next I{count} records from the wire data and add them to
+        the specified section.
+
+        section_number: the section of the message to which to add records
+        count: the number of records to read
+        """
+        assert self.message is not None
+        section = self.message.sections[section_number]
+        force_unique = self.one_rr_per_rrset
+        for i in range(count):
+            rr_start = self.parser.current
+            absolute_name = self.parser.get_name()
+            if self.message.origin is not None:
+                name = absolute_name.relativize(self.message.origin)
+            else:
+                name = absolute_name
+            (rdtype, rdclass, ttl, rdlen) = self.parser.get_struct("!HHIH")
+            if rdtype in (dns.rdatatype.OPT, dns.rdatatype.TSIG):
+                (
+                    rdclass,
+                    rdtype,
+                    deleting,
+                    empty,
+                ) = self.message._parse_special_rr_header(
+                    section_number, count, i, name, rdclass, rdtype
+                )
+            else:
+                (rdclass, rdtype, deleting, empty) = self.message._parse_rr_header(
+                    section_number, name, rdclass, rdtype
+                )
+            rdata_start = self.parser.current
+            try:
+                if empty:
+                    if rdlen > 0:
+                        raise dns.exception.FormError
+                    rd = None
+                    covers = dns.rdatatype.NONE
+                else:
+                    with self.parser.restrict_to(rdlen):
+                        rd = dns.rdata.from_wire_parser(
+                            rdclass, rdtype, self.parser, self.message.origin
+                        )
+                    covers = rd.covers()
+                if self.message.xfr and rdtype == dns.rdatatype.SOA:
+                    force_unique = True
+                if rdtype == dns.rdatatype.OPT:
+                    self.message.opt = dns.rrset.from_rdata(name, ttl, rd)
+                elif rdtype == dns.rdatatype.TSIG:
+                    if self.keyring is None or self.keyring is True:
+                        raise UnknownTSIGKey("got signed message without keyring")
+                    elif isinstance(self.keyring, dict):
+                        key = self.keyring.get(absolute_name)
+                        if isinstance(key, bytes):
+                            key = dns.tsig.Key(absolute_name, key, rd.algorithm)
+                    elif callable(self.keyring):
+                        key = self.keyring(self.message, absolute_name)
+                    else:
+                        key = self.keyring
+                    if key is None:
+                        raise UnknownTSIGKey(f"key '{name}' unknown")
+                    if key:
+                        self.message.keyring = key
+                        self.message.tsig_ctx = dns.tsig.validate(
+                            self.parser.wire,
+                            key,
+                            absolute_name,
+                            rd,
+                            int(time.time()),
+                            self.message.request_mac,
+                            rr_start,
+                            self.message.tsig_ctx,
+                            self.multi,
+                        )
+                    self.message.tsig = dns.rrset.from_rdata(absolute_name, 0, rd)
+                else:
+                    rrset = self.message.find_rrset(
+                        section,
+                        name,
+                        rdclass,
+                        rdtype,
+                        covers,
+                        deleting,
+                        True,
+                        force_unique,
+                    )
+                    if rd is not None:
+                        if ttl > 0x7FFFFFFF:
+                            ttl = 0
+                        rrset.add(rd, ttl)
+            except Exception as e:
+                if self.continue_on_error:
+                    self._add_error(e)
+                    self.parser.seek(rdata_start + rdlen)
+                else:
+                    raise
+
+    def read(self):
+        """Read a wire format DNS message and build a dns.message.Message
+        object."""
+
+        if self.parser.remaining() < 12:
+            raise ShortHeader
+        (id, flags, qcount, ancount, aucount, adcount) = self.parser.get_struct(
+            "!HHHHHH"
+        )
+        factory = _message_factory_from_opcode(dns.opcode.from_flags(flags))
+        self.message = factory(id=id)
+        self.message.flags = dns.flags.Flag(flags)
+        self.message.wire = self.parser.wire
+        self.initialize_message(self.message)
+        self.one_rr_per_rrset = self.message._get_one_rr_per_rrset(
+            self.one_rr_per_rrset
+        )
+        try:
+            self._get_question(MessageSection.QUESTION, qcount)
+            if self.question_only:
+                return self.message
+            self._get_section(MessageSection.ANSWER, ancount)
+            self._get_section(MessageSection.AUTHORITY, aucount)
+            self._get_section(MessageSection.ADDITIONAL, adcount)
+            if not self.ignore_trailing and self.parser.remaining() != 0:
+                raise TrailingJunk
+            if self.multi and self.message.tsig_ctx and not self.message.had_tsig:
+                self.message.tsig_ctx.update(self.parser.wire)
+        except Exception as e:
+            if self.continue_on_error:
+                self._add_error(e)
+            else:
+                raise
+        return self.message
+
+
+def from_wire(
+    wire: bytes,
+    keyring: Optional[Any] = None,
+    request_mac: Optional[bytes] = b"",
+    xfr: bool = False,
+    origin: Optional[dns.name.Name] = None,
+    tsig_ctx: Optional[Union[dns.tsig.HMACTSig, dns.tsig.GSSTSig]] = None,
+    multi: bool = False,
+    question_only: bool = False,
+    one_rr_per_rrset: bool = False,
+    ignore_trailing: bool = False,
+    raise_on_truncation: bool = False,
+    continue_on_error: bool = False,
+) -> Message:
+    """Convert a DNS wire format message into a message object.
+
+    *keyring*, a ``dns.tsig.Key``, ``dict``, ``bool``, or ``None``, the key or keyring
+    to use if the message is signed.  If ``None`` or ``True``, then trying to decode
+    a message with a TSIG will fail as it cannot be validated.  If ``False``, then
+    TSIG validation is disabled.
+
+    *request_mac*, a ``bytes`` or ``None``.  If the message is a response to a
+    TSIG-signed request, *request_mac* should be set to the MAC of that request.
+
+    *xfr*, a ``bool``, should be set to ``True`` if this message is part of a zone
+    transfer.
+
+    *origin*, a ``dns.name.Name`` or ``None``.  If the message is part of a zone
+    transfer, *origin* should be the origin name of the zone.  If not ``None``, names
+    will be relativized to the origin.
+
+    *tsig_ctx*, a ``dns.tsig.HMACTSig`` or ``dns.tsig.GSSTSig`` object, the ongoing TSIG
+    context, used when validating zone transfers.
+
+    *multi*, a ``bool``, should be set to ``True`` if this message is part of a multiple
+    message sequence.
+
+    *question_only*, a ``bool``.  If ``True``, read only up to the end of the question
+    section.
+
+    *one_rr_per_rrset*, a ``bool``.  If ``True``, put each RR into its own RRset.
+
+    *ignore_trailing*, a ``bool``.  If ``True``, ignore trailing junk at end of the
+    message.
+
+    *raise_on_truncation*, a ``bool``.  If ``True``, raise an exception if the TC bit is
+    set.
+
+    *continue_on_error*, a ``bool``.  If ``True``, try to continue parsing even if
+    errors occur.  Erroneous rdata will be ignored.  Errors will be accumulated as a
+    list of MessageError objects in the message's ``errors`` attribute.  This option is
+    recommended only for DNS analysis tools, or for use in a server as part of an error
+    handling path.  The default is ``False``.
+
+    Raises ``dns.message.ShortHeader`` if the message is less than 12 octets long.
+
+    Raises ``dns.message.TrailingJunk`` if there were octets in the message past the end
+    of the proper DNS message, and *ignore_trailing* is ``False``.
+
+    Raises ``dns.message.BadEDNS`` if an OPT record was in the wrong section, or
+    occurred more than once.
+
+    Raises ``dns.message.BadTSIG`` if a TSIG record was not the last record of the
+    additional data section.
+
+    Raises ``dns.message.Truncated`` if the TC flag is set and *raise_on_truncation* is
+    ``True``.
+
+    Returns a ``dns.message.Message``.
+    """
+
+    # We permit None for request_mac solely for backwards compatibility
+    if request_mac is None:
+        request_mac = b""
+
+    def initialize_message(message):
+        message.request_mac = request_mac
+        message.xfr = xfr
+        message.origin = origin
+        message.tsig_ctx = tsig_ctx
+
+    reader = _WireReader(
+        wire,
+        initialize_message,
+        question_only,
+        one_rr_per_rrset,
+        ignore_trailing,
+        keyring,
+        multi,
+        continue_on_error,
+    )
+    try:
+        m = reader.read()
+    except dns.exception.FormError:
+        if (
+            reader.message
+            and (reader.message.flags & dns.flags.TC)
+            and raise_on_truncation
+        ):
+            raise Truncated(message=reader.message)
+        else:
+            raise
+    # Reading a truncated message might not have any errors, so we
+    # have to do this check here too.
+    if m.flags & dns.flags.TC and raise_on_truncation:
+        raise Truncated(message=m)
+    if continue_on_error:
+        m.errors = reader.errors
+
+    return m
+
+
+class _TextReader:
+    """Text format reader.
+
+    tok: the tokenizer.
+    message: The message object being built.
+    DNS dynamic updates.
+    last_name: The most recently read name when building a message object.
+    one_rr_per_rrset: Put each RR into its own RRset?
+    origin: The origin for relative names
+    relativize: relativize names?
+    relativize_to: the origin to relativize to.
+    """
+
+    def __init__(
+        self,
+        text,
+        idna_codec,
+        one_rr_per_rrset=False,
+        origin=None,
+        relativize=True,
+        relativize_to=None,
+    ):
+        self.message = None
+        self.tok = dns.tokenizer.Tokenizer(text, idna_codec=idna_codec)
+        self.last_name = None
+        self.one_rr_per_rrset = one_rr_per_rrset
+        self.origin = origin
+        self.relativize = relativize
+        self.relativize_to = relativize_to
+        self.id = None
+        self.edns = -1
+        self.ednsflags = 0
+        self.payload = DEFAULT_EDNS_PAYLOAD
+        self.rcode = None
+        self.opcode = dns.opcode.QUERY
+        self.flags = 0
+
+    def _header_line(self, _):
+        """Process one line from the text format header section."""
+
+        token = self.tok.get()
+        what = token.value
+        if what == "id":
+            self.id = self.tok.get_int()
+        elif what == "flags":
+            while True:
+                token = self.tok.get()
+                if not token.is_identifier():
+                    self.tok.unget(token)
+                    break
+                self.flags = self.flags | dns.flags.from_text(token.value)
+        elif what == "edns":
+            self.edns = self.tok.get_int()
+            self.ednsflags = self.ednsflags | (self.edns << 16)
+        elif what == "eflags":
+            if self.edns < 0:
+                self.edns = 0
+            while True:
+                token = self.tok.get()
+                if not token.is_identifier():
+                    self.tok.unget(token)
+                    break
+                self.ednsflags = self.ednsflags | dns.flags.edns_from_text(token.value)
+        elif what == "payload":
+            self.payload = self.tok.get_int()
+            if self.edns < 0:
+                self.edns = 0
+        elif what == "opcode":
+            text = self.tok.get_string()
+            self.opcode = dns.opcode.from_text(text)
+            self.flags = self.flags | dns.opcode.to_flags(self.opcode)
+        elif what == "rcode":
+            text = self.tok.get_string()
+            self.rcode = dns.rcode.from_text(text)
+        else:
+            raise UnknownHeaderField
+        self.tok.get_eol()
+
+    def _question_line(self, section_number):
+        """Process one line from the text format question section."""
+
+        section = self.message.sections[section_number]
+        token = self.tok.get(want_leading=True)
+        if not token.is_whitespace():
+            self.last_name = self.tok.as_name(
+                token, self.message.origin, self.relativize, self.relativize_to
+            )
+        name = self.last_name
+        if name is None:
+            raise NoPreviousName
+        token = self.tok.get()
+        if not token.is_identifier():
+            raise dns.exception.SyntaxError
+        # Class
+        try:
+            rdclass = dns.rdataclass.from_text(token.value)
+            token = self.tok.get()
+            if not token.is_identifier():
+                raise dns.exception.SyntaxError
+        except dns.exception.SyntaxError:
+            raise dns.exception.SyntaxError
+        except Exception:
+            rdclass = dns.rdataclass.IN
+        # Type
+        rdtype = dns.rdatatype.from_text(token.value)
+        (rdclass, rdtype, _, _) = self.message._parse_rr_header(
+            section_number, name, rdclass, rdtype
+        )
+        self.message.find_rrset(
+            section, name, rdclass, rdtype, create=True, force_unique=True
+        )
+        self.tok.get_eol()
+
+    def _rr_line(self, section_number):
+        """Process one line from the text format answer, authority, or
+        additional data sections.
+        """
+
+        section = self.message.sections[section_number]
+        # Name
+        token = self.tok.get(want_leading=True)
+        if not token.is_whitespace():
+            self.last_name = self.tok.as_name(
+                token, self.message.origin, self.relativize, self.relativize_to
+            )
+        name = self.last_name
+        if name is None:
+            raise NoPreviousName
+        token = self.tok.get()
+        if not token.is_identifier():
+            raise dns.exception.SyntaxError
+        # TTL
+        try:
+            ttl = int(token.value, 0)
+            token = self.tok.get()
+            if not token.is_identifier():
+                raise dns.exception.SyntaxError
+        except dns.exception.SyntaxError:
+            raise dns.exception.SyntaxError
+        except Exception:
+            ttl = 0
+        # Class
+        try:
+            rdclass = dns.rdataclass.from_text(token.value)
+            token = self.tok.get()
+            if not token.is_identifier():
+                raise dns.exception.SyntaxError
+        except dns.exception.SyntaxError:
+            raise dns.exception.SyntaxError
+        except Exception:
+            rdclass = dns.rdataclass.IN
+        # Type
+        rdtype = dns.rdatatype.from_text(token.value)
+        (rdclass, rdtype, deleting, empty) = self.message._parse_rr_header(
+            section_number, name, rdclass, rdtype
+        )
+        token = self.tok.get()
+        if empty and not token.is_eol_or_eof():
+            raise dns.exception.SyntaxError
+        if not empty and token.is_eol_or_eof():
+            raise dns.exception.UnexpectedEnd
+        if not token.is_eol_or_eof():
+            self.tok.unget(token)
+            rd = dns.rdata.from_text(
+                rdclass,
+                rdtype,
+                self.tok,
+                self.message.origin,
+                self.relativize,
+                self.relativize_to,
+            )
+            covers = rd.covers()
+        else:
+            rd = None
+            covers = dns.rdatatype.NONE
+        rrset = self.message.find_rrset(
+            section,
+            name,
+            rdclass,
+            rdtype,
+            covers,
+            deleting,
+            True,
+            self.one_rr_per_rrset,
+        )
+        if rd is not None:
+            rrset.add(rd, ttl)
+
+    def _make_message(self):
+        factory = _message_factory_from_opcode(self.opcode)
+        message = factory(id=self.id)
+        message.flags = self.flags
+        if self.edns >= 0:
+            message.use_edns(self.edns, self.ednsflags, self.payload)
+        if self.rcode:
+            message.set_rcode(self.rcode)
+        if self.origin:
+            message.origin = self.origin
+        return message
+
+    def read(self):
+        """Read a text format DNS message and build a dns.message.Message
+        object."""
+
+        line_method = self._header_line
+        section_number = None
+        while 1:
+            token = self.tok.get(True, True)
+            if token.is_eol_or_eof():
+                break
+            if token.is_comment():
+                u = token.value.upper()
+                if u == "HEADER":
+                    line_method = self._header_line
+
+                if self.message:
+                    message = self.message
+                else:
+                    # If we don't have a message, create one with the current
+                    # opcode, so that we know which section names to parse.
+                    message = self._make_message()
+                try:
+                    section_number = message._section_enum.from_text(u)
+                    # We found a section name.  If we don't have a message,
+                    # use the one we just created.
+                    if not self.message:
+                        self.message = message
+                        self.one_rr_per_rrset = message._get_one_rr_per_rrset(
+                            self.one_rr_per_rrset
+                        )
+                    if section_number == MessageSection.QUESTION:
+                        line_method = self._question_line
+                    else:
+                        line_method = self._rr_line
+                except Exception:
+                    # It's just a comment.
+                    pass
+                self.tok.get_eol()
+                continue
+            self.tok.unget(token)
+            line_method(section_number)
+        if not self.message:
+            self.message = self._make_message()
+        return self.message
+
+
+def from_text(
+    text: str,
+    idna_codec: Optional[dns.name.IDNACodec] = None,
+    one_rr_per_rrset: bool = False,
+    origin: Optional[dns.name.Name] = None,
+    relativize: bool = True,
+    relativize_to: Optional[dns.name.Name] = None,
+) -> Message:
+    """Convert the text format message into a message object.
+
+    The reader stops after reading the first blank line in the input to
+    facilitate reading multiple messages from a single file with
+    ``dns.message.from_file()``.
+
+    *text*, a ``str``, the text format message.
+
+    *idna_codec*, a ``dns.name.IDNACodec``, specifies the IDNA
+    encoder/decoder.  If ``None``, the default IDNA 2003 encoder/decoder
+    is used.
+
+    *one_rr_per_rrset*, a ``bool``.  If ``True``, then each RR is put
+    into its own rrset.  The default is ``False``.
+
+    *origin*, a ``dns.name.Name`` (or ``None``), the
+    origin to use for relative names.
+
+    *relativize*, a ``bool``.  If true, name will be relativized.
+
+    *relativize_to*, a ``dns.name.Name`` (or ``None``), the origin to use
+    when relativizing names.  If not set, the *origin* value will be used.
+
+    Raises ``dns.message.UnknownHeaderField`` if a header is unknown.
+
+    Raises ``dns.exception.SyntaxError`` if the text is badly formed.
+
+    Returns a ``dns.message.Message object``
+    """
+
+    # 'text' can also be a file, but we don't publish that fact
+    # since it's an implementation detail.  The official file
+    # interface is from_file().
+
+    reader = _TextReader(
+        text, idna_codec, one_rr_per_rrset, origin, relativize, relativize_to
+    )
+    return reader.read()
+
+
+def from_file(
+    f: Any,
+    idna_codec: Optional[dns.name.IDNACodec] = None,
+    one_rr_per_rrset: bool = False,
+) -> Message:
+    """Read the next text format message from the specified file.
+
+    Message blocks are separated by a single blank line.
+
+    *f*, a ``file`` or ``str``.  If *f* is text, it is treated as the
+    pathname of a file to open.
+
+    *idna_codec*, a ``dns.name.IDNACodec``, specifies the IDNA
+    encoder/decoder.  If ``None``, the default IDNA 2003 encoder/decoder
+    is used.
+
+    *one_rr_per_rrset*, a ``bool``.  If ``True``, then each RR is put
+    into its own rrset.  The default is ``False``.
+
+    Raises ``dns.message.UnknownHeaderField`` if a header is unknown.
+
+    Raises ``dns.exception.SyntaxError`` if the text is badly formed.
+
+    Returns a ``dns.message.Message object``
+    """
+
+    if isinstance(f, str):
+        cm: contextlib.AbstractContextManager = open(f)
+    else:
+        cm = contextlib.nullcontext(f)
+    with cm as f:
+        return from_text(f, idna_codec, one_rr_per_rrset)
+    assert False  # for mypy  lgtm[py/unreachable-statement]
+
+
+def make_query(
+    qname: Union[dns.name.Name, str],
+    rdtype: Union[dns.rdatatype.RdataType, str],
+    rdclass: Union[dns.rdataclass.RdataClass, str] = dns.rdataclass.IN,
+    use_edns: Optional[Union[int, bool]] = None,
+    want_dnssec: bool = False,
+    ednsflags: Optional[int] = None,
+    payload: Optional[int] = None,
+    request_payload: Optional[int] = None,
+    options: Optional[List[dns.edns.Option]] = None,
+    idna_codec: Optional[dns.name.IDNACodec] = None,
+    id: Optional[int] = None,
+    flags: int = dns.flags.RD,
+    pad: int = 0,
+) -> QueryMessage:
+    """Make a query message.
+
+    The query name, type, and class may all be specified either
+    as objects of the appropriate type, or as strings.
+
+    The query will have a randomly chosen query id, and its DNS flags
+    will be set to dns.flags.RD.
+
+    qname, a ``dns.name.Name`` or ``str``, the query name.
+
+    *rdtype*, an ``int`` or ``str``, the desired rdata type.
+
+    *rdclass*, an ``int`` or ``str``,  the desired rdata class; the default
+    is class IN.
+
+    *use_edns*, an ``int``, ``bool`` or ``None``.  The EDNS level to use; the
+    default is ``None``.  If ``None``, EDNS will be enabled only if other
+    parameters (*ednsflags*, *payload*, *request_payload*, or *options*) are
+    set.
+    See the description of dns.message.Message.use_edns() for the possible
+    values for use_edns and their meanings.
+
+    *want_dnssec*, a ``bool``.  If ``True``, DNSSEC data is desired.
+
+    *ednsflags*, an ``int``, the EDNS flag values.
+
+    *payload*, an ``int``, is the EDNS sender's payload field, which is the
+    maximum size of UDP datagram the sender can handle.  I.e. how big
+    a response to this message can be.
+
+    *request_payload*, an ``int``, is the EDNS payload size to use when
+    sending this message.  If not specified, defaults to the value of
+    *payload*.
+
+    *options*, a list of ``dns.edns.Option`` objects or ``None``, the EDNS
+    options.
+
+    *idna_codec*, a ``dns.name.IDNACodec``, specifies the IDNA
+    encoder/decoder.  If ``None``, the default IDNA 2003 encoder/decoder
+    is used.
+
+    *id*, an ``int`` or ``None``, the desired query id.  The default is
+    ``None``, which generates a random query id.
+
+    *flags*, an ``int``, the desired query flags.  The default is
+    ``dns.flags.RD``.
+
+    *pad*, a non-negative ``int``.  If 0, the default, do not pad; otherwise add
+    padding bytes to make the message size a multiple of *pad*.  Note that if
+    padding is non-zero, an EDNS PADDING option will always be added to the
+    message.
+
+    Returns a ``dns.message.QueryMessage``
+    """
+
+    if isinstance(qname, str):
+        qname = dns.name.from_text(qname, idna_codec=idna_codec)
+    rdtype = dns.rdatatype.RdataType.make(rdtype)
+    rdclass = dns.rdataclass.RdataClass.make(rdclass)
+    m = QueryMessage(id=id)
+    m.flags = dns.flags.Flag(flags)
+    m.find_rrset(m.question, qname, rdclass, rdtype, create=True, force_unique=True)
+    # only pass keywords on to use_edns if they have been set to a
+    # non-None value.  Setting a field will turn EDNS on if it hasn't
+    # been configured.
+    kwargs: Dict[str, Any] = {}
+    if ednsflags is not None:
+        kwargs["ednsflags"] = ednsflags
+    if payload is not None:
+        kwargs["payload"] = payload
+    if request_payload is not None:
+        kwargs["request_payload"] = request_payload
+    if options is not None:
+        kwargs["options"] = options
+    if kwargs and use_edns is None:
+        use_edns = 0
+    kwargs["edns"] = use_edns
+    kwargs["pad"] = pad
+    m.use_edns(**kwargs)
+    m.want_dnssec(want_dnssec)
+    return m
+
+
+class CopyMode(enum.Enum):
+    """
+    How should sections be copied when making an update response?
+    """
+
+    NOTHING = 0
+    QUESTION = 1
+    EVERYTHING = 2
+
+
+def make_response(
+    query: Message,
+    recursion_available: bool = False,
+    our_payload: int = 8192,
+    fudge: int = 300,
+    tsig_error: int = 0,
+    pad: Optional[int] = None,
+    copy_mode: Optional[CopyMode] = None,
+) -> Message:
+    """Make a message which is a response for the specified query.
+    The message returned is really a response skeleton; it has all of the infrastructure
+    required of a response, but none of the content.
+
+    Response section(s) which are copied are shallow copies of the matching section(s)
+    in the query, so the query's RRsets should not be changed.
+
+    *query*, a ``dns.message.Message``, the query to respond to.
+
+    *recursion_available*, a ``bool``, should RA be set in the response?
+
+    *our_payload*, an ``int``, the payload size to advertise in EDNS responses.
+
+    *fudge*, an ``int``, the TSIG time fudge.
+
+    *tsig_error*, an ``int``, the TSIG error.
+
+    *pad*, a non-negative ``int`` or ``None``.  If 0, the default, do not pad; otherwise
+    if not ``None`` add padding bytes to make the message size a multiple of *pad*. Note
+    that if padding is non-zero, an EDNS PADDING option will always be added to the
+    message.  If ``None``, add padding following RFC 8467, namely if the request is
+    padded, pad the response to 468 otherwise do not pad.
+
+    *copy_mode*, a ``dns.message.CopyMode`` or ``None``, determines how sections are
+    copied.  The default, ``None`` copies sections according to the default for the
+    message's opcode, which is currently ``dns.message.CopyMode.QUESTION`` for all
+    opcodes.   ``dns.message.CopyMode.QUESTION`` copies only the question section.
+    ``dns.message.CopyMode.EVERYTHING`` copies all sections other than OPT or TSIG
+    records, which are created appropriately if needed. ``dns.message.CopyMode.NOTHING``
+    copies no sections; note that this mode is for server testing purposes and is
+    otherwise not recommended for use.  In particular, ``dns.message.is_response()``
+    will be ``False`` if you create a response this way and the rcode is not
+    ``FORMERR``, ``SERVFAIL``, ``NOTIMP``, or ``REFUSED``.
+
+    Returns a ``dns.message.Message`` object whose specific class is appropriate for the
+    query.  For example, if query is a ``dns.update.UpdateMessage``, the response will
+    be one too.
+    """
+
+    if query.flags & dns.flags.QR:
+        raise dns.exception.FormError("specified query message is not a query")
+    opcode = query.opcode()
+    factory = _message_factory_from_opcode(opcode)
+    response = factory(id=query.id)
+    response.flags = dns.flags.QR | (query.flags & dns.flags.RD)
+    if recursion_available:
+        response.flags |= dns.flags.RA
+    response.set_opcode(opcode)
+    if copy_mode is None:
+        copy_mode = CopyMode.QUESTION
+    if copy_mode != CopyMode.NOTHING:
+        response.question = list(query.question)
+    if copy_mode == CopyMode.EVERYTHING:
+        response.answer = list(query.answer)
+        response.authority = list(query.authority)
+        response.additional = list(query.additional)
+    if query.edns >= 0:
+        if pad is None:
+            # Set response padding per RFC 8467
+            pad = 0
+            for option in query.options:
+                if option.otype == dns.edns.OptionType.PADDING:
+                    pad = 468
+        response.use_edns(0, 0, our_payload, query.payload, pad=pad)
+    if query.had_tsig:
+        response.use_tsig(
+            query.keyring,
+            query.keyname,
+            fudge,
+            None,
+            tsig_error,
+            b"",
+            query.keyalgorithm,
+        )
+        response.request_mac = query.mac
+    return response
+
+
+### BEGIN generated MessageSection constants
+
+QUESTION = MessageSection.QUESTION
+ANSWER = MessageSection.ANSWER
+AUTHORITY = MessageSection.AUTHORITY
+ADDITIONAL = MessageSection.ADDITIONAL
+
+### END generated MessageSection constants
diff --git a/.venv/lib/python3.12/site-packages/dns/name.py b/.venv/lib/python3.12/site-packages/dns/name.py
new file mode 100644
index 00000000..f79f0d0f
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/dns/name.py
@@ -0,0 +1,1284 @@
+# Copyright (C) Dnspython Contributors, see LICENSE for text of ISC license
+
+# Copyright (C) 2001-2017 Nominum, Inc.
+#
+# Permission to use, copy, modify, and distribute this software and its
+# documentation for any purpose with or without fee is hereby granted,
+# provided that the above copyright notice and this permission notice
+# appear in all copies.
+#
+# THE SOFTWARE IS PROVIDED "AS IS" AND NOMINUM DISCLAIMS ALL WARRANTIES
+# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL NOMINUM BE LIABLE FOR
+# ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+# ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT
+# OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+
+"""DNS Names.
+"""
+
+import copy
+import encodings.idna  # type: ignore
+import functools
+import struct
+from typing import Any, Callable, Dict, Iterable, Optional, Tuple, Union
+
+import dns._features
+import dns.enum
+import dns.exception
+import dns.immutable
+import dns.wire
+
+if dns._features.have("idna"):
+    import idna  # type: ignore
+
+    have_idna_2008 = True
+else:  # pragma: no cover
+    have_idna_2008 = False
+
+CompressType = Dict["Name", int]
+
+
+class NameRelation(dns.enum.IntEnum):
+    """Name relation result from fullcompare()."""
+
+    # This is an IntEnum for backwards compatibility in case anyone
+    # has hardwired the constants.
+
+    #: The compared names have no relationship to each other.
+    NONE = 0
+    #: the first name is a superdomain of the second.
+    SUPERDOMAIN = 1
+    #: The first name is a subdomain of the second.
+    SUBDOMAIN = 2
+    #: The compared names are equal.
+    EQUAL = 3
+    #: The compared names have a common ancestor.
+    COMMONANCESTOR = 4
+
+    @classmethod
+    def _maximum(cls):
+        return cls.COMMONANCESTOR  # pragma: no cover
+
+    @classmethod
+    def _short_name(cls):
+        return cls.__name__  # pragma: no cover
+
+
+# Backwards compatibility
+NAMERELN_NONE = NameRelation.NONE
+NAMERELN_SUPERDOMAIN = NameRelation.SUPERDOMAIN
+NAMERELN_SUBDOMAIN = NameRelation.SUBDOMAIN
+NAMERELN_EQUAL = NameRelation.EQUAL
+NAMERELN_COMMONANCESTOR = NameRelation.COMMONANCESTOR
+
+
+class EmptyLabel(dns.exception.SyntaxError):
+    """A DNS label is empty."""
+
+
+class BadEscape(dns.exception.SyntaxError):
+    """An escaped code in a text format of DNS name is invalid."""
+
+
+class BadPointer(dns.exception.FormError):
+    """A DNS compression pointer points forward instead of backward."""
+
+
+class BadLabelType(dns.exception.FormError):
+    """The label type in DNS name wire format is unknown."""
+
+
+class NeedAbsoluteNameOrOrigin(dns.exception.DNSException):
+    """An attempt was made to convert a non-absolute name to
+    wire when there was also a non-absolute (or missing) origin."""
+
+
+class NameTooLong(dns.exception.FormError):
+    """A DNS name is > 255 octets long."""
+
+
+class LabelTooLong(dns.exception.SyntaxError):
+    """A DNS label is > 63 octets long."""
+
+
+class AbsoluteConcatenation(dns.exception.DNSException):
+    """An attempt was made to append anything other than the
+    empty name to an absolute DNS name."""
+
+
+class NoParent(dns.exception.DNSException):
+    """An attempt was made to get the parent of the root name
+    or the empty name."""
+
+
+class NoIDNA2008(dns.exception.DNSException):
+    """IDNA 2008 processing was requested but the idna module is not
+    available."""
+
+
+class IDNAException(dns.exception.DNSException):
+    """IDNA processing raised an exception."""
+
+    supp_kwargs = {"idna_exception"}
+    fmt = "IDNA processing exception: {idna_exception}"
+
+    # We do this as otherwise mypy complains about unexpected keyword argument
+    # idna_exception
+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+
+
+class NeedSubdomainOfOrigin(dns.exception.DNSException):
+    """An absolute name was provided that is not a subdomain of the specified origin."""
+
+
+_escaped = b'"().;\\@$'
+_escaped_text = '"().;\\@$'
+
+
+def _escapify(label: Union[bytes, str]) -> str:
+    """Escape the characters in label which need it.
+    @returns: the escaped string
+    @rtype: string"""
+    if isinstance(label, bytes):
+        # Ordinary DNS label mode.  Escape special characters and values
+        # < 0x20 or > 0x7f.
+        text = ""
+        for c in label:
+            if c in _escaped:
+                text += "\\" + chr(c)
+            elif c > 0x20 and c < 0x7F:
+                text += chr(c)
+            else:
+                text += "\\%03d" % c
+        return text
+
+    # Unicode label mode.  Escape only special characters and values < 0x20
+    text = ""
+    for uc in label:
+        if uc in _escaped_text:
+            text += "\\" + uc
+        elif uc <= "\x20":
+            text += "\\%03d" % ord(uc)
+        else:
+            text += uc
+    return text
+
+
+class IDNACodec:
+    """Abstract base class for IDNA encoder/decoders."""
+
+    def __init__(self):
+        pass
+
+    def is_idna(self, label: bytes) -> bool:
+        return label.lower().startswith(b"xn--")
+
+    def encode(self, label: str) -> bytes:
+        raise NotImplementedError  # pragma: no cover
+
+    def decode(self, label: bytes) -> str:
+        # We do not apply any IDNA policy on decode.
+        if self.is_idna(label):
+            try:
+                slabel = label[4:].decode("punycode")
+                return _escapify(slabel)
+            except Exception as e:
+                raise IDNAException(idna_exception=e)
+        else:
+            return _escapify(label)
+
+
+class IDNA2003Codec(IDNACodec):
+    """IDNA 2003 encoder/decoder."""
+
+    def __init__(self, strict_decode: bool = False):
+        """Initialize the IDNA 2003 encoder/decoder.
+
+        *strict_decode* is a ``bool``. If `True`, then IDNA2003 checking
+        is done when decoding.  This can cause failures if the name
+        was encoded with IDNA2008.  The default is `False`.
+        """
+
+        super().__init__()
+        self.strict_decode = strict_decode
+
+    def encode(self, label: str) -> bytes:
+        """Encode *label*."""
+
+        if label == "":
+            return b""
+        try:
+            return encodings.idna.ToASCII(label)
+        except UnicodeError:
+            raise LabelTooLong
+
+    def decode(self, label: bytes) -> str:
+        """Decode *label*."""
+        if not self.strict_decode:
+            return super().decode(label)
+        if label == b"":
+            return ""
+        try:
+            return _escapify(encodings.idna.ToUnicode(label))
+        except Exception as e:
+            raise IDNAException(idna_exception=e)
+
+
+class IDNA2008Codec(IDNACodec):
+    """IDNA 2008 encoder/decoder."""
+
+    def __init__(
+        self,
+        uts_46: bool = False,
+        transitional: bool = False,
+        allow_pure_ascii: bool = False,
+        strict_decode: bool = False,
+    ):
+        """Initialize the IDNA 2008 encoder/decoder.
+
+        *uts_46* is a ``bool``.  If True, apply Unicode IDNA
+        compatibility processing as described in Unicode Technical
+        Standard #46 (https://unicode.org/reports/tr46/).
+        If False, do not apply the mapping.  The default is False.
+
+        *transitional* is a ``bool``: If True, use the
+        "transitional" mode described in Unicode Technical Standard
+        #46.  The default is False.
+
+        *allow_pure_ascii* is a ``bool``.  If True, then a label which
+        consists of only ASCII characters is allowed.  This is less
+        strict than regular IDNA 2008, but is also necessary for mixed
+        names, e.g. a name with starting with "_sip._tcp." and ending
+        in an IDN suffix which would otherwise be disallowed.  The
+        default is False.
+
+        *strict_decode* is a ``bool``: If True, then IDNA2008 checking
+        is done when decoding.  This can cause failures if the name
+        was encoded with IDNA2003.  The default is False.
+        """
+        super().__init__()
+        self.uts_46 = uts_46
+        self.transitional = transitional
+        self.allow_pure_ascii = allow_pure_ascii
+        self.strict_decode = strict_decode
+
+    def encode(self, label: str) -> bytes:
+        if label == "":
+            return b""
+        if self.allow_pure_ascii and is_all_ascii(label):
+            encoded = label.encode("ascii")
+            if len(encoded) > 63:
+                raise LabelTooLong
+            return encoded
+        if not have_idna_2008:
+            raise NoIDNA2008
+        try:
+            if self.uts_46:
+                # pylint: disable=possibly-used-before-assignment
+                label = idna.uts46_remap(label, False, self.transitional)
+            return idna.alabel(label)
+        except idna.IDNAError as e:
+            if e.args[0] == "Label too long":
+                raise LabelTooLong
+            else:
+                raise IDNAException(idna_exception=e)
+
+    def decode(self, label: bytes) -> str:
+        if not self.strict_decode:
+            return super().decode(label)
+        if label == b"":
+            return ""
+        if not have_idna_2008:
+            raise NoIDNA2008
+        try:
+            ulabel = idna.ulabel(label)
+            if self.uts_46:
+                ulabel = idna.uts46_remap(ulabel, False, self.transitional)
+            return _escapify(ulabel)
+        except (idna.IDNAError, UnicodeError) as e:
+            raise IDNAException(idna_exception=e)
+
+
+IDNA_2003_Practical = IDNA2003Codec(False)
+IDNA_2003_Strict = IDNA2003Codec(True)
+IDNA_2003 = IDNA_2003_Practical
+IDNA_2008_Practical = IDNA2008Codec(True, False, True, False)
+IDNA_2008_UTS_46 = IDNA2008Codec(True, False, False, False)
+IDNA_2008_Strict = IDNA2008Codec(False, False, False, True)
+IDNA_2008_Transitional = IDNA2008Codec(True, True, False, False)
+IDNA_2008 = IDNA_2008_Practical
+
+
+def _validate_labels(labels: Tuple[bytes, ...]) -> None:
+    """Check for empty labels in the middle of a label sequence,
+    labels that are too long, and for too many labels.
+
+    Raises ``dns.name.NameTooLong`` if the name as a whole is too long.
+
+    Raises ``dns.name.EmptyLabel`` if a label is empty (i.e. the root
+    label) and appears in a position other than the end of the label
+    sequence
+
+    """
+
+    l = len(labels)
+    total = 0
+    i = -1
+    j = 0
+    for label in labels:
+        ll = len(label)
+        total += ll + 1
+        if ll > 63:
+            raise LabelTooLong
+        if i < 0 and label == b"":
+            i = j
+        j += 1
+    if total > 255:
+        raise NameTooLong
+    if i >= 0 and i != l - 1:
+        raise EmptyLabel
+
+
+def _maybe_convert_to_binary(label: Union[bytes, str]) -> bytes:
+    """If label is ``str``, convert it to ``bytes``.  If it is already
+    ``bytes`` just return it.
+
+    """
+
+    if isinstance(label, bytes):
+        return label
+    if isinstance(label, str):
+        return label.encode()
+    raise ValueError  # pragma: no cover
+
+
+@dns.immutable.immutable
+class Name:
+    """A DNS name.
+
+    The dns.name.Name class represents a DNS name as a tuple of
+    labels.  Each label is a ``bytes`` in DNS wire format.  Instances
+    of the class are immutable.
+    """
+
+    __slots__ = ["labels"]
+
+    def __init__(self, labels: Iterable[Union[bytes, str]]):
+        """*labels* is any iterable whose values are ``str`` or ``bytes``."""
+
+        blabels = [_maybe_convert_to_binary(x) for x in labels]
+        self.labels = tuple(blabels)
+        _validate_labels(self.labels)
+
+    def __copy__(self):
+        return Name(self.labels)
+
+    def __deepcopy__(self, memo):
+        return Name(copy.deepcopy(self.labels, memo))
+
+    def __getstate__(self):
+        # Names can be pickled
+        return {"labels": self.labels}
+
+    def __setstate__(self, state):
+        super().__setattr__("labels", state["labels"])
+        _validate_labels(self.labels)
+
+    def is_absolute(self) -> bool:
+        """Is the most significant label of this name the root label?
+
+        Returns a ``bool``.
+        """
+
+        return len(self.labels) > 0 and self.labels[-1] == b""
+
+    def is_wild(self) -> bool:
+        """Is this name wild?  (I.e. Is the least significant label '*'?)
+
+        Returns a ``bool``.
+        """
+
+        return len(self.labels) > 0 and self.labels[0] == b"*"
+
+    def __hash__(self) -> int:
+        """Return a case-insensitive hash of the name.
+
+        Returns an ``int``.
+        """
+
+        h = 0
+        for label in self.labels:
+            for c in label.lower():
+                h += (h << 3) + c
+        return h
+
+    def fullcompare(self, other: "Name") -> Tuple[NameRelation, int, int]:
+        """Compare two names, returning a 3-tuple
+        ``(relation, order, nlabels)``.
+
+        *relation* describes the relation ship between the names,
+        and is one of: ``dns.name.NameRelation.NONE``,
+        ``dns.name.NameRelation.SUPERDOMAIN``, ``dns.name.NameRelation.SUBDOMAIN``,
+        ``dns.name.NameRelation.EQUAL``, or ``dns.name.NameRelation.COMMONANCESTOR``.
+
+        *order* is < 0 if *self* < *other*, > 0 if *self* > *other*, and ==
+        0 if *self* == *other*.  A relative name is always less than an
+        absolute name.  If both names have the same relativity, then
+        the DNSSEC order relation is used to order them.
+
+        *nlabels* is the number of significant labels that the two names
+        have in common.
+
+        Here are some examples.  Names ending in "." are absolute names,
+        those not ending in "." are relative names.
+
+        =============  =============  ===========  =====  =======
+        self           other          relation     order  nlabels
+        =============  =============  ===========  =====  =======
+        www.example.   www.example.   equal        0      3
+        www.example.   example.       subdomain    > 0    2
+        example.       www.example.   superdomain  < 0    2
+        example1.com.  example2.com.  common anc.  < 0    2
+        example1       example2.      none         < 0    0
+        example1.      example2       none         > 0    0
+        =============  =============  ===========  =====  =======
+        """
+
+        sabs = self.is_absolute()
+        oabs = other.is_absolute()
+        if sabs != oabs:
+            if sabs:
+                return (NameRelation.NONE, 1, 0)
+            else:
+                return (NameRelation.NONE, -1, 0)
+        l1 = len(self.labels)
+        l2 = len(other.labels)
+        ldiff = l1 - l2
+        if ldiff < 0:
+            l = l1
+        else:
+            l = l2
+
+        order = 0
+        nlabels = 0
+        namereln = NameRelation.NONE
+        while l > 0:
+            l -= 1
+            l1 -= 1
+            l2 -= 1
+            label1 = self.labels[l1].lower()
+            label2 = other.labels[l2].lower()
+            if label1 < label2:
+                order = -1
+                if nlabels > 0:
+                    namereln = NameRelation.COMMONANCESTOR
+                return (namereln, order, nlabels)
+            elif label1 > label2:
+                order = 1
+                if nlabels > 0:
+                    namereln = NameRelation.COMMONANCESTOR
+                return (namereln, order, nlabels)
+            nlabels += 1
+        order = ldiff
+        if ldiff < 0:
+            namereln = NameRelation.SUPERDOMAIN
+        elif ldiff > 0:
+            namereln = NameRelation.SUBDOMAIN
+        else:
+            namereln = NameRelation.EQUAL
+        return (namereln, order, nlabels)
+
+    def is_subdomain(self, other: "Name") -> bool:
+        """Is self a subdomain of other?
+
+        Note that the notion of subdomain includes equality, e.g.
+        "dnspython.org" is a subdomain of itself.
+
+        Returns a ``bool``.
+        """
+
+        (nr, _, _) = self.fullcompare(other)
+        if nr == NameRelation.SUBDOMAIN or nr == NameRelation.EQUAL:
+            return True
+        return False
+
+    def is_superdomain(self, other: "Name") -> bool:
+        """Is self a superdomain of other?
+
+        Note that the notion of superdomain includes equality, e.g.
+        "dnspython.org" is a superdomain of itself.
+
+        Returns a ``bool``.
+        """
+
+        (nr, _, _) = self.fullcompare(other)
+        if nr == NameRelation.SUPERDOMAIN or nr == NameRelation.EQUAL:
+            return True
+        return False
+
+    def canonicalize(self) -> "Name":
+        """Return a name which is equal to the current name, but is in
+        DNSSEC canonical form.
+        """
+
+        return Name([x.lower() for x in self.labels])
+
+    def __eq__(self, other):
+        if isinstance(other, Name):
+            return self.fullcompare(other)[1] == 0
+        else:
+            return False
+
+    def __ne__(self, other):
+        if isinstance(other, Name):
+            return self.fullcompare(other)[1] != 0
+        else:
+            return True
+
+    def __lt__(self, other):
+        if isinstance(other, Name):
+            return self.fullcompare(other)[1] < 0
+        else:
+            return NotImplemented
+
+    def __le__(self, other):
+        if isinstance(other, Name):
+            return self.fullcompare(other)[1] <= 0
+        else:
+            return NotImplemented
+
+    def __ge__(self, other):
+        if isinstance(other, Name):
+            return self.fullcompare(other)[1] >= 0
+        else:
+            return NotImplemented
+
+    def __gt__(self, other):
+        if isinstance(other, Name):
+            return self.fullcompare(other)[1] > 0
+        else:
+            return NotImplemented
+
+    def __repr__(self):
+        return "<DNS name " + self.__str__() + ">"
+
+    def __str__(self):
+        return self.to_text(False)
+
+    def to_text(self, omit_final_dot: bool = False) -> str:
+        """Convert name to DNS text format.
+
+        *omit_final_dot* is a ``bool``.  If True, don't emit the final
+        dot (denoting the root label) for absolute names.  The default
+        is False.
+
+        Returns a ``str``.
+        """
+
+        if len(self.labels) == 0:
+            return "@"
+        if len(self.labels) == 1 and self.labels[0] == b"":
+            return "."
+        if omit_final_dot and self.is_absolute():
+            l = self.labels[:-1]
+        else:
+            l = self.labels
+        s = ".".join(map(_escapify, l))
+        return s
+
+    def to_unicode(
+        self, omit_final_dot: bool = False, idna_codec: Optional[IDNACodec] = None
+    ) -> str:
+        """Convert name to Unicode text format.
+
+        IDN ACE labels are converted to Unicode.
+
+        *omit_final_dot* is a ``bool``.  If True, don't emit the final
+        dot (denoting the root label) for absolute names.  The default
+        is False.
+        *idna_codec* specifies the IDNA encoder/decoder.  If None, the
+        dns.name.IDNA_2003_Practical encoder/decoder is used.
+        The IDNA_2003_Practical decoder does
+        not impose any policy, it just decodes punycode, so if you
+        don't want checking for compliance, you can use this decoder
+        for IDNA2008 as well.
+
+        Returns a ``str``.
+        """
+
+        if len(self.labels) == 0:
+            return "@"
+        if len(self.labels) == 1 and self.labels[0] == b"":
+            return "."
+        if omit_final_dot and self.is_absolute():
+            l = self.labels[:-1]
+        else:
+            l = self.labels
+        if idna_codec is None:
+            idna_codec = IDNA_2003_Practical
+        return ".".join([idna_codec.decode(x) for x in l])
+
+    def to_digestable(self, origin: Optional["Name"] = None) -> bytes:
+        """Convert name to a format suitable for digesting in hashes.
+
+        The name is canonicalized and converted to uncompressed wire
+        format.  All names in wire format are absolute.  If the name
+        is a relative name, then an origin must be supplied.
+
+        *origin* is a ``dns.name.Name`` or ``None``.  If the name is
+        relative and origin is not ``None``, then origin will be appended
+        to the name.
+
+        Raises ``dns.name.NeedAbsoluteNameOrOrigin`` if the name is
+        relative and no origin was provided.
+
+        Returns a ``bytes``.
+        """
+
+        digest = self.to_wire(origin=origin, canonicalize=True)
+        assert digest is not None
+        return digest
+
+    def to_wire(
+        self,
+        file: Optional[Any] = None,
+        compress: Optional[CompressType] = None,
+        origin: Optional["Name"] = None,
+        canonicalize: bool = False,
+    ) -> Optional[bytes]:
+        """Convert name to wire format, possibly compressing it.
+
+        *file* is the file where the name is emitted (typically an
+        io.BytesIO file).  If ``None`` (the default), a ``bytes``
+        containing the wire name will be returned.
+
+        *compress*, a ``dict``, is the compression table to use.  If
+        ``None`` (the default), names will not be compressed.  Note that
+        the compression code assumes that compression offset 0 is the
+        start of *file*, and thus compression will not be correct
+        if this is not the case.
+
+        *origin* is a ``dns.name.Name`` or ``None``.  If the name is
+        relative and origin is not ``None``, then *origin* will be appended
+        to it.
+
+        *canonicalize*, a ``bool``, indicates whether the name should
+        be canonicalized; that is, converted to a format suitable for
+        digesting in hashes.
+
+        Raises ``dns.name.NeedAbsoluteNameOrOrigin`` if the name is
+        relative and no origin was provided.
+
+        Returns a ``bytes`` or ``None``.
+        """
+
+        if file is None:
+            out = bytearray()
+            for label in self.labels:
+                out.append(len(label))
+                if canonicalize:
+                    out += label.lower()
+                else:
+                    out += label
+            if not self.is_absolute():
+                if origin is None or not origin.is_absolute():
+                    raise NeedAbsoluteNameOrOrigin
+                for label in origin.labels:
+                    out.append(len(label))
+                    if canonicalize:
+                        out += label.lower()
+                    else:
+                        out += label
+            return bytes(out)
+
+        labels: Iterable[bytes]
+        if not self.is_absolute():
+            if origin is None or not origin.is_absolute():
+                raise NeedAbsoluteNameOrOrigin
+            labels = list(self.labels)
+            labels.extend(list(origin.labels))
+        else:
+            labels = self.labels
+        i = 0
+        for label in labels:
+            n = Name(labels[i:])
+            i += 1
+            if compress is not None:
+                pos = compress.get(n)
+            else:
+                pos = None
+            if pos is not None:
+                value = 0xC000 + pos
+                s = struct.pack("!H", value)
+                file.write(s)
+                break
+            else:
+                if compress is not None and len(n) > 1:
+                    pos = file.tell()
+                    if pos <= 0x3FFF:
+                        compress[n] = pos
+                l = len(label)
+                file.write(struct.pack("!B", l))
+                if l > 0:
+                    if canonicalize:
+                        file.write(label.lower())
+                    else:
+                        file.write(label)
+        return None
+
+    def __len__(self) -> int:
+        """The length of the name (in labels).
+
+        Returns an ``int``.
+        """
+
+        return len(self.labels)
+
+    def __getitem__(self, index):
+        return self.labels[index]
+
+    def __add__(self, other):
+        return self.concatenate(other)
+
+    def __sub__(self, other):
+        return self.relativize(other)
+
+    def split(self, depth: int) -> Tuple["Name", "Name"]:
+        """Split a name into a prefix and suffix names at the specified depth.
+
+        *depth* is an ``int`` specifying the number of labels in the suffix
+
+        Raises ``ValueError`` if *depth* was not >= 0 and <= the length of the
+        name.
+
+        Returns the tuple ``(prefix, suffix)``.
+        """
+
+        l = len(self.labels)
+        if depth == 0:
+            return (self, dns.name.empty)
+        elif depth == l:
+            return (dns.name.empty, self)
+        elif depth < 0 or depth > l:
+            raise ValueError("depth must be >= 0 and <= the length of the name")
+        return (Name(self[:-depth]), Name(self[-depth:]))
+
+    def concatenate(self, other: "Name") -> "Name":
+        """Return a new name which is the concatenation of self and other.
+
+        Raises ``dns.name.AbsoluteConcatenation`` if the name is
+        absolute and *other* is not the empty name.
+
+        Returns a ``dns.name.Name``.
+        """
+
+        if self.is_absolute() and len(other) > 0:
+            raise AbsoluteConcatenation
+        labels = list(self.labels)
+        labels.extend(list(other.labels))
+        return Name(labels)
+
+    def relativize(self, origin: "Name") -> "Name":
+        """If the name is a subdomain of *origin*, return a new name which is
+        the name relative to origin.  Otherwise return the name.
+
+        For example, relativizing ``www.dnspython.org.`` to origin
+        ``dnspython.org.`` returns the name ``www``.  Relativizing ``example.``
+        to origin ``dnspython.org.`` returns ``example.``.
+
+        Returns a ``dns.name.Name``.
+        """
+
+        if origin is not None and self.is_subdomain(origin):
+            return Name(self[: -len(origin)])
+        else:
+            return self
+
+    def derelativize(self, origin: "Name") -> "Name":
+        """If the name is a relative name, return a new name which is the
+        concatenation of the name and origin.  Otherwise return the name.
+
+        For example, derelativizing ``www`` to origin ``dnspython.org.``
+        returns the name ``www.dnspython.org.``.  Derelativizing ``example.``
+        to origin ``dnspython.org.`` returns ``example.``.
+
+        Returns a ``dns.name.Name``.
+        """
+
+        if not self.is_absolute():
+            return self.concatenate(origin)
+        else:
+            return self
+
+    def choose_relativity(
+        self, origin: Optional["Name"] = None, relativize: bool = True
+    ) -> "Name":
+        """Return a name with the relativity desired by the caller.
+
+        If *origin* is ``None``, then the name is returned.
+        Otherwise, if *relativize* is ``True`` the name is
+        relativized, and if *relativize* is ``False`` the name is
+        derelativized.
+
+        Returns a ``dns.name.Name``.
+        """
+
+        if origin:
+            if relativize:
+                return self.relativize(origin)
+            else:
+                return self.derelativize(origin)
+        else:
+            return self
+
+    def parent(self) -> "Name":
+        """Return the parent of the name.
+
+        For example, the parent of ``www.dnspython.org.`` is ``dnspython.org``.
+
+        Raises ``dns.name.NoParent`` if the name is either the root name or the
+        empty name, and thus has no parent.
+
+        Returns a ``dns.name.Name``.
+        """
+
+        if self == root or self == empty:
+            raise NoParent
+        return Name(self.labels[1:])
+
+    def predecessor(self, origin: "Name", prefix_ok: bool = True) -> "Name":
+        """Return the maximal predecessor of *name* in the DNSSEC ordering in the zone
+        whose origin is *origin*, or return the longest name under *origin* if the
+        name is origin (i.e. wrap around to the longest name, which may still be
+        *origin* due to length considerations.
+
+        The relativity of the name is preserved, so if this name is relative
+        then the method will return a relative name, and likewise if this name
+        is absolute then the predecessor will be absolute.
+
+        *prefix_ok* indicates if prefixing labels is allowed, and
+        defaults to ``True``.  Normally it is good to allow this, but if computing
+        a maximal predecessor at a zone cut point then ``False`` must be specified.
+        """
+        return _handle_relativity_and_call(
+            _absolute_predecessor, self, origin, prefix_ok
+        )
+
+    def successor(self, origin: "Name", prefix_ok: bool = True) -> "Name":
+        """Return the minimal successor of *name* in the DNSSEC ordering in the zone
+        whose origin is *origin*, or return *origin* if the successor cannot be
+        computed due to name length limitations.
+
+        Note that *origin* is returned in the "too long" cases because wrapping
+        around to the origin is how NSEC records express "end of the zone".
+
+        The relativity of the name is preserved, so if this name is relative
+        then the method will return a relative name, and likewise if this name
+        is absolute then the successor will be absolute.
+
+        *prefix_ok* indicates if prefixing a new minimal label is allowed, and
+        defaults to ``True``.  Normally it is good to allow this, but if computing
+        a minimal successor at a zone cut point then ``False`` must be specified.
+        """
+        return _handle_relativity_and_call(_absolute_successor, self, origin, prefix_ok)
+
+
+#: The root name, '.'
+root = Name([b""])
+
+#: The empty name.
+empty = Name([])
+
+
+def from_unicode(
+    text: str, origin: Optional[Name] = root, idna_codec: Optional[IDNACodec] = None
+) -> Name:
+    """Convert unicode text into a Name object.
+
+    Labels are encoded in IDN ACE form according to rules specified by
+    the IDNA codec.
+
+    *text*, a ``str``, is the text to convert into a name.
+
+    *origin*, a ``dns.name.Name``, specifies the origin to
+    append to non-absolute names.  The default is the root name.
+
+    *idna_codec*, a ``dns.name.IDNACodec``, specifies the IDNA
+    encoder/decoder.  If ``None``, the default IDNA 2003 encoder/decoder
+    is used.
+
+    Returns a ``dns.name.Name``.
+    """
+
+    if not isinstance(text, str):
+        raise ValueError("input to from_unicode() must be a unicode string")
+    if not (origin is None or isinstance(origin, Name)):
+        raise ValueError("origin must be a Name or None")
+    labels = []
+    label = ""
+    escaping = False
+    edigits = 0
+    total = 0
+    if idna_codec is None:
+        idna_codec = IDNA_2003
+    if text == "@":
+        text = ""
+    if text:
+        if text in [".", "\u3002", "\uff0e", "\uff61"]:
+            return Name([b""])  # no Unicode "u" on this constant!
+        for c in text:
+            if escaping:
+                if edigits == 0:
+                    if c.isdigit():
+                        total = int(c)
+                        edigits += 1
+                    else:
+                        label += c
+                        escaping = False
+                else:
+                    if not c.isdigit():
+                        raise BadEscape
+                    total *= 10
+                    total += int(c)
+                    edigits += 1
+                    if edigits == 3:
+                        escaping = False
+                        label += chr(total)
+            elif c in [".", "\u3002", "\uff0e", "\uff61"]:
+                if len(label) == 0:
+                    raise EmptyLabel
+                labels.append(idna_codec.encode(label))
+                label = ""
+            elif c == "\\":
+                escaping = True
+                edigits = 0
+                total = 0
+            else:
+                label += c
+        if escaping:
+            raise BadEscape
+        if len(label) > 0:
+            labels.append(idna_codec.encode(label))
+        else:
+            labels.append(b"")
+
+    if (len(labels) == 0 or labels[-1] != b"") and origin is not None:
+        labels.extend(list(origin.labels))
+    return Name(labels)
+
+
+def is_all_ascii(text: str) -> bool:
+    for c in text:
+        if ord(c) > 0x7F:
+            return False
+    return True
+
+
+def from_text(
+    text: Union[bytes, str],
+    origin: Optional[Name] = root,
+    idna_codec: Optional[IDNACodec] = None,
+) -> Name:
+    """Convert text into a Name object.
+
+    *text*, a ``bytes`` or ``str``, is the text to convert into a name.
+
+    *origin*, a ``dns.name.Name``, specifies the origin to
+    append to non-absolute names.  The default is the root name.
+
+    *idna_codec*, a ``dns.name.IDNACodec``, specifies the IDNA
+    encoder/decoder.  If ``None``, the default IDNA 2003 encoder/decoder
+    is used.
+
+    Returns a ``dns.name.Name``.
+    """
+
+    if isinstance(text, str):
+        if not is_all_ascii(text):
+            # Some codepoint in the input text is > 127, so IDNA applies.
+            return from_unicode(text, origin, idna_codec)
+        # The input is all ASCII, so treat this like an ordinary non-IDNA
+        # domain name.  Note that "all ASCII" is about the input text,
+        # not the codepoints in the domain name.  E.g. if text has value
+        #
+        # r'\150\151\152\153\154\155\156\157\158\159'
+        #
+        # then it's still "all ASCII" even though the domain name has
+        # codepoints > 127.
+        text = text.encode("ascii")
+    if not isinstance(text, bytes):
+        raise ValueError("input to from_text() must be a string")
+    if not (origin is None or isinstance(origin, Name)):
+        raise ValueError("origin must be a Name or None")
+    labels = []
+    label = b""
+    escaping = False
+    edigits = 0
+    total = 0
+    if text == b"@":
+        text = b""
+    if text:
+        if text == b".":
+            return Name([b""])
+        for c in text:
+            byte_ = struct.pack("!B", c)
+            if escaping:
+                if edigits == 0:
+                    if byte_.isdigit():
+                        total = int(byte_)
+                        edigits += 1
+                    else:
+                        label += byte_
+                        escaping = False
+                else:
+                    if not byte_.isdigit():
+                        raise BadEscape
+                    total *= 10
+                    total += int(byte_)
+                    edigits += 1
+                    if edigits == 3:
+                        escaping = False
+                        label += struct.pack("!B", total)
+            elif byte_ == b".":
+                if len(label) == 0:
+                    raise EmptyLabel
+                labels.append(label)
+                label = b""
+            elif byte_ == b"\\":
+                escaping = True
+                edigits = 0
+                total = 0
+            else:
+                label += byte_
+        if escaping:
+            raise BadEscape
+        if len(label) > 0:
+            labels.append(label)
+        else:
+            labels.append(b"")
+    if (len(labels) == 0 or labels[-1] != b"") and origin is not None:
+        labels.extend(list(origin.labels))
+    return Name(labels)
+
+
+# we need 'dns.wire.Parser' quoted as dns.name and dns.wire depend on each other.
+
+
+def from_wire_parser(parser: "dns.wire.Parser") -> Name:
+    """Convert possibly compressed wire format into a Name.
+
+    *parser* is a dns.wire.Parser.
+
+    Raises ``dns.name.BadPointer`` if a compression pointer did not
+    point backwards in the message.
+
+    Raises ``dns.name.BadLabelType`` if an invalid label type was encountered.
+
+    Returns a ``dns.name.Name``
+    """
+
+    labels = []
+    biggest_pointer = parser.current
+    with parser.restore_furthest():
+        count = parser.get_uint8()
+        while count != 0:
+            if count < 64:
+                labels.append(parser.get_bytes(count))
+            elif count >= 192:
+                current = (count & 0x3F) * 256 + parser.get_uint8()
+                if current >= biggest_pointer:
+                    raise BadPointer
+                biggest_pointer = current
+                parser.seek(current)
+            else:
+                raise BadLabelType
+            count = parser.get_uint8()
+        labels.append(b"")
+    return Name(labels)
+
+
+def from_wire(message: bytes, current: int) -> Tuple[Name, int]:
+    """Convert possibly compressed wire format into a Name.
+
+    *message* is a ``bytes`` containing an entire DNS message in DNS
+    wire form.
+
+    *current*, an ``int``, is the offset of the beginning of the name
+    from the start of the message
+
+    Raises ``dns.name.BadPointer`` if a compression pointer did not
+    point backwards in the message.
+
+    Raises ``dns.name.BadLabelType`` if an invalid label type was encountered.
+
+    Returns a ``(dns.name.Name, int)`` tuple consisting of the name
+    that was read and the number of bytes of the wire format message
+    which were consumed reading it.
+    """
+
+    if not isinstance(message, bytes):
+        raise ValueError("input to from_wire() must be a byte string")
+    parser = dns.wire.Parser(message, current)
+    name = from_wire_parser(parser)
+    return (name, parser.current - current)
+
+
+# RFC 4471 Support
+
+_MINIMAL_OCTET = b"\x00"
+_MINIMAL_OCTET_VALUE = ord(_MINIMAL_OCTET)
+_SUCCESSOR_PREFIX = Name([_MINIMAL_OCTET])
+_MAXIMAL_OCTET = b"\xff"
+_MAXIMAL_OCTET_VALUE = ord(_MAXIMAL_OCTET)
+_AT_SIGN_VALUE = ord("@")
+_LEFT_SQUARE_BRACKET_VALUE = ord("[")
+
+
+def _wire_length(labels):
+    return functools.reduce(lambda v, x: v + len(x) + 1, labels, 0)
+
+
+def _pad_to_max_name(name):
+    needed = 255 - _wire_length(name.labels)
+    new_labels = []
+    while needed > 64:
+        new_labels.append(_MAXIMAL_OCTET * 63)
+        needed -= 64
+    if needed >= 2:
+        new_labels.append(_MAXIMAL_OCTET * (needed - 1))
+    # Note we're already maximal in the needed == 1 case as while we'd like
+    # to add one more byte as a new label, we can't, as adding a new non-empty
+    # label requires at least 2 bytes.
+    new_labels = list(reversed(new_labels))
+    new_labels.extend(name.labels)
+    return Name(new_labels)
+
+
+def _pad_to_max_label(label, suffix_labels):
+    length = len(label)
+    # We have to subtract one here to account for the length byte of label.
+    remaining = 255 - _wire_length(suffix_labels) - length - 1
+    if remaining <= 0:
+        # Shouldn't happen!
+        return label
+    needed = min(63 - length, remaining)
+    return label + _MAXIMAL_OCTET * needed
+
+
+def _absolute_predecessor(name: Name, origin: Name, prefix_ok: bool) -> Name:
+    # This is the RFC 4471 predecessor algorithm using the "absolute method" of section
+    # 3.1.1.
+    #
+    # Our caller must ensure that the name and origin are absolute, and that name is a
+    # subdomain of origin.
+    if name == origin:
+        return _pad_to_max_name(name)
+    least_significant_label = name[0]
+    if least_significant_label == _MINIMAL_OCTET:
+        return name.parent()
+    least_octet = least_significant_label[-1]
+    suffix_labels = name.labels[1:]
+    if least_octet == _MINIMAL_OCTET_VALUE:
+        new_labels = [least_significant_label[:-1]]
+    else:
+        octets = bytearray(least_significant_label)
+        octet = octets[-1]
+        if octet == _LEFT_SQUARE_BRACKET_VALUE:
+            octet = _AT_SIGN_VALUE
+        else:
+            octet -= 1
+        octets[-1] = octet
+        least_significant_label = bytes(octets)
+        new_labels = [_pad_to_max_label(least_significant_label, suffix_labels)]
+    new_labels.extend(suffix_labels)
+    name = Name(new_labels)
+    if prefix_ok:
+        return _pad_to_max_name(name)
+    else:
+        return name
+
+
+def _absolute_successor(name: Name, origin: Name, prefix_ok: bool) -> Name:
+    # This is the RFC 4471 successor algorithm using the "absolute method" of section
+    # 3.1.2.
+    #
+    # Our caller must ensure that the name and origin are absolute, and that name is a
+    # subdomain of origin.
+    if prefix_ok:
+        # Try prefixing \000 as new label
+        try:
+            return _SUCCESSOR_PREFIX.concatenate(name)
+        except NameTooLong:
+            pass
+    while name != origin:
+        # Try extending the least significant label.
+        least_significant_label = name[0]
+        if len(least_significant_label) < 63:
+            # We may be able to extend the least label with a minimal additional byte.
+            # This is only "may" because we could have a maximal length name even though
+            # the least significant label isn't maximally long.
+            new_labels = [least_significant_label + _MINIMAL_OCTET]
+            new_labels.extend(name.labels[1:])
+            try:
+                return dns.name.Name(new_labels)
+            except dns.name.NameTooLong:
+                pass
+        # We can't extend the label either, so we'll try to increment the least
+        # signficant non-maximal byte in it.
+        octets = bytearray(least_significant_label)
+        # We do this reversed iteration with an explicit indexing variable because
+        # if we find something to increment, we're going to want to truncate everything
+        # to the right of it.
+        for i in range(len(octets) - 1, -1, -1):
+            octet = octets[i]
+            if octet == _MAXIMAL_OCTET_VALUE:
+                # We can't increment this, so keep looking.
+                continue
+            # Finally, something we can increment.  We have to apply a special rule for
+            # incrementing "@", sending it to "[", because RFC 4034 6.1 says that when
+            # comparing names, uppercase letters compare as if they were their
+            # lower-case equivalents. If we increment "@" to "A", then it would compare
+            # as "a", which is after "[", "\", "]", "^", "_", and "`", so we would have
+            # skipped the most minimal successor, namely "[".
+            if octet == _AT_SIGN_VALUE:
+                octet = _LEFT_SQUARE_BRACKET_VALUE
+            else:
+                octet += 1
+            octets[i] = octet
+            # We can now truncate all of the maximal values we skipped (if any)
+            new_labels = [bytes(octets[: i + 1])]
+            new_labels.extend(name.labels[1:])
+            # We haven't changed the length of the name, so the Name constructor will
+            # always work.
+            return Name(new_labels)
+        # We couldn't increment, so chop off the least significant label and try
+        # again.
+        name = name.parent()
+
+    # We couldn't increment at all, so return the origin, as wrapping around is the
+    # DNSSEC way.
+    return origin
+
+
+def _handle_relativity_and_call(
+    function: Callable[[Name, Name, bool], Name],
+    name: Name,
+    origin: Name,
+    prefix_ok: bool,
+) -> Name:
+    # Make "name" absolute if needed, ensure that the origin is absolute,
+    # call function(), and then relativize the result if needed.
+    if not origin.is_absolute():
+        raise NeedAbsoluteNameOrOrigin
+    relative = not name.is_absolute()
+    if relative:
+        name = name.derelativize(origin)
+    elif not name.is_subdomain(origin):
+        raise NeedSubdomainOfOrigin
+    result_name = function(name, origin, prefix_ok)
+    if relative:
+        result_name = result_name.relativize(origin)
+    return result_name
diff --git a/.venv/lib/python3.12/site-packages/dns/namedict.py b/.venv/lib/python3.12/site-packages/dns/namedict.py
new file mode 100644
index 00000000..ca8b1978
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/dns/namedict.py
@@ -0,0 +1,109 @@
+# Copyright (C) Dnspython Contributors, see LICENSE for text of ISC license
+
+# Copyright (C) 2003-2017 Nominum, Inc.
+# Copyright (C) 2016 Coresec Systems AB
+#
+# Permission to use, copy, modify, and distribute this software and its
+# documentation for any purpose with or without fee is hereby granted,
+# provided that the above copyright notice and this permission notice
+# appear in all copies.
+#
+# THE SOFTWARE IS PROVIDED "AS IS" AND NOMINUM DISCLAIMS ALL WARRANTIES
+# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL NOMINUM BE LIABLE FOR
+# ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+# ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT
+# OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+#
+# THE SOFTWARE IS PROVIDED "AS IS" AND CORESEC SYSTEMS AB DISCLAIMS ALL
+# WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED
+# WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL CORESEC
+# SYSTEMS AB BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR
+# CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS
+# OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT,
+# NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION
+# WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+
+"""DNS name dictionary"""
+
+# pylint seems to be confused about this one!
+from collections.abc import MutableMapping  # pylint: disable=no-name-in-module
+
+import dns.name
+
+
+class NameDict(MutableMapping):
+    """A dictionary whose keys are dns.name.Name objects.
+
+    In addition to being like a regular Python dictionary, this
+    dictionary can also get the deepest match for a given key.
+    """
+
+    __slots__ = ["max_depth", "max_depth_items", "__store"]
+
+    def __init__(self, *args, **kwargs):
+        super().__init__()
+        self.__store = dict()
+        #: the maximum depth of the keys that have ever been added
+        self.max_depth = 0
+        #: the number of items of maximum depth
+        self.max_depth_items = 0
+        self.update(dict(*args, **kwargs))
+
+    def __update_max_depth(self, key):
+        if len(key) == self.max_depth:
+            self.max_depth_items = self.max_depth_items + 1
+        elif len(key) > self.max_depth:
+            self.max_depth = len(key)
+            self.max_depth_items = 1
+
+    def __getitem__(self, key):
+        return self.__store[key]
+
+    def __setitem__(self, key, value):
+        if not isinstance(key, dns.name.Name):
+            raise ValueError("NameDict key must be a name")
+        self.__store[key] = value
+        self.__update_max_depth(key)
+
+    def __delitem__(self, key):
+        self.__store.pop(key)
+        if len(key) == self.max_depth:
+            self.max_depth_items = self.max_depth_items - 1
+        if self.max_depth_items == 0:
+            self.max_depth = 0
+            for k in self.__store:
+                self.__update_max_depth(k)
+
+    def __iter__(self):
+        return iter(self.__store)
+
+    def __len__(self):
+        return len(self.__store)
+
+    def has_key(self, key):
+        return key in self.__store
+
+    def get_deepest_match(self, name):
+        """Find the deepest match to *name* in the dictionary.
+
+        The deepest match is the longest name in the dictionary which is
+        a superdomain of *name*.  Note that *superdomain* includes matching
+        *name* itself.
+
+        *name*, a ``dns.name.Name``, the name to find.
+
+        Returns a ``(key, value)`` where *key* is the deepest
+        ``dns.name.Name``, and *value* is the value associated with *key*.
+        """
+
+        depth = len(name)
+        if depth > self.max_depth:
+            depth = self.max_depth
+        for i in range(-depth, 0):
+            n = dns.name.Name(name[i:])
+            if n in self:
+                return (n, self[n])
+        v = self[dns.name.empty]
+        return (dns.name.empty, v)
diff --git a/.venv/lib/python3.12/site-packages/dns/nameserver.py b/.venv/lib/python3.12/site-packages/dns/nameserver.py
new file mode 100644
index 00000000..b02a239b
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/dns/nameserver.py
@@ -0,0 +1,363 @@
+from typing import Optional, Union
+from urllib.parse import urlparse
+
+import dns.asyncbackend
+import dns.asyncquery
+import dns.inet
+import dns.message
+import dns.query
+
+
+class Nameserver:
+    def __init__(self):
+        pass
+
+    def __str__(self):
+        raise NotImplementedError
+
+    def kind(self) -> str:
+        raise NotImplementedError
+
+    def is_always_max_size(self) -> bool:
+        raise NotImplementedError
+
+    def answer_nameserver(self) -> str:
+        raise NotImplementedError
+
+    def answer_port(self) -> int:
+        raise NotImplementedError
+
+    def query(
+        self,
+        request: dns.message.QueryMessage,
+        timeout: float,
+        source: Optional[str],
+        source_port: int,
+        max_size: bool,
+        one_rr_per_rrset: bool = False,
+        ignore_trailing: bool = False,
+    ) -> dns.message.Message:
+        raise NotImplementedError
+
+    async def async_query(
+        self,
+        request: dns.message.QueryMessage,
+        timeout: float,
+        source: Optional[str],
+        source_port: int,
+        max_size: bool,
+        backend: dns.asyncbackend.Backend,
+        one_rr_per_rrset: bool = False,
+        ignore_trailing: bool = False,
+    ) -> dns.message.Message:
+        raise NotImplementedError
+
+
+class AddressAndPortNameserver(Nameserver):
+    def __init__(self, address: str, port: int):
+        super().__init__()
+        self.address = address
+        self.port = port
+
+    def kind(self) -> str:
+        raise NotImplementedError
+
+    def is_always_max_size(self) -> bool:
+        return False
+
+    def __str__(self):
+        ns_kind = self.kind()
+        return f"{ns_kind}:{self.address}@{self.port}"
+
+    def answer_nameserver(self) -> str:
+        return self.address
+
+    def answer_port(self) -> int:
+        return self.port
+
+
+class Do53Nameserver(AddressAndPortNameserver):
+    def __init__(self, address: str, port: int = 53):
+        super().__init__(address, port)
+
+    def kind(self):
+        return "Do53"
+
+    def query(
+        self,
+        request: dns.message.QueryMessage,
+        timeout: float,
+        source: Optional[str],
+        source_port: int,
+        max_size: bool,
+        one_rr_per_rrset: bool = False,
+        ignore_trailing: bool = False,
+    ) -> dns.message.Message:
+        if max_size:
+            response = dns.query.tcp(
+                request,
+                self.address,
+                timeout=timeout,
+                port=self.port,
+                source=source,
+                source_port=source_port,
+                one_rr_per_rrset=one_rr_per_rrset,
+                ignore_trailing=ignore_trailing,
+            )
+        else:
+            response = dns.query.udp(
+                request,
+                self.address,
+                timeout=timeout,
+                port=self.port,
+                source=source,
+                source_port=source_port,
+                raise_on_truncation=True,
+                one_rr_per_rrset=one_rr_per_rrset,
+                ignore_trailing=ignore_trailing,
+                ignore_errors=True,
+                ignore_unexpected=True,
+            )
+        return response
+
+    async def async_query(
+        self,
+        request: dns.message.QueryMessage,
+        timeout: float,
+        source: Optional[str],
+        source_port: int,
+        max_size: bool,
+        backend: dns.asyncbackend.Backend,
+        one_rr_per_rrset: bool = False,
+        ignore_trailing: bool = False,
+    ) -> dns.message.Message:
+        if max_size:
+            response = await dns.asyncquery.tcp(
+                request,
+                self.address,
+                timeout=timeout,
+                port=self.port,
+                source=source,
+                source_port=source_port,
+                backend=backend,
+                one_rr_per_rrset=one_rr_per_rrset,
+                ignore_trailing=ignore_trailing,
+            )
+        else:
+            response = await dns.asyncquery.udp(
+                request,
+                self.address,
+                timeout=timeout,
+                port=self.port,
+                source=source,
+                source_port=source_port,
+                raise_on_truncation=True,
+                backend=backend,
+                one_rr_per_rrset=one_rr_per_rrset,
+                ignore_trailing=ignore_trailing,
+                ignore_errors=True,
+                ignore_unexpected=True,
+            )
+        return response
+
+
+class DoHNameserver(Nameserver):
+    def __init__(
+        self,
+        url: str,
+        bootstrap_address: Optional[str] = None,
+        verify: Union[bool, str] = True,
+        want_get: bool = False,
+        http_version: dns.query.HTTPVersion = dns.query.HTTPVersion.DEFAULT,
+    ):
+        super().__init__()
+        self.url = url
+        self.bootstrap_address = bootstrap_address
+        self.verify = verify
+        self.want_get = want_get
+        self.http_version = http_version
+
+    def kind(self):
+        return "DoH"
+
+    def is_always_max_size(self) -> bool:
+        return True
+
+    def __str__(self):
+        return self.url
+
+    def answer_nameserver(self) -> str:
+        return self.url
+
+    def answer_port(self) -> int:
+        port = urlparse(self.url).port
+        if port is None:
+            port = 443
+        return port
+
+    def query(
+        self,
+        request: dns.message.QueryMessage,
+        timeout: float,
+        source: Optional[str],
+        source_port: int,
+        max_size: bool = False,
+        one_rr_per_rrset: bool = False,
+        ignore_trailing: bool = False,
+    ) -> dns.message.Message:
+        return dns.query.https(
+            request,
+            self.url,
+            timeout=timeout,
+            source=source,
+            source_port=source_port,
+            bootstrap_address=self.bootstrap_address,
+            one_rr_per_rrset=one_rr_per_rrset,
+            ignore_trailing=ignore_trailing,
+            verify=self.verify,
+            post=(not self.want_get),
+            http_version=self.http_version,
+        )
+
+    async def async_query(
+        self,
+        request: dns.message.QueryMessage,
+        timeout: float,
+        source: Optional[str],
+        source_port: int,
+        max_size: bool,
+        backend: dns.asyncbackend.Backend,
+        one_rr_per_rrset: bool = False,
+        ignore_trailing: bool = False,
+    ) -> dns.message.Message:
+        return await dns.asyncquery.https(
+            request,
+            self.url,
+            timeout=timeout,
+            source=source,
+            source_port=source_port,
+            bootstrap_address=self.bootstrap_address,
+            one_rr_per_rrset=one_rr_per_rrset,
+            ignore_trailing=ignore_trailing,
+            verify=self.verify,
+            post=(not self.want_get),
+            http_version=self.http_version,
+        )
+
+
+class DoTNameserver(AddressAndPortNameserver):
+    def __init__(
+        self,
+        address: str,
+        port: int = 853,
+        hostname: Optional[str] = None,
+        verify: Union[bool, str] = True,
+    ):
+        super().__init__(address, port)
+        self.hostname = hostname
+        self.verify = verify
+
+    def kind(self):
+        return "DoT"
+
+    def query(
+        self,
+        request: dns.message.QueryMessage,
+        timeout: float,
+        source: Optional[str],
+        source_port: int,
+        max_size: bool = False,
+        one_rr_per_rrset: bool = False,
+        ignore_trailing: bool = False,
+    ) -> dns.message.Message:
+        return dns.query.tls(
+            request,
+            self.address,
+            port=self.port,
+            timeout=timeout,
+            one_rr_per_rrset=one_rr_per_rrset,
+            ignore_trailing=ignore_trailing,
+            server_hostname=self.hostname,
+            verify=self.verify,
+        )
+
+    async def async_query(
+        self,
+        request: dns.message.QueryMessage,
+        timeout: float,
+        source: Optional[str],
+        source_port: int,
+        max_size: bool,
+        backend: dns.asyncbackend.Backend,
+        one_rr_per_rrset: bool = False,
+        ignore_trailing: bool = False,
+    ) -> dns.message.Message:
+        return await dns.asyncquery.tls(
+            request,
+            self.address,
+            port=self.port,
+            timeout=timeout,
+            one_rr_per_rrset=one_rr_per_rrset,
+            ignore_trailing=ignore_trailing,
+            server_hostname=self.hostname,
+            verify=self.verify,
+        )
+
+
+class DoQNameserver(AddressAndPortNameserver):
+    def __init__(
+        self,
+        address: str,
+        port: int = 853,
+        verify: Union[bool, str] = True,
+        server_hostname: Optional[str] = None,
+    ):
+        super().__init__(address, port)
+        self.verify = verify
+        self.server_hostname = server_hostname
+
+    def kind(self):
+        return "DoQ"
+
+    def query(
+        self,
+        request: dns.message.QueryMessage,
+        timeout: float,
+        source: Optional[str],
+        source_port: int,
+        max_size: bool = False,
+        one_rr_per_rrset: bool = False,
+        ignore_trailing: bool = False,
+    ) -> dns.message.Message:
+        return dns.query.quic(
+            request,
+            self.address,
+            port=self.port,
+            timeout=timeout,
+            one_rr_per_rrset=one_rr_per_rrset,
+            ignore_trailing=ignore_trailing,
+            verify=self.verify,
+            server_hostname=self.server_hostname,
+        )
+
+    async def async_query(
+        self,
+        request: dns.message.QueryMessage,
+        timeout: float,
+        source: Optional[str],
+        source_port: int,
+        max_size: bool,
+        backend: dns.asyncbackend.Backend,
+        one_rr_per_rrset: bool = False,
+        ignore_trailing: bool = False,
+    ) -> dns.message.Message:
+        return await dns.asyncquery.quic(
+            request,
+            self.address,
+            port=self.port,
+            timeout=timeout,
+            one_rr_per_rrset=one_rr_per_rrset,
+            ignore_trailing=ignore_trailing,
+            verify=self.verify,
+            server_hostname=self.server_hostname,
+        )
diff --git a/.venv/lib/python3.12/site-packages/dns/node.py b/.venv/lib/python3.12/site-packages/dns/node.py
new file mode 100644
index 00000000..de85a82d
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/dns/node.py
@@ -0,0 +1,359 @@
+# Copyright (C) Dnspython Contributors, see LICENSE for text of ISC license
+
+# Copyright (C) 2001-2017 Nominum, Inc.
+#
+# Permission to use, copy, modify, and distribute this software and its
+# documentation for any purpose with or without fee is hereby granted,
+# provided that the above copyright notice and this permission notice
+# appear in all copies.
+#
+# THE SOFTWARE IS PROVIDED "AS IS" AND NOMINUM DISCLAIMS ALL WARRANTIES
+# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL NOMINUM BE LIABLE FOR
+# ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+# ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT
+# OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+
+"""DNS nodes.  A node is a set of rdatasets."""
+
+import enum
+import io
+from typing import Any, Dict, Optional
+
+import dns.immutable
+import dns.name
+import dns.rdataclass
+import dns.rdataset
+import dns.rdatatype
+import dns.renderer
+import dns.rrset
+
+_cname_types = {
+    dns.rdatatype.CNAME,
+}
+
+# "neutral" types can coexist with a CNAME and thus are not "other data"
+_neutral_types = {
+    dns.rdatatype.NSEC,  # RFC 4035 section 2.5
+    dns.rdatatype.NSEC3,  # This is not likely to happen, but not impossible!
+    dns.rdatatype.KEY,  # RFC 4035 section 2.5, RFC 3007
+}
+
+
+def _matches_type_or_its_signature(rdtypes, rdtype, covers):
+    return rdtype in rdtypes or (rdtype == dns.rdatatype.RRSIG and covers in rdtypes)
+
+
+@enum.unique
+class NodeKind(enum.Enum):
+    """Rdatasets in nodes"""
+
+    REGULAR = 0  # a.k.a "other data"
+    NEUTRAL = 1
+    CNAME = 2
+
+    @classmethod
+    def classify(
+        cls, rdtype: dns.rdatatype.RdataType, covers: dns.rdatatype.RdataType
+    ) -> "NodeKind":
+        if _matches_type_or_its_signature(_cname_types, rdtype, covers):
+            return NodeKind.CNAME
+        elif _matches_type_or_its_signature(_neutral_types, rdtype, covers):
+            return NodeKind.NEUTRAL
+        else:
+            return NodeKind.REGULAR
+
+    @classmethod
+    def classify_rdataset(cls, rdataset: dns.rdataset.Rdataset) -> "NodeKind":
+        return cls.classify(rdataset.rdtype, rdataset.covers)
+
+
+class Node:
+    """A Node is a set of rdatasets.
+
+    A node is either a CNAME node or an "other data" node.  A CNAME
+    node contains only CNAME, KEY, NSEC, and NSEC3 rdatasets along with their
+    covering RRSIG rdatasets.  An "other data" node contains any
+    rdataset other than a CNAME or RRSIG(CNAME) rdataset.  When
+    changes are made to a node, the CNAME or "other data" state is
+    always consistent with the update, i.e. the most recent change
+    wins.  For example, if you have a node which contains a CNAME
+    rdataset, and then add an MX rdataset to it, then the CNAME
+    rdataset will be deleted.  Likewise if you have a node containing
+    an MX rdataset and add a CNAME rdataset, the MX rdataset will be
+    deleted.
+    """
+
+    __slots__ = ["rdatasets"]
+
+    def __init__(self):
+        # the set of rdatasets, represented as a list.
+        self.rdatasets = []
+
+    def to_text(self, name: dns.name.Name, **kw: Dict[str, Any]) -> str:
+        """Convert a node to text format.
+
+        Each rdataset at the node is printed.  Any keyword arguments
+        to this method are passed on to the rdataset's to_text() method.
+
+        *name*, a ``dns.name.Name``, the owner name of the
+        rdatasets.
+
+        Returns a ``str``.
+
+        """
+
+        s = io.StringIO()
+        for rds in self.rdatasets:
+            if len(rds) > 0:
+                s.write(rds.to_text(name, **kw))  # type: ignore[arg-type]
+                s.write("\n")
+        return s.getvalue()[:-1]
+
+    def __repr__(self):
+        return "<DNS node " + str(id(self)) + ">"
+
+    def __eq__(self, other):
+        #
+        # This is inefficient.  Good thing we don't need to do it much.
+        #
+        for rd in self.rdatasets:
+            if rd not in other.rdatasets:
+                return False
+        for rd in other.rdatasets:
+            if rd not in self.rdatasets:
+                return False
+        return True
+
+    def __ne__(self, other):
+        return not self.__eq__(other)
+
+    def __len__(self):
+        return len(self.rdatasets)
+
+    def __iter__(self):
+        return iter(self.rdatasets)
+
+    def _append_rdataset(self, rdataset):
+        """Append rdataset to the node with special handling for CNAME and
+        other data conditions.
+
+        Specifically, if the rdataset being appended has ``NodeKind.CNAME``,
+        then all rdatasets other than KEY, NSEC, NSEC3, and their covering
+        RRSIGs are deleted.  If the rdataset being appended has
+        ``NodeKind.REGULAR`` then CNAME and RRSIG(CNAME) are deleted.
+        """
+        # Make having just one rdataset at the node fast.
+        if len(self.rdatasets) > 0:
+            kind = NodeKind.classify_rdataset(rdataset)
+            if kind == NodeKind.CNAME:
+                self.rdatasets = [
+                    rds
+                    for rds in self.rdatasets
+                    if NodeKind.classify_rdataset(rds) != NodeKind.REGULAR
+                ]
+            elif kind == NodeKind.REGULAR:
+                self.rdatasets = [
+                    rds
+                    for rds in self.rdatasets
+                    if NodeKind.classify_rdataset(rds) != NodeKind.CNAME
+                ]
+            # Otherwise the rdataset is NodeKind.NEUTRAL and we do not need to
+            # edit self.rdatasets.
+        self.rdatasets.append(rdataset)
+
+    def find_rdataset(
+        self,
+        rdclass: dns.rdataclass.RdataClass,
+        rdtype: dns.rdatatype.RdataType,
+        covers: dns.rdatatype.RdataType = dns.rdatatype.NONE,
+        create: bool = False,
+    ) -> dns.rdataset.Rdataset:
+        """Find an rdataset matching the specified properties in the
+        current node.
+
+        *rdclass*, a ``dns.rdataclass.RdataClass``, the class of the rdataset.
+
+        *rdtype*, a ``dns.rdatatype.RdataType``, the type of the rdataset.
+
+        *covers*, a ``dns.rdatatype.RdataType``, the covered type.
+        Usually this value is ``dns.rdatatype.NONE``, but if the
+        rdtype is ``dns.rdatatype.SIG`` or ``dns.rdatatype.RRSIG``,
+        then the covers value will be the rdata type the SIG/RRSIG
+        covers.  The library treats the SIG and RRSIG types as if they
+        were a family of types, e.g. RRSIG(A), RRSIG(NS), RRSIG(SOA).
+        This makes RRSIGs much easier to work with than if RRSIGs
+        covering different rdata types were aggregated into a single
+        RRSIG rdataset.
+
+        *create*, a ``bool``.  If True, create the rdataset if it is not found.
+
+        Raises ``KeyError`` if an rdataset of the desired type and class does
+        not exist and *create* is not ``True``.
+
+        Returns a ``dns.rdataset.Rdataset``.
+        """
+
+        for rds in self.rdatasets:
+            if rds.match(rdclass, rdtype, covers):
+                return rds
+        if not create:
+            raise KeyError
+        rds = dns.rdataset.Rdataset(rdclass, rdtype, covers)
+        self._append_rdataset(rds)
+        return rds
+
+    def get_rdataset(
+        self,
+        rdclass: dns.rdataclass.RdataClass,
+        rdtype: dns.rdatatype.RdataType,
+        covers: dns.rdatatype.RdataType = dns.rdatatype.NONE,
+        create: bool = False,
+    ) -> Optional[dns.rdataset.Rdataset]:
+        """Get an rdataset matching the specified properties in the
+        current node.
+
+        None is returned if an rdataset of the specified type and
+        class does not exist and *create* is not ``True``.
+
+        *rdclass*, an ``int``, the class of the rdataset.
+
+        *rdtype*, an ``int``, the type of the rdataset.
+
+        *covers*, an ``int``, the covered type.  Usually this value is
+        dns.rdatatype.NONE, but if the rdtype is dns.rdatatype.SIG or
+        dns.rdatatype.RRSIG, then the covers value will be the rdata
+        type the SIG/RRSIG covers.  The library treats the SIG and RRSIG
+        types as if they were a family of
+        types, e.g. RRSIG(A), RRSIG(NS), RRSIG(SOA).  This makes RRSIGs much
+        easier to work with than if RRSIGs covering different rdata
+        types were aggregated into a single RRSIG rdataset.
+
+        *create*, a ``bool``.  If True, create the rdataset if it is not found.
+
+        Returns a ``dns.rdataset.Rdataset`` or ``None``.
+        """
+
+        try:
+            rds = self.find_rdataset(rdclass, rdtype, covers, create)
+        except KeyError:
+            rds = None
+        return rds
+
+    def delete_rdataset(
+        self,
+        rdclass: dns.rdataclass.RdataClass,
+        rdtype: dns.rdatatype.RdataType,
+        covers: dns.rdatatype.RdataType = dns.rdatatype.NONE,
+    ) -> None:
+        """Delete the rdataset matching the specified properties in the
+        current node.
+
+        If a matching rdataset does not exist, it is not an error.
+
+        *rdclass*, an ``int``, the class of the rdataset.
+
+        *rdtype*, an ``int``, the type of the rdataset.
+
+        *covers*, an ``int``, the covered type.
+        """
+
+        rds = self.get_rdataset(rdclass, rdtype, covers)
+        if rds is not None:
+            self.rdatasets.remove(rds)
+
+    def replace_rdataset(self, replacement: dns.rdataset.Rdataset) -> None:
+        """Replace an rdataset.
+
+        It is not an error if there is no rdataset matching *replacement*.
+
+        Ownership of the *replacement* object is transferred to the node;
+        in other words, this method does not store a copy of *replacement*
+        at the node, it stores *replacement* itself.
+
+        *replacement*, a ``dns.rdataset.Rdataset``.
+
+        Raises ``ValueError`` if *replacement* is not a
+        ``dns.rdataset.Rdataset``.
+        """
+
+        if not isinstance(replacement, dns.rdataset.Rdataset):
+            raise ValueError("replacement is not an rdataset")
+        if isinstance(replacement, dns.rrset.RRset):
+            # RRsets are not good replacements as the match() method
+            # is not compatible.
+            replacement = replacement.to_rdataset()
+        self.delete_rdataset(
+            replacement.rdclass, replacement.rdtype, replacement.covers
+        )
+        self._append_rdataset(replacement)
+
+    def classify(self) -> NodeKind:
+        """Classify a node.
+
+        A node which contains a CNAME or RRSIG(CNAME) is a
+        ``NodeKind.CNAME`` node.
+
+        A node which contains only "neutral" types, i.e. types allowed to
+        co-exist with a CNAME, is a ``NodeKind.NEUTRAL`` node.  The neutral
+        types are NSEC, NSEC3, KEY, and their associated RRSIGS.  An empty node
+        is also considered neutral.
+
+        A node which contains some rdataset which is not a CNAME, RRSIG(CNAME),
+        or a neutral type is a a ``NodeKind.REGULAR`` node.  Regular nodes are
+        also commonly referred to as "other data".
+        """
+        for rdataset in self.rdatasets:
+            kind = NodeKind.classify(rdataset.rdtype, rdataset.covers)
+            if kind != NodeKind.NEUTRAL:
+                return kind
+        return NodeKind.NEUTRAL
+
+    def is_immutable(self) -> bool:
+        return False
+
+
+@dns.immutable.immutable
+class ImmutableNode(Node):
+    def __init__(self, node):
+        super().__init__()
+        self.rdatasets = tuple(
+            [dns.rdataset.ImmutableRdataset(rds) for rds in node.rdatasets]
+        )
+
+    def find_rdataset(
+        self,
+        rdclass: dns.rdataclass.RdataClass,
+        rdtype: dns.rdatatype.RdataType,
+        covers: dns.rdatatype.RdataType = dns.rdatatype.NONE,
+        create: bool = False,
+    ) -> dns.rdataset.Rdataset:
+        if create:
+            raise TypeError("immutable")
+        return super().find_rdataset(rdclass, rdtype, covers, False)
+
+    def get_rdataset(
+        self,
+        rdclass: dns.rdataclass.RdataClass,
+        rdtype: dns.rdatatype.RdataType,
+        covers: dns.rdatatype.RdataType = dns.rdatatype.NONE,
+        create: bool = False,
+    ) -> Optional[dns.rdataset.Rdataset]:
+        if create:
+            raise TypeError("immutable")
+        return super().get_rdataset(rdclass, rdtype, covers, False)
+
+    def delete_rdataset(
+        self,
+        rdclass: dns.rdataclass.RdataClass,
+        rdtype: dns.rdatatype.RdataType,
+        covers: dns.rdatatype.RdataType = dns.rdatatype.NONE,
+    ) -> None:
+        raise TypeError("immutable")
+
+    def replace_rdataset(self, replacement: dns.rdataset.Rdataset) -> None:
+        raise TypeError("immutable")
+
+    def is_immutable(self) -> bool:
+        return True
diff --git a/.venv/lib/python3.12/site-packages/dns/opcode.py b/.venv/lib/python3.12/site-packages/dns/opcode.py
new file mode 100644
index 00000000..78b43d2c
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/dns/opcode.py
@@ -0,0 +1,117 @@
+# Copyright (C) Dnspython Contributors, see LICENSE for text of ISC license
+
+# Copyright (C) 2001-2017 Nominum, Inc.
+#
+# Permission to use, copy, modify, and distribute this software and its
+# documentation for any purpose with or without fee is hereby granted,
+# provided that the above copyright notice and this permission notice
+# appear in all copies.
+#
+# THE SOFTWARE IS PROVIDED "AS IS" AND NOMINUM DISCLAIMS ALL WARRANTIES
+# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL NOMINUM BE LIABLE FOR
+# ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+# ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT
+# OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+
+"""DNS Opcodes."""
+
+import dns.enum
+import dns.exception
+
+
+class Opcode(dns.enum.IntEnum):
+    #: Query
+    QUERY = 0
+    #: Inverse Query (historical)
+    IQUERY = 1
+    #: Server Status (unspecified and unimplemented anywhere)
+    STATUS = 2
+    #: Notify
+    NOTIFY = 4
+    #: Dynamic Update
+    UPDATE = 5
+
+    @classmethod
+    def _maximum(cls):
+        return 15
+
+    @classmethod
+    def _unknown_exception_class(cls):
+        return UnknownOpcode
+
+
+class UnknownOpcode(dns.exception.DNSException):
+    """An DNS opcode is unknown."""
+
+
+def from_text(text: str) -> Opcode:
+    """Convert text into an opcode.
+
+    *text*, a ``str``, the textual opcode
+
+    Raises ``dns.opcode.UnknownOpcode`` if the opcode is unknown.
+
+    Returns an ``int``.
+    """
+
+    return Opcode.from_text(text)
+
+
+def from_flags(flags: int) -> Opcode:
+    """Extract an opcode from DNS message flags.
+
+    *flags*, an ``int``, the DNS flags.
+
+    Returns an ``int``.
+    """
+
+    return Opcode((flags & 0x7800) >> 11)
+
+
+def to_flags(value: Opcode) -> int:
+    """Convert an opcode to a value suitable for ORing into DNS message
+    flags.
+
+    *value*, an ``int``, the DNS opcode value.
+
+    Returns an ``int``.
+    """
+
+    return (value << 11) & 0x7800
+
+
+def to_text(value: Opcode) -> str:
+    """Convert an opcode to text.
+
+    *value*, an ``int`` the opcode value,
+
+    Raises ``dns.opcode.UnknownOpcode`` if the opcode is unknown.
+
+    Returns a ``str``.
+    """
+
+    return Opcode.to_text(value)
+
+
+def is_update(flags: int) -> bool:
+    """Is the opcode in flags UPDATE?
+
+    *flags*, an ``int``, the DNS message flags.
+
+    Returns a ``bool``.
+    """
+
+    return from_flags(flags) == Opcode.UPDATE
+
+
+### BEGIN generated Opcode constants
+
+QUERY = Opcode.QUERY
+IQUERY = Opcode.IQUERY
+STATUS = Opcode.STATUS
+NOTIFY = Opcode.NOTIFY
+UPDATE = Opcode.UPDATE
+
+### END generated Opcode constants
diff --git a/.venv/lib/python3.12/site-packages/dns/py.typed b/.venv/lib/python3.12/site-packages/dns/py.typed
new file mode 100644
index 00000000..e69de29b
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/dns/py.typed
diff --git a/.venv/lib/python3.12/site-packages/dns/query.py b/.venv/lib/python3.12/site-packages/dns/query.py
new file mode 100644
index 00000000..0d8a977a
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/dns/query.py
@@ -0,0 +1,1665 @@
+# Copyright (C) Dnspython Contributors, see LICENSE for text of ISC license
+
+# Copyright (C) 2003-2017 Nominum, Inc.
+#
+# Permission to use, copy, modify, and distribute this software and its
+# documentation for any purpose with or without fee is hereby granted,
+# provided that the above copyright notice and this permission notice
+# appear in all copies.
+#
+# THE SOFTWARE IS PROVIDED "AS IS" AND NOMINUM DISCLAIMS ALL WARRANTIES
+# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL NOMINUM BE LIABLE FOR
+# ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+# ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT
+# OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+
+"""Talk to a DNS server."""
+
+import base64
+import contextlib
+import enum
+import errno
+import os
+import os.path
+import random
+import selectors
+import socket
+import struct
+import time
+import urllib.parse
+from typing import Any, Dict, Optional, Tuple, Union, cast
+
+import dns._features
+import dns.exception
+import dns.inet
+import dns.message
+import dns.name
+import dns.quic
+import dns.rcode
+import dns.rdataclass
+import dns.rdatatype
+import dns.serial
+import dns.transaction
+import dns.tsig
+import dns.xfr
+
+
+def _remaining(expiration):
+    if expiration is None:
+        return None
+    timeout = expiration - time.time()
+    if timeout <= 0.0:
+        raise dns.exception.Timeout
+    return timeout
+
+
+def _expiration_for_this_attempt(timeout, expiration):
+    if expiration is None:
+        return None
+    return min(time.time() + timeout, expiration)
+
+
+_have_httpx = dns._features.have("doh")
+if _have_httpx:
+    import httpcore._backends.sync
+    import httpx
+
+    _CoreNetworkBackend = httpcore.NetworkBackend
+    _CoreSyncStream = httpcore._backends.sync.SyncStream
+
+    class _NetworkBackend(_CoreNetworkBackend):
+        def __init__(self, resolver, local_port, bootstrap_address, family):
+            super().__init__()
+            self._local_port = local_port
+            self._resolver = resolver
+            self._bootstrap_address = bootstrap_address
+            self._family = family
+
+        def connect_tcp(
+            self, host, port, timeout, local_address, socket_options=None
+        ):  # pylint: disable=signature-differs
+            addresses = []
+            _, expiration = _compute_times(timeout)
+            if dns.inet.is_address(host):
+                addresses.append(host)
+            elif self._bootstrap_address is not None:
+                addresses.append(self._bootstrap_address)
+            else:
+                timeout = _remaining(expiration)
+                family = self._family
+                if local_address:
+                    family = dns.inet.af_for_address(local_address)
+                answers = self._resolver.resolve_name(
+                    host, family=family, lifetime=timeout
+                )
+                addresses = answers.addresses()
+            for address in addresses:
+                af = dns.inet.af_for_address(address)
+                if local_address is not None or self._local_port != 0:
+                    source = dns.inet.low_level_address_tuple(
+                        (local_address, self._local_port), af
+                    )
+                else:
+                    source = None
+                sock = _make_socket(af, socket.SOCK_STREAM, source)
+                attempt_expiration = _expiration_for_this_attempt(2.0, expiration)
+                try:
+                    _connect(
+                        sock,
+                        dns.inet.low_level_address_tuple((address, port), af),
+                        attempt_expiration,
+                    )
+                    return _CoreSyncStream(sock)
+                except Exception:
+                    pass
+            raise httpcore.ConnectError
+
+        def connect_unix_socket(
+            self, path, timeout, socket_options=None
+        ):  # pylint: disable=signature-differs
+            raise NotImplementedError
+
+    class _HTTPTransport(httpx.HTTPTransport):
+        def __init__(
+            self,
+            *args,
+            local_port=0,
+            bootstrap_address=None,
+            resolver=None,
+            family=socket.AF_UNSPEC,
+            **kwargs,
+        ):
+            if resolver is None and bootstrap_address is None:
+                # pylint: disable=import-outside-toplevel,redefined-outer-name
+                import dns.resolver
+
+                resolver = dns.resolver.Resolver()
+            super().__init__(*args, **kwargs)
+            self._pool._network_backend = _NetworkBackend(
+                resolver, local_port, bootstrap_address, family
+            )
+
+else:
+
+    class _HTTPTransport:  # type: ignore
+        def connect_tcp(self, host, port, timeout, local_address):
+            raise NotImplementedError
+
+
+have_doh = _have_httpx
+
+try:
+    import ssl
+except ImportError:  # pragma: no cover
+
+    class ssl:  # type: ignore
+        CERT_NONE = 0
+
+        class WantReadException(Exception):
+            pass
+
+        class WantWriteException(Exception):
+            pass
+
+        class SSLContext:
+            pass
+
+        class SSLSocket:
+            pass
+
+        @classmethod
+        def create_default_context(cls, *args, **kwargs):
+            raise Exception("no ssl support")  # pylint: disable=broad-exception-raised
+
+
+# Function used to create a socket.  Can be overridden if needed in special
+# situations.
+socket_factory = socket.socket
+
+
+class UnexpectedSource(dns.exception.DNSException):
+    """A DNS query response came from an unexpected address or port."""
+
+
+class BadResponse(dns.exception.FormError):
+    """A DNS query response does not respond to the question asked."""
+
+
+class NoDOH(dns.exception.DNSException):
+    """DNS over HTTPS (DOH) was requested but the httpx module is not
+    available."""
+
+
+class NoDOQ(dns.exception.DNSException):
+    """DNS over QUIC (DOQ) was requested but the aioquic module is not
+    available."""
+
+
+# for backwards compatibility
+TransferError = dns.xfr.TransferError
+
+
+def _compute_times(timeout):
+    now = time.time()
+    if timeout is None:
+        return (now, None)
+    else:
+        return (now, now + timeout)
+
+
+def _wait_for(fd, readable, writable, _, expiration):
+    # Use the selected selector class to wait for any of the specified
+    # events.  An "expiration" absolute time is converted into a relative
+    # timeout.
+    #
+    # The unused parameter is 'error', which is always set when
+    # selecting for read or write, and we have no error-only selects.
+
+    if readable and isinstance(fd, ssl.SSLSocket) and fd.pending() > 0:
+        return True
+    sel = selectors.DefaultSelector()
+    events = 0
+    if readable:
+        events |= selectors.EVENT_READ
+    if writable:
+        events |= selectors.EVENT_WRITE
+    if events:
+        sel.register(fd, events)
+    if expiration is None:
+        timeout = None
+    else:
+        timeout = expiration - time.time()
+        if timeout <= 0.0:
+            raise dns.exception.Timeout
+    if not sel.select(timeout):
+        raise dns.exception.Timeout
+
+
+def _wait_for_readable(s, expiration):
+    _wait_for(s, True, False, True, expiration)
+
+
+def _wait_for_writable(s, expiration):
+    _wait_for(s, False, True, True, expiration)
+
+
+def _addresses_equal(af, a1, a2):
+    # Convert the first value of the tuple, which is a textual format
+    # address into binary form, so that we are not confused by different
+    # textual representations of the same address
+    try:
+        n1 = dns.inet.inet_pton(af, a1[0])
+        n2 = dns.inet.inet_pton(af, a2[0])
+    except dns.exception.SyntaxError:
+        return False
+    return n1 == n2 and a1[1:] == a2[1:]
+
+
+def _matches_destination(af, from_address, destination, ignore_unexpected):
+    # Check that from_address is appropriate for a response to a query
+    # sent to destination.
+    if not destination:
+        return True
+    if _addresses_equal(af, from_address, destination) or (
+        dns.inet.is_multicast(destination[0]) and from_address[1:] == destination[1:]
+    ):
+        return True
+    elif ignore_unexpected:
+        return False
+    raise UnexpectedSource(
+        f"got a response from {from_address} instead of " f"{destination}"
+    )
+
+
+def _destination_and_source(
+    where, port, source, source_port, where_must_be_address=True
+):
+    # Apply defaults and compute destination and source tuples
+    # suitable for use in connect(), sendto(), or bind().
+    af = None
+    destination = None
+    try:
+        af = dns.inet.af_for_address(where)
+        destination = where
+    except Exception:
+        if where_must_be_address:
+            raise
+        # URLs are ok so eat the exception
+    if source:
+        saf = dns.inet.af_for_address(source)
+        if af:
+            # We know the destination af, so source had better agree!
+            if saf != af:
+                raise ValueError(
+                    "different address families for source and destination"
+                )
+        else:
+            # We didn't know the destination af, but we know the source,
+            # so that's our af.
+            af = saf
+    if source_port and not source:
+        # Caller has specified a source_port but not an address, so we
+        # need to return a source, and we need to use the appropriate
+        # wildcard address as the address.
+        try:
+            source = dns.inet.any_for_af(af)
+        except Exception:
+            # we catch this and raise ValueError for backwards compatibility
+            raise ValueError("source_port specified but address family is unknown")
+    # Convert high-level (address, port) tuples into low-level address
+    # tuples.
+    if destination:
+        destination = dns.inet.low_level_address_tuple((destination, port), af)
+    if source:
+        source = dns.inet.low_level_address_tuple((source, source_port), af)
+    return (af, destination, source)
+
+
+def _make_socket(af, type, source, ssl_context=None, server_hostname=None):
+    s = socket_factory(af, type)
+    try:
+        s.setblocking(False)
+        if source is not None:
+            s.bind(source)
+        if ssl_context:
+            # LGTM gets a false positive here, as our default context is OK
+            return ssl_context.wrap_socket(
+                s,
+                do_handshake_on_connect=False,  # lgtm[py/insecure-protocol]
+                server_hostname=server_hostname,
+            )
+        else:
+            return s
+    except Exception:
+        s.close()
+        raise
+
+
+def _maybe_get_resolver(
+    resolver: Optional["dns.resolver.Resolver"],
+) -> "dns.resolver.Resolver":
+    # We need a separate method for this to avoid overriding the global
+    # variable "dns" with the as-yet undefined local variable "dns"
+    # in https().
+    if resolver is None:
+        # pylint: disable=import-outside-toplevel,redefined-outer-name
+        import dns.resolver
+
+        resolver = dns.resolver.Resolver()
+    return resolver
+
+
+class HTTPVersion(enum.IntEnum):
+    """Which version of HTTP should be used?
+
+    DEFAULT will select the first version from the list [2, 1.1, 3] that
+    is available.
+    """
+
+    DEFAULT = 0
+    HTTP_1 = 1
+    H1 = 1
+    HTTP_2 = 2
+    H2 = 2
+    HTTP_3 = 3
+    H3 = 3
+
+
+def https(
+    q: dns.message.Message,
+    where: str,
+    timeout: Optional[float] = None,
+    port: int = 443,
+    source: Optional[str] = None,
+    source_port: int = 0,
+    one_rr_per_rrset: bool = False,
+    ignore_trailing: bool = False,
+    session: Optional[Any] = None,
+    path: str = "/dns-query",
+    post: bool = True,
+    bootstrap_address: Optional[str] = None,
+    verify: Union[bool, str] = True,
+    resolver: Optional["dns.resolver.Resolver"] = None,
+    family: int = socket.AF_UNSPEC,
+    http_version: HTTPVersion = HTTPVersion.DEFAULT,
+) -> dns.message.Message:
+    """Return the response obtained after sending a query via DNS-over-HTTPS.
+
+    *q*, a ``dns.message.Message``, the query to send.
+
+    *where*, a ``str``, the nameserver IP address or the full URL. If an IP address is
+    given, the URL will be constructed using the following schema:
+    https://<IP-address>:<port>/<path>.
+
+    *timeout*, a ``float`` or ``None``, the number of seconds to wait before the query
+    times out. If ``None``, the default, wait forever.
+
+    *port*, a ``int``, the port to send the query to. The default is 443.
+
+    *source*, a ``str`` containing an IPv4 or IPv6 address, specifying the source
+    address.  The default is the wildcard address.
+
+    *source_port*, an ``int``, the port from which to send the message. The default is
+    0.
+
+    *one_rr_per_rrset*, a ``bool``. If ``True``, put each RR into its own RRset.
+
+    *ignore_trailing*, a ``bool``. If ``True``, ignore trailing junk at end of the
+    received message.
+
+    *session*, an ``httpx.Client``.  If provided, the client session to use to send the
+    queries.
+
+    *path*, a ``str``. If *where* is an IP address, then *path* will be used to
+    construct the URL to send the DNS query to.
+
+    *post*, a ``bool``. If ``True``, the default, POST method will be used.
+
+    *bootstrap_address*, a ``str``, the IP address to use to bypass resolution.
+
+    *verify*, a ``bool`` or ``str``.  If a ``True``, then TLS certificate verification
+    of the server is done using the default CA bundle; if ``False``, then no
+    verification is done; if a `str` then it specifies the path to a certificate file or
+    directory which will be used for verification.
+
+    *resolver*, a ``dns.resolver.Resolver`` or ``None``, the resolver to use for
+    resolution of hostnames in URLs.  If not specified, a new resolver with a default
+    configuration will be used; note this is *not* the default resolver as that resolver
+    might have been configured to use DoH causing a chicken-and-egg problem.  This
+    parameter only has an effect if the HTTP library is httpx.
+
+    *family*, an ``int``, the address family.  If socket.AF_UNSPEC (the default), both A
+    and AAAA records will be retrieved.
+
+    *http_version*, a ``dns.query.HTTPVersion``, indicating which HTTP version to use.
+
+    Returns a ``dns.message.Message``.
+    """
+
+    (af, _, the_source) = _destination_and_source(
+        where, port, source, source_port, False
+    )
+    if af is not None and dns.inet.is_address(where):
+        if af == socket.AF_INET:
+            url = f"https://{where}:{port}{path}"
+        elif af == socket.AF_INET6:
+            url = f"https://[{where}]:{port}{path}"
+    else:
+        url = where
+
+    extensions = {}
+    if bootstrap_address is None:
+        # pylint: disable=possibly-used-before-assignment
+        parsed = urllib.parse.urlparse(url)
+        if parsed.hostname is None:
+            raise ValueError("no hostname in URL")
+        if dns.inet.is_address(parsed.hostname):
+            bootstrap_address = parsed.hostname
+            extensions["sni_hostname"] = parsed.hostname
+        if parsed.port is not None:
+            port = parsed.port
+
+    if http_version == HTTPVersion.H3 or (
+        http_version == HTTPVersion.DEFAULT and not have_doh
+    ):
+        if bootstrap_address is None:
+            resolver = _maybe_get_resolver(resolver)
+            assert parsed.hostname is not None  # for mypy
+            answers = resolver.resolve_name(parsed.hostname, family)
+            bootstrap_address = random.choice(list(answers.addresses()))
+        return _http3(
+            q,
+            bootstrap_address,
+            url,
+            timeout,
+            port,
+            source,
+            source_port,
+            one_rr_per_rrset,
+            ignore_trailing,
+            verify=verify,
+            post=post,
+        )
+
+    if not have_doh:
+        raise NoDOH  # pragma: no cover
+    if session and not isinstance(session, httpx.Client):
+        raise ValueError("session parameter must be an httpx.Client")
+
+    wire = q.to_wire()
+    headers = {"accept": "application/dns-message"}
+
+    h1 = http_version in (HTTPVersion.H1, HTTPVersion.DEFAULT)
+    h2 = http_version in (HTTPVersion.H2, HTTPVersion.DEFAULT)
+
+    # set source port and source address
+
+    if the_source is None:
+        local_address = None
+        local_port = 0
+    else:
+        local_address = the_source[0]
+        local_port = the_source[1]
+
+    if session:
+        cm: contextlib.AbstractContextManager = contextlib.nullcontext(session)
+    else:
+        transport = _HTTPTransport(
+            local_address=local_address,
+            http1=h1,
+            http2=h2,
+            verify=verify,
+            local_port=local_port,
+            bootstrap_address=bootstrap_address,
+            resolver=resolver,
+            family=family,
+        )
+
+        cm = httpx.Client(http1=h1, http2=h2, verify=verify, transport=transport)
+    with cm as session:
+        # see https://tools.ietf.org/html/rfc8484#section-4.1.1 for DoH
+        # GET and POST examples
+        if post:
+            headers.update(
+                {
+                    "content-type": "application/dns-message",
+                    "content-length": str(len(wire)),
+                }
+            )
+            response = session.post(
+                url,
+                headers=headers,
+                content=wire,
+                timeout=timeout,
+                extensions=extensions,
+            )
+        else:
+            wire = base64.urlsafe_b64encode(wire).rstrip(b"=")
+            twire = wire.decode()  # httpx does a repr() if we give it bytes
+            response = session.get(
+                url,
+                headers=headers,
+                timeout=timeout,
+                params={"dns": twire},
+                extensions=extensions,
+            )
+
+    # see https://tools.ietf.org/html/rfc8484#section-4.2.1 for info about DoH
+    # status codes
+    if response.status_code < 200 or response.status_code > 299:
+        raise ValueError(
+            f"{where} responded with status code {response.status_code}"
+            f"\nResponse body: {response.content}"
+        )
+    r = dns.message.from_wire(
+        response.content,
+        keyring=q.keyring,
+        request_mac=q.request_mac,
+        one_rr_per_rrset=one_rr_per_rrset,
+        ignore_trailing=ignore_trailing,
+    )
+    r.time = response.elapsed.total_seconds()
+    if not q.is_response(r):
+        raise BadResponse
+    return r
+
+
+def _find_header(headers: dns.quic.Headers, name: bytes) -> bytes:
+    if headers is None:
+        raise KeyError
+    for header, value in headers:
+        if header == name:
+            return value
+    raise KeyError
+
+
+def _check_status(headers: dns.quic.Headers, peer: str, wire: bytes) -> None:
+    value = _find_header(headers, b":status")
+    if value is None:
+        raise SyntaxError("no :status header in response")
+    status = int(value)
+    if status < 0:
+        raise SyntaxError("status is negative")
+    if status < 200 or status > 299:
+        error = ""
+        if len(wire) > 0:
+            try:
+                error = ": " + wire.decode()
+            except Exception:
+                pass
+        raise ValueError(f"{peer} responded with status code {status}{error}")
+
+
+def _http3(
+    q: dns.message.Message,
+    where: str,
+    url: str,
+    timeout: Optional[float] = None,
+    port: int = 853,
+    source: Optional[str] = None,
+    source_port: int = 0,
+    one_rr_per_rrset: bool = False,
+    ignore_trailing: bool = False,
+    verify: Union[bool, str] = True,
+    hostname: Optional[str] = None,
+    post: bool = True,
+) -> dns.message.Message:
+    if not dns.quic.have_quic:
+        raise NoDOH("DNS-over-HTTP3 is not available.")  # pragma: no cover
+
+    url_parts = urllib.parse.urlparse(url)
+    hostname = url_parts.hostname
+    if url_parts.port is not None:
+        port = url_parts.port
+
+    q.id = 0
+    wire = q.to_wire()
+    manager = dns.quic.SyncQuicManager(
+        verify_mode=verify, server_name=hostname, h3=True
+    )
+
+    with manager:
+        connection = manager.connect(where, port, source, source_port)
+        (start, expiration) = _compute_times(timeout)
+        with connection.make_stream(timeout) as stream:
+            stream.send_h3(url, wire, post)
+            wire = stream.receive(_remaining(expiration))
+            _check_status(stream.headers(), where, wire)
+        finish = time.time()
+    r = dns.message.from_wire(
+        wire,
+        keyring=q.keyring,
+        request_mac=q.request_mac,
+        one_rr_per_rrset=one_rr_per_rrset,
+        ignore_trailing=ignore_trailing,
+    )
+    r.time = max(finish - start, 0.0)
+    if not q.is_response(r):
+        raise BadResponse
+    return r
+
+
+def _udp_recv(sock, max_size, expiration):
+    """Reads a datagram from the socket.
+    A Timeout exception will be raised if the operation is not completed
+    by the expiration time.
+    """
+    while True:
+        try:
+            return sock.recvfrom(max_size)
+        except BlockingIOError:
+            _wait_for_readable(sock, expiration)
+
+
+def _udp_send(sock, data, destination, expiration):
+    """Sends the specified datagram to destination over the socket.
+    A Timeout exception will be raised if the operation is not completed
+    by the expiration time.
+    """
+    while True:
+        try:
+            if destination:
+                return sock.sendto(data, destination)
+            else:
+                return sock.send(data)
+        except BlockingIOError:  # pragma: no cover
+            _wait_for_writable(sock, expiration)
+
+
+def send_udp(
+    sock: Any,
+    what: Union[dns.message.Message, bytes],
+    destination: Any,
+    expiration: Optional[float] = None,
+) -> Tuple[int, float]:
+    """Send a DNS message to the specified UDP socket.
+
+    *sock*, a ``socket``.
+
+    *what*, a ``bytes`` or ``dns.message.Message``, the message to send.
+
+    *destination*, a destination tuple appropriate for the address family
+    of the socket, specifying where to send the query.
+
+    *expiration*, a ``float`` or ``None``, the absolute time at which
+    a timeout exception should be raised.  If ``None``, no timeout will
+    occur.
+
+    Returns an ``(int, float)`` tuple of bytes sent and the sent time.
+    """
+
+    if isinstance(what, dns.message.Message):
+        what = what.to_wire()
+    sent_time = time.time()
+    n = _udp_send(sock, what, destination, expiration)
+    return (n, sent_time)
+
+
+def receive_udp(
+    sock: Any,
+    destination: Optional[Any] = None,
+    expiration: Optional[float] = None,
+    ignore_unexpected: bool = False,
+    one_rr_per_rrset: bool = False,
+    keyring: Optional[Dict[dns.name.Name, dns.tsig.Key]] = None,
+    request_mac: Optional[bytes] = b"",
+    ignore_trailing: bool = False,
+    raise_on_truncation: bool = False,
+    ignore_errors: bool = False,
+    query: Optional[dns.message.Message] = None,
+) -> Any:
+    """Read a DNS message from a UDP socket.
+
+    *sock*, a ``socket``.
+
+    *destination*, a destination tuple appropriate for the address family
+    of the socket, specifying where the message is expected to arrive from.
+    When receiving a response, this would be where the associated query was
+    sent.
+
+    *expiration*, a ``float`` or ``None``, the absolute time at which
+    a timeout exception should be raised.  If ``None``, no timeout will
+    occur.
+
+    *ignore_unexpected*, a ``bool``.  If ``True``, ignore responses from
+    unexpected sources.
+
+    *one_rr_per_rrset*, a ``bool``.  If ``True``, put each RR into its own
+    RRset.
+
+    *keyring*, a ``dict``, the keyring to use for TSIG.
+
+    *request_mac*, a ``bytes`` or ``None``, the MAC of the request (for TSIG).
+
+    *ignore_trailing*, a ``bool``.  If ``True``, ignore trailing
+    junk at end of the received message.
+
+    *raise_on_truncation*, a ``bool``.  If ``True``, raise an exception if
+    the TC bit is set.
+
+    Raises if the message is malformed, if network errors occur, of if
+    there is a timeout.
+
+    If *destination* is not ``None``, returns a ``(dns.message.Message, float)``
+    tuple of the received message and the received time.
+
+    If *destination* is ``None``, returns a
+    ``(dns.message.Message, float, tuple)``
+    tuple of the received message, the received time, and the address where
+    the message arrived from.
+
+    *ignore_errors*, a ``bool``.  If various format errors or response
+    mismatches occur, ignore them and keep listening for a valid response.
+    The default is ``False``.
+
+    *query*, a ``dns.message.Message`` or ``None``.  If not ``None`` and
+    *ignore_errors* is ``True``, check that the received message is a response
+    to this query, and if not keep listening for a valid response.
+    """
+
+    wire = b""
+    while True:
+        (wire, from_address) = _udp_recv(sock, 65535, expiration)
+        if not _matches_destination(
+            sock.family, from_address, destination, ignore_unexpected
+        ):
+            continue
+        received_time = time.time()
+        try:
+            r = dns.message.from_wire(
+                wire,
+                keyring=keyring,
+                request_mac=request_mac,
+                one_rr_per_rrset=one_rr_per_rrset,
+                ignore_trailing=ignore_trailing,
+                raise_on_truncation=raise_on_truncation,
+            )
+        except dns.message.Truncated as e:
+            # If we got Truncated and not FORMERR, we at least got the header with TC
+            # set, and very likely the question section, so we'll re-raise if the
+            # message seems to be a response as we need to know when truncation happens.
+            # We need to check that it seems to be a response as we don't want a random
+            # injected message with TC set to cause us to bail out.
+            if (
+                ignore_errors
+                and query is not None
+                and not query.is_response(e.message())
+            ):
+                continue
+            else:
+                raise
+        except Exception:
+            if ignore_errors:
+                continue
+            else:
+                raise
+        if ignore_errors and query is not None and not query.is_response(r):
+            continue
+        if destination:
+            return (r, received_time)
+        else:
+            return (r, received_time, from_address)
+
+
+def udp(
+    q: dns.message.Message,
+    where: str,
+    timeout: Optional[float] = None,
+    port: int = 53,
+    source: Optional[str] = None,
+    source_port: int = 0,
+    ignore_unexpected: bool = False,
+    one_rr_per_rrset: bool = False,
+    ignore_trailing: bool = False,
+    raise_on_truncation: bool = False,
+    sock: Optional[Any] = None,
+    ignore_errors: bool = False,
+) -> dns.message.Message:
+    """Return the response obtained after sending a query via UDP.
+
+    *q*, a ``dns.message.Message``, the query to send
+
+    *where*, a ``str`` containing an IPv4 or IPv6 address,  where
+    to send the message.
+
+    *timeout*, a ``float`` or ``None``, the number of seconds to wait before the
+    query times out.  If ``None``, the default, wait forever.
+
+    *port*, an ``int``, the port send the message to.  The default is 53.
+
+    *source*, a ``str`` containing an IPv4 or IPv6 address, specifying
+    the source address.  The default is the wildcard address.
+
+    *source_port*, an ``int``, the port from which to send the message.
+    The default is 0.
+
+    *ignore_unexpected*, a ``bool``.  If ``True``, ignore responses from
+    unexpected sources.
+
+    *one_rr_per_rrset*, a ``bool``.  If ``True``, put each RR into its own
+    RRset.
+
+    *ignore_trailing*, a ``bool``.  If ``True``, ignore trailing
+    junk at end of the received message.
+
+    *raise_on_truncation*, a ``bool``.  If ``True``, raise an exception if
+    the TC bit is set.
+
+    *sock*, a ``socket.socket``, or ``None``, the socket to use for the
+    query.  If ``None``, the default, a socket is created.  Note that
+    if a socket is provided, it must be a nonblocking datagram socket,
+    and the *source* and *source_port* are ignored.
+
+    *ignore_errors*, a ``bool``.  If various format errors or response
+    mismatches occur, ignore them and keep listening for a valid response.
+    The default is ``False``.
+
+    Returns a ``dns.message.Message``.
+    """
+
+    wire = q.to_wire()
+    (af, destination, source) = _destination_and_source(
+        where, port, source, source_port
+    )
+    (begin_time, expiration) = _compute_times(timeout)
+    if sock:
+        cm: contextlib.AbstractContextManager = contextlib.nullcontext(sock)
+    else:
+        cm = _make_socket(af, socket.SOCK_DGRAM, source)
+    with cm as s:
+        send_udp(s, wire, destination, expiration)
+        (r, received_time) = receive_udp(
+            s,
+            destination,
+            expiration,
+            ignore_unexpected,
+            one_rr_per_rrset,
+            q.keyring,
+            q.mac,
+            ignore_trailing,
+            raise_on_truncation,
+            ignore_errors,
+            q,
+        )
+        r.time = received_time - begin_time
+        # We don't need to check q.is_response() if we are in ignore_errors mode
+        # as receive_udp() will have checked it.
+        if not (ignore_errors or q.is_response(r)):
+            raise BadResponse
+        return r
+    assert (
+        False  # help mypy figure out we can't get here  lgtm[py/unreachable-statement]
+    )
+
+
+def udp_with_fallback(
+    q: dns.message.Message,
+    where: str,
+    timeout: Optional[float] = None,
+    port: int = 53,
+    source: Optional[str] = None,
+    source_port: int = 0,
+    ignore_unexpected: bool = False,
+    one_rr_per_rrset: bool = False,
+    ignore_trailing: bool = False,
+    udp_sock: Optional[Any] = None,
+    tcp_sock: Optional[Any] = None,
+    ignore_errors: bool = False,
+) -> Tuple[dns.message.Message, bool]:
+    """Return the response to the query, trying UDP first and falling back
+    to TCP if UDP results in a truncated response.
+
+    *q*, a ``dns.message.Message``, the query to send
+
+    *where*, a ``str`` containing an IPv4 or IPv6 address,  where to send the message.
+
+    *timeout*, a ``float`` or ``None``, the number of seconds to wait before the query
+    times out.  If ``None``, the default, wait forever.
+
+    *port*, an ``int``, the port send the message to.  The default is 53.
+
+    *source*, a ``str`` containing an IPv4 or IPv6 address, specifying the source
+    address.  The default is the wildcard address.
+
+    *source_port*, an ``int``, the port from which to send the message. The default is
+    0.
+
+    *ignore_unexpected*, a ``bool``.  If ``True``, ignore responses from unexpected
+    sources.
+
+    *one_rr_per_rrset*, a ``bool``.  If ``True``, put each RR into its own RRset.
+
+    *ignore_trailing*, a ``bool``.  If ``True``, ignore trailing junk at end of the
+    received message.
+
+    *udp_sock*, a ``socket.socket``, or ``None``, the socket to use for the UDP query.
+    If ``None``, the default, a socket is created.  Note that if a socket is provided,
+    it must be a nonblocking datagram socket, and the *source* and *source_port* are
+    ignored for the UDP query.
+
+    *tcp_sock*, a ``socket.socket``, or ``None``, the connected socket to use for the
+    TCP query.  If ``None``, the default, a socket is created.  Note that if a socket is
+    provided, it must be a nonblocking connected stream socket, and *where*, *source*
+    and *source_port* are ignored for the TCP query.
+
+    *ignore_errors*, a ``bool``.  If various format errors or response mismatches occur
+    while listening for UDP, ignore them and keep listening for a valid response. The
+    default is ``False``.
+
+    Returns a (``dns.message.Message``, tcp) tuple where tcp is ``True`` if and only if
+    TCP was used.
+    """
+    try:
+        response = udp(
+            q,
+            where,
+            timeout,
+            port,
+            source,
+            source_port,
+            ignore_unexpected,
+            one_rr_per_rrset,
+            ignore_trailing,
+            True,
+            udp_sock,
+            ignore_errors,
+        )
+        return (response, False)
+    except dns.message.Truncated:
+        response = tcp(
+            q,
+            where,
+            timeout,
+            port,
+            source,
+            source_port,
+            one_rr_per_rrset,
+            ignore_trailing,
+            tcp_sock,
+        )
+        return (response, True)
+
+
+def _net_read(sock, count, expiration):
+    """Read the specified number of bytes from sock.  Keep trying until we
+    either get the desired amount, or we hit EOF.
+    A Timeout exception will be raised if the operation is not completed
+    by the expiration time.
+    """
+    s = b""
+    while count > 0:
+        try:
+            n = sock.recv(count)
+            if n == b"":
+                raise EOFError("EOF")
+            count -= len(n)
+            s += n
+        except (BlockingIOError, ssl.SSLWantReadError):
+            _wait_for_readable(sock, expiration)
+        except ssl.SSLWantWriteError:  # pragma: no cover
+            _wait_for_writable(sock, expiration)
+    return s
+
+
+def _net_write(sock, data, expiration):
+    """Write the specified data to the socket.
+    A Timeout exception will be raised if the operation is not completed
+    by the expiration time.
+    """
+    current = 0
+    l = len(data)
+    while current < l:
+        try:
+            current += sock.send(data[current:])
+        except (BlockingIOError, ssl.SSLWantWriteError):
+            _wait_for_writable(sock, expiration)
+        except ssl.SSLWantReadError:  # pragma: no cover
+            _wait_for_readable(sock, expiration)
+
+
+def send_tcp(
+    sock: Any,
+    what: Union[dns.message.Message, bytes],
+    expiration: Optional[float] = None,
+) -> Tuple[int, float]:
+    """Send a DNS message to the specified TCP socket.
+
+    *sock*, a ``socket``.
+
+    *what*, a ``bytes`` or ``dns.message.Message``, the message to send.
+
+    *expiration*, a ``float`` or ``None``, the absolute time at which
+    a timeout exception should be raised.  If ``None``, no timeout will
+    occur.
+
+    Returns an ``(int, float)`` tuple of bytes sent and the sent time.
+    """
+
+    if isinstance(what, dns.message.Message):
+        tcpmsg = what.to_wire(prepend_length=True)
+    else:
+        # copying the wire into tcpmsg is inefficient, but lets us
+        # avoid writev() or doing a short write that would get pushed
+        # onto the net
+        tcpmsg = len(what).to_bytes(2, "big") + what
+    sent_time = time.time()
+    _net_write(sock, tcpmsg, expiration)
+    return (len(tcpmsg), sent_time)
+
+
+def receive_tcp(
+    sock: Any,
+    expiration: Optional[float] = None,
+    one_rr_per_rrset: bool = False,
+    keyring: Optional[Dict[dns.name.Name, dns.tsig.Key]] = None,
+    request_mac: Optional[bytes] = b"",
+    ignore_trailing: bool = False,
+) -> Tuple[dns.message.Message, float]:
+    """Read a DNS message from a TCP socket.
+
+    *sock*, a ``socket``.
+
+    *expiration*, a ``float`` or ``None``, the absolute time at which
+    a timeout exception should be raised.  If ``None``, no timeout will
+    occur.
+
+    *one_rr_per_rrset*, a ``bool``.  If ``True``, put each RR into its own
+    RRset.
+
+    *keyring*, a ``dict``, the keyring to use for TSIG.
+
+    *request_mac*, a ``bytes`` or ``None``, the MAC of the request (for TSIG).
+
+    *ignore_trailing*, a ``bool``.  If ``True``, ignore trailing
+    junk at end of the received message.
+
+    Raises if the message is malformed, if network errors occur, of if
+    there is a timeout.
+
+    Returns a ``(dns.message.Message, float)`` tuple of the received message
+    and the received time.
+    """
+
+    ldata = _net_read(sock, 2, expiration)
+    (l,) = struct.unpack("!H", ldata)
+    wire = _net_read(sock, l, expiration)
+    received_time = time.time()
+    r = dns.message.from_wire(
+        wire,
+        keyring=keyring,
+        request_mac=request_mac,
+        one_rr_per_rrset=one_rr_per_rrset,
+        ignore_trailing=ignore_trailing,
+    )
+    return (r, received_time)
+
+
+def _connect(s, address, expiration):
+    err = s.connect_ex(address)
+    if err == 0:
+        return
+    if err in (errno.EINPROGRESS, errno.EWOULDBLOCK, errno.EALREADY):
+        _wait_for_writable(s, expiration)
+        err = s.getsockopt(socket.SOL_SOCKET, socket.SO_ERROR)
+    if err != 0:
+        raise OSError(err, os.strerror(err))
+
+
+def tcp(
+    q: dns.message.Message,
+    where: str,
+    timeout: Optional[float] = None,
+    port: int = 53,
+    source: Optional[str] = None,
+    source_port: int = 0,
+    one_rr_per_rrset: bool = False,
+    ignore_trailing: bool = False,
+    sock: Optional[Any] = None,
+) -> dns.message.Message:
+    """Return the response obtained after sending a query via TCP.
+
+    *q*, a ``dns.message.Message``, the query to send
+
+    *where*, a ``str`` containing an IPv4 or IPv6 address, where
+    to send the message.
+
+    *timeout*, a ``float`` or ``None``, the number of seconds to wait before the
+    query times out.  If ``None``, the default, wait forever.
+
+    *port*, an ``int``, the port send the message to.  The default is 53.
+
+    *source*, a ``str`` containing an IPv4 or IPv6 address, specifying
+    the source address.  The default is the wildcard address.
+
+    *source_port*, an ``int``, the port from which to send the message.
+    The default is 0.
+
+    *one_rr_per_rrset*, a ``bool``.  If ``True``, put each RR into its own
+    RRset.
+
+    *ignore_trailing*, a ``bool``.  If ``True``, ignore trailing
+    junk at end of the received message.
+
+    *sock*, a ``socket.socket``, or ``None``, the connected socket to use for the
+    query.  If ``None``, the default, a socket is created.  Note that
+    if a socket is provided, it must be a nonblocking connected stream
+    socket, and *where*, *port*, *source* and *source_port* are ignored.
+
+    Returns a ``dns.message.Message``.
+    """
+
+    wire = q.to_wire()
+    (begin_time, expiration) = _compute_times(timeout)
+    if sock:
+        cm: contextlib.AbstractContextManager = contextlib.nullcontext(sock)
+    else:
+        (af, destination, source) = _destination_and_source(
+            where, port, source, source_port
+        )
+        cm = _make_socket(af, socket.SOCK_STREAM, source)
+    with cm as s:
+        if not sock:
+            # pylint: disable=possibly-used-before-assignment
+            _connect(s, destination, expiration)
+        send_tcp(s, wire, expiration)
+        (r, received_time) = receive_tcp(
+            s, expiration, one_rr_per_rrset, q.keyring, q.mac, ignore_trailing
+        )
+        r.time = received_time - begin_time
+        if not q.is_response(r):
+            raise BadResponse
+        return r
+    assert (
+        False  # help mypy figure out we can't get here  lgtm[py/unreachable-statement]
+    )
+
+
+def _tls_handshake(s, expiration):
+    while True:
+        try:
+            s.do_handshake()
+            return
+        except ssl.SSLWantReadError:
+            _wait_for_readable(s, expiration)
+        except ssl.SSLWantWriteError:  # pragma: no cover
+            _wait_for_writable(s, expiration)
+
+
+def _make_dot_ssl_context(
+    server_hostname: Optional[str], verify: Union[bool, str]
+) -> ssl.SSLContext:
+    cafile: Optional[str] = None
+    capath: Optional[str] = None
+    if isinstance(verify, str):
+        if os.path.isfile(verify):
+            cafile = verify
+        elif os.path.isdir(verify):
+            capath = verify
+        else:
+            raise ValueError("invalid verify string")
+    ssl_context = ssl.create_default_context(cafile=cafile, capath=capath)
+    ssl_context.minimum_version = ssl.TLSVersion.TLSv1_2
+    if server_hostname is None:
+        ssl_context.check_hostname = False
+    ssl_context.set_alpn_protocols(["dot"])
+    if verify is False:
+        ssl_context.verify_mode = ssl.CERT_NONE
+    return ssl_context
+
+
+def tls(
+    q: dns.message.Message,
+    where: str,
+    timeout: Optional[float] = None,
+    port: int = 853,
+    source: Optional[str] = None,
+    source_port: int = 0,
+    one_rr_per_rrset: bool = False,
+    ignore_trailing: bool = False,
+    sock: Optional[ssl.SSLSocket] = None,
+    ssl_context: Optional[ssl.SSLContext] = None,
+    server_hostname: Optional[str] = None,
+    verify: Union[bool, str] = True,
+) -> dns.message.Message:
+    """Return the response obtained after sending a query via TLS.
+
+    *q*, a ``dns.message.Message``, the query to send
+
+    *where*, a ``str`` containing an IPv4 or IPv6 address,  where
+    to send the message.
+
+    *timeout*, a ``float`` or ``None``, the number of seconds to wait before the
+    query times out.  If ``None``, the default, wait forever.
+
+    *port*, an ``int``, the port send the message to.  The default is 853.
+
+    *source*, a ``str`` containing an IPv4 or IPv6 address, specifying
+    the source address.  The default is the wildcard address.
+
+    *source_port*, an ``int``, the port from which to send the message.
+    The default is 0.
+
+    *one_rr_per_rrset*, a ``bool``.  If ``True``, put each RR into its own
+    RRset.
+
+    *ignore_trailing*, a ``bool``.  If ``True``, ignore trailing
+    junk at end of the received message.
+
+    *sock*, an ``ssl.SSLSocket``, or ``None``, the socket to use for
+    the query.  If ``None``, the default, a socket is created.  Note
+    that if a socket is provided, it must be a nonblocking connected
+    SSL stream socket, and *where*, *port*, *source*, *source_port*,
+    and *ssl_context* are ignored.
+
+    *ssl_context*, an ``ssl.SSLContext``, the context to use when establishing
+    a TLS connection. If ``None``, the default, creates one with the default
+    configuration.
+
+    *server_hostname*, a ``str`` containing the server's hostname.  The
+    default is ``None``, which means that no hostname is known, and if an
+    SSL context is created, hostname checking will be disabled.
+
+    *verify*, a ``bool`` or ``str``.  If a ``True``, then TLS certificate verification
+    of the server is done using the default CA bundle; if ``False``, then no
+    verification is done; if a `str` then it specifies the path to a certificate file or
+    directory which will be used for verification.
+
+    Returns a ``dns.message.Message``.
+
+    """
+
+    if sock:
+        #
+        # If a socket was provided, there's no special TLS handling needed.
+        #
+        return tcp(
+            q,
+            where,
+            timeout,
+            port,
+            source,
+            source_port,
+            one_rr_per_rrset,
+            ignore_trailing,
+            sock,
+        )
+
+    wire = q.to_wire()
+    (begin_time, expiration) = _compute_times(timeout)
+    (af, destination, source) = _destination_and_source(
+        where, port, source, source_port
+    )
+    if ssl_context is None and not sock:
+        ssl_context = _make_dot_ssl_context(server_hostname, verify)
+
+    with _make_socket(
+        af,
+        socket.SOCK_STREAM,
+        source,
+        ssl_context=ssl_context,
+        server_hostname=server_hostname,
+    ) as s:
+        _connect(s, destination, expiration)
+        _tls_handshake(s, expiration)
+        send_tcp(s, wire, expiration)
+        (r, received_time) = receive_tcp(
+            s, expiration, one_rr_per_rrset, q.keyring, q.mac, ignore_trailing
+        )
+        r.time = received_time - begin_time
+        if not q.is_response(r):
+            raise BadResponse
+        return r
+    assert (
+        False  # help mypy figure out we can't get here  lgtm[py/unreachable-statement]
+    )
+
+
+def quic(
+    q: dns.message.Message,
+    where: str,
+    timeout: Optional[float] = None,
+    port: int = 853,
+    source: Optional[str] = None,
+    source_port: int = 0,
+    one_rr_per_rrset: bool = False,
+    ignore_trailing: bool = False,
+    connection: Optional[dns.quic.SyncQuicConnection] = None,
+    verify: Union[bool, str] = True,
+    hostname: Optional[str] = None,
+    server_hostname: Optional[str] = None,
+) -> dns.message.Message:
+    """Return the response obtained after sending a query via DNS-over-QUIC.
+
+    *q*, a ``dns.message.Message``, the query to send.
+
+    *where*, a ``str``, the nameserver IP address.
+
+    *timeout*, a ``float`` or ``None``, the number of seconds to wait before the query
+    times out. If ``None``, the default, wait forever.
+
+    *port*, a ``int``, the port to send the query to. The default is 853.
+
+    *source*, a ``str`` containing an IPv4 or IPv6 address, specifying the source
+    address.  The default is the wildcard address.
+
+    *source_port*, an ``int``, the port from which to send the message. The default is
+    0.
+
+    *one_rr_per_rrset*, a ``bool``. If ``True``, put each RR into its own RRset.
+
+    *ignore_trailing*, a ``bool``. If ``True``, ignore trailing junk at end of the
+    received message.
+
+    *connection*, a ``dns.quic.SyncQuicConnection``.  If provided, the connection to use
+    to send the query.
+
+    *verify*, a ``bool`` or ``str``.  If a ``True``, then TLS certificate verification
+    of the server is done using the default CA bundle; if ``False``, then no
+    verification is done; if a `str` then it specifies the path to a certificate file or
+    directory which will be used for verification.
+
+    *hostname*, a ``str`` containing the server's hostname or ``None``.  The default is
+    ``None``, which means that no hostname is known, and if an SSL context is created,
+    hostname checking will be disabled.  This value is ignored if *url* is not
+    ``None``.
+
+    *server_hostname*, a ``str`` or ``None``.  This item is for backwards compatibility
+    only, and has the same meaning as *hostname*.
+
+    Returns a ``dns.message.Message``.
+    """
+
+    if not dns.quic.have_quic:
+        raise NoDOQ("DNS-over-QUIC is not available.")  # pragma: no cover
+
+    if server_hostname is not None and hostname is None:
+        hostname = server_hostname
+
+    q.id = 0
+    wire = q.to_wire()
+    the_connection: dns.quic.SyncQuicConnection
+    the_manager: dns.quic.SyncQuicManager
+    if connection:
+        manager: contextlib.AbstractContextManager = contextlib.nullcontext(None)
+        the_connection = connection
+    else:
+        manager = dns.quic.SyncQuicManager(verify_mode=verify, server_name=hostname)
+        the_manager = manager  # for type checking happiness
+
+    with manager:
+        if not connection:
+            the_connection = the_manager.connect(where, port, source, source_port)
+        (start, expiration) = _compute_times(timeout)
+        with the_connection.make_stream(timeout) as stream:
+            stream.send(wire, True)
+            wire = stream.receive(_remaining(expiration))
+        finish = time.time()
+    r = dns.message.from_wire(
+        wire,
+        keyring=q.keyring,
+        request_mac=q.request_mac,
+        one_rr_per_rrset=one_rr_per_rrset,
+        ignore_trailing=ignore_trailing,
+    )
+    r.time = max(finish - start, 0.0)
+    if not q.is_response(r):
+        raise BadResponse
+    return r
+
+
+class UDPMode(enum.IntEnum):
+    """How should UDP be used in an IXFR from :py:func:`inbound_xfr()`?
+
+    NEVER means "never use UDP; always use TCP"
+    TRY_FIRST means "try to use UDP but fall back to TCP if needed"
+    ONLY means "raise ``dns.xfr.UseTCP`` if trying UDP does not succeed"
+    """
+
+    NEVER = 0
+    TRY_FIRST = 1
+    ONLY = 2
+
+
+def _inbound_xfr(
+    txn_manager: dns.transaction.TransactionManager,
+    s: socket.socket,
+    query: dns.message.Message,
+    serial: Optional[int],
+    timeout: Optional[float],
+    expiration: float,
+) -> Any:
+    """Given a socket, does the zone transfer."""
+    rdtype = query.question[0].rdtype
+    is_ixfr = rdtype == dns.rdatatype.IXFR
+    origin = txn_manager.from_wire_origin()
+    wire = query.to_wire()
+    is_udp = s.type == socket.SOCK_DGRAM
+    if is_udp:
+        _udp_send(s, wire, None, expiration)
+    else:
+        tcpmsg = struct.pack("!H", len(wire)) + wire
+        _net_write(s, tcpmsg, expiration)
+    with dns.xfr.Inbound(txn_manager, rdtype, serial, is_udp) as inbound:
+        done = False
+        tsig_ctx = None
+        while not done:
+            (_, mexpiration) = _compute_times(timeout)
+            if mexpiration is None or (
+                expiration is not None and mexpiration > expiration
+            ):
+                mexpiration = expiration
+            if is_udp:
+                (rwire, _) = _udp_recv(s, 65535, mexpiration)
+            else:
+                ldata = _net_read(s, 2, mexpiration)
+                (l,) = struct.unpack("!H", ldata)
+                rwire = _net_read(s, l, mexpiration)
+            r = dns.message.from_wire(
+                rwire,
+                keyring=query.keyring,
+                request_mac=query.mac,
+                xfr=True,
+                origin=origin,
+                tsig_ctx=tsig_ctx,
+                multi=(not is_udp),
+                one_rr_per_rrset=is_ixfr,
+            )
+            done = inbound.process_message(r)
+            yield r
+            tsig_ctx = r.tsig_ctx
+        if query.keyring and not r.had_tsig:
+            raise dns.exception.FormError("missing TSIG")
+
+
+def xfr(
+    where: str,
+    zone: Union[dns.name.Name, str],
+    rdtype: Union[dns.rdatatype.RdataType, str] = dns.rdatatype.AXFR,
+    rdclass: Union[dns.rdataclass.RdataClass, str] = dns.rdataclass.IN,
+    timeout: Optional[float] = None,
+    port: int = 53,
+    keyring: Optional[Dict[dns.name.Name, dns.tsig.Key]] = None,
+    keyname: Optional[Union[dns.name.Name, str]] = None,
+    relativize: bool = True,
+    lifetime: Optional[float] = None,
+    source: Optional[str] = None,
+    source_port: int = 0,
+    serial: int = 0,
+    use_udp: bool = False,
+    keyalgorithm: Union[dns.name.Name, str] = dns.tsig.default_algorithm,
+) -> Any:
+    """Return a generator for the responses to a zone transfer.
+
+    *where*, a ``str`` containing an IPv4 or IPv6 address,  where
+    to send the message.
+
+    *zone*, a ``dns.name.Name`` or ``str``, the name of the zone to transfer.
+
+    *rdtype*, an ``int`` or ``str``, the type of zone transfer.  The
+    default is ``dns.rdatatype.AXFR``.  ``dns.rdatatype.IXFR`` can be
+    used to do an incremental transfer instead.
+
+    *rdclass*, an ``int`` or ``str``, the class of the zone transfer.
+    The default is ``dns.rdataclass.IN``.
+
+    *timeout*, a ``float``, the number of seconds to wait for each
+    response message.  If None, the default, wait forever.
+
+    *port*, an ``int``, the port send the message to.  The default is 53.
+
+    *keyring*, a ``dict``, the keyring to use for TSIG.
+
+    *keyname*, a ``dns.name.Name`` or ``str``, the name of the TSIG
+    key to use.
+
+    *relativize*, a ``bool``.  If ``True``, all names in the zone will be
+    relativized to the zone origin.  It is essential that the
+    relativize setting matches the one specified to
+    ``dns.zone.from_xfr()`` if using this generator to make a zone.
+
+    *lifetime*, a ``float``, the total number of seconds to spend
+    doing the transfer.  If ``None``, the default, then there is no
+    limit on the time the transfer may take.
+
+    *source*, a ``str`` containing an IPv4 or IPv6 address, specifying
+    the source address.  The default is the wildcard address.
+
+    *source_port*, an ``int``, the port from which to send the message.
+    The default is 0.
+
+    *serial*, an ``int``, the SOA serial number to use as the base for
+    an IXFR diff sequence (only meaningful if *rdtype* is
+    ``dns.rdatatype.IXFR``).
+
+    *use_udp*, a ``bool``.  If ``True``, use UDP (only meaningful for IXFR).
+
+    *keyalgorithm*, a ``dns.name.Name`` or ``str``, the TSIG algorithm to use.
+
+    Raises on errors, and so does the generator.
+
+    Returns a generator of ``dns.message.Message`` objects.
+    """
+
+    class DummyTransactionManager(dns.transaction.TransactionManager):
+        def __init__(self, origin, relativize):
+            self.info = (origin, relativize, dns.name.empty if relativize else origin)
+
+        def origin_information(self):
+            return self.info
+
+        def get_class(self) -> dns.rdataclass.RdataClass:
+            raise NotImplementedError  # pragma: no cover
+
+        def reader(self):
+            raise NotImplementedError  # pragma: no cover
+
+        def writer(self, replacement: bool = False) -> dns.transaction.Transaction:
+            class DummyTransaction:
+                def nop(self, *args, **kw):
+                    pass
+
+                def __getattr__(self, _):
+                    return self.nop
+
+            return cast(dns.transaction.Transaction, DummyTransaction())
+
+    if isinstance(zone, str):
+        zone = dns.name.from_text(zone)
+    rdtype = dns.rdatatype.RdataType.make(rdtype)
+    q = dns.message.make_query(zone, rdtype, rdclass)
+    if rdtype == dns.rdatatype.IXFR:
+        rrset = q.find_rrset(
+            q.authority, zone, dns.rdataclass.IN, dns.rdatatype.SOA, create=True
+        )
+        soa = dns.rdata.from_text("IN", "SOA", ". . %u 0 0 0 0" % serial)
+        rrset.add(soa, 0)
+    if keyring is not None:
+        q.use_tsig(keyring, keyname, algorithm=keyalgorithm)
+    (af, destination, source) = _destination_and_source(
+        where, port, source, source_port
+    )
+    (_, expiration) = _compute_times(lifetime)
+    tm = DummyTransactionManager(zone, relativize)
+    if use_udp and rdtype != dns.rdatatype.IXFR:
+        raise ValueError("cannot do a UDP AXFR")
+    sock_type = socket.SOCK_DGRAM if use_udp else socket.SOCK_STREAM
+    with _make_socket(af, sock_type, source) as s:
+        _connect(s, destination, expiration)
+        yield from _inbound_xfr(tm, s, q, serial, timeout, expiration)
+
+
+def inbound_xfr(
+    where: str,
+    txn_manager: dns.transaction.TransactionManager,
+    query: Optional[dns.message.Message] = None,
+    port: int = 53,
+    timeout: Optional[float] = None,
+    lifetime: Optional[float] = None,
+    source: Optional[str] = None,
+    source_port: int = 0,
+    udp_mode: UDPMode = UDPMode.NEVER,
+) -> None:
+    """Conduct an inbound transfer and apply it via a transaction from the
+    txn_manager.
+
+    *where*, a ``str`` containing an IPv4 or IPv6 address,  where
+    to send the message.
+
+    *txn_manager*, a ``dns.transaction.TransactionManager``, the txn_manager
+    for this transfer (typically a ``dns.zone.Zone``).
+
+    *query*, the query to send.  If not supplied, a default query is
+    constructed using information from the *txn_manager*.
+
+    *port*, an ``int``, the port send the message to.  The default is 53.
+
+    *timeout*, a ``float``, the number of seconds to wait for each
+    response message.  If None, the default, wait forever.
+
+    *lifetime*, a ``float``, the total number of seconds to spend
+    doing the transfer.  If ``None``, the default, then there is no
+    limit on the time the transfer may take.
+
+    *source*, a ``str`` containing an IPv4 or IPv6 address, specifying
+    the source address.  The default is the wildcard address.
+
+    *source_port*, an ``int``, the port from which to send the message.
+    The default is 0.
+
+    *udp_mode*, a ``dns.query.UDPMode``, determines how UDP is used
+    for IXFRs.  The default is ``dns.UDPMode.NEVER``, i.e. only use
+    TCP.  Other possibilities are ``dns.UDPMode.TRY_FIRST``, which
+    means "try UDP but fallback to TCP if needed", and
+    ``dns.UDPMode.ONLY``, which means "try UDP and raise
+    ``dns.xfr.UseTCP`` if it does not succeed.
+
+    Raises on errors.
+    """
+    if query is None:
+        (query, serial) = dns.xfr.make_query(txn_manager)
+    else:
+        serial = dns.xfr.extract_serial_from_query(query)
+
+    (af, destination, source) = _destination_and_source(
+        where, port, source, source_port
+    )
+    (_, expiration) = _compute_times(lifetime)
+    if query.question[0].rdtype == dns.rdatatype.IXFR and udp_mode != UDPMode.NEVER:
+        with _make_socket(af, socket.SOCK_DGRAM, source) as s:
+            _connect(s, destination, expiration)
+            try:
+                for _ in _inbound_xfr(
+                    txn_manager, s, query, serial, timeout, expiration
+                ):
+                    pass
+                return
+            except dns.xfr.UseTCP:
+                if udp_mode == UDPMode.ONLY:
+                    raise
+
+    with _make_socket(af, socket.SOCK_STREAM, source) as s:
+        _connect(s, destination, expiration)
+        for _ in _inbound_xfr(txn_manager, s, query, serial, timeout, expiration):
+            pass
diff --git a/.venv/lib/python3.12/site-packages/dns/quic/__init__.py b/.venv/lib/python3.12/site-packages/dns/quic/__init__.py
new file mode 100644
index 00000000..0750e729
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/dns/quic/__init__.py
@@ -0,0 +1,80 @@
+# Copyright (C) Dnspython Contributors, see LICENSE for text of ISC license
+
+from typing import List, Tuple
+
+import dns._features
+import dns.asyncbackend
+
+if dns._features.have("doq"):
+    import aioquic.quic.configuration  # type: ignore
+
+    from dns._asyncbackend import NullContext
+    from dns.quic._asyncio import (
+        AsyncioQuicConnection,
+        AsyncioQuicManager,
+        AsyncioQuicStream,
+    )
+    from dns.quic._common import AsyncQuicConnection, AsyncQuicManager
+    from dns.quic._sync import SyncQuicConnection, SyncQuicManager, SyncQuicStream
+
+    have_quic = True
+
+    def null_factory(
+        *args,  # pylint: disable=unused-argument
+        **kwargs,  # pylint: disable=unused-argument
+    ):
+        return NullContext(None)
+
+    def _asyncio_manager_factory(
+        context, *args, **kwargs  # pylint: disable=unused-argument
+    ):
+        return AsyncioQuicManager(*args, **kwargs)
+
+    # We have a context factory and a manager factory as for trio we need to have
+    # a nursery.
+
+    _async_factories = {"asyncio": (null_factory, _asyncio_manager_factory)}
+
+    if dns._features.have("trio"):
+        import trio
+
+        from dns.quic._trio import (  # pylint: disable=ungrouped-imports
+            TrioQuicConnection,
+            TrioQuicManager,
+            TrioQuicStream,
+        )
+
+        def _trio_context_factory():
+            return trio.open_nursery()
+
+        def _trio_manager_factory(context, *args, **kwargs):
+            return TrioQuicManager(context, *args, **kwargs)
+
+        _async_factories["trio"] = (_trio_context_factory, _trio_manager_factory)
+
+    def factories_for_backend(backend=None):
+        if backend is None:
+            backend = dns.asyncbackend.get_default_backend()
+        return _async_factories[backend.name()]
+
+else:  # pragma: no cover
+    have_quic = False
+
+    from typing import Any
+
+    class AsyncQuicStream:  # type: ignore
+        pass
+
+    class AsyncQuicConnection:  # type: ignore
+        async def make_stream(self) -> Any:
+            raise NotImplementedError
+
+    class SyncQuicStream:  # type: ignore
+        pass
+
+    class SyncQuicConnection:  # type: ignore
+        def make_stream(self) -> Any:
+            raise NotImplementedError
+
+
+Headers = List[Tuple[bytes, bytes]]
diff --git a/.venv/lib/python3.12/site-packages/dns/quic/_asyncio.py b/.venv/lib/python3.12/site-packages/dns/quic/_asyncio.py
new file mode 100644
index 00000000..f87515da
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/dns/quic/_asyncio.py
@@ -0,0 +1,267 @@
+# Copyright (C) Dnspython Contributors, see LICENSE for text of ISC license
+
+import asyncio
+import socket
+import ssl
+import struct
+import time
+
+import aioquic.quic.configuration  # type: ignore
+import aioquic.quic.connection  # type: ignore
+import aioquic.quic.events  # type: ignore
+
+import dns.asyncbackend
+import dns.exception
+import dns.inet
+from dns.quic._common import (
+    QUIC_MAX_DATAGRAM,
+    AsyncQuicConnection,
+    AsyncQuicManager,
+    BaseQuicStream,
+    UnexpectedEOF,
+)
+
+
+class AsyncioQuicStream(BaseQuicStream):
+    def __init__(self, connection, stream_id):
+        super().__init__(connection, stream_id)
+        self._wake_up = asyncio.Condition()
+
+    async def _wait_for_wake_up(self):
+        async with self._wake_up:
+            await self._wake_up.wait()
+
+    async def wait_for(self, amount, expiration):
+        while True:
+            timeout = self._timeout_from_expiration(expiration)
+            if self._buffer.have(amount):
+                return
+            self._expecting = amount
+            try:
+                await asyncio.wait_for(self._wait_for_wake_up(), timeout)
+            except TimeoutError:
+                raise dns.exception.Timeout
+            self._expecting = 0
+
+    async def wait_for_end(self, expiration):
+        while True:
+            timeout = self._timeout_from_expiration(expiration)
+            if self._buffer.seen_end():
+                return
+            try:
+                await asyncio.wait_for(self._wait_for_wake_up(), timeout)
+            except TimeoutError:
+                raise dns.exception.Timeout
+
+    async def receive(self, timeout=None):
+        expiration = self._expiration_from_timeout(timeout)
+        if self._connection.is_h3():
+            await self.wait_for_end(expiration)
+            return self._buffer.get_all()
+        else:
+            await self.wait_for(2, expiration)
+            (size,) = struct.unpack("!H", self._buffer.get(2))
+            await self.wait_for(size, expiration)
+            return self._buffer.get(size)
+
+    async def send(self, datagram, is_end=False):
+        data = self._encapsulate(datagram)
+        await self._connection.write(self._stream_id, data, is_end)
+
+    async def _add_input(self, data, is_end):
+        if self._common_add_input(data, is_end):
+            async with self._wake_up:
+                self._wake_up.notify()
+
+    async def close(self):
+        self._close()
+
+    # Streams are async context managers
+
+    async def __aenter__(self):
+        return self
+
+    async def __aexit__(self, exc_type, exc_val, exc_tb):
+        await self.close()
+        async with self._wake_up:
+            self._wake_up.notify()
+        return False
+
+
+class AsyncioQuicConnection(AsyncQuicConnection):
+    def __init__(self, connection, address, port, source, source_port, manager=None):
+        super().__init__(connection, address, port, source, source_port, manager)
+        self._socket = None
+        self._handshake_complete = asyncio.Event()
+        self._socket_created = asyncio.Event()
+        self._wake_timer = asyncio.Condition()
+        self._receiver_task = None
+        self._sender_task = None
+        self._wake_pending = False
+
+    async def _receiver(self):
+        try:
+            af = dns.inet.af_for_address(self._address)
+            backend = dns.asyncbackend.get_backend("asyncio")
+            # Note that peer is a low-level address tuple, but make_socket() wants
+            # a high-level address tuple, so we convert.
+            self._socket = await backend.make_socket(
+                af, socket.SOCK_DGRAM, 0, self._source, (self._peer[0], self._peer[1])
+            )
+            self._socket_created.set()
+            async with self._socket:
+                while not self._done:
+                    (datagram, address) = await self._socket.recvfrom(
+                        QUIC_MAX_DATAGRAM, None
+                    )
+                    if address[0] != self._peer[0] or address[1] != self._peer[1]:
+                        continue
+                    self._connection.receive_datagram(datagram, address, time.time())
+                    # Wake up the timer in case the sender is sleeping, as there may be
+                    # stuff to send now.
+                    await self._wakeup()
+        except Exception:
+            pass
+        finally:
+            self._done = True
+            await self._wakeup()
+            self._handshake_complete.set()
+
+    async def _wakeup(self):
+        self._wake_pending = True
+        async with self._wake_timer:
+            self._wake_timer.notify_all()
+
+    async def _wait_for_wake_timer(self):
+        async with self._wake_timer:
+            if not self._wake_pending:
+                await self._wake_timer.wait()
+        self._wake_pending = False
+
+    async def _sender(self):
+        await self._socket_created.wait()
+        while not self._done:
+            datagrams = self._connection.datagrams_to_send(time.time())
+            for datagram, address in datagrams:
+                assert address == self._peer
+                await self._socket.sendto(datagram, self._peer, None)
+            (expiration, interval) = self._get_timer_values()
+            try:
+                await asyncio.wait_for(self._wait_for_wake_timer(), interval)
+            except Exception:
+                pass
+            self._handle_timer(expiration)
+            await self._handle_events()
+
+    async def _handle_events(self):
+        count = 0
+        while True:
+            event = self._connection.next_event()
+            if event is None:
+                return
+            if isinstance(event, aioquic.quic.events.StreamDataReceived):
+                if self.is_h3():
+                    h3_events = self._h3_conn.handle_event(event)
+                    for h3_event in h3_events:
+                        if isinstance(h3_event, aioquic.h3.events.HeadersReceived):
+                            stream = self._streams.get(event.stream_id)
+                            if stream:
+                                if stream._headers is None:
+                                    stream._headers = h3_event.headers
+                                elif stream._trailers is None:
+                                    stream._trailers = h3_event.headers
+                                if h3_event.stream_ended:
+                                    await stream._add_input(b"", True)
+                        elif isinstance(h3_event, aioquic.h3.events.DataReceived):
+                            stream = self._streams.get(event.stream_id)
+                            if stream:
+                                await stream._add_input(
+                                    h3_event.data, h3_event.stream_ended
+                                )
+                else:
+                    stream = self._streams.get(event.stream_id)
+                    if stream:
+                        await stream._add_input(event.data, event.end_stream)
+            elif isinstance(event, aioquic.quic.events.HandshakeCompleted):
+                self._handshake_complete.set()
+            elif isinstance(event, aioquic.quic.events.ConnectionTerminated):
+                self._done = True
+                self._receiver_task.cancel()
+            elif isinstance(event, aioquic.quic.events.StreamReset):
+                stream = self._streams.get(event.stream_id)
+                if stream:
+                    await stream._add_input(b"", True)
+
+            count += 1
+            if count > 10:
+                # yield
+                count = 0
+                await asyncio.sleep(0)
+
+    async def write(self, stream, data, is_end=False):
+        self._connection.send_stream_data(stream, data, is_end)
+        await self._wakeup()
+
+    def run(self):
+        if self._closed:
+            return
+        self._receiver_task = asyncio.Task(self._receiver())
+        self._sender_task = asyncio.Task(self._sender())
+
+    async def make_stream(self, timeout=None):
+        try:
+            await asyncio.wait_for(self._handshake_complete.wait(), timeout)
+        except TimeoutError:
+            raise dns.exception.Timeout
+        if self._done:
+            raise UnexpectedEOF
+        stream_id = self._connection.get_next_available_stream_id(False)
+        stream = AsyncioQuicStream(self, stream_id)
+        self._streams[stream_id] = stream
+        return stream
+
+    async def close(self):
+        if not self._closed:
+            self._manager.closed(self._peer[0], self._peer[1])
+            self._closed = True
+            self._connection.close()
+            # sender might be blocked on this, so set it
+            self._socket_created.set()
+            await self._wakeup()
+            try:
+                await self._receiver_task
+            except asyncio.CancelledError:
+                pass
+            try:
+                await self._sender_task
+            except asyncio.CancelledError:
+                pass
+            await self._socket.close()
+
+
+class AsyncioQuicManager(AsyncQuicManager):
+    def __init__(
+        self, conf=None, verify_mode=ssl.CERT_REQUIRED, server_name=None, h3=False
+    ):
+        super().__init__(conf, verify_mode, AsyncioQuicConnection, server_name, h3)
+
+    def connect(
+        self, address, port=853, source=None, source_port=0, want_session_ticket=True
+    ):
+        (connection, start) = self._connect(
+            address, port, source, source_port, want_session_ticket
+        )
+        if start:
+            connection.run()
+        return connection
+
+    async def __aenter__(self):
+        return self
+
+    async def __aexit__(self, exc_type, exc_val, exc_tb):
+        # Copy the iterator into a list as exiting things will mutate the connections
+        # table.
+        connections = list(self._connections.values())
+        for connection in connections:
+            await connection.close()
+        return False
diff --git a/.venv/lib/python3.12/site-packages/dns/quic/_common.py b/.venv/lib/python3.12/site-packages/dns/quic/_common.py
new file mode 100644
index 00000000..ce575b03
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/dns/quic/_common.py
@@ -0,0 +1,339 @@
+# Copyright (C) Dnspython Contributors, see LICENSE for text of ISC license
+
+import base64
+import copy
+import functools
+import socket
+import struct
+import time
+import urllib
+from typing import Any, Optional
+
+import aioquic.h3.connection  # type: ignore
+import aioquic.h3.events  # type: ignore
+import aioquic.quic.configuration  # type: ignore
+import aioquic.quic.connection  # type: ignore
+
+import dns.inet
+
+QUIC_MAX_DATAGRAM = 2048
+MAX_SESSION_TICKETS = 8
+# If we hit the max sessions limit we will delete this many of the oldest connections.
+# The value must be a integer > 0 and <= MAX_SESSION_TICKETS.
+SESSIONS_TO_DELETE = MAX_SESSION_TICKETS // 4
+
+
+class UnexpectedEOF(Exception):
+    pass
+
+
+class Buffer:
+    def __init__(self):
+        self._buffer = b""
+        self._seen_end = False
+
+    def put(self, data, is_end):
+        if self._seen_end:
+            return
+        self._buffer += data
+        if is_end:
+            self._seen_end = True
+
+    def have(self, amount):
+        if len(self._buffer) >= amount:
+            return True
+        if self._seen_end:
+            raise UnexpectedEOF
+        return False
+
+    def seen_end(self):
+        return self._seen_end
+
+    def get(self, amount):
+        assert self.have(amount)
+        data = self._buffer[:amount]
+        self._buffer = self._buffer[amount:]
+        return data
+
+    def get_all(self):
+        assert self.seen_end()
+        data = self._buffer
+        self._buffer = b""
+        return data
+
+
+class BaseQuicStream:
+    def __init__(self, connection, stream_id):
+        self._connection = connection
+        self._stream_id = stream_id
+        self._buffer = Buffer()
+        self._expecting = 0
+        self._headers = None
+        self._trailers = None
+
+    def id(self):
+        return self._stream_id
+
+    def headers(self):
+        return self._headers
+
+    def trailers(self):
+        return self._trailers
+
+    def _expiration_from_timeout(self, timeout):
+        if timeout is not None:
+            expiration = time.time() + timeout
+        else:
+            expiration = None
+        return expiration
+
+    def _timeout_from_expiration(self, expiration):
+        if expiration is not None:
+            timeout = max(expiration - time.time(), 0.0)
+        else:
+            timeout = None
+        return timeout
+
+    # Subclass must implement receive() as sync / async and which returns a message
+    # or raises.
+
+    # Subclass must implement send() as sync / async and which takes a message and
+    # an EOF indicator.
+
+    def send_h3(self, url, datagram, post=True):
+        if not self._connection.is_h3():
+            raise SyntaxError("cannot send H3 to a non-H3 connection")
+        url_parts = urllib.parse.urlparse(url)
+        path = url_parts.path.encode()
+        if post:
+            method = b"POST"
+        else:
+            method = b"GET"
+            path += b"?dns=" + base64.urlsafe_b64encode(datagram).rstrip(b"=")
+        headers = [
+            (b":method", method),
+            (b":scheme", url_parts.scheme.encode()),
+            (b":authority", url_parts.netloc.encode()),
+            (b":path", path),
+            (b"accept", b"application/dns-message"),
+        ]
+        if post:
+            headers.extend(
+                [
+                    (b"content-type", b"application/dns-message"),
+                    (b"content-length", str(len(datagram)).encode()),
+                ]
+            )
+        self._connection.send_headers(self._stream_id, headers, not post)
+        if post:
+            self._connection.send_data(self._stream_id, datagram, True)
+
+    def _encapsulate(self, datagram):
+        if self._connection.is_h3():
+            return datagram
+        l = len(datagram)
+        return struct.pack("!H", l) + datagram
+
+    def _common_add_input(self, data, is_end):
+        self._buffer.put(data, is_end)
+        try:
+            return (
+                self._expecting > 0 and self._buffer.have(self._expecting)
+            ) or self._buffer.seen_end
+        except UnexpectedEOF:
+            return True
+
+    def _close(self):
+        self._connection.close_stream(self._stream_id)
+        self._buffer.put(b"", True)  # send EOF in case we haven't seen it.
+
+
+class BaseQuicConnection:
+    def __init__(
+        self,
+        connection,
+        address,
+        port,
+        source=None,
+        source_port=0,
+        manager=None,
+    ):
+        self._done = False
+        self._connection = connection
+        self._address = address
+        self._port = port
+        self._closed = False
+        self._manager = manager
+        self._streams = {}
+        if manager.is_h3():
+            self._h3_conn = aioquic.h3.connection.H3Connection(connection, False)
+        else:
+            self._h3_conn = None
+        self._af = dns.inet.af_for_address(address)
+        self._peer = dns.inet.low_level_address_tuple((address, port))
+        if source is None and source_port != 0:
+            if self._af == socket.AF_INET:
+                source = "0.0.0.0"
+            elif self._af == socket.AF_INET6:
+                source = "::"
+            else:
+                raise NotImplementedError
+        if source:
+            self._source = (source, source_port)
+        else:
+            self._source = None
+
+    def is_h3(self):
+        return self._h3_conn is not None
+
+    def close_stream(self, stream_id):
+        del self._streams[stream_id]
+
+    def send_headers(self, stream_id, headers, is_end=False):
+        self._h3_conn.send_headers(stream_id, headers, is_end)
+
+    def send_data(self, stream_id, data, is_end=False):
+        self._h3_conn.send_data(stream_id, data, is_end)
+
+    def _get_timer_values(self, closed_is_special=True):
+        now = time.time()
+        expiration = self._connection.get_timer()
+        if expiration is None:
+            expiration = now + 3600  # arbitrary "big" value
+        interval = max(expiration - now, 0)
+        if self._closed and closed_is_special:
+            # lower sleep interval to avoid a race in the closing process
+            # which can lead to higher latency closing due to sleeping when
+            # we have events.
+            interval = min(interval, 0.05)
+        return (expiration, interval)
+
+    def _handle_timer(self, expiration):
+        now = time.time()
+        if expiration <= now:
+            self._connection.handle_timer(now)
+
+
+class AsyncQuicConnection(BaseQuicConnection):
+    async def make_stream(self, timeout: Optional[float] = None) -> Any:
+        pass
+
+
+class BaseQuicManager:
+    def __init__(
+        self, conf, verify_mode, connection_factory, server_name=None, h3=False
+    ):
+        self._connections = {}
+        self._connection_factory = connection_factory
+        self._session_tickets = {}
+        self._tokens = {}
+        self._h3 = h3
+        if conf is None:
+            verify_path = None
+            if isinstance(verify_mode, str):
+                verify_path = verify_mode
+                verify_mode = True
+            if h3:
+                alpn_protocols = ["h3"]
+            else:
+                alpn_protocols = ["doq", "doq-i03"]
+            conf = aioquic.quic.configuration.QuicConfiguration(
+                alpn_protocols=alpn_protocols,
+                verify_mode=verify_mode,
+                server_name=server_name,
+            )
+            if verify_path is not None:
+                conf.load_verify_locations(verify_path)
+        self._conf = conf
+
+    def _connect(
+        self,
+        address,
+        port=853,
+        source=None,
+        source_port=0,
+        want_session_ticket=True,
+        want_token=True,
+    ):
+        connection = self._connections.get((address, port))
+        if connection is not None:
+            return (connection, False)
+        conf = self._conf
+        if want_session_ticket:
+            try:
+                session_ticket = self._session_tickets.pop((address, port))
+                # We found a session ticket, so make a configuration that uses it.
+                conf = copy.copy(conf)
+                conf.session_ticket = session_ticket
+            except KeyError:
+                # No session ticket.
+                pass
+            # Whether or not we found a session ticket, we want a handler to save
+            # one.
+            session_ticket_handler = functools.partial(
+                self.save_session_ticket, address, port
+            )
+        else:
+            session_ticket_handler = None
+        if want_token:
+            try:
+                token = self._tokens.pop((address, port))
+                # We found a token, so make a configuration that uses it.
+                conf = copy.copy(conf)
+                conf.token = token
+            except KeyError:
+                # No token
+                pass
+            # Whether or not we found a token, we want a handler to save # one.
+            token_handler = functools.partial(self.save_token, address, port)
+        else:
+            token_handler = None
+
+        qconn = aioquic.quic.connection.QuicConnection(
+            configuration=conf,
+            session_ticket_handler=session_ticket_handler,
+            token_handler=token_handler,
+        )
+        lladdress = dns.inet.low_level_address_tuple((address, port))
+        qconn.connect(lladdress, time.time())
+        connection = self._connection_factory(
+            qconn, address, port, source, source_port, self
+        )
+        self._connections[(address, port)] = connection
+        return (connection, True)
+
+    def closed(self, address, port):
+        try:
+            del self._connections[(address, port)]
+        except KeyError:
+            pass
+
+    def is_h3(self):
+        return self._h3
+
+    def save_session_ticket(self, address, port, ticket):
+        # We rely on dictionaries keys() being in insertion order here.  We
+        # can't just popitem() as that would be LIFO which is the opposite of
+        # what we want.
+        l = len(self._session_tickets)
+        if l >= MAX_SESSION_TICKETS:
+            keys_to_delete = list(self._session_tickets.keys())[0:SESSIONS_TO_DELETE]
+            for key in keys_to_delete:
+                del self._session_tickets[key]
+        self._session_tickets[(address, port)] = ticket
+
+    def save_token(self, address, port, token):
+        # We rely on dictionaries keys() being in insertion order here.  We
+        # can't just popitem() as that would be LIFO which is the opposite of
+        # what we want.
+        l = len(self._tokens)
+        if l >= MAX_SESSION_TICKETS:
+            keys_to_delete = list(self._tokens.keys())[0:SESSIONS_TO_DELETE]
+            for key in keys_to_delete:
+                del self._tokens[key]
+        self._tokens[(address, port)] = token
+
+
+class AsyncQuicManager(BaseQuicManager):
+    def connect(self, address, port=853, source=None, source_port=0):
+        raise NotImplementedError
diff --git a/.venv/lib/python3.12/site-packages/dns/quic/_sync.py b/.venv/lib/python3.12/site-packages/dns/quic/_sync.py
new file mode 100644
index 00000000..473d1f48
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/dns/quic/_sync.py
@@ -0,0 +1,295 @@
+# Copyright (C) Dnspython Contributors, see LICENSE for text of ISC license
+
+import selectors
+import socket
+import ssl
+import struct
+import threading
+import time
+
+import aioquic.quic.configuration  # type: ignore
+import aioquic.quic.connection  # type: ignore
+import aioquic.quic.events  # type: ignore
+
+import dns.exception
+import dns.inet
+from dns.quic._common import (
+    QUIC_MAX_DATAGRAM,
+    BaseQuicConnection,
+    BaseQuicManager,
+    BaseQuicStream,
+    UnexpectedEOF,
+)
+
+# Function used to create a socket.  Can be overridden if needed in special
+# situations.
+socket_factory = socket.socket
+
+
+class SyncQuicStream(BaseQuicStream):
+    def __init__(self, connection, stream_id):
+        super().__init__(connection, stream_id)
+        self._wake_up = threading.Condition()
+        self._lock = threading.Lock()
+
+    def wait_for(self, amount, expiration):
+        while True:
+            timeout = self._timeout_from_expiration(expiration)
+            with self._lock:
+                if self._buffer.have(amount):
+                    return
+                self._expecting = amount
+            with self._wake_up:
+                if not self._wake_up.wait(timeout):
+                    raise dns.exception.Timeout
+            self._expecting = 0
+
+    def wait_for_end(self, expiration):
+        while True:
+            timeout = self._timeout_from_expiration(expiration)
+            with self._lock:
+                if self._buffer.seen_end():
+                    return
+            with self._wake_up:
+                if not self._wake_up.wait(timeout):
+                    raise dns.exception.Timeout
+
+    def receive(self, timeout=None):
+        expiration = self._expiration_from_timeout(timeout)
+        if self._connection.is_h3():
+            self.wait_for_end(expiration)
+            with self._lock:
+                return self._buffer.get_all()
+        else:
+            self.wait_for(2, expiration)
+            with self._lock:
+                (size,) = struct.unpack("!H", self._buffer.get(2))
+            self.wait_for(size, expiration)
+            with self._lock:
+                return self._buffer.get(size)
+
+    def send(self, datagram, is_end=False):
+        data = self._encapsulate(datagram)
+        self._connection.write(self._stream_id, data, is_end)
+
+    def _add_input(self, data, is_end):
+        if self._common_add_input(data, is_end):
+            with self._wake_up:
+                self._wake_up.notify()
+
+    def close(self):
+        with self._lock:
+            self._close()
+
+    def __enter__(self):
+        return self
+
+    def __exit__(self, exc_type, exc_val, exc_tb):
+        self.close()
+        with self._wake_up:
+            self._wake_up.notify()
+        return False
+
+
+class SyncQuicConnection(BaseQuicConnection):
+    def __init__(self, connection, address, port, source, source_port, manager):
+        super().__init__(connection, address, port, source, source_port, manager)
+        self._socket = socket_factory(self._af, socket.SOCK_DGRAM, 0)
+        if self._source is not None:
+            try:
+                self._socket.bind(
+                    dns.inet.low_level_address_tuple(self._source, self._af)
+                )
+            except Exception:
+                self._socket.close()
+                raise
+        self._socket.connect(self._peer)
+        (self._send_wakeup, self._receive_wakeup) = socket.socketpair()
+        self._receive_wakeup.setblocking(False)
+        self._socket.setblocking(False)
+        self._handshake_complete = threading.Event()
+        self._worker_thread = None
+        self._lock = threading.Lock()
+
+    def _read(self):
+        count = 0
+        while count < 10:
+            count += 1
+            try:
+                datagram = self._socket.recv(QUIC_MAX_DATAGRAM)
+            except BlockingIOError:
+                return
+            with self._lock:
+                self._connection.receive_datagram(datagram, self._peer, time.time())
+
+    def _drain_wakeup(self):
+        while True:
+            try:
+                self._receive_wakeup.recv(32)
+            except BlockingIOError:
+                return
+
+    def _worker(self):
+        try:
+            sel = selectors.DefaultSelector()
+            sel.register(self._socket, selectors.EVENT_READ, self._read)
+            sel.register(self._receive_wakeup, selectors.EVENT_READ, self._drain_wakeup)
+            while not self._done:
+                (expiration, interval) = self._get_timer_values(False)
+                items = sel.select(interval)
+                for key, _ in items:
+                    key.data()
+                with self._lock:
+                    self._handle_timer(expiration)
+                self._handle_events()
+                with self._lock:
+                    datagrams = self._connection.datagrams_to_send(time.time())
+                for datagram, _ in datagrams:
+                    try:
+                        self._socket.send(datagram)
+                    except BlockingIOError:
+                        # we let QUIC handle any lossage
+                        pass
+        finally:
+            with self._lock:
+                self._done = True
+            self._socket.close()
+            # Ensure anyone waiting for this gets woken up.
+            self._handshake_complete.set()
+
+    def _handle_events(self):
+        while True:
+            with self._lock:
+                event = self._connection.next_event()
+            if event is None:
+                return
+            if isinstance(event, aioquic.quic.events.StreamDataReceived):
+                if self.is_h3():
+                    h3_events = self._h3_conn.handle_event(event)
+                    for h3_event in h3_events:
+                        if isinstance(h3_event, aioquic.h3.events.HeadersReceived):
+                            with self._lock:
+                                stream = self._streams.get(event.stream_id)
+                            if stream:
+                                if stream._headers is None:
+                                    stream._headers = h3_event.headers
+                                elif stream._trailers is None:
+                                    stream._trailers = h3_event.headers
+                                if h3_event.stream_ended:
+                                    stream._add_input(b"", True)
+                        elif isinstance(h3_event, aioquic.h3.events.DataReceived):
+                            with self._lock:
+                                stream = self._streams.get(event.stream_id)
+                            if stream:
+                                stream._add_input(h3_event.data, h3_event.stream_ended)
+                else:
+                    with self._lock:
+                        stream = self._streams.get(event.stream_id)
+                    if stream:
+                        stream._add_input(event.data, event.end_stream)
+            elif isinstance(event, aioquic.quic.events.HandshakeCompleted):
+                self._handshake_complete.set()
+            elif isinstance(event, aioquic.quic.events.ConnectionTerminated):
+                with self._lock:
+                    self._done = True
+            elif isinstance(event, aioquic.quic.events.StreamReset):
+                with self._lock:
+                    stream = self._streams.get(event.stream_id)
+                if stream:
+                    stream._add_input(b"", True)
+
+    def write(self, stream, data, is_end=False):
+        with self._lock:
+            self._connection.send_stream_data(stream, data, is_end)
+        self._send_wakeup.send(b"\x01")
+
+    def send_headers(self, stream_id, headers, is_end=False):
+        with self._lock:
+            super().send_headers(stream_id, headers, is_end)
+        if is_end:
+            self._send_wakeup.send(b"\x01")
+
+    def send_data(self, stream_id, data, is_end=False):
+        with self._lock:
+            super().send_data(stream_id, data, is_end)
+        if is_end:
+            self._send_wakeup.send(b"\x01")
+
+    def run(self):
+        if self._closed:
+            return
+        self._worker_thread = threading.Thread(target=self._worker)
+        self._worker_thread.start()
+
+    def make_stream(self, timeout=None):
+        if not self._handshake_complete.wait(timeout):
+            raise dns.exception.Timeout
+        with self._lock:
+            if self._done:
+                raise UnexpectedEOF
+            stream_id = self._connection.get_next_available_stream_id(False)
+            stream = SyncQuicStream(self, stream_id)
+            self._streams[stream_id] = stream
+        return stream
+
+    def close_stream(self, stream_id):
+        with self._lock:
+            super().close_stream(stream_id)
+
+    def close(self):
+        with self._lock:
+            if self._closed:
+                return
+            self._manager.closed(self._peer[0], self._peer[1])
+            self._closed = True
+            self._connection.close()
+            self._send_wakeup.send(b"\x01")
+        self._worker_thread.join()
+
+
+class SyncQuicManager(BaseQuicManager):
+    def __init__(
+        self, conf=None, verify_mode=ssl.CERT_REQUIRED, server_name=None, h3=False
+    ):
+        super().__init__(conf, verify_mode, SyncQuicConnection, server_name, h3)
+        self._lock = threading.Lock()
+
+    def connect(
+        self,
+        address,
+        port=853,
+        source=None,
+        source_port=0,
+        want_session_ticket=True,
+        want_token=True,
+    ):
+        with self._lock:
+            (connection, start) = self._connect(
+                address, port, source, source_port, want_session_ticket, want_token
+            )
+            if start:
+                connection.run()
+            return connection
+
+    def closed(self, address, port):
+        with self._lock:
+            super().closed(address, port)
+
+    def save_session_ticket(self, address, port, ticket):
+        with self._lock:
+            super().save_session_ticket(address, port, ticket)
+
+    def save_token(self, address, port, token):
+        with self._lock:
+            super().save_token(address, port, token)
+
+    def __enter__(self):
+        return self
+
+    def __exit__(self, exc_type, exc_val, exc_tb):
+        # Copy the iterator into a list as exiting things will mutate the connections
+        # table.
+        connections = list(self._connections.values())
+        for connection in connections:
+            connection.close()
+        return False
diff --git a/.venv/lib/python3.12/site-packages/dns/quic/_trio.py b/.venv/lib/python3.12/site-packages/dns/quic/_trio.py
new file mode 100644
index 00000000..ae79f369
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/dns/quic/_trio.py
@@ -0,0 +1,246 @@
+# Copyright (C) Dnspython Contributors, see LICENSE for text of ISC license
+
+import socket
+import ssl
+import struct
+import time
+
+import aioquic.quic.configuration  # type: ignore
+import aioquic.quic.connection  # type: ignore
+import aioquic.quic.events  # type: ignore
+import trio
+
+import dns.exception
+import dns.inet
+from dns._asyncbackend import NullContext
+from dns.quic._common import (
+    QUIC_MAX_DATAGRAM,
+    AsyncQuicConnection,
+    AsyncQuicManager,
+    BaseQuicStream,
+    UnexpectedEOF,
+)
+
+
+class TrioQuicStream(BaseQuicStream):
+    def __init__(self, connection, stream_id):
+        super().__init__(connection, stream_id)
+        self._wake_up = trio.Condition()
+
+    async def wait_for(self, amount):
+        while True:
+            if self._buffer.have(amount):
+                return
+            self._expecting = amount
+            async with self._wake_up:
+                await self._wake_up.wait()
+            self._expecting = 0
+
+    async def wait_for_end(self):
+        while True:
+            if self._buffer.seen_end():
+                return
+            async with self._wake_up:
+                await self._wake_up.wait()
+
+    async def receive(self, timeout=None):
+        if timeout is None:
+            context = NullContext(None)
+        else:
+            context = trio.move_on_after(timeout)
+        with context:
+            if self._connection.is_h3():
+                await self.wait_for_end()
+                return self._buffer.get_all()
+            else:
+                await self.wait_for(2)
+                (size,) = struct.unpack("!H", self._buffer.get(2))
+                await self.wait_for(size)
+                return self._buffer.get(size)
+        raise dns.exception.Timeout
+
+    async def send(self, datagram, is_end=False):
+        data = self._encapsulate(datagram)
+        await self._connection.write(self._stream_id, data, is_end)
+
+    async def _add_input(self, data, is_end):
+        if self._common_add_input(data, is_end):
+            async with self._wake_up:
+                self._wake_up.notify()
+
+    async def close(self):
+        self._close()
+
+    # Streams are async context managers
+
+    async def __aenter__(self):
+        return self
+
+    async def __aexit__(self, exc_type, exc_val, exc_tb):
+        await self.close()
+        async with self._wake_up:
+            self._wake_up.notify()
+        return False
+
+
+class TrioQuicConnection(AsyncQuicConnection):
+    def __init__(self, connection, address, port, source, source_port, manager=None):
+        super().__init__(connection, address, port, source, source_port, manager)
+        self._socket = trio.socket.socket(self._af, socket.SOCK_DGRAM, 0)
+        self._handshake_complete = trio.Event()
+        self._run_done = trio.Event()
+        self._worker_scope = None
+        self._send_pending = False
+
+    async def _worker(self):
+        try:
+            if self._source:
+                await self._socket.bind(
+                    dns.inet.low_level_address_tuple(self._source, self._af)
+                )
+            await self._socket.connect(self._peer)
+            while not self._done:
+                (expiration, interval) = self._get_timer_values(False)
+                if self._send_pending:
+                    # Do not block forever if sends are pending.  Even though we
+                    # have a wake-up mechanism if we've already started the blocking
+                    # read, the possibility of context switching in send means that
+                    # more writes can happen while we have no wake up context, so
+                    # we need self._send_pending to avoid (effectively) a "lost wakeup"
+                    # race.
+                    interval = 0.0
+                with trio.CancelScope(
+                    deadline=trio.current_time() + interval
+                ) as self._worker_scope:
+                    datagram = await self._socket.recv(QUIC_MAX_DATAGRAM)
+                    self._connection.receive_datagram(datagram, self._peer, time.time())
+                self._worker_scope = None
+                self._handle_timer(expiration)
+                await self._handle_events()
+                # We clear this now, before sending anything, as sending can cause
+                # context switches that do more sends.  We want to know if that
+                # happens so we don't block a long time on the recv() above.
+                self._send_pending = False
+                datagrams = self._connection.datagrams_to_send(time.time())
+                for datagram, _ in datagrams:
+                    await self._socket.send(datagram)
+        finally:
+            self._done = True
+            self._socket.close()
+            self._handshake_complete.set()
+
+    async def _handle_events(self):
+        count = 0
+        while True:
+            event = self._connection.next_event()
+            if event is None:
+                return
+            if isinstance(event, aioquic.quic.events.StreamDataReceived):
+                if self.is_h3():
+                    h3_events = self._h3_conn.handle_event(event)
+                    for h3_event in h3_events:
+                        if isinstance(h3_event, aioquic.h3.events.HeadersReceived):
+                            stream = self._streams.get(event.stream_id)
+                            if stream:
+                                if stream._headers is None:
+                                    stream._headers = h3_event.headers
+                                elif stream._trailers is None:
+                                    stream._trailers = h3_event.headers
+                                if h3_event.stream_ended:
+                                    await stream._add_input(b"", True)
+                        elif isinstance(h3_event, aioquic.h3.events.DataReceived):
+                            stream = self._streams.get(event.stream_id)
+                            if stream:
+                                await stream._add_input(
+                                    h3_event.data, h3_event.stream_ended
+                                )
+                else:
+                    stream = self._streams.get(event.stream_id)
+                    if stream:
+                        await stream._add_input(event.data, event.end_stream)
+            elif isinstance(event, aioquic.quic.events.HandshakeCompleted):
+                self._handshake_complete.set()
+            elif isinstance(event, aioquic.quic.events.ConnectionTerminated):
+                self._done = True
+                self._socket.close()
+            elif isinstance(event, aioquic.quic.events.StreamReset):
+                stream = self._streams.get(event.stream_id)
+                if stream:
+                    await stream._add_input(b"", True)
+            count += 1
+            if count > 10:
+                # yield
+                count = 0
+                await trio.sleep(0)
+
+    async def write(self, stream, data, is_end=False):
+        self._connection.send_stream_data(stream, data, is_end)
+        self._send_pending = True
+        if self._worker_scope is not None:
+            self._worker_scope.cancel()
+
+    async def run(self):
+        if self._closed:
+            return
+        async with trio.open_nursery() as nursery:
+            nursery.start_soon(self._worker)
+        self._run_done.set()
+
+    async def make_stream(self, timeout=None):
+        if timeout is None:
+            context = NullContext(None)
+        else:
+            context = trio.move_on_after(timeout)
+        with context:
+            await self._handshake_complete.wait()
+            if self._done:
+                raise UnexpectedEOF
+            stream_id = self._connection.get_next_available_stream_id(False)
+            stream = TrioQuicStream(self, stream_id)
+            self._streams[stream_id] = stream
+            return stream
+        raise dns.exception.Timeout
+
+    async def close(self):
+        if not self._closed:
+            self._manager.closed(self._peer[0], self._peer[1])
+            self._closed = True
+            self._connection.close()
+            self._send_pending = True
+            if self._worker_scope is not None:
+                self._worker_scope.cancel()
+            await self._run_done.wait()
+
+
+class TrioQuicManager(AsyncQuicManager):
+    def __init__(
+        self,
+        nursery,
+        conf=None,
+        verify_mode=ssl.CERT_REQUIRED,
+        server_name=None,
+        h3=False,
+    ):
+        super().__init__(conf, verify_mode, TrioQuicConnection, server_name, h3)
+        self._nursery = nursery
+
+    def connect(
+        self, address, port=853, source=None, source_port=0, want_session_ticket=True
+    ):
+        (connection, start) = self._connect(
+            address, port, source, source_port, want_session_ticket
+        )
+        if start:
+            self._nursery.start_soon(connection.run)
+        return connection
+
+    async def __aenter__(self):
+        return self
+
+    async def __aexit__(self, exc_type, exc_val, exc_tb):
+        # Copy the iterator into a list as exiting things will mutate the connections
+        # table.
+        connections = list(self._connections.values())
+        for connection in connections:
+            await connection.close()
+        return False
diff --git a/.venv/lib/python3.12/site-packages/dns/rcode.py b/.venv/lib/python3.12/site-packages/dns/rcode.py
new file mode 100644
index 00000000..8e6386f8
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/dns/rcode.py
@@ -0,0 +1,168 @@
+# Copyright (C) Dnspython Contributors, see LICENSE for text of ISC license
+
+# Copyright (C) 2001-2017 Nominum, Inc.
+#
+# Permission to use, copy, modify, and distribute this software and its
+# documentation for any purpose with or without fee is hereby granted,
+# provided that the above copyright notice and this permission notice
+# appear in all copies.
+#
+# THE SOFTWARE IS PROVIDED "AS IS" AND NOMINUM DISCLAIMS ALL WARRANTIES
+# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL NOMINUM BE LIABLE FOR
+# ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+# ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT
+# OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+
+"""DNS Result Codes."""
+
+from typing import Tuple
+
+import dns.enum
+import dns.exception
+
+
+class Rcode(dns.enum.IntEnum):
+    #: No error
+    NOERROR = 0
+    #: Format error
+    FORMERR = 1
+    #: Server failure
+    SERVFAIL = 2
+    #: Name does not exist ("Name Error" in RFC 1025 terminology).
+    NXDOMAIN = 3
+    #: Not implemented
+    NOTIMP = 4
+    #: Refused
+    REFUSED = 5
+    #: Name exists.
+    YXDOMAIN = 6
+    #: RRset exists.
+    YXRRSET = 7
+    #: RRset does not exist.
+    NXRRSET = 8
+    #: Not authoritative.
+    NOTAUTH = 9
+    #: Name not in zone.
+    NOTZONE = 10
+    #: DSO-TYPE Not Implemented
+    DSOTYPENI = 11
+    #: Bad EDNS version.
+    BADVERS = 16
+    #: TSIG Signature Failure
+    BADSIG = 16
+    #: Key not recognized.
+    BADKEY = 17
+    #: Signature out of time window.
+    BADTIME = 18
+    #: Bad TKEY Mode.
+    BADMODE = 19
+    #: Duplicate key name.
+    BADNAME = 20
+    #: Algorithm not supported.
+    BADALG = 21
+    #: Bad Truncation
+    BADTRUNC = 22
+    #: Bad/missing Server Cookie
+    BADCOOKIE = 23
+
+    @classmethod
+    def _maximum(cls):
+        return 4095
+
+    @classmethod
+    def _unknown_exception_class(cls):
+        return UnknownRcode
+
+
+class UnknownRcode(dns.exception.DNSException):
+    """A DNS rcode is unknown."""
+
+
+def from_text(text: str) -> Rcode:
+    """Convert text into an rcode.
+
+    *text*, a ``str``, the textual rcode or an integer in textual form.
+
+    Raises ``dns.rcode.UnknownRcode`` if the rcode mnemonic is unknown.
+
+    Returns a ``dns.rcode.Rcode``.
+    """
+
+    return Rcode.from_text(text)
+
+
+def from_flags(flags: int, ednsflags: int) -> Rcode:
+    """Return the rcode value encoded by flags and ednsflags.
+
+    *flags*, an ``int``, the DNS flags field.
+
+    *ednsflags*, an ``int``, the EDNS flags field.
+
+    Raises ``ValueError`` if rcode is < 0 or > 4095
+
+    Returns a ``dns.rcode.Rcode``.
+    """
+
+    value = (flags & 0x000F) | ((ednsflags >> 20) & 0xFF0)
+    return Rcode.make(value)
+
+
+def to_flags(value: Rcode) -> Tuple[int, int]:
+    """Return a (flags, ednsflags) tuple which encodes the rcode.
+
+    *value*, a ``dns.rcode.Rcode``, the rcode.
+
+    Raises ``ValueError`` if rcode is < 0 or > 4095.
+
+    Returns an ``(int, int)`` tuple.
+    """
+
+    if value < 0 or value > 4095:
+        raise ValueError("rcode must be >= 0 and <= 4095")
+    v = value & 0xF
+    ev = (value & 0xFF0) << 20
+    return (v, ev)
+
+
+def to_text(value: Rcode, tsig: bool = False) -> str:
+    """Convert rcode into text.
+
+    *value*, a ``dns.rcode.Rcode``, the rcode.
+
+    Raises ``ValueError`` if rcode is < 0 or > 4095.
+
+    Returns a ``str``.
+    """
+
+    if tsig and value == Rcode.BADVERS:
+        return "BADSIG"
+    return Rcode.to_text(value)
+
+
+### BEGIN generated Rcode constants
+
+NOERROR = Rcode.NOERROR
+FORMERR = Rcode.FORMERR
+SERVFAIL = Rcode.SERVFAIL
+NXDOMAIN = Rcode.NXDOMAIN
+NOTIMP = Rcode.NOTIMP
+REFUSED = Rcode.REFUSED
+YXDOMAIN = Rcode.YXDOMAIN
+YXRRSET = Rcode.YXRRSET
+NXRRSET = Rcode.NXRRSET
+NOTAUTH = Rcode.NOTAUTH
+NOTZONE = Rcode.NOTZONE
+DSOTYPENI = Rcode.DSOTYPENI
+BADVERS = Rcode.BADVERS
+BADSIG = Rcode.BADSIG
+BADKEY = Rcode.BADKEY
+BADTIME = Rcode.BADTIME
+BADMODE = Rcode.BADMODE
+BADNAME = Rcode.BADNAME
+BADALG = Rcode.BADALG
+BADTRUNC = Rcode.BADTRUNC
+BADCOOKIE = Rcode.BADCOOKIE
+
+### END generated Rcode constants
diff --git a/.venv/lib/python3.12/site-packages/dns/rdata.py b/.venv/lib/python3.12/site-packages/dns/rdata.py
new file mode 100644
index 00000000..8099c26a
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/dns/rdata.py
@@ -0,0 +1,911 @@
+# Copyright (C) Dnspython Contributors, see LICENSE for text of ISC license
+
+# Copyright (C) 2001-2017 Nominum, Inc.
+#
+# Permission to use, copy, modify, and distribute this software and its
+# documentation for any purpose with or without fee is hereby granted,
+# provided that the above copyright notice and this permission notice
+# appear in all copies.
+#
+# THE SOFTWARE IS PROVIDED "AS IS" AND NOMINUM DISCLAIMS ALL WARRANTIES
+# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL NOMINUM BE LIABLE FOR
+# ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+# ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT
+# OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+
+"""DNS rdata."""
+
+import base64
+import binascii
+import inspect
+import io
+import itertools
+import random
+from importlib import import_module
+from typing import Any, Dict, Optional, Tuple, Union
+
+import dns.exception
+import dns.immutable
+import dns.ipv4
+import dns.ipv6
+import dns.name
+import dns.rdataclass
+import dns.rdatatype
+import dns.tokenizer
+import dns.ttl
+import dns.wire
+
+_chunksize = 32
+
+# We currently allow comparisons for rdata with relative names for backwards
+# compatibility, but in the future we will not, as these kinds of comparisons
+# can lead to subtle bugs if code is not carefully written.
+#
+# This switch allows the future behavior to be turned on so code can be
+# tested with it.
+_allow_relative_comparisons = True
+
+
+class NoRelativeRdataOrdering(dns.exception.DNSException):
+    """An attempt was made to do an ordered comparison of one or more
+    rdata with relative names.  The only reliable way of sorting rdata
+    is to use non-relativized rdata.
+
+    """
+
+
+def _wordbreak(data, chunksize=_chunksize, separator=b" "):
+    """Break a binary string into chunks of chunksize characters separated by
+    a space.
+    """
+
+    if not chunksize:
+        return data.decode()
+    return separator.join(
+        [data[i : i + chunksize] for i in range(0, len(data), chunksize)]
+    ).decode()
+
+
+# pylint: disable=unused-argument
+
+
+def _hexify(data, chunksize=_chunksize, separator=b" ", **kw):
+    """Convert a binary string into its hex encoding, broken up into chunks
+    of chunksize characters separated by a separator.
+    """
+
+    return _wordbreak(binascii.hexlify(data), chunksize, separator)
+
+
+def _base64ify(data, chunksize=_chunksize, separator=b" ", **kw):
+    """Convert a binary string into its base64 encoding, broken up into chunks
+    of chunksize characters separated by a separator.
+    """
+
+    return _wordbreak(base64.b64encode(data), chunksize, separator)
+
+
+# pylint: enable=unused-argument
+
+__escaped = b'"\\'
+
+
+def _escapify(qstring):
+    """Escape the characters in a quoted string which need it."""
+
+    if isinstance(qstring, str):
+        qstring = qstring.encode()
+    if not isinstance(qstring, bytearray):
+        qstring = bytearray(qstring)
+
+    text = ""
+    for c in qstring:
+        if c in __escaped:
+            text += "\\" + chr(c)
+        elif c >= 0x20 and c < 0x7F:
+            text += chr(c)
+        else:
+            text += "\\%03d" % c
+    return text
+
+
+def _truncate_bitmap(what):
+    """Determine the index of greatest byte that isn't all zeros, and
+    return the bitmap that contains all the bytes less than that index.
+    """
+
+    for i in range(len(what) - 1, -1, -1):
+        if what[i] != 0:
+            return what[0 : i + 1]
+    return what[0:1]
+
+
+# So we don't have to edit all the rdata classes...
+_constify = dns.immutable.constify
+
+
+@dns.immutable.immutable
+class Rdata:
+    """Base class for all DNS rdata types."""
+
+    __slots__ = ["rdclass", "rdtype", "rdcomment"]
+
+    def __init__(self, rdclass, rdtype):
+        """Initialize an rdata.
+
+        *rdclass*, an ``int`` is the rdataclass of the Rdata.
+
+        *rdtype*, an ``int`` is the rdatatype of the Rdata.
+        """
+
+        self.rdclass = self._as_rdataclass(rdclass)
+        self.rdtype = self._as_rdatatype(rdtype)
+        self.rdcomment = None
+
+    def _get_all_slots(self):
+        return itertools.chain.from_iterable(
+            getattr(cls, "__slots__", []) for cls in self.__class__.__mro__
+        )
+
+    def __getstate__(self):
+        # We used to try to do a tuple of all slots here, but it
+        # doesn't work as self._all_slots isn't available at
+        # __setstate__() time.  Before that we tried to store a tuple
+        # of __slots__, but that didn't work as it didn't store the
+        # slots defined by ancestors.  This older way didn't fail
+        # outright, but ended up with partially broken objects, e.g.
+        # if you unpickled an A RR it wouldn't have rdclass and rdtype
+        # attributes, and would compare badly.
+        state = {}
+        for slot in self._get_all_slots():
+            state[slot] = getattr(self, slot)
+        return state
+
+    def __setstate__(self, state):
+        for slot, val in state.items():
+            object.__setattr__(self, slot, val)
+        if not hasattr(self, "rdcomment"):
+            # Pickled rdata from 2.0.x might not have a rdcomment, so add
+            # it if needed.
+            object.__setattr__(self, "rdcomment", None)
+
+    def covers(self) -> dns.rdatatype.RdataType:
+        """Return the type a Rdata covers.
+
+        DNS SIG/RRSIG rdatas apply to a specific type; this type is
+        returned by the covers() function.  If the rdata type is not
+        SIG or RRSIG, dns.rdatatype.NONE is returned.  This is useful when
+        creating rdatasets, allowing the rdataset to contain only RRSIGs
+        of a particular type, e.g. RRSIG(NS).
+
+        Returns a ``dns.rdatatype.RdataType``.
+        """
+
+        return dns.rdatatype.NONE
+
+    def extended_rdatatype(self) -> int:
+        """Return a 32-bit type value, the least significant 16 bits of
+        which are the ordinary DNS type, and the upper 16 bits of which are
+        the "covered" type, if any.
+
+        Returns an ``int``.
+        """
+
+        return self.covers() << 16 | self.rdtype
+
+    def to_text(
+        self,
+        origin: Optional[dns.name.Name] = None,
+        relativize: bool = True,
+        **kw: Dict[str, Any],
+    ) -> str:
+        """Convert an rdata to text format.
+
+        Returns a ``str``.
+        """
+
+        raise NotImplementedError  # pragma: no cover
+
+    def _to_wire(
+        self,
+        file: Optional[Any],
+        compress: Optional[dns.name.CompressType] = None,
+        origin: Optional[dns.name.Name] = None,
+        canonicalize: bool = False,
+    ) -> None:
+        raise NotImplementedError  # pragma: no cover
+
+    def to_wire(
+        self,
+        file: Optional[Any] = None,
+        compress: Optional[dns.name.CompressType] = None,
+        origin: Optional[dns.name.Name] = None,
+        canonicalize: bool = False,
+    ) -> Optional[bytes]:
+        """Convert an rdata to wire format.
+
+        Returns a ``bytes`` if no output file was specified, or ``None`` otherwise.
+        """
+
+        if file:
+            # We call _to_wire() and then return None explicitly instead of
+            # of just returning the None from _to_wire() as mypy's func-returns-value
+            # unhelpfully errors out with "error: "_to_wire" of "Rdata" does not return
+            # a value (it only ever returns None)"
+            self._to_wire(file, compress, origin, canonicalize)
+            return None
+        else:
+            f = io.BytesIO()
+            self._to_wire(f, compress, origin, canonicalize)
+            return f.getvalue()
+
+    def to_generic(
+        self, origin: Optional[dns.name.Name] = None
+    ) -> "dns.rdata.GenericRdata":
+        """Creates a dns.rdata.GenericRdata equivalent of this rdata.
+
+        Returns a ``dns.rdata.GenericRdata``.
+        """
+        return dns.rdata.GenericRdata(
+            self.rdclass, self.rdtype, self.to_wire(origin=origin)
+        )
+
+    def to_digestable(self, origin: Optional[dns.name.Name] = None) -> bytes:
+        """Convert rdata to a format suitable for digesting in hashes.  This
+        is also the DNSSEC canonical form.
+
+        Returns a ``bytes``.
+        """
+        wire = self.to_wire(origin=origin, canonicalize=True)
+        assert wire is not None  # for mypy
+        return wire
+
+    def __repr__(self):
+        covers = self.covers()
+        if covers == dns.rdatatype.NONE:
+            ctext = ""
+        else:
+            ctext = "(" + dns.rdatatype.to_text(covers) + ")"
+        return (
+            "<DNS "
+            + dns.rdataclass.to_text(self.rdclass)
+            + " "
+            + dns.rdatatype.to_text(self.rdtype)
+            + ctext
+            + " rdata: "
+            + str(self)
+            + ">"
+        )
+
+    def __str__(self):
+        return self.to_text()
+
+    def _cmp(self, other):
+        """Compare an rdata with another rdata of the same rdtype and
+        rdclass.
+
+        For rdata with only absolute names:
+            Return < 0 if self < other in the DNSSEC ordering, 0 if self
+            == other, and > 0 if self > other.
+        For rdata with at least one relative names:
+            The rdata sorts before any rdata with only absolute names.
+            When compared with another relative rdata, all names are
+            made absolute as if they were relative to the root, as the
+            proper origin is not available.  While this creates a stable
+            ordering, it is NOT guaranteed to be the DNSSEC ordering.
+            In the future, all ordering comparisons for rdata with
+            relative names will be disallowed.
+        """
+        try:
+            our = self.to_digestable()
+            our_relative = False
+        except dns.name.NeedAbsoluteNameOrOrigin:
+            if _allow_relative_comparisons:
+                our = self.to_digestable(dns.name.root)
+            our_relative = True
+        try:
+            their = other.to_digestable()
+            their_relative = False
+        except dns.name.NeedAbsoluteNameOrOrigin:
+            if _allow_relative_comparisons:
+                their = other.to_digestable(dns.name.root)
+            their_relative = True
+        if _allow_relative_comparisons:
+            if our_relative != their_relative:
+                # For the purpose of comparison, all rdata with at least one
+                # relative name is less than an rdata with only absolute names.
+                if our_relative:
+                    return -1
+                else:
+                    return 1
+        elif our_relative or their_relative:
+            raise NoRelativeRdataOrdering
+        if our == their:
+            return 0
+        elif our > their:
+            return 1
+        else:
+            return -1
+
+    def __eq__(self, other):
+        if not isinstance(other, Rdata):
+            return False
+        if self.rdclass != other.rdclass or self.rdtype != other.rdtype:
+            return False
+        our_relative = False
+        their_relative = False
+        try:
+            our = self.to_digestable()
+        except dns.name.NeedAbsoluteNameOrOrigin:
+            our = self.to_digestable(dns.name.root)
+            our_relative = True
+        try:
+            their = other.to_digestable()
+        except dns.name.NeedAbsoluteNameOrOrigin:
+            their = other.to_digestable(dns.name.root)
+            their_relative = True
+        if our_relative != their_relative:
+            return False
+        return our == their
+
+    def __ne__(self, other):
+        if not isinstance(other, Rdata):
+            return True
+        if self.rdclass != other.rdclass or self.rdtype != other.rdtype:
+            return True
+        return not self.__eq__(other)
+
+    def __lt__(self, other):
+        if (
+            not isinstance(other, Rdata)
+            or self.rdclass != other.rdclass
+            or self.rdtype != other.rdtype
+        ):
+            return NotImplemented
+        return self._cmp(other) < 0
+
+    def __le__(self, other):
+        if (
+            not isinstance(other, Rdata)
+            or self.rdclass != other.rdclass
+            or self.rdtype != other.rdtype
+        ):
+            return NotImplemented
+        return self._cmp(other) <= 0
+
+    def __ge__(self, other):
+        if (
+            not isinstance(other, Rdata)
+            or self.rdclass != other.rdclass
+            or self.rdtype != other.rdtype
+        ):
+            return NotImplemented
+        return self._cmp(other) >= 0
+
+    def __gt__(self, other):
+        if (
+            not isinstance(other, Rdata)
+            or self.rdclass != other.rdclass
+            or self.rdtype != other.rdtype
+        ):
+            return NotImplemented
+        return self._cmp(other) > 0
+
+    def __hash__(self):
+        return hash(self.to_digestable(dns.name.root))
+
+    @classmethod
+    def from_text(
+        cls,
+        rdclass: dns.rdataclass.RdataClass,
+        rdtype: dns.rdatatype.RdataType,
+        tok: dns.tokenizer.Tokenizer,
+        origin: Optional[dns.name.Name] = None,
+        relativize: bool = True,
+        relativize_to: Optional[dns.name.Name] = None,
+    ) -> "Rdata":
+        raise NotImplementedError  # pragma: no cover
+
+    @classmethod
+    def from_wire_parser(
+        cls,
+        rdclass: dns.rdataclass.RdataClass,
+        rdtype: dns.rdatatype.RdataType,
+        parser: dns.wire.Parser,
+        origin: Optional[dns.name.Name] = None,
+    ) -> "Rdata":
+        raise NotImplementedError  # pragma: no cover
+
+    def replace(self, **kwargs: Any) -> "Rdata":
+        """
+        Create a new Rdata instance based on the instance replace was
+        invoked on. It is possible to pass different parameters to
+        override the corresponding properties of the base Rdata.
+
+        Any field specific to the Rdata type can be replaced, but the
+        *rdtype* and *rdclass* fields cannot.
+
+        Returns an instance of the same Rdata subclass as *self*.
+        """
+
+        # Get the constructor parameters.
+        parameters = inspect.signature(self.__init__).parameters  # type: ignore
+
+        # Ensure that all of the arguments correspond to valid fields.
+        # Don't allow rdclass or rdtype to be changed, though.
+        for key in kwargs:
+            if key == "rdcomment":
+                continue
+            if key not in parameters:
+                raise AttributeError(
+                    f"'{self.__class__.__name__}' object has no attribute '{key}'"
+                )
+            if key in ("rdclass", "rdtype"):
+                raise AttributeError(
+                    f"Cannot overwrite '{self.__class__.__name__}' attribute '{key}'"
+                )
+
+        # Construct the parameter list.  For each field, use the value in
+        # kwargs if present, and the current value otherwise.
+        args = (kwargs.get(key, getattr(self, key)) for key in parameters)
+
+        # Create, validate, and return the new object.
+        rd = self.__class__(*args)
+        # The comment is not set in the constructor, so give it special
+        # handling.
+        rdcomment = kwargs.get("rdcomment", self.rdcomment)
+        if rdcomment is not None:
+            object.__setattr__(rd, "rdcomment", rdcomment)
+        return rd
+
+    # Type checking and conversion helpers.  These are class methods as
+    # they don't touch object state and may be useful to others.
+
+    @classmethod
+    def _as_rdataclass(cls, value):
+        return dns.rdataclass.RdataClass.make(value)
+
+    @classmethod
+    def _as_rdatatype(cls, value):
+        return dns.rdatatype.RdataType.make(value)
+
+    @classmethod
+    def _as_bytes(
+        cls,
+        value: Any,
+        encode: bool = False,
+        max_length: Optional[int] = None,
+        empty_ok: bool = True,
+    ) -> bytes:
+        if encode and isinstance(value, str):
+            bvalue = value.encode()
+        elif isinstance(value, bytearray):
+            bvalue = bytes(value)
+        elif isinstance(value, bytes):
+            bvalue = value
+        else:
+            raise ValueError("not bytes")
+        if max_length is not None and len(bvalue) > max_length:
+            raise ValueError("too long")
+        if not empty_ok and len(bvalue) == 0:
+            raise ValueError("empty bytes not allowed")
+        return bvalue
+
+    @classmethod
+    def _as_name(cls, value):
+        # Note that proper name conversion (e.g. with origin and IDNA
+        # awareness) is expected to be done via from_text.  This is just
+        # a simple thing for people invoking the constructor directly.
+        if isinstance(value, str):
+            return dns.name.from_text(value)
+        elif not isinstance(value, dns.name.Name):
+            raise ValueError("not a name")
+        return value
+
+    @classmethod
+    def _as_uint8(cls, value):
+        if not isinstance(value, int):
+            raise ValueError("not an integer")
+        if value < 0 or value > 255:
+            raise ValueError("not a uint8")
+        return value
+
+    @classmethod
+    def _as_uint16(cls, value):
+        if not isinstance(value, int):
+            raise ValueError("not an integer")
+        if value < 0 or value > 65535:
+            raise ValueError("not a uint16")
+        return value
+
+    @classmethod
+    def _as_uint32(cls, value):
+        if not isinstance(value, int):
+            raise ValueError("not an integer")
+        if value < 0 or value > 4294967295:
+            raise ValueError("not a uint32")
+        return value
+
+    @classmethod
+    def _as_uint48(cls, value):
+        if not isinstance(value, int):
+            raise ValueError("not an integer")
+        if value < 0 or value > 281474976710655:
+            raise ValueError("not a uint48")
+        return value
+
+    @classmethod
+    def _as_int(cls, value, low=None, high=None):
+        if not isinstance(value, int):
+            raise ValueError("not an integer")
+        if low is not None and value < low:
+            raise ValueError("value too small")
+        if high is not None and value > high:
+            raise ValueError("value too large")
+        return value
+
+    @classmethod
+    def _as_ipv4_address(cls, value):
+        if isinstance(value, str):
+            return dns.ipv4.canonicalize(value)
+        elif isinstance(value, bytes):
+            return dns.ipv4.inet_ntoa(value)
+        else:
+            raise ValueError("not an IPv4 address")
+
+    @classmethod
+    def _as_ipv6_address(cls, value):
+        if isinstance(value, str):
+            return dns.ipv6.canonicalize(value)
+        elif isinstance(value, bytes):
+            return dns.ipv6.inet_ntoa(value)
+        else:
+            raise ValueError("not an IPv6 address")
+
+    @classmethod
+    def _as_bool(cls, value):
+        if isinstance(value, bool):
+            return value
+        else:
+            raise ValueError("not a boolean")
+
+    @classmethod
+    def _as_ttl(cls, value):
+        if isinstance(value, int):
+            return cls._as_int(value, 0, dns.ttl.MAX_TTL)
+        elif isinstance(value, str):
+            return dns.ttl.from_text(value)
+        else:
+            raise ValueError("not a TTL")
+
+    @classmethod
+    def _as_tuple(cls, value, as_value):
+        try:
+            # For user convenience, if value is a singleton of the list
+            # element type, wrap it in a tuple.
+            return (as_value(value),)
+        except Exception:
+            # Otherwise, check each element of the iterable *value*
+            # against *as_value*.
+            return tuple(as_value(v) for v in value)
+
+    # Processing order
+
+    @classmethod
+    def _processing_order(cls, iterable):
+        items = list(iterable)
+        random.shuffle(items)
+        return items
+
+
+@dns.immutable.immutable
+class GenericRdata(Rdata):
+    """Generic Rdata Class
+
+    This class is used for rdata types for which we have no better
+    implementation.  It implements the DNS "unknown RRs" scheme.
+    """
+
+    __slots__ = ["data"]
+
+    def __init__(self, rdclass, rdtype, data):
+        super().__init__(rdclass, rdtype)
+        self.data = data
+
+    def to_text(
+        self,
+        origin: Optional[dns.name.Name] = None,
+        relativize: bool = True,
+        **kw: Dict[str, Any],
+    ) -> str:
+        return r"\# %d " % len(self.data) + _hexify(self.data, **kw)
+
+    @classmethod
+    def from_text(
+        cls, rdclass, rdtype, tok, origin=None, relativize=True, relativize_to=None
+    ):
+        token = tok.get()
+        if not token.is_identifier() or token.value != r"\#":
+            raise dns.exception.SyntaxError(r"generic rdata does not start with \#")
+        length = tok.get_int()
+        hex = tok.concatenate_remaining_identifiers(True).encode()
+        data = binascii.unhexlify(hex)
+        if len(data) != length:
+            raise dns.exception.SyntaxError("generic rdata hex data has wrong length")
+        return cls(rdclass, rdtype, data)
+
+    def _to_wire(self, file, compress=None, origin=None, canonicalize=False):
+        file.write(self.data)
+
+    @classmethod
+    def from_wire_parser(cls, rdclass, rdtype, parser, origin=None):
+        return cls(rdclass, rdtype, parser.get_remaining())
+
+
+_rdata_classes: Dict[Tuple[dns.rdataclass.RdataClass, dns.rdatatype.RdataType], Any] = (
+    {}
+)
+_module_prefix = "dns.rdtypes"
+_dynamic_load_allowed = True
+
+
+def get_rdata_class(rdclass, rdtype, use_generic=True):
+    cls = _rdata_classes.get((rdclass, rdtype))
+    if not cls:
+        cls = _rdata_classes.get((dns.rdatatype.ANY, rdtype))
+        if not cls and _dynamic_load_allowed:
+            rdclass_text = dns.rdataclass.to_text(rdclass)
+            rdtype_text = dns.rdatatype.to_text(rdtype)
+            rdtype_text = rdtype_text.replace("-", "_")
+            try:
+                mod = import_module(
+                    ".".join([_module_prefix, rdclass_text, rdtype_text])
+                )
+                cls = getattr(mod, rdtype_text)
+                _rdata_classes[(rdclass, rdtype)] = cls
+            except ImportError:
+                try:
+                    mod = import_module(".".join([_module_prefix, "ANY", rdtype_text]))
+                    cls = getattr(mod, rdtype_text)
+                    _rdata_classes[(dns.rdataclass.ANY, rdtype)] = cls
+                    _rdata_classes[(rdclass, rdtype)] = cls
+                except ImportError:
+                    pass
+    if not cls and use_generic:
+        cls = GenericRdata
+        _rdata_classes[(rdclass, rdtype)] = cls
+    return cls
+
+
+def load_all_types(disable_dynamic_load=True):
+    """Load all rdata types for which dnspython has a non-generic implementation.
+
+    Normally dnspython loads DNS rdatatype implementations on demand, but in some
+    specialized cases loading all types at an application-controlled time is preferred.
+
+    If *disable_dynamic_load*, a ``bool``, is ``True`` then dnspython will not attempt
+    to use its dynamic loading mechanism if an unknown type is subsequently encountered,
+    and will simply use the ``GenericRdata`` class.
+    """
+    # Load class IN and ANY types.
+    for rdtype in dns.rdatatype.RdataType:
+        get_rdata_class(dns.rdataclass.IN, rdtype, False)
+    # Load the one non-ANY implementation we have in CH.  Everything
+    # else in CH is an ANY type, and we'll discover those on demand but won't
+    # have to import anything.
+    get_rdata_class(dns.rdataclass.CH, dns.rdatatype.A, False)
+    if disable_dynamic_load:
+        # Now disable dynamic loading so any subsequent unknown type immediately becomes
+        # GenericRdata without a load attempt.
+        global _dynamic_load_allowed
+        _dynamic_load_allowed = False
+
+
+def from_text(
+    rdclass: Union[dns.rdataclass.RdataClass, str],
+    rdtype: Union[dns.rdatatype.RdataType, str],
+    tok: Union[dns.tokenizer.Tokenizer, str],
+    origin: Optional[dns.name.Name] = None,
+    relativize: bool = True,
+    relativize_to: Optional[dns.name.Name] = None,
+    idna_codec: Optional[dns.name.IDNACodec] = None,
+) -> Rdata:
+    """Build an rdata object from text format.
+
+    This function attempts to dynamically load a class which
+    implements the specified rdata class and type.  If there is no
+    class-and-type-specific implementation, the GenericRdata class
+    is used.
+
+    Once a class is chosen, its from_text() class method is called
+    with the parameters to this function.
+
+    If *tok* is a ``str``, then a tokenizer is created and the string
+    is used as its input.
+
+    *rdclass*, a ``dns.rdataclass.RdataClass`` or ``str``, the rdataclass.
+
+    *rdtype*, a ``dns.rdatatype.RdataType`` or ``str``, the rdatatype.
+
+    *tok*, a ``dns.tokenizer.Tokenizer`` or a ``str``.
+
+    *origin*, a ``dns.name.Name`` (or ``None``), the
+    origin to use for relative names.
+
+    *relativize*, a ``bool``.  If true, name will be relativized.
+
+    *relativize_to*, a ``dns.name.Name`` (or ``None``), the origin to use
+    when relativizing names.  If not set, the *origin* value will be used.
+
+    *idna_codec*, a ``dns.name.IDNACodec``, specifies the IDNA
+    encoder/decoder to use if a tokenizer needs to be created.  If
+    ``None``, the default IDNA 2003 encoder/decoder is used.  If a
+    tokenizer is not created, then the codec associated with the tokenizer
+    is the one that is used.
+
+    Returns an instance of the chosen Rdata subclass.
+
+    """
+    if isinstance(tok, str):
+        tok = dns.tokenizer.Tokenizer(tok, idna_codec=idna_codec)
+    rdclass = dns.rdataclass.RdataClass.make(rdclass)
+    rdtype = dns.rdatatype.RdataType.make(rdtype)
+    cls = get_rdata_class(rdclass, rdtype)
+    with dns.exception.ExceptionWrapper(dns.exception.SyntaxError):
+        rdata = None
+        if cls != GenericRdata:
+            # peek at first token
+            token = tok.get()
+            tok.unget(token)
+            if token.is_identifier() and token.value == r"\#":
+                #
+                # Known type using the generic syntax.  Extract the
+                # wire form from the generic syntax, and then run
+                # from_wire on it.
+                #
+                grdata = GenericRdata.from_text(
+                    rdclass, rdtype, tok, origin, relativize, relativize_to
+                )
+                rdata = from_wire(
+                    rdclass, rdtype, grdata.data, 0, len(grdata.data), origin
+                )
+                #
+                # If this comparison isn't equal, then there must have been
+                # compressed names in the wire format, which is an error,
+                # there being no reasonable context to decompress with.
+                #
+                rwire = rdata.to_wire()
+                if rwire != grdata.data:
+                    raise dns.exception.SyntaxError(
+                        "compressed data in "
+                        "generic syntax form "
+                        "of known rdatatype"
+                    )
+        if rdata is None:
+            rdata = cls.from_text(
+                rdclass, rdtype, tok, origin, relativize, relativize_to
+            )
+        token = tok.get_eol_as_token()
+        if token.comment is not None:
+            object.__setattr__(rdata, "rdcomment", token.comment)
+        return rdata
+
+
+def from_wire_parser(
+    rdclass: Union[dns.rdataclass.RdataClass, str],
+    rdtype: Union[dns.rdatatype.RdataType, str],
+    parser: dns.wire.Parser,
+    origin: Optional[dns.name.Name] = None,
+) -> Rdata:
+    """Build an rdata object from wire format
+
+    This function attempts to dynamically load a class which
+    implements the specified rdata class and type.  If there is no
+    class-and-type-specific implementation, the GenericRdata class
+    is used.
+
+    Once a class is chosen, its from_wire() class method is called
+    with the parameters to this function.
+
+    *rdclass*, a ``dns.rdataclass.RdataClass`` or ``str``, the rdataclass.
+
+    *rdtype*, a ``dns.rdatatype.RdataType`` or ``str``, the rdatatype.
+
+    *parser*, a ``dns.wire.Parser``, the parser, which should be
+    restricted to the rdata length.
+
+    *origin*, a ``dns.name.Name`` (or ``None``).  If not ``None``,
+    then names will be relativized to this origin.
+
+    Returns an instance of the chosen Rdata subclass.
+    """
+
+    rdclass = dns.rdataclass.RdataClass.make(rdclass)
+    rdtype = dns.rdatatype.RdataType.make(rdtype)
+    cls = get_rdata_class(rdclass, rdtype)
+    with dns.exception.ExceptionWrapper(dns.exception.FormError):
+        return cls.from_wire_parser(rdclass, rdtype, parser, origin)
+
+
+def from_wire(
+    rdclass: Union[dns.rdataclass.RdataClass, str],
+    rdtype: Union[dns.rdatatype.RdataType, str],
+    wire: bytes,
+    current: int,
+    rdlen: int,
+    origin: Optional[dns.name.Name] = None,
+) -> Rdata:
+    """Build an rdata object from wire format
+
+    This function attempts to dynamically load a class which
+    implements the specified rdata class and type.  If there is no
+    class-and-type-specific implementation, the GenericRdata class
+    is used.
+
+    Once a class is chosen, its from_wire() class method is called
+    with the parameters to this function.
+
+    *rdclass*, an ``int``, the rdataclass.
+
+    *rdtype*, an ``int``, the rdatatype.
+
+    *wire*, a ``bytes``, the wire-format message.
+
+    *current*, an ``int``, the offset in wire of the beginning of
+    the rdata.
+
+    *rdlen*, an ``int``, the length of the wire-format rdata
+
+    *origin*, a ``dns.name.Name`` (or ``None``).  If not ``None``,
+    then names will be relativized to this origin.
+
+    Returns an instance of the chosen Rdata subclass.
+    """
+    parser = dns.wire.Parser(wire, current)
+    with parser.restrict_to(rdlen):
+        return from_wire_parser(rdclass, rdtype, parser, origin)
+
+
+class RdatatypeExists(dns.exception.DNSException):
+    """DNS rdatatype already exists."""
+
+    supp_kwargs = {"rdclass", "rdtype"}
+    fmt = (
+        "The rdata type with class {rdclass:d} and rdtype {rdtype:d} "
+        + "already exists."
+    )
+
+
+def register_type(
+    implementation: Any,
+    rdtype: int,
+    rdtype_text: str,
+    is_singleton: bool = False,
+    rdclass: dns.rdataclass.RdataClass = dns.rdataclass.IN,
+) -> None:
+    """Dynamically register a module to handle an rdatatype.
+
+    *implementation*, a module implementing the type in the usual dnspython
+    way.
+
+    *rdtype*, an ``int``, the rdatatype to register.
+
+    *rdtype_text*, a ``str``, the textual form of the rdatatype.
+
+    *is_singleton*, a ``bool``, indicating if the type is a singleton (i.e.
+    RRsets of the type can have only one member.)
+
+    *rdclass*, the rdataclass of the type, or ``dns.rdataclass.ANY`` if
+    it applies to all classes.
+    """
+
+    rdtype = dns.rdatatype.RdataType.make(rdtype)
+    existing_cls = get_rdata_class(rdclass, rdtype)
+    if existing_cls != GenericRdata or dns.rdatatype.is_metatype(rdtype):
+        raise RdatatypeExists(rdclass=rdclass, rdtype=rdtype)
+    _rdata_classes[(rdclass, rdtype)] = getattr(
+        implementation, rdtype_text.replace("-", "_")
+    )
+    dns.rdatatype.register_type(rdtype, rdtype_text, is_singleton)
diff --git a/.venv/lib/python3.12/site-packages/dns/rdataclass.py b/.venv/lib/python3.12/site-packages/dns/rdataclass.py
new file mode 100644
index 00000000..89b85a79
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/dns/rdataclass.py
@@ -0,0 +1,118 @@
+# Copyright (C) Dnspython Contributors, see LICENSE for text of ISC license
+
+# Copyright (C) 2001-2017 Nominum, Inc.
+#
+# Permission to use, copy, modify, and distribute this software and its
+# documentation for any purpose with or without fee is hereby granted,
+# provided that the above copyright notice and this permission notice
+# appear in all copies.
+#
+# THE SOFTWARE IS PROVIDED "AS IS" AND NOMINUM DISCLAIMS ALL WARRANTIES
+# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL NOMINUM BE LIABLE FOR
+# ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+# ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT
+# OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+
+"""DNS Rdata Classes."""
+
+import dns.enum
+import dns.exception
+
+
+class RdataClass(dns.enum.IntEnum):
+    """DNS Rdata Class"""
+
+    RESERVED0 = 0
+    IN = 1
+    INTERNET = IN
+    CH = 3
+    CHAOS = CH
+    HS = 4
+    HESIOD = HS
+    NONE = 254
+    ANY = 255
+
+    @classmethod
+    def _maximum(cls):
+        return 65535
+
+    @classmethod
+    def _short_name(cls):
+        return "class"
+
+    @classmethod
+    def _prefix(cls):
+        return "CLASS"
+
+    @classmethod
+    def _unknown_exception_class(cls):
+        return UnknownRdataclass
+
+
+_metaclasses = {RdataClass.NONE, RdataClass.ANY}
+
+
+class UnknownRdataclass(dns.exception.DNSException):
+    """A DNS class is unknown."""
+
+
+def from_text(text: str) -> RdataClass:
+    """Convert text into a DNS rdata class value.
+
+    The input text can be a defined DNS RR class mnemonic or
+    instance of the DNS generic class syntax.
+
+    For example, "IN" and "CLASS1" will both result in a value of 1.
+
+    Raises ``dns.rdatatype.UnknownRdataclass`` if the class is unknown.
+
+    Raises ``ValueError`` if the rdata class value is not >= 0 and <= 65535.
+
+    Returns a ``dns.rdataclass.RdataClass``.
+    """
+
+    return RdataClass.from_text(text)
+
+
+def to_text(value: RdataClass) -> str:
+    """Convert a DNS rdata class value to text.
+
+    If the value has a known mnemonic, it will be used, otherwise the
+    DNS generic class syntax will be used.
+
+    Raises ``ValueError`` if the rdata class value is not >= 0 and <= 65535.
+
+    Returns a ``str``.
+    """
+
+    return RdataClass.to_text(value)
+
+
+def is_metaclass(rdclass: RdataClass) -> bool:
+    """True if the specified class is a metaclass.
+
+    The currently defined metaclasses are ANY and NONE.
+
+    *rdclass* is a ``dns.rdataclass.RdataClass``.
+    """
+
+    if rdclass in _metaclasses:
+        return True
+    return False
+
+
+### BEGIN generated RdataClass constants
+
+RESERVED0 = RdataClass.RESERVED0
+IN = RdataClass.IN
+INTERNET = RdataClass.INTERNET
+CH = RdataClass.CH
+CHAOS = RdataClass.CHAOS
+HS = RdataClass.HS
+HESIOD = RdataClass.HESIOD
+NONE = RdataClass.NONE
+ANY = RdataClass.ANY
+
+### END generated RdataClass constants
diff --git a/.venv/lib/python3.12/site-packages/dns/rdataset.py b/.venv/lib/python3.12/site-packages/dns/rdataset.py
new file mode 100644
index 00000000..39cab236
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/dns/rdataset.py
@@ -0,0 +1,512 @@
+# Copyright (C) Dnspython Contributors, see LICENSE for text of ISC license
+
+# Copyright (C) 2001-2017 Nominum, Inc.
+#
+# Permission to use, copy, modify, and distribute this software and its
+# documentation for any purpose with or without fee is hereby granted,
+# provided that the above copyright notice and this permission notice
+# appear in all copies.
+#
+# THE SOFTWARE IS PROVIDED "AS IS" AND NOMINUM DISCLAIMS ALL WARRANTIES
+# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL NOMINUM BE LIABLE FOR
+# ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+# ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT
+# OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+
+"""DNS rdatasets (an rdataset is a set of rdatas of a given type and class)"""
+
+import io
+import random
+import struct
+from typing import Any, Collection, Dict, List, Optional, Union, cast
+
+import dns.exception
+import dns.immutable
+import dns.name
+import dns.rdata
+import dns.rdataclass
+import dns.rdatatype
+import dns.renderer
+import dns.set
+import dns.ttl
+
+# define SimpleSet here for backwards compatibility
+SimpleSet = dns.set.Set
+
+
+class DifferingCovers(dns.exception.DNSException):
+    """An attempt was made to add a DNS SIG/RRSIG whose covered type
+    is not the same as that of the other rdatas in the rdataset."""
+
+
+class IncompatibleTypes(dns.exception.DNSException):
+    """An attempt was made to add DNS RR data of an incompatible type."""
+
+
+class Rdataset(dns.set.Set):
+    """A DNS rdataset."""
+
+    __slots__ = ["rdclass", "rdtype", "covers", "ttl"]
+
+    def __init__(
+        self,
+        rdclass: dns.rdataclass.RdataClass,
+        rdtype: dns.rdatatype.RdataType,
+        covers: dns.rdatatype.RdataType = dns.rdatatype.NONE,
+        ttl: int = 0,
+    ):
+        """Create a new rdataset of the specified class and type.
+
+        *rdclass*, a ``dns.rdataclass.RdataClass``, the rdataclass.
+
+        *rdtype*, an ``dns.rdatatype.RdataType``, the rdatatype.
+
+        *covers*, an ``dns.rdatatype.RdataType``, the covered rdatatype.
+
+        *ttl*, an ``int``, the TTL.
+        """
+
+        super().__init__()
+        self.rdclass = rdclass
+        self.rdtype: dns.rdatatype.RdataType = rdtype
+        self.covers: dns.rdatatype.RdataType = covers
+        self.ttl = ttl
+
+    def _clone(self):
+        obj = super()._clone()
+        obj.rdclass = self.rdclass
+        obj.rdtype = self.rdtype
+        obj.covers = self.covers
+        obj.ttl = self.ttl
+        return obj
+
+    def update_ttl(self, ttl: int) -> None:
+        """Perform TTL minimization.
+
+        Set the TTL of the rdataset to be the lesser of the set's current
+        TTL or the specified TTL.  If the set contains no rdatas, set the TTL
+        to the specified TTL.
+
+        *ttl*, an ``int`` or ``str``.
+        """
+        ttl = dns.ttl.make(ttl)
+        if len(self) == 0:
+            self.ttl = ttl
+        elif ttl < self.ttl:
+            self.ttl = ttl
+
+    def add(  # pylint: disable=arguments-differ,arguments-renamed
+        self, rd: dns.rdata.Rdata, ttl: Optional[int] = None
+    ) -> None:
+        """Add the specified rdata to the rdataset.
+
+        If the optional *ttl* parameter is supplied, then
+        ``self.update_ttl(ttl)`` will be called prior to adding the rdata.
+
+        *rd*, a ``dns.rdata.Rdata``, the rdata
+
+        *ttl*, an ``int``, the TTL.
+
+        Raises ``dns.rdataset.IncompatibleTypes`` if the type and class
+        do not match the type and class of the rdataset.
+
+        Raises ``dns.rdataset.DifferingCovers`` if the type is a signature
+        type and the covered type does not match that of the rdataset.
+        """
+
+        #
+        # If we're adding a signature, do some special handling to
+        # check that the signature covers the same type as the
+        # other rdatas in this rdataset.  If this is the first rdata
+        # in the set, initialize the covers field.
+        #
+        if self.rdclass != rd.rdclass or self.rdtype != rd.rdtype:
+            raise IncompatibleTypes
+        if ttl is not None:
+            self.update_ttl(ttl)
+        if self.rdtype == dns.rdatatype.RRSIG or self.rdtype == dns.rdatatype.SIG:
+            covers = rd.covers()
+            if len(self) == 0 and self.covers == dns.rdatatype.NONE:
+                self.covers = covers
+            elif self.covers != covers:
+                raise DifferingCovers
+        if dns.rdatatype.is_singleton(rd.rdtype) and len(self) > 0:
+            self.clear()
+        super().add(rd)
+
+    def union_update(self, other):
+        self.update_ttl(other.ttl)
+        super().union_update(other)
+
+    def intersection_update(self, other):
+        self.update_ttl(other.ttl)
+        super().intersection_update(other)
+
+    def update(self, other):
+        """Add all rdatas in other to self.
+
+        *other*, a ``dns.rdataset.Rdataset``, the rdataset from which
+        to update.
+        """
+
+        self.update_ttl(other.ttl)
+        super().update(other)
+
+    def _rdata_repr(self):
+        def maybe_truncate(s):
+            if len(s) > 100:
+                return s[:100] + "..."
+            return s
+
+        return "[" + ", ".join(f"<{maybe_truncate(str(rr))}>" for rr in self) + "]"
+
+    def __repr__(self):
+        if self.covers == 0:
+            ctext = ""
+        else:
+            ctext = "(" + dns.rdatatype.to_text(self.covers) + ")"
+        return (
+            "<DNS "
+            + dns.rdataclass.to_text(self.rdclass)
+            + " "
+            + dns.rdatatype.to_text(self.rdtype)
+            + ctext
+            + " rdataset: "
+            + self._rdata_repr()
+            + ">"
+        )
+
+    def __str__(self):
+        return self.to_text()
+
+    def __eq__(self, other):
+        if not isinstance(other, Rdataset):
+            return False
+        if (
+            self.rdclass != other.rdclass
+            or self.rdtype != other.rdtype
+            or self.covers != other.covers
+        ):
+            return False
+        return super().__eq__(other)
+
+    def __ne__(self, other):
+        return not self.__eq__(other)
+
+    def to_text(
+        self,
+        name: Optional[dns.name.Name] = None,
+        origin: Optional[dns.name.Name] = None,
+        relativize: bool = True,
+        override_rdclass: Optional[dns.rdataclass.RdataClass] = None,
+        want_comments: bool = False,
+        **kw: Dict[str, Any],
+    ) -> str:
+        """Convert the rdataset into DNS zone file format.
+
+        See ``dns.name.Name.choose_relativity`` for more information
+        on how *origin* and *relativize* determine the way names
+        are emitted.
+
+        Any additional keyword arguments are passed on to the rdata
+        ``to_text()`` method.
+
+        *name*, a ``dns.name.Name``.  If name is not ``None``, emit RRs with
+        *name* as the owner name.
+
+        *origin*, a ``dns.name.Name`` or ``None``, the origin for relative
+        names.
+
+        *relativize*, a ``bool``.  If ``True``, names will be relativized
+        to *origin*.
+
+        *override_rdclass*, a ``dns.rdataclass.RdataClass`` or ``None``.
+        If not ``None``, use this class instead of the Rdataset's class.
+
+        *want_comments*, a ``bool``.  If ``True``, emit comments for rdata
+        which have them.  The default is ``False``.
+        """
+
+        if name is not None:
+            name = name.choose_relativity(origin, relativize)
+            ntext = str(name)
+            pad = " "
+        else:
+            ntext = ""
+            pad = ""
+        s = io.StringIO()
+        if override_rdclass is not None:
+            rdclass = override_rdclass
+        else:
+            rdclass = self.rdclass
+        if len(self) == 0:
+            #
+            # Empty rdatasets are used for the question section, and in
+            # some dynamic updates, so we don't need to print out the TTL
+            # (which is meaningless anyway).
+            #
+            s.write(
+                f"{ntext}{pad}{dns.rdataclass.to_text(rdclass)} "
+                f"{dns.rdatatype.to_text(self.rdtype)}\n"
+            )
+        else:
+            for rd in self:
+                extra = ""
+                if want_comments:
+                    if rd.rdcomment:
+                        extra = f" ;{rd.rdcomment}"
+                s.write(
+                    "%s%s%d %s %s %s%s\n"
+                    % (
+                        ntext,
+                        pad,
+                        self.ttl,
+                        dns.rdataclass.to_text(rdclass),
+                        dns.rdatatype.to_text(self.rdtype),
+                        rd.to_text(origin=origin, relativize=relativize, **kw),
+                        extra,
+                    )
+                )
+        #
+        # We strip off the final \n for the caller's convenience in printing
+        #
+        return s.getvalue()[:-1]
+
+    def to_wire(
+        self,
+        name: dns.name.Name,
+        file: Any,
+        compress: Optional[dns.name.CompressType] = None,
+        origin: Optional[dns.name.Name] = None,
+        override_rdclass: Optional[dns.rdataclass.RdataClass] = None,
+        want_shuffle: bool = True,
+    ) -> int:
+        """Convert the rdataset to wire format.
+
+        *name*, a ``dns.name.Name`` is the owner name to use.
+
+        *file* is the file where the name is emitted (typically a
+        BytesIO file).
+
+        *compress*, a ``dict``, is the compression table to use.  If
+        ``None`` (the default), names will not be compressed.
+
+        *origin* is a ``dns.name.Name`` or ``None``.  If the name is
+        relative and origin is not ``None``, then *origin* will be appended
+        to it.
+
+        *override_rdclass*, an ``int``, is used as the class instead of the
+        class of the rdataset.  This is useful when rendering rdatasets
+        associated with dynamic updates.
+
+        *want_shuffle*, a ``bool``.  If ``True``, then the order of the
+        Rdatas within the Rdataset will be shuffled before rendering.
+
+        Returns an ``int``, the number of records emitted.
+        """
+
+        if override_rdclass is not None:
+            rdclass = override_rdclass
+            want_shuffle = False
+        else:
+            rdclass = self.rdclass
+        if len(self) == 0:
+            name.to_wire(file, compress, origin)
+            file.write(struct.pack("!HHIH", self.rdtype, rdclass, 0, 0))
+            return 1
+        else:
+            l: Union[Rdataset, List[dns.rdata.Rdata]]
+            if want_shuffle:
+                l = list(self)
+                random.shuffle(l)
+            else:
+                l = self
+            for rd in l:
+                name.to_wire(file, compress, origin)
+                file.write(struct.pack("!HHI", self.rdtype, rdclass, self.ttl))
+                with dns.renderer.prefixed_length(file, 2):
+                    rd.to_wire(file, compress, origin)
+            return len(self)
+
+    def match(
+        self,
+        rdclass: dns.rdataclass.RdataClass,
+        rdtype: dns.rdatatype.RdataType,
+        covers: dns.rdatatype.RdataType,
+    ) -> bool:
+        """Returns ``True`` if this rdataset matches the specified class,
+        type, and covers.
+        """
+        if self.rdclass == rdclass and self.rdtype == rdtype and self.covers == covers:
+            return True
+        return False
+
+    def processing_order(self) -> List[dns.rdata.Rdata]:
+        """Return rdatas in a valid processing order according to the type's
+        specification.  For example, MX records are in preference order from
+        lowest to highest preferences, with items of the same preference
+        shuffled.
+
+        For types that do not define a processing order, the rdatas are
+        simply shuffled.
+        """
+        if len(self) == 0:
+            return []
+        else:
+            return self[0]._processing_order(iter(self))
+
+
+@dns.immutable.immutable
+class ImmutableRdataset(Rdataset):  # lgtm[py/missing-equals]
+    """An immutable DNS rdataset."""
+
+    _clone_class = Rdataset
+
+    def __init__(self, rdataset: Rdataset):
+        """Create an immutable rdataset from the specified rdataset."""
+
+        super().__init__(
+            rdataset.rdclass, rdataset.rdtype, rdataset.covers, rdataset.ttl
+        )
+        self.items = dns.immutable.Dict(rdataset.items)
+
+    def update_ttl(self, ttl):
+        raise TypeError("immutable")
+
+    def add(self, rd, ttl=None):
+        raise TypeError("immutable")
+
+    def union_update(self, other):
+        raise TypeError("immutable")
+
+    def intersection_update(self, other):
+        raise TypeError("immutable")
+
+    def update(self, other):
+        raise TypeError("immutable")
+
+    def __delitem__(self, i):
+        raise TypeError("immutable")
+
+    # lgtm complains about these not raising ArithmeticError, but there is
+    # precedent for overrides of these methods in other classes to raise
+    # TypeError, and it seems like the better exception.
+
+    def __ior__(self, other):  # lgtm[py/unexpected-raise-in-special-method]
+        raise TypeError("immutable")
+
+    def __iand__(self, other):  # lgtm[py/unexpected-raise-in-special-method]
+        raise TypeError("immutable")
+
+    def __iadd__(self, other):  # lgtm[py/unexpected-raise-in-special-method]
+        raise TypeError("immutable")
+
+    def __isub__(self, other):  # lgtm[py/unexpected-raise-in-special-method]
+        raise TypeError("immutable")
+
+    def clear(self):
+        raise TypeError("immutable")
+
+    def __copy__(self):
+        return ImmutableRdataset(super().copy())
+
+    def copy(self):
+        return ImmutableRdataset(super().copy())
+
+    def union(self, other):
+        return ImmutableRdataset(super().union(other))
+
+    def intersection(self, other):
+        return ImmutableRdataset(super().intersection(other))
+
+    def difference(self, other):
+        return ImmutableRdataset(super().difference(other))
+
+    def symmetric_difference(self, other):
+        return ImmutableRdataset(super().symmetric_difference(other))
+
+
+def from_text_list(
+    rdclass: Union[dns.rdataclass.RdataClass, str],
+    rdtype: Union[dns.rdatatype.RdataType, str],
+    ttl: int,
+    text_rdatas: Collection[str],
+    idna_codec: Optional[dns.name.IDNACodec] = None,
+    origin: Optional[dns.name.Name] = None,
+    relativize: bool = True,
+    relativize_to: Optional[dns.name.Name] = None,
+) -> Rdataset:
+    """Create an rdataset with the specified class, type, and TTL, and with
+    the specified list of rdatas in text format.
+
+    *idna_codec*, a ``dns.name.IDNACodec``, specifies the IDNA
+    encoder/decoder to use; if ``None``, the default IDNA 2003
+    encoder/decoder is used.
+
+    *origin*, a ``dns.name.Name`` (or ``None``), the
+    origin to use for relative names.
+
+    *relativize*, a ``bool``.  If true, name will be relativized.
+
+    *relativize_to*, a ``dns.name.Name`` (or ``None``), the origin to use
+    when relativizing names.  If not set, the *origin* value will be used.
+
+    Returns a ``dns.rdataset.Rdataset`` object.
+    """
+
+    rdclass = dns.rdataclass.RdataClass.make(rdclass)
+    rdtype = dns.rdatatype.RdataType.make(rdtype)
+    r = Rdataset(rdclass, rdtype)
+    r.update_ttl(ttl)
+    for t in text_rdatas:
+        rd = dns.rdata.from_text(
+            r.rdclass, r.rdtype, t, origin, relativize, relativize_to, idna_codec
+        )
+        r.add(rd)
+    return r
+
+
+def from_text(
+    rdclass: Union[dns.rdataclass.RdataClass, str],
+    rdtype: Union[dns.rdatatype.RdataType, str],
+    ttl: int,
+    *text_rdatas: Any,
+) -> Rdataset:
+    """Create an rdataset with the specified class, type, and TTL, and with
+    the specified rdatas in text format.
+
+    Returns a ``dns.rdataset.Rdataset`` object.
+    """
+
+    return from_text_list(rdclass, rdtype, ttl, cast(Collection[str], text_rdatas))
+
+
+def from_rdata_list(ttl: int, rdatas: Collection[dns.rdata.Rdata]) -> Rdataset:
+    """Create an rdataset with the specified TTL, and with
+    the specified list of rdata objects.
+
+    Returns a ``dns.rdataset.Rdataset`` object.
+    """
+
+    if len(rdatas) == 0:
+        raise ValueError("rdata list must not be empty")
+    r = None
+    for rd in rdatas:
+        if r is None:
+            r = Rdataset(rd.rdclass, rd.rdtype)
+            r.update_ttl(ttl)
+        r.add(rd)
+    assert r is not None
+    return r
+
+
+def from_rdata(ttl: int, *rdatas: Any) -> Rdataset:
+    """Create an rdataset with the specified TTL, and with
+    the specified rdata objects.
+
+    Returns a ``dns.rdataset.Rdataset`` object.
+    """
+
+    return from_rdata_list(ttl, cast(Collection[dns.rdata.Rdata], rdatas))
diff --git a/.venv/lib/python3.12/site-packages/dns/rdatatype.py b/.venv/lib/python3.12/site-packages/dns/rdatatype.py
new file mode 100644
index 00000000..aa9e561c
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/dns/rdatatype.py
@@ -0,0 +1,336 @@
+# Copyright (C) Dnspython Contributors, see LICENSE for text of ISC license
+
+# Copyright (C) 2001-2017 Nominum, Inc.
+#
+# Permission to use, copy, modify, and distribute this software and its
+# documentation for any purpose with or without fee is hereby granted,
+# provided that the above copyright notice and this permission notice
+# appear in all copies.
+#
+# THE SOFTWARE IS PROVIDED "AS IS" AND NOMINUM DISCLAIMS ALL WARRANTIES
+# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL NOMINUM BE LIABLE FOR
+# ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+# ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT
+# OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+
+"""DNS Rdata Types."""
+
+from typing import Dict
+
+import dns.enum
+import dns.exception
+
+
+class RdataType(dns.enum.IntEnum):
+    """DNS Rdata Type"""
+
+    TYPE0 = 0
+    NONE = 0
+    A = 1
+    NS = 2
+    MD = 3
+    MF = 4
+    CNAME = 5
+    SOA = 6
+    MB = 7
+    MG = 8
+    MR = 9
+    NULL = 10
+    WKS = 11
+    PTR = 12
+    HINFO = 13
+    MINFO = 14
+    MX = 15
+    TXT = 16
+    RP = 17
+    AFSDB = 18
+    X25 = 19
+    ISDN = 20
+    RT = 21
+    NSAP = 22
+    NSAP_PTR = 23
+    SIG = 24
+    KEY = 25
+    PX = 26
+    GPOS = 27
+    AAAA = 28
+    LOC = 29
+    NXT = 30
+    SRV = 33
+    NAPTR = 35
+    KX = 36
+    CERT = 37
+    A6 = 38
+    DNAME = 39
+    OPT = 41
+    APL = 42
+    DS = 43
+    SSHFP = 44
+    IPSECKEY = 45
+    RRSIG = 46
+    NSEC = 47
+    DNSKEY = 48
+    DHCID = 49
+    NSEC3 = 50
+    NSEC3PARAM = 51
+    TLSA = 52
+    SMIMEA = 53
+    HIP = 55
+    NINFO = 56
+    CDS = 59
+    CDNSKEY = 60
+    OPENPGPKEY = 61
+    CSYNC = 62
+    ZONEMD = 63
+    SVCB = 64
+    HTTPS = 65
+    SPF = 99
+    UNSPEC = 103
+    NID = 104
+    L32 = 105
+    L64 = 106
+    LP = 107
+    EUI48 = 108
+    EUI64 = 109
+    TKEY = 249
+    TSIG = 250
+    IXFR = 251
+    AXFR = 252
+    MAILB = 253
+    MAILA = 254
+    ANY = 255
+    URI = 256
+    CAA = 257
+    AVC = 258
+    AMTRELAY = 260
+    RESINFO = 261
+    WALLET = 262
+    TA = 32768
+    DLV = 32769
+
+    @classmethod
+    def _maximum(cls):
+        return 65535
+
+    @classmethod
+    def _short_name(cls):
+        return "type"
+
+    @classmethod
+    def _prefix(cls):
+        return "TYPE"
+
+    @classmethod
+    def _extra_from_text(cls, text):
+        if text.find("-") >= 0:
+            try:
+                return cls[text.replace("-", "_")]
+            except KeyError:  # pragma: no cover
+                pass
+        return _registered_by_text.get(text)
+
+    @classmethod
+    def _extra_to_text(cls, value, current_text):
+        if current_text is None:
+            return _registered_by_value.get(value)
+        if current_text.find("_") >= 0:
+            return current_text.replace("_", "-")
+        return current_text
+
+    @classmethod
+    def _unknown_exception_class(cls):
+        return UnknownRdatatype
+
+
+_registered_by_text: Dict[str, RdataType] = {}
+_registered_by_value: Dict[RdataType, str] = {}
+
+_metatypes = {RdataType.OPT}
+
+_singletons = {
+    RdataType.SOA,
+    RdataType.NXT,
+    RdataType.DNAME,
+    RdataType.NSEC,
+    RdataType.CNAME,
+}
+
+
+class UnknownRdatatype(dns.exception.DNSException):
+    """DNS resource record type is unknown."""
+
+
+def from_text(text: str) -> RdataType:
+    """Convert text into a DNS rdata type value.
+
+    The input text can be a defined DNS RR type mnemonic or
+    instance of the DNS generic type syntax.
+
+    For example, "NS" and "TYPE2" will both result in a value of 2.
+
+    Raises ``dns.rdatatype.UnknownRdatatype`` if the type is unknown.
+
+    Raises ``ValueError`` if the rdata type value is not >= 0 and <= 65535.
+
+    Returns a ``dns.rdatatype.RdataType``.
+    """
+
+    return RdataType.from_text(text)
+
+
+def to_text(value: RdataType) -> str:
+    """Convert a DNS rdata type value to text.
+
+    If the value has a known mnemonic, it will be used, otherwise the
+    DNS generic type syntax will be used.
+
+    Raises ``ValueError`` if the rdata type value is not >= 0 and <= 65535.
+
+    Returns a ``str``.
+    """
+
+    return RdataType.to_text(value)
+
+
+def is_metatype(rdtype: RdataType) -> bool:
+    """True if the specified type is a metatype.
+
+    *rdtype* is a ``dns.rdatatype.RdataType``.
+
+    The currently defined metatypes are TKEY, TSIG, IXFR, AXFR, MAILA,
+    MAILB, ANY, and OPT.
+
+    Returns a ``bool``.
+    """
+
+    return (256 > rdtype >= 128) or rdtype in _metatypes
+
+
+def is_singleton(rdtype: RdataType) -> bool:
+    """Is the specified type a singleton type?
+
+    Singleton types can only have a single rdata in an rdataset, or a single
+    RR in an RRset.
+
+    The currently defined singleton types are CNAME, DNAME, NSEC, NXT, and
+    SOA.
+
+    *rdtype* is an ``int``.
+
+    Returns a ``bool``.
+    """
+
+    if rdtype in _singletons:
+        return True
+    return False
+
+
+# pylint: disable=redefined-outer-name
+def register_type(
+    rdtype: RdataType, rdtype_text: str, is_singleton: bool = False
+) -> None:
+    """Dynamically register an rdatatype.
+
+    *rdtype*, a ``dns.rdatatype.RdataType``, the rdatatype to register.
+
+    *rdtype_text*, a ``str``, the textual form of the rdatatype.
+
+    *is_singleton*, a ``bool``, indicating if the type is a singleton (i.e.
+    RRsets of the type can have only one member.)
+    """
+
+    _registered_by_text[rdtype_text] = rdtype
+    _registered_by_value[rdtype] = rdtype_text
+    if is_singleton:
+        _singletons.add(rdtype)
+
+
+### BEGIN generated RdataType constants
+
+TYPE0 = RdataType.TYPE0
+NONE = RdataType.NONE
+A = RdataType.A
+NS = RdataType.NS
+MD = RdataType.MD
+MF = RdataType.MF
+CNAME = RdataType.CNAME
+SOA = RdataType.SOA
+MB = RdataType.MB
+MG = RdataType.MG
+MR = RdataType.MR
+NULL = RdataType.NULL
+WKS = RdataType.WKS
+PTR = RdataType.PTR
+HINFO = RdataType.HINFO
+MINFO = RdataType.MINFO
+MX = RdataType.MX
+TXT = RdataType.TXT
+RP = RdataType.RP
+AFSDB = RdataType.AFSDB
+X25 = RdataType.X25
+ISDN = RdataType.ISDN
+RT = RdataType.RT
+NSAP = RdataType.NSAP
+NSAP_PTR = RdataType.NSAP_PTR
+SIG = RdataType.SIG
+KEY = RdataType.KEY
+PX = RdataType.PX
+GPOS = RdataType.GPOS
+AAAA = RdataType.AAAA
+LOC = RdataType.LOC
+NXT = RdataType.NXT
+SRV = RdataType.SRV
+NAPTR = RdataType.NAPTR
+KX = RdataType.KX
+CERT = RdataType.CERT
+A6 = RdataType.A6
+DNAME = RdataType.DNAME
+OPT = RdataType.OPT
+APL = RdataType.APL
+DS = RdataType.DS
+SSHFP = RdataType.SSHFP
+IPSECKEY = RdataType.IPSECKEY
+RRSIG = RdataType.RRSIG
+NSEC = RdataType.NSEC
+DNSKEY = RdataType.DNSKEY
+DHCID = RdataType.DHCID
+NSEC3 = RdataType.NSEC3
+NSEC3PARAM = RdataType.NSEC3PARAM
+TLSA = RdataType.TLSA
+SMIMEA = RdataType.SMIMEA
+HIP = RdataType.HIP
+NINFO = RdataType.NINFO
+CDS = RdataType.CDS
+CDNSKEY = RdataType.CDNSKEY
+OPENPGPKEY = RdataType.OPENPGPKEY
+CSYNC = RdataType.CSYNC
+ZONEMD = RdataType.ZONEMD
+SVCB = RdataType.SVCB
+HTTPS = RdataType.HTTPS
+SPF = RdataType.SPF
+UNSPEC = RdataType.UNSPEC
+NID = RdataType.NID
+L32 = RdataType.L32
+L64 = RdataType.L64
+LP = RdataType.LP
+EUI48 = RdataType.EUI48
+EUI64 = RdataType.EUI64
+TKEY = RdataType.TKEY
+TSIG = RdataType.TSIG
+IXFR = RdataType.IXFR
+AXFR = RdataType.AXFR
+MAILB = RdataType.MAILB
+MAILA = RdataType.MAILA
+ANY = RdataType.ANY
+URI = RdataType.URI
+CAA = RdataType.CAA
+AVC = RdataType.AVC
+AMTRELAY = RdataType.AMTRELAY
+RESINFO = RdataType.RESINFO
+WALLET = RdataType.WALLET
+TA = RdataType.TA
+DLV = RdataType.DLV
+
+### END generated RdataType constants
diff --git a/.venv/lib/python3.12/site-packages/dns/rdtypes/ANY/AFSDB.py b/.venv/lib/python3.12/site-packages/dns/rdtypes/ANY/AFSDB.py
new file mode 100644
index 00000000..06a3b970
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/dns/rdtypes/ANY/AFSDB.py
@@ -0,0 +1,45 @@
+# Copyright (C) Dnspython Contributors, see LICENSE for text of ISC license
+
+# Copyright (C) 2003-2007, 2009-2011 Nominum, Inc.
+#
+# Permission to use, copy, modify, and distribute this software and its
+# documentation for any purpose with or without fee is hereby granted,
+# provided that the above copyright notice and this permission notice
+# appear in all copies.
+#
+# THE SOFTWARE IS PROVIDED "AS IS" AND NOMINUM DISCLAIMS ALL WARRANTIES
+# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL NOMINUM BE LIABLE FOR
+# ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+# ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT
+# OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+
+import dns.immutable
+import dns.rdtypes.mxbase
+
+
+@dns.immutable.immutable
+class AFSDB(dns.rdtypes.mxbase.UncompressedDowncasingMX):
+    """AFSDB record"""
+
+    # Use the property mechanism to make "subtype" an alias for the
+    # "preference" attribute, and "hostname" an alias for the "exchange"
+    # attribute.
+    #
+    # This lets us inherit the UncompressedMX implementation but lets
+    # the caller use appropriate attribute names for the rdata type.
+    #
+    # We probably lose some performance vs. a cut-and-paste
+    # implementation, but this way we don't copy code, and that's
+    # good.
+
+    @property
+    def subtype(self):
+        "the AFSDB subtype"
+        return self.preference
+
+    @property
+    def hostname(self):
+        "the AFSDB hostname"
+        return self.exchange
diff --git a/.venv/lib/python3.12/site-packages/dns/rdtypes/ANY/AMTRELAY.py b/.venv/lib/python3.12/site-packages/dns/rdtypes/ANY/AMTRELAY.py
new file mode 100644
index 00000000..ed2b072b
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/dns/rdtypes/ANY/AMTRELAY.py
@@ -0,0 +1,91 @@
+# Copyright (C) Dnspython Contributors, see LICENSE for text of ISC license
+
+# Copyright (C) 2006, 2007, 2009-2011 Nominum, Inc.
+#
+# Permission to use, copy, modify, and distribute this software and its
+# documentation for any purpose with or without fee is hereby granted,
+# provided that the above copyright notice and this permission notice
+# appear in all copies.
+#
+# THE SOFTWARE IS PROVIDED "AS IS" AND NOMINUM DISCLAIMS ALL WARRANTIES
+# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL NOMINUM BE LIABLE FOR
+# ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+# ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT
+# OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+
+import struct
+
+import dns.exception
+import dns.immutable
+import dns.rdtypes.util
+
+
+class Relay(dns.rdtypes.util.Gateway):
+    name = "AMTRELAY relay"
+
+    @property
+    def relay(self):
+        return self.gateway
+
+
+@dns.immutable.immutable
+class AMTRELAY(dns.rdata.Rdata):
+    """AMTRELAY record"""
+
+    # see: RFC 8777
+
+    __slots__ = ["precedence", "discovery_optional", "relay_type", "relay"]
+
+    def __init__(
+        self, rdclass, rdtype, precedence, discovery_optional, relay_type, relay
+    ):
+        super().__init__(rdclass, rdtype)
+        relay = Relay(relay_type, relay)
+        self.precedence = self._as_uint8(precedence)
+        self.discovery_optional = self._as_bool(discovery_optional)
+        self.relay_type = relay.type
+        self.relay = relay.relay
+
+    def to_text(self, origin=None, relativize=True, **kw):
+        relay = Relay(self.relay_type, self.relay).to_text(origin, relativize)
+        return "%d %d %d %s" % (
+            self.precedence,
+            self.discovery_optional,
+            self.relay_type,
+            relay,
+        )
+
+    @classmethod
+    def from_text(
+        cls, rdclass, rdtype, tok, origin=None, relativize=True, relativize_to=None
+    ):
+        precedence = tok.get_uint8()
+        discovery_optional = tok.get_uint8()
+        if discovery_optional > 1:
+            raise dns.exception.SyntaxError("expecting 0 or 1")
+        discovery_optional = bool(discovery_optional)
+        relay_type = tok.get_uint8()
+        if relay_type > 0x7F:
+            raise dns.exception.SyntaxError("expecting an integer <= 127")
+        relay = Relay.from_text(relay_type, tok, origin, relativize, relativize_to)
+        return cls(
+            rdclass, rdtype, precedence, discovery_optional, relay_type, relay.relay
+        )
+
+    def _to_wire(self, file, compress=None, origin=None, canonicalize=False):
+        relay_type = self.relay_type | (self.discovery_optional << 7)
+        header = struct.pack("!BB", self.precedence, relay_type)
+        file.write(header)
+        Relay(self.relay_type, self.relay).to_wire(file, compress, origin, canonicalize)
+
+    @classmethod
+    def from_wire_parser(cls, rdclass, rdtype, parser, origin=None):
+        (precedence, relay_type) = parser.get_struct("!BB")
+        discovery_optional = bool(relay_type >> 7)
+        relay_type &= 0x7F
+        relay = Relay.from_wire_parser(relay_type, parser, origin)
+        return cls(
+            rdclass, rdtype, precedence, discovery_optional, relay_type, relay.relay
+        )
diff --git a/.venv/lib/python3.12/site-packages/dns/rdtypes/ANY/AVC.py b/.venv/lib/python3.12/site-packages/dns/rdtypes/ANY/AVC.py
new file mode 100644
index 00000000..a27ae2d6
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/dns/rdtypes/ANY/AVC.py
@@ -0,0 +1,26 @@
+# Copyright (C) Dnspython Contributors, see LICENSE for text of ISC license
+
+# Copyright (C) 2016 Nominum, Inc.
+#
+# Permission to use, copy, modify, and distribute this software and its
+# documentation for any purpose with or without fee is hereby granted,
+# provided that the above copyright notice and this permission notice
+# appear in all copies.
+#
+# THE SOFTWARE IS PROVIDED "AS IS" AND NOMINUM DISCLAIMS ALL WARRANTIES
+# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL NOMINUM BE LIABLE FOR
+# ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+# ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT
+# OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+
+import dns.immutable
+import dns.rdtypes.txtbase
+
+
+@dns.immutable.immutable
+class AVC(dns.rdtypes.txtbase.TXTBase):
+    """AVC record"""
+
+    # See: IANA dns parameters for AVC
diff --git a/.venv/lib/python3.12/site-packages/dns/rdtypes/ANY/CAA.py b/.venv/lib/python3.12/site-packages/dns/rdtypes/ANY/CAA.py
new file mode 100644
index 00000000..2e6a7e7e
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/dns/rdtypes/ANY/CAA.py
@@ -0,0 +1,71 @@
+# Copyright (C) Dnspython Contributors, see LICENSE for text of ISC license
+
+# Copyright (C) 2003-2007, 2009-2011 Nominum, Inc.
+#
+# Permission to use, copy, modify, and distribute this software and its
+# documentation for any purpose with or without fee is hereby granted,
+# provided that the above copyright notice and this permission notice
+# appear in all copies.
+#
+# THE SOFTWARE IS PROVIDED "AS IS" AND NOMINUM DISCLAIMS ALL WARRANTIES
+# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL NOMINUM BE LIABLE FOR
+# ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+# ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT
+# OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+
+import struct
+
+import dns.exception
+import dns.immutable
+import dns.rdata
+import dns.tokenizer
+
+
+@dns.immutable.immutable
+class CAA(dns.rdata.Rdata):
+    """CAA (Certification Authority Authorization) record"""
+
+    # see: RFC 6844
+
+    __slots__ = ["flags", "tag", "value"]
+
+    def __init__(self, rdclass, rdtype, flags, tag, value):
+        super().__init__(rdclass, rdtype)
+        self.flags = self._as_uint8(flags)
+        self.tag = self._as_bytes(tag, True, 255)
+        if not tag.isalnum():
+            raise ValueError("tag is not alphanumeric")
+        self.value = self._as_bytes(value)
+
+    def to_text(self, origin=None, relativize=True, **kw):
+        return '%u %s "%s"' % (
+            self.flags,
+            dns.rdata._escapify(self.tag),
+            dns.rdata._escapify(self.value),
+        )
+
+    @classmethod
+    def from_text(
+        cls, rdclass, rdtype, tok, origin=None, relativize=True, relativize_to=None
+    ):
+        flags = tok.get_uint8()
+        tag = tok.get_string().encode()
+        value = tok.get_string().encode()
+        return cls(rdclass, rdtype, flags, tag, value)
+
+    def _to_wire(self, file, compress=None, origin=None, canonicalize=False):
+        file.write(struct.pack("!B", self.flags))
+        l = len(self.tag)
+        assert l < 256
+        file.write(struct.pack("!B", l))
+        file.write(self.tag)
+        file.write(self.value)
+
+    @classmethod
+    def from_wire_parser(cls, rdclass, rdtype, parser, origin=None):
+        flags = parser.get_uint8()
+        tag = parser.get_counted_bytes()
+        value = parser.get_remaining()
+        return cls(rdclass, rdtype, flags, tag, value)
diff --git a/.venv/lib/python3.12/site-packages/dns/rdtypes/ANY/CDNSKEY.py b/.venv/lib/python3.12/site-packages/dns/rdtypes/ANY/CDNSKEY.py
new file mode 100644
index 00000000..b613409f
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/dns/rdtypes/ANY/CDNSKEY.py
@@ -0,0 +1,33 @@
+# Copyright (C) Dnspython Contributors, see LICENSE for text of ISC license
+
+# Copyright (C) 2004-2007, 2009-2011 Nominum, Inc.
+#
+# Permission to use, copy, modify, and distribute this software and its
+# documentation for any purpose with or without fee is hereby granted,
+# provided that the above copyright notice and this permission notice
+# appear in all copies.
+#
+# THE SOFTWARE IS PROVIDED "AS IS" AND NOMINUM DISCLAIMS ALL WARRANTIES
+# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL NOMINUM BE LIABLE FOR
+# ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+# ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT
+# OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+
+import dns.immutable
+import dns.rdtypes.dnskeybase  # lgtm[py/import-and-import-from]
+
+# pylint: disable=unused-import
+from dns.rdtypes.dnskeybase import (  # noqa: F401  lgtm[py/unused-import]
+    REVOKE,
+    SEP,
+    ZONE,
+)
+
+# pylint: enable=unused-import
+
+
+@dns.immutable.immutable
+class CDNSKEY(dns.rdtypes.dnskeybase.DNSKEYBase):
+    """CDNSKEY record"""
diff --git a/.venv/lib/python3.12/site-packages/dns/rdtypes/ANY/CDS.py b/.venv/lib/python3.12/site-packages/dns/rdtypes/ANY/CDS.py
new file mode 100644
index 00000000..8312b972
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/dns/rdtypes/ANY/CDS.py
@@ -0,0 +1,29 @@
+# Copyright (C) Dnspython Contributors, see LICENSE for text of ISC license
+
+# Copyright (C) 2003-2007, 2009-2011 Nominum, Inc.
+#
+# Permission to use, copy, modify, and distribute this software and its
+# documentation for any purpose with or without fee is hereby granted,
+# provided that the above copyright notice and this permission notice
+# appear in all copies.
+#
+# THE SOFTWARE IS PROVIDED "AS IS" AND NOMINUM DISCLAIMS ALL WARRANTIES
+# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL NOMINUM BE LIABLE FOR
+# ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+# ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT
+# OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+
+import dns.immutable
+import dns.rdtypes.dsbase
+
+
+@dns.immutable.immutable
+class CDS(dns.rdtypes.dsbase.DSBase):
+    """CDS record"""
+
+    _digest_length_by_type = {
+        **dns.rdtypes.dsbase.DSBase._digest_length_by_type,
+        0: 1,  # delete, RFC 8078 Sec. 4 (including Errata ID 5049)
+    }
diff --git a/.venv/lib/python3.12/site-packages/dns/rdtypes/ANY/CERT.py b/.venv/lib/python3.12/site-packages/dns/rdtypes/ANY/CERT.py
new file mode 100644
index 00000000..f369cc85
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/dns/rdtypes/ANY/CERT.py
@@ -0,0 +1,116 @@
+# Copyright (C) Dnspython Contributors, see LICENSE for text of ISC license
+
+# Copyright (C) 2003-2007, 2009-2011 Nominum, Inc.
+#
+# Permission to use, copy, modify, and distribute this software and its
+# documentation for any purpose with or without fee is hereby granted,
+# provided that the above copyright notice and this permission notice
+# appear in all copies.
+#
+# THE SOFTWARE IS PROVIDED "AS IS" AND NOMINUM DISCLAIMS ALL WARRANTIES
+# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL NOMINUM BE LIABLE FOR
+# ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+# ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT
+# OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+
+import base64
+import struct
+
+import dns.dnssectypes
+import dns.exception
+import dns.immutable
+import dns.rdata
+import dns.tokenizer
+
+_ctype_by_value = {
+    1: "PKIX",
+    2: "SPKI",
+    3: "PGP",
+    4: "IPKIX",
+    5: "ISPKI",
+    6: "IPGP",
+    7: "ACPKIX",
+    8: "IACPKIX",
+    253: "URI",
+    254: "OID",
+}
+
+_ctype_by_name = {
+    "PKIX": 1,
+    "SPKI": 2,
+    "PGP": 3,
+    "IPKIX": 4,
+    "ISPKI": 5,
+    "IPGP": 6,
+    "ACPKIX": 7,
+    "IACPKIX": 8,
+    "URI": 253,
+    "OID": 254,
+}
+
+
+def _ctype_from_text(what):
+    v = _ctype_by_name.get(what)
+    if v is not None:
+        return v
+    return int(what)
+
+
+def _ctype_to_text(what):
+    v = _ctype_by_value.get(what)
+    if v is not None:
+        return v
+    return str(what)
+
+
+@dns.immutable.immutable
+class CERT(dns.rdata.Rdata):
+    """CERT record"""
+
+    # see RFC 4398
+
+    __slots__ = ["certificate_type", "key_tag", "algorithm", "certificate"]
+
+    def __init__(
+        self, rdclass, rdtype, certificate_type, key_tag, algorithm, certificate
+    ):
+        super().__init__(rdclass, rdtype)
+        self.certificate_type = self._as_uint16(certificate_type)
+        self.key_tag = self._as_uint16(key_tag)
+        self.algorithm = self._as_uint8(algorithm)
+        self.certificate = self._as_bytes(certificate)
+
+    def to_text(self, origin=None, relativize=True, **kw):
+        certificate_type = _ctype_to_text(self.certificate_type)
+        return "%s %d %s %s" % (
+            certificate_type,
+            self.key_tag,
+            dns.dnssectypes.Algorithm.to_text(self.algorithm),
+            dns.rdata._base64ify(self.certificate, **kw),
+        )
+
+    @classmethod
+    def from_text(
+        cls, rdclass, rdtype, tok, origin=None, relativize=True, relativize_to=None
+    ):
+        certificate_type = _ctype_from_text(tok.get_string())
+        key_tag = tok.get_uint16()
+        algorithm = dns.dnssectypes.Algorithm.from_text(tok.get_string())
+        b64 = tok.concatenate_remaining_identifiers().encode()
+        certificate = base64.b64decode(b64)
+        return cls(rdclass, rdtype, certificate_type, key_tag, algorithm, certificate)
+
+    def _to_wire(self, file, compress=None, origin=None, canonicalize=False):
+        prefix = struct.pack(
+            "!HHB", self.certificate_type, self.key_tag, self.algorithm
+        )
+        file.write(prefix)
+        file.write(self.certificate)
+
+    @classmethod
+    def from_wire_parser(cls, rdclass, rdtype, parser, origin=None):
+        (certificate_type, key_tag, algorithm) = parser.get_struct("!HHB")
+        certificate = parser.get_remaining()
+        return cls(rdclass, rdtype, certificate_type, key_tag, algorithm, certificate)
diff --git a/.venv/lib/python3.12/site-packages/dns/rdtypes/ANY/CNAME.py b/.venv/lib/python3.12/site-packages/dns/rdtypes/ANY/CNAME.py
new file mode 100644
index 00000000..665e407c
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/dns/rdtypes/ANY/CNAME.py
@@ -0,0 +1,28 @@
+# Copyright (C) Dnspython Contributors, see LICENSE for text of ISC license
+
+# Copyright (C) 2003-2007, 2009-2011 Nominum, Inc.
+#
+# Permission to use, copy, modify, and distribute this software and its
+# documentation for any purpose with or without fee is hereby granted,
+# provided that the above copyright notice and this permission notice
+# appear in all copies.
+#
+# THE SOFTWARE IS PROVIDED "AS IS" AND NOMINUM DISCLAIMS ALL WARRANTIES
+# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL NOMINUM BE LIABLE FOR
+# ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+# ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT
+# OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+
+import dns.immutable
+import dns.rdtypes.nsbase
+
+
+@dns.immutable.immutable
+class CNAME(dns.rdtypes.nsbase.NSBase):
+    """CNAME record
+
+    Note: although CNAME is officially a singleton type, dnspython allows
+    non-singleton CNAME rdatasets because such sets have been commonly
+    used by BIND and other nameservers for load balancing."""
diff --git a/.venv/lib/python3.12/site-packages/dns/rdtypes/ANY/CSYNC.py b/.venv/lib/python3.12/site-packages/dns/rdtypes/ANY/CSYNC.py
new file mode 100644
index 00000000..2f972f6e
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/dns/rdtypes/ANY/CSYNC.py
@@ -0,0 +1,68 @@
+# Copyright (C) Dnspython Contributors, see LICENSE for text of ISC license
+
+# Copyright (C) 2004-2007, 2009-2011, 2016 Nominum, Inc.
+#
+# Permission to use, copy, modify, and distribute this software and its
+# documentation for any purpose with or without fee is hereby granted,
+# provided that the above copyright notice and this permission notice
+# appear in all copies.
+#
+# THE SOFTWARE IS PROVIDED "AS IS" AND NOMINUM DISCLAIMS ALL WARRANTIES
+# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL NOMINUM BE LIABLE FOR
+# ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+# ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT
+# OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+
+import struct
+
+import dns.exception
+import dns.immutable
+import dns.name
+import dns.rdata
+import dns.rdatatype
+import dns.rdtypes.util
+
+
+@dns.immutable.immutable
+class Bitmap(dns.rdtypes.util.Bitmap):
+    type_name = "CSYNC"
+
+
+@dns.immutable.immutable
+class CSYNC(dns.rdata.Rdata):
+    """CSYNC record"""
+
+    __slots__ = ["serial", "flags", "windows"]
+
+    def __init__(self, rdclass, rdtype, serial, flags, windows):
+        super().__init__(rdclass, rdtype)
+        self.serial = self._as_uint32(serial)
+        self.flags = self._as_uint16(flags)
+        if not isinstance(windows, Bitmap):
+            windows = Bitmap(windows)
+        self.windows = tuple(windows.windows)
+
+    def to_text(self, origin=None, relativize=True, **kw):
+        text = Bitmap(self.windows).to_text()
+        return "%d %d%s" % (self.serial, self.flags, text)
+
+    @classmethod
+    def from_text(
+        cls, rdclass, rdtype, tok, origin=None, relativize=True, relativize_to=None
+    ):
+        serial = tok.get_uint32()
+        flags = tok.get_uint16()
+        bitmap = Bitmap.from_text(tok)
+        return cls(rdclass, rdtype, serial, flags, bitmap)
+
+    def _to_wire(self, file, compress=None, origin=None, canonicalize=False):
+        file.write(struct.pack("!IH", self.serial, self.flags))
+        Bitmap(self.windows).to_wire(file)
+
+    @classmethod
+    def from_wire_parser(cls, rdclass, rdtype, parser, origin=None):
+        (serial, flags) = parser.get_struct("!IH")
+        bitmap = Bitmap.from_wire_parser(parser)
+        return cls(rdclass, rdtype, serial, flags, bitmap)
diff --git a/.venv/lib/python3.12/site-packages/dns/rdtypes/ANY/DLV.py b/.venv/lib/python3.12/site-packages/dns/rdtypes/ANY/DLV.py
new file mode 100644
index 00000000..6c134f18
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/dns/rdtypes/ANY/DLV.py
@@ -0,0 +1,24 @@
+# Copyright (C) Dnspython Contributors, see LICENSE for text of ISC license
+
+# Copyright (C) 2009-2011 Nominum, Inc.
+#
+# Permission to use, copy, modify, and distribute this software and its
+# documentation for any purpose with or without fee is hereby granted,
+# provided that the above copyright notice and this permission notice
+# appear in all copies.
+#
+# THE SOFTWARE IS PROVIDED "AS IS" AND NOMINUM DISCLAIMS ALL WARRANTIES
+# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL NOMINUM BE LIABLE FOR
+# ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+# ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT
+# OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+
+import dns.immutable
+import dns.rdtypes.dsbase
+
+
+@dns.immutable.immutable
+class DLV(dns.rdtypes.dsbase.DSBase):
+    """DLV record"""
diff --git a/.venv/lib/python3.12/site-packages/dns/rdtypes/ANY/DNAME.py b/.venv/lib/python3.12/site-packages/dns/rdtypes/ANY/DNAME.py
new file mode 100644
index 00000000..bbf9186c
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/dns/rdtypes/ANY/DNAME.py
@@ -0,0 +1,27 @@
+# Copyright (C) Dnspython Contributors, see LICENSE for text of ISC license
+
+# Copyright (C) 2003-2007, 2009-2011 Nominum, Inc.
+#
+# Permission to use, copy, modify, and distribute this software and its
+# documentation for any purpose with or without fee is hereby granted,
+# provided that the above copyright notice and this permission notice
+# appear in all copies.
+#
+# THE SOFTWARE IS PROVIDED "AS IS" AND NOMINUM DISCLAIMS ALL WARRANTIES
+# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL NOMINUM BE LIABLE FOR
+# ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+# ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT
+# OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+
+import dns.immutable
+import dns.rdtypes.nsbase
+
+
+@dns.immutable.immutable
+class DNAME(dns.rdtypes.nsbase.UncompressedNS):
+    """DNAME record"""
+
+    def _to_wire(self, file, compress=None, origin=None, canonicalize=False):
+        self.target.to_wire(file, None, origin, canonicalize)
diff --git a/.venv/lib/python3.12/site-packages/dns/rdtypes/ANY/DNSKEY.py b/.venv/lib/python3.12/site-packages/dns/rdtypes/ANY/DNSKEY.py
new file mode 100644
index 00000000..6d961a9f
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/dns/rdtypes/ANY/DNSKEY.py
@@ -0,0 +1,33 @@
+# Copyright (C) Dnspython Contributors, see LICENSE for text of ISC license
+
+# Copyright (C) 2004-2007, 2009-2011 Nominum, Inc.
+#
+# Permission to use, copy, modify, and distribute this software and its
+# documentation for any purpose with or without fee is hereby granted,
+# provided that the above copyright notice and this permission notice
+# appear in all copies.
+#
+# THE SOFTWARE IS PROVIDED "AS IS" AND NOMINUM DISCLAIMS ALL WARRANTIES
+# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL NOMINUM BE LIABLE FOR
+# ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+# ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT
+# OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+
+import dns.immutable
+import dns.rdtypes.dnskeybase  # lgtm[py/import-and-import-from]
+
+# pylint: disable=unused-import
+from dns.rdtypes.dnskeybase import (  # noqa: F401  lgtm[py/unused-import]
+    REVOKE,
+    SEP,
+    ZONE,
+)
+
+# pylint: enable=unused-import
+
+
+@dns.immutable.immutable
+class DNSKEY(dns.rdtypes.dnskeybase.DNSKEYBase):
+    """DNSKEY record"""
diff --git a/.venv/lib/python3.12/site-packages/dns/rdtypes/ANY/DS.py b/.venv/lib/python3.12/site-packages/dns/rdtypes/ANY/DS.py
new file mode 100644
index 00000000..58b3108d
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/dns/rdtypes/ANY/DS.py
@@ -0,0 +1,24 @@
+# Copyright (C) Dnspython Contributors, see LICENSE for text of ISC license
+
+# Copyright (C) 2003-2007, 2009-2011 Nominum, Inc.
+#
+# Permission to use, copy, modify, and distribute this software and its
+# documentation for any purpose with or without fee is hereby granted,
+# provided that the above copyright notice and this permission notice
+# appear in all copies.
+#
+# THE SOFTWARE IS PROVIDED "AS IS" AND NOMINUM DISCLAIMS ALL WARRANTIES
+# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL NOMINUM BE LIABLE FOR
+# ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+# ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT
+# OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+
+import dns.immutable
+import dns.rdtypes.dsbase
+
+
+@dns.immutable.immutable
+class DS(dns.rdtypes.dsbase.DSBase):
+    """DS record"""
diff --git a/.venv/lib/python3.12/site-packages/dns/rdtypes/ANY/EUI48.py b/.venv/lib/python3.12/site-packages/dns/rdtypes/ANY/EUI48.py
new file mode 100644
index 00000000..c843be50
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/dns/rdtypes/ANY/EUI48.py
@@ -0,0 +1,30 @@
+# Copyright (C) Dnspython Contributors, see LICENSE for text of ISC license
+
+# Copyright (C) 2015 Red Hat, Inc.
+# Author: Petr Spacek <pspacek@redhat.com>
+#
+# Permission to use, copy, modify, and distribute this software and its
+# documentation for any purpose with or without fee is hereby granted,
+# provided that the above copyright notice and this permission notice
+# appear in all copies.
+#
+# THE SOFTWARE IS PROVIDED 'AS IS' AND RED HAT DISCLAIMS ALL WARRANTIES
+# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL NOMINUM BE LIABLE FOR
+# ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+# ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT
+# OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+
+import dns.immutable
+import dns.rdtypes.euibase
+
+
+@dns.immutable.immutable
+class EUI48(dns.rdtypes.euibase.EUIBase):
+    """EUI48 record"""
+
+    # see: rfc7043.txt
+
+    byte_len = 6  # 0123456789ab (in hex)
+    text_len = byte_len * 3 - 1  # 01-23-45-67-89-ab
diff --git a/.venv/lib/python3.12/site-packages/dns/rdtypes/ANY/EUI64.py b/.venv/lib/python3.12/site-packages/dns/rdtypes/ANY/EUI64.py
new file mode 100644
index 00000000..f6d7e257
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/dns/rdtypes/ANY/EUI64.py
@@ -0,0 +1,30 @@
+# Copyright (C) Dnspython Contributors, see LICENSE for text of ISC license
+
+# Copyright (C) 2015 Red Hat, Inc.
+# Author: Petr Spacek <pspacek@redhat.com>
+#
+# Permission to use, copy, modify, and distribute this software and its
+# documentation for any purpose with or without fee is hereby granted,
+# provided that the above copyright notice and this permission notice
+# appear in all copies.
+#
+# THE SOFTWARE IS PROVIDED 'AS IS' AND RED HAT DISCLAIMS ALL WARRANTIES
+# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL NOMINUM BE LIABLE FOR
+# ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+# ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT
+# OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+
+import dns.immutable
+import dns.rdtypes.euibase
+
+
+@dns.immutable.immutable
+class EUI64(dns.rdtypes.euibase.EUIBase):
+    """EUI64 record"""
+
+    # see: rfc7043.txt
+
+    byte_len = 8  # 0123456789abcdef (in hex)
+    text_len = byte_len * 3 - 1  # 01-23-45-67-89-ab-cd-ef
diff --git a/.venv/lib/python3.12/site-packages/dns/rdtypes/ANY/GPOS.py b/.venv/lib/python3.12/site-packages/dns/rdtypes/ANY/GPOS.py
new file mode 100644
index 00000000..d79f4a06
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/dns/rdtypes/ANY/GPOS.py
@@ -0,0 +1,126 @@
+# Copyright (C) Dnspython Contributors, see LICENSE for text of ISC license
+
+# Copyright (C) 2003-2007, 2009-2011 Nominum, Inc.
+#
+# Permission to use, copy, modify, and distribute this software and its
+# documentation for any purpose with or without fee is hereby granted,
+# provided that the above copyright notice and this permission notice
+# appear in all copies.
+#
+# THE SOFTWARE IS PROVIDED "AS IS" AND NOMINUM DISCLAIMS ALL WARRANTIES
+# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL NOMINUM BE LIABLE FOR
+# ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+# ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT
+# OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+
+import struct
+
+import dns.exception
+import dns.immutable
+import dns.rdata
+import dns.tokenizer
+
+
+def _validate_float_string(what):
+    if len(what) == 0:
+        raise dns.exception.FormError
+    if what[0] == b"-"[0] or what[0] == b"+"[0]:
+        what = what[1:]
+    if what.isdigit():
+        return
+    try:
+        (left, right) = what.split(b".")
+    except ValueError:
+        raise dns.exception.FormError
+    if left == b"" and right == b"":
+        raise dns.exception.FormError
+    if not left == b"" and not left.decode().isdigit():
+        raise dns.exception.FormError
+    if not right == b"" and not right.decode().isdigit():
+        raise dns.exception.FormError
+
+
+@dns.immutable.immutable
+class GPOS(dns.rdata.Rdata):
+    """GPOS record"""
+
+    # see: RFC 1712
+
+    __slots__ = ["latitude", "longitude", "altitude"]
+
+    def __init__(self, rdclass, rdtype, latitude, longitude, altitude):
+        super().__init__(rdclass, rdtype)
+        if isinstance(latitude, float) or isinstance(latitude, int):
+            latitude = str(latitude)
+        if isinstance(longitude, float) or isinstance(longitude, int):
+            longitude = str(longitude)
+        if isinstance(altitude, float) or isinstance(altitude, int):
+            altitude = str(altitude)
+        latitude = self._as_bytes(latitude, True, 255)
+        longitude = self._as_bytes(longitude, True, 255)
+        altitude = self._as_bytes(altitude, True, 255)
+        _validate_float_string(latitude)
+        _validate_float_string(longitude)
+        _validate_float_string(altitude)
+        self.latitude = latitude
+        self.longitude = longitude
+        self.altitude = altitude
+        flat = self.float_latitude
+        if flat < -90.0 or flat > 90.0:
+            raise dns.exception.FormError("bad latitude")
+        flong = self.float_longitude
+        if flong < -180.0 or flong > 180.0:
+            raise dns.exception.FormError("bad longitude")
+
+    def to_text(self, origin=None, relativize=True, **kw):
+        return (
+            f"{self.latitude.decode()} {self.longitude.decode()} "
+            f"{self.altitude.decode()}"
+        )
+
+    @classmethod
+    def from_text(
+        cls, rdclass, rdtype, tok, origin=None, relativize=True, relativize_to=None
+    ):
+        latitude = tok.get_string()
+        longitude = tok.get_string()
+        altitude = tok.get_string()
+        return cls(rdclass, rdtype, latitude, longitude, altitude)
+
+    def _to_wire(self, file, compress=None, origin=None, canonicalize=False):
+        l = len(self.latitude)
+        assert l < 256
+        file.write(struct.pack("!B", l))
+        file.write(self.latitude)
+        l = len(self.longitude)
+        assert l < 256
+        file.write(struct.pack("!B", l))
+        file.write(self.longitude)
+        l = len(self.altitude)
+        assert l < 256
+        file.write(struct.pack("!B", l))
+        file.write(self.altitude)
+
+    @classmethod
+    def from_wire_parser(cls, rdclass, rdtype, parser, origin=None):
+        latitude = parser.get_counted_bytes()
+        longitude = parser.get_counted_bytes()
+        altitude = parser.get_counted_bytes()
+        return cls(rdclass, rdtype, latitude, longitude, altitude)
+
+    @property
+    def float_latitude(self):
+        "latitude as a floating point value"
+        return float(self.latitude)
+
+    @property
+    def float_longitude(self):
+        "longitude as a floating point value"
+        return float(self.longitude)
+
+    @property
+    def float_altitude(self):
+        "altitude as a floating point value"
+        return float(self.altitude)
diff --git a/.venv/lib/python3.12/site-packages/dns/rdtypes/ANY/HINFO.py b/.venv/lib/python3.12/site-packages/dns/rdtypes/ANY/HINFO.py
new file mode 100644
index 00000000..06ad3487
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/dns/rdtypes/ANY/HINFO.py
@@ -0,0 +1,64 @@
+# Copyright (C) Dnspython Contributors, see LICENSE for text of ISC license
+
+# Copyright (C) 2003-2007, 2009-2011 Nominum, Inc.
+#
+# Permission to use, copy, modify, and distribute this software and its
+# documentation for any purpose with or without fee is hereby granted,
+# provided that the above copyright notice and this permission notice
+# appear in all copies.
+#
+# THE SOFTWARE IS PROVIDED "AS IS" AND NOMINUM DISCLAIMS ALL WARRANTIES
+# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL NOMINUM BE LIABLE FOR
+# ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+# ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT
+# OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+
+import struct
+
+import dns.exception
+import dns.immutable
+import dns.rdata
+import dns.tokenizer
+
+
+@dns.immutable.immutable
+class HINFO(dns.rdata.Rdata):
+    """HINFO record"""
+
+    # see: RFC 1035
+
+    __slots__ = ["cpu", "os"]
+
+    def __init__(self, rdclass, rdtype, cpu, os):
+        super().__init__(rdclass, rdtype)
+        self.cpu = self._as_bytes(cpu, True, 255)
+        self.os = self._as_bytes(os, True, 255)
+
+    def to_text(self, origin=None, relativize=True, **kw):
+        return f'"{dns.rdata._escapify(self.cpu)}" "{dns.rdata._escapify(self.os)}"'
+
+    @classmethod
+    def from_text(
+        cls, rdclass, rdtype, tok, origin=None, relativize=True, relativize_to=None
+    ):
+        cpu = tok.get_string(max_length=255)
+        os = tok.get_string(max_length=255)
+        return cls(rdclass, rdtype, cpu, os)
+
+    def _to_wire(self, file, compress=None, origin=None, canonicalize=False):
+        l = len(self.cpu)
+        assert l < 256
+        file.write(struct.pack("!B", l))
+        file.write(self.cpu)
+        l = len(self.os)
+        assert l < 256
+        file.write(struct.pack("!B", l))
+        file.write(self.os)
+
+    @classmethod
+    def from_wire_parser(cls, rdclass, rdtype, parser, origin=None):
+        cpu = parser.get_counted_bytes()
+        os = parser.get_counted_bytes()
+        return cls(rdclass, rdtype, cpu, os)
diff --git a/.venv/lib/python3.12/site-packages/dns/rdtypes/ANY/HIP.py b/.venv/lib/python3.12/site-packages/dns/rdtypes/ANY/HIP.py
new file mode 100644
index 00000000..f3157da7
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/dns/rdtypes/ANY/HIP.py
@@ -0,0 +1,85 @@
+# Copyright (C) Dnspython Contributors, see LICENSE for text of ISC license
+
+# Copyright (C) 2010, 2011 Nominum, Inc.
+#
+# Permission to use, copy, modify, and distribute this software and its
+# documentation for any purpose with or without fee is hereby granted,
+# provided that the above copyright notice and this permission notice
+# appear in all copies.
+#
+# THE SOFTWARE IS PROVIDED "AS IS" AND NOMINUM DISCLAIMS ALL WARRANTIES
+# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL NOMINUM BE LIABLE FOR
+# ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+# ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT
+# OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+
+import base64
+import binascii
+import struct
+
+import dns.exception
+import dns.immutable
+import dns.rdata
+import dns.rdatatype
+
+
+@dns.immutable.immutable
+class HIP(dns.rdata.Rdata):
+    """HIP record"""
+
+    # see: RFC 5205
+
+    __slots__ = ["hit", "algorithm", "key", "servers"]
+
+    def __init__(self, rdclass, rdtype, hit, algorithm, key, servers):
+        super().__init__(rdclass, rdtype)
+        self.hit = self._as_bytes(hit, True, 255)
+        self.algorithm = self._as_uint8(algorithm)
+        self.key = self._as_bytes(key, True)
+        self.servers = self._as_tuple(servers, self._as_name)
+
+    def to_text(self, origin=None, relativize=True, **kw):
+        hit = binascii.hexlify(self.hit).decode()
+        key = base64.b64encode(self.key).replace(b"\n", b"").decode()
+        text = ""
+        servers = []
+        for server in self.servers:
+            servers.append(server.choose_relativity(origin, relativize))
+        if len(servers) > 0:
+            text += " " + " ".join(x.to_unicode() for x in servers)
+        return "%u %s %s%s" % (self.algorithm, hit, key, text)
+
+    @classmethod
+    def from_text(
+        cls, rdclass, rdtype, tok, origin=None, relativize=True, relativize_to=None
+    ):
+        algorithm = tok.get_uint8()
+        hit = binascii.unhexlify(tok.get_string().encode())
+        key = base64.b64decode(tok.get_string().encode())
+        servers = []
+        for token in tok.get_remaining():
+            server = tok.as_name(token, origin, relativize, relativize_to)
+            servers.append(server)
+        return cls(rdclass, rdtype, hit, algorithm, key, servers)
+
+    def _to_wire(self, file, compress=None, origin=None, canonicalize=False):
+        lh = len(self.hit)
+        lk = len(self.key)
+        file.write(struct.pack("!BBH", lh, self.algorithm, lk))
+        file.write(self.hit)
+        file.write(self.key)
+        for server in self.servers:
+            server.to_wire(file, None, origin, False)
+
+    @classmethod
+    def from_wire_parser(cls, rdclass, rdtype, parser, origin=None):
+        (lh, algorithm, lk) = parser.get_struct("!BBH")
+        hit = parser.get_bytes(lh)
+        key = parser.get_bytes(lk)
+        servers = []
+        while parser.remaining() > 0:
+            server = parser.get_name(origin)
+            servers.append(server)
+        return cls(rdclass, rdtype, hit, algorithm, key, servers)
diff --git a/.venv/lib/python3.12/site-packages/dns/rdtypes/ANY/ISDN.py b/.venv/lib/python3.12/site-packages/dns/rdtypes/ANY/ISDN.py
new file mode 100644
index 00000000..6428a0a8
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/dns/rdtypes/ANY/ISDN.py
@@ -0,0 +1,78 @@
+# Copyright (C) Dnspython Contributors, see LICENSE for text of ISC license
+
+# Copyright (C) 2003-2007, 2009-2011 Nominum, Inc.
+#
+# Permission to use, copy, modify, and distribute this software and its
+# documentation for any purpose with or without fee is hereby granted,
+# provided that the above copyright notice and this permission notice
+# appear in all copies.
+#
+# THE SOFTWARE IS PROVIDED "AS IS" AND NOMINUM DISCLAIMS ALL WARRANTIES
+# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL NOMINUM BE LIABLE FOR
+# ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+# ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT
+# OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+
+import struct
+
+import dns.exception
+import dns.immutable
+import dns.rdata
+import dns.tokenizer
+
+
+@dns.immutable.immutable
+class ISDN(dns.rdata.Rdata):
+    """ISDN record"""
+
+    # see: RFC 1183
+
+    __slots__ = ["address", "subaddress"]
+
+    def __init__(self, rdclass, rdtype, address, subaddress):
+        super().__init__(rdclass, rdtype)
+        self.address = self._as_bytes(address, True, 255)
+        self.subaddress = self._as_bytes(subaddress, True, 255)
+
+    def to_text(self, origin=None, relativize=True, **kw):
+        if self.subaddress:
+            return (
+                f'"{dns.rdata._escapify(self.address)}" '
+                f'"{dns.rdata._escapify(self.subaddress)}"'
+            )
+        else:
+            return f'"{dns.rdata._escapify(self.address)}"'
+
+    @classmethod
+    def from_text(
+        cls, rdclass, rdtype, tok, origin=None, relativize=True, relativize_to=None
+    ):
+        address = tok.get_string()
+        tokens = tok.get_remaining(max_tokens=1)
+        if len(tokens) >= 1:
+            subaddress = tokens[0].unescape().value
+        else:
+            subaddress = ""
+        return cls(rdclass, rdtype, address, subaddress)
+
+    def _to_wire(self, file, compress=None, origin=None, canonicalize=False):
+        l = len(self.address)
+        assert l < 256
+        file.write(struct.pack("!B", l))
+        file.write(self.address)
+        l = len(self.subaddress)
+        if l > 0:
+            assert l < 256
+            file.write(struct.pack("!B", l))
+            file.write(self.subaddress)
+
+    @classmethod
+    def from_wire_parser(cls, rdclass, rdtype, parser, origin=None):
+        address = parser.get_counted_bytes()
+        if parser.remaining() > 0:
+            subaddress = parser.get_counted_bytes()
+        else:
+            subaddress = b""
+        return cls(rdclass, rdtype, address, subaddress)
diff --git a/.venv/lib/python3.12/site-packages/dns/rdtypes/ANY/L32.py b/.venv/lib/python3.12/site-packages/dns/rdtypes/ANY/L32.py
new file mode 100644
index 00000000..09804c2d
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/dns/rdtypes/ANY/L32.py
@@ -0,0 +1,41 @@
+# Copyright (C) Dnspython Contributors, see LICENSE for text of ISC license
+
+import struct
+
+import dns.immutable
+import dns.rdata
+
+
+@dns.immutable.immutable
+class L32(dns.rdata.Rdata):
+    """L32 record"""
+
+    # see: rfc6742.txt
+
+    __slots__ = ["preference", "locator32"]
+
+    def __init__(self, rdclass, rdtype, preference, locator32):
+        super().__init__(rdclass, rdtype)
+        self.preference = self._as_uint16(preference)
+        self.locator32 = self._as_ipv4_address(locator32)
+
+    def to_text(self, origin=None, relativize=True, **kw):
+        return f"{self.preference} {self.locator32}"
+
+    @classmethod
+    def from_text(
+        cls, rdclass, rdtype, tok, origin=None, relativize=True, relativize_to=None
+    ):
+        preference = tok.get_uint16()
+        nodeid = tok.get_identifier()
+        return cls(rdclass, rdtype, preference, nodeid)
+
+    def _to_wire(self, file, compress=None, origin=None, canonicalize=False):
+        file.write(struct.pack("!H", self.preference))
+        file.write(dns.ipv4.inet_aton(self.locator32))
+
+    @classmethod
+    def from_wire_parser(cls, rdclass, rdtype, parser, origin=None):
+        preference = parser.get_uint16()
+        locator32 = parser.get_remaining()
+        return cls(rdclass, rdtype, preference, locator32)
diff --git a/.venv/lib/python3.12/site-packages/dns/rdtypes/ANY/L64.py b/.venv/lib/python3.12/site-packages/dns/rdtypes/ANY/L64.py
new file mode 100644
index 00000000..fb76808e
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/dns/rdtypes/ANY/L64.py
@@ -0,0 +1,47 @@
+# Copyright (C) Dnspython Contributors, see LICENSE for text of ISC license
+
+import struct
+
+import dns.immutable
+import dns.rdtypes.util
+
+
+@dns.immutable.immutable
+class L64(dns.rdata.Rdata):
+    """L64 record"""
+
+    # see: rfc6742.txt
+
+    __slots__ = ["preference", "locator64"]
+
+    def __init__(self, rdclass, rdtype, preference, locator64):
+        super().__init__(rdclass, rdtype)
+        self.preference = self._as_uint16(preference)
+        if isinstance(locator64, bytes):
+            if len(locator64) != 8:
+                raise ValueError("invalid locator64")
+            self.locator64 = dns.rdata._hexify(locator64, 4, b":")
+        else:
+            dns.rdtypes.util.parse_formatted_hex(locator64, 4, 4, ":")
+            self.locator64 = locator64
+
+    def to_text(self, origin=None, relativize=True, **kw):
+        return f"{self.preference} {self.locator64}"
+
+    @classmethod
+    def from_text(
+        cls, rdclass, rdtype, tok, origin=None, relativize=True, relativize_to=None
+    ):
+        preference = tok.get_uint16()
+        locator64 = tok.get_identifier()
+        return cls(rdclass, rdtype, preference, locator64)
+
+    def _to_wire(self, file, compress=None, origin=None, canonicalize=False):
+        file.write(struct.pack("!H", self.preference))
+        file.write(dns.rdtypes.util.parse_formatted_hex(self.locator64, 4, 4, ":"))
+
+    @classmethod
+    def from_wire_parser(cls, rdclass, rdtype, parser, origin=None):
+        preference = parser.get_uint16()
+        locator64 = parser.get_remaining()
+        return cls(rdclass, rdtype, preference, locator64)
diff --git a/.venv/lib/python3.12/site-packages/dns/rdtypes/ANY/LOC.py b/.venv/lib/python3.12/site-packages/dns/rdtypes/ANY/LOC.py
new file mode 100644
index 00000000..1153cf03
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/dns/rdtypes/ANY/LOC.py
@@ -0,0 +1,353 @@
+# Copyright (C) Dnspython Contributors, see LICENSE for text of ISC license
+
+# Copyright (C) 2003-2007, 2009-2011 Nominum, Inc.
+#
+# Permission to use, copy, modify, and distribute this software and its
+# documentation for any purpose with or without fee is hereby granted,
+# provided that the above copyright notice and this permission notice
+# appear in all copies.
+#
+# THE SOFTWARE IS PROVIDED "AS IS" AND NOMINUM DISCLAIMS ALL WARRANTIES
+# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL NOMINUM BE LIABLE FOR
+# ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+# ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT
+# OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+
+import struct
+
+import dns.exception
+import dns.immutable
+import dns.rdata
+
+_pows = tuple(10**i for i in range(0, 11))
+
+# default values are in centimeters
+_default_size = 100.0
+_default_hprec = 1000000.0
+_default_vprec = 1000.0
+
+# for use by from_wire()
+_MAX_LATITUDE = 0x80000000 + 90 * 3600000
+_MIN_LATITUDE = 0x80000000 - 90 * 3600000
+_MAX_LONGITUDE = 0x80000000 + 180 * 3600000
+_MIN_LONGITUDE = 0x80000000 - 180 * 3600000
+
+
+def _exponent_of(what, desc):
+    if what == 0:
+        return 0
+    exp = None
+    for i, pow in enumerate(_pows):
+        if what < pow:
+            exp = i - 1
+            break
+    if exp is None or exp < 0:
+        raise dns.exception.SyntaxError(f"{desc} value out of bounds")
+    return exp
+
+
+def _float_to_tuple(what):
+    if what < 0:
+        sign = -1
+        what *= -1
+    else:
+        sign = 1
+    what = round(what * 3600000)
+    degrees = int(what // 3600000)
+    what -= degrees * 3600000
+    minutes = int(what // 60000)
+    what -= minutes * 60000
+    seconds = int(what // 1000)
+    what -= int(seconds * 1000)
+    what = int(what)
+    return (degrees, minutes, seconds, what, sign)
+
+
+def _tuple_to_float(what):
+    value = float(what[0])
+    value += float(what[1]) / 60.0
+    value += float(what[2]) / 3600.0
+    value += float(what[3]) / 3600000.0
+    return float(what[4]) * value
+
+
+def _encode_size(what, desc):
+    what = int(what)
+    exponent = _exponent_of(what, desc) & 0xF
+    base = what // pow(10, exponent) & 0xF
+    return base * 16 + exponent
+
+
+def _decode_size(what, desc):
+    exponent = what & 0x0F
+    if exponent > 9:
+        raise dns.exception.FormError(f"bad {desc} exponent")
+    base = (what & 0xF0) >> 4
+    if base > 9:
+        raise dns.exception.FormError(f"bad {desc} base")
+    return base * pow(10, exponent)
+
+
+def _check_coordinate_list(value, low, high):
+    if value[0] < low or value[0] > high:
+        raise ValueError(f"not in range [{low}, {high}]")
+    if value[1] < 0 or value[1] > 59:
+        raise ValueError("bad minutes value")
+    if value[2] < 0 or value[2] > 59:
+        raise ValueError("bad seconds value")
+    if value[3] < 0 or value[3] > 999:
+        raise ValueError("bad milliseconds value")
+    if value[4] != 1 and value[4] != -1:
+        raise ValueError("bad hemisphere value")
+
+
+@dns.immutable.immutable
+class LOC(dns.rdata.Rdata):
+    """LOC record"""
+
+    # see: RFC 1876
+
+    __slots__ = [
+        "latitude",
+        "longitude",
+        "altitude",
+        "size",
+        "horizontal_precision",
+        "vertical_precision",
+    ]
+
+    def __init__(
+        self,
+        rdclass,
+        rdtype,
+        latitude,
+        longitude,
+        altitude,
+        size=_default_size,
+        hprec=_default_hprec,
+        vprec=_default_vprec,
+    ):
+        """Initialize a LOC record instance.
+
+        The parameters I{latitude} and I{longitude} may be either a 4-tuple
+        of integers specifying (degrees, minutes, seconds, milliseconds),
+        or they may be floating point values specifying the number of
+        degrees. The other parameters are floats. Size, horizontal precision,
+        and vertical precision are specified in centimeters."""
+
+        super().__init__(rdclass, rdtype)
+        if isinstance(latitude, int):
+            latitude = float(latitude)
+        if isinstance(latitude, float):
+            latitude = _float_to_tuple(latitude)
+        _check_coordinate_list(latitude, -90, 90)
+        self.latitude = tuple(latitude)
+        if isinstance(longitude, int):
+            longitude = float(longitude)
+        if isinstance(longitude, float):
+            longitude = _float_to_tuple(longitude)
+        _check_coordinate_list(longitude, -180, 180)
+        self.longitude = tuple(longitude)
+        self.altitude = float(altitude)
+        self.size = float(size)
+        self.horizontal_precision = float(hprec)
+        self.vertical_precision = float(vprec)
+
+    def to_text(self, origin=None, relativize=True, **kw):
+        if self.latitude[4] > 0:
+            lat_hemisphere = "N"
+        else:
+            lat_hemisphere = "S"
+        if self.longitude[4] > 0:
+            long_hemisphere = "E"
+        else:
+            long_hemisphere = "W"
+        text = "%d %d %d.%03d %s %d %d %d.%03d %s %0.2fm" % (
+            self.latitude[0],
+            self.latitude[1],
+            self.latitude[2],
+            self.latitude[3],
+            lat_hemisphere,
+            self.longitude[0],
+            self.longitude[1],
+            self.longitude[2],
+            self.longitude[3],
+            long_hemisphere,
+            self.altitude / 100.0,
+        )
+
+        # do not print default values
+        if (
+            self.size != _default_size
+            or self.horizontal_precision != _default_hprec
+            or self.vertical_precision != _default_vprec
+        ):
+            text += (
+                f" {self.size / 100.0:0.2f}m {self.horizontal_precision / 100.0:0.2f}m"
+                f" {self.vertical_precision / 100.0:0.2f}m"
+            )
+        return text
+
+    @classmethod
+    def from_text(
+        cls, rdclass, rdtype, tok, origin=None, relativize=True, relativize_to=None
+    ):
+        latitude = [0, 0, 0, 0, 1]
+        longitude = [0, 0, 0, 0, 1]
+        size = _default_size
+        hprec = _default_hprec
+        vprec = _default_vprec
+
+        latitude[0] = tok.get_int()
+        t = tok.get_string()
+        if t.isdigit():
+            latitude[1] = int(t)
+            t = tok.get_string()
+            if "." in t:
+                (seconds, milliseconds) = t.split(".")
+                if not seconds.isdigit():
+                    raise dns.exception.SyntaxError("bad latitude seconds value")
+                latitude[2] = int(seconds)
+                l = len(milliseconds)
+                if l == 0 or l > 3 or not milliseconds.isdigit():
+                    raise dns.exception.SyntaxError("bad latitude milliseconds value")
+                if l == 1:
+                    m = 100
+                elif l == 2:
+                    m = 10
+                else:
+                    m = 1
+                latitude[3] = m * int(milliseconds)
+                t = tok.get_string()
+            elif t.isdigit():
+                latitude[2] = int(t)
+                t = tok.get_string()
+        if t == "S":
+            latitude[4] = -1
+        elif t != "N":
+            raise dns.exception.SyntaxError("bad latitude hemisphere value")
+
+        longitude[0] = tok.get_int()
+        t = tok.get_string()
+        if t.isdigit():
+            longitude[1] = int(t)
+            t = tok.get_string()
+            if "." in t:
+                (seconds, milliseconds) = t.split(".")
+                if not seconds.isdigit():
+                    raise dns.exception.SyntaxError("bad longitude seconds value")
+                longitude[2] = int(seconds)
+                l = len(milliseconds)
+                if l == 0 or l > 3 or not milliseconds.isdigit():
+                    raise dns.exception.SyntaxError("bad longitude milliseconds value")
+                if l == 1:
+                    m = 100
+                elif l == 2:
+                    m = 10
+                else:
+                    m = 1
+                longitude[3] = m * int(milliseconds)
+                t = tok.get_string()
+            elif t.isdigit():
+                longitude[2] = int(t)
+                t = tok.get_string()
+        if t == "W":
+            longitude[4] = -1
+        elif t != "E":
+            raise dns.exception.SyntaxError("bad longitude hemisphere value")
+
+        t = tok.get_string()
+        if t[-1] == "m":
+            t = t[0:-1]
+        altitude = float(t) * 100.0  # m -> cm
+
+        tokens = tok.get_remaining(max_tokens=3)
+        if len(tokens) >= 1:
+            value = tokens[0].unescape().value
+            if value[-1] == "m":
+                value = value[0:-1]
+            size = float(value) * 100.0  # m -> cm
+            if len(tokens) >= 2:
+                value = tokens[1].unescape().value
+                if value[-1] == "m":
+                    value = value[0:-1]
+                hprec = float(value) * 100.0  # m -> cm
+                if len(tokens) >= 3:
+                    value = tokens[2].unescape().value
+                    if value[-1] == "m":
+                        value = value[0:-1]
+                    vprec = float(value) * 100.0  # m -> cm
+
+        # Try encoding these now so we raise if they are bad
+        _encode_size(size, "size")
+        _encode_size(hprec, "horizontal precision")
+        _encode_size(vprec, "vertical precision")
+
+        return cls(rdclass, rdtype, latitude, longitude, altitude, size, hprec, vprec)
+
+    def _to_wire(self, file, compress=None, origin=None, canonicalize=False):
+        milliseconds = (
+            self.latitude[0] * 3600000
+            + self.latitude[1] * 60000
+            + self.latitude[2] * 1000
+            + self.latitude[3]
+        ) * self.latitude[4]
+        latitude = 0x80000000 + milliseconds
+        milliseconds = (
+            self.longitude[0] * 3600000
+            + self.longitude[1] * 60000
+            + self.longitude[2] * 1000
+            + self.longitude[3]
+        ) * self.longitude[4]
+        longitude = 0x80000000 + milliseconds
+        altitude = int(self.altitude) + 10000000
+        size = _encode_size(self.size, "size")
+        hprec = _encode_size(self.horizontal_precision, "horizontal precision")
+        vprec = _encode_size(self.vertical_precision, "vertical precision")
+        wire = struct.pack(
+            "!BBBBIII", 0, size, hprec, vprec, latitude, longitude, altitude
+        )
+        file.write(wire)
+
+    @classmethod
+    def from_wire_parser(cls, rdclass, rdtype, parser, origin=None):
+        (
+            version,
+            size,
+            hprec,
+            vprec,
+            latitude,
+            longitude,
+            altitude,
+        ) = parser.get_struct("!BBBBIII")
+        if version != 0:
+            raise dns.exception.FormError("LOC version not zero")
+        if latitude < _MIN_LATITUDE or latitude > _MAX_LATITUDE:
+            raise dns.exception.FormError("bad latitude")
+        if latitude > 0x80000000:
+            latitude = (latitude - 0x80000000) / 3600000
+        else:
+            latitude = -1 * (0x80000000 - latitude) / 3600000
+        if longitude < _MIN_LONGITUDE or longitude > _MAX_LONGITUDE:
+            raise dns.exception.FormError("bad longitude")
+        if longitude > 0x80000000:
+            longitude = (longitude - 0x80000000) / 3600000
+        else:
+            longitude = -1 * (0x80000000 - longitude) / 3600000
+        altitude = float(altitude) - 10000000.0
+        size = _decode_size(size, "size")
+        hprec = _decode_size(hprec, "horizontal precision")
+        vprec = _decode_size(vprec, "vertical precision")
+        return cls(rdclass, rdtype, latitude, longitude, altitude, size, hprec, vprec)
+
+    @property
+    def float_latitude(self):
+        "latitude as a floating point value"
+        return _tuple_to_float(self.latitude)
+
+    @property
+    def float_longitude(self):
+        "longitude as a floating point value"
+        return _tuple_to_float(self.longitude)
diff --git a/.venv/lib/python3.12/site-packages/dns/rdtypes/ANY/LP.py b/.venv/lib/python3.12/site-packages/dns/rdtypes/ANY/LP.py
new file mode 100644
index 00000000..312663f1
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/dns/rdtypes/ANY/LP.py
@@ -0,0 +1,42 @@
+# Copyright (C) Dnspython Contributors, see LICENSE for text of ISC license
+
+import struct
+
+import dns.immutable
+import dns.rdata
+
+
+@dns.immutable.immutable
+class LP(dns.rdata.Rdata):
+    """LP record"""
+
+    # see: rfc6742.txt
+
+    __slots__ = ["preference", "fqdn"]
+
+    def __init__(self, rdclass, rdtype, preference, fqdn):
+        super().__init__(rdclass, rdtype)
+        self.preference = self._as_uint16(preference)
+        self.fqdn = self._as_name(fqdn)
+
+    def to_text(self, origin=None, relativize=True, **kw):
+        fqdn = self.fqdn.choose_relativity(origin, relativize)
+        return "%d %s" % (self.preference, fqdn)
+
+    @classmethod
+    def from_text(
+        cls, rdclass, rdtype, tok, origin=None, relativize=True, relativize_to=None
+    ):
+        preference = tok.get_uint16()
+        fqdn = tok.get_name(origin, relativize, relativize_to)
+        return cls(rdclass, rdtype, preference, fqdn)
+
+    def _to_wire(self, file, compress=None, origin=None, canonicalize=False):
+        file.write(struct.pack("!H", self.preference))
+        self.fqdn.to_wire(file, compress, origin, canonicalize)
+
+    @classmethod
+    def from_wire_parser(cls, rdclass, rdtype, parser, origin=None):
+        preference = parser.get_uint16()
+        fqdn = parser.get_name(origin)
+        return cls(rdclass, rdtype, preference, fqdn)
diff --git a/.venv/lib/python3.12/site-packages/dns/rdtypes/ANY/MX.py b/.venv/lib/python3.12/site-packages/dns/rdtypes/ANY/MX.py
new file mode 100644
index 00000000..0c300c5a
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/dns/rdtypes/ANY/MX.py
@@ -0,0 +1,24 @@
+# Copyright (C) Dnspython Contributors, see LICENSE for text of ISC license
+
+# Copyright (C) 2003-2007, 2009-2011 Nominum, Inc.
+#
+# Permission to use, copy, modify, and distribute this software and its
+# documentation for any purpose with or without fee is hereby granted,
+# provided that the above copyright notice and this permission notice
+# appear in all copies.
+#
+# THE SOFTWARE IS PROVIDED "AS IS" AND NOMINUM DISCLAIMS ALL WARRANTIES
+# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL NOMINUM BE LIABLE FOR
+# ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+# ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT
+# OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+
+import dns.immutable
+import dns.rdtypes.mxbase
+
+
+@dns.immutable.immutable
+class MX(dns.rdtypes.mxbase.MXBase):
+    """MX record"""
diff --git a/.venv/lib/python3.12/site-packages/dns/rdtypes/ANY/NID.py b/.venv/lib/python3.12/site-packages/dns/rdtypes/ANY/NID.py
new file mode 100644
index 00000000..2f649178
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/dns/rdtypes/ANY/NID.py
@@ -0,0 +1,47 @@
+# Copyright (C) Dnspython Contributors, see LICENSE for text of ISC license
+
+import struct
+
+import dns.immutable
+import dns.rdtypes.util
+
+
+@dns.immutable.immutable
+class NID(dns.rdata.Rdata):
+    """NID record"""
+
+    # see: rfc6742.txt
+
+    __slots__ = ["preference", "nodeid"]
+
+    def __init__(self, rdclass, rdtype, preference, nodeid):
+        super().__init__(rdclass, rdtype)
+        self.preference = self._as_uint16(preference)
+        if isinstance(nodeid, bytes):
+            if len(nodeid) != 8:
+                raise ValueError("invalid nodeid")
+            self.nodeid = dns.rdata._hexify(nodeid, 4, b":")
+        else:
+            dns.rdtypes.util.parse_formatted_hex(nodeid, 4, 4, ":")
+            self.nodeid = nodeid
+
+    def to_text(self, origin=None, relativize=True, **kw):
+        return f"{self.preference} {self.nodeid}"
+
+    @classmethod
+    def from_text(
+        cls, rdclass, rdtype, tok, origin=None, relativize=True, relativize_to=None
+    ):
+        preference = tok.get_uint16()
+        nodeid = tok.get_identifier()
+        return cls(rdclass, rdtype, preference, nodeid)
+
+    def _to_wire(self, file, compress=None, origin=None, canonicalize=False):
+        file.write(struct.pack("!H", self.preference))
+        file.write(dns.rdtypes.util.parse_formatted_hex(self.nodeid, 4, 4, ":"))
+
+    @classmethod
+    def from_wire_parser(cls, rdclass, rdtype, parser, origin=None):
+        preference = parser.get_uint16()
+        nodeid = parser.get_remaining()
+        return cls(rdclass, rdtype, preference, nodeid)
diff --git a/.venv/lib/python3.12/site-packages/dns/rdtypes/ANY/NINFO.py b/.venv/lib/python3.12/site-packages/dns/rdtypes/ANY/NINFO.py
new file mode 100644
index 00000000..b177bddb
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/dns/rdtypes/ANY/NINFO.py
@@ -0,0 +1,26 @@
+# Copyright (C) Dnspython Contributors, see LICENSE for text of ISC license
+
+# Copyright (C) 2006, 2007, 2009-2011 Nominum, Inc.
+#
+# Permission to use, copy, modify, and distribute this software and its
+# documentation for any purpose with or without fee is hereby granted,
+# provided that the above copyright notice and this permission notice
+# appear in all copies.
+#
+# THE SOFTWARE IS PROVIDED "AS IS" AND NOMINUM DISCLAIMS ALL WARRANTIES
+# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL NOMINUM BE LIABLE FOR
+# ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+# ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT
+# OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+
+import dns.immutable
+import dns.rdtypes.txtbase
+
+
+@dns.immutable.immutable
+class NINFO(dns.rdtypes.txtbase.TXTBase):
+    """NINFO record"""
+
+    # see: draft-reid-dnsext-zs-01
diff --git a/.venv/lib/python3.12/site-packages/dns/rdtypes/ANY/NS.py b/.venv/lib/python3.12/site-packages/dns/rdtypes/ANY/NS.py
new file mode 100644
index 00000000..c3f34ce9
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/dns/rdtypes/ANY/NS.py
@@ -0,0 +1,24 @@
+# Copyright (C) Dnspython Contributors, see LICENSE for text of ISC license
+
+# Copyright (C) 2003-2007, 2009-2011 Nominum, Inc.
+#
+# Permission to use, copy, modify, and distribute this software and its
+# documentation for any purpose with or without fee is hereby granted,
+# provided that the above copyright notice and this permission notice
+# appear in all copies.
+#
+# THE SOFTWARE IS PROVIDED "AS IS" AND NOMINUM DISCLAIMS ALL WARRANTIES
+# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL NOMINUM BE LIABLE FOR
+# ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+# ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT
+# OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+
+import dns.immutable
+import dns.rdtypes.nsbase
+
+
+@dns.immutable.immutable
+class NS(dns.rdtypes.nsbase.NSBase):
+    """NS record"""
diff --git a/.venv/lib/python3.12/site-packages/dns/rdtypes/ANY/NSEC.py b/.venv/lib/python3.12/site-packages/dns/rdtypes/ANY/NSEC.py
new file mode 100644
index 00000000..3c78b722
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/dns/rdtypes/ANY/NSEC.py
@@ -0,0 +1,67 @@
+# Copyright (C) Dnspython Contributors, see LICENSE for text of ISC license
+
+# Copyright (C) 2004-2007, 2009-2011 Nominum, Inc.
+#
+# Permission to use, copy, modify, and distribute this software and its
+# documentation for any purpose with or without fee is hereby granted,
+# provided that the above copyright notice and this permission notice
+# appear in all copies.
+#
+# THE SOFTWARE IS PROVIDED "AS IS" AND NOMINUM DISCLAIMS ALL WARRANTIES
+# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL NOMINUM BE LIABLE FOR
+# ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+# ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT
+# OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+
+import dns.exception
+import dns.immutable
+import dns.name
+import dns.rdata
+import dns.rdatatype
+import dns.rdtypes.util
+
+
+@dns.immutable.immutable
+class Bitmap(dns.rdtypes.util.Bitmap):
+    type_name = "NSEC"
+
+
+@dns.immutable.immutable
+class NSEC(dns.rdata.Rdata):
+    """NSEC record"""
+
+    __slots__ = ["next", "windows"]
+
+    def __init__(self, rdclass, rdtype, next, windows):
+        super().__init__(rdclass, rdtype)
+        self.next = self._as_name(next)
+        if not isinstance(windows, Bitmap):
+            windows = Bitmap(windows)
+        self.windows = tuple(windows.windows)
+
+    def to_text(self, origin=None, relativize=True, **kw):
+        next = self.next.choose_relativity(origin, relativize)
+        text = Bitmap(self.windows).to_text()
+        return f"{next}{text}"
+
+    @classmethod
+    def from_text(
+        cls, rdclass, rdtype, tok, origin=None, relativize=True, relativize_to=None
+    ):
+        next = tok.get_name(origin, relativize, relativize_to)
+        windows = Bitmap.from_text(tok)
+        return cls(rdclass, rdtype, next, windows)
+
+    def _to_wire(self, file, compress=None, origin=None, canonicalize=False):
+        # Note that NSEC downcasing, originally mandated by RFC 4034
+        # section 6.2 was removed by RFC 6840 section 5.1.
+        self.next.to_wire(file, None, origin, False)
+        Bitmap(self.windows).to_wire(file)
+
+    @classmethod
+    def from_wire_parser(cls, rdclass, rdtype, parser, origin=None):
+        next = parser.get_name(origin)
+        bitmap = Bitmap.from_wire_parser(parser)
+        return cls(rdclass, rdtype, next, bitmap)
diff --git a/.venv/lib/python3.12/site-packages/dns/rdtypes/ANY/NSEC3.py b/.venv/lib/python3.12/site-packages/dns/rdtypes/ANY/NSEC3.py
new file mode 100644
index 00000000..d71302b7
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/dns/rdtypes/ANY/NSEC3.py
@@ -0,0 +1,126 @@
+# Copyright (C) Dnspython Contributors, see LICENSE for text of ISC license
+
+# Copyright (C) 2004-2017 Nominum, Inc.
+#
+# Permission to use, copy, modify, and distribute this software and its
+# documentation for any purpose with or without fee is hereby granted,
+# provided that the above copyright notice and this permission notice
+# appear in all copies.
+#
+# THE SOFTWARE IS PROVIDED "AS IS" AND NOMINUM DISCLAIMS ALL WARRANTIES
+# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL NOMINUM BE LIABLE FOR
+# ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+# ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT
+# OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+
+import base64
+import binascii
+import struct
+
+import dns.exception
+import dns.immutable
+import dns.rdata
+import dns.rdatatype
+import dns.rdtypes.util
+
+b32_hex_to_normal = bytes.maketrans(
+    b"0123456789ABCDEFGHIJKLMNOPQRSTUV", b"ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"
+)
+b32_normal_to_hex = bytes.maketrans(
+    b"ABCDEFGHIJKLMNOPQRSTUVWXYZ234567", b"0123456789ABCDEFGHIJKLMNOPQRSTUV"
+)
+
+# hash algorithm constants
+SHA1 = 1
+
+# flag constants
+OPTOUT = 1
+
+
+@dns.immutable.immutable
+class Bitmap(dns.rdtypes.util.Bitmap):
+    type_name = "NSEC3"
+
+
+@dns.immutable.immutable
+class NSEC3(dns.rdata.Rdata):
+    """NSEC3 record"""
+
+    __slots__ = ["algorithm", "flags", "iterations", "salt", "next", "windows"]
+
+    def __init__(
+        self, rdclass, rdtype, algorithm, flags, iterations, salt, next, windows
+    ):
+        super().__init__(rdclass, rdtype)
+        self.algorithm = self._as_uint8(algorithm)
+        self.flags = self._as_uint8(flags)
+        self.iterations = self._as_uint16(iterations)
+        self.salt = self._as_bytes(salt, True, 255)
+        self.next = self._as_bytes(next, True, 255)
+        if not isinstance(windows, Bitmap):
+            windows = Bitmap(windows)
+        self.windows = tuple(windows.windows)
+
+    def _next_text(self):
+        next = base64.b32encode(self.next).translate(b32_normal_to_hex).lower().decode()
+        next = next.rstrip("=")
+        return next
+
+    def to_text(self, origin=None, relativize=True, **kw):
+        next = self._next_text()
+        if self.salt == b"":
+            salt = "-"
+        else:
+            salt = binascii.hexlify(self.salt).decode()
+        text = Bitmap(self.windows).to_text()
+        return "%u %u %u %s %s%s" % (
+            self.algorithm,
+            self.flags,
+            self.iterations,
+            salt,
+            next,
+            text,
+        )
+
+    @classmethod
+    def from_text(
+        cls, rdclass, rdtype, tok, origin=None, relativize=True, relativize_to=None
+    ):
+        algorithm = tok.get_uint8()
+        flags = tok.get_uint8()
+        iterations = tok.get_uint16()
+        salt = tok.get_string()
+        if salt == "-":
+            salt = b""
+        else:
+            salt = binascii.unhexlify(salt.encode("ascii"))
+        next = tok.get_string().encode("ascii").upper().translate(b32_hex_to_normal)
+        if next.endswith(b"="):
+            raise binascii.Error("Incorrect padding")
+        if len(next) % 8 != 0:
+            next += b"=" * (8 - len(next) % 8)
+        next = base64.b32decode(next)
+        bitmap = Bitmap.from_text(tok)
+        return cls(rdclass, rdtype, algorithm, flags, iterations, salt, next, bitmap)
+
+    def _to_wire(self, file, compress=None, origin=None, canonicalize=False):
+        l = len(self.salt)
+        file.write(struct.pack("!BBHB", self.algorithm, self.flags, self.iterations, l))
+        file.write(self.salt)
+        l = len(self.next)
+        file.write(struct.pack("!B", l))
+        file.write(self.next)
+        Bitmap(self.windows).to_wire(file)
+
+    @classmethod
+    def from_wire_parser(cls, rdclass, rdtype, parser, origin=None):
+        (algorithm, flags, iterations) = parser.get_struct("!BBH")
+        salt = parser.get_counted_bytes()
+        next = parser.get_counted_bytes()
+        bitmap = Bitmap.from_wire_parser(parser)
+        return cls(rdclass, rdtype, algorithm, flags, iterations, salt, next, bitmap)
+
+    def next_name(self, origin=None):
+        return dns.name.from_text(self._next_text(), origin)
diff --git a/.venv/lib/python3.12/site-packages/dns/rdtypes/ANY/NSEC3PARAM.py b/.venv/lib/python3.12/site-packages/dns/rdtypes/ANY/NSEC3PARAM.py
new file mode 100644
index 00000000..d1e62ebc
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/dns/rdtypes/ANY/NSEC3PARAM.py
@@ -0,0 +1,69 @@
+# Copyright (C) Dnspython Contributors, see LICENSE for text of ISC license
+
+# Copyright (C) 2004-2007, 2009-2011 Nominum, Inc.
+#
+# Permission to use, copy, modify, and distribute this software and its
+# documentation for any purpose with or without fee is hereby granted,
+# provided that the above copyright notice and this permission notice
+# appear in all copies.
+#
+# THE SOFTWARE IS PROVIDED "AS IS" AND NOMINUM DISCLAIMS ALL WARRANTIES
+# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL NOMINUM BE LIABLE FOR
+# ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+# ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT
+# OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+
+import binascii
+import struct
+
+import dns.exception
+import dns.immutable
+import dns.rdata
+
+
+@dns.immutable.immutable
+class NSEC3PARAM(dns.rdata.Rdata):
+    """NSEC3PARAM record"""
+
+    __slots__ = ["algorithm", "flags", "iterations", "salt"]
+
+    def __init__(self, rdclass, rdtype, algorithm, flags, iterations, salt):
+        super().__init__(rdclass, rdtype)
+        self.algorithm = self._as_uint8(algorithm)
+        self.flags = self._as_uint8(flags)
+        self.iterations = self._as_uint16(iterations)
+        self.salt = self._as_bytes(salt, True, 255)
+
+    def to_text(self, origin=None, relativize=True, **kw):
+        if self.salt == b"":
+            salt = "-"
+        else:
+            salt = binascii.hexlify(self.salt).decode()
+        return "%u %u %u %s" % (self.algorithm, self.flags, self.iterations, salt)
+
+    @classmethod
+    def from_text(
+        cls, rdclass, rdtype, tok, origin=None, relativize=True, relativize_to=None
+    ):
+        algorithm = tok.get_uint8()
+        flags = tok.get_uint8()
+        iterations = tok.get_uint16()
+        salt = tok.get_string()
+        if salt == "-":
+            salt = ""
+        else:
+            salt = binascii.unhexlify(salt.encode())
+        return cls(rdclass, rdtype, algorithm, flags, iterations, salt)
+
+    def _to_wire(self, file, compress=None, origin=None, canonicalize=False):
+        l = len(self.salt)
+        file.write(struct.pack("!BBHB", self.algorithm, self.flags, self.iterations, l))
+        file.write(self.salt)
+
+    @classmethod
+    def from_wire_parser(cls, rdclass, rdtype, parser, origin=None):
+        (algorithm, flags, iterations) = parser.get_struct("!BBH")
+        salt = parser.get_counted_bytes()
+        return cls(rdclass, rdtype, algorithm, flags, iterations, salt)
diff --git a/.venv/lib/python3.12/site-packages/dns/rdtypes/ANY/OPENPGPKEY.py b/.venv/lib/python3.12/site-packages/dns/rdtypes/ANY/OPENPGPKEY.py
new file mode 100644
index 00000000..4d7a4b6c
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/dns/rdtypes/ANY/OPENPGPKEY.py
@@ -0,0 +1,53 @@
+# Copyright (C) Dnspython Contributors, see LICENSE for text of ISC license
+
+# Copyright (C) 2016 Nominum, Inc.
+#
+# Permission to use, copy, modify, and distribute this software and its
+# documentation for any purpose with or without fee is hereby granted,
+# provided that the above copyright notice and this permission notice
+# appear in all copies.
+#
+# THE SOFTWARE IS PROVIDED "AS IS" AND NOMINUM DISCLAIMS ALL WARRANTIES
+# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL NOMINUM BE LIABLE FOR
+# ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+# ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT
+# OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+
+import base64
+
+import dns.exception
+import dns.immutable
+import dns.rdata
+import dns.tokenizer
+
+
+@dns.immutable.immutable
+class OPENPGPKEY(dns.rdata.Rdata):
+    """OPENPGPKEY record"""
+
+    # see: RFC 7929
+
+    def __init__(self, rdclass, rdtype, key):
+        super().__init__(rdclass, rdtype)
+        self.key = self._as_bytes(key)
+
+    def to_text(self, origin=None, relativize=True, **kw):
+        return dns.rdata._base64ify(self.key, chunksize=None, **kw)
+
+    @classmethod
+    def from_text(
+        cls, rdclass, rdtype, tok, origin=None, relativize=True, relativize_to=None
+    ):
+        b64 = tok.concatenate_remaining_identifiers().encode()
+        key = base64.b64decode(b64)
+        return cls(rdclass, rdtype, key)
+
+    def _to_wire(self, file, compress=None, origin=None, canonicalize=False):
+        file.write(self.key)
+
+    @classmethod
+    def from_wire_parser(cls, rdclass, rdtype, parser, origin=None):
+        key = parser.get_remaining()
+        return cls(rdclass, rdtype, key)
diff --git a/.venv/lib/python3.12/site-packages/dns/rdtypes/ANY/OPT.py b/.venv/lib/python3.12/site-packages/dns/rdtypes/ANY/OPT.py
new file mode 100644
index 00000000..d343dfa5
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/dns/rdtypes/ANY/OPT.py
@@ -0,0 +1,77 @@
+# Copyright (C) Dnspython Contributors, see LICENSE for text of ISC license
+
+# Copyright (C) 2001-2017 Nominum, Inc.
+#
+# Permission to use, copy, modify, and distribute this software and its
+# documentation for any purpose with or without fee is hereby granted,
+# provided that the above copyright notice and this permission notice
+# appear in all copies.
+#
+# THE SOFTWARE IS PROVIDED "AS IS" AND NOMINUM DISCLAIMS ALL WARRANTIES
+# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL NOMINUM BE LIABLE FOR
+# ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+# ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT
+# OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+
+import struct
+
+import dns.edns
+import dns.exception
+import dns.immutable
+import dns.rdata
+
+# We don't implement from_text, and that's ok.
+# pylint: disable=abstract-method
+
+
+@dns.immutable.immutable
+class OPT(dns.rdata.Rdata):
+    """OPT record"""
+
+    __slots__ = ["options"]
+
+    def __init__(self, rdclass, rdtype, options):
+        """Initialize an OPT rdata.
+
+        *rdclass*, an ``int`` is the rdataclass of the Rdata,
+        which is also the payload size.
+
+        *rdtype*, an ``int`` is the rdatatype of the Rdata.
+
+        *options*, a tuple of ``bytes``
+        """
+
+        super().__init__(rdclass, rdtype)
+
+        def as_option(option):
+            if not isinstance(option, dns.edns.Option):
+                raise ValueError("option is not a dns.edns.option")
+            return option
+
+        self.options = self._as_tuple(options, as_option)
+
+    def _to_wire(self, file, compress=None, origin=None, canonicalize=False):
+        for opt in self.options:
+            owire = opt.to_wire()
+            file.write(struct.pack("!HH", opt.otype, len(owire)))
+            file.write(owire)
+
+    def to_text(self, origin=None, relativize=True, **kw):
+        return " ".join(opt.to_text() for opt in self.options)
+
+    @classmethod
+    def from_wire_parser(cls, rdclass, rdtype, parser, origin=None):
+        options = []
+        while parser.remaining() > 0:
+            (otype, olen) = parser.get_struct("!HH")
+            with parser.restrict_to(olen):
+                opt = dns.edns.option_from_wire_parser(otype, parser)
+            options.append(opt)
+        return cls(rdclass, rdtype, options)
+
+    @property
+    def payload(self):
+        "payload size"
+        return self.rdclass
diff --git a/.venv/lib/python3.12/site-packages/dns/rdtypes/ANY/PTR.py b/.venv/lib/python3.12/site-packages/dns/rdtypes/ANY/PTR.py
new file mode 100644
index 00000000..98c36167
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/dns/rdtypes/ANY/PTR.py
@@ -0,0 +1,24 @@
+# Copyright (C) Dnspython Contributors, see LICENSE for text of ISC license
+
+# Copyright (C) 2003-2007, 2009-2011 Nominum, Inc.
+#
+# Permission to use, copy, modify, and distribute this software and its
+# documentation for any purpose with or without fee is hereby granted,
+# provided that the above copyright notice and this permission notice
+# appear in all copies.
+#
+# THE SOFTWARE IS PROVIDED "AS IS" AND NOMINUM DISCLAIMS ALL WARRANTIES
+# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL NOMINUM BE LIABLE FOR
+# ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+# ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT
+# OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+
+import dns.immutable
+import dns.rdtypes.nsbase
+
+
+@dns.immutable.immutable
+class PTR(dns.rdtypes.nsbase.NSBase):
+    """PTR record"""
diff --git a/.venv/lib/python3.12/site-packages/dns/rdtypes/ANY/RESINFO.py b/.venv/lib/python3.12/site-packages/dns/rdtypes/ANY/RESINFO.py
new file mode 100644
index 00000000..76c8ea2a
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/dns/rdtypes/ANY/RESINFO.py
@@ -0,0 +1,24 @@
+# Copyright (C) Dnspython Contributors, see LICENSE for text of ISC license
+
+# Copyright (C) 2003-2007, 2009-2011 Nominum, Inc.
+#
+# Permission to use, copy, modify, and distribute this software and its
+# documentation for any purpose with or without fee is hereby granted,
+# provided that the above copyright notice and this permission notice
+# appear in all copies.
+#
+# THE SOFTWARE IS PROVIDED "AS IS" AND NOMINUM DISCLAIMS ALL WARRANTIES
+# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL NOMINUM BE LIABLE FOR
+# ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+# ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT
+# OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+
+import dns.immutable
+import dns.rdtypes.txtbase
+
+
+@dns.immutable.immutable
+class RESINFO(dns.rdtypes.txtbase.TXTBase):
+    """RESINFO record"""
diff --git a/.venv/lib/python3.12/site-packages/dns/rdtypes/ANY/RP.py b/.venv/lib/python3.12/site-packages/dns/rdtypes/ANY/RP.py
new file mode 100644
index 00000000..a66cfc50
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/dns/rdtypes/ANY/RP.py
@@ -0,0 +1,58 @@
+# Copyright (C) Dnspython Contributors, see LICENSE for text of ISC license
+
+# Copyright (C) 2003-2007, 2009-2011 Nominum, Inc.
+#
+# Permission to use, copy, modify, and distribute this software and its
+# documentation for any purpose with or without fee is hereby granted,
+# provided that the above copyright notice and this permission notice
+# appear in all copies.
+#
+# THE SOFTWARE IS PROVIDED "AS IS" AND NOMINUM DISCLAIMS ALL WARRANTIES
+# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL NOMINUM BE LIABLE FOR
+# ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+# ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT
+# OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+
+import dns.exception
+import dns.immutable
+import dns.name
+import dns.rdata
+
+
+@dns.immutable.immutable
+class RP(dns.rdata.Rdata):
+    """RP record"""
+
+    # see: RFC 1183
+
+    __slots__ = ["mbox", "txt"]
+
+    def __init__(self, rdclass, rdtype, mbox, txt):
+        super().__init__(rdclass, rdtype)
+        self.mbox = self._as_name(mbox)
+        self.txt = self._as_name(txt)
+
+    def to_text(self, origin=None, relativize=True, **kw):
+        mbox = self.mbox.choose_relativity(origin, relativize)
+        txt = self.txt.choose_relativity(origin, relativize)
+        return f"{str(mbox)} {str(txt)}"
+
+    @classmethod
+    def from_text(
+        cls, rdclass, rdtype, tok, origin=None, relativize=True, relativize_to=None
+    ):
+        mbox = tok.get_name(origin, relativize, relativize_to)
+        txt = tok.get_name(origin, relativize, relativize_to)
+        return cls(rdclass, rdtype, mbox, txt)
+
+    def _to_wire(self, file, compress=None, origin=None, canonicalize=False):
+        self.mbox.to_wire(file, None, origin, canonicalize)
+        self.txt.to_wire(file, None, origin, canonicalize)
+
+    @classmethod
+    def from_wire_parser(cls, rdclass, rdtype, parser, origin=None):
+        mbox = parser.get_name(origin)
+        txt = parser.get_name(origin)
+        return cls(rdclass, rdtype, mbox, txt)
diff --git a/.venv/lib/python3.12/site-packages/dns/rdtypes/ANY/RRSIG.py b/.venv/lib/python3.12/site-packages/dns/rdtypes/ANY/RRSIG.py
new file mode 100644
index 00000000..8beb4237
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/dns/rdtypes/ANY/RRSIG.py
@@ -0,0 +1,157 @@
+# Copyright (C) Dnspython Contributors, see LICENSE for text of ISC license
+
+# Copyright (C) 2004-2007, 2009-2011 Nominum, Inc.
+#
+# Permission to use, copy, modify, and distribute this software and its
+# documentation for any purpose with or without fee is hereby granted,
+# provided that the above copyright notice and this permission notice
+# appear in all copies.
+#
+# THE SOFTWARE IS PROVIDED "AS IS" AND NOMINUM DISCLAIMS ALL WARRANTIES
+# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL NOMINUM BE LIABLE FOR
+# ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+# ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT
+# OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+
+import base64
+import calendar
+import struct
+import time
+
+import dns.dnssectypes
+import dns.exception
+import dns.immutable
+import dns.rdata
+import dns.rdatatype
+
+
+class BadSigTime(dns.exception.DNSException):
+    """Time in DNS SIG or RRSIG resource record cannot be parsed."""
+
+
+def sigtime_to_posixtime(what):
+    if len(what) <= 10 and what.isdigit():
+        return int(what)
+    if len(what) != 14:
+        raise BadSigTime
+    year = int(what[0:4])
+    month = int(what[4:6])
+    day = int(what[6:8])
+    hour = int(what[8:10])
+    minute = int(what[10:12])
+    second = int(what[12:14])
+    return calendar.timegm((year, month, day, hour, minute, second, 0, 0, 0))
+
+
+def posixtime_to_sigtime(what):
+    return time.strftime("%Y%m%d%H%M%S", time.gmtime(what))
+
+
+@dns.immutable.immutable
+class RRSIG(dns.rdata.Rdata):
+    """RRSIG record"""
+
+    __slots__ = [
+        "type_covered",
+        "algorithm",
+        "labels",
+        "original_ttl",
+        "expiration",
+        "inception",
+        "key_tag",
+        "signer",
+        "signature",
+    ]
+
+    def __init__(
+        self,
+        rdclass,
+        rdtype,
+        type_covered,
+        algorithm,
+        labels,
+        original_ttl,
+        expiration,
+        inception,
+        key_tag,
+        signer,
+        signature,
+    ):
+        super().__init__(rdclass, rdtype)
+        self.type_covered = self._as_rdatatype(type_covered)
+        self.algorithm = dns.dnssectypes.Algorithm.make(algorithm)
+        self.labels = self._as_uint8(labels)
+        self.original_ttl = self._as_ttl(original_ttl)
+        self.expiration = self._as_uint32(expiration)
+        self.inception = self._as_uint32(inception)
+        self.key_tag = self._as_uint16(key_tag)
+        self.signer = self._as_name(signer)
+        self.signature = self._as_bytes(signature)
+
+    def covers(self):
+        return self.type_covered
+
+    def to_text(self, origin=None, relativize=True, **kw):
+        return "%s %d %d %d %s %s %d %s %s" % (
+            dns.rdatatype.to_text(self.type_covered),
+            self.algorithm,
+            self.labels,
+            self.original_ttl,
+            posixtime_to_sigtime(self.expiration),
+            posixtime_to_sigtime(self.inception),
+            self.key_tag,
+            self.signer.choose_relativity(origin, relativize),
+            dns.rdata._base64ify(self.signature, **kw),
+        )
+
+    @classmethod
+    def from_text(
+        cls, rdclass, rdtype, tok, origin=None, relativize=True, relativize_to=None
+    ):
+        type_covered = dns.rdatatype.from_text(tok.get_string())
+        algorithm = dns.dnssectypes.Algorithm.from_text(tok.get_string())
+        labels = tok.get_int()
+        original_ttl = tok.get_ttl()
+        expiration = sigtime_to_posixtime(tok.get_string())
+        inception = sigtime_to_posixtime(tok.get_string())
+        key_tag = tok.get_int()
+        signer = tok.get_name(origin, relativize, relativize_to)
+        b64 = tok.concatenate_remaining_identifiers().encode()
+        signature = base64.b64decode(b64)
+        return cls(
+            rdclass,
+            rdtype,
+            type_covered,
+            algorithm,
+            labels,
+            original_ttl,
+            expiration,
+            inception,
+            key_tag,
+            signer,
+            signature,
+        )
+
+    def _to_wire(self, file, compress=None, origin=None, canonicalize=False):
+        header = struct.pack(
+            "!HBBIIIH",
+            self.type_covered,
+            self.algorithm,
+            self.labels,
+            self.original_ttl,
+            self.expiration,
+            self.inception,
+            self.key_tag,
+        )
+        file.write(header)
+        self.signer.to_wire(file, None, origin, canonicalize)
+        file.write(self.signature)
+
+    @classmethod
+    def from_wire_parser(cls, rdclass, rdtype, parser, origin=None):
+        header = parser.get_struct("!HBBIIIH")
+        signer = parser.get_name(origin)
+        signature = parser.get_remaining()
+        return cls(rdclass, rdtype, *header, signer, signature)
diff --git a/.venv/lib/python3.12/site-packages/dns/rdtypes/ANY/RT.py b/.venv/lib/python3.12/site-packages/dns/rdtypes/ANY/RT.py
new file mode 100644
index 00000000..5a4d45cf
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/dns/rdtypes/ANY/RT.py
@@ -0,0 +1,24 @@
+# Copyright (C) Dnspython Contributors, see LICENSE for text of ISC license
+
+# Copyright (C) 2003-2007, 2009-2011 Nominum, Inc.
+#
+# Permission to use, copy, modify, and distribute this software and its
+# documentation for any purpose with or without fee is hereby granted,
+# provided that the above copyright notice and this permission notice
+# appear in all copies.
+#
+# THE SOFTWARE IS PROVIDED "AS IS" AND NOMINUM DISCLAIMS ALL WARRANTIES
+# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL NOMINUM BE LIABLE FOR
+# ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+# ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT
+# OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+
+import dns.immutable
+import dns.rdtypes.mxbase
+
+
+@dns.immutable.immutable
+class RT(dns.rdtypes.mxbase.UncompressedDowncasingMX):
+    """RT record"""
diff --git a/.venv/lib/python3.12/site-packages/dns/rdtypes/ANY/SMIMEA.py b/.venv/lib/python3.12/site-packages/dns/rdtypes/ANY/SMIMEA.py
new file mode 100644
index 00000000..55d87bf8
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/dns/rdtypes/ANY/SMIMEA.py
@@ -0,0 +1,9 @@
+# Copyright (C) Dnspython Contributors, see LICENSE for text of ISC license
+
+import dns.immutable
+import dns.rdtypes.tlsabase
+
+
+@dns.immutable.immutable
+class SMIMEA(dns.rdtypes.tlsabase.TLSABase):
+    """SMIMEA record"""
diff --git a/.venv/lib/python3.12/site-packages/dns/rdtypes/ANY/SOA.py b/.venv/lib/python3.12/site-packages/dns/rdtypes/ANY/SOA.py
new file mode 100644
index 00000000..09aa8321
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/dns/rdtypes/ANY/SOA.py
@@ -0,0 +1,86 @@
+# Copyright (C) Dnspython Contributors, see LICENSE for text of ISC license
+
+# Copyright (C) 2003-2007, 2009-2011 Nominum, Inc.
+#
+# Permission to use, copy, modify, and distribute this software and its
+# documentation for any purpose with or without fee is hereby granted,
+# provided that the above copyright notice and this permission notice
+# appear in all copies.
+#
+# THE SOFTWARE IS PROVIDED "AS IS" AND NOMINUM DISCLAIMS ALL WARRANTIES
+# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL NOMINUM BE LIABLE FOR
+# ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+# ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT
+# OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+
+import struct
+
+import dns.exception
+import dns.immutable
+import dns.name
+import dns.rdata
+
+
+@dns.immutable.immutable
+class SOA(dns.rdata.Rdata):
+    """SOA record"""
+
+    # see: RFC 1035
+
+    __slots__ = ["mname", "rname", "serial", "refresh", "retry", "expire", "minimum"]
+
+    def __init__(
+        self, rdclass, rdtype, mname, rname, serial, refresh, retry, expire, minimum
+    ):
+        super().__init__(rdclass, rdtype)
+        self.mname = self._as_name(mname)
+        self.rname = self._as_name(rname)
+        self.serial = self._as_uint32(serial)
+        self.refresh = self._as_ttl(refresh)
+        self.retry = self._as_ttl(retry)
+        self.expire = self._as_ttl(expire)
+        self.minimum = self._as_ttl(minimum)
+
+    def to_text(self, origin=None, relativize=True, **kw):
+        mname = self.mname.choose_relativity(origin, relativize)
+        rname = self.rname.choose_relativity(origin, relativize)
+        return "%s %s %d %d %d %d %d" % (
+            mname,
+            rname,
+            self.serial,
+            self.refresh,
+            self.retry,
+            self.expire,
+            self.minimum,
+        )
+
+    @classmethod
+    def from_text(
+        cls, rdclass, rdtype, tok, origin=None, relativize=True, relativize_to=None
+    ):
+        mname = tok.get_name(origin, relativize, relativize_to)
+        rname = tok.get_name(origin, relativize, relativize_to)
+        serial = tok.get_uint32()
+        refresh = tok.get_ttl()
+        retry = tok.get_ttl()
+        expire = tok.get_ttl()
+        minimum = tok.get_ttl()
+        return cls(
+            rdclass, rdtype, mname, rname, serial, refresh, retry, expire, minimum
+        )
+
+    def _to_wire(self, file, compress=None, origin=None, canonicalize=False):
+        self.mname.to_wire(file, compress, origin, canonicalize)
+        self.rname.to_wire(file, compress, origin, canonicalize)
+        five_ints = struct.pack(
+            "!IIIII", self.serial, self.refresh, self.retry, self.expire, self.minimum
+        )
+        file.write(five_ints)
+
+    @classmethod
+    def from_wire_parser(cls, rdclass, rdtype, parser, origin=None):
+        mname = parser.get_name(origin)
+        rname = parser.get_name(origin)
+        return cls(rdclass, rdtype, mname, rname, *parser.get_struct("!IIIII"))
diff --git a/.venv/lib/python3.12/site-packages/dns/rdtypes/ANY/SPF.py b/.venv/lib/python3.12/site-packages/dns/rdtypes/ANY/SPF.py
new file mode 100644
index 00000000..1df3b705
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/dns/rdtypes/ANY/SPF.py
@@ -0,0 +1,26 @@
+# Copyright (C) Dnspython Contributors, see LICENSE for text of ISC license
+
+# Copyright (C) 2006, 2007, 2009-2011 Nominum, Inc.
+#
+# Permission to use, copy, modify, and distribute this software and its
+# documentation for any purpose with or without fee is hereby granted,
+# provided that the above copyright notice and this permission notice
+# appear in all copies.
+#
+# THE SOFTWARE IS PROVIDED "AS IS" AND NOMINUM DISCLAIMS ALL WARRANTIES
+# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL NOMINUM BE LIABLE FOR
+# ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+# ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT
+# OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+
+import dns.immutable
+import dns.rdtypes.txtbase
+
+
+@dns.immutable.immutable
+class SPF(dns.rdtypes.txtbase.TXTBase):
+    """SPF record"""
+
+    # see: RFC 4408
diff --git a/.venv/lib/python3.12/site-packages/dns/rdtypes/ANY/SSHFP.py b/.venv/lib/python3.12/site-packages/dns/rdtypes/ANY/SSHFP.py
new file mode 100644
index 00000000..d2c4b073
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/dns/rdtypes/ANY/SSHFP.py
@@ -0,0 +1,68 @@
+# Copyright (C) Dnspython Contributors, see LICENSE for text of ISC license
+
+# Copyright (C) 2005-2007, 2009-2011 Nominum, Inc.
+#
+# Permission to use, copy, modify, and distribute this software and its
+# documentation for any purpose with or without fee is hereby granted,
+# provided that the above copyright notice and this permission notice
+# appear in all copies.
+#
+# THE SOFTWARE IS PROVIDED "AS IS" AND NOMINUM DISCLAIMS ALL WARRANTIES
+# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL NOMINUM BE LIABLE FOR
+# ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+# ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT
+# OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+
+import binascii
+import struct
+
+import dns.immutable
+import dns.rdata
+import dns.rdatatype
+
+
+@dns.immutable.immutable
+class SSHFP(dns.rdata.Rdata):
+    """SSHFP record"""
+
+    # See RFC 4255
+
+    __slots__ = ["algorithm", "fp_type", "fingerprint"]
+
+    def __init__(self, rdclass, rdtype, algorithm, fp_type, fingerprint):
+        super().__init__(rdclass, rdtype)
+        self.algorithm = self._as_uint8(algorithm)
+        self.fp_type = self._as_uint8(fp_type)
+        self.fingerprint = self._as_bytes(fingerprint, True)
+
+    def to_text(self, origin=None, relativize=True, **kw):
+        kw = kw.copy()
+        chunksize = kw.pop("chunksize", 128)
+        return "%d %d %s" % (
+            self.algorithm,
+            self.fp_type,
+            dns.rdata._hexify(self.fingerprint, chunksize=chunksize, **kw),
+        )
+
+    @classmethod
+    def from_text(
+        cls, rdclass, rdtype, tok, origin=None, relativize=True, relativize_to=None
+    ):
+        algorithm = tok.get_uint8()
+        fp_type = tok.get_uint8()
+        fingerprint = tok.concatenate_remaining_identifiers().encode()
+        fingerprint = binascii.unhexlify(fingerprint)
+        return cls(rdclass, rdtype, algorithm, fp_type, fingerprint)
+
+    def _to_wire(self, file, compress=None, origin=None, canonicalize=False):
+        header = struct.pack("!BB", self.algorithm, self.fp_type)
+        file.write(header)
+        file.write(self.fingerprint)
+
+    @classmethod
+    def from_wire_parser(cls, rdclass, rdtype, parser, origin=None):
+        header = parser.get_struct("BB")
+        fingerprint = parser.get_remaining()
+        return cls(rdclass, rdtype, header[0], header[1], fingerprint)
diff --git a/.venv/lib/python3.12/site-packages/dns/rdtypes/ANY/TKEY.py b/.venv/lib/python3.12/site-packages/dns/rdtypes/ANY/TKEY.py
new file mode 100644
index 00000000..75f62249
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/dns/rdtypes/ANY/TKEY.py
@@ -0,0 +1,142 @@
+# Copyright (C) Dnspython Contributors, see LICENSE for text of ISC license
+
+# Copyright (C) 2004-2007, 2009-2011 Nominum, Inc.
+#
+# Permission to use, copy, modify, and distribute this software and its
+# documentation for any purpose with or without fee is hereby granted,
+# provided that the above copyright notice and this permission notice
+# appear in all copies.
+#
+# THE SOFTWARE IS PROVIDED "AS IS" AND NOMINUM DISCLAIMS ALL WARRANTIES
+# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL NOMINUM BE LIABLE FOR
+# ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+# ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT
+# OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+
+import base64
+import struct
+
+import dns.exception
+import dns.immutable
+import dns.rdata
+
+
+@dns.immutable.immutable
+class TKEY(dns.rdata.Rdata):
+    """TKEY Record"""
+
+    __slots__ = [
+        "algorithm",
+        "inception",
+        "expiration",
+        "mode",
+        "error",
+        "key",
+        "other",
+    ]
+
+    def __init__(
+        self,
+        rdclass,
+        rdtype,
+        algorithm,
+        inception,
+        expiration,
+        mode,
+        error,
+        key,
+        other=b"",
+    ):
+        super().__init__(rdclass, rdtype)
+        self.algorithm = self._as_name(algorithm)
+        self.inception = self._as_uint32(inception)
+        self.expiration = self._as_uint32(expiration)
+        self.mode = self._as_uint16(mode)
+        self.error = self._as_uint16(error)
+        self.key = self._as_bytes(key)
+        self.other = self._as_bytes(other)
+
+    def to_text(self, origin=None, relativize=True, **kw):
+        _algorithm = self.algorithm.choose_relativity(origin, relativize)
+        text = "%s %u %u %u %u %s" % (
+            str(_algorithm),
+            self.inception,
+            self.expiration,
+            self.mode,
+            self.error,
+            dns.rdata._base64ify(self.key, 0),
+        )
+        if len(self.other) > 0:
+            text += f" {dns.rdata._base64ify(self.other, 0)}"
+
+        return text
+
+    @classmethod
+    def from_text(
+        cls, rdclass, rdtype, tok, origin=None, relativize=True, relativize_to=None
+    ):
+        algorithm = tok.get_name(relativize=False)
+        inception = tok.get_uint32()
+        expiration = tok.get_uint32()
+        mode = tok.get_uint16()
+        error = tok.get_uint16()
+        key_b64 = tok.get_string().encode()
+        key = base64.b64decode(key_b64)
+        other_b64 = tok.concatenate_remaining_identifiers(True).encode()
+        other = base64.b64decode(other_b64)
+
+        return cls(
+            rdclass, rdtype, algorithm, inception, expiration, mode, error, key, other
+        )
+
+    def _to_wire(self, file, compress=None, origin=None, canonicalize=False):
+        self.algorithm.to_wire(file, compress, origin)
+        file.write(
+            struct.pack("!IIHH", self.inception, self.expiration, self.mode, self.error)
+        )
+        file.write(struct.pack("!H", len(self.key)))
+        file.write(self.key)
+        file.write(struct.pack("!H", len(self.other)))
+        if len(self.other) > 0:
+            file.write(self.other)
+
+    @classmethod
+    def from_wire_parser(cls, rdclass, rdtype, parser, origin=None):
+        algorithm = parser.get_name(origin)
+        inception, expiration, mode, error = parser.get_struct("!IIHH")
+        key = parser.get_counted_bytes(2)
+        other = parser.get_counted_bytes(2)
+
+        return cls(
+            rdclass, rdtype, algorithm, inception, expiration, mode, error, key, other
+        )
+
+    # Constants for the mode field - from RFC 2930:
+    # 2.5 The Mode Field
+    #
+    #    The mode field specifies the general scheme for key agreement or
+    #    the purpose of the TKEY DNS message.  Servers and resolvers
+    #    supporting this specification MUST implement the Diffie-Hellman key
+    #    agreement mode and the key deletion mode for queries.  All other
+    #    modes are OPTIONAL.  A server supporting TKEY that receives a TKEY
+    #    request with a mode it does not support returns the BADMODE error.
+    #    The following values of the Mode octet are defined, available, or
+    #    reserved:
+    #
+    #          Value    Description
+    #          -----    -----------
+    #           0        - reserved, see section 7
+    #           1       server assignment
+    #           2       Diffie-Hellman exchange
+    #           3       GSS-API negotiation
+    #           4       resolver assignment
+    #           5       key deletion
+    #          6-65534   - available, see section 7
+    #          65535     - reserved, see section 7
+    SERVER_ASSIGNMENT = 1
+    DIFFIE_HELLMAN_EXCHANGE = 2
+    GSSAPI_NEGOTIATION = 3
+    RESOLVER_ASSIGNMENT = 4
+    KEY_DELETION = 5
diff --git a/.venv/lib/python3.12/site-packages/dns/rdtypes/ANY/TLSA.py b/.venv/lib/python3.12/site-packages/dns/rdtypes/ANY/TLSA.py
new file mode 100644
index 00000000..4dffc553
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/dns/rdtypes/ANY/TLSA.py
@@ -0,0 +1,9 @@
+# Copyright (C) Dnspython Contributors, see LICENSE for text of ISC license
+
+import dns.immutable
+import dns.rdtypes.tlsabase
+
+
+@dns.immutable.immutable
+class TLSA(dns.rdtypes.tlsabase.TLSABase):
+    """TLSA record"""
diff --git a/.venv/lib/python3.12/site-packages/dns/rdtypes/ANY/TSIG.py b/.venv/lib/python3.12/site-packages/dns/rdtypes/ANY/TSIG.py
new file mode 100644
index 00000000..79423826
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/dns/rdtypes/ANY/TSIG.py
@@ -0,0 +1,160 @@
+# Copyright (C) Dnspython Contributors, see LICENSE for text of ISC license
+
+# Copyright (C) 2001-2017 Nominum, Inc.
+#
+# Permission to use, copy, modify, and distribute this software and its
+# documentation for any purpose with or without fee is hereby granted,
+# provided that the above copyright notice and this permission notice
+# appear in all copies.
+#
+# THE SOFTWARE IS PROVIDED "AS IS" AND NOMINUM DISCLAIMS ALL WARRANTIES
+# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL NOMINUM BE LIABLE FOR
+# ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+# ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT
+# OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+
+import base64
+import struct
+
+import dns.exception
+import dns.immutable
+import dns.rcode
+import dns.rdata
+
+
+@dns.immutable.immutable
+class TSIG(dns.rdata.Rdata):
+    """TSIG record"""
+
+    __slots__ = [
+        "algorithm",
+        "time_signed",
+        "fudge",
+        "mac",
+        "original_id",
+        "error",
+        "other",
+    ]
+
+    def __init__(
+        self,
+        rdclass,
+        rdtype,
+        algorithm,
+        time_signed,
+        fudge,
+        mac,
+        original_id,
+        error,
+        other,
+    ):
+        """Initialize a TSIG rdata.
+
+        *rdclass*, an ``int`` is the rdataclass of the Rdata.
+
+        *rdtype*, an ``int`` is the rdatatype of the Rdata.
+
+        *algorithm*, a ``dns.name.Name``.
+
+        *time_signed*, an ``int``.
+
+        *fudge*, an ``int`.
+
+        *mac*, a ``bytes``
+
+        *original_id*, an ``int``
+
+        *error*, an ``int``
+
+        *other*, a ``bytes``
+        """
+
+        super().__init__(rdclass, rdtype)
+        self.algorithm = self._as_name(algorithm)
+        self.time_signed = self._as_uint48(time_signed)
+        self.fudge = self._as_uint16(fudge)
+        self.mac = self._as_bytes(mac)
+        self.original_id = self._as_uint16(original_id)
+        self.error = dns.rcode.Rcode.make(error)
+        self.other = self._as_bytes(other)
+
+    def to_text(self, origin=None, relativize=True, **kw):
+        algorithm = self.algorithm.choose_relativity(origin, relativize)
+        error = dns.rcode.to_text(self.error, True)
+        text = (
+            f"{algorithm} {self.time_signed} {self.fudge} "
+            + f"{len(self.mac)} {dns.rdata._base64ify(self.mac, 0)} "
+            + f"{self.original_id} {error} {len(self.other)}"
+        )
+        if self.other:
+            text += f" {dns.rdata._base64ify(self.other, 0)}"
+        return text
+
+    @classmethod
+    def from_text(
+        cls, rdclass, rdtype, tok, origin=None, relativize=True, relativize_to=None
+    ):
+        algorithm = tok.get_name(relativize=False)
+        time_signed = tok.get_uint48()
+        fudge = tok.get_uint16()
+        mac_len = tok.get_uint16()
+        mac = base64.b64decode(tok.get_string())
+        if len(mac) != mac_len:
+            raise SyntaxError("invalid MAC")
+        original_id = tok.get_uint16()
+        error = dns.rcode.from_text(tok.get_string())
+        other_len = tok.get_uint16()
+        if other_len > 0:
+            other = base64.b64decode(tok.get_string())
+            if len(other) != other_len:
+                raise SyntaxError("invalid other data")
+        else:
+            other = b""
+        return cls(
+            rdclass,
+            rdtype,
+            algorithm,
+            time_signed,
+            fudge,
+            mac,
+            original_id,
+            error,
+            other,
+        )
+
+    def _to_wire(self, file, compress=None, origin=None, canonicalize=False):
+        self.algorithm.to_wire(file, None, origin, False)
+        file.write(
+            struct.pack(
+                "!HIHH",
+                (self.time_signed >> 32) & 0xFFFF,
+                self.time_signed & 0xFFFFFFFF,
+                self.fudge,
+                len(self.mac),
+            )
+        )
+        file.write(self.mac)
+        file.write(struct.pack("!HHH", self.original_id, self.error, len(self.other)))
+        file.write(self.other)
+
+    @classmethod
+    def from_wire_parser(cls, rdclass, rdtype, parser, origin=None):
+        algorithm = parser.get_name()
+        time_signed = parser.get_uint48()
+        fudge = parser.get_uint16()
+        mac = parser.get_counted_bytes(2)
+        (original_id, error) = parser.get_struct("!HH")
+        other = parser.get_counted_bytes(2)
+        return cls(
+            rdclass,
+            rdtype,
+            algorithm,
+            time_signed,
+            fudge,
+            mac,
+            original_id,
+            error,
+            other,
+        )
diff --git a/.venv/lib/python3.12/site-packages/dns/rdtypes/ANY/TXT.py b/.venv/lib/python3.12/site-packages/dns/rdtypes/ANY/TXT.py
new file mode 100644
index 00000000..6d4dae27
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/dns/rdtypes/ANY/TXT.py
@@ -0,0 +1,24 @@
+# Copyright (C) Dnspython Contributors, see LICENSE for text of ISC license
+
+# Copyright (C) 2003-2007, 2009-2011 Nominum, Inc.
+#
+# Permission to use, copy, modify, and distribute this software and its
+# documentation for any purpose with or without fee is hereby granted,
+# provided that the above copyright notice and this permission notice
+# appear in all copies.
+#
+# THE SOFTWARE IS PROVIDED "AS IS" AND NOMINUM DISCLAIMS ALL WARRANTIES
+# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL NOMINUM BE LIABLE FOR
+# ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+# ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT
+# OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+
+import dns.immutable
+import dns.rdtypes.txtbase
+
+
+@dns.immutable.immutable
+class TXT(dns.rdtypes.txtbase.TXTBase):
+    """TXT record"""
diff --git a/.venv/lib/python3.12/site-packages/dns/rdtypes/ANY/URI.py b/.venv/lib/python3.12/site-packages/dns/rdtypes/ANY/URI.py
new file mode 100644
index 00000000..2efbb305
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/dns/rdtypes/ANY/URI.py
@@ -0,0 +1,79 @@
+# Copyright (C) Dnspython Contributors, see LICENSE for text of ISC license
+
+# Copyright (C) 2003-2007, 2009-2011 Nominum, Inc.
+# Copyright (C) 2015 Red Hat, Inc.
+#
+# Permission to use, copy, modify, and distribute this software and its
+# documentation for any purpose with or without fee is hereby granted,
+# provided that the above copyright notice and this permission notice
+# appear in all copies.
+#
+# THE SOFTWARE IS PROVIDED "AS IS" AND NOMINUM DISCLAIMS ALL WARRANTIES
+# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL NOMINUM BE LIABLE FOR
+# ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+# ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT
+# OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+
+import struct
+
+import dns.exception
+import dns.immutable
+import dns.name
+import dns.rdata
+import dns.rdtypes.util
+
+
+@dns.immutable.immutable
+class URI(dns.rdata.Rdata):
+    """URI record"""
+
+    # see RFC 7553
+
+    __slots__ = ["priority", "weight", "target"]
+
+    def __init__(self, rdclass, rdtype, priority, weight, target):
+        super().__init__(rdclass, rdtype)
+        self.priority = self._as_uint16(priority)
+        self.weight = self._as_uint16(weight)
+        self.target = self._as_bytes(target, True)
+        if len(self.target) == 0:
+            raise dns.exception.SyntaxError("URI target cannot be empty")
+
+    def to_text(self, origin=None, relativize=True, **kw):
+        return '%d %d "%s"' % (self.priority, self.weight, self.target.decode())
+
+    @classmethod
+    def from_text(
+        cls, rdclass, rdtype, tok, origin=None, relativize=True, relativize_to=None
+    ):
+        priority = tok.get_uint16()
+        weight = tok.get_uint16()
+        target = tok.get().unescape()
+        if not (target.is_quoted_string() or target.is_identifier()):
+            raise dns.exception.SyntaxError("URI target must be a string")
+        return cls(rdclass, rdtype, priority, weight, target.value)
+
+    def _to_wire(self, file, compress=None, origin=None, canonicalize=False):
+        two_ints = struct.pack("!HH", self.priority, self.weight)
+        file.write(two_ints)
+        file.write(self.target)
+
+    @classmethod
+    def from_wire_parser(cls, rdclass, rdtype, parser, origin=None):
+        (priority, weight) = parser.get_struct("!HH")
+        target = parser.get_remaining()
+        if len(target) == 0:
+            raise dns.exception.FormError("URI target may not be empty")
+        return cls(rdclass, rdtype, priority, weight, target)
+
+    def _processing_priority(self):
+        return self.priority
+
+    def _processing_weight(self):
+        return self.weight
+
+    @classmethod
+    def _processing_order(cls, iterable):
+        return dns.rdtypes.util.weighted_processing_order(iterable)
diff --git a/.venv/lib/python3.12/site-packages/dns/rdtypes/ANY/WALLET.py b/.venv/lib/python3.12/site-packages/dns/rdtypes/ANY/WALLET.py
new file mode 100644
index 00000000..ff464763
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/dns/rdtypes/ANY/WALLET.py
@@ -0,0 +1,9 @@
+# Copyright (C) Dnspython Contributors, see LICENSE for text of ISC license
+
+import dns.immutable
+import dns.rdtypes.txtbase
+
+
+@dns.immutable.immutable
+class WALLET(dns.rdtypes.txtbase.TXTBase):
+    """WALLET record"""
diff --git a/.venv/lib/python3.12/site-packages/dns/rdtypes/ANY/X25.py b/.venv/lib/python3.12/site-packages/dns/rdtypes/ANY/X25.py
new file mode 100644
index 00000000..2436ddb6
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/dns/rdtypes/ANY/X25.py
@@ -0,0 +1,57 @@
+# Copyright (C) Dnspython Contributors, see LICENSE for text of ISC license
+
+# Copyright (C) 2003-2007, 2009-2011 Nominum, Inc.
+#
+# Permission to use, copy, modify, and distribute this software and its
+# documentation for any purpose with or without fee is hereby granted,
+# provided that the above copyright notice and this permission notice
+# appear in all copies.
+#
+# THE SOFTWARE IS PROVIDED "AS IS" AND NOMINUM DISCLAIMS ALL WARRANTIES
+# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL NOMINUM BE LIABLE FOR
+# ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+# ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT
+# OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+
+import struct
+
+import dns.exception
+import dns.immutable
+import dns.rdata
+import dns.tokenizer
+
+
+@dns.immutable.immutable
+class X25(dns.rdata.Rdata):
+    """X25 record"""
+
+    # see RFC 1183
+
+    __slots__ = ["address"]
+
+    def __init__(self, rdclass, rdtype, address):
+        super().__init__(rdclass, rdtype)
+        self.address = self._as_bytes(address, True, 255)
+
+    def to_text(self, origin=None, relativize=True, **kw):
+        return f'"{dns.rdata._escapify(self.address)}"'
+
+    @classmethod
+    def from_text(
+        cls, rdclass, rdtype, tok, origin=None, relativize=True, relativize_to=None
+    ):
+        address = tok.get_string()
+        return cls(rdclass, rdtype, address)
+
+    def _to_wire(self, file, compress=None, origin=None, canonicalize=False):
+        l = len(self.address)
+        assert l < 256
+        file.write(struct.pack("!B", l))
+        file.write(self.address)
+
+    @classmethod
+    def from_wire_parser(cls, rdclass, rdtype, parser, origin=None):
+        address = parser.get_counted_bytes()
+        return cls(rdclass, rdtype, address)
diff --git a/.venv/lib/python3.12/site-packages/dns/rdtypes/ANY/ZONEMD.py b/.venv/lib/python3.12/site-packages/dns/rdtypes/ANY/ZONEMD.py
new file mode 100644
index 00000000..c90e3ee1
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/dns/rdtypes/ANY/ZONEMD.py
@@ -0,0 +1,66 @@
+# Copyright (C) Dnspython Contributors, see LICENSE for text of ISC license
+
+import binascii
+import struct
+
+import dns.immutable
+import dns.rdata
+import dns.rdatatype
+import dns.zonetypes
+
+
+@dns.immutable.immutable
+class ZONEMD(dns.rdata.Rdata):
+    """ZONEMD record"""
+
+    # See RFC 8976
+
+    __slots__ = ["serial", "scheme", "hash_algorithm", "digest"]
+
+    def __init__(self, rdclass, rdtype, serial, scheme, hash_algorithm, digest):
+        super().__init__(rdclass, rdtype)
+        self.serial = self._as_uint32(serial)
+        self.scheme = dns.zonetypes.DigestScheme.make(scheme)
+        self.hash_algorithm = dns.zonetypes.DigestHashAlgorithm.make(hash_algorithm)
+        self.digest = self._as_bytes(digest)
+
+        if self.scheme == 0:  # reserved, RFC 8976 Sec. 5.2
+            raise ValueError("scheme 0 is reserved")
+        if self.hash_algorithm == 0:  # reserved, RFC 8976 Sec. 5.3
+            raise ValueError("hash_algorithm 0 is reserved")
+
+        hasher = dns.zonetypes._digest_hashers.get(self.hash_algorithm)
+        if hasher and hasher().digest_size != len(self.digest):
+            raise ValueError("digest length inconsistent with hash algorithm")
+
+    def to_text(self, origin=None, relativize=True, **kw):
+        kw = kw.copy()
+        chunksize = kw.pop("chunksize", 128)
+        return "%d %d %d %s" % (
+            self.serial,
+            self.scheme,
+            self.hash_algorithm,
+            dns.rdata._hexify(self.digest, chunksize=chunksize, **kw),
+        )
+
+    @classmethod
+    def from_text(
+        cls, rdclass, rdtype, tok, origin=None, relativize=True, relativize_to=None
+    ):
+        serial = tok.get_uint32()
+        scheme = tok.get_uint8()
+        hash_algorithm = tok.get_uint8()
+        digest = tok.concatenate_remaining_identifiers().encode()
+        digest = binascii.unhexlify(digest)
+        return cls(rdclass, rdtype, serial, scheme, hash_algorithm, digest)
+
+    def _to_wire(self, file, compress=None, origin=None, canonicalize=False):
+        header = struct.pack("!IBB", self.serial, self.scheme, self.hash_algorithm)
+        file.write(header)
+        file.write(self.digest)
+
+    @classmethod
+    def from_wire_parser(cls, rdclass, rdtype, parser, origin=None):
+        header = parser.get_struct("!IBB")
+        digest = parser.get_remaining()
+        return cls(rdclass, rdtype, header[0], header[1], header[2], digest)
diff --git a/.venv/lib/python3.12/site-packages/dns/rdtypes/ANY/__init__.py b/.venv/lib/python3.12/site-packages/dns/rdtypes/ANY/__init__.py
new file mode 100644
index 00000000..647b215b
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/dns/rdtypes/ANY/__init__.py
@@ -0,0 +1,70 @@
+# Copyright (C) Dnspython Contributors, see LICENSE for text of ISC license
+
+# Copyright (C) 2003-2007, 2009-2011 Nominum, Inc.
+#
+# Permission to use, copy, modify, and distribute this software and its
+# documentation for any purpose with or without fee is hereby granted,
+# provided that the above copyright notice and this permission notice
+# appear in all copies.
+#
+# THE SOFTWARE IS PROVIDED "AS IS" AND NOMINUM DISCLAIMS ALL WARRANTIES
+# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL NOMINUM BE LIABLE FOR
+# ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+# ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT
+# OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+
+"""Class ANY (generic) rdata type classes."""
+
+__all__ = [
+    "AFSDB",
+    "AMTRELAY",
+    "AVC",
+    "CAA",
+    "CDNSKEY",
+    "CDS",
+    "CERT",
+    "CNAME",
+    "CSYNC",
+    "DLV",
+    "DNAME",
+    "DNSKEY",
+    "DS",
+    "EUI48",
+    "EUI64",
+    "GPOS",
+    "HINFO",
+    "HIP",
+    "ISDN",
+    "L32",
+    "L64",
+    "LOC",
+    "LP",
+    "MX",
+    "NID",
+    "NINFO",
+    "NS",
+    "NSEC",
+    "NSEC3",
+    "NSEC3PARAM",
+    "OPENPGPKEY",
+    "OPT",
+    "PTR",
+    "RESINFO",
+    "RP",
+    "RRSIG",
+    "RT",
+    "SMIMEA",
+    "SOA",
+    "SPF",
+    "SSHFP",
+    "TKEY",
+    "TLSA",
+    "TSIG",
+    "TXT",
+    "URI",
+    "WALLET",
+    "X25",
+    "ZONEMD",
+]
diff --git a/.venv/lib/python3.12/site-packages/dns/rdtypes/CH/A.py b/.venv/lib/python3.12/site-packages/dns/rdtypes/CH/A.py
new file mode 100644
index 00000000..832e8d3a
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/dns/rdtypes/CH/A.py
@@ -0,0 +1,59 @@
+# Copyright (C) Dnspython Contributors, see LICENSE for text of ISC license
+
+# Copyright (C) 2003-2007, 2009-2011 Nominum, Inc.
+#
+# Permission to use, copy, modify, and distribute this software and its
+# documentation for any purpose with or without fee is hereby granted,
+# provided that the above copyright notice and this permission notice
+# appear in all copies.
+#
+# THE SOFTWARE IS PROVIDED "AS IS" AND NOMINUM DISCLAIMS ALL WARRANTIES
+# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL NOMINUM BE LIABLE FOR
+# ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+# ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT
+# OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+
+import struct
+
+import dns.immutable
+import dns.rdtypes.mxbase
+
+
+@dns.immutable.immutable
+class A(dns.rdata.Rdata):
+    """A record for Chaosnet"""
+
+    # domain: the domain of the address
+    # address: the 16-bit address
+
+    __slots__ = ["domain", "address"]
+
+    def __init__(self, rdclass, rdtype, domain, address):
+        super().__init__(rdclass, rdtype)
+        self.domain = self._as_name(domain)
+        self.address = self._as_uint16(address)
+
+    def to_text(self, origin=None, relativize=True, **kw):
+        domain = self.domain.choose_relativity(origin, relativize)
+        return f"{domain} {self.address:o}"
+
+    @classmethod
+    def from_text(
+        cls, rdclass, rdtype, tok, origin=None, relativize=True, relativize_to=None
+    ):
+        domain = tok.get_name(origin, relativize, relativize_to)
+        address = tok.get_uint16(base=8)
+        return cls(rdclass, rdtype, domain, address)
+
+    def _to_wire(self, file, compress=None, origin=None, canonicalize=False):
+        self.domain.to_wire(file, compress, origin, canonicalize)
+        pref = struct.pack("!H", self.address)
+        file.write(pref)
+
+    @classmethod
+    def from_wire_parser(cls, rdclass, rdtype, parser, origin=None):
+        domain = parser.get_name(origin)
+        address = parser.get_uint16()
+        return cls(rdclass, rdtype, domain, address)
diff --git a/.venv/lib/python3.12/site-packages/dns/rdtypes/CH/__init__.py b/.venv/lib/python3.12/site-packages/dns/rdtypes/CH/__init__.py
new file mode 100644
index 00000000..0760c26c
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/dns/rdtypes/CH/__init__.py
@@ -0,0 +1,22 @@
+# Copyright (C) Dnspython Contributors, see LICENSE for text of ISC license
+
+# Copyright (C) 2003-2007, 2009-2011 Nominum, Inc.
+#
+# Permission to use, copy, modify, and distribute this software and its
+# documentation for any purpose with or without fee is hereby granted,
+# provided that the above copyright notice and this permission notice
+# appear in all copies.
+#
+# THE SOFTWARE IS PROVIDED "AS IS" AND NOMINUM DISCLAIMS ALL WARRANTIES
+# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL NOMINUM BE LIABLE FOR
+# ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+# ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT
+# OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+
+"""Class CH rdata type classes."""
+
+__all__ = [
+    "A",
+]
diff --git a/.venv/lib/python3.12/site-packages/dns/rdtypes/IN/A.py b/.venv/lib/python3.12/site-packages/dns/rdtypes/IN/A.py
new file mode 100644
index 00000000..e09d6110
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/dns/rdtypes/IN/A.py
@@ -0,0 +1,51 @@
+# Copyright (C) Dnspython Contributors, see LICENSE for text of ISC license
+
+# Copyright (C) 2003-2007, 2009-2011 Nominum, Inc.
+#
+# Permission to use, copy, modify, and distribute this software and its
+# documentation for any purpose with or without fee is hereby granted,
+# provided that the above copyright notice and this permission notice
+# appear in all copies.
+#
+# THE SOFTWARE IS PROVIDED "AS IS" AND NOMINUM DISCLAIMS ALL WARRANTIES
+# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL NOMINUM BE LIABLE FOR
+# ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+# ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT
+# OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+
+import dns.exception
+import dns.immutable
+import dns.ipv4
+import dns.rdata
+import dns.tokenizer
+
+
+@dns.immutable.immutable
+class A(dns.rdata.Rdata):
+    """A record."""
+
+    __slots__ = ["address"]
+
+    def __init__(self, rdclass, rdtype, address):
+        super().__init__(rdclass, rdtype)
+        self.address = self._as_ipv4_address(address)
+
+    def to_text(self, origin=None, relativize=True, **kw):
+        return self.address
+
+    @classmethod
+    def from_text(
+        cls, rdclass, rdtype, tok, origin=None, relativize=True, relativize_to=None
+    ):
+        address = tok.get_identifier()
+        return cls(rdclass, rdtype, address)
+
+    def _to_wire(self, file, compress=None, origin=None, canonicalize=False):
+        file.write(dns.ipv4.inet_aton(self.address))
+
+    @classmethod
+    def from_wire_parser(cls, rdclass, rdtype, parser, origin=None):
+        address = parser.get_remaining()
+        return cls(rdclass, rdtype, address)
diff --git a/.venv/lib/python3.12/site-packages/dns/rdtypes/IN/AAAA.py b/.venv/lib/python3.12/site-packages/dns/rdtypes/IN/AAAA.py
new file mode 100644
index 00000000..0cd139e7
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/dns/rdtypes/IN/AAAA.py
@@ -0,0 +1,51 @@
+# Copyright (C) Dnspython Contributors, see LICENSE for text of ISC license
+
+# Copyright (C) 2003-2007, 2009-2011 Nominum, Inc.
+#
+# Permission to use, copy, modify, and distribute this software and its
+# documentation for any purpose with or without fee is hereby granted,
+# provided that the above copyright notice and this permission notice
+# appear in all copies.
+#
+# THE SOFTWARE IS PROVIDED "AS IS" AND NOMINUM DISCLAIMS ALL WARRANTIES
+# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL NOMINUM BE LIABLE FOR
+# ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+# ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT
+# OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+
+import dns.exception
+import dns.immutable
+import dns.ipv6
+import dns.rdata
+import dns.tokenizer
+
+
+@dns.immutable.immutable
+class AAAA(dns.rdata.Rdata):
+    """AAAA record."""
+
+    __slots__ = ["address"]
+
+    def __init__(self, rdclass, rdtype, address):
+        super().__init__(rdclass, rdtype)
+        self.address = self._as_ipv6_address(address)
+
+    def to_text(self, origin=None, relativize=True, **kw):
+        return self.address
+
+    @classmethod
+    def from_text(
+        cls, rdclass, rdtype, tok, origin=None, relativize=True, relativize_to=None
+    ):
+        address = tok.get_identifier()
+        return cls(rdclass, rdtype, address)
+
+    def _to_wire(self, file, compress=None, origin=None, canonicalize=False):
+        file.write(dns.ipv6.inet_aton(self.address))
+
+    @classmethod
+    def from_wire_parser(cls, rdclass, rdtype, parser, origin=None):
+        address = parser.get_remaining()
+        return cls(rdclass, rdtype, address)
diff --git a/.venv/lib/python3.12/site-packages/dns/rdtypes/IN/APL.py b/.venv/lib/python3.12/site-packages/dns/rdtypes/IN/APL.py
new file mode 100644
index 00000000..44cb3fef
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/dns/rdtypes/IN/APL.py
@@ -0,0 +1,150 @@
+# Copyright (C) Dnspython Contributors, see LICENSE for text of ISC license
+
+# Copyright (C) 2003-2017 Nominum, Inc.
+#
+# Permission to use, copy, modify, and distribute this software and its
+# documentation for any purpose with or without fee is hereby granted,
+# provided that the above copyright notice and this permission notice
+# appear in all copies.
+#
+# THE SOFTWARE IS PROVIDED "AS IS" AND NOMINUM DISCLAIMS ALL WARRANTIES
+# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL NOMINUM BE LIABLE FOR
+# ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+# ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT
+# OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+
+import binascii
+import codecs
+import struct
+
+import dns.exception
+import dns.immutable
+import dns.ipv4
+import dns.ipv6
+import dns.rdata
+import dns.tokenizer
+
+
+@dns.immutable.immutable
+class APLItem:
+    """An APL list item."""
+
+    __slots__ = ["family", "negation", "address", "prefix"]
+
+    def __init__(self, family, negation, address, prefix):
+        self.family = dns.rdata.Rdata._as_uint16(family)
+        self.negation = dns.rdata.Rdata._as_bool(negation)
+        if self.family == 1:
+            self.address = dns.rdata.Rdata._as_ipv4_address(address)
+            self.prefix = dns.rdata.Rdata._as_int(prefix, 0, 32)
+        elif self.family == 2:
+            self.address = dns.rdata.Rdata._as_ipv6_address(address)
+            self.prefix = dns.rdata.Rdata._as_int(prefix, 0, 128)
+        else:
+            self.address = dns.rdata.Rdata._as_bytes(address, max_length=127)
+            self.prefix = dns.rdata.Rdata._as_uint8(prefix)
+
+    def __str__(self):
+        if self.negation:
+            return "!%d:%s/%s" % (self.family, self.address, self.prefix)
+        else:
+            return "%d:%s/%s" % (self.family, self.address, self.prefix)
+
+    def to_wire(self, file):
+        if self.family == 1:
+            address = dns.ipv4.inet_aton(self.address)
+        elif self.family == 2:
+            address = dns.ipv6.inet_aton(self.address)
+        else:
+            address = binascii.unhexlify(self.address)
+        #
+        # Truncate least significant zero bytes.
+        #
+        last = 0
+        for i in range(len(address) - 1, -1, -1):
+            if address[i] != 0:
+                last = i + 1
+                break
+        address = address[0:last]
+        l = len(address)
+        assert l < 128
+        if self.negation:
+            l |= 0x80
+        header = struct.pack("!HBB", self.family, self.prefix, l)
+        file.write(header)
+        file.write(address)
+
+
+@dns.immutable.immutable
+class APL(dns.rdata.Rdata):
+    """APL record."""
+
+    # see: RFC 3123
+
+    __slots__ = ["items"]
+
+    def __init__(self, rdclass, rdtype, items):
+        super().__init__(rdclass, rdtype)
+        for item in items:
+            if not isinstance(item, APLItem):
+                raise ValueError("item not an APLItem")
+        self.items = tuple(items)
+
+    def to_text(self, origin=None, relativize=True, **kw):
+        return " ".join(map(str, self.items))
+
+    @classmethod
+    def from_text(
+        cls, rdclass, rdtype, tok, origin=None, relativize=True, relativize_to=None
+    ):
+        items = []
+        for token in tok.get_remaining():
+            item = token.unescape().value
+            if item[0] == "!":
+                negation = True
+                item = item[1:]
+            else:
+                negation = False
+            (family, rest) = item.split(":", 1)
+            family = int(family)
+            (address, prefix) = rest.split("/", 1)
+            prefix = int(prefix)
+            item = APLItem(family, negation, address, prefix)
+            items.append(item)
+
+        return cls(rdclass, rdtype, items)
+
+    def _to_wire(self, file, compress=None, origin=None, canonicalize=False):
+        for item in self.items:
+            item.to_wire(file)
+
+    @classmethod
+    def from_wire_parser(cls, rdclass, rdtype, parser, origin=None):
+        items = []
+        while parser.remaining() > 0:
+            header = parser.get_struct("!HBB")
+            afdlen = header[2]
+            if afdlen > 127:
+                negation = True
+                afdlen -= 128
+            else:
+                negation = False
+            address = parser.get_bytes(afdlen)
+            l = len(address)
+            if header[0] == 1:
+                if l < 4:
+                    address += b"\x00" * (4 - l)
+            elif header[0] == 2:
+                if l < 16:
+                    address += b"\x00" * (16 - l)
+            else:
+                #
+                # This isn't really right according to the RFC, but it
+                # seems better than throwing an exception
+                #
+                address = codecs.encode(address, "hex_codec")
+            item = APLItem(header[0], negation, address, header[1])
+            items.append(item)
+        return cls(rdclass, rdtype, items)
diff --git a/.venv/lib/python3.12/site-packages/dns/rdtypes/IN/DHCID.py b/.venv/lib/python3.12/site-packages/dns/rdtypes/IN/DHCID.py
new file mode 100644
index 00000000..723492fa
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/dns/rdtypes/IN/DHCID.py
@@ -0,0 +1,54 @@
+# Copyright (C) Dnspython Contributors, see LICENSE for text of ISC license
+
+# Copyright (C) 2006, 2007, 2009-2011 Nominum, Inc.
+#
+# Permission to use, copy, modify, and distribute this software and its
+# documentation for any purpose with or without fee is hereby granted,
+# provided that the above copyright notice and this permission notice
+# appear in all copies.
+#
+# THE SOFTWARE IS PROVIDED "AS IS" AND NOMINUM DISCLAIMS ALL WARRANTIES
+# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL NOMINUM BE LIABLE FOR
+# ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+# ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT
+# OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+
+import base64
+
+import dns.exception
+import dns.immutable
+import dns.rdata
+
+
+@dns.immutable.immutable
+class DHCID(dns.rdata.Rdata):
+    """DHCID record"""
+
+    # see: RFC 4701
+
+    __slots__ = ["data"]
+
+    def __init__(self, rdclass, rdtype, data):
+        super().__init__(rdclass, rdtype)
+        self.data = self._as_bytes(data)
+
+    def to_text(self, origin=None, relativize=True, **kw):
+        return dns.rdata._base64ify(self.data, **kw)
+
+    @classmethod
+    def from_text(
+        cls, rdclass, rdtype, tok, origin=None, relativize=True, relativize_to=None
+    ):
+        b64 = tok.concatenate_remaining_identifiers().encode()
+        data = base64.b64decode(b64)
+        return cls(rdclass, rdtype, data)
+
+    def _to_wire(self, file, compress=None, origin=None, canonicalize=False):
+        file.write(self.data)
+
+    @classmethod
+    def from_wire_parser(cls, rdclass, rdtype, parser, origin=None):
+        data = parser.get_remaining()
+        return cls(rdclass, rdtype, data)
diff --git a/.venv/lib/python3.12/site-packages/dns/rdtypes/IN/HTTPS.py b/.venv/lib/python3.12/site-packages/dns/rdtypes/IN/HTTPS.py
new file mode 100644
index 00000000..15464cbd
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/dns/rdtypes/IN/HTTPS.py
@@ -0,0 +1,9 @@
+# Copyright (C) Dnspython Contributors, see LICENSE for text of ISC license
+
+import dns.immutable
+import dns.rdtypes.svcbbase
+
+
+@dns.immutable.immutable
+class HTTPS(dns.rdtypes.svcbbase.SVCBBase):
+    """HTTPS record"""
diff --git a/.venv/lib/python3.12/site-packages/dns/rdtypes/IN/IPSECKEY.py b/.venv/lib/python3.12/site-packages/dns/rdtypes/IN/IPSECKEY.py
new file mode 100644
index 00000000..e3a66157
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/dns/rdtypes/IN/IPSECKEY.py
@@ -0,0 +1,91 @@
+# Copyright (C) Dnspython Contributors, see LICENSE for text of ISC license
+
+# Copyright (C) 2006, 2007, 2009-2011 Nominum, Inc.
+#
+# Permission to use, copy, modify, and distribute this software and its
+# documentation for any purpose with or without fee is hereby granted,
+# provided that the above copyright notice and this permission notice
+# appear in all copies.
+#
+# THE SOFTWARE IS PROVIDED "AS IS" AND NOMINUM DISCLAIMS ALL WARRANTIES
+# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL NOMINUM BE LIABLE FOR
+# ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+# ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT
+# OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+
+import base64
+import struct
+
+import dns.exception
+import dns.immutable
+import dns.rdtypes.util
+
+
+class Gateway(dns.rdtypes.util.Gateway):
+    name = "IPSECKEY gateway"
+
+
+@dns.immutable.immutable
+class IPSECKEY(dns.rdata.Rdata):
+    """IPSECKEY record"""
+
+    # see: RFC 4025
+
+    __slots__ = ["precedence", "gateway_type", "algorithm", "gateway", "key"]
+
+    def __init__(
+        self, rdclass, rdtype, precedence, gateway_type, algorithm, gateway, key
+    ):
+        super().__init__(rdclass, rdtype)
+        gateway = Gateway(gateway_type, gateway)
+        self.precedence = self._as_uint8(precedence)
+        self.gateway_type = gateway.type
+        self.algorithm = self._as_uint8(algorithm)
+        self.gateway = gateway.gateway
+        self.key = self._as_bytes(key)
+
+    def to_text(self, origin=None, relativize=True, **kw):
+        gateway = Gateway(self.gateway_type, self.gateway).to_text(origin, relativize)
+        return "%d %d %d %s %s" % (
+            self.precedence,
+            self.gateway_type,
+            self.algorithm,
+            gateway,
+            dns.rdata._base64ify(self.key, **kw),
+        )
+
+    @classmethod
+    def from_text(
+        cls, rdclass, rdtype, tok, origin=None, relativize=True, relativize_to=None
+    ):
+        precedence = tok.get_uint8()
+        gateway_type = tok.get_uint8()
+        algorithm = tok.get_uint8()
+        gateway = Gateway.from_text(
+            gateway_type, tok, origin, relativize, relativize_to
+        )
+        b64 = tok.concatenate_remaining_identifiers().encode()
+        key = base64.b64decode(b64)
+        return cls(
+            rdclass, rdtype, precedence, gateway_type, algorithm, gateway.gateway, key
+        )
+
+    def _to_wire(self, file, compress=None, origin=None, canonicalize=False):
+        header = struct.pack("!BBB", self.precedence, self.gateway_type, self.algorithm)
+        file.write(header)
+        Gateway(self.gateway_type, self.gateway).to_wire(
+            file, compress, origin, canonicalize
+        )
+        file.write(self.key)
+
+    @classmethod
+    def from_wire_parser(cls, rdclass, rdtype, parser, origin=None):
+        header = parser.get_struct("!BBB")
+        gateway_type = header[1]
+        gateway = Gateway.from_wire_parser(gateway_type, parser, origin)
+        key = parser.get_remaining()
+        return cls(
+            rdclass, rdtype, header[0], gateway_type, header[2], gateway.gateway, key
+        )
diff --git a/.venv/lib/python3.12/site-packages/dns/rdtypes/IN/KX.py b/.venv/lib/python3.12/site-packages/dns/rdtypes/IN/KX.py
new file mode 100644
index 00000000..6073df47
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/dns/rdtypes/IN/KX.py
@@ -0,0 +1,24 @@
+# Copyright (C) Dnspython Contributors, see LICENSE for text of ISC license
+
+# Copyright (C) 2003-2007, 2009-2011 Nominum, Inc.
+#
+# Permission to use, copy, modify, and distribute this software and its
+# documentation for any purpose with or without fee is hereby granted,
+# provided that the above copyright notice and this permission notice
+# appear in all copies.
+#
+# THE SOFTWARE IS PROVIDED "AS IS" AND NOMINUM DISCLAIMS ALL WARRANTIES
+# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL NOMINUM BE LIABLE FOR
+# ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+# ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT
+# OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+
+import dns.immutable
+import dns.rdtypes.mxbase
+
+
+@dns.immutable.immutable
+class KX(dns.rdtypes.mxbase.UncompressedDowncasingMX):
+    """KX record"""
diff --git a/.venv/lib/python3.12/site-packages/dns/rdtypes/IN/NAPTR.py b/.venv/lib/python3.12/site-packages/dns/rdtypes/IN/NAPTR.py
new file mode 100644
index 00000000..195d1cba
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/dns/rdtypes/IN/NAPTR.py
@@ -0,0 +1,110 @@
+# Copyright (C) Dnspython Contributors, see LICENSE for text of ISC license
+
+# Copyright (C) 2003-2007, 2009-2011 Nominum, Inc.
+#
+# Permission to use, copy, modify, and distribute this software and its
+# documentation for any purpose with or without fee is hereby granted,
+# provided that the above copyright notice and this permission notice
+# appear in all copies.
+#
+# THE SOFTWARE IS PROVIDED "AS IS" AND NOMINUM DISCLAIMS ALL WARRANTIES
+# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL NOMINUM BE LIABLE FOR
+# ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+# ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT
+# OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+
+import struct
+
+import dns.exception
+import dns.immutable
+import dns.name
+import dns.rdata
+import dns.rdtypes.util
+
+
+def _write_string(file, s):
+    l = len(s)
+    assert l < 256
+    file.write(struct.pack("!B", l))
+    file.write(s)
+
+
+@dns.immutable.immutable
+class NAPTR(dns.rdata.Rdata):
+    """NAPTR record"""
+
+    # see: RFC 3403
+
+    __slots__ = ["order", "preference", "flags", "service", "regexp", "replacement"]
+
+    def __init__(
+        self, rdclass, rdtype, order, preference, flags, service, regexp, replacement
+    ):
+        super().__init__(rdclass, rdtype)
+        self.flags = self._as_bytes(flags, True, 255)
+        self.service = self._as_bytes(service, True, 255)
+        self.regexp = self._as_bytes(regexp, True, 255)
+        self.order = self._as_uint16(order)
+        self.preference = self._as_uint16(preference)
+        self.replacement = self._as_name(replacement)
+
+    def to_text(self, origin=None, relativize=True, **kw):
+        replacement = self.replacement.choose_relativity(origin, relativize)
+        return '%d %d "%s" "%s" "%s" %s' % (
+            self.order,
+            self.preference,
+            dns.rdata._escapify(self.flags),
+            dns.rdata._escapify(self.service),
+            dns.rdata._escapify(self.regexp),
+            replacement,
+        )
+
+    @classmethod
+    def from_text(
+        cls, rdclass, rdtype, tok, origin=None, relativize=True, relativize_to=None
+    ):
+        order = tok.get_uint16()
+        preference = tok.get_uint16()
+        flags = tok.get_string()
+        service = tok.get_string()
+        regexp = tok.get_string()
+        replacement = tok.get_name(origin, relativize, relativize_to)
+        return cls(
+            rdclass, rdtype, order, preference, flags, service, regexp, replacement
+        )
+
+    def _to_wire(self, file, compress=None, origin=None, canonicalize=False):
+        two_ints = struct.pack("!HH", self.order, self.preference)
+        file.write(two_ints)
+        _write_string(file, self.flags)
+        _write_string(file, self.service)
+        _write_string(file, self.regexp)
+        self.replacement.to_wire(file, compress, origin, canonicalize)
+
+    @classmethod
+    def from_wire_parser(cls, rdclass, rdtype, parser, origin=None):
+        (order, preference) = parser.get_struct("!HH")
+        strings = []
+        for _ in range(3):
+            s = parser.get_counted_bytes()
+            strings.append(s)
+        replacement = parser.get_name(origin)
+        return cls(
+            rdclass,
+            rdtype,
+            order,
+            preference,
+            strings[0],
+            strings[1],
+            strings[2],
+            replacement,
+        )
+
+    def _processing_priority(self):
+        return (self.order, self.preference)
+
+    @classmethod
+    def _processing_order(cls, iterable):
+        return dns.rdtypes.util.priority_processing_order(iterable)
diff --git a/.venv/lib/python3.12/site-packages/dns/rdtypes/IN/NSAP.py b/.venv/lib/python3.12/site-packages/dns/rdtypes/IN/NSAP.py
new file mode 100644
index 00000000..d55edb73
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/dns/rdtypes/IN/NSAP.py
@@ -0,0 +1,60 @@
+# Copyright (C) Dnspython Contributors, see LICENSE for text of ISC license
+
+# Copyright (C) 2003-2007, 2009-2011 Nominum, Inc.
+#
+# Permission to use, copy, modify, and distribute this software and its
+# documentation for any purpose with or without fee is hereby granted,
+# provided that the above copyright notice and this permission notice
+# appear in all copies.
+#
+# THE SOFTWARE IS PROVIDED "AS IS" AND NOMINUM DISCLAIMS ALL WARRANTIES
+# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL NOMINUM BE LIABLE FOR
+# ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+# ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT
+# OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+
+import binascii
+
+import dns.exception
+import dns.immutable
+import dns.rdata
+import dns.tokenizer
+
+
+@dns.immutable.immutable
+class NSAP(dns.rdata.Rdata):
+    """NSAP record."""
+
+    # see: RFC 1706
+
+    __slots__ = ["address"]
+
+    def __init__(self, rdclass, rdtype, address):
+        super().__init__(rdclass, rdtype)
+        self.address = self._as_bytes(address)
+
+    def to_text(self, origin=None, relativize=True, **kw):
+        return f"0x{binascii.hexlify(self.address).decode()}"
+
+    @classmethod
+    def from_text(
+        cls, rdclass, rdtype, tok, origin=None, relativize=True, relativize_to=None
+    ):
+        address = tok.get_string()
+        if address[0:2] != "0x":
+            raise dns.exception.SyntaxError("string does not start with 0x")
+        address = address[2:].replace(".", "")
+        if len(address) % 2 != 0:
+            raise dns.exception.SyntaxError("hexstring has odd length")
+        address = binascii.unhexlify(address.encode())
+        return cls(rdclass, rdtype, address)
+
+    def _to_wire(self, file, compress=None, origin=None, canonicalize=False):
+        file.write(self.address)
+
+    @classmethod
+    def from_wire_parser(cls, rdclass, rdtype, parser, origin=None):
+        address = parser.get_remaining()
+        return cls(rdclass, rdtype, address)
diff --git a/.venv/lib/python3.12/site-packages/dns/rdtypes/IN/NSAP_PTR.py b/.venv/lib/python3.12/site-packages/dns/rdtypes/IN/NSAP_PTR.py
new file mode 100644
index 00000000..ce1c6632
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/dns/rdtypes/IN/NSAP_PTR.py
@@ -0,0 +1,24 @@
+# Copyright (C) Dnspython Contributors, see LICENSE for text of ISC license
+
+# Copyright (C) 2003-2007, 2009-2011 Nominum, Inc.
+#
+# Permission to use, copy, modify, and distribute this software and its
+# documentation for any purpose with or without fee is hereby granted,
+# provided that the above copyright notice and this permission notice
+# appear in all copies.
+#
+# THE SOFTWARE IS PROVIDED "AS IS" AND NOMINUM DISCLAIMS ALL WARRANTIES
+# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL NOMINUM BE LIABLE FOR
+# ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+# ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT
+# OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+
+import dns.immutable
+import dns.rdtypes.nsbase
+
+
+@dns.immutable.immutable
+class NSAP_PTR(dns.rdtypes.nsbase.UncompressedNS):
+    """NSAP-PTR record"""
diff --git a/.venv/lib/python3.12/site-packages/dns/rdtypes/IN/PX.py b/.venv/lib/python3.12/site-packages/dns/rdtypes/IN/PX.py
new file mode 100644
index 00000000..cdca1532
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/dns/rdtypes/IN/PX.py
@@ -0,0 +1,73 @@
+# Copyright (C) Dnspython Contributors, see LICENSE for text of ISC license
+
+# Copyright (C) 2003-2007, 2009-2011 Nominum, Inc.
+#
+# Permission to use, copy, modify, and distribute this software and its
+# documentation for any purpose with or without fee is hereby granted,
+# provided that the above copyright notice and this permission notice
+# appear in all copies.
+#
+# THE SOFTWARE IS PROVIDED "AS IS" AND NOMINUM DISCLAIMS ALL WARRANTIES
+# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL NOMINUM BE LIABLE FOR
+# ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+# ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT
+# OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+
+import struct
+
+import dns.exception
+import dns.immutable
+import dns.name
+import dns.rdata
+import dns.rdtypes.util
+
+
+@dns.immutable.immutable
+class PX(dns.rdata.Rdata):
+    """PX record."""
+
+    # see: RFC 2163
+
+    __slots__ = ["preference", "map822", "mapx400"]
+
+    def __init__(self, rdclass, rdtype, preference, map822, mapx400):
+        super().__init__(rdclass, rdtype)
+        self.preference = self._as_uint16(preference)
+        self.map822 = self._as_name(map822)
+        self.mapx400 = self._as_name(mapx400)
+
+    def to_text(self, origin=None, relativize=True, **kw):
+        map822 = self.map822.choose_relativity(origin, relativize)
+        mapx400 = self.mapx400.choose_relativity(origin, relativize)
+        return "%d %s %s" % (self.preference, map822, mapx400)
+
+    @classmethod
+    def from_text(
+        cls, rdclass, rdtype, tok, origin=None, relativize=True, relativize_to=None
+    ):
+        preference = tok.get_uint16()
+        map822 = tok.get_name(origin, relativize, relativize_to)
+        mapx400 = tok.get_name(origin, relativize, relativize_to)
+        return cls(rdclass, rdtype, preference, map822, mapx400)
+
+    def _to_wire(self, file, compress=None, origin=None, canonicalize=False):
+        pref = struct.pack("!H", self.preference)
+        file.write(pref)
+        self.map822.to_wire(file, None, origin, canonicalize)
+        self.mapx400.to_wire(file, None, origin, canonicalize)
+
+    @classmethod
+    def from_wire_parser(cls, rdclass, rdtype, parser, origin=None):
+        preference = parser.get_uint16()
+        map822 = parser.get_name(origin)
+        mapx400 = parser.get_name(origin)
+        return cls(rdclass, rdtype, preference, map822, mapx400)
+
+    def _processing_priority(self):
+        return self.preference
+
+    @classmethod
+    def _processing_order(cls, iterable):
+        return dns.rdtypes.util.priority_processing_order(iterable)
diff --git a/.venv/lib/python3.12/site-packages/dns/rdtypes/IN/SRV.py b/.venv/lib/python3.12/site-packages/dns/rdtypes/IN/SRV.py
new file mode 100644
index 00000000..5adef98f
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/dns/rdtypes/IN/SRV.py
@@ -0,0 +1,75 @@
+# Copyright (C) Dnspython Contributors, see LICENSE for text of ISC license
+
+# Copyright (C) 2003-2007, 2009-2011 Nominum, Inc.
+#
+# Permission to use, copy, modify, and distribute this software and its
+# documentation for any purpose with or without fee is hereby granted,
+# provided that the above copyright notice and this permission notice
+# appear in all copies.
+#
+# THE SOFTWARE IS PROVIDED "AS IS" AND NOMINUM DISCLAIMS ALL WARRANTIES
+# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL NOMINUM BE LIABLE FOR
+# ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+# ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT
+# OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+
+import struct
+
+import dns.exception
+import dns.immutable
+import dns.name
+import dns.rdata
+import dns.rdtypes.util
+
+
+@dns.immutable.immutable
+class SRV(dns.rdata.Rdata):
+    """SRV record"""
+
+    # see: RFC 2782
+
+    __slots__ = ["priority", "weight", "port", "target"]
+
+    def __init__(self, rdclass, rdtype, priority, weight, port, target):
+        super().__init__(rdclass, rdtype)
+        self.priority = self._as_uint16(priority)
+        self.weight = self._as_uint16(weight)
+        self.port = self._as_uint16(port)
+        self.target = self._as_name(target)
+
+    def to_text(self, origin=None, relativize=True, **kw):
+        target = self.target.choose_relativity(origin, relativize)
+        return "%d %d %d %s" % (self.priority, self.weight, self.port, target)
+
+    @classmethod
+    def from_text(
+        cls, rdclass, rdtype, tok, origin=None, relativize=True, relativize_to=None
+    ):
+        priority = tok.get_uint16()
+        weight = tok.get_uint16()
+        port = tok.get_uint16()
+        target = tok.get_name(origin, relativize, relativize_to)
+        return cls(rdclass, rdtype, priority, weight, port, target)
+
+    def _to_wire(self, file, compress=None, origin=None, canonicalize=False):
+        three_ints = struct.pack("!HHH", self.priority, self.weight, self.port)
+        file.write(three_ints)
+        self.target.to_wire(file, compress, origin, canonicalize)
+
+    @classmethod
+    def from_wire_parser(cls, rdclass, rdtype, parser, origin=None):
+        (priority, weight, port) = parser.get_struct("!HHH")
+        target = parser.get_name(origin)
+        return cls(rdclass, rdtype, priority, weight, port, target)
+
+    def _processing_priority(self):
+        return self.priority
+
+    def _processing_weight(self):
+        return self.weight
+
+    @classmethod
+    def _processing_order(cls, iterable):
+        return dns.rdtypes.util.weighted_processing_order(iterable)
diff --git a/.venv/lib/python3.12/site-packages/dns/rdtypes/IN/SVCB.py b/.venv/lib/python3.12/site-packages/dns/rdtypes/IN/SVCB.py
new file mode 100644
index 00000000..ff3e9327
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/dns/rdtypes/IN/SVCB.py
@@ -0,0 +1,9 @@
+# Copyright (C) Dnspython Contributors, see LICENSE for text of ISC license
+
+import dns.immutable
+import dns.rdtypes.svcbbase
+
+
+@dns.immutable.immutable
+class SVCB(dns.rdtypes.svcbbase.SVCBBase):
+    """SVCB record"""
diff --git a/.venv/lib/python3.12/site-packages/dns/rdtypes/IN/WKS.py b/.venv/lib/python3.12/site-packages/dns/rdtypes/IN/WKS.py
new file mode 100644
index 00000000..881a7849
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/dns/rdtypes/IN/WKS.py
@@ -0,0 +1,100 @@
+# Copyright (C) Dnspython Contributors, see LICENSE for text of ISC license
+
+# Copyright (C) 2003-2007, 2009-2011 Nominum, Inc.
+#
+# Permission to use, copy, modify, and distribute this software and its
+# documentation for any purpose with or without fee is hereby granted,
+# provided that the above copyright notice and this permission notice
+# appear in all copies.
+#
+# THE SOFTWARE IS PROVIDED "AS IS" AND NOMINUM DISCLAIMS ALL WARRANTIES
+# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL NOMINUM BE LIABLE FOR
+# ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+# ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT
+# OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+
+import socket
+import struct
+
+import dns.immutable
+import dns.ipv4
+import dns.rdata
+
+try:
+    _proto_tcp = socket.getprotobyname("tcp")
+    _proto_udp = socket.getprotobyname("udp")
+except OSError:
+    # Fall back to defaults in case /etc/protocols is unavailable.
+    _proto_tcp = 6
+    _proto_udp = 17
+
+
+@dns.immutable.immutable
+class WKS(dns.rdata.Rdata):
+    """WKS record"""
+
+    # see: RFC 1035
+
+    __slots__ = ["address", "protocol", "bitmap"]
+
+    def __init__(self, rdclass, rdtype, address, protocol, bitmap):
+        super().__init__(rdclass, rdtype)
+        self.address = self._as_ipv4_address(address)
+        self.protocol = self._as_uint8(protocol)
+        self.bitmap = self._as_bytes(bitmap)
+
+    def to_text(self, origin=None, relativize=True, **kw):
+        bits = []
+        for i, byte in enumerate(self.bitmap):
+            for j in range(0, 8):
+                if byte & (0x80 >> j):
+                    bits.append(str(i * 8 + j))
+        text = " ".join(bits)
+        return "%s %d %s" % (self.address, self.protocol, text)
+
+    @classmethod
+    def from_text(
+        cls, rdclass, rdtype, tok, origin=None, relativize=True, relativize_to=None
+    ):
+        address = tok.get_string()
+        protocol = tok.get_string()
+        if protocol.isdigit():
+            protocol = int(protocol)
+        else:
+            protocol = socket.getprotobyname(protocol)
+        bitmap = bytearray()
+        for token in tok.get_remaining():
+            value = token.unescape().value
+            if value.isdigit():
+                serv = int(value)
+            else:
+                if protocol != _proto_udp and protocol != _proto_tcp:
+                    raise NotImplementedError("protocol must be TCP or UDP")
+                if protocol == _proto_udp:
+                    protocol_text = "udp"
+                else:
+                    protocol_text = "tcp"
+                serv = socket.getservbyname(value, protocol_text)
+            i = serv // 8
+            l = len(bitmap)
+            if l < i + 1:
+                for _ in range(l, i + 1):
+                    bitmap.append(0)
+            bitmap[i] = bitmap[i] | (0x80 >> (serv % 8))
+        bitmap = dns.rdata._truncate_bitmap(bitmap)
+        return cls(rdclass, rdtype, address, protocol, bitmap)
+
+    def _to_wire(self, file, compress=None, origin=None, canonicalize=False):
+        file.write(dns.ipv4.inet_aton(self.address))
+        protocol = struct.pack("!B", self.protocol)
+        file.write(protocol)
+        file.write(self.bitmap)
+
+    @classmethod
+    def from_wire_parser(cls, rdclass, rdtype, parser, origin=None):
+        address = parser.get_bytes(4)
+        protocol = parser.get_uint8()
+        bitmap = parser.get_remaining()
+        return cls(rdclass, rdtype, address, protocol, bitmap)
diff --git a/.venv/lib/python3.12/site-packages/dns/rdtypes/IN/__init__.py b/.venv/lib/python3.12/site-packages/dns/rdtypes/IN/__init__.py
new file mode 100644
index 00000000..dcec4dd2
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/dns/rdtypes/IN/__init__.py
@@ -0,0 +1,35 @@
+# Copyright (C) Dnspython Contributors, see LICENSE for text of ISC license
+
+# Copyright (C) 2003-2007, 2009-2011 Nominum, Inc.
+#
+# Permission to use, copy, modify, and distribute this software and its
+# documentation for any purpose with or without fee is hereby granted,
+# provided that the above copyright notice and this permission notice
+# appear in all copies.
+#
+# THE SOFTWARE IS PROVIDED "AS IS" AND NOMINUM DISCLAIMS ALL WARRANTIES
+# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL NOMINUM BE LIABLE FOR
+# ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+# ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT
+# OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+
+"""Class IN rdata type classes."""
+
+__all__ = [
+    "A",
+    "AAAA",
+    "APL",
+    "DHCID",
+    "HTTPS",
+    "IPSECKEY",
+    "KX",
+    "NAPTR",
+    "NSAP",
+    "NSAP_PTR",
+    "PX",
+    "SRV",
+    "SVCB",
+    "WKS",
+]
diff --git a/.venv/lib/python3.12/site-packages/dns/rdtypes/__init__.py b/.venv/lib/python3.12/site-packages/dns/rdtypes/__init__.py
new file mode 100644
index 00000000..3997f84c
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/dns/rdtypes/__init__.py
@@ -0,0 +1,33 @@
+# Copyright (C) Dnspython Contributors, see LICENSE for text of ISC license
+
+# Copyright (C) 2003-2007, 2009-2011 Nominum, Inc.
+#
+# Permission to use, copy, modify, and distribute this software and its
+# documentation for any purpose with or without fee is hereby granted,
+# provided that the above copyright notice and this permission notice
+# appear in all copies.
+#
+# THE SOFTWARE IS PROVIDED "AS IS" AND NOMINUM DISCLAIMS ALL WARRANTIES
+# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL NOMINUM BE LIABLE FOR
+# ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+# ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT
+# OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+
+"""DNS rdata type classes"""
+
+__all__ = [
+    "ANY",
+    "IN",
+    "CH",
+    "dnskeybase",
+    "dsbase",
+    "euibase",
+    "mxbase",
+    "nsbase",
+    "svcbbase",
+    "tlsabase",
+    "txtbase",
+    "util",
+]
diff --git a/.venv/lib/python3.12/site-packages/dns/rdtypes/dnskeybase.py b/.venv/lib/python3.12/site-packages/dns/rdtypes/dnskeybase.py
new file mode 100644
index 00000000..db300f8b
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/dns/rdtypes/dnskeybase.py
@@ -0,0 +1,87 @@
+# Copyright (C) Dnspython Contributors, see LICENSE for text of ISC license
+
+# Copyright (C) 2004-2007, 2009-2011 Nominum, Inc.
+#
+# Permission to use, copy, modify, and distribute this software and its
+# documentation for any purpose with or without fee is hereby granted,
+# provided that the above copyright notice and this permission notice
+# appear in all copies.
+#
+# THE SOFTWARE IS PROVIDED "AS IS" AND NOMINUM DISCLAIMS ALL WARRANTIES
+# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL NOMINUM BE LIABLE FOR
+# ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+# ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT
+# OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+
+import base64
+import enum
+import struct
+
+import dns.dnssectypes
+import dns.exception
+import dns.immutable
+import dns.rdata
+
+# wildcard import
+__all__ = ["SEP", "REVOKE", "ZONE"]  # noqa: F822
+
+
+class Flag(enum.IntFlag):
+    SEP = 0x0001
+    REVOKE = 0x0080
+    ZONE = 0x0100
+
+
+@dns.immutable.immutable
+class DNSKEYBase(dns.rdata.Rdata):
+    """Base class for rdata that is like a DNSKEY record"""
+
+    __slots__ = ["flags", "protocol", "algorithm", "key"]
+
+    def __init__(self, rdclass, rdtype, flags, protocol, algorithm, key):
+        super().__init__(rdclass, rdtype)
+        self.flags = Flag(self._as_uint16(flags))
+        self.protocol = self._as_uint8(protocol)
+        self.algorithm = dns.dnssectypes.Algorithm.make(algorithm)
+        self.key = self._as_bytes(key)
+
+    def to_text(self, origin=None, relativize=True, **kw):
+        return "%d %d %d %s" % (
+            self.flags,
+            self.protocol,
+            self.algorithm,
+            dns.rdata._base64ify(self.key, **kw),
+        )
+
+    @classmethod
+    def from_text(
+        cls, rdclass, rdtype, tok, origin=None, relativize=True, relativize_to=None
+    ):
+        flags = tok.get_uint16()
+        protocol = tok.get_uint8()
+        algorithm = tok.get_string()
+        b64 = tok.concatenate_remaining_identifiers().encode()
+        key = base64.b64decode(b64)
+        return cls(rdclass, rdtype, flags, protocol, algorithm, key)
+
+    def _to_wire(self, file, compress=None, origin=None, canonicalize=False):
+        header = struct.pack("!HBB", self.flags, self.protocol, self.algorithm)
+        file.write(header)
+        file.write(self.key)
+
+    @classmethod
+    def from_wire_parser(cls, rdclass, rdtype, parser, origin=None):
+        header = parser.get_struct("!HBB")
+        key = parser.get_remaining()
+        return cls(rdclass, rdtype, header[0], header[1], header[2], key)
+
+
+### BEGIN generated Flag constants
+
+SEP = Flag.SEP
+REVOKE = Flag.REVOKE
+ZONE = Flag.ZONE
+
+### END generated Flag constants
diff --git a/.venv/lib/python3.12/site-packages/dns/rdtypes/dsbase.py b/.venv/lib/python3.12/site-packages/dns/rdtypes/dsbase.py
new file mode 100644
index 00000000..cd21f026
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/dns/rdtypes/dsbase.py
@@ -0,0 +1,85 @@
+# Copyright (C) Dnspython Contributors, see LICENSE for text of ISC license
+
+# Copyright (C) 2010, 2011 Nominum, Inc.
+#
+# Permission to use, copy, modify, and distribute this software and its
+# documentation for any purpose with or without fee is hereby granted,
+# provided that the above copyright notice and this permission notice
+# appear in all copies.
+#
+# THE SOFTWARE IS PROVIDED "AS IS" AND NOMINUM DISCLAIMS ALL WARRANTIES
+# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL NOMINUM BE LIABLE FOR
+# ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+# ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT
+# OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+
+import binascii
+import struct
+
+import dns.dnssectypes
+import dns.immutable
+import dns.rdata
+import dns.rdatatype
+
+
+@dns.immutable.immutable
+class DSBase(dns.rdata.Rdata):
+    """Base class for rdata that is like a DS record"""
+
+    __slots__ = ["key_tag", "algorithm", "digest_type", "digest"]
+
+    # Digest types registry:
+    # https://www.iana.org/assignments/ds-rr-types/ds-rr-types.xhtml
+    _digest_length_by_type = {
+        1: 20,  # SHA-1, RFC 3658 Sec. 2.4
+        2: 32,  # SHA-256, RFC 4509 Sec. 2.2
+        3: 32,  # GOST R 34.11-94, RFC 5933 Sec. 4 in conjunction with RFC 4490 Sec. 2.1
+        4: 48,  # SHA-384, RFC 6605 Sec. 2
+    }
+
+    def __init__(self, rdclass, rdtype, key_tag, algorithm, digest_type, digest):
+        super().__init__(rdclass, rdtype)
+        self.key_tag = self._as_uint16(key_tag)
+        self.algorithm = dns.dnssectypes.Algorithm.make(algorithm)
+        self.digest_type = dns.dnssectypes.DSDigest.make(self._as_uint8(digest_type))
+        self.digest = self._as_bytes(digest)
+        try:
+            if len(self.digest) != self._digest_length_by_type[self.digest_type]:
+                raise ValueError("digest length inconsistent with digest type")
+        except KeyError:
+            if self.digest_type == 0:  # reserved, RFC 3658 Sec. 2.4
+                raise ValueError("digest type 0 is reserved")
+
+    def to_text(self, origin=None, relativize=True, **kw):
+        kw = kw.copy()
+        chunksize = kw.pop("chunksize", 128)
+        return "%d %d %d %s" % (
+            self.key_tag,
+            self.algorithm,
+            self.digest_type,
+            dns.rdata._hexify(self.digest, chunksize=chunksize, **kw),
+        )
+
+    @classmethod
+    def from_text(
+        cls, rdclass, rdtype, tok, origin=None, relativize=True, relativize_to=None
+    ):
+        key_tag = tok.get_uint16()
+        algorithm = tok.get_string()
+        digest_type = tok.get_uint8()
+        digest = tok.concatenate_remaining_identifiers().encode()
+        digest = binascii.unhexlify(digest)
+        return cls(rdclass, rdtype, key_tag, algorithm, digest_type, digest)
+
+    def _to_wire(self, file, compress=None, origin=None, canonicalize=False):
+        header = struct.pack("!HBB", self.key_tag, self.algorithm, self.digest_type)
+        file.write(header)
+        file.write(self.digest)
+
+    @classmethod
+    def from_wire_parser(cls, rdclass, rdtype, parser, origin=None):
+        header = parser.get_struct("!HBB")
+        digest = parser.get_remaining()
+        return cls(rdclass, rdtype, header[0], header[1], header[2], digest)
diff --git a/.venv/lib/python3.12/site-packages/dns/rdtypes/euibase.py b/.venv/lib/python3.12/site-packages/dns/rdtypes/euibase.py
new file mode 100644
index 00000000..a39c166b
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/dns/rdtypes/euibase.py
@@ -0,0 +1,70 @@
+# Copyright (C) 2015 Red Hat, Inc.
+# Author: Petr Spacek <pspacek@redhat.com>
+#
+# Permission to use, copy, modify, and distribute this software and its
+# documentation for any purpose with or without fee is hereby granted,
+# provided that the above copyright notice and this permission notice
+# appear in all copies.
+#
+# THE SOFTWARE IS PROVIDED 'AS IS' AND RED HAT DISCLAIMS ALL WARRANTIES
+# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL NOMINUM BE LIABLE FOR
+# ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+# ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT
+# OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+
+import binascii
+
+import dns.immutable
+import dns.rdata
+
+
+@dns.immutable.immutable
+class EUIBase(dns.rdata.Rdata):
+    """EUIxx record"""
+
+    # see: rfc7043.txt
+
+    __slots__ = ["eui"]
+    # define these in subclasses
+    # byte_len = 6  # 0123456789ab (in hex)
+    # text_len = byte_len * 3 - 1  # 01-23-45-67-89-ab
+
+    def __init__(self, rdclass, rdtype, eui):
+        super().__init__(rdclass, rdtype)
+        self.eui = self._as_bytes(eui)
+        if len(self.eui) != self.byte_len:
+            raise dns.exception.FormError(
+                f"EUI{self.byte_len * 8} rdata has to have {self.byte_len} bytes"
+            )
+
+    def to_text(self, origin=None, relativize=True, **kw):
+        return dns.rdata._hexify(self.eui, chunksize=2, separator=b"-", **kw)
+
+    @classmethod
+    def from_text(
+        cls, rdclass, rdtype, tok, origin=None, relativize=True, relativize_to=None
+    ):
+        text = tok.get_string()
+        if len(text) != cls.text_len:
+            raise dns.exception.SyntaxError(
+                f"Input text must have {cls.text_len} characters"
+            )
+        for i in range(2, cls.byte_len * 3 - 1, 3):
+            if text[i] != "-":
+                raise dns.exception.SyntaxError(f"Dash expected at position {i}")
+        text = text.replace("-", "")
+        try:
+            data = binascii.unhexlify(text.encode())
+        except (ValueError, TypeError) as ex:
+            raise dns.exception.SyntaxError(f"Hex decoding error: {str(ex)}")
+        return cls(rdclass, rdtype, data)
+
+    def _to_wire(self, file, compress=None, origin=None, canonicalize=False):
+        file.write(self.eui)
+
+    @classmethod
+    def from_wire_parser(cls, rdclass, rdtype, parser, origin=None):
+        eui = parser.get_bytes(cls.byte_len)
+        return cls(rdclass, rdtype, eui)
diff --git a/.venv/lib/python3.12/site-packages/dns/rdtypes/mxbase.py b/.venv/lib/python3.12/site-packages/dns/rdtypes/mxbase.py
new file mode 100644
index 00000000..6d5e3d87
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/dns/rdtypes/mxbase.py
@@ -0,0 +1,87 @@
+# Copyright (C) Dnspython Contributors, see LICENSE for text of ISC license
+
+# Copyright (C) 2003-2007, 2009-2011 Nominum, Inc.
+#
+# Permission to use, copy, modify, and distribute this software and its
+# documentation for any purpose with or without fee is hereby granted,
+# provided that the above copyright notice and this permission notice
+# appear in all copies.
+#
+# THE SOFTWARE IS PROVIDED "AS IS" AND NOMINUM DISCLAIMS ALL WARRANTIES
+# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL NOMINUM BE LIABLE FOR
+# ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+# ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT
+# OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+
+"""MX-like base classes."""
+
+import struct
+
+import dns.exception
+import dns.immutable
+import dns.name
+import dns.rdata
+import dns.rdtypes.util
+
+
+@dns.immutable.immutable
+class MXBase(dns.rdata.Rdata):
+    """Base class for rdata that is like an MX record."""
+
+    __slots__ = ["preference", "exchange"]
+
+    def __init__(self, rdclass, rdtype, preference, exchange):
+        super().__init__(rdclass, rdtype)
+        self.preference = self._as_uint16(preference)
+        self.exchange = self._as_name(exchange)
+
+    def to_text(self, origin=None, relativize=True, **kw):
+        exchange = self.exchange.choose_relativity(origin, relativize)
+        return "%d %s" % (self.preference, exchange)
+
+    @classmethod
+    def from_text(
+        cls, rdclass, rdtype, tok, origin=None, relativize=True, relativize_to=None
+    ):
+        preference = tok.get_uint16()
+        exchange = tok.get_name(origin, relativize, relativize_to)
+        return cls(rdclass, rdtype, preference, exchange)
+
+    def _to_wire(self, file, compress=None, origin=None, canonicalize=False):
+        pref = struct.pack("!H", self.preference)
+        file.write(pref)
+        self.exchange.to_wire(file, compress, origin, canonicalize)
+
+    @classmethod
+    def from_wire_parser(cls, rdclass, rdtype, parser, origin=None):
+        preference = parser.get_uint16()
+        exchange = parser.get_name(origin)
+        return cls(rdclass, rdtype, preference, exchange)
+
+    def _processing_priority(self):
+        return self.preference
+
+    @classmethod
+    def _processing_order(cls, iterable):
+        return dns.rdtypes.util.priority_processing_order(iterable)
+
+
+@dns.immutable.immutable
+class UncompressedMX(MXBase):
+    """Base class for rdata that is like an MX record, but whose name
+    is not compressed when converted to DNS wire format, and whose
+    digestable form is not downcased."""
+
+    def _to_wire(self, file, compress=None, origin=None, canonicalize=False):
+        super()._to_wire(file, None, origin, False)
+
+
+@dns.immutable.immutable
+class UncompressedDowncasingMX(MXBase):
+    """Base class for rdata that is like an MX record, but whose name
+    is not compressed when convert to DNS wire format."""
+
+    def _to_wire(self, file, compress=None, origin=None, canonicalize=False):
+        super()._to_wire(file, None, origin, canonicalize)
diff --git a/.venv/lib/python3.12/site-packages/dns/rdtypes/nsbase.py b/.venv/lib/python3.12/site-packages/dns/rdtypes/nsbase.py
new file mode 100644
index 00000000..904224f0
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/dns/rdtypes/nsbase.py
@@ -0,0 +1,63 @@
+# Copyright (C) Dnspython Contributors, see LICENSE for text of ISC license
+
+# Copyright (C) 2003-2007, 2009-2011 Nominum, Inc.
+#
+# Permission to use, copy, modify, and distribute this software and its
+# documentation for any purpose with or without fee is hereby granted,
+# provided that the above copyright notice and this permission notice
+# appear in all copies.
+#
+# THE SOFTWARE IS PROVIDED "AS IS" AND NOMINUM DISCLAIMS ALL WARRANTIES
+# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL NOMINUM BE LIABLE FOR
+# ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+# ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT
+# OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+
+"""NS-like base classes."""
+
+import dns.exception
+import dns.immutable
+import dns.name
+import dns.rdata
+
+
+@dns.immutable.immutable
+class NSBase(dns.rdata.Rdata):
+    """Base class for rdata that is like an NS record."""
+
+    __slots__ = ["target"]
+
+    def __init__(self, rdclass, rdtype, target):
+        super().__init__(rdclass, rdtype)
+        self.target = self._as_name(target)
+
+    def to_text(self, origin=None, relativize=True, **kw):
+        target = self.target.choose_relativity(origin, relativize)
+        return str(target)
+
+    @classmethod
+    def from_text(
+        cls, rdclass, rdtype, tok, origin=None, relativize=True, relativize_to=None
+    ):
+        target = tok.get_name(origin, relativize, relativize_to)
+        return cls(rdclass, rdtype, target)
+
+    def _to_wire(self, file, compress=None, origin=None, canonicalize=False):
+        self.target.to_wire(file, compress, origin, canonicalize)
+
+    @classmethod
+    def from_wire_parser(cls, rdclass, rdtype, parser, origin=None):
+        target = parser.get_name(origin)
+        return cls(rdclass, rdtype, target)
+
+
+@dns.immutable.immutable
+class UncompressedNS(NSBase):
+    """Base class for rdata that is like an NS record, but whose name
+    is not compressed when convert to DNS wire format, and whose
+    digestable form is not downcased."""
+
+    def _to_wire(self, file, compress=None, origin=None, canonicalize=False):
+        self.target.to_wire(file, None, origin, False)
diff --git a/.venv/lib/python3.12/site-packages/dns/rdtypes/svcbbase.py b/.venv/lib/python3.12/site-packages/dns/rdtypes/svcbbase.py
new file mode 100644
index 00000000..a2b15b92
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/dns/rdtypes/svcbbase.py
@@ -0,0 +1,585 @@
+# Copyright (C) Dnspython Contributors, see LICENSE for text of ISC license
+
+import base64
+import enum
+import struct
+
+import dns.enum
+import dns.exception
+import dns.immutable
+import dns.ipv4
+import dns.ipv6
+import dns.name
+import dns.rdata
+import dns.rdtypes.util
+import dns.renderer
+import dns.tokenizer
+import dns.wire
+
+# Until there is an RFC, this module is experimental and may be changed in
+# incompatible ways.
+
+
+class UnknownParamKey(dns.exception.DNSException):
+    """Unknown SVCB ParamKey"""
+
+
+class ParamKey(dns.enum.IntEnum):
+    """SVCB ParamKey"""
+
+    MANDATORY = 0
+    ALPN = 1
+    NO_DEFAULT_ALPN = 2
+    PORT = 3
+    IPV4HINT = 4
+    ECH = 5
+    IPV6HINT = 6
+    DOHPATH = 7
+    OHTTP = 8
+
+    @classmethod
+    def _maximum(cls):
+        return 65535
+
+    @classmethod
+    def _short_name(cls):
+        return "SVCBParamKey"
+
+    @classmethod
+    def _prefix(cls):
+        return "KEY"
+
+    @classmethod
+    def _unknown_exception_class(cls):
+        return UnknownParamKey
+
+
+class Emptiness(enum.IntEnum):
+    NEVER = 0
+    ALWAYS = 1
+    ALLOWED = 2
+
+
+def _validate_key(key):
+    force_generic = False
+    if isinstance(key, bytes):
+        # We decode to latin-1 so we get 0-255 as valid and do NOT interpret
+        # UTF-8 sequences
+        key = key.decode("latin-1")
+    if isinstance(key, str):
+        if key.lower().startswith("key"):
+            force_generic = True
+            if key[3:].startswith("0") and len(key) != 4:
+                # key has leading zeros
+                raise ValueError("leading zeros in key")
+        key = key.replace("-", "_")
+    return (ParamKey.make(key), force_generic)
+
+
+def key_to_text(key):
+    return ParamKey.to_text(key).replace("_", "-").lower()
+
+
+# Like rdata escapify, but escapes ',' too.
+
+_escaped = b'",\\'
+
+
+def _escapify(qstring):
+    text = ""
+    for c in qstring:
+        if c in _escaped:
+            text += "\\" + chr(c)
+        elif c >= 0x20 and c < 0x7F:
+            text += chr(c)
+        else:
+            text += "\\%03d" % c
+    return text
+
+
+def _unescape(value):
+    if value == "":
+        return value
+    unescaped = b""
+    l = len(value)
+    i = 0
+    while i < l:
+        c = value[i]
+        i += 1
+        if c == "\\":
+            if i >= l:  # pragma: no cover   (can't happen via tokenizer get())
+                raise dns.exception.UnexpectedEnd
+            c = value[i]
+            i += 1
+            if c.isdigit():
+                if i >= l:
+                    raise dns.exception.UnexpectedEnd
+                c2 = value[i]
+                i += 1
+                if i >= l:
+                    raise dns.exception.UnexpectedEnd
+                c3 = value[i]
+                i += 1
+                if not (c2.isdigit() and c3.isdigit()):
+                    raise dns.exception.SyntaxError
+                codepoint = int(c) * 100 + int(c2) * 10 + int(c3)
+                if codepoint > 255:
+                    raise dns.exception.SyntaxError
+                unescaped += b"%c" % (codepoint)
+                continue
+        unescaped += c.encode()
+    return unescaped
+
+
+def _split(value):
+    l = len(value)
+    i = 0
+    items = []
+    unescaped = b""
+    while i < l:
+        c = value[i]
+        i += 1
+        if c == ord("\\"):
+            if i >= l:  # pragma: no cover   (can't happen via tokenizer get())
+                raise dns.exception.UnexpectedEnd
+            c = value[i]
+            i += 1
+            unescaped += b"%c" % (c)
+        elif c == ord(","):
+            items.append(unescaped)
+            unescaped = b""
+        else:
+            unescaped += b"%c" % (c)
+    items.append(unescaped)
+    return items
+
+
+@dns.immutable.immutable
+class Param:
+    """Abstract base class for SVCB parameters"""
+
+    @classmethod
+    def emptiness(cls):
+        return Emptiness.NEVER
+
+
+@dns.immutable.immutable
+class GenericParam(Param):
+    """Generic SVCB parameter"""
+
+    def __init__(self, value):
+        self.value = dns.rdata.Rdata._as_bytes(value, True)
+
+    @classmethod
+    def emptiness(cls):
+        return Emptiness.ALLOWED
+
+    @classmethod
+    def from_value(cls, value):
+        if value is None or len(value) == 0:
+            return None
+        else:
+            return cls(_unescape(value))
+
+    def to_text(self):
+        return '"' + dns.rdata._escapify(self.value) + '"'
+
+    @classmethod
+    def from_wire_parser(cls, parser, origin=None):  # pylint: disable=W0613
+        value = parser.get_bytes(parser.remaining())
+        if len(value) == 0:
+            return None
+        else:
+            return cls(value)
+
+    def to_wire(self, file, origin=None):  # pylint: disable=W0613
+        file.write(self.value)
+
+
+@dns.immutable.immutable
+class MandatoryParam(Param):
+    def __init__(self, keys):
+        # check for duplicates
+        keys = sorted([_validate_key(key)[0] for key in keys])
+        prior_k = None
+        for k in keys:
+            if k == prior_k:
+                raise ValueError(f"duplicate key {k:d}")
+            prior_k = k
+            if k == ParamKey.MANDATORY:
+                raise ValueError("listed the mandatory key as mandatory")
+        self.keys = tuple(keys)
+
+    @classmethod
+    def from_value(cls, value):
+        keys = [k.encode() for k in value.split(",")]
+        return cls(keys)
+
+    def to_text(self):
+        return '"' + ",".join([key_to_text(key) for key in self.keys]) + '"'
+
+    @classmethod
+    def from_wire_parser(cls, parser, origin=None):  # pylint: disable=W0613
+        keys = []
+        last_key = -1
+        while parser.remaining() > 0:
+            key = parser.get_uint16()
+            if key < last_key:
+                raise dns.exception.FormError("manadatory keys not ascending")
+            last_key = key
+            keys.append(key)
+        return cls(keys)
+
+    def to_wire(self, file, origin=None):  # pylint: disable=W0613
+        for key in self.keys:
+            file.write(struct.pack("!H", key))
+
+
+@dns.immutable.immutable
+class ALPNParam(Param):
+    def __init__(self, ids):
+        self.ids = dns.rdata.Rdata._as_tuple(
+            ids, lambda x: dns.rdata.Rdata._as_bytes(x, True, 255, False)
+        )
+
+    @classmethod
+    def from_value(cls, value):
+        return cls(_split(_unescape(value)))
+
+    def to_text(self):
+        value = ",".join([_escapify(id) for id in self.ids])
+        return '"' + dns.rdata._escapify(value.encode()) + '"'
+
+    @classmethod
+    def from_wire_parser(cls, parser, origin=None):  # pylint: disable=W0613
+        ids = []
+        while parser.remaining() > 0:
+            id = parser.get_counted_bytes()
+            ids.append(id)
+        return cls(ids)
+
+    def to_wire(self, file, origin=None):  # pylint: disable=W0613
+        for id in self.ids:
+            file.write(struct.pack("!B", len(id)))
+            file.write(id)
+
+
+@dns.immutable.immutable
+class NoDefaultALPNParam(Param):
+    # We don't ever expect to instantiate this class, but we need
+    # a from_value() and a from_wire_parser(), so we just return None
+    # from the class methods when things are OK.
+
+    @classmethod
+    def emptiness(cls):
+        return Emptiness.ALWAYS
+
+    @classmethod
+    def from_value(cls, value):
+        if value is None or value == "":
+            return None
+        else:
+            raise ValueError("no-default-alpn with non-empty value")
+
+    def to_text(self):
+        raise NotImplementedError  # pragma: no cover
+
+    @classmethod
+    def from_wire_parser(cls, parser, origin=None):  # pylint: disable=W0613
+        if parser.remaining() != 0:
+            raise dns.exception.FormError
+        return None
+
+    def to_wire(self, file, origin=None):  # pylint: disable=W0613
+        raise NotImplementedError  # pragma: no cover
+
+
+@dns.immutable.immutable
+class PortParam(Param):
+    def __init__(self, port):
+        self.port = dns.rdata.Rdata._as_uint16(port)
+
+    @classmethod
+    def from_value(cls, value):
+        value = int(value)
+        return cls(value)
+
+    def to_text(self):
+        return f'"{self.port}"'
+
+    @classmethod
+    def from_wire_parser(cls, parser, origin=None):  # pylint: disable=W0613
+        port = parser.get_uint16()
+        return cls(port)
+
+    def to_wire(self, file, origin=None):  # pylint: disable=W0613
+        file.write(struct.pack("!H", self.port))
+
+
+@dns.immutable.immutable
+class IPv4HintParam(Param):
+    def __init__(self, addresses):
+        self.addresses = dns.rdata.Rdata._as_tuple(
+            addresses, dns.rdata.Rdata._as_ipv4_address
+        )
+
+    @classmethod
+    def from_value(cls, value):
+        addresses = value.split(",")
+        return cls(addresses)
+
+    def to_text(self):
+        return '"' + ",".join(self.addresses) + '"'
+
+    @classmethod
+    def from_wire_parser(cls, parser, origin=None):  # pylint: disable=W0613
+        addresses = []
+        while parser.remaining() > 0:
+            ip = parser.get_bytes(4)
+            addresses.append(dns.ipv4.inet_ntoa(ip))
+        return cls(addresses)
+
+    def to_wire(self, file, origin=None):  # pylint: disable=W0613
+        for address in self.addresses:
+            file.write(dns.ipv4.inet_aton(address))
+
+
+@dns.immutable.immutable
+class IPv6HintParam(Param):
+    def __init__(self, addresses):
+        self.addresses = dns.rdata.Rdata._as_tuple(
+            addresses, dns.rdata.Rdata._as_ipv6_address
+        )
+
+    @classmethod
+    def from_value(cls, value):
+        addresses = value.split(",")
+        return cls(addresses)
+
+    def to_text(self):
+        return '"' + ",".join(self.addresses) + '"'
+
+    @classmethod
+    def from_wire_parser(cls, parser, origin=None):  # pylint: disable=W0613
+        addresses = []
+        while parser.remaining() > 0:
+            ip = parser.get_bytes(16)
+            addresses.append(dns.ipv6.inet_ntoa(ip))
+        return cls(addresses)
+
+    def to_wire(self, file, origin=None):  # pylint: disable=W0613
+        for address in self.addresses:
+            file.write(dns.ipv6.inet_aton(address))
+
+
+@dns.immutable.immutable
+class ECHParam(Param):
+    def __init__(self, ech):
+        self.ech = dns.rdata.Rdata._as_bytes(ech, True)
+
+    @classmethod
+    def from_value(cls, value):
+        if "\\" in value:
+            raise ValueError("escape in ECH value")
+        value = base64.b64decode(value.encode())
+        return cls(value)
+
+    def to_text(self):
+        b64 = base64.b64encode(self.ech).decode("ascii")
+        return f'"{b64}"'
+
+    @classmethod
+    def from_wire_parser(cls, parser, origin=None):  # pylint: disable=W0613
+        value = parser.get_bytes(parser.remaining())
+        return cls(value)
+
+    def to_wire(self, file, origin=None):  # pylint: disable=W0613
+        file.write(self.ech)
+
+
+@dns.immutable.immutable
+class OHTTPParam(Param):
+    # We don't ever expect to instantiate this class, but we need
+    # a from_value() and a from_wire_parser(), so we just return None
+    # from the class methods when things are OK.
+
+    @classmethod
+    def emptiness(cls):
+        return Emptiness.ALWAYS
+
+    @classmethod
+    def from_value(cls, value):
+        if value is None or value == "":
+            return None
+        else:
+            raise ValueError("ohttp with non-empty value")
+
+    def to_text(self):
+        raise NotImplementedError  # pragma: no cover
+
+    @classmethod
+    def from_wire_parser(cls, parser, origin=None):  # pylint: disable=W0613
+        if parser.remaining() != 0:
+            raise dns.exception.FormError
+        return None
+
+    def to_wire(self, file, origin=None):  # pylint: disable=W0613
+        raise NotImplementedError  # pragma: no cover
+
+
+_class_for_key = {
+    ParamKey.MANDATORY: MandatoryParam,
+    ParamKey.ALPN: ALPNParam,
+    ParamKey.NO_DEFAULT_ALPN: NoDefaultALPNParam,
+    ParamKey.PORT: PortParam,
+    ParamKey.IPV4HINT: IPv4HintParam,
+    ParamKey.ECH: ECHParam,
+    ParamKey.IPV6HINT: IPv6HintParam,
+    ParamKey.OHTTP: OHTTPParam,
+}
+
+
+def _validate_and_define(params, key, value):
+    (key, force_generic) = _validate_key(_unescape(key))
+    if key in params:
+        raise SyntaxError(f'duplicate key "{key:d}"')
+    cls = _class_for_key.get(key, GenericParam)
+    emptiness = cls.emptiness()
+    if value is None:
+        if emptiness == Emptiness.NEVER:
+            raise SyntaxError("value cannot be empty")
+        value = cls.from_value(value)
+    else:
+        if force_generic:
+            value = cls.from_wire_parser(dns.wire.Parser(_unescape(value)))
+        else:
+            value = cls.from_value(value)
+    params[key] = value
+
+
+@dns.immutable.immutable
+class SVCBBase(dns.rdata.Rdata):
+    """Base class for SVCB-like records"""
+
+    # see: draft-ietf-dnsop-svcb-https-11
+
+    __slots__ = ["priority", "target", "params"]
+
+    def __init__(self, rdclass, rdtype, priority, target, params):
+        super().__init__(rdclass, rdtype)
+        self.priority = self._as_uint16(priority)
+        self.target = self._as_name(target)
+        for k, v in params.items():
+            k = ParamKey.make(k)
+            if not isinstance(v, Param) and v is not None:
+                raise ValueError(f"{k:d} not a Param")
+        self.params = dns.immutable.Dict(params)
+        # Make sure any parameter listed as mandatory is present in the
+        # record.
+        mandatory = params.get(ParamKey.MANDATORY)
+        if mandatory:
+            for key in mandatory.keys:
+                # Note we have to say "not in" as we have None as a value
+                # so a get() and a not None test would be wrong.
+                if key not in params:
+                    raise ValueError(f"key {key:d} declared mandatory but not present")
+        # The no-default-alpn parameter requires the alpn parameter.
+        if ParamKey.NO_DEFAULT_ALPN in params:
+            if ParamKey.ALPN not in params:
+                raise ValueError("no-default-alpn present, but alpn missing")
+
+    def to_text(self, origin=None, relativize=True, **kw):
+        target = self.target.choose_relativity(origin, relativize)
+        params = []
+        for key in sorted(self.params.keys()):
+            value = self.params[key]
+            if value is None:
+                params.append(key_to_text(key))
+            else:
+                kv = key_to_text(key) + "=" + value.to_text()
+                params.append(kv)
+        if len(params) > 0:
+            space = " "
+        else:
+            space = ""
+        return "%d %s%s%s" % (self.priority, target, space, " ".join(params))
+
+    @classmethod
+    def from_text(
+        cls, rdclass, rdtype, tok, origin=None, relativize=True, relativize_to=None
+    ):
+        priority = tok.get_uint16()
+        target = tok.get_name(origin, relativize, relativize_to)
+        if priority == 0:
+            token = tok.get()
+            if not token.is_eol_or_eof():
+                raise SyntaxError("parameters in AliasMode")
+            tok.unget(token)
+        params = {}
+        while True:
+            token = tok.get()
+            if token.is_eol_or_eof():
+                tok.unget(token)
+                break
+            if token.ttype != dns.tokenizer.IDENTIFIER:
+                raise SyntaxError("parameter is not an identifier")
+            equals = token.value.find("=")
+            if equals == len(token.value) - 1:
+                # 'key=', so next token should be a quoted string without
+                # any intervening whitespace.
+                key = token.value[:-1]
+                token = tok.get(want_leading=True)
+                if token.ttype != dns.tokenizer.QUOTED_STRING:
+                    raise SyntaxError("whitespace after =")
+                value = token.value
+            elif equals > 0:
+                # key=value
+                key = token.value[:equals]
+                value = token.value[equals + 1 :]
+            elif equals == 0:
+                # =key
+                raise SyntaxError('parameter cannot start with "="')
+            else:
+                # key
+                key = token.value
+                value = None
+            _validate_and_define(params, key, value)
+        return cls(rdclass, rdtype, priority, target, params)
+
+    def _to_wire(self, file, compress=None, origin=None, canonicalize=False):
+        file.write(struct.pack("!H", self.priority))
+        self.target.to_wire(file, None, origin, False)
+        for key in sorted(self.params):
+            file.write(struct.pack("!H", key))
+            value = self.params[key]
+            with dns.renderer.prefixed_length(file, 2):
+                # Note that we're still writing a length of zero if the value is None
+                if value is not None:
+                    value.to_wire(file, origin)
+
+    @classmethod
+    def from_wire_parser(cls, rdclass, rdtype, parser, origin=None):
+        priority = parser.get_uint16()
+        target = parser.get_name(origin)
+        if priority == 0 and parser.remaining() != 0:
+            raise dns.exception.FormError("parameters in AliasMode")
+        params = {}
+        prior_key = -1
+        while parser.remaining() > 0:
+            key = parser.get_uint16()
+            if key < prior_key:
+                raise dns.exception.FormError("keys not in order")
+            prior_key = key
+            vlen = parser.get_uint16()
+            pcls = _class_for_key.get(key, GenericParam)
+            with parser.restrict_to(vlen):
+                value = pcls.from_wire_parser(parser, origin)
+            params[key] = value
+        return cls(rdclass, rdtype, priority, target, params)
+
+    def _processing_priority(self):
+        return self.priority
+
+    @classmethod
+    def _processing_order(cls, iterable):
+        return dns.rdtypes.util.priority_processing_order(iterable)
diff --git a/.venv/lib/python3.12/site-packages/dns/rdtypes/tlsabase.py b/.venv/lib/python3.12/site-packages/dns/rdtypes/tlsabase.py
new file mode 100644
index 00000000..a059d2c4
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/dns/rdtypes/tlsabase.py
@@ -0,0 +1,71 @@
+# Copyright (C) Dnspython Contributors, see LICENSE for text of ISC license
+
+# Copyright (C) 2005-2007, 2009-2011 Nominum, Inc.
+#
+# Permission to use, copy, modify, and distribute this software and its
+# documentation for any purpose with or without fee is hereby granted,
+# provided that the above copyright notice and this permission notice
+# appear in all copies.
+#
+# THE SOFTWARE IS PROVIDED "AS IS" AND NOMINUM DISCLAIMS ALL WARRANTIES
+# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL NOMINUM BE LIABLE FOR
+# ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+# ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT
+# OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+
+import binascii
+import struct
+
+import dns.immutable
+import dns.rdata
+import dns.rdatatype
+
+
+@dns.immutable.immutable
+class TLSABase(dns.rdata.Rdata):
+    """Base class for TLSA and SMIMEA records"""
+
+    # see: RFC 6698
+
+    __slots__ = ["usage", "selector", "mtype", "cert"]
+
+    def __init__(self, rdclass, rdtype, usage, selector, mtype, cert):
+        super().__init__(rdclass, rdtype)
+        self.usage = self._as_uint8(usage)
+        self.selector = self._as_uint8(selector)
+        self.mtype = self._as_uint8(mtype)
+        self.cert = self._as_bytes(cert)
+
+    def to_text(self, origin=None, relativize=True, **kw):
+        kw = kw.copy()
+        chunksize = kw.pop("chunksize", 128)
+        return "%d %d %d %s" % (
+            self.usage,
+            self.selector,
+            self.mtype,
+            dns.rdata._hexify(self.cert, chunksize=chunksize, **kw),
+        )
+
+    @classmethod
+    def from_text(
+        cls, rdclass, rdtype, tok, origin=None, relativize=True, relativize_to=None
+    ):
+        usage = tok.get_uint8()
+        selector = tok.get_uint8()
+        mtype = tok.get_uint8()
+        cert = tok.concatenate_remaining_identifiers().encode()
+        cert = binascii.unhexlify(cert)
+        return cls(rdclass, rdtype, usage, selector, mtype, cert)
+
+    def _to_wire(self, file, compress=None, origin=None, canonicalize=False):
+        header = struct.pack("!BBB", self.usage, self.selector, self.mtype)
+        file.write(header)
+        file.write(self.cert)
+
+    @classmethod
+    def from_wire_parser(cls, rdclass, rdtype, parser, origin=None):
+        header = parser.get_struct("BBB")
+        cert = parser.get_remaining()
+        return cls(rdclass, rdtype, header[0], header[1], header[2], cert)
diff --git a/.venv/lib/python3.12/site-packages/dns/rdtypes/txtbase.py b/.venv/lib/python3.12/site-packages/dns/rdtypes/txtbase.py
new file mode 100644
index 00000000..73db6d9e
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/dns/rdtypes/txtbase.py
@@ -0,0 +1,106 @@
+# Copyright (C) Dnspython Contributors, see LICENSE for text of ISC license
+
+# Copyright (C) 2006-2017 Nominum, Inc.
+#
+# Permission to use, copy, modify, and distribute this software and its
+# documentation for any purpose with or without fee is hereby granted,
+# provided that the above copyright notice and this permission notice
+# appear in all copies.
+#
+# THE SOFTWARE IS PROVIDED "AS IS" AND NOMINUM DISCLAIMS ALL WARRANTIES
+# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL NOMINUM BE LIABLE FOR
+# ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+# ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT
+# OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+
+"""TXT-like base class."""
+
+from typing import Any, Dict, Iterable, Optional, Tuple, Union
+
+import dns.exception
+import dns.immutable
+import dns.rdata
+import dns.renderer
+import dns.tokenizer
+
+
+@dns.immutable.immutable
+class TXTBase(dns.rdata.Rdata):
+    """Base class for rdata that is like a TXT record (see RFC 1035)."""
+
+    __slots__ = ["strings"]
+
+    def __init__(
+        self,
+        rdclass: dns.rdataclass.RdataClass,
+        rdtype: dns.rdatatype.RdataType,
+        strings: Iterable[Union[bytes, str]],
+    ):
+        """Initialize a TXT-like rdata.
+
+        *rdclass*, an ``int`` is the rdataclass of the Rdata.
+
+        *rdtype*, an ``int`` is the rdatatype of the Rdata.
+
+        *strings*, a tuple of ``bytes``
+        """
+        super().__init__(rdclass, rdtype)
+        self.strings: Tuple[bytes] = self._as_tuple(
+            strings, lambda x: self._as_bytes(x, True, 255)
+        )
+        if len(self.strings) == 0:
+            raise ValueError("the list of strings must not be empty")
+
+    def to_text(
+        self,
+        origin: Optional[dns.name.Name] = None,
+        relativize: bool = True,
+        **kw: Dict[str, Any],
+    ) -> str:
+        txt = ""
+        prefix = ""
+        for s in self.strings:
+            txt += f'{prefix}"{dns.rdata._escapify(s)}"'
+            prefix = " "
+        return txt
+
+    @classmethod
+    def from_text(
+        cls,
+        rdclass: dns.rdataclass.RdataClass,
+        rdtype: dns.rdatatype.RdataType,
+        tok: dns.tokenizer.Tokenizer,
+        origin: Optional[dns.name.Name] = None,
+        relativize: bool = True,
+        relativize_to: Optional[dns.name.Name] = None,
+    ) -> dns.rdata.Rdata:
+        strings = []
+        for token in tok.get_remaining():
+            token = token.unescape_to_bytes()
+            # The 'if' below is always true in the current code, but we
+            # are leaving this check in in case things change some day.
+            if not (
+                token.is_quoted_string() or token.is_identifier()
+            ):  # pragma: no cover
+                raise dns.exception.SyntaxError("expected a string")
+            if len(token.value) > 255:
+                raise dns.exception.SyntaxError("string too long")
+            strings.append(token.value)
+        if len(strings) == 0:
+            raise dns.exception.UnexpectedEnd
+        return cls(rdclass, rdtype, strings)
+
+    def _to_wire(self, file, compress=None, origin=None, canonicalize=False):
+        for s in self.strings:
+            with dns.renderer.prefixed_length(file, 1):
+                file.write(s)
+
+    @classmethod
+    def from_wire_parser(cls, rdclass, rdtype, parser, origin=None):
+        strings = []
+        while parser.remaining() > 0:
+            s = parser.get_counted_bytes()
+            strings.append(s)
+        return cls(rdclass, rdtype, strings)
diff --git a/.venv/lib/python3.12/site-packages/dns/rdtypes/util.py b/.venv/lib/python3.12/site-packages/dns/rdtypes/util.py
new file mode 100644
index 00000000..653a0bf2
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/dns/rdtypes/util.py
@@ -0,0 +1,257 @@
+# Copyright (C) Dnspython Contributors, see LICENSE for text of ISC license
+
+# Copyright (C) 2006, 2007, 2009-2011 Nominum, Inc.
+#
+# Permission to use, copy, modify, and distribute this software and its
+# documentation for any purpose with or without fee is hereby granted,
+# provided that the above copyright notice and this permission notice
+# appear in all copies.
+#
+# THE SOFTWARE IS PROVIDED "AS IS" AND NOMINUM DISCLAIMS ALL WARRANTIES
+# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL NOMINUM BE LIABLE FOR
+# ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+# ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT
+# OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+
+import collections
+import random
+import struct
+from typing import Any, List
+
+import dns.exception
+import dns.ipv4
+import dns.ipv6
+import dns.name
+import dns.rdata
+
+
+class Gateway:
+    """A helper class for the IPSECKEY gateway and AMTRELAY relay fields"""
+
+    name = ""
+
+    def __init__(self, type, gateway=None):
+        self.type = dns.rdata.Rdata._as_uint8(type)
+        self.gateway = gateway
+        self._check()
+
+    @classmethod
+    def _invalid_type(cls, gateway_type):
+        return f"invalid {cls.name} type: {gateway_type}"
+
+    def _check(self):
+        if self.type == 0:
+            if self.gateway not in (".", None):
+                raise SyntaxError(f"invalid {self.name} for type 0")
+            self.gateway = None
+        elif self.type == 1:
+            # check that it's OK
+            dns.ipv4.inet_aton(self.gateway)
+        elif self.type == 2:
+            # check that it's OK
+            dns.ipv6.inet_aton(self.gateway)
+        elif self.type == 3:
+            if not isinstance(self.gateway, dns.name.Name):
+                raise SyntaxError(f"invalid {self.name}; not a name")
+        else:
+            raise SyntaxError(self._invalid_type(self.type))
+
+    def to_text(self, origin=None, relativize=True):
+        if self.type == 0:
+            return "."
+        elif self.type in (1, 2):
+            return self.gateway
+        elif self.type == 3:
+            return str(self.gateway.choose_relativity(origin, relativize))
+        else:
+            raise ValueError(self._invalid_type(self.type))  # pragma: no cover
+
+    @classmethod
+    def from_text(
+        cls, gateway_type, tok, origin=None, relativize=True, relativize_to=None
+    ):
+        if gateway_type in (0, 1, 2):
+            gateway = tok.get_string()
+        elif gateway_type == 3:
+            gateway = tok.get_name(origin, relativize, relativize_to)
+        else:
+            raise dns.exception.SyntaxError(
+                cls._invalid_type(gateway_type)
+            )  # pragma: no cover
+        return cls(gateway_type, gateway)
+
+    # pylint: disable=unused-argument
+    def to_wire(self, file, compress=None, origin=None, canonicalize=False):
+        if self.type == 0:
+            pass
+        elif self.type == 1:
+            file.write(dns.ipv4.inet_aton(self.gateway))
+        elif self.type == 2:
+            file.write(dns.ipv6.inet_aton(self.gateway))
+        elif self.type == 3:
+            self.gateway.to_wire(file, None, origin, False)
+        else:
+            raise ValueError(self._invalid_type(self.type))  # pragma: no cover
+
+    # pylint: enable=unused-argument
+
+    @classmethod
+    def from_wire_parser(cls, gateway_type, parser, origin=None):
+        if gateway_type == 0:
+            gateway = None
+        elif gateway_type == 1:
+            gateway = dns.ipv4.inet_ntoa(parser.get_bytes(4))
+        elif gateway_type == 2:
+            gateway = dns.ipv6.inet_ntoa(parser.get_bytes(16))
+        elif gateway_type == 3:
+            gateway = parser.get_name(origin)
+        else:
+            raise dns.exception.FormError(cls._invalid_type(gateway_type))
+        return cls(gateway_type, gateway)
+
+
+class Bitmap:
+    """A helper class for the NSEC/NSEC3/CSYNC type bitmaps"""
+
+    type_name = ""
+
+    def __init__(self, windows=None):
+        last_window = -1
+        self.windows = windows
+        for window, bitmap in self.windows:
+            if not isinstance(window, int):
+                raise ValueError(f"bad {self.type_name} window type")
+            if window <= last_window:
+                raise ValueError(f"bad {self.type_name} window order")
+            if window > 256:
+                raise ValueError(f"bad {self.type_name} window number")
+            last_window = window
+            if not isinstance(bitmap, bytes):
+                raise ValueError(f"bad {self.type_name} octets type")
+            if len(bitmap) == 0 or len(bitmap) > 32:
+                raise ValueError(f"bad {self.type_name} octets")
+
+    def to_text(self) -> str:
+        text = ""
+        for window, bitmap in self.windows:
+            bits = []
+            for i, byte in enumerate(bitmap):
+                for j in range(0, 8):
+                    if byte & (0x80 >> j):
+                        rdtype = window * 256 + i * 8 + j
+                        bits.append(dns.rdatatype.to_text(rdtype))
+            text += " " + " ".join(bits)
+        return text
+
+    @classmethod
+    def from_text(cls, tok: "dns.tokenizer.Tokenizer") -> "Bitmap":
+        rdtypes = []
+        for token in tok.get_remaining():
+            rdtype = dns.rdatatype.from_text(token.unescape().value)
+            if rdtype == 0:
+                raise dns.exception.SyntaxError(f"{cls.type_name} with bit 0")
+            rdtypes.append(rdtype)
+        return cls.from_rdtypes(rdtypes)
+
+    @classmethod
+    def from_rdtypes(cls, rdtypes: List[dns.rdatatype.RdataType]) -> "Bitmap":
+        rdtypes = sorted(rdtypes)
+        window = 0
+        octets = 0
+        prior_rdtype = 0
+        bitmap = bytearray(b"\0" * 32)
+        windows = []
+        for rdtype in rdtypes:
+            if rdtype == prior_rdtype:
+                continue
+            prior_rdtype = rdtype
+            new_window = rdtype // 256
+            if new_window != window:
+                if octets != 0:
+                    windows.append((window, bytes(bitmap[0:octets])))
+                bitmap = bytearray(b"\0" * 32)
+                window = new_window
+            offset = rdtype % 256
+            byte = offset // 8
+            bit = offset % 8
+            octets = byte + 1
+            bitmap[byte] = bitmap[byte] | (0x80 >> bit)
+        if octets != 0:
+            windows.append((window, bytes(bitmap[0:octets])))
+        return cls(windows)
+
+    def to_wire(self, file: Any) -> None:
+        for window, bitmap in self.windows:
+            file.write(struct.pack("!BB", window, len(bitmap)))
+            file.write(bitmap)
+
+    @classmethod
+    def from_wire_parser(cls, parser: "dns.wire.Parser") -> "Bitmap":
+        windows = []
+        while parser.remaining() > 0:
+            window = parser.get_uint8()
+            bitmap = parser.get_counted_bytes()
+            windows.append((window, bitmap))
+        return cls(windows)
+
+
+def _priority_table(items):
+    by_priority = collections.defaultdict(list)
+    for rdata in items:
+        by_priority[rdata._processing_priority()].append(rdata)
+    return by_priority
+
+
+def priority_processing_order(iterable):
+    items = list(iterable)
+    if len(items) == 1:
+        return items
+    by_priority = _priority_table(items)
+    ordered = []
+    for k in sorted(by_priority.keys()):
+        rdatas = by_priority[k]
+        random.shuffle(rdatas)
+        ordered.extend(rdatas)
+    return ordered
+
+
+_no_weight = 0.1
+
+
+def weighted_processing_order(iterable):
+    items = list(iterable)
+    if len(items) == 1:
+        return items
+    by_priority = _priority_table(items)
+    ordered = []
+    for k in sorted(by_priority.keys()):
+        rdatas = by_priority[k]
+        total = sum(rdata._processing_weight() or _no_weight for rdata in rdatas)
+        while len(rdatas) > 1:
+            r = random.uniform(0, total)
+            for n, rdata in enumerate(rdatas):  # noqa: B007
+                weight = rdata._processing_weight() or _no_weight
+                if weight > r:
+                    break
+                r -= weight
+            total -= weight
+            ordered.append(rdata)  # pylint: disable=undefined-loop-variable
+            del rdatas[n]  # pylint: disable=undefined-loop-variable
+        ordered.append(rdatas[0])
+    return ordered
+
+
+def parse_formatted_hex(formatted, num_chunks, chunk_size, separator):
+    if len(formatted) != num_chunks * (chunk_size + 1) - 1:
+        raise ValueError("invalid formatted hex string")
+    value = b""
+    for _ in range(num_chunks):
+        chunk = formatted[0:chunk_size]
+        value += int(chunk, 16).to_bytes(chunk_size // 2, "big")
+        formatted = formatted[chunk_size:]
+        if len(formatted) > 0 and formatted[0] != separator:
+            raise ValueError("invalid formatted hex string")
+        formatted = formatted[1:]
+    return value
diff --git a/.venv/lib/python3.12/site-packages/dns/renderer.py b/.venv/lib/python3.12/site-packages/dns/renderer.py
new file mode 100644
index 00000000..a77481f6
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/dns/renderer.py
@@ -0,0 +1,346 @@
+# Copyright (C) Dnspython Contributors, see LICENSE for text of ISC license
+
+# Copyright (C) 2001-2017 Nominum, Inc.
+#
+# Permission to use, copy, modify, and distribute this software and its
+# documentation for any purpose with or without fee is hereby granted,
+# provided that the above copyright notice and this permission notice
+# appear in all copies.
+#
+# THE SOFTWARE IS PROVIDED "AS IS" AND NOMINUM DISCLAIMS ALL WARRANTIES
+# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL NOMINUM BE LIABLE FOR
+# ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+# ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT
+# OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+
+"""Help for building DNS wire format messages"""
+
+import contextlib
+import io
+import random
+import struct
+import time
+
+import dns.exception
+import dns.tsig
+
+QUESTION = 0
+ANSWER = 1
+AUTHORITY = 2
+ADDITIONAL = 3
+
+
+@contextlib.contextmanager
+def prefixed_length(output, length_length):
+    output.write(b"\00" * length_length)
+    start = output.tell()
+    yield
+    end = output.tell()
+    length = end - start
+    if length > 0:
+        try:
+            output.seek(start - length_length)
+            try:
+                output.write(length.to_bytes(length_length, "big"))
+            except OverflowError:
+                raise dns.exception.FormError
+        finally:
+            output.seek(end)
+
+
+class Renderer:
+    """Helper class for building DNS wire-format messages.
+
+    Most applications can use the higher-level L{dns.message.Message}
+    class and its to_wire() method to generate wire-format messages.
+    This class is for those applications which need finer control
+    over the generation of messages.
+
+    Typical use::
+
+        r = dns.renderer.Renderer(id=1, flags=0x80, max_size=512)
+        r.add_question(qname, qtype, qclass)
+        r.add_rrset(dns.renderer.ANSWER, rrset_1)
+        r.add_rrset(dns.renderer.ANSWER, rrset_2)
+        r.add_rrset(dns.renderer.AUTHORITY, ns_rrset)
+        r.add_rrset(dns.renderer.ADDITIONAL, ad_rrset_1)
+        r.add_rrset(dns.renderer.ADDITIONAL, ad_rrset_2)
+        r.add_edns(0, 0, 4096)
+        r.write_header()
+        r.add_tsig(keyname, secret, 300, 1, 0, '', request_mac)
+        wire = r.get_wire()
+
+    If padding is going to be used, then the OPT record MUST be
+    written after everything else in the additional section except for
+    the TSIG (if any).
+
+    output, an io.BytesIO, where rendering is written
+
+    id: the message id
+
+    flags: the message flags
+
+    max_size: the maximum size of the message
+
+    origin: the origin to use when rendering relative names
+
+    compress: the compression table
+
+    section: an int, the section currently being rendered
+
+    counts: list of the number of RRs in each section
+
+    mac: the MAC of the rendered message (if TSIG was used)
+    """
+
+    def __init__(self, id=None, flags=0, max_size=65535, origin=None):
+        """Initialize a new renderer."""
+
+        self.output = io.BytesIO()
+        if id is None:
+            self.id = random.randint(0, 65535)
+        else:
+            self.id = id
+        self.flags = flags
+        self.max_size = max_size
+        self.origin = origin
+        self.compress = {}
+        self.section = QUESTION
+        self.counts = [0, 0, 0, 0]
+        self.output.write(b"\x00" * 12)
+        self.mac = ""
+        self.reserved = 0
+        self.was_padded = False
+
+    def _rollback(self, where):
+        """Truncate the output buffer at offset *where*, and remove any
+        compression table entries that pointed beyond the truncation
+        point.
+        """
+
+        self.output.seek(where)
+        self.output.truncate()
+        keys_to_delete = []
+        for k, v in self.compress.items():
+            if v >= where:
+                keys_to_delete.append(k)
+        for k in keys_to_delete:
+            del self.compress[k]
+
+    def _set_section(self, section):
+        """Set the renderer's current section.
+
+        Sections must be rendered order: QUESTION, ANSWER, AUTHORITY,
+        ADDITIONAL.  Sections may be empty.
+
+        Raises dns.exception.FormError if an attempt was made to set
+        a section value less than the current section.
+        """
+
+        if self.section != section:
+            if self.section > section:
+                raise dns.exception.FormError
+            self.section = section
+
+    @contextlib.contextmanager
+    def _track_size(self):
+        start = self.output.tell()
+        yield start
+        if self.output.tell() > self.max_size:
+            self._rollback(start)
+            raise dns.exception.TooBig
+
+    @contextlib.contextmanager
+    def _temporarily_seek_to(self, where):
+        current = self.output.tell()
+        try:
+            self.output.seek(where)
+            yield
+        finally:
+            self.output.seek(current)
+
+    def add_question(self, qname, rdtype, rdclass=dns.rdataclass.IN):
+        """Add a question to the message."""
+
+        self._set_section(QUESTION)
+        with self._track_size():
+            qname.to_wire(self.output, self.compress, self.origin)
+            self.output.write(struct.pack("!HH", rdtype, rdclass))
+        self.counts[QUESTION] += 1
+
+    def add_rrset(self, section, rrset, **kw):
+        """Add the rrset to the specified section.
+
+        Any keyword arguments are passed on to the rdataset's to_wire()
+        routine.
+        """
+
+        self._set_section(section)
+        with self._track_size():
+            n = rrset.to_wire(self.output, self.compress, self.origin, **kw)
+        self.counts[section] += n
+
+    def add_rdataset(self, section, name, rdataset, **kw):
+        """Add the rdataset to the specified section, using the specified
+        name as the owner name.
+
+        Any keyword arguments are passed on to the rdataset's to_wire()
+        routine.
+        """
+
+        self._set_section(section)
+        with self._track_size():
+            n = rdataset.to_wire(name, self.output, self.compress, self.origin, **kw)
+        self.counts[section] += n
+
+    def add_opt(self, opt, pad=0, opt_size=0, tsig_size=0):
+        """Add *opt* to the additional section, applying padding if desired.  The
+        padding will take the specified precomputed OPT size and TSIG size into
+        account.
+
+        Note that we don't have reliable way of knowing how big a GSS-TSIG digest
+        might be, so we we might not get an even multiple of the pad in that case."""
+        if pad:
+            ttl = opt.ttl
+            assert opt_size >= 11
+            opt_rdata = opt[0]
+            size_without_padding = self.output.tell() + opt_size + tsig_size
+            remainder = size_without_padding % pad
+            if remainder:
+                pad = b"\x00" * (pad - remainder)
+            else:
+                pad = b""
+            options = list(opt_rdata.options)
+            options.append(dns.edns.GenericOption(dns.edns.OptionType.PADDING, pad))
+            opt = dns.message.Message._make_opt(ttl, opt_rdata.rdclass, options)
+            self.was_padded = True
+        self.add_rrset(ADDITIONAL, opt)
+
+    def add_edns(self, edns, ednsflags, payload, options=None):
+        """Add an EDNS OPT record to the message."""
+
+        # make sure the EDNS version in ednsflags agrees with edns
+        ednsflags &= 0xFF00FFFF
+        ednsflags |= edns << 16
+        opt = dns.message.Message._make_opt(ednsflags, payload, options)
+        self.add_opt(opt)
+
+    def add_tsig(
+        self,
+        keyname,
+        secret,
+        fudge,
+        id,
+        tsig_error,
+        other_data,
+        request_mac,
+        algorithm=dns.tsig.default_algorithm,
+    ):
+        """Add a TSIG signature to the message."""
+
+        s = self.output.getvalue()
+
+        if isinstance(secret, dns.tsig.Key):
+            key = secret
+        else:
+            key = dns.tsig.Key(keyname, secret, algorithm)
+        tsig = dns.message.Message._make_tsig(
+            keyname, algorithm, 0, fudge, b"", id, tsig_error, other_data
+        )
+        (tsig, _) = dns.tsig.sign(s, key, tsig[0], int(time.time()), request_mac)
+        self._write_tsig(tsig, keyname)
+
+    def add_multi_tsig(
+        self,
+        ctx,
+        keyname,
+        secret,
+        fudge,
+        id,
+        tsig_error,
+        other_data,
+        request_mac,
+        algorithm=dns.tsig.default_algorithm,
+    ):
+        """Add a TSIG signature to the message. Unlike add_tsig(), this can be
+        used for a series of consecutive DNS envelopes, e.g. for a zone
+        transfer over TCP [RFC2845, 4.4].
+
+        For the first message in the sequence, give ctx=None. For each
+        subsequent message, give the ctx that was returned from the
+        add_multi_tsig() call for the previous message."""
+
+        s = self.output.getvalue()
+
+        if isinstance(secret, dns.tsig.Key):
+            key = secret
+        else:
+            key = dns.tsig.Key(keyname, secret, algorithm)
+        tsig = dns.message.Message._make_tsig(
+            keyname, algorithm, 0, fudge, b"", id, tsig_error, other_data
+        )
+        (tsig, ctx) = dns.tsig.sign(
+            s, key, tsig[0], int(time.time()), request_mac, ctx, True
+        )
+        self._write_tsig(tsig, keyname)
+        return ctx
+
+    def _write_tsig(self, tsig, keyname):
+        if self.was_padded:
+            compress = None
+        else:
+            compress = self.compress
+        self._set_section(ADDITIONAL)
+        with self._track_size():
+            keyname.to_wire(self.output, compress, self.origin)
+            self.output.write(
+                struct.pack("!HHI", dns.rdatatype.TSIG, dns.rdataclass.ANY, 0)
+            )
+            with prefixed_length(self.output, 2):
+                tsig.to_wire(self.output)
+
+        self.counts[ADDITIONAL] += 1
+        with self._temporarily_seek_to(10):
+            self.output.write(struct.pack("!H", self.counts[ADDITIONAL]))
+
+    def write_header(self):
+        """Write the DNS message header.
+
+        Writing the DNS message header is done after all sections
+        have been rendered, but before the optional TSIG signature
+        is added.
+        """
+
+        with self._temporarily_seek_to(0):
+            self.output.write(
+                struct.pack(
+                    "!HHHHHH",
+                    self.id,
+                    self.flags,
+                    self.counts[0],
+                    self.counts[1],
+                    self.counts[2],
+                    self.counts[3],
+                )
+            )
+
+    def get_wire(self):
+        """Return the wire format message."""
+
+        return self.output.getvalue()
+
+    def reserve(self, size: int) -> None:
+        """Reserve *size* bytes."""
+        if size < 0:
+            raise ValueError("reserved amount must be non-negative")
+        if size > self.max_size:
+            raise ValueError("cannot reserve more than the maximum size")
+        self.reserved += size
+        self.max_size -= size
+
+    def release_reserved(self) -> None:
+        """Release the reserved bytes."""
+        self.max_size += self.reserved
+        self.reserved = 0
diff --git a/.venv/lib/python3.12/site-packages/dns/resolver.py b/.venv/lib/python3.12/site-packages/dns/resolver.py
new file mode 100644
index 00000000..3ba76e31
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/dns/resolver.py
@@ -0,0 +1,2053 @@
+# Copyright (C) Dnspython Contributors, see LICENSE for text of ISC license
+
+# Copyright (C) 2003-2017 Nominum, Inc.
+#
+# Permission to use, copy, modify, and distribute this software and its
+# documentation for any purpose with or without fee is hereby granted,
+# provided that the above copyright notice and this permission notice
+# appear in all copies.
+#
+# THE SOFTWARE IS PROVIDED "AS IS" AND NOMINUM DISCLAIMS ALL WARRANTIES
+# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL NOMINUM BE LIABLE FOR
+# ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+# ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT
+# OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+
+"""DNS stub resolver."""
+
+import contextlib
+import random
+import socket
+import sys
+import threading
+import time
+import warnings
+from typing import Any, Dict, Iterator, List, Optional, Sequence, Tuple, Union
+from urllib.parse import urlparse
+
+import dns._ddr
+import dns.edns
+import dns.exception
+import dns.flags
+import dns.inet
+import dns.ipv4
+import dns.ipv6
+import dns.message
+import dns.name
+import dns.rdata
+import dns.nameserver
+import dns.query
+import dns.rcode
+import dns.rdataclass
+import dns.rdatatype
+import dns.rdtypes.svcbbase
+import dns.reversename
+import dns.tsig
+
+if sys.platform == "win32":  # pragma: no cover
+    import dns.win32util
+
+
+class NXDOMAIN(dns.exception.DNSException):
+    """The DNS query name does not exist."""
+
+    supp_kwargs = {"qnames", "responses"}
+    fmt = None  # we have our own __str__ implementation
+
+    # pylint: disable=arguments-differ
+
+    # We do this as otherwise mypy complains about unexpected keyword argument
+    # idna_exception
+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+
+    def _check_kwargs(self, qnames, responses=None):
+        if not isinstance(qnames, (list, tuple, set)):
+            raise AttributeError("qnames must be a list, tuple or set")
+        if len(qnames) == 0:
+            raise AttributeError("qnames must contain at least one element")
+        if responses is None:
+            responses = {}
+        elif not isinstance(responses, dict):
+            raise AttributeError("responses must be a dict(qname=response)")
+        kwargs = dict(qnames=qnames, responses=responses)
+        return kwargs
+
+    def __str__(self) -> str:
+        if "qnames" not in self.kwargs:
+            return super().__str__()
+        qnames = self.kwargs["qnames"]
+        if len(qnames) > 1:
+            msg = "None of DNS query names exist"
+        else:
+            msg = "The DNS query name does not exist"
+        qnames = ", ".join(map(str, qnames))
+        return f"{msg}: {qnames}"
+
+    @property
+    def canonical_name(self):
+        """Return the unresolved canonical name."""
+        if "qnames" not in self.kwargs:
+            raise TypeError("parametrized exception required")
+        for qname in self.kwargs["qnames"]:
+            response = self.kwargs["responses"][qname]
+            try:
+                cname = response.canonical_name()
+                if cname != qname:
+                    return cname
+            except Exception:  # pragma: no cover
+                # We can just eat this exception as it means there was
+                # something wrong with the response.
+                pass
+        return self.kwargs["qnames"][0]
+
+    def __add__(self, e_nx):
+        """Augment by results from another NXDOMAIN exception."""
+        qnames0 = list(self.kwargs.get("qnames", []))
+        responses0 = dict(self.kwargs.get("responses", {}))
+        responses1 = e_nx.kwargs.get("responses", {})
+        for qname1 in e_nx.kwargs.get("qnames", []):
+            if qname1 not in qnames0:
+                qnames0.append(qname1)
+            if qname1 in responses1:
+                responses0[qname1] = responses1[qname1]
+        return NXDOMAIN(qnames=qnames0, responses=responses0)
+
+    def qnames(self):
+        """All of the names that were tried.
+
+        Returns a list of ``dns.name.Name``.
+        """
+        return self.kwargs["qnames"]
+
+    def responses(self):
+        """A map from queried names to their NXDOMAIN responses.
+
+        Returns a dict mapping a ``dns.name.Name`` to a
+        ``dns.message.Message``.
+        """
+        return self.kwargs["responses"]
+
+    def response(self, qname):
+        """The response for query *qname*.
+
+        Returns a ``dns.message.Message``.
+        """
+        return self.kwargs["responses"][qname]
+
+
+class YXDOMAIN(dns.exception.DNSException):
+    """The DNS query name is too long after DNAME substitution."""
+
+
+ErrorTuple = Tuple[
+    Optional[str],
+    bool,
+    int,
+    Union[Exception, str],
+    Optional[dns.message.Message],
+]
+
+
+def _errors_to_text(errors: List[ErrorTuple]) -> List[str]:
+    """Turn a resolution errors trace into a list of text."""
+    texts = []
+    for err in errors:
+        texts.append(f"Server {err[0]} answered {err[3]}")
+    return texts
+
+
+class LifetimeTimeout(dns.exception.Timeout):
+    """The resolution lifetime expired."""
+
+    msg = "The resolution lifetime expired."
+    fmt = f"{msg[:-1]} after {{timeout:.3f}} seconds: {{errors}}"
+    supp_kwargs = {"timeout", "errors"}
+
+    # We do this as otherwise mypy complains about unexpected keyword argument
+    # idna_exception
+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+
+    def _fmt_kwargs(self, **kwargs):
+        srv_msgs = _errors_to_text(kwargs["errors"])
+        return super()._fmt_kwargs(
+            timeout=kwargs["timeout"], errors="; ".join(srv_msgs)
+        )
+
+
+# We added more detail to resolution timeouts, but they are still
+# subclasses of dns.exception.Timeout for backwards compatibility.  We also
+# keep dns.resolver.Timeout defined for backwards compatibility.
+Timeout = LifetimeTimeout
+
+
+class NoAnswer(dns.exception.DNSException):
+    """The DNS response does not contain an answer to the question."""
+
+    fmt = "The DNS response does not contain an answer to the question: {query}"
+    supp_kwargs = {"response"}
+
+    # We do this as otherwise mypy complains about unexpected keyword argument
+    # idna_exception
+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+
+    def _fmt_kwargs(self, **kwargs):
+        return super()._fmt_kwargs(query=kwargs["response"].question)
+
+    def response(self):
+        return self.kwargs["response"]
+
+
+class NoNameservers(dns.exception.DNSException):
+    """All nameservers failed to answer the query.
+
+    errors: list of servers and respective errors
+    The type of errors is
+    [(server IP address, any object convertible to string)].
+    Non-empty errors list will add explanatory message ()
+    """
+
+    msg = "All nameservers failed to answer the query."
+    fmt = f"{msg[:-1]} {{query}}: {{errors}}"
+    supp_kwargs = {"request", "errors"}
+
+    # We do this as otherwise mypy complains about unexpected keyword argument
+    # idna_exception
+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+
+    def _fmt_kwargs(self, **kwargs):
+        srv_msgs = _errors_to_text(kwargs["errors"])
+        return super()._fmt_kwargs(
+            query=kwargs["request"].question, errors="; ".join(srv_msgs)
+        )
+
+
+class NotAbsolute(dns.exception.DNSException):
+    """An absolute domain name is required but a relative name was provided."""
+
+
+class NoRootSOA(dns.exception.DNSException):
+    """There is no SOA RR at the DNS root name. This should never happen!"""
+
+
+class NoMetaqueries(dns.exception.DNSException):
+    """DNS metaqueries are not allowed."""
+
+
+class NoResolverConfiguration(dns.exception.DNSException):
+    """Resolver configuration could not be read or specified no nameservers."""
+
+
+class Answer:
+    """DNS stub resolver answer.
+
+    Instances of this class bundle up the result of a successful DNS
+    resolution.
+
+    For convenience, the answer object implements much of the sequence
+    protocol, forwarding to its ``rrset`` attribute.  E.g.
+    ``for a in answer`` is equivalent to ``for a in answer.rrset``.
+    ``answer[i]`` is equivalent to ``answer.rrset[i]``, and
+    ``answer[i:j]`` is equivalent to ``answer.rrset[i:j]``.
+
+    Note that CNAMEs or DNAMEs in the response may mean that answer
+    RRset's name might not be the query name.
+    """
+
+    def __init__(
+        self,
+        qname: dns.name.Name,
+        rdtype: dns.rdatatype.RdataType,
+        rdclass: dns.rdataclass.RdataClass,
+        response: dns.message.QueryMessage,
+        nameserver: Optional[str] = None,
+        port: Optional[int] = None,
+    ) -> None:
+        self.qname = qname
+        self.rdtype = rdtype
+        self.rdclass = rdclass
+        self.response = response
+        self.nameserver = nameserver
+        self.port = port
+        self.chaining_result = response.resolve_chaining()
+        # Copy some attributes out of chaining_result for backwards
+        # compatibility and convenience.
+        self.canonical_name = self.chaining_result.canonical_name
+        self.rrset = self.chaining_result.answer
+        self.expiration = time.time() + self.chaining_result.minimum_ttl
+
+    def __getattr__(self, attr):  # pragma: no cover
+        if attr == "name":
+            return self.rrset.name
+        elif attr == "ttl":
+            return self.rrset.ttl
+        elif attr == "covers":
+            return self.rrset.covers
+        elif attr == "rdclass":
+            return self.rrset.rdclass
+        elif attr == "rdtype":
+            return self.rrset.rdtype
+        else:
+            raise AttributeError(attr)
+
+    def __len__(self) -> int:
+        return self.rrset and len(self.rrset) or 0
+
+    def __iter__(self) -> Iterator[dns.rdata.Rdata]:
+        return self.rrset and iter(self.rrset) or iter(tuple())
+
+    def __getitem__(self, i):
+        if self.rrset is None:
+            raise IndexError
+        return self.rrset[i]
+
+    def __delitem__(self, i):
+        if self.rrset is None:
+            raise IndexError
+        del self.rrset[i]
+
+
+class Answers(dict):
+    """A dict of DNS stub resolver answers, indexed by type."""
+
+
+class HostAnswers(Answers):
+    """A dict of DNS stub resolver answers to a host name lookup, indexed by
+    type.
+    """
+
+    @classmethod
+    def make(
+        cls,
+        v6: Optional[Answer] = None,
+        v4: Optional[Answer] = None,
+        add_empty: bool = True,
+    ) -> "HostAnswers":
+        answers = HostAnswers()
+        if v6 is not None and (add_empty or v6.rrset):
+            answers[dns.rdatatype.AAAA] = v6
+        if v4 is not None and (add_empty or v4.rrset):
+            answers[dns.rdatatype.A] = v4
+        return answers
+
+    # Returns pairs of (address, family) from this result, potentially
+    # filtering by address family.
+    def addresses_and_families(
+        self, family: int = socket.AF_UNSPEC
+    ) -> Iterator[Tuple[str, int]]:
+        if family == socket.AF_UNSPEC:
+            yield from self.addresses_and_families(socket.AF_INET6)
+            yield from self.addresses_and_families(socket.AF_INET)
+            return
+        elif family == socket.AF_INET6:
+            answer = self.get(dns.rdatatype.AAAA)
+        elif family == socket.AF_INET:
+            answer = self.get(dns.rdatatype.A)
+        else:  # pragma: no cover
+            raise NotImplementedError(f"unknown address family {family}")
+        if answer:
+            for rdata in answer:
+                yield (rdata.address, family)
+
+    # Returns addresses from this result, potentially filtering by
+    # address family.
+    def addresses(self, family: int = socket.AF_UNSPEC) -> Iterator[str]:
+        return (pair[0] for pair in self.addresses_and_families(family))
+
+    # Returns the canonical name from this result.
+    def canonical_name(self) -> dns.name.Name:
+        answer = self.get(dns.rdatatype.AAAA, self.get(dns.rdatatype.A))
+        return answer.canonical_name
+
+
+class CacheStatistics:
+    """Cache Statistics"""
+
+    def __init__(self, hits: int = 0, misses: int = 0) -> None:
+        self.hits = hits
+        self.misses = misses
+
+    def reset(self) -> None:
+        self.hits = 0
+        self.misses = 0
+
+    def clone(self) -> "CacheStatistics":
+        return CacheStatistics(self.hits, self.misses)
+
+
+class CacheBase:
+    def __init__(self) -> None:
+        self.lock = threading.Lock()
+        self.statistics = CacheStatistics()
+
+    def reset_statistics(self) -> None:
+        """Reset all statistics to zero."""
+        with self.lock:
+            self.statistics.reset()
+
+    def hits(self) -> int:
+        """How many hits has the cache had?"""
+        with self.lock:
+            return self.statistics.hits
+
+    def misses(self) -> int:
+        """How many misses has the cache had?"""
+        with self.lock:
+            return self.statistics.misses
+
+    def get_statistics_snapshot(self) -> CacheStatistics:
+        """Return a consistent snapshot of all the statistics.
+
+        If running with multiple threads, it's better to take a
+        snapshot than to call statistics methods such as hits() and
+        misses() individually.
+        """
+        with self.lock:
+            return self.statistics.clone()
+
+
+CacheKey = Tuple[dns.name.Name, dns.rdatatype.RdataType, dns.rdataclass.RdataClass]
+
+
+class Cache(CacheBase):
+    """Simple thread-safe DNS answer cache."""
+
+    def __init__(self, cleaning_interval: float = 300.0) -> None:
+        """*cleaning_interval*, a ``float`` is the number of seconds between
+        periodic cleanings.
+        """
+
+        super().__init__()
+        self.data: Dict[CacheKey, Answer] = {}
+        self.cleaning_interval = cleaning_interval
+        self.next_cleaning: float = time.time() + self.cleaning_interval
+
+    def _maybe_clean(self) -> None:
+        """Clean the cache if it's time to do so."""
+
+        now = time.time()
+        if self.next_cleaning <= now:
+            keys_to_delete = []
+            for k, v in self.data.items():
+                if v.expiration <= now:
+                    keys_to_delete.append(k)
+            for k in keys_to_delete:
+                del self.data[k]
+            now = time.time()
+            self.next_cleaning = now + self.cleaning_interval
+
+    def get(self, key: CacheKey) -> Optional[Answer]:
+        """Get the answer associated with *key*.
+
+        Returns None if no answer is cached for the key.
+
+        *key*, a ``(dns.name.Name, dns.rdatatype.RdataType, dns.rdataclass.RdataClass)``
+        tuple whose values are the query name, rdtype, and rdclass respectively.
+
+        Returns a ``dns.resolver.Answer`` or ``None``.
+        """
+
+        with self.lock:
+            self._maybe_clean()
+            v = self.data.get(key)
+            if v is None or v.expiration <= time.time():
+                self.statistics.misses += 1
+                return None
+            self.statistics.hits += 1
+            return v
+
+    def put(self, key: CacheKey, value: Answer) -> None:
+        """Associate key and value in the cache.
+
+        *key*, a ``(dns.name.Name, dns.rdatatype.RdataType, dns.rdataclass.RdataClass)``
+        tuple whose values are the query name, rdtype, and rdclass respectively.
+
+        *value*, a ``dns.resolver.Answer``, the answer.
+        """
+
+        with self.lock:
+            self._maybe_clean()
+            self.data[key] = value
+
+    def flush(self, key: Optional[CacheKey] = None) -> None:
+        """Flush the cache.
+
+        If *key* is not ``None``, only that item is flushed.  Otherwise the entire cache
+        is flushed.
+
+        *key*, a ``(dns.name.Name, dns.rdatatype.RdataType, dns.rdataclass.RdataClass)``
+        tuple whose values are the query name, rdtype, and rdclass respectively.
+        """
+
+        with self.lock:
+            if key is not None:
+                if key in self.data:
+                    del self.data[key]
+            else:
+                self.data = {}
+                self.next_cleaning = time.time() + self.cleaning_interval
+
+
+class LRUCacheNode:
+    """LRUCache node."""
+
+    def __init__(self, key, value):
+        self.key = key
+        self.value = value
+        self.hits = 0
+        self.prev = self
+        self.next = self
+
+    def link_after(self, node: "LRUCacheNode") -> None:
+        self.prev = node
+        self.next = node.next
+        node.next.prev = self
+        node.next = self
+
+    def unlink(self) -> None:
+        self.next.prev = self.prev
+        self.prev.next = self.next
+
+
+class LRUCache(CacheBase):
+    """Thread-safe, bounded, least-recently-used DNS answer cache.
+
+    This cache is better than the simple cache (above) if you're
+    running a web crawler or other process that does a lot of
+    resolutions.  The LRUCache has a maximum number of nodes, and when
+    it is full, the least-recently used node is removed to make space
+    for a new one.
+    """
+
+    def __init__(self, max_size: int = 100000) -> None:
+        """*max_size*, an ``int``, is the maximum number of nodes to cache;
+        it must be greater than 0.
+        """
+
+        super().__init__()
+        self.data: Dict[CacheKey, LRUCacheNode] = {}
+        self.set_max_size(max_size)
+        self.sentinel: LRUCacheNode = LRUCacheNode(None, None)
+        self.sentinel.prev = self.sentinel
+        self.sentinel.next = self.sentinel
+
+    def set_max_size(self, max_size: int) -> None:
+        if max_size < 1:
+            max_size = 1
+        self.max_size = max_size
+
+    def get(self, key: CacheKey) -> Optional[Answer]:
+        """Get the answer associated with *key*.
+
+        Returns None if no answer is cached for the key.
+
+        *key*, a ``(dns.name.Name, dns.rdatatype.RdataType, dns.rdataclass.RdataClass)``
+        tuple whose values are the query name, rdtype, and rdclass respectively.
+
+        Returns a ``dns.resolver.Answer`` or ``None``.
+        """
+
+        with self.lock:
+            node = self.data.get(key)
+            if node is None:
+                self.statistics.misses += 1
+                return None
+            # Unlink because we're either going to move the node to the front
+            # of the LRU list or we're going to free it.
+            node.unlink()
+            if node.value.expiration <= time.time():
+                del self.data[node.key]
+                self.statistics.misses += 1
+                return None
+            node.link_after(self.sentinel)
+            self.statistics.hits += 1
+            node.hits += 1
+            return node.value
+
+    def get_hits_for_key(self, key: CacheKey) -> int:
+        """Return the number of cache hits associated with the specified key."""
+        with self.lock:
+            node = self.data.get(key)
+            if node is None or node.value.expiration <= time.time():
+                return 0
+            else:
+                return node.hits
+
+    def put(self, key: CacheKey, value: Answer) -> None:
+        """Associate key and value in the cache.
+
+        *key*, a ``(dns.name.Name, dns.rdatatype.RdataType, dns.rdataclass.RdataClass)``
+        tuple whose values are the query name, rdtype, and rdclass respectively.
+
+        *value*, a ``dns.resolver.Answer``, the answer.
+        """
+
+        with self.lock:
+            node = self.data.get(key)
+            if node is not None:
+                node.unlink()
+                del self.data[node.key]
+            while len(self.data) >= self.max_size:
+                gnode = self.sentinel.prev
+                gnode.unlink()
+                del self.data[gnode.key]
+            node = LRUCacheNode(key, value)
+            node.link_after(self.sentinel)
+            self.data[key] = node
+
+    def flush(self, key: Optional[CacheKey] = None) -> None:
+        """Flush the cache.
+
+        If *key* is not ``None``, only that item is flushed.  Otherwise the entire cache
+        is flushed.
+
+        *key*, a ``(dns.name.Name, dns.rdatatype.RdataType, dns.rdataclass.RdataClass)``
+        tuple whose values are the query name, rdtype, and rdclass respectively.
+        """
+
+        with self.lock:
+            if key is not None:
+                node = self.data.get(key)
+                if node is not None:
+                    node.unlink()
+                    del self.data[node.key]
+            else:
+                gnode = self.sentinel.next
+                while gnode != self.sentinel:
+                    next = gnode.next
+                    gnode.unlink()
+                    gnode = next
+                self.data = {}
+
+
+class _Resolution:
+    """Helper class for dns.resolver.Resolver.resolve().
+
+    All of the "business logic" of resolution is encapsulated in this
+    class, allowing us to have multiple resolve() implementations
+    using different I/O schemes without copying all of the
+    complicated logic.
+
+    This class is a "friend" to dns.resolver.Resolver and manipulates
+    resolver data structures directly.
+    """
+
+    def __init__(
+        self,
+        resolver: "BaseResolver",
+        qname: Union[dns.name.Name, str],
+        rdtype: Union[dns.rdatatype.RdataType, str],
+        rdclass: Union[dns.rdataclass.RdataClass, str],
+        tcp: bool,
+        raise_on_no_answer: bool,
+        search: Optional[bool],
+    ) -> None:
+        if isinstance(qname, str):
+            qname = dns.name.from_text(qname, None)
+        rdtype = dns.rdatatype.RdataType.make(rdtype)
+        if dns.rdatatype.is_metatype(rdtype):
+            raise NoMetaqueries
+        rdclass = dns.rdataclass.RdataClass.make(rdclass)
+        if dns.rdataclass.is_metaclass(rdclass):
+            raise NoMetaqueries
+        self.resolver = resolver
+        self.qnames_to_try = resolver._get_qnames_to_try(qname, search)
+        self.qnames = self.qnames_to_try[:]
+        self.rdtype = rdtype
+        self.rdclass = rdclass
+        self.tcp = tcp
+        self.raise_on_no_answer = raise_on_no_answer
+        self.nxdomain_responses: Dict[dns.name.Name, dns.message.QueryMessage] = {}
+        # Initialize other things to help analysis tools
+        self.qname = dns.name.empty
+        self.nameservers: List[dns.nameserver.Nameserver] = []
+        self.current_nameservers: List[dns.nameserver.Nameserver] = []
+        self.errors: List[ErrorTuple] = []
+        self.nameserver: Optional[dns.nameserver.Nameserver] = None
+        self.tcp_attempt = False
+        self.retry_with_tcp = False
+        self.request: Optional[dns.message.QueryMessage] = None
+        self.backoff = 0.0
+
+    def next_request(
+        self,
+    ) -> Tuple[Optional[dns.message.QueryMessage], Optional[Answer]]:
+        """Get the next request to send, and check the cache.
+
+        Returns a (request, answer) tuple.  At most one of request or
+        answer will not be None.
+        """
+
+        # We return a tuple instead of Union[Message,Answer] as it lets
+        # the caller avoid isinstance().
+
+        while len(self.qnames) > 0:
+            self.qname = self.qnames.pop(0)
+
+            # Do we know the answer?
+            if self.resolver.cache:
+                answer = self.resolver.cache.get(
+                    (self.qname, self.rdtype, self.rdclass)
+                )
+                if answer is not None:
+                    if answer.rrset is None and self.raise_on_no_answer:
+                        raise NoAnswer(response=answer.response)
+                    else:
+                        return (None, answer)
+                answer = self.resolver.cache.get(
+                    (self.qname, dns.rdatatype.ANY, self.rdclass)
+                )
+                if answer is not None and answer.response.rcode() == dns.rcode.NXDOMAIN:
+                    # cached NXDOMAIN; record it and continue to next
+                    # name.
+                    self.nxdomain_responses[self.qname] = answer.response
+                    continue
+
+            # Build the request
+            request = dns.message.make_query(self.qname, self.rdtype, self.rdclass)
+            if self.resolver.keyname is not None:
+                request.use_tsig(
+                    self.resolver.keyring,
+                    self.resolver.keyname,
+                    algorithm=self.resolver.keyalgorithm,
+                )
+            request.use_edns(
+                self.resolver.edns,
+                self.resolver.ednsflags,
+                self.resolver.payload,
+                options=self.resolver.ednsoptions,
+            )
+            if self.resolver.flags is not None:
+                request.flags = self.resolver.flags
+
+            self.nameservers = self.resolver._enrich_nameservers(
+                self.resolver._nameservers,
+                self.resolver.nameserver_ports,
+                self.resolver.port,
+            )
+            if self.resolver.rotate:
+                random.shuffle(self.nameservers)
+            self.current_nameservers = self.nameservers[:]
+            self.errors = []
+            self.nameserver = None
+            self.tcp_attempt = False
+            self.retry_with_tcp = False
+            self.request = request
+            self.backoff = 0.10
+
+            return (request, None)
+
+        #
+        # We've tried everything and only gotten NXDOMAINs.  (We know
+        # it's only NXDOMAINs as anything else would have returned
+        # before now.)
+        #
+        raise NXDOMAIN(qnames=self.qnames_to_try, responses=self.nxdomain_responses)
+
+    def next_nameserver(self) -> Tuple[dns.nameserver.Nameserver, bool, float]:
+        if self.retry_with_tcp:
+            assert self.nameserver is not None
+            assert not self.nameserver.is_always_max_size()
+            self.tcp_attempt = True
+            self.retry_with_tcp = False
+            return (self.nameserver, True, 0)
+
+        backoff = 0.0
+        if not self.current_nameservers:
+            if len(self.nameservers) == 0:
+                # Out of things to try!
+                raise NoNameservers(request=self.request, errors=self.errors)
+            self.current_nameservers = self.nameservers[:]
+            backoff = self.backoff
+            self.backoff = min(self.backoff * 2, 2)
+
+        self.nameserver = self.current_nameservers.pop(0)
+        self.tcp_attempt = self.tcp or self.nameserver.is_always_max_size()
+        return (self.nameserver, self.tcp_attempt, backoff)
+
+    def query_result(
+        self, response: Optional[dns.message.Message], ex: Optional[Exception]
+    ) -> Tuple[Optional[Answer], bool]:
+        #
+        # returns an (answer: Answer, end_loop: bool) tuple.
+        #
+        assert self.nameserver is not None
+        if ex:
+            # Exception during I/O or from_wire()
+            assert response is None
+            self.errors.append(
+                (
+                    str(self.nameserver),
+                    self.tcp_attempt,
+                    self.nameserver.answer_port(),
+                    ex,
+                    response,
+                )
+            )
+            if (
+                isinstance(ex, dns.exception.FormError)
+                or isinstance(ex, EOFError)
+                or isinstance(ex, OSError)
+                or isinstance(ex, NotImplementedError)
+            ):
+                # This nameserver is no good, take it out of the mix.
+                self.nameservers.remove(self.nameserver)
+            elif isinstance(ex, dns.message.Truncated):
+                if self.tcp_attempt:
+                    # Truncation with TCP is no good!
+                    self.nameservers.remove(self.nameserver)
+                else:
+                    self.retry_with_tcp = True
+            return (None, False)
+        # We got an answer!
+        assert response is not None
+        assert isinstance(response, dns.message.QueryMessage)
+        rcode = response.rcode()
+        if rcode == dns.rcode.NOERROR:
+            try:
+                answer = Answer(
+                    self.qname,
+                    self.rdtype,
+                    self.rdclass,
+                    response,
+                    self.nameserver.answer_nameserver(),
+                    self.nameserver.answer_port(),
+                )
+            except Exception as e:
+                self.errors.append(
+                    (
+                        str(self.nameserver),
+                        self.tcp_attempt,
+                        self.nameserver.answer_port(),
+                        e,
+                        response,
+                    )
+                )
+                # The nameserver is no good, take it out of the mix.
+                self.nameservers.remove(self.nameserver)
+                return (None, False)
+            if self.resolver.cache:
+                self.resolver.cache.put((self.qname, self.rdtype, self.rdclass), answer)
+            if answer.rrset is None and self.raise_on_no_answer:
+                raise NoAnswer(response=answer.response)
+            return (answer, True)
+        elif rcode == dns.rcode.NXDOMAIN:
+            # Further validate the response by making an Answer, even
+            # if we aren't going to cache it.
+            try:
+                answer = Answer(
+                    self.qname, dns.rdatatype.ANY, dns.rdataclass.IN, response
+                )
+            except Exception as e:
+                self.errors.append(
+                    (
+                        str(self.nameserver),
+                        self.tcp_attempt,
+                        self.nameserver.answer_port(),
+                        e,
+                        response,
+                    )
+                )
+                # The nameserver is no good, take it out of the mix.
+                self.nameservers.remove(self.nameserver)
+                return (None, False)
+            self.nxdomain_responses[self.qname] = response
+            if self.resolver.cache:
+                self.resolver.cache.put(
+                    (self.qname, dns.rdatatype.ANY, self.rdclass), answer
+                )
+            # Make next_nameserver() return None, so caller breaks its
+            # inner loop and calls next_request().
+            return (None, True)
+        elif rcode == dns.rcode.YXDOMAIN:
+            yex = YXDOMAIN()
+            self.errors.append(
+                (
+                    str(self.nameserver),
+                    self.tcp_attempt,
+                    self.nameserver.answer_port(),
+                    yex,
+                    response,
+                )
+            )
+            raise yex
+        else:
+            #
+            # We got a response, but we're not happy with the
+            # rcode in it.
+            #
+            if rcode != dns.rcode.SERVFAIL or not self.resolver.retry_servfail:
+                self.nameservers.remove(self.nameserver)
+            self.errors.append(
+                (
+                    str(self.nameserver),
+                    self.tcp_attempt,
+                    self.nameserver.answer_port(),
+                    dns.rcode.to_text(rcode),
+                    response,
+                )
+            )
+            return (None, False)
+
+
+class BaseResolver:
+    """DNS stub resolver."""
+
+    # We initialize in reset()
+    #
+    # pylint: disable=attribute-defined-outside-init
+
+    domain: dns.name.Name
+    nameserver_ports: Dict[str, int]
+    port: int
+    search: List[dns.name.Name]
+    use_search_by_default: bool
+    timeout: float
+    lifetime: float
+    keyring: Optional[Any]
+    keyname: Optional[Union[dns.name.Name, str]]
+    keyalgorithm: Union[dns.name.Name, str]
+    edns: int
+    ednsflags: int
+    ednsoptions: Optional[List[dns.edns.Option]]
+    payload: int
+    cache: Any
+    flags: Optional[int]
+    retry_servfail: bool
+    rotate: bool
+    ndots: Optional[int]
+    _nameservers: Sequence[Union[str, dns.nameserver.Nameserver]]
+
+    def __init__(
+        self, filename: str = "/etc/resolv.conf", configure: bool = True
+    ) -> None:
+        """*filename*, a ``str`` or file object, specifying a file
+        in standard /etc/resolv.conf format.  This parameter is meaningful
+        only when *configure* is true and the platform is POSIX.
+
+        *configure*, a ``bool``.  If True (the default), the resolver
+        instance is configured in the normal fashion for the operating
+        system the resolver is running on.  (I.e. by reading a
+        /etc/resolv.conf file on POSIX systems and from the registry
+        on Windows systems.)
+        """
+
+        self.reset()
+        if configure:
+            if sys.platform == "win32":  # pragma: no cover
+                self.read_registry()
+            elif filename:
+                self.read_resolv_conf(filename)
+
+    def reset(self) -> None:
+        """Reset all resolver configuration to the defaults."""
+
+        self.domain = dns.name.Name(dns.name.from_text(socket.gethostname())[1:])
+        if len(self.domain) == 0:  # pragma: no cover
+            self.domain = dns.name.root
+        self._nameservers = []
+        self.nameserver_ports = {}
+        self.port = 53
+        self.search = []
+        self.use_search_by_default = False
+        self.timeout = 2.0
+        self.lifetime = 5.0
+        self.keyring = None
+        self.keyname = None
+        self.keyalgorithm = dns.tsig.default_algorithm
+        self.edns = -1
+        self.ednsflags = 0
+        self.ednsoptions = None
+        self.payload = 0
+        self.cache = None
+        self.flags = None
+        self.retry_servfail = False
+        self.rotate = False
+        self.ndots = None
+
+    def read_resolv_conf(self, f: Any) -> None:
+        """Process *f* as a file in the /etc/resolv.conf format.  If f is
+        a ``str``, it is used as the name of the file to open; otherwise it
+        is treated as the file itself.
+
+        Interprets the following items:
+
+        - nameserver - name server IP address
+
+        - domain - local domain name
+
+        - search - search list for host-name lookup
+
+        - options - supported options are rotate, timeout, edns0, and ndots
+
+        """
+
+        nameservers = []
+        if isinstance(f, str):
+            try:
+                cm: contextlib.AbstractContextManager = open(f)
+            except OSError:
+                # /etc/resolv.conf doesn't exist, can't be read, etc.
+                raise NoResolverConfiguration(f"cannot open {f}")
+        else:
+            cm = contextlib.nullcontext(f)
+        with cm as f:
+            for l in f:
+                if len(l) == 0 or l[0] == "#" or l[0] == ";":
+                    continue
+                tokens = l.split()
+
+                # Any line containing less than 2 tokens is malformed
+                if len(tokens) < 2:
+                    continue
+
+                if tokens[0] == "nameserver":
+                    nameservers.append(tokens[1])
+                elif tokens[0] == "domain":
+                    self.domain = dns.name.from_text(tokens[1])
+                    # domain and search are exclusive
+                    self.search = []
+                elif tokens[0] == "search":
+                    # the last search wins
+                    self.search = []
+                    for suffix in tokens[1:]:
+                        self.search.append(dns.name.from_text(suffix))
+                    # We don't set domain as it is not used if
+                    # len(self.search) > 0
+                elif tokens[0] == "options":
+                    for opt in tokens[1:]:
+                        if opt == "rotate":
+                            self.rotate = True
+                        elif opt == "edns0":
+                            self.use_edns()
+                        elif "timeout" in opt:
+                            try:
+                                self.timeout = int(opt.split(":")[1])
+                            except (ValueError, IndexError):
+                                pass
+                        elif "ndots" in opt:
+                            try:
+                                self.ndots = int(opt.split(":")[1])
+                            except (ValueError, IndexError):
+                                pass
+        if len(nameservers) == 0:
+            raise NoResolverConfiguration("no nameservers")
+        # Assigning directly instead of appending means we invoke the
+        # setter logic, with additonal checking and enrichment.
+        self.nameservers = nameservers
+
+    def read_registry(self) -> None:  # pragma: no cover
+        """Extract resolver configuration from the Windows registry."""
+        try:
+            info = dns.win32util.get_dns_info()  # type: ignore
+            if info.domain is not None:
+                self.domain = info.domain
+            self.nameservers = info.nameservers
+            self.search = info.search
+        except AttributeError:
+            raise NotImplementedError
+
+    def _compute_timeout(
+        self,
+        start: float,
+        lifetime: Optional[float] = None,
+        errors: Optional[List[ErrorTuple]] = None,
+    ) -> float:
+        lifetime = self.lifetime if lifetime is None else lifetime
+        now = time.time()
+        duration = now - start
+        if errors is None:
+            errors = []
+        if duration < 0:
+            if duration < -1:
+                # Time going backwards is bad.  Just give up.
+                raise LifetimeTimeout(timeout=duration, errors=errors)
+            else:
+                # Time went backwards, but only a little.  This can
+                # happen, e.g. under vmware with older linux kernels.
+                # Pretend it didn't happen.
+                duration = 0
+        if duration >= lifetime:
+            raise LifetimeTimeout(timeout=duration, errors=errors)
+        return min(lifetime - duration, self.timeout)
+
+    def _get_qnames_to_try(
+        self, qname: dns.name.Name, search: Optional[bool]
+    ) -> List[dns.name.Name]:
+        # This is a separate method so we can unit test the search
+        # rules without requiring the Internet.
+        if search is None:
+            search = self.use_search_by_default
+        qnames_to_try = []
+        if qname.is_absolute():
+            qnames_to_try.append(qname)
+        else:
+            abs_qname = qname.concatenate(dns.name.root)
+            if search:
+                if len(self.search) > 0:
+                    # There is a search list, so use it exclusively
+                    search_list = self.search[:]
+                elif self.domain != dns.name.root and self.domain is not None:
+                    # We have some notion of a domain that isn't the root, so
+                    # use it as the search list.
+                    search_list = [self.domain]
+                else:
+                    search_list = []
+                # Figure out the effective ndots (default is 1)
+                if self.ndots is None:
+                    ndots = 1
+                else:
+                    ndots = self.ndots
+                for suffix in search_list:
+                    qnames_to_try.append(qname + suffix)
+                if len(qname) > ndots:
+                    # The name has at least ndots dots, so we should try an
+                    # absolute query first.
+                    qnames_to_try.insert(0, abs_qname)
+                else:
+                    # The name has less than ndots dots, so we should search
+                    # first, then try the absolute name.
+                    qnames_to_try.append(abs_qname)
+            else:
+                qnames_to_try.append(abs_qname)
+        return qnames_to_try
+
+    def use_tsig(
+        self,
+        keyring: Any,
+        keyname: Optional[Union[dns.name.Name, str]] = None,
+        algorithm: Union[dns.name.Name, str] = dns.tsig.default_algorithm,
+    ) -> None:
+        """Add a TSIG signature to each query.
+
+        The parameters are passed to ``dns.message.Message.use_tsig()``;
+        see its documentation for details.
+        """
+
+        self.keyring = keyring
+        self.keyname = keyname
+        self.keyalgorithm = algorithm
+
+    def use_edns(
+        self,
+        edns: Optional[Union[int, bool]] = 0,
+        ednsflags: int = 0,
+        payload: int = dns.message.DEFAULT_EDNS_PAYLOAD,
+        options: Optional[List[dns.edns.Option]] = None,
+    ) -> None:
+        """Configure EDNS behavior.
+
+        *edns*, an ``int``, is the EDNS level to use.  Specifying
+        ``None``, ``False``, or ``-1`` means "do not use EDNS", and in this case
+        the other parameters are ignored.  Specifying ``True`` is
+        equivalent to specifying 0, i.e. "use EDNS0".
+
+        *ednsflags*, an ``int``, the EDNS flag values.
+
+        *payload*, an ``int``, is the EDNS sender's payload field, which is the
+        maximum size of UDP datagram the sender can handle.  I.e. how big
+        a response to this message can be.
+
+        *options*, a list of ``dns.edns.Option`` objects or ``None``, the EDNS
+        options.
+        """
+
+        if edns is None or edns is False:
+            edns = -1
+        elif edns is True:
+            edns = 0
+        self.edns = edns
+        self.ednsflags = ednsflags
+        self.payload = payload
+        self.ednsoptions = options
+
+    def set_flags(self, flags: int) -> None:
+        """Overrides the default flags with your own.
+
+        *flags*, an ``int``, the message flags to use.
+        """
+
+        self.flags = flags
+
+    @classmethod
+    def _enrich_nameservers(
+        cls,
+        nameservers: Sequence[Union[str, dns.nameserver.Nameserver]],
+        nameserver_ports: Dict[str, int],
+        default_port: int,
+    ) -> List[dns.nameserver.Nameserver]:
+        enriched_nameservers = []
+        if isinstance(nameservers, list):
+            for nameserver in nameservers:
+                enriched_nameserver: dns.nameserver.Nameserver
+                if isinstance(nameserver, dns.nameserver.Nameserver):
+                    enriched_nameserver = nameserver
+                elif dns.inet.is_address(nameserver):
+                    port = nameserver_ports.get(nameserver, default_port)
+                    enriched_nameserver = dns.nameserver.Do53Nameserver(
+                        nameserver, port
+                    )
+                else:
+                    try:
+                        if urlparse(nameserver).scheme != "https":
+                            raise NotImplementedError
+                    except Exception:
+                        raise ValueError(
+                            f"nameserver {nameserver} is not a "
+                            "dns.nameserver.Nameserver instance or text form, "
+                            "IP address, nor a valid https URL"
+                        )
+                    enriched_nameserver = dns.nameserver.DoHNameserver(nameserver)
+                enriched_nameservers.append(enriched_nameserver)
+        else:
+            raise ValueError(
+                f"nameservers must be a list or tuple (not a {type(nameservers)})"
+            )
+        return enriched_nameservers
+
+    @property
+    def nameservers(
+        self,
+    ) -> Sequence[Union[str, dns.nameserver.Nameserver]]:
+        return self._nameservers
+
+    @nameservers.setter
+    def nameservers(
+        self, nameservers: Sequence[Union[str, dns.nameserver.Nameserver]]
+    ) -> None:
+        """
+        *nameservers*, a ``list`` of nameservers, where a nameserver is either
+        a string interpretable as a nameserver, or a ``dns.nameserver.Nameserver``
+        instance.
+
+        Raises ``ValueError`` if *nameservers* is not a list of nameservers.
+        """
+        # We just call _enrich_nameservers() for checking
+        self._enrich_nameservers(nameservers, self.nameserver_ports, self.port)
+        self._nameservers = nameservers
+
+
+class Resolver(BaseResolver):
+    """DNS stub resolver."""
+
+    def resolve(
+        self,
+        qname: Union[dns.name.Name, str],
+        rdtype: Union[dns.rdatatype.RdataType, str] = dns.rdatatype.A,
+        rdclass: Union[dns.rdataclass.RdataClass, str] = dns.rdataclass.IN,
+        tcp: bool = False,
+        source: Optional[str] = None,
+        raise_on_no_answer: bool = True,
+        source_port: int = 0,
+        lifetime: Optional[float] = None,
+        search: Optional[bool] = None,
+    ) -> Answer:  # pylint: disable=arguments-differ
+        """Query nameservers to find the answer to the question.
+
+        The *qname*, *rdtype*, and *rdclass* parameters may be objects
+        of the appropriate type, or strings that can be converted into objects
+        of the appropriate type.
+
+        *qname*, a ``dns.name.Name`` or ``str``, the query name.
+
+        *rdtype*, an ``int`` or ``str``,  the query type.
+
+        *rdclass*, an ``int`` or ``str``,  the query class.
+
+        *tcp*, a ``bool``.  If ``True``, use TCP to make the query.
+
+        *source*, a ``str`` or ``None``.  If not ``None``, bind to this IP
+        address when making queries.
+
+        *raise_on_no_answer*, a ``bool``.  If ``True``, raise
+        ``dns.resolver.NoAnswer`` if there's no answer to the question.
+
+        *source_port*, an ``int``, the port from which to send the message.
+
+        *lifetime*, a ``float``, how many seconds a query should run
+        before timing out.
+
+        *search*, a ``bool`` or ``None``, determines whether the
+        search list configured in the system's resolver configuration
+        are used for relative names, and whether the resolver's domain
+        may be added to relative names.  The default is ``None``,
+        which causes the value of the resolver's
+        ``use_search_by_default`` attribute to be used.
+
+        Raises ``dns.resolver.LifetimeTimeout`` if no answers could be found
+        in the specified lifetime.
+
+        Raises ``dns.resolver.NXDOMAIN`` if the query name does not exist.
+
+        Raises ``dns.resolver.YXDOMAIN`` if the query name is too long after
+        DNAME substitution.
+
+        Raises ``dns.resolver.NoAnswer`` if *raise_on_no_answer* is
+        ``True`` and the query name exists but has no RRset of the
+        desired type and class.
+
+        Raises ``dns.resolver.NoNameservers`` if no non-broken
+        nameservers are available to answer the question.
+
+        Returns a ``dns.resolver.Answer`` instance.
+
+        """
+
+        resolution = _Resolution(
+            self, qname, rdtype, rdclass, tcp, raise_on_no_answer, search
+        )
+        start = time.time()
+        while True:
+            (request, answer) = resolution.next_request()
+            # Note we need to say "if answer is not None" and not just
+            # "if answer" because answer implements __len__, and python
+            # will call that.  We want to return if we have an answer
+            # object, including in cases where its length is 0.
+            if answer is not None:
+                # cache hit!
+                return answer
+            assert request is not None  # needed for type checking
+            done = False
+            while not done:
+                (nameserver, tcp, backoff) = resolution.next_nameserver()
+                if backoff:
+                    time.sleep(backoff)
+                timeout = self._compute_timeout(start, lifetime, resolution.errors)
+                try:
+                    response = nameserver.query(
+                        request,
+                        timeout=timeout,
+                        source=source,
+                        source_port=source_port,
+                        max_size=tcp,
+                    )
+                except Exception as ex:
+                    (_, done) = resolution.query_result(None, ex)
+                    continue
+                (answer, done) = resolution.query_result(response, None)
+                # Note we need to say "if answer is not None" and not just
+                # "if answer" because answer implements __len__, and python
+                # will call that.  We want to return if we have an answer
+                # object, including in cases where its length is 0.
+                if answer is not None:
+                    return answer
+
+    def query(
+        self,
+        qname: Union[dns.name.Name, str],
+        rdtype: Union[dns.rdatatype.RdataType, str] = dns.rdatatype.A,
+        rdclass: Union[dns.rdataclass.RdataClass, str] = dns.rdataclass.IN,
+        tcp: bool = False,
+        source: Optional[str] = None,
+        raise_on_no_answer: bool = True,
+        source_port: int = 0,
+        lifetime: Optional[float] = None,
+    ) -> Answer:  # pragma: no cover
+        """Query nameservers to find the answer to the question.
+
+        This method calls resolve() with ``search=True``, and is
+        provided for backwards compatibility with prior versions of
+        dnspython.  See the documentation for the resolve() method for
+        further details.
+        """
+        warnings.warn(
+            "please use dns.resolver.Resolver.resolve() instead",
+            DeprecationWarning,
+            stacklevel=2,
+        )
+        return self.resolve(
+            qname,
+            rdtype,
+            rdclass,
+            tcp,
+            source,
+            raise_on_no_answer,
+            source_port,
+            lifetime,
+            True,
+        )
+
+    def resolve_address(self, ipaddr: str, *args: Any, **kwargs: Any) -> Answer:
+        """Use a resolver to run a reverse query for PTR records.
+
+        This utilizes the resolve() method to perform a PTR lookup on the
+        specified IP address.
+
+        *ipaddr*, a ``str``, the IPv4 or IPv6 address you want to get
+        the PTR record for.
+
+        All other arguments that can be passed to the resolve() function
+        except for rdtype and rdclass are also supported by this
+        function.
+        """
+        # We make a modified kwargs for type checking happiness, as otherwise
+        # we get a legit warning about possibly having rdtype and rdclass
+        # in the kwargs more than once.
+        modified_kwargs: Dict[str, Any] = {}
+        modified_kwargs.update(kwargs)
+        modified_kwargs["rdtype"] = dns.rdatatype.PTR
+        modified_kwargs["rdclass"] = dns.rdataclass.IN
+        return self.resolve(
+            dns.reversename.from_address(ipaddr), *args, **modified_kwargs
+        )
+
+    def resolve_name(
+        self,
+        name: Union[dns.name.Name, str],
+        family: int = socket.AF_UNSPEC,
+        **kwargs: Any,
+    ) -> HostAnswers:
+        """Use a resolver to query for address records.
+
+        This utilizes the resolve() method to perform A and/or AAAA lookups on
+        the specified name.
+
+        *qname*, a ``dns.name.Name`` or ``str``, the name to resolve.
+
+        *family*, an ``int``, the address family.  If socket.AF_UNSPEC
+        (the default), both A and AAAA records will be retrieved.
+
+        All other arguments that can be passed to the resolve() function
+        except for rdtype and rdclass are also supported by this
+        function.
+        """
+        # We make a modified kwargs for type checking happiness, as otherwise
+        # we get a legit warning about possibly having rdtype and rdclass
+        # in the kwargs more than once.
+        modified_kwargs: Dict[str, Any] = {}
+        modified_kwargs.update(kwargs)
+        modified_kwargs.pop("rdtype", None)
+        modified_kwargs["rdclass"] = dns.rdataclass.IN
+
+        if family == socket.AF_INET:
+            v4 = self.resolve(name, dns.rdatatype.A, **modified_kwargs)
+            return HostAnswers.make(v4=v4)
+        elif family == socket.AF_INET6:
+            v6 = self.resolve(name, dns.rdatatype.AAAA, **modified_kwargs)
+            return HostAnswers.make(v6=v6)
+        elif family != socket.AF_UNSPEC:  # pragma: no cover
+            raise NotImplementedError(f"unknown address family {family}")
+
+        raise_on_no_answer = modified_kwargs.pop("raise_on_no_answer", True)
+        lifetime = modified_kwargs.pop("lifetime", None)
+        start = time.time()
+        v6 = self.resolve(
+            name,
+            dns.rdatatype.AAAA,
+            raise_on_no_answer=False,
+            lifetime=self._compute_timeout(start, lifetime),
+            **modified_kwargs,
+        )
+        # Note that setting name ensures we query the same name
+        # for A as we did for AAAA.  (This is just in case search lists
+        # are active by default in the resolver configuration and
+        # we might be talking to a server that says NXDOMAIN when it
+        # wants to say NOERROR no data.
+        name = v6.qname
+        v4 = self.resolve(
+            name,
+            dns.rdatatype.A,
+            raise_on_no_answer=False,
+            lifetime=self._compute_timeout(start, lifetime),
+            **modified_kwargs,
+        )
+        answers = HostAnswers.make(v6=v6, v4=v4, add_empty=not raise_on_no_answer)
+        if not answers:
+            raise NoAnswer(response=v6.response)
+        return answers
+
+    # pylint: disable=redefined-outer-name
+
+    def canonical_name(self, name: Union[dns.name.Name, str]) -> dns.name.Name:
+        """Determine the canonical name of *name*.
+
+        The canonical name is the name the resolver uses for queries
+        after all CNAME and DNAME renamings have been applied.
+
+        *name*, a ``dns.name.Name`` or ``str``, the query name.
+
+        This method can raise any exception that ``resolve()`` can
+        raise, other than ``dns.resolver.NoAnswer`` and
+        ``dns.resolver.NXDOMAIN``.
+
+        Returns a ``dns.name.Name``.
+        """
+        try:
+            answer = self.resolve(name, raise_on_no_answer=False)
+            canonical_name = answer.canonical_name
+        except dns.resolver.NXDOMAIN as e:
+            canonical_name = e.canonical_name
+        return canonical_name
+
+    # 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:  # pragma: no cover
+            pass
+
+
+#: The default resolver.
+default_resolver: Optional[Resolver] = None
+
+
+def get_default_resolver() -> Resolver:
+    """Get the default resolver, initializing it if necessary."""
+    if default_resolver is None:
+        reset_default_resolver()
+    assert default_resolver is not None
+    return default_resolver
+
+
+def reset_default_resolver() -> None:
+    """Re-initialize default resolver.
+
+    Note that the resolver configuration (i.e. /etc/resolv.conf on UNIX
+    systems) will be re-read immediately.
+    """
+
+    global default_resolver
+    default_resolver = Resolver()
+
+
+def resolve(
+    qname: Union[dns.name.Name, str],
+    rdtype: Union[dns.rdatatype.RdataType, str] = dns.rdatatype.A,
+    rdclass: Union[dns.rdataclass.RdataClass, str] = dns.rdataclass.IN,
+    tcp: bool = False,
+    source: Optional[str] = None,
+    raise_on_no_answer: bool = True,
+    source_port: int = 0,
+    lifetime: Optional[float] = None,
+    search: Optional[bool] = None,
+) -> Answer:  # pragma: no cover
+    """Query nameservers to find the answer to the question.
+
+    This is a convenience function that uses the default resolver
+    object to make the query.
+
+    See ``dns.resolver.Resolver.resolve`` for more information on the
+    parameters.
+    """
+
+    return get_default_resolver().resolve(
+        qname,
+        rdtype,
+        rdclass,
+        tcp,
+        source,
+        raise_on_no_answer,
+        source_port,
+        lifetime,
+        search,
+    )
+
+
+def query(
+    qname: Union[dns.name.Name, str],
+    rdtype: Union[dns.rdatatype.RdataType, str] = dns.rdatatype.A,
+    rdclass: Union[dns.rdataclass.RdataClass, str] = dns.rdataclass.IN,
+    tcp: bool = False,
+    source: Optional[str] = None,
+    raise_on_no_answer: bool = True,
+    source_port: int = 0,
+    lifetime: Optional[float] = None,
+) -> Answer:  # pragma: no cover
+    """Query nameservers to find the answer to the question.
+
+    This method calls resolve() with ``search=True``, and is
+    provided for backwards compatibility with prior versions of
+    dnspython.  See the documentation for the resolve() method for
+    further details.
+    """
+    warnings.warn(
+        "please use dns.resolver.resolve() instead", DeprecationWarning, stacklevel=2
+    )
+    return resolve(
+        qname,
+        rdtype,
+        rdclass,
+        tcp,
+        source,
+        raise_on_no_answer,
+        source_port,
+        lifetime,
+        True,
+    )
+
+
+def resolve_address(ipaddr: str, *args: Any, **kwargs: Any) -> Answer:
+    """Use a resolver to run a reverse query for PTR records.
+
+    See ``dns.resolver.Resolver.resolve_address`` for more information on the
+    parameters.
+    """
+
+    return get_default_resolver().resolve_address(ipaddr, *args, **kwargs)
+
+
+def resolve_name(
+    name: Union[dns.name.Name, str], family: int = socket.AF_UNSPEC, **kwargs: Any
+) -> HostAnswers:
+    """Use a resolver to query for address records.
+
+    See ``dns.resolver.Resolver.resolve_name`` for more information on the
+    parameters.
+    """
+
+    return get_default_resolver().resolve_name(name, family, **kwargs)
+
+
+def canonical_name(name: Union[dns.name.Name, str]) -> dns.name.Name:
+    """Determine the canonical name of *name*.
+
+    See ``dns.resolver.Resolver.canonical_name`` for more information on the
+    parameters and possible exceptions.
+    """
+
+    return get_default_resolver().canonical_name(name)
+
+
+def try_ddr(lifetime: float = 5.0) -> None:  # pragma: no cover
+    """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,
+    tcp: bool = False,
+    resolver: Optional[Resolver] = None,
+    lifetime: Optional[float] = None,
+) -> dns.name.Name:
+    """Find the name of the zone which contains the specified name.
+
+    *name*, an absolute ``dns.name.Name`` or ``str``, the query name.
+
+    *rdclass*, an ``int``, the query class.
+
+    *tcp*, a ``bool``.  If ``True``, use TCP to make the query.
+
+    *resolver*, a ``dns.resolver.Resolver`` or ``None``, the resolver to use.
+    If ``None``, the default, then the default resolver is used.
+
+    *lifetime*, a ``float``, the total time to allow for the queries needed
+    to determine the zone.  If ``None``, the default, then only the individual
+    query limits of the resolver apply.
+
+    Raises ``dns.resolver.NoRootSOA`` if there is no SOA RR at the DNS
+    root.  (This is only likely to happen if you're using non-default
+    root servers in your network and they are misconfigured.)
+
+    Raises ``dns.resolver.LifetimeTimeout`` if the answer could not be
+    found in the allotted lifetime.
+
+    Returns a ``dns.name.Name``.
+    """
+
+    if isinstance(name, str):
+        name = dns.name.from_text(name, dns.name.root)
+    if resolver is None:
+        resolver = get_default_resolver()
+    if not name.is_absolute():
+        raise NotAbsolute(name)
+    start = time.time()
+    expiration: Optional[float]
+    if lifetime is not None:
+        expiration = start + lifetime
+    else:
+        expiration = None
+    while 1:
+        try:
+            rlifetime: Optional[float]
+            if expiration is not None:
+                rlifetime = expiration - time.time()
+                if rlifetime <= 0:
+                    rlifetime = 0
+            else:
+                rlifetime = None
+            answer = resolver.resolve(
+                name, dns.rdatatype.SOA, rdclass, tcp, lifetime=rlifetime
+            )
+            assert answer.rrset is not None
+            if answer.rrset.name == name:
+                return name
+            # otherwise we were CNAMEd or DNAMEd and need to look higher
+        except (dns.resolver.NXDOMAIN, dns.resolver.NoAnswer) as e:
+            if isinstance(e, dns.resolver.NXDOMAIN):
+                response = e.responses().get(name)
+            else:
+                response = e.response()  # pylint: disable=no-value-for-parameter
+            if response:
+                for rrs in response.authority:
+                    if rrs.rdtype == dns.rdatatype.SOA and rrs.rdclass == rdclass:
+                        (nr, _, _) = rrs.name.fullcompare(name)
+                        if nr == dns.name.NAMERELN_SUPERDOMAIN:
+                            # We're doing a proper superdomain check as
+                            # if the name were equal we ought to have gotten
+                            # it in the answer section!  We are ignoring the
+                            # possibility that the authority is insane and
+                            # is including multiple SOA RRs for different
+                            # authorities.
+                            return rrs.name
+            # we couldn't extract anything useful from the response (e.g. it's
+            # a type 3 NXDOMAIN)
+        try:
+            name = name.parent()
+        except dns.name.NoParent:
+            raise NoRootSOA
+
+
+def make_resolver_at(
+    where: Union[dns.name.Name, str],
+    port: int = 53,
+    family: int = socket.AF_UNSPEC,
+    resolver: Optional[Resolver] = None,
+) -> Resolver:
+    """Make a stub resolver using the specified destination as the full resolver.
+
+    *where*, a ``dns.name.Name`` or ``str`` the domain name or IP address of the
+    full resolver.
+
+    *port*, an ``int``, the port to use.  If not specified, the default is 53.
+
+    *family*, an ``int``, the address family to use.  This parameter is used if
+    *where* is not an address.  The default is ``socket.AF_UNSPEC`` in which case
+    the first address returned by ``resolve_name()`` will be used, otherwise the
+    first address of the specified family will be used.
+
+    *resolver*, a ``dns.resolver.Resolver`` or ``None``, the resolver to use for
+    resolution of hostnames.  If not specified, the default resolver will be used.
+
+    Returns a ``dns.resolver.Resolver`` or raises an exception.
+    """
+    if resolver is None:
+        resolver = get_default_resolver()
+    nameservers: List[Union[str, dns.nameserver.Nameserver]] = []
+    if isinstance(where, str) and dns.inet.is_address(where):
+        nameservers.append(dns.nameserver.Do53Nameserver(where, port))
+    else:
+        for address in resolver.resolve_name(where, family).addresses():
+            nameservers.append(dns.nameserver.Do53Nameserver(address, port))
+    res = dns.resolver.Resolver(configure=False)
+    res.nameservers = nameservers
+    return res
+
+
+def resolve_at(
+    where: Union[dns.name.Name, str],
+    qname: Union[dns.name.Name, str],
+    rdtype: Union[dns.rdatatype.RdataType, str] = dns.rdatatype.A,
+    rdclass: Union[dns.rdataclass.RdataClass, str] = dns.rdataclass.IN,
+    tcp: bool = False,
+    source: Optional[str] = None,
+    raise_on_no_answer: bool = True,
+    source_port: int = 0,
+    lifetime: Optional[float] = None,
+    search: Optional[bool] = None,
+    port: int = 53,
+    family: int = socket.AF_UNSPEC,
+    resolver: Optional[Resolver] = None,
+) -> Answer:
+    """Query nameservers to find the answer to the question.
+
+    This is a convenience function that calls ``dns.resolver.make_resolver_at()`` to
+    make a resolver, and then uses it to resolve the query.
+
+    See ``dns.resolver.Resolver.resolve`` for more information on the resolution
+    parameters, and ``dns.resolver.make_resolver_at`` for information about the resolver
+    parameters *where*, *port*, *family*, and *resolver*.
+
+    If making more than one query, it is more efficient to call
+    ``dns.resolver.make_resolver_at()`` and then use that resolver for the queries
+    instead of calling ``resolve_at()`` multiple times.
+    """
+    return make_resolver_at(where, port, family, resolver).resolve(
+        qname,
+        rdtype,
+        rdclass,
+        tcp,
+        source,
+        raise_on_no_answer,
+        source_port,
+        lifetime,
+        search,
+    )
+
+
+#
+# Support for overriding the system resolver for all python code in the
+# running process.
+#
+
+_protocols_for_socktype = {
+    socket.SOCK_DGRAM: [socket.SOL_UDP],
+    socket.SOCK_STREAM: [socket.SOL_TCP],
+}
+
+_resolver = None
+_original_getaddrinfo = socket.getaddrinfo
+_original_getnameinfo = socket.getnameinfo
+_original_getfqdn = socket.getfqdn
+_original_gethostbyname = socket.gethostbyname
+_original_gethostbyname_ex = socket.gethostbyname_ex
+_original_gethostbyaddr = socket.gethostbyaddr
+
+
+def _getaddrinfo(
+    host=None, service=None, family=socket.AF_UNSPEC, socktype=0, proto=0, flags=0
+):
+    if flags & socket.AI_NUMERICHOST != 0:
+        # Short circuit directly into the system's getaddrinfo().  We're
+        # not adding any value in this case, and this avoids infinite loops
+        # because dns.query.* needs to call getaddrinfo() for IPv6 scoping
+        # reasons.  We will also do this short circuit below if we
+        # discover that the host is an address literal.
+        return _original_getaddrinfo(host, service, family, socktype, proto, flags)
+    if flags & (socket.AI_ADDRCONFIG | socket.AI_V4MAPPED) != 0:
+        # Not implemented.  We raise a gaierror as opposed to a
+        # NotImplementedError as it helps callers handle errors more
+        # appropriately.  [Issue #316]
+        #
+        # We raise EAI_FAIL as opposed to EAI_SYSTEM because there is
+        # no EAI_SYSTEM on Windows [Issue #416].  We didn't go for
+        # EAI_BADFLAGS as the flags aren't bad, we just don't
+        # implement them.
+        raise socket.gaierror(
+            socket.EAI_FAIL, "Non-recoverable failure in name resolution"
+        )
+    if host is None and service is None:
+        raise socket.gaierror(socket.EAI_NONAME, "Name or service not known")
+    addrs = []
+    canonical_name = None  # pylint: disable=redefined-outer-name
+    # Is host None or an address literal?  If so, use the system's
+    # getaddrinfo().
+    if host is None:
+        return _original_getaddrinfo(host, service, family, socktype, proto, flags)
+    try:
+        # We don't care about the result of af_for_address(), we're just
+        # calling it so it raises an exception if host is not an IPv4 or
+        # IPv6 address.
+        dns.inet.af_for_address(host)
+        return _original_getaddrinfo(host, service, family, socktype, proto, flags)
+    except Exception:
+        pass
+    # Something needs resolution!
+    try:
+        answers = _resolver.resolve_name(host, family)
+        addrs = answers.addresses_and_families()
+        canonical_name = answers.canonical_name().to_text(True)
+    except dns.resolver.NXDOMAIN:
+        raise socket.gaierror(socket.EAI_NONAME, "Name or service not known")
+    except Exception:
+        # We raise EAI_AGAIN here as the failure may be temporary
+        # (e.g. a timeout) and EAI_SYSTEM isn't defined on Windows.
+        # [Issue #416]
+        raise socket.gaierror(socket.EAI_AGAIN, "Temporary failure in name resolution")
+    port = None
+    try:
+        # Is it a port literal?
+        if service is None:
+            port = 0
+        else:
+            port = int(service)
+    except Exception:
+        if flags & socket.AI_NUMERICSERV == 0:
+            try:
+                port = socket.getservbyname(service)
+            except Exception:
+                pass
+    if port is None:
+        raise socket.gaierror(socket.EAI_NONAME, "Name or service not known")
+    tuples = []
+    if socktype == 0:
+        socktypes = [socket.SOCK_DGRAM, socket.SOCK_STREAM]
+    else:
+        socktypes = [socktype]
+    if flags & socket.AI_CANONNAME != 0:
+        cname = canonical_name
+    else:
+        cname = ""
+    for addr, af in addrs:
+        for socktype in socktypes:
+            for proto in _protocols_for_socktype[socktype]:
+                addr_tuple = dns.inet.low_level_address_tuple((addr, port), af)
+                tuples.append((af, socktype, proto, cname, addr_tuple))
+    if len(tuples) == 0:
+        raise socket.gaierror(socket.EAI_NONAME, "Name or service not known")
+    return tuples
+
+
+def _getnameinfo(sockaddr, flags=0):
+    host = sockaddr[0]
+    port = sockaddr[1]
+    if len(sockaddr) == 4:
+        scope = sockaddr[3]
+        family = socket.AF_INET6
+    else:
+        scope = None
+        family = socket.AF_INET
+    tuples = _getaddrinfo(host, port, family, socket.SOCK_STREAM, socket.SOL_TCP, 0)
+    if len(tuples) > 1:
+        raise OSError("sockaddr resolved to multiple addresses")
+    addr = tuples[0][4][0]
+    if flags & socket.NI_DGRAM:
+        pname = "udp"
+    else:
+        pname = "tcp"
+    qname = dns.reversename.from_address(addr)
+    if flags & socket.NI_NUMERICHOST == 0:
+        try:
+            answer = _resolver.resolve(qname, "PTR")
+            hostname = answer.rrset[0].target.to_text(True)
+        except (dns.resolver.NXDOMAIN, dns.resolver.NoAnswer):
+            if flags & socket.NI_NAMEREQD:
+                raise socket.gaierror(socket.EAI_NONAME, "Name or service not known")
+            hostname = addr
+            if scope is not None:
+                hostname += "%" + str(scope)
+    else:
+        hostname = addr
+        if scope is not None:
+            hostname += "%" + str(scope)
+    if flags & socket.NI_NUMERICSERV:
+        service = str(port)
+    else:
+        service = socket.getservbyport(port, pname)
+    return (hostname, service)
+
+
+def _getfqdn(name=None):
+    if name is None:
+        name = socket.gethostname()
+    try:
+        (name, _, _) = _gethostbyaddr(name)
+        # Python's version checks aliases too, but our gethostbyname
+        # ignores them, so we do so here as well.
+    except Exception:  # pragma: no cover
+        pass
+    return name
+
+
+def _gethostbyname(name):
+    return _gethostbyname_ex(name)[2][0]
+
+
+def _gethostbyname_ex(name):
+    aliases = []
+    addresses = []
+    tuples = _getaddrinfo(
+        name, 0, socket.AF_INET, socket.SOCK_STREAM, socket.SOL_TCP, socket.AI_CANONNAME
+    )
+    canonical = tuples[0][3]
+    for item in tuples:
+        addresses.append(item[4][0])
+    # XXX we just ignore aliases
+    return (canonical, aliases, addresses)
+
+
+def _gethostbyaddr(ip):
+    try:
+        dns.ipv6.inet_aton(ip)
+        sockaddr = (ip, 80, 0, 0)
+        family = socket.AF_INET6
+    except Exception:
+        try:
+            dns.ipv4.inet_aton(ip)
+        except Exception:
+            raise socket.gaierror(socket.EAI_NONAME, "Name or service not known")
+        sockaddr = (ip, 80)
+        family = socket.AF_INET
+    (name, _) = _getnameinfo(sockaddr, socket.NI_NAMEREQD)
+    aliases = []
+    addresses = []
+    tuples = _getaddrinfo(
+        name, 0, family, socket.SOCK_STREAM, socket.SOL_TCP, socket.AI_CANONNAME
+    )
+    canonical = tuples[0][3]
+    # We only want to include an address from the tuples if it's the
+    # same as the one we asked about.  We do this comparison in binary
+    # to avoid any differences in text representations.
+    bin_ip = dns.inet.inet_pton(family, ip)
+    for item in tuples:
+        addr = item[4][0]
+        bin_addr = dns.inet.inet_pton(family, addr)
+        if bin_ip == bin_addr:
+            addresses.append(addr)
+    # XXX we just ignore aliases
+    return (canonical, aliases, addresses)
+
+
+def override_system_resolver(resolver: Optional[Resolver] = None) -> None:
+    """Override the system resolver routines in the socket module with
+    versions which use dnspython's resolver.
+
+    This can be useful in testing situations where you want to control
+    the resolution behavior of python code without having to change
+    the system's resolver settings (e.g. /etc/resolv.conf).
+
+    The resolver to use may be specified; if it's not, the default
+    resolver will be used.
+
+    resolver, a ``dns.resolver.Resolver`` or ``None``, the resolver to use.
+    """
+
+    if resolver is None:
+        resolver = get_default_resolver()
+    global _resolver
+    _resolver = resolver
+    socket.getaddrinfo = _getaddrinfo
+    socket.getnameinfo = _getnameinfo
+    socket.getfqdn = _getfqdn
+    socket.gethostbyname = _gethostbyname
+    socket.gethostbyname_ex = _gethostbyname_ex
+    socket.gethostbyaddr = _gethostbyaddr
+
+
+def restore_system_resolver() -> None:
+    """Undo the effects of prior override_system_resolver()."""
+
+    global _resolver
+    _resolver = None
+    socket.getaddrinfo = _original_getaddrinfo
+    socket.getnameinfo = _original_getnameinfo
+    socket.getfqdn = _original_getfqdn
+    socket.gethostbyname = _original_gethostbyname
+    socket.gethostbyname_ex = _original_gethostbyname_ex
+    socket.gethostbyaddr = _original_gethostbyaddr
diff --git a/.venv/lib/python3.12/site-packages/dns/reversename.py b/.venv/lib/python3.12/site-packages/dns/reversename.py
new file mode 100644
index 00000000..8236c711
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/dns/reversename.py
@@ -0,0 +1,105 @@
+# Copyright (C) Dnspython Contributors, see LICENSE for text of ISC license
+
+# Copyright (C) 2006-2017 Nominum, Inc.
+#
+# Permission to use, copy, modify, and distribute this software and its
+# documentation for any purpose with or without fee is hereby granted,
+# provided that the above copyright notice and this permission notice
+# appear in all copies.
+#
+# THE SOFTWARE IS PROVIDED "AS IS" AND NOMINUM DISCLAIMS ALL WARRANTIES
+# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL NOMINUM BE LIABLE FOR
+# ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+# ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT
+# OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+
+"""DNS Reverse Map Names."""
+
+import binascii
+
+import dns.ipv4
+import dns.ipv6
+import dns.name
+
+ipv4_reverse_domain = dns.name.from_text("in-addr.arpa.")
+ipv6_reverse_domain = dns.name.from_text("ip6.arpa.")
+
+
+def from_address(
+    text: str,
+    v4_origin: dns.name.Name = ipv4_reverse_domain,
+    v6_origin: dns.name.Name = ipv6_reverse_domain,
+) -> dns.name.Name:
+    """Convert an IPv4 or IPv6 address in textual form into a Name object whose
+    value is the reverse-map domain name of the address.
+
+    *text*, a ``str``, is an IPv4 or IPv6 address in textual form
+    (e.g. '127.0.0.1', '::1')
+
+    *v4_origin*, a ``dns.name.Name`` to append to the labels corresponding to
+    the address if the address is an IPv4 address, instead of the default
+    (in-addr.arpa.)
+
+    *v6_origin*, a ``dns.name.Name`` to append to the labels corresponding to
+    the address if the address is an IPv6 address, instead of the default
+    (ip6.arpa.)
+
+    Raises ``dns.exception.SyntaxError`` if the address is badly formed.
+
+    Returns a ``dns.name.Name``.
+    """
+
+    try:
+        v6 = dns.ipv6.inet_aton(text)
+        if dns.ipv6.is_mapped(v6):
+            parts = ["%d" % byte for byte in v6[12:]]
+            origin = v4_origin
+        else:
+            parts = [x for x in str(binascii.hexlify(v6).decode())]
+            origin = v6_origin
+    except Exception:
+        parts = ["%d" % byte for byte in dns.ipv4.inet_aton(text)]
+        origin = v4_origin
+    return dns.name.from_text(".".join(reversed(parts)), origin=origin)
+
+
+def to_address(
+    name: dns.name.Name,
+    v4_origin: dns.name.Name = ipv4_reverse_domain,
+    v6_origin: dns.name.Name = ipv6_reverse_domain,
+) -> str:
+    """Convert a reverse map domain name into textual address form.
+
+    *name*, a ``dns.name.Name``, an IPv4 or IPv6 address in reverse-map name
+    form.
+
+    *v4_origin*, a ``dns.name.Name`` representing the top-level domain for
+    IPv4 addresses, instead of the default (in-addr.arpa.)
+
+    *v6_origin*, a ``dns.name.Name`` representing the top-level domain for
+    IPv4 addresses, instead of the default (ip6.arpa.)
+
+    Raises ``dns.exception.SyntaxError`` if the name does not have a
+    reverse-map form.
+
+    Returns a ``str``.
+    """
+
+    if name.is_subdomain(v4_origin):
+        name = name.relativize(v4_origin)
+        text = b".".join(reversed(name.labels))
+        # run through inet_ntoa() to check syntax and make pretty.
+        return dns.ipv4.inet_ntoa(dns.ipv4.inet_aton(text))
+    elif name.is_subdomain(v6_origin):
+        name = name.relativize(v6_origin)
+        labels = list(reversed(name.labels))
+        parts = []
+        for i in range(0, len(labels), 4):
+            parts.append(b"".join(labels[i : i + 4]))
+        text = b":".join(parts)
+        # run through inet_ntoa() to check syntax and make pretty.
+        return dns.ipv6.inet_ntoa(dns.ipv6.inet_aton(text))
+    else:
+        raise dns.exception.SyntaxError("unknown reverse-map address family")
diff --git a/.venv/lib/python3.12/site-packages/dns/rrset.py b/.venv/lib/python3.12/site-packages/dns/rrset.py
new file mode 100644
index 00000000..6f39b108
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/dns/rrset.py
@@ -0,0 +1,285 @@
+# Copyright (C) Dnspython Contributors, see LICENSE for text of ISC license
+
+# Copyright (C) 2003-2017 Nominum, Inc.
+#
+# Permission to use, copy, modify, and distribute this software and its
+# documentation for any purpose with or without fee is hereby granted,
+# provided that the above copyright notice and this permission notice
+# appear in all copies.
+#
+# THE SOFTWARE IS PROVIDED "AS IS" AND NOMINUM DISCLAIMS ALL WARRANTIES
+# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL NOMINUM BE LIABLE FOR
+# ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+# ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT
+# OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+
+"""DNS RRsets (an RRset is a named rdataset)"""
+
+from typing import Any, Collection, Dict, Optional, Union, cast
+
+import dns.name
+import dns.rdataclass
+import dns.rdataset
+import dns.renderer
+
+
+class RRset(dns.rdataset.Rdataset):
+    """A DNS RRset (named rdataset).
+
+    RRset inherits from Rdataset, and RRsets can be treated as
+    Rdatasets in most cases.  There are, however, a few notable
+    exceptions.  RRsets have different to_wire() and to_text() method
+    arguments, reflecting the fact that RRsets always have an owner
+    name.
+    """
+
+    __slots__ = ["name", "deleting"]
+
+    def __init__(
+        self,
+        name: dns.name.Name,
+        rdclass: dns.rdataclass.RdataClass,
+        rdtype: dns.rdatatype.RdataType,
+        covers: dns.rdatatype.RdataType = dns.rdatatype.NONE,
+        deleting: Optional[dns.rdataclass.RdataClass] = None,
+    ):
+        """Create a new RRset."""
+
+        super().__init__(rdclass, rdtype, covers)
+        self.name = name
+        self.deleting = deleting
+
+    def _clone(self):
+        obj = super()._clone()
+        obj.name = self.name
+        obj.deleting = self.deleting
+        return obj
+
+    def __repr__(self):
+        if self.covers == 0:
+            ctext = ""
+        else:
+            ctext = "(" + dns.rdatatype.to_text(self.covers) + ")"
+        if self.deleting is not None:
+            dtext = " delete=" + dns.rdataclass.to_text(self.deleting)
+        else:
+            dtext = ""
+        return (
+            "<DNS "
+            + str(self.name)
+            + " "
+            + dns.rdataclass.to_text(self.rdclass)
+            + " "
+            + dns.rdatatype.to_text(self.rdtype)
+            + ctext
+            + dtext
+            + " RRset: "
+            + self._rdata_repr()
+            + ">"
+        )
+
+    def __str__(self):
+        return self.to_text()
+
+    def __eq__(self, other):
+        if isinstance(other, RRset):
+            if self.name != other.name:
+                return False
+        elif not isinstance(other, dns.rdataset.Rdataset):
+            return False
+        return super().__eq__(other)
+
+    def match(self, *args: Any, **kwargs: Any) -> bool:  # type: ignore[override]
+        """Does this rrset match the specified attributes?
+
+        Behaves as :py:func:`full_match()` if the first argument is a
+        ``dns.name.Name``, and as :py:func:`dns.rdataset.Rdataset.match()`
+        otherwise.
+
+        (This behavior fixes a design mistake where the signature of this
+        method became incompatible with that of its superclass.  The fix
+        makes RRsets matchable as Rdatasets while preserving backwards
+        compatibility.)
+        """
+        if isinstance(args[0], dns.name.Name):
+            return self.full_match(*args, **kwargs)  # type: ignore[arg-type]
+        else:
+            return super().match(*args, **kwargs)  # type: ignore[arg-type]
+
+    def full_match(
+        self,
+        name: dns.name.Name,
+        rdclass: dns.rdataclass.RdataClass,
+        rdtype: dns.rdatatype.RdataType,
+        covers: dns.rdatatype.RdataType,
+        deleting: Optional[dns.rdataclass.RdataClass] = None,
+    ) -> bool:
+        """Returns ``True`` if this rrset matches the specified name, class,
+        type, covers, and deletion state.
+        """
+        if not super().match(rdclass, rdtype, covers):
+            return False
+        if self.name != name or self.deleting != deleting:
+            return False
+        return True
+
+    # pylint: disable=arguments-differ
+
+    def to_text(  # type: ignore[override]
+        self,
+        origin: Optional[dns.name.Name] = None,
+        relativize: bool = True,
+        **kw: Dict[str, Any],
+    ) -> str:
+        """Convert the RRset into DNS zone file format.
+
+        See ``dns.name.Name.choose_relativity`` for more information
+        on how *origin* and *relativize* determine the way names
+        are emitted.
+
+        Any additional keyword arguments are passed on to the rdata
+        ``to_text()`` method.
+
+        *origin*, a ``dns.name.Name`` or ``None``, the origin for relative
+        names.
+
+        *relativize*, a ``bool``.  If ``True``, names will be relativized
+        to *origin*.
+        """
+
+        return super().to_text(
+            self.name, origin, relativize, self.deleting, **kw  # type: ignore
+        )
+
+    def to_wire(  # type: ignore[override]
+        self,
+        file: Any,
+        compress: Optional[dns.name.CompressType] = None,  # type: ignore
+        origin: Optional[dns.name.Name] = None,
+        **kw: Dict[str, Any],
+    ) -> int:
+        """Convert the RRset to wire format.
+
+        All keyword arguments are passed to ``dns.rdataset.to_wire()``; see
+        that function for details.
+
+        Returns an ``int``, the number of records emitted.
+        """
+
+        return super().to_wire(
+            self.name, file, compress, origin, self.deleting, **kw  # type:ignore
+        )
+
+    # pylint: enable=arguments-differ
+
+    def to_rdataset(self) -> dns.rdataset.Rdataset:
+        """Convert an RRset into an Rdataset.
+
+        Returns a ``dns.rdataset.Rdataset``.
+        """
+        return dns.rdataset.from_rdata_list(self.ttl, list(self))
+
+
+def from_text_list(
+    name: Union[dns.name.Name, str],
+    ttl: int,
+    rdclass: Union[dns.rdataclass.RdataClass, str],
+    rdtype: Union[dns.rdatatype.RdataType, str],
+    text_rdatas: Collection[str],
+    idna_codec: Optional[dns.name.IDNACodec] = None,
+    origin: Optional[dns.name.Name] = None,
+    relativize: bool = True,
+    relativize_to: Optional[dns.name.Name] = None,
+) -> RRset:
+    """Create an RRset with the specified name, TTL, class, and type, and with
+    the specified list of rdatas in text format.
+
+    *idna_codec*, a ``dns.name.IDNACodec``, specifies the IDNA
+    encoder/decoder to use; if ``None``, the default IDNA 2003
+    encoder/decoder is used.
+
+    *origin*, a ``dns.name.Name`` (or ``None``), the
+    origin to use for relative names.
+
+    *relativize*, a ``bool``.  If true, name will be relativized.
+
+    *relativize_to*, a ``dns.name.Name`` (or ``None``), the origin to use
+    when relativizing names.  If not set, the *origin* value will be used.
+
+    Returns a ``dns.rrset.RRset`` object.
+    """
+
+    if isinstance(name, str):
+        name = dns.name.from_text(name, None, idna_codec=idna_codec)
+    rdclass = dns.rdataclass.RdataClass.make(rdclass)
+    rdtype = dns.rdatatype.RdataType.make(rdtype)
+    r = RRset(name, rdclass, rdtype)
+    r.update_ttl(ttl)
+    for t in text_rdatas:
+        rd = dns.rdata.from_text(
+            r.rdclass, r.rdtype, t, origin, relativize, relativize_to, idna_codec
+        )
+        r.add(rd)
+    return r
+
+
+def from_text(
+    name: Union[dns.name.Name, str],
+    ttl: int,
+    rdclass: Union[dns.rdataclass.RdataClass, str],
+    rdtype: Union[dns.rdatatype.RdataType, str],
+    *text_rdatas: Any,
+) -> RRset:
+    """Create an RRset with the specified name, TTL, class, and type and with
+    the specified rdatas in text format.
+
+    Returns a ``dns.rrset.RRset`` object.
+    """
+
+    return from_text_list(
+        name, ttl, rdclass, rdtype, cast(Collection[str], text_rdatas)
+    )
+
+
+def from_rdata_list(
+    name: Union[dns.name.Name, str],
+    ttl: int,
+    rdatas: Collection[dns.rdata.Rdata],
+    idna_codec: Optional[dns.name.IDNACodec] = None,
+) -> RRset:
+    """Create an RRset with the specified name and TTL, and with
+    the specified list of rdata objects.
+
+    *idna_codec*, a ``dns.name.IDNACodec``, specifies the IDNA
+    encoder/decoder to use; if ``None``, the default IDNA 2003
+    encoder/decoder is used.
+
+    Returns a ``dns.rrset.RRset`` object.
+
+    """
+
+    if isinstance(name, str):
+        name = dns.name.from_text(name, None, idna_codec=idna_codec)
+
+    if len(rdatas) == 0:
+        raise ValueError("rdata list must not be empty")
+    r = None
+    for rd in rdatas:
+        if r is None:
+            r = RRset(name, rd.rdclass, rd.rdtype)
+            r.update_ttl(ttl)
+        r.add(rd)
+    assert r is not None
+    return r
+
+
+def from_rdata(name: Union[dns.name.Name, str], ttl: int, *rdatas: Any) -> RRset:
+    """Create an RRset with the specified name and TTL, and with
+    the specified rdata objects.
+
+    Returns a ``dns.rrset.RRset`` object.
+    """
+
+    return from_rdata_list(name, ttl, cast(Collection[dns.rdata.Rdata], rdatas))
diff --git a/.venv/lib/python3.12/site-packages/dns/serial.py b/.venv/lib/python3.12/site-packages/dns/serial.py
new file mode 100644
index 00000000..3417299b
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/dns/serial.py
@@ -0,0 +1,118 @@
+# Copyright (C) Dnspython Contributors, see LICENSE for text of ISC license
+
+"""Serial Number Arthimetic from RFC 1982"""
+
+
+class Serial:
+    def __init__(self, value: int, bits: int = 32):
+        self.value = value % 2**bits
+        self.bits = bits
+
+    def __repr__(self):
+        return f"dns.serial.Serial({self.value}, {self.bits})"
+
+    def __eq__(self, other):
+        if isinstance(other, int):
+            other = Serial(other, self.bits)
+        elif not isinstance(other, Serial) or other.bits != self.bits:
+            return NotImplemented
+        return self.value == other.value
+
+    def __ne__(self, other):
+        if isinstance(other, int):
+            other = Serial(other, self.bits)
+        elif not isinstance(other, Serial) or other.bits != self.bits:
+            return NotImplemented
+        return self.value != other.value
+
+    def __lt__(self, other):
+        if isinstance(other, int):
+            other = Serial(other, self.bits)
+        elif not isinstance(other, Serial) or other.bits != self.bits:
+            return NotImplemented
+        if self.value < other.value and other.value - self.value < 2 ** (self.bits - 1):
+            return True
+        elif self.value > other.value and self.value - other.value > 2 ** (
+            self.bits - 1
+        ):
+            return True
+        else:
+            return False
+
+    def __le__(self, other):
+        return self == other or self < other
+
+    def __gt__(self, other):
+        if isinstance(other, int):
+            other = Serial(other, self.bits)
+        elif not isinstance(other, Serial) or other.bits != self.bits:
+            return NotImplemented
+        if self.value < other.value and other.value - self.value > 2 ** (self.bits - 1):
+            return True
+        elif self.value > other.value and self.value - other.value < 2 ** (
+            self.bits - 1
+        ):
+            return True
+        else:
+            return False
+
+    def __ge__(self, other):
+        return self == other or self > other
+
+    def __add__(self, other):
+        v = self.value
+        if isinstance(other, Serial):
+            delta = other.value
+        elif isinstance(other, int):
+            delta = other
+        else:
+            raise ValueError
+        if abs(delta) > (2 ** (self.bits - 1) - 1):
+            raise ValueError
+        v += delta
+        v = v % 2**self.bits
+        return Serial(v, self.bits)
+
+    def __iadd__(self, other):
+        v = self.value
+        if isinstance(other, Serial):
+            delta = other.value
+        elif isinstance(other, int):
+            delta = other
+        else:
+            raise ValueError
+        if abs(delta) > (2 ** (self.bits - 1) - 1):
+            raise ValueError
+        v += delta
+        v = v % 2**self.bits
+        self.value = v
+        return self
+
+    def __sub__(self, other):
+        v = self.value
+        if isinstance(other, Serial):
+            delta = other.value
+        elif isinstance(other, int):
+            delta = other
+        else:
+            raise ValueError
+        if abs(delta) > (2 ** (self.bits - 1) - 1):
+            raise ValueError
+        v -= delta
+        v = v % 2**self.bits
+        return Serial(v, self.bits)
+
+    def __isub__(self, other):
+        v = self.value
+        if isinstance(other, Serial):
+            delta = other.value
+        elif isinstance(other, int):
+            delta = other
+        else:
+            raise ValueError
+        if abs(delta) > (2 ** (self.bits - 1) - 1):
+            raise ValueError
+        v -= delta
+        v = v % 2**self.bits
+        self.value = v
+        return self
diff --git a/.venv/lib/python3.12/site-packages/dns/set.py b/.venv/lib/python3.12/site-packages/dns/set.py
new file mode 100644
index 00000000..ae8f0dd5
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/dns/set.py
@@ -0,0 +1,308 @@
+# Copyright (C) Dnspython Contributors, see LICENSE for text of ISC license
+
+# Copyright (C) 2003-2017 Nominum, Inc.
+#
+# Permission to use, copy, modify, and distribute this software and its
+# documentation for any purpose with or without fee is hereby granted,
+# provided that the above copyright notice and this permission notice
+# appear in all copies.
+#
+# THE SOFTWARE IS PROVIDED "AS IS" AND NOMINUM DISCLAIMS ALL WARRANTIES
+# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL NOMINUM BE LIABLE FOR
+# ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+# ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT
+# OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+
+import itertools
+
+
+class Set:
+    """A simple set class.
+
+    This class was originally used to deal with python not having a set class, and
+    originally the class used lists in its implementation.  The ordered and indexable
+    nature of RRsets and Rdatasets is unfortunately widely used in dnspython
+    applications, so for backwards compatibility sets continue to be a custom class, now
+    based on an ordered dictionary.
+    """
+
+    __slots__ = ["items"]
+
+    def __init__(self, items=None):
+        """Initialize the set.
+
+        *items*, an iterable or ``None``, the initial set of items.
+        """
+
+        self.items = dict()
+        if items is not None:
+            for item in items:
+                # This is safe for how we use set, but if other code
+                # subclasses it could be a legitimate issue.
+                self.add(item)  # lgtm[py/init-calls-subclass]
+
+    def __repr__(self):
+        return f"dns.set.Set({repr(list(self.items.keys()))})"  # pragma: no cover
+
+    def add(self, item):
+        """Add an item to the set."""
+
+        if item not in self.items:
+            self.items[item] = None
+
+    def remove(self, item):
+        """Remove an item from the set."""
+
+        try:
+            del self.items[item]
+        except KeyError:
+            raise ValueError
+
+    def discard(self, item):
+        """Remove an item from the set if present."""
+
+        self.items.pop(item, None)
+
+    def pop(self):
+        """Remove an arbitrary item from the set."""
+        (k, _) = self.items.popitem()
+        return k
+
+    def _clone(self) -> "Set":
+        """Make a (shallow) copy of the set.
+
+        There is a 'clone protocol' that subclasses of this class
+        should use.  To make a copy, first call your super's _clone()
+        method, and use the object returned as the new instance.  Then
+        make shallow copies of the attributes defined in the subclass.
+
+        This protocol allows us to write the set algorithms that
+        return new instances (e.g. union) once, and keep using them in
+        subclasses.
+        """
+
+        if hasattr(self, "_clone_class"):
+            cls = self._clone_class  # type: ignore
+        else:
+            cls = self.__class__
+        obj = cls.__new__(cls)
+        obj.items = dict()
+        obj.items.update(self.items)
+        return obj
+
+    def __copy__(self):
+        """Make a (shallow) copy of the set."""
+
+        return self._clone()
+
+    def copy(self):
+        """Make a (shallow) copy of the set."""
+
+        return self._clone()
+
+    def union_update(self, other):
+        """Update the set, adding any elements from other which are not
+        already in the set.
+        """
+
+        if not isinstance(other, Set):
+            raise ValueError("other must be a Set instance")
+        if self is other:  # lgtm[py/comparison-using-is]
+            return
+        for item in other.items:
+            self.add(item)
+
+    def intersection_update(self, other):
+        """Update the set, removing any elements from other which are not
+        in both sets.
+        """
+
+        if not isinstance(other, Set):
+            raise ValueError("other must be a Set instance")
+        if self is other:  # lgtm[py/comparison-using-is]
+            return
+        # we make a copy of the list so that we can remove items from
+        # the list without breaking the iterator.
+        for item in list(self.items):
+            if item not in other.items:
+                del self.items[item]
+
+    def difference_update(self, other):
+        """Update the set, removing any elements from other which are in
+        the set.
+        """
+
+        if not isinstance(other, Set):
+            raise ValueError("other must be a Set instance")
+        if self is other:  # lgtm[py/comparison-using-is]
+            self.items.clear()
+        else:
+            for item in other.items:
+                self.discard(item)
+
+    def symmetric_difference_update(self, other):
+        """Update the set, retaining only elements unique to both sets."""
+
+        if not isinstance(other, Set):
+            raise ValueError("other must be a Set instance")
+        if self is other:  # lgtm[py/comparison-using-is]
+            self.items.clear()
+        else:
+            overlap = self.intersection(other)
+            self.union_update(other)
+            self.difference_update(overlap)
+
+    def union(self, other):
+        """Return a new set which is the union of ``self`` and ``other``.
+
+        Returns the same Set type as this set.
+        """
+
+        obj = self._clone()
+        obj.union_update(other)
+        return obj
+
+    def intersection(self, other):
+        """Return a new set which is the intersection of ``self`` and
+        ``other``.
+
+        Returns the same Set type as this set.
+        """
+
+        obj = self._clone()
+        obj.intersection_update(other)
+        return obj
+
+    def difference(self, other):
+        """Return a new set which ``self`` - ``other``, i.e. the items
+        in ``self`` which are not also in ``other``.
+
+        Returns the same Set type as this set.
+        """
+
+        obj = self._clone()
+        obj.difference_update(other)
+        return obj
+
+    def symmetric_difference(self, other):
+        """Return a new set which (``self`` - ``other``) | (``other``
+        - ``self), ie: the items in either ``self`` or ``other`` which
+        are not contained in their intersection.
+
+        Returns the same Set type as this set.
+        """
+
+        obj = self._clone()
+        obj.symmetric_difference_update(other)
+        return obj
+
+    def __or__(self, other):
+        return self.union(other)
+
+    def __and__(self, other):
+        return self.intersection(other)
+
+    def __add__(self, other):
+        return self.union(other)
+
+    def __sub__(self, other):
+        return self.difference(other)
+
+    def __xor__(self, other):
+        return self.symmetric_difference(other)
+
+    def __ior__(self, other):
+        self.union_update(other)
+        return self
+
+    def __iand__(self, other):
+        self.intersection_update(other)
+        return self
+
+    def __iadd__(self, other):
+        self.union_update(other)
+        return self
+
+    def __isub__(self, other):
+        self.difference_update(other)
+        return self
+
+    def __ixor__(self, other):
+        self.symmetric_difference_update(other)
+        return self
+
+    def update(self, other):
+        """Update the set, adding any elements from other which are not
+        already in the set.
+
+        *other*, the collection of items with which to update the set, which
+        may be any iterable type.
+        """
+
+        for item in other:
+            self.add(item)
+
+    def clear(self):
+        """Make the set empty."""
+        self.items.clear()
+
+    def __eq__(self, other):
+        return self.items == other.items
+
+    def __ne__(self, other):
+        return not self.__eq__(other)
+
+    def __len__(self):
+        return len(self.items)
+
+    def __iter__(self):
+        return iter(self.items)
+
+    def __getitem__(self, i):
+        if isinstance(i, slice):
+            return list(itertools.islice(self.items, i.start, i.stop, i.step))
+        else:
+            return next(itertools.islice(self.items, i, i + 1))
+
+    def __delitem__(self, i):
+        if isinstance(i, slice):
+            for elt in list(self[i]):
+                del self.items[elt]
+        else:
+            del self.items[self[i]]
+
+    def issubset(self, other):
+        """Is this set a subset of *other*?
+
+        Returns a ``bool``.
+        """
+
+        if not isinstance(other, Set):
+            raise ValueError("other must be a Set instance")
+        for item in self.items:
+            if item not in other.items:
+                return False
+        return True
+
+    def issuperset(self, other):
+        """Is this set a superset of *other*?
+
+        Returns a ``bool``.
+        """
+
+        if not isinstance(other, Set):
+            raise ValueError("other must be a Set instance")
+        for item in other.items:
+            if item not in self.items:
+                return False
+        return True
+
+    def isdisjoint(self, other):
+        if not isinstance(other, Set):
+            raise ValueError("other must be a Set instance")
+        for item in other.items:
+            if item in self.items:
+                return False
+        return True
diff --git a/.venv/lib/python3.12/site-packages/dns/tokenizer.py b/.venv/lib/python3.12/site-packages/dns/tokenizer.py
new file mode 100644
index 00000000..ab205bc3
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/dns/tokenizer.py
@@ -0,0 +1,708 @@
+# Copyright (C) Dnspython Contributors, see LICENSE for text of ISC license
+
+# Copyright (C) 2003-2017 Nominum, Inc.
+#
+# Permission to use, copy, modify, and distribute this software and its
+# documentation for any purpose with or without fee is hereby granted,
+# provided that the above copyright notice and this permission notice
+# appear in all copies.
+#
+# THE SOFTWARE IS PROVIDED "AS IS" AND NOMINUM DISCLAIMS ALL WARRANTIES
+# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL NOMINUM BE LIABLE FOR
+# ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+# ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT
+# OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+
+"""Tokenize DNS zone file format"""
+
+import io
+import sys
+from typing import Any, List, Optional, Tuple
+
+import dns.exception
+import dns.name
+import dns.ttl
+
+_DELIMITERS = {" ", "\t", "\n", ";", "(", ")", '"'}
+_QUOTING_DELIMITERS = {'"'}
+
+EOF = 0
+EOL = 1
+WHITESPACE = 2
+IDENTIFIER = 3
+QUOTED_STRING = 4
+COMMENT = 5
+DELIMITER = 6
+
+
+class UngetBufferFull(dns.exception.DNSException):
+    """An attempt was made to unget a token when the unget buffer was full."""
+
+
+class Token:
+    """A DNS zone file format token.
+
+    ttype: The token type
+    value: The token value
+    has_escape: Does the token value contain escapes?
+    """
+
+    def __init__(
+        self,
+        ttype: int,
+        value: Any = "",
+        has_escape: bool = False,
+        comment: Optional[str] = None,
+    ):
+        """Initialize a token instance."""
+
+        self.ttype = ttype
+        self.value = value
+        self.has_escape = has_escape
+        self.comment = comment
+
+    def is_eof(self) -> bool:
+        return self.ttype == EOF
+
+    def is_eol(self) -> bool:
+        return self.ttype == EOL
+
+    def is_whitespace(self) -> bool:
+        return self.ttype == WHITESPACE
+
+    def is_identifier(self) -> bool:
+        return self.ttype == IDENTIFIER
+
+    def is_quoted_string(self) -> bool:
+        return self.ttype == QUOTED_STRING
+
+    def is_comment(self) -> bool:
+        return self.ttype == COMMENT
+
+    def is_delimiter(self) -> bool:  # pragma: no cover (we don't return delimiters yet)
+        return self.ttype == DELIMITER
+
+    def is_eol_or_eof(self) -> bool:
+        return self.ttype == EOL or self.ttype == EOF
+
+    def __eq__(self, other):
+        if not isinstance(other, Token):
+            return False
+        return self.ttype == other.ttype and self.value == other.value
+
+    def __ne__(self, other):
+        if not isinstance(other, Token):
+            return True
+        return self.ttype != other.ttype or self.value != other.value
+
+    def __str__(self):
+        return '%d "%s"' % (self.ttype, self.value)
+
+    def unescape(self) -> "Token":
+        if not self.has_escape:
+            return self
+        unescaped = ""
+        l = len(self.value)
+        i = 0
+        while i < l:
+            c = self.value[i]
+            i += 1
+            if c == "\\":
+                if i >= l:  # pragma: no cover   (can't happen via get())
+                    raise dns.exception.UnexpectedEnd
+                c = self.value[i]
+                i += 1
+                if c.isdigit():
+                    if i >= l:
+                        raise dns.exception.UnexpectedEnd
+                    c2 = self.value[i]
+                    i += 1
+                    if i >= l:
+                        raise dns.exception.UnexpectedEnd
+                    c3 = self.value[i]
+                    i += 1
+                    if not (c2.isdigit() and c3.isdigit()):
+                        raise dns.exception.SyntaxError
+                    codepoint = int(c) * 100 + int(c2) * 10 + int(c3)
+                    if codepoint > 255:
+                        raise dns.exception.SyntaxError
+                    c = chr(codepoint)
+            unescaped += c
+        return Token(self.ttype, unescaped)
+
+    def unescape_to_bytes(self) -> "Token":
+        # We used to use unescape() for TXT-like records, but this
+        # caused problems as we'd process DNS escapes into Unicode code
+        # points instead of byte values, and then a to_text() of the
+        # processed data would not equal the original input.  For
+        # example, \226 in the TXT record would have a to_text() of
+        # \195\162 because we applied UTF-8 encoding to Unicode code
+        # point 226.
+        #
+        # We now apply escapes while converting directly to bytes,
+        # avoiding this double encoding.
+        #
+        # This code also handles cases where the unicode input has
+        # non-ASCII code-points in it by converting it to UTF-8.  TXT
+        # records aren't defined for Unicode, but this is the best we
+        # can do to preserve meaning.  For example,
+        #
+        #     foo\u200bbar
+        #
+        # (where \u200b is Unicode code point 0x200b) will be treated
+        # as if the input had been the UTF-8 encoding of that string,
+        # namely:
+        #
+        #     foo\226\128\139bar
+        #
+        unescaped = b""
+        l = len(self.value)
+        i = 0
+        while i < l:
+            c = self.value[i]
+            i += 1
+            if c == "\\":
+                if i >= l:  # pragma: no cover   (can't happen via get())
+                    raise dns.exception.UnexpectedEnd
+                c = self.value[i]
+                i += 1
+                if c.isdigit():
+                    if i >= l:
+                        raise dns.exception.UnexpectedEnd
+                    c2 = self.value[i]
+                    i += 1
+                    if i >= l:
+                        raise dns.exception.UnexpectedEnd
+                    c3 = self.value[i]
+                    i += 1
+                    if not (c2.isdigit() and c3.isdigit()):
+                        raise dns.exception.SyntaxError
+                    codepoint = int(c) * 100 + int(c2) * 10 + int(c3)
+                    if codepoint > 255:
+                        raise dns.exception.SyntaxError
+                    unescaped += b"%c" % (codepoint)
+                else:
+                    # Note that as mentioned above, if c is a Unicode
+                    # code point outside of the ASCII range, then this
+                    # += is converting that code point to its UTF-8
+                    # encoding and appending multiple bytes to
+                    # unescaped.
+                    unescaped += c.encode()
+            else:
+                unescaped += c.encode()
+        return Token(self.ttype, bytes(unescaped))
+
+
+class Tokenizer:
+    """A DNS zone file format tokenizer.
+
+    A token object is basically a (type, value) tuple.  The valid
+    types are EOF, EOL, WHITESPACE, IDENTIFIER, QUOTED_STRING,
+    COMMENT, and DELIMITER.
+
+    file: The file to tokenize
+
+    ungotten_char: The most recently ungotten character, or None.
+
+    ungotten_token: The most recently ungotten token, or None.
+
+    multiline: The current multiline level.  This value is increased
+    by one every time a '(' delimiter is read, and decreased by one every time
+    a ')' delimiter is read.
+
+    quoting: This variable is true if the tokenizer is currently
+    reading a quoted string.
+
+    eof: This variable is true if the tokenizer has encountered EOF.
+
+    delimiters: The current delimiter dictionary.
+
+    line_number: The current line number
+
+    filename: A filename that will be returned by the where() method.
+
+    idna_codec: A dns.name.IDNACodec, specifies the IDNA
+    encoder/decoder.  If None, the default IDNA 2003
+    encoder/decoder is used.
+    """
+
+    def __init__(
+        self,
+        f: Any = sys.stdin,
+        filename: Optional[str] = None,
+        idna_codec: Optional[dns.name.IDNACodec] = None,
+    ):
+        """Initialize a tokenizer instance.
+
+        f: The file to tokenize.  The default is sys.stdin.
+        This parameter may also be a string, in which case the tokenizer
+        will take its input from the contents of the string.
+
+        filename: the name of the filename that the where() method
+        will return.
+
+        idna_codec: A dns.name.IDNACodec, specifies the IDNA
+        encoder/decoder.  If None, the default IDNA 2003
+        encoder/decoder is used.
+        """
+
+        if isinstance(f, str):
+            f = io.StringIO(f)
+            if filename is None:
+                filename = "<string>"
+        elif isinstance(f, bytes):
+            f = io.StringIO(f.decode())
+            if filename is None:
+                filename = "<string>"
+        else:
+            if filename is None:
+                if f is sys.stdin:
+                    filename = "<stdin>"
+                else:
+                    filename = "<file>"
+        self.file = f
+        self.ungotten_char: Optional[str] = None
+        self.ungotten_token: Optional[Token] = None
+        self.multiline = 0
+        self.quoting = False
+        self.eof = False
+        self.delimiters = _DELIMITERS
+        self.line_number = 1
+        assert filename is not None
+        self.filename = filename
+        if idna_codec is None:
+            self.idna_codec: dns.name.IDNACodec = dns.name.IDNA_2003
+        else:
+            self.idna_codec = idna_codec
+
+    def _get_char(self) -> str:
+        """Read a character from input."""
+
+        if self.ungotten_char is None:
+            if self.eof:
+                c = ""
+            else:
+                c = self.file.read(1)
+                if c == "":
+                    self.eof = True
+                elif c == "\n":
+                    self.line_number += 1
+        else:
+            c = self.ungotten_char
+            self.ungotten_char = None
+        return c
+
+    def where(self) -> Tuple[str, int]:
+        """Return the current location in the input.
+
+        Returns a (string, int) tuple.  The first item is the filename of
+        the input, the second is the current line number.
+        """
+
+        return (self.filename, self.line_number)
+
+    def _unget_char(self, c: str) -> None:
+        """Unget a character.
+
+        The unget buffer for characters is only one character large; it is
+        an error to try to unget a character when the unget buffer is not
+        empty.
+
+        c: the character to unget
+        raises UngetBufferFull: there is already an ungotten char
+        """
+
+        if self.ungotten_char is not None:
+            # this should never happen!
+            raise UngetBufferFull  # pragma: no cover
+        self.ungotten_char = c
+
+    def skip_whitespace(self) -> int:
+        """Consume input until a non-whitespace character is encountered.
+
+        The non-whitespace character is then ungotten, and the number of
+        whitespace characters consumed is returned.
+
+        If the tokenizer is in multiline mode, then newlines are whitespace.
+
+        Returns the number of characters skipped.
+        """
+
+        skipped = 0
+        while True:
+            c = self._get_char()
+            if c != " " and c != "\t":
+                if (c != "\n") or not self.multiline:
+                    self._unget_char(c)
+                    return skipped
+            skipped += 1
+
+    def get(self, want_leading: bool = False, want_comment: bool = False) -> Token:
+        """Get the next token.
+
+        want_leading: If True, return a WHITESPACE token if the
+        first character read is whitespace.  The default is False.
+
+        want_comment: If True, return a COMMENT token if the
+        first token read is a comment.  The default is False.
+
+        Raises dns.exception.UnexpectedEnd: input ended prematurely
+
+        Raises dns.exception.SyntaxError: input was badly formed
+
+        Returns a Token.
+        """
+
+        if self.ungotten_token is not None:
+            utoken = self.ungotten_token
+            self.ungotten_token = None
+            if utoken.is_whitespace():
+                if want_leading:
+                    return utoken
+            elif utoken.is_comment():
+                if want_comment:
+                    return utoken
+            else:
+                return utoken
+        skipped = self.skip_whitespace()
+        if want_leading and skipped > 0:
+            return Token(WHITESPACE, " ")
+        token = ""
+        ttype = IDENTIFIER
+        has_escape = False
+        while True:
+            c = self._get_char()
+            if c == "" or c in self.delimiters:
+                if c == "" and self.quoting:
+                    raise dns.exception.UnexpectedEnd
+                if token == "" and ttype != QUOTED_STRING:
+                    if c == "(":
+                        self.multiline += 1
+                        self.skip_whitespace()
+                        continue
+                    elif c == ")":
+                        if self.multiline <= 0:
+                            raise dns.exception.SyntaxError
+                        self.multiline -= 1
+                        self.skip_whitespace()
+                        continue
+                    elif c == '"':
+                        if not self.quoting:
+                            self.quoting = True
+                            self.delimiters = _QUOTING_DELIMITERS
+                            ttype = QUOTED_STRING
+                            continue
+                        else:
+                            self.quoting = False
+                            self.delimiters = _DELIMITERS
+                            self.skip_whitespace()
+                            continue
+                    elif c == "\n":
+                        return Token(EOL, "\n")
+                    elif c == ";":
+                        while 1:
+                            c = self._get_char()
+                            if c == "\n" or c == "":
+                                break
+                            token += c
+                        if want_comment:
+                            self._unget_char(c)
+                            return Token(COMMENT, token)
+                        elif c == "":
+                            if self.multiline:
+                                raise dns.exception.SyntaxError(
+                                    "unbalanced parentheses"
+                                )
+                            return Token(EOF, comment=token)
+                        elif self.multiline:
+                            self.skip_whitespace()
+                            token = ""
+                            continue
+                        else:
+                            return Token(EOL, "\n", comment=token)
+                    else:
+                        # This code exists in case we ever want a
+                        # delimiter to be returned.  It never produces
+                        # a token currently.
+                        token = c
+                        ttype = DELIMITER
+                else:
+                    self._unget_char(c)
+                break
+            elif self.quoting and c == "\n":
+                raise dns.exception.SyntaxError("newline in quoted string")
+            elif c == "\\":
+                #
+                # It's an escape.  Put it and the next character into
+                # the token; it will be checked later for goodness.
+                #
+                token += c
+                has_escape = True
+                c = self._get_char()
+                if c == "" or (c == "\n" and not self.quoting):
+                    raise dns.exception.UnexpectedEnd
+            token += c
+        if token == "" and ttype != QUOTED_STRING:
+            if self.multiline:
+                raise dns.exception.SyntaxError("unbalanced parentheses")
+            ttype = EOF
+        return Token(ttype, token, has_escape)
+
+    def unget(self, token: Token) -> None:
+        """Unget a token.
+
+        The unget buffer for tokens is only one token large; it is
+        an error to try to unget a token when the unget buffer is not
+        empty.
+
+        token: the token to unget
+
+        Raises UngetBufferFull: there is already an ungotten token
+        """
+
+        if self.ungotten_token is not None:
+            raise UngetBufferFull
+        self.ungotten_token = token
+
+    def next(self):
+        """Return the next item in an iteration.
+
+        Returns a Token.
+        """
+
+        token = self.get()
+        if token.is_eof():
+            raise StopIteration
+        return token
+
+    __next__ = next
+
+    def __iter__(self):
+        return self
+
+    # Helpers
+
+    def get_int(self, base: int = 10) -> int:
+        """Read the next token and interpret it as an unsigned integer.
+
+        Raises dns.exception.SyntaxError if not an unsigned integer.
+
+        Returns an int.
+        """
+
+        token = self.get().unescape()
+        if not token.is_identifier():
+            raise dns.exception.SyntaxError("expecting an identifier")
+        if not token.value.isdigit():
+            raise dns.exception.SyntaxError("expecting an integer")
+        return int(token.value, base)
+
+    def get_uint8(self) -> int:
+        """Read the next token and interpret it as an 8-bit unsigned
+        integer.
+
+        Raises dns.exception.SyntaxError if not an 8-bit unsigned integer.
+
+        Returns an int.
+        """
+
+        value = self.get_int()
+        if value < 0 or value > 255:
+            raise dns.exception.SyntaxError(
+                "%d is not an unsigned 8-bit integer" % value
+            )
+        return value
+
+    def get_uint16(self, base: int = 10) -> int:
+        """Read the next token and interpret it as a 16-bit unsigned
+        integer.
+
+        Raises dns.exception.SyntaxError if not a 16-bit unsigned integer.
+
+        Returns an int.
+        """
+
+        value = self.get_int(base=base)
+        if value < 0 or value > 65535:
+            if base == 8:
+                raise dns.exception.SyntaxError(
+                    f"{value:o} is not an octal unsigned 16-bit integer"
+                )
+            else:
+                raise dns.exception.SyntaxError(
+                    "%d is not an unsigned 16-bit integer" % value
+                )
+        return value
+
+    def get_uint32(self, base: int = 10) -> int:
+        """Read the next token and interpret it as a 32-bit unsigned
+        integer.
+
+        Raises dns.exception.SyntaxError if not a 32-bit unsigned integer.
+
+        Returns an int.
+        """
+
+        value = self.get_int(base=base)
+        if value < 0 or value > 4294967295:
+            raise dns.exception.SyntaxError(
+                "%d is not an unsigned 32-bit integer" % value
+            )
+        return value
+
+    def get_uint48(self, base: int = 10) -> int:
+        """Read the next token and interpret it as a 48-bit unsigned
+        integer.
+
+        Raises dns.exception.SyntaxError if not a 48-bit unsigned integer.
+
+        Returns an int.
+        """
+
+        value = self.get_int(base=base)
+        if value < 0 or value > 281474976710655:
+            raise dns.exception.SyntaxError(
+                "%d is not an unsigned 48-bit integer" % value
+            )
+        return value
+
+    def get_string(self, max_length: Optional[int] = None) -> str:
+        """Read the next token and interpret it as a string.
+
+        Raises dns.exception.SyntaxError if not a string.
+        Raises dns.exception.SyntaxError if token value length
+        exceeds max_length (if specified).
+
+        Returns a string.
+        """
+
+        token = self.get().unescape()
+        if not (token.is_identifier() or token.is_quoted_string()):
+            raise dns.exception.SyntaxError("expecting a string")
+        if max_length and len(token.value) > max_length:
+            raise dns.exception.SyntaxError("string too long")
+        return token.value
+
+    def get_identifier(self) -> str:
+        """Read the next token, which should be an identifier.
+
+        Raises dns.exception.SyntaxError if not an identifier.
+
+        Returns a string.
+        """
+
+        token = self.get().unescape()
+        if not token.is_identifier():
+            raise dns.exception.SyntaxError("expecting an identifier")
+        return token.value
+
+    def get_remaining(self, max_tokens: Optional[int] = None) -> List[Token]:
+        """Return the remaining tokens on the line, until an EOL or EOF is seen.
+
+        max_tokens: If not None, stop after this number of tokens.
+
+        Returns a list of tokens.
+        """
+
+        tokens = []
+        while True:
+            token = self.get()
+            if token.is_eol_or_eof():
+                self.unget(token)
+                break
+            tokens.append(token)
+            if len(tokens) == max_tokens:
+                break
+        return tokens
+
+    def concatenate_remaining_identifiers(self, allow_empty: bool = False) -> str:
+        """Read the remaining tokens on the line, which should be identifiers.
+
+        Raises dns.exception.SyntaxError if there are no remaining tokens,
+        unless `allow_empty=True` is given.
+
+        Raises dns.exception.SyntaxError if a token is seen that is not an
+        identifier.
+
+        Returns a string containing a concatenation of the remaining
+        identifiers.
+        """
+        s = ""
+        while True:
+            token = self.get().unescape()
+            if token.is_eol_or_eof():
+                self.unget(token)
+                break
+            if not token.is_identifier():
+                raise dns.exception.SyntaxError
+            s += token.value
+        if not (allow_empty or s):
+            raise dns.exception.SyntaxError("expecting another identifier")
+        return s
+
+    def as_name(
+        self,
+        token: Token,
+        origin: Optional[dns.name.Name] = None,
+        relativize: bool = False,
+        relativize_to: Optional[dns.name.Name] = None,
+    ) -> dns.name.Name:
+        """Try to interpret the token as a DNS name.
+
+        Raises dns.exception.SyntaxError if not a name.
+
+        Returns a dns.name.Name.
+        """
+        if not token.is_identifier():
+            raise dns.exception.SyntaxError("expecting an identifier")
+        name = dns.name.from_text(token.value, origin, self.idna_codec)
+        return name.choose_relativity(relativize_to or origin, relativize)
+
+    def get_name(
+        self,
+        origin: Optional[dns.name.Name] = None,
+        relativize: bool = False,
+        relativize_to: Optional[dns.name.Name] = None,
+    ) -> dns.name.Name:
+        """Read the next token and interpret it as a DNS name.
+
+        Raises dns.exception.SyntaxError if not a name.
+
+        Returns a dns.name.Name.
+        """
+
+        token = self.get()
+        return self.as_name(token, origin, relativize, relativize_to)
+
+    def get_eol_as_token(self) -> Token:
+        """Read the next token and raise an exception if it isn't EOL or
+        EOF.
+
+        Returns a string.
+        """
+
+        token = self.get()
+        if not token.is_eol_or_eof():
+            raise dns.exception.SyntaxError(
+                'expected EOL or EOF, got %d "%s"' % (token.ttype, token.value)
+            )
+        return token
+
+    def get_eol(self) -> str:
+        return self.get_eol_as_token().value
+
+    def get_ttl(self) -> int:
+        """Read the next token and interpret it as a DNS TTL.
+
+        Raises dns.exception.SyntaxError or dns.ttl.BadTTL if not an
+        identifier or badly formed.
+
+        Returns an int.
+        """
+
+        token = self.get().unescape()
+        if not token.is_identifier():
+            raise dns.exception.SyntaxError("expecting an identifier")
+        return dns.ttl.from_text(token.value)
diff --git a/.venv/lib/python3.12/site-packages/dns/transaction.py b/.venv/lib/python3.12/site-packages/dns/transaction.py
new file mode 100644
index 00000000..aa2e1160
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/dns/transaction.py
@@ -0,0 +1,649 @@
+# Copyright (C) Dnspython Contributors, see LICENSE for text of ISC license
+
+import collections
+from typing import Any, Callable, Iterator, List, Optional, Tuple, Union
+
+import dns.exception
+import dns.name
+import dns.node
+import dns.rdataclass
+import dns.rdataset
+import dns.rdatatype
+import dns.rrset
+import dns.serial
+import dns.ttl
+
+
+class TransactionManager:
+    def reader(self) -> "Transaction":
+        """Begin a read-only transaction."""
+        raise NotImplementedError  # pragma: no cover
+
+    def writer(self, replacement: bool = False) -> "Transaction":
+        """Begin a writable transaction.
+
+        *replacement*, a ``bool``.  If `True`, the content of the
+        transaction completely replaces any prior content.  If False,
+        the default, then the content of the transaction updates the
+        existing content.
+        """
+        raise NotImplementedError  # pragma: no cover
+
+    def origin_information(
+        self,
+    ) -> Tuple[Optional[dns.name.Name], bool, Optional[dns.name.Name]]:
+        """Returns a tuple
+
+            (absolute_origin, relativize, effective_origin)
+
+        giving the absolute name of the default origin for any
+        relative domain names, the "effective origin", and whether
+        names should be relativized.  The "effective origin" is the
+        absolute origin if relativize is False, and the empty name if
+        relativize is true.  (The effective origin is provided even
+        though it can be computed from the absolute_origin and
+        relativize setting because it avoids a lot of code
+        duplication.)
+
+        If the returned names are `None`, then no origin information is
+        available.
+
+        This information is used by code working with transactions to
+        allow it to coordinate relativization.  The transaction code
+        itself takes what it gets (i.e. does not change name
+        relativity).
+
+        """
+        raise NotImplementedError  # pragma: no cover
+
+    def get_class(self) -> dns.rdataclass.RdataClass:
+        """The class of the transaction manager."""
+        raise NotImplementedError  # pragma: no cover
+
+    def from_wire_origin(self) -> Optional[dns.name.Name]:
+        """Origin to use in from_wire() calls."""
+        (absolute_origin, relativize, _) = self.origin_information()
+        if relativize:
+            return absolute_origin
+        else:
+            return None
+
+
+class DeleteNotExact(dns.exception.DNSException):
+    """Existing data did not match data specified by an exact delete."""
+
+
+class ReadOnly(dns.exception.DNSException):
+    """Tried to write to a read-only transaction."""
+
+
+class AlreadyEnded(dns.exception.DNSException):
+    """Tried to use an already-ended transaction."""
+
+
+def _ensure_immutable_rdataset(rdataset):
+    if rdataset is None or isinstance(rdataset, dns.rdataset.ImmutableRdataset):
+        return rdataset
+    return dns.rdataset.ImmutableRdataset(rdataset)
+
+
+def _ensure_immutable_node(node):
+    if node is None or node.is_immutable():
+        return node
+    return dns.node.ImmutableNode(node)
+
+
+CheckPutRdatasetType = Callable[
+    ["Transaction", dns.name.Name, dns.rdataset.Rdataset], None
+]
+CheckDeleteRdatasetType = Callable[
+    ["Transaction", dns.name.Name, dns.rdatatype.RdataType, dns.rdatatype.RdataType],
+    None,
+]
+CheckDeleteNameType = Callable[["Transaction", dns.name.Name], None]
+
+
+class Transaction:
+    def __init__(
+        self,
+        manager: TransactionManager,
+        replacement: bool = False,
+        read_only: bool = False,
+    ):
+        self.manager = manager
+        self.replacement = replacement
+        self.read_only = read_only
+        self._ended = False
+        self._check_put_rdataset: List[CheckPutRdatasetType] = []
+        self._check_delete_rdataset: List[CheckDeleteRdatasetType] = []
+        self._check_delete_name: List[CheckDeleteNameType] = []
+
+    #
+    # This is the high level API
+    #
+    # Note that we currently use non-immutable types in the return type signature to
+    # avoid covariance problems, e.g. if the caller has a List[Rdataset], mypy will be
+    # unhappy if we return an ImmutableRdataset.
+
+    def get(
+        self,
+        name: Optional[Union[dns.name.Name, str]],
+        rdtype: Union[dns.rdatatype.RdataType, str],
+        covers: Union[dns.rdatatype.RdataType, str] = dns.rdatatype.NONE,
+    ) -> dns.rdataset.Rdataset:
+        """Return the rdataset associated with *name*, *rdtype*, and *covers*,
+        or `None` if not found.
+
+        Note that the returned rdataset is immutable.
+        """
+        self._check_ended()
+        if isinstance(name, str):
+            name = dns.name.from_text(name, None)
+        rdtype = dns.rdatatype.RdataType.make(rdtype)
+        covers = dns.rdatatype.RdataType.make(covers)
+        rdataset = self._get_rdataset(name, rdtype, covers)
+        return _ensure_immutable_rdataset(rdataset)
+
+    def get_node(self, name: dns.name.Name) -> Optional[dns.node.Node]:
+        """Return the node at *name*, if any.
+
+        Returns an immutable node or ``None``.
+        """
+        return _ensure_immutable_node(self._get_node(name))
+
+    def _check_read_only(self) -> None:
+        if self.read_only:
+            raise ReadOnly
+
+    def add(self, *args: Any) -> None:
+        """Add records.
+
+        The arguments may be:
+
+            - rrset
+
+            - name, rdataset...
+
+            - name, ttl, rdata...
+        """
+        self._check_ended()
+        self._check_read_only()
+        self._add(False, args)
+
+    def replace(self, *args: Any) -> None:
+        """Replace the existing rdataset at the name with the specified
+        rdataset, or add the specified rdataset if there was no existing
+        rdataset.
+
+        The arguments may be:
+
+            - rrset
+
+            - name, rdataset...
+
+            - name, ttl, rdata...
+
+        Note that if you want to replace the entire node, you should do
+        a delete of the name followed by one or more calls to add() or
+        replace().
+        """
+        self._check_ended()
+        self._check_read_only()
+        self._add(True, args)
+
+    def delete(self, *args: Any) -> None:
+        """Delete records.
+
+        It is not an error if some of the records are not in the existing
+        set.
+
+        The arguments may be:
+
+            - rrset
+
+            - name
+
+            - name, rdatatype, [covers]
+
+            - name, rdataset...
+
+            - name, rdata...
+        """
+        self._check_ended()
+        self._check_read_only()
+        self._delete(False, args)
+
+    def delete_exact(self, *args: Any) -> None:
+        """Delete records.
+
+        The arguments may be:
+
+            - rrset
+
+            - name
+
+            - name, rdatatype, [covers]
+
+            - name, rdataset...
+
+            - name, rdata...
+
+        Raises dns.transaction.DeleteNotExact if some of the records
+        are not in the existing set.
+
+        """
+        self._check_ended()
+        self._check_read_only()
+        self._delete(True, args)
+
+    def name_exists(self, name: Union[dns.name.Name, str]) -> bool:
+        """Does the specified name exist?"""
+        self._check_ended()
+        if isinstance(name, str):
+            name = dns.name.from_text(name, None)
+        return self._name_exists(name)
+
+    def update_serial(
+        self,
+        value: int = 1,
+        relative: bool = True,
+        name: dns.name.Name = dns.name.empty,
+    ) -> None:
+        """Update the serial number.
+
+        *value*, an `int`, is an increment if *relative* is `True`, or the
+        actual value to set if *relative* is `False`.
+
+        Raises `KeyError` if there is no SOA rdataset at *name*.
+
+        Raises `ValueError` if *value* is negative or if the increment is
+        so large that it would cause the new serial to be less than the
+        prior value.
+        """
+        self._check_ended()
+        if value < 0:
+            raise ValueError("negative update_serial() value")
+        if isinstance(name, str):
+            name = dns.name.from_text(name, None)
+        rdataset = self._get_rdataset(name, dns.rdatatype.SOA, dns.rdatatype.NONE)
+        if rdataset is None or len(rdataset) == 0:
+            raise KeyError
+        if relative:
+            serial = dns.serial.Serial(rdataset[0].serial) + value
+        else:
+            serial = dns.serial.Serial(value)
+        serial = serial.value  # convert back to int
+        if serial == 0:
+            serial = 1
+        rdata = rdataset[0].replace(serial=serial)
+        new_rdataset = dns.rdataset.from_rdata(rdataset.ttl, rdata)
+        self.replace(name, new_rdataset)
+
+    def __iter__(self):
+        self._check_ended()
+        return self._iterate_rdatasets()
+
+    def changed(self) -> bool:
+        """Has this transaction changed anything?
+
+        For read-only transactions, the result is always `False`.
+
+        For writable transactions, the result is `True` if at some time
+        during the life of the transaction, the content was changed.
+        """
+        self._check_ended()
+        return self._changed()
+
+    def commit(self) -> None:
+        """Commit the transaction.
+
+        Normally transactions are used as context managers and commit
+        or rollback automatically, but it may be done explicitly if needed.
+        A ``dns.transaction.Ended`` exception will be raised if you try
+        to use a transaction after it has been committed or rolled back.
+
+        Raises an exception if the commit fails (in which case the transaction
+        is also rolled back.
+        """
+        self._end(True)
+
+    def rollback(self) -> None:
+        """Rollback the transaction.
+
+        Normally transactions are used as context managers and commit
+        or rollback automatically, but it may be done explicitly if needed.
+        A ``dns.transaction.AlreadyEnded`` exception will be raised if you try
+        to use a transaction after it has been committed or rolled back.
+
+        Rollback cannot otherwise fail.
+        """
+        self._end(False)
+
+    def check_put_rdataset(self, check: CheckPutRdatasetType) -> None:
+        """Call *check* before putting (storing) an rdataset.
+
+        The function is called with the transaction, the name, and the rdataset.
+
+        The check function may safely make non-mutating transaction method
+        calls, but behavior is undefined if mutating transaction methods are
+        called.  The check function should raise an exception if it objects to
+        the put, and otherwise should return ``None``.
+        """
+        self._check_put_rdataset.append(check)
+
+    def check_delete_rdataset(self, check: CheckDeleteRdatasetType) -> None:
+        """Call *check* before deleting an rdataset.
+
+        The function is called with the transaction, the name, the rdatatype,
+        and the covered rdatatype.
+
+        The check function may safely make non-mutating transaction method
+        calls, but behavior is undefined if mutating transaction methods are
+        called.  The check function should raise an exception if it objects to
+        the put, and otherwise should return ``None``.
+        """
+        self._check_delete_rdataset.append(check)
+
+    def check_delete_name(self, check: CheckDeleteNameType) -> None:
+        """Call *check* before putting (storing) an rdataset.
+
+        The function is called with the transaction and the name.
+
+        The check function may safely make non-mutating transaction method
+        calls, but behavior is undefined if mutating transaction methods are
+        called.  The check function should raise an exception if it objects to
+        the put, and otherwise should return ``None``.
+        """
+        self._check_delete_name.append(check)
+
+    def iterate_rdatasets(
+        self,
+    ) -> Iterator[Tuple[dns.name.Name, dns.rdataset.Rdataset]]:
+        """Iterate all the rdatasets in the transaction, returning
+        (`dns.name.Name`, `dns.rdataset.Rdataset`) tuples.
+
+        Note that as is usual with python iterators, adding or removing items
+        while iterating will invalidate the iterator and may raise `RuntimeError`
+        or fail to iterate over all entries."""
+        self._check_ended()
+        return self._iterate_rdatasets()
+
+    def iterate_names(self) -> Iterator[dns.name.Name]:
+        """Iterate all the names in the transaction.
+
+        Note that as is usual with python iterators, adding or removing names
+        while iterating will invalidate the iterator and may raise `RuntimeError`
+        or fail to iterate over all entries."""
+        self._check_ended()
+        return self._iterate_names()
+
+    #
+    # Helper methods
+    #
+
+    def _raise_if_not_empty(self, method, args):
+        if len(args) != 0:
+            raise TypeError(f"extra parameters to {method}")
+
+    def _rdataset_from_args(self, method, deleting, args):
+        try:
+            arg = args.popleft()
+            if isinstance(arg, dns.rrset.RRset):
+                rdataset = arg.to_rdataset()
+            elif isinstance(arg, dns.rdataset.Rdataset):
+                rdataset = arg
+            else:
+                if deleting:
+                    ttl = 0
+                else:
+                    if isinstance(arg, int):
+                        ttl = arg
+                        if ttl > dns.ttl.MAX_TTL:
+                            raise ValueError(f"{method}: TTL value too big")
+                    else:
+                        raise TypeError(f"{method}: expected a TTL")
+                    arg = args.popleft()
+                if isinstance(arg, dns.rdata.Rdata):
+                    rdataset = dns.rdataset.from_rdata(ttl, arg)
+                else:
+                    raise TypeError(f"{method}: expected an Rdata")
+            return rdataset
+        except IndexError:
+            if deleting:
+                return None
+            else:
+                # reraise
+                raise TypeError(f"{method}: expected more arguments")
+
+    def _add(self, replace, args):
+        try:
+            args = collections.deque(args)
+            if replace:
+                method = "replace()"
+            else:
+                method = "add()"
+            arg = args.popleft()
+            if isinstance(arg, str):
+                arg = dns.name.from_text(arg, None)
+            if isinstance(arg, dns.name.Name):
+                name = arg
+                rdataset = self._rdataset_from_args(method, False, args)
+            elif isinstance(arg, dns.rrset.RRset):
+                rrset = arg
+                name = rrset.name
+                # rrsets are also rdatasets, but they don't print the
+                # same and can't be stored in nodes, so convert.
+                rdataset = rrset.to_rdataset()
+            else:
+                raise TypeError(
+                    f"{method} requires a name or RRset as the first argument"
+                )
+            if rdataset.rdclass != self.manager.get_class():
+                raise ValueError(f"{method} has objects of wrong RdataClass")
+            if rdataset.rdtype == dns.rdatatype.SOA:
+                (_, _, origin) = self._origin_information()
+                if name != origin:
+                    raise ValueError(f"{method} has non-origin SOA")
+            self._raise_if_not_empty(method, args)
+            if not replace:
+                existing = self._get_rdataset(name, rdataset.rdtype, rdataset.covers)
+                if existing is not None:
+                    if isinstance(existing, dns.rdataset.ImmutableRdataset):
+                        trds = dns.rdataset.Rdataset(
+                            existing.rdclass, existing.rdtype, existing.covers
+                        )
+                        trds.update(existing)
+                        existing = trds
+                    rdataset = existing.union(rdataset)
+            self._checked_put_rdataset(name, rdataset)
+        except IndexError:
+            raise TypeError(f"not enough parameters to {method}")
+
+    def _delete(self, exact, args):
+        try:
+            args = collections.deque(args)
+            if exact:
+                method = "delete_exact()"
+            else:
+                method = "delete()"
+            arg = args.popleft()
+            if isinstance(arg, str):
+                arg = dns.name.from_text(arg, None)
+            if isinstance(arg, dns.name.Name):
+                name = arg
+                if len(args) > 0 and (
+                    isinstance(args[0], int) or isinstance(args[0], str)
+                ):
+                    # deleting by type and (optionally) covers
+                    rdtype = dns.rdatatype.RdataType.make(args.popleft())
+                    if len(args) > 0:
+                        covers = dns.rdatatype.RdataType.make(args.popleft())
+                    else:
+                        covers = dns.rdatatype.NONE
+                    self._raise_if_not_empty(method, args)
+                    existing = self._get_rdataset(name, rdtype, covers)
+                    if existing is None:
+                        if exact:
+                            raise DeleteNotExact(f"{method}: missing rdataset")
+                    else:
+                        self._checked_delete_rdataset(name, rdtype, covers)
+                    return
+                else:
+                    rdataset = self._rdataset_from_args(method, True, args)
+            elif isinstance(arg, dns.rrset.RRset):
+                rdataset = arg  # rrsets are also rdatasets
+                name = rdataset.name
+            else:
+                raise TypeError(
+                    f"{method} requires a name or RRset as the first argument"
+                )
+            self._raise_if_not_empty(method, args)
+            if rdataset:
+                if rdataset.rdclass != self.manager.get_class():
+                    raise ValueError(f"{method} has objects of wrong RdataClass")
+                existing = self._get_rdataset(name, rdataset.rdtype, rdataset.covers)
+                if existing is not None:
+                    if exact:
+                        intersection = existing.intersection(rdataset)
+                        if intersection != rdataset:
+                            raise DeleteNotExact(f"{method}: missing rdatas")
+                    rdataset = existing.difference(rdataset)
+                    if len(rdataset) == 0:
+                        self._checked_delete_rdataset(
+                            name, rdataset.rdtype, rdataset.covers
+                        )
+                    else:
+                        self._checked_put_rdataset(name, rdataset)
+                elif exact:
+                    raise DeleteNotExact(f"{method}: missing rdataset")
+            else:
+                if exact and not self._name_exists(name):
+                    raise DeleteNotExact(f"{method}: name not known")
+                self._checked_delete_name(name)
+        except IndexError:
+            raise TypeError(f"not enough parameters to {method}")
+
+    def _check_ended(self):
+        if self._ended:
+            raise AlreadyEnded
+
+    def _end(self, commit):
+        self._check_ended()
+        try:
+            self._end_transaction(commit)
+        finally:
+            self._ended = True
+
+    def _checked_put_rdataset(self, name, rdataset):
+        for check in self._check_put_rdataset:
+            check(self, name, rdataset)
+        self._put_rdataset(name, rdataset)
+
+    def _checked_delete_rdataset(self, name, rdtype, covers):
+        for check in self._check_delete_rdataset:
+            check(self, name, rdtype, covers)
+        self._delete_rdataset(name, rdtype, covers)
+
+    def _checked_delete_name(self, name):
+        for check in self._check_delete_name:
+            check(self, name)
+        self._delete_name(name)
+
+    #
+    # Transactions are context managers.
+    #
+
+    def __enter__(self):
+        return self
+
+    def __exit__(self, exc_type, exc_val, exc_tb):
+        if not self._ended:
+            if exc_type is None:
+                self.commit()
+            else:
+                self.rollback()
+        return False
+
+    #
+    # This is the low level API, which must be implemented by subclasses
+    # of Transaction.
+    #
+
+    def _get_rdataset(self, name, rdtype, covers):
+        """Return the rdataset associated with *name*, *rdtype*, and *covers*,
+        or `None` if not found.
+        """
+        raise NotImplementedError  # pragma: no cover
+
+    def _put_rdataset(self, name, rdataset):
+        """Store the rdataset."""
+        raise NotImplementedError  # pragma: no cover
+
+    def _delete_name(self, name):
+        """Delete all data associated with *name*.
+
+        It is not an error if the name does not exist.
+        """
+        raise NotImplementedError  # pragma: no cover
+
+    def _delete_rdataset(self, name, rdtype, covers):
+        """Delete all data associated with *name*, *rdtype*, and *covers*.
+
+        It is not an error if the rdataset does not exist.
+        """
+        raise NotImplementedError  # pragma: no cover
+
+    def _name_exists(self, name):
+        """Does name exist?
+
+        Returns a bool.
+        """
+        raise NotImplementedError  # pragma: no cover
+
+    def _changed(self):
+        """Has this transaction changed anything?"""
+        raise NotImplementedError  # pragma: no cover
+
+    def _end_transaction(self, commit):
+        """End the transaction.
+
+        *commit*, a bool.  If ``True``, commit the transaction, otherwise
+        roll it back.
+
+        If committing and the commit fails, then roll back and raise an
+        exception.
+        """
+        raise NotImplementedError  # pragma: no cover
+
+    def _set_origin(self, origin):
+        """Set the origin.
+
+        This method is called when reading a possibly relativized
+        source, and an origin setting operation occurs (e.g. $ORIGIN
+        in a zone file).
+        """
+        raise NotImplementedError  # pragma: no cover
+
+    def _iterate_rdatasets(self):
+        """Return an iterator that yields (name, rdataset) tuples."""
+        raise NotImplementedError  # pragma: no cover
+
+    def _iterate_names(self):
+        """Return an iterator that yields a name."""
+        raise NotImplementedError  # pragma: no cover
+
+    def _get_node(self, name):
+        """Return the node at *name*, if any.
+
+        Returns a node or ``None``.
+        """
+        raise NotImplementedError  # pragma: no cover
+
+    #
+    # Low-level API with a default implementation, in case a subclass needs
+    # to override.
+    #
+
+    def _origin_information(self):
+        # This is only used by _add()
+        return self.manager.origin_information()
diff --git a/.venv/lib/python3.12/site-packages/dns/tsig.py b/.venv/lib/python3.12/site-packages/dns/tsig.py
new file mode 100644
index 00000000..780852e8
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/dns/tsig.py
@@ -0,0 +1,352 @@
+# Copyright (C) Dnspython Contributors, see LICENSE for text of ISC license
+
+# Copyright (C) 2001-2007, 2009-2011 Nominum, Inc.
+#
+# Permission to use, copy, modify, and distribute this software and its
+# documentation for any purpose with or without fee is hereby granted,
+# provided that the above copyright notice and this permission notice
+# appear in all copies.
+#
+# THE SOFTWARE IS PROVIDED "AS IS" AND NOMINUM DISCLAIMS ALL WARRANTIES
+# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL NOMINUM BE LIABLE FOR
+# ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+# ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT
+# OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+
+"""DNS TSIG support."""
+
+import base64
+import hashlib
+import hmac
+import struct
+
+import dns.exception
+import dns.name
+import dns.rcode
+import dns.rdataclass
+
+
+class BadTime(dns.exception.DNSException):
+    """The current time is not within the TSIG's validity time."""
+
+
+class BadSignature(dns.exception.DNSException):
+    """The TSIG signature fails to verify."""
+
+
+class BadKey(dns.exception.DNSException):
+    """The TSIG record owner name does not match the key."""
+
+
+class BadAlgorithm(dns.exception.DNSException):
+    """The TSIG algorithm does not match the key."""
+
+
+class PeerError(dns.exception.DNSException):
+    """Base class for all TSIG errors generated by the remote peer"""
+
+
+class PeerBadKey(PeerError):
+    """The peer didn't know the key we used"""
+
+
+class PeerBadSignature(PeerError):
+    """The peer didn't like the signature we sent"""
+
+
+class PeerBadTime(PeerError):
+    """The peer didn't like the time we sent"""
+
+
+class PeerBadTruncation(PeerError):
+    """The peer didn't like amount of truncation in the TSIG we sent"""
+
+
+# TSIG Algorithms
+
+HMAC_MD5 = dns.name.from_text("HMAC-MD5.SIG-ALG.REG.INT")
+HMAC_SHA1 = dns.name.from_text("hmac-sha1")
+HMAC_SHA224 = dns.name.from_text("hmac-sha224")
+HMAC_SHA256 = dns.name.from_text("hmac-sha256")
+HMAC_SHA256_128 = dns.name.from_text("hmac-sha256-128")
+HMAC_SHA384 = dns.name.from_text("hmac-sha384")
+HMAC_SHA384_192 = dns.name.from_text("hmac-sha384-192")
+HMAC_SHA512 = dns.name.from_text("hmac-sha512")
+HMAC_SHA512_256 = dns.name.from_text("hmac-sha512-256")
+GSS_TSIG = dns.name.from_text("gss-tsig")
+
+default_algorithm = HMAC_SHA256
+
+mac_sizes = {
+    HMAC_SHA1: 20,
+    HMAC_SHA224: 28,
+    HMAC_SHA256: 32,
+    HMAC_SHA256_128: 16,
+    HMAC_SHA384: 48,
+    HMAC_SHA384_192: 24,
+    HMAC_SHA512: 64,
+    HMAC_SHA512_256: 32,
+    HMAC_MD5: 16,
+    GSS_TSIG: 128,  # This is what we assume to be the worst case!
+}
+
+
+class GSSTSig:
+    """
+    GSS-TSIG TSIG implementation.  This uses the GSS-API context established
+    in the TKEY message handshake to sign messages using GSS-API message
+    integrity codes, per the RFC.
+
+    In order to avoid a direct GSSAPI dependency, the keyring holds a ref
+    to the GSSAPI object required, rather than the key itself.
+    """
+
+    def __init__(self, gssapi_context):
+        self.gssapi_context = gssapi_context
+        self.data = b""
+        self.name = "gss-tsig"
+
+    def update(self, data):
+        self.data += data
+
+    def sign(self):
+        # defer to the GSSAPI function to sign
+        return self.gssapi_context.get_signature(self.data)
+
+    def verify(self, expected):
+        try:
+            # defer to the GSSAPI function to verify
+            return self.gssapi_context.verify_signature(self.data, expected)
+        except Exception:
+            # note the usage of a bare exception
+            raise BadSignature
+
+
+class GSSTSigAdapter:
+    def __init__(self, keyring):
+        self.keyring = keyring
+
+    def __call__(self, message, keyname):
+        if keyname in self.keyring:
+            key = self.keyring[keyname]
+            if isinstance(key, Key) and key.algorithm == GSS_TSIG:
+                if message:
+                    GSSTSigAdapter.parse_tkey_and_step(key, message, keyname)
+            return key
+        else:
+            return None
+
+    @classmethod
+    def parse_tkey_and_step(cls, key, message, keyname):
+        # if the message is a TKEY type, absorb the key material
+        # into the context using step(); this is used to allow the
+        # client to complete the GSSAPI negotiation before attempting
+        # to verify the signed response to a TKEY message exchange
+        try:
+            rrset = message.find_rrset(
+                message.answer, keyname, dns.rdataclass.ANY, dns.rdatatype.TKEY
+            )
+            if rrset:
+                token = rrset[0].key
+                gssapi_context = key.secret
+                return gssapi_context.step(token)
+        except KeyError:
+            pass
+
+
+class HMACTSig:
+    """
+    HMAC TSIG implementation.  This uses the HMAC python module to handle the
+    sign/verify operations.
+    """
+
+    _hashes = {
+        HMAC_SHA1: hashlib.sha1,
+        HMAC_SHA224: hashlib.sha224,
+        HMAC_SHA256: hashlib.sha256,
+        HMAC_SHA256_128: (hashlib.sha256, 128),
+        HMAC_SHA384: hashlib.sha384,
+        HMAC_SHA384_192: (hashlib.sha384, 192),
+        HMAC_SHA512: hashlib.sha512,
+        HMAC_SHA512_256: (hashlib.sha512, 256),
+        HMAC_MD5: hashlib.md5,
+    }
+
+    def __init__(self, key, algorithm):
+        try:
+            hashinfo = self._hashes[algorithm]
+        except KeyError:
+            raise NotImplementedError(f"TSIG algorithm {algorithm} is not supported")
+
+        # create the HMAC context
+        if isinstance(hashinfo, tuple):
+            self.hmac_context = hmac.new(key, digestmod=hashinfo[0])
+            self.size = hashinfo[1]
+        else:
+            self.hmac_context = hmac.new(key, digestmod=hashinfo)
+            self.size = None
+        self.name = self.hmac_context.name
+        if self.size:
+            self.name += f"-{self.size}"
+
+    def update(self, data):
+        return self.hmac_context.update(data)
+
+    def sign(self):
+        # defer to the HMAC digest() function for that digestmod
+        digest = self.hmac_context.digest()
+        if self.size:
+            digest = digest[: (self.size // 8)]
+        return digest
+
+    def verify(self, expected):
+        # re-digest and compare the results
+        mac = self.sign()
+        if not hmac.compare_digest(mac, expected):
+            raise BadSignature
+
+
+def _digest(wire, key, rdata, time=None, request_mac=None, ctx=None, multi=None):
+    """Return a context containing the TSIG rdata for the input parameters
+    @rtype: dns.tsig.HMACTSig or dns.tsig.GSSTSig object
+    @raises ValueError: I{other_data} is too long
+    @raises NotImplementedError: I{algorithm} is not supported
+    """
+
+    first = not (ctx and multi)
+    if first:
+        ctx = get_context(key)
+        if request_mac:
+            ctx.update(struct.pack("!H", len(request_mac)))
+            ctx.update(request_mac)
+    ctx.update(struct.pack("!H", rdata.original_id))
+    ctx.update(wire[2:])
+    if first:
+        ctx.update(key.name.to_digestable())
+        ctx.update(struct.pack("!H", dns.rdataclass.ANY))
+        ctx.update(struct.pack("!I", 0))
+    if time is None:
+        time = rdata.time_signed
+    upper_time = (time >> 32) & 0xFFFF
+    lower_time = time & 0xFFFFFFFF
+    time_encoded = struct.pack("!HIH", upper_time, lower_time, rdata.fudge)
+    other_len = len(rdata.other)
+    if other_len > 65535:
+        raise ValueError("TSIG Other Data is > 65535 bytes")
+    if first:
+        ctx.update(key.algorithm.to_digestable() + time_encoded)
+        ctx.update(struct.pack("!HH", rdata.error, other_len) + rdata.other)
+    else:
+        ctx.update(time_encoded)
+    return ctx
+
+
+def _maybe_start_digest(key, mac, multi):
+    """If this is the first message in a multi-message sequence,
+    start a new context.
+    @rtype: dns.tsig.HMACTSig or dns.tsig.GSSTSig object
+    """
+    if multi:
+        ctx = get_context(key)
+        ctx.update(struct.pack("!H", len(mac)))
+        ctx.update(mac)
+        return ctx
+    else:
+        return None
+
+
+def sign(wire, key, rdata, time=None, request_mac=None, ctx=None, multi=False):
+    """Return a (tsig_rdata, mac, ctx) tuple containing the HMAC TSIG rdata
+    for the input parameters, the HMAC MAC calculated by applying the
+    TSIG signature algorithm, and the TSIG digest context.
+    @rtype: (string, dns.tsig.HMACTSig or dns.tsig.GSSTSig object)
+    @raises ValueError: I{other_data} is too long
+    @raises NotImplementedError: I{algorithm} is not supported
+    """
+
+    ctx = _digest(wire, key, rdata, time, request_mac, ctx, multi)
+    mac = ctx.sign()
+    tsig = rdata.replace(time_signed=time, mac=mac)
+
+    return (tsig, _maybe_start_digest(key, mac, multi))
+
+
+def validate(
+    wire, key, owner, rdata, now, request_mac, tsig_start, ctx=None, multi=False
+):
+    """Validate the specified TSIG rdata against the other input parameters.
+
+    @raises FormError: The TSIG is badly formed.
+    @raises BadTime: There is too much time skew between the client and the
+    server.
+    @raises BadSignature: The TSIG signature did not validate
+    @rtype: dns.tsig.HMACTSig or dns.tsig.GSSTSig object"""
+
+    (adcount,) = struct.unpack("!H", wire[10:12])
+    if adcount == 0:
+        raise dns.exception.FormError
+    adcount -= 1
+    new_wire = wire[0:10] + struct.pack("!H", adcount) + wire[12:tsig_start]
+    if rdata.error != 0:
+        if rdata.error == dns.rcode.BADSIG:
+            raise PeerBadSignature
+        elif rdata.error == dns.rcode.BADKEY:
+            raise PeerBadKey
+        elif rdata.error == dns.rcode.BADTIME:
+            raise PeerBadTime
+        elif rdata.error == dns.rcode.BADTRUNC:
+            raise PeerBadTruncation
+        else:
+            raise PeerError("unknown TSIG error code %d" % rdata.error)
+    if abs(rdata.time_signed - now) > rdata.fudge:
+        raise BadTime
+    if key.name != owner:
+        raise BadKey
+    if key.algorithm != rdata.algorithm:
+        raise BadAlgorithm
+    ctx = _digest(new_wire, key, rdata, None, request_mac, ctx, multi)
+    ctx.verify(rdata.mac)
+    return _maybe_start_digest(key, rdata.mac, multi)
+
+
+def get_context(key):
+    """Returns an HMAC context for the specified key.
+
+    @rtype: HMAC context
+    @raises NotImplementedError: I{algorithm} is not supported
+    """
+
+    if key.algorithm == GSS_TSIG:
+        return GSSTSig(key.secret)
+    else:
+        return HMACTSig(key.secret, key.algorithm)
+
+
+class Key:
+    def __init__(self, name, secret, algorithm=default_algorithm):
+        if isinstance(name, str):
+            name = dns.name.from_text(name)
+        self.name = name
+        if isinstance(secret, str):
+            secret = base64.decodebytes(secret.encode())
+        self.secret = secret
+        if isinstance(algorithm, str):
+            algorithm = dns.name.from_text(algorithm)
+        self.algorithm = algorithm
+
+    def __eq__(self, other):
+        return (
+            isinstance(other, Key)
+            and self.name == other.name
+            and self.secret == other.secret
+            and self.algorithm == other.algorithm
+        )
+
+    def __repr__(self):
+        r = f"<DNS key name='{self.name}', " + f"algorithm='{self.algorithm}'"
+        if self.algorithm != GSS_TSIG:
+            r += f", secret='{base64.b64encode(self.secret).decode()}'"
+        r += ">"
+        return r
diff --git a/.venv/lib/python3.12/site-packages/dns/tsigkeyring.py b/.venv/lib/python3.12/site-packages/dns/tsigkeyring.py
new file mode 100644
index 00000000..1010a79f
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/dns/tsigkeyring.py
@@ -0,0 +1,68 @@
+# Copyright (C) Dnspython Contributors, see LICENSE for text of ISC license
+
+# Copyright (C) 2003-2007, 2009-2011 Nominum, Inc.
+#
+# Permission to use, copy, modify, and distribute this software and its
+# documentation for any purpose with or without fee is hereby granted,
+# provided that the above copyright notice and this permission notice
+# appear in all copies.
+#
+# THE SOFTWARE IS PROVIDED "AS IS" AND NOMINUM DISCLAIMS ALL WARRANTIES
+# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL NOMINUM BE LIABLE FOR
+# ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+# ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT
+# OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+
+"""A place to store TSIG keys."""
+
+import base64
+from typing import Any, Dict
+
+import dns.name
+import dns.tsig
+
+
+def from_text(textring: Dict[str, Any]) -> Dict[dns.name.Name, dns.tsig.Key]:
+    """Convert a dictionary containing (textual DNS name, base64 secret)
+    pairs into a binary keyring which has (dns.name.Name, bytes) pairs, or
+    a dictionary containing (textual DNS name, (algorithm, base64 secret))
+    pairs into a binary keyring which has (dns.name.Name, dns.tsig.Key) pairs.
+    @rtype: dict"""
+
+    keyring = {}
+    for name, value in textring.items():
+        kname = dns.name.from_text(name)
+        if isinstance(value, str):
+            keyring[kname] = dns.tsig.Key(kname, value).secret
+        else:
+            (algorithm, secret) = value
+            keyring[kname] = dns.tsig.Key(kname, secret, algorithm)
+    return keyring
+
+
+def to_text(keyring: Dict[dns.name.Name, Any]) -> Dict[str, Any]:
+    """Convert a dictionary containing (dns.name.Name, dns.tsig.Key) pairs
+    into a text keyring which has (textual DNS name, (textual algorithm,
+    base64 secret)) pairs, or a dictionary containing (dns.name.Name, bytes)
+    pairs into a text keyring which has (textual DNS name, base64 secret) pairs.
+    @rtype: dict"""
+
+    textring = {}
+
+    def b64encode(secret):
+        return base64.encodebytes(secret).decode().rstrip()
+
+    for name, key in keyring.items():
+        tname = name.to_text()
+        if isinstance(key, bytes):
+            textring[tname] = b64encode(key)
+        else:
+            if isinstance(key.secret, bytes):
+                text_secret = b64encode(key.secret)
+            else:
+                text_secret = str(key.secret)
+
+            textring[tname] = (key.algorithm.to_text(), text_secret)
+    return textring
diff --git a/.venv/lib/python3.12/site-packages/dns/ttl.py b/.venv/lib/python3.12/site-packages/dns/ttl.py
new file mode 100644
index 00000000..b9a99fe3
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/dns/ttl.py
@@ -0,0 +1,92 @@
+# Copyright (C) Dnspython Contributors, see LICENSE for text of ISC license
+
+# Copyright (C) 2003-2017 Nominum, Inc.
+#
+# Permission to use, copy, modify, and distribute this software and its
+# documentation for any purpose with or without fee is hereby granted,
+# provided that the above copyright notice and this permission notice
+# appear in all copies.
+#
+# THE SOFTWARE IS PROVIDED "AS IS" AND NOMINUM DISCLAIMS ALL WARRANTIES
+# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL NOMINUM BE LIABLE FOR
+# ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+# ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT
+# OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+
+"""DNS TTL conversion."""
+
+from typing import Union
+
+import dns.exception
+
+# Technically TTLs are supposed to be between 0 and 2**31 - 1, with values
+# greater than that interpreted as 0, but we do not impose this policy here
+# as values > 2**31 - 1 occur in real world data.
+#
+# We leave it to applications to impose tighter bounds if desired.
+MAX_TTL = 2**32 - 1
+
+
+class BadTTL(dns.exception.SyntaxError):
+    """DNS TTL value is not well-formed."""
+
+
+def from_text(text: str) -> int:
+    """Convert the text form of a TTL to an integer.
+
+    The BIND 8 units syntax for TTLs (e.g. '1w6d4h3m10s') is supported.
+
+    *text*, a ``str``, the textual TTL.
+
+    Raises ``dns.ttl.BadTTL`` if the TTL is not well-formed.
+
+    Returns an ``int``.
+    """
+
+    if text.isdigit():
+        total = int(text)
+    elif len(text) == 0:
+        raise BadTTL
+    else:
+        total = 0
+        current = 0
+        need_digit = True
+        for c in text:
+            if c.isdigit():
+                current *= 10
+                current += int(c)
+                need_digit = False
+            else:
+                if need_digit:
+                    raise BadTTL
+                c = c.lower()
+                if c == "w":
+                    total += current * 604800
+                elif c == "d":
+                    total += current * 86400
+                elif c == "h":
+                    total += current * 3600
+                elif c == "m":
+                    total += current * 60
+                elif c == "s":
+                    total += current
+                else:
+                    raise BadTTL(f"unknown unit '{c}'")
+                current = 0
+                need_digit = True
+        if not current == 0:
+            raise BadTTL("trailing integer")
+    if total < 0 or total > MAX_TTL:
+        raise BadTTL("TTL should be between 0 and 2**32 - 1 (inclusive)")
+    return total
+
+
+def make(value: Union[int, str]) -> int:
+    if isinstance(value, int):
+        return value
+    elif isinstance(value, str):
+        return dns.ttl.from_text(value)
+    else:
+        raise ValueError("cannot convert value to TTL")
diff --git a/.venv/lib/python3.12/site-packages/dns/update.py b/.venv/lib/python3.12/site-packages/dns/update.py
new file mode 100644
index 00000000..bf1157ac
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/dns/update.py
@@ -0,0 +1,386 @@
+# Copyright (C) Dnspython Contributors, see LICENSE for text of ISC license
+
+# Copyright (C) 2003-2007, 2009-2011 Nominum, Inc.
+#
+# Permission to use, copy, modify, and distribute this software and its
+# documentation for any purpose with or without fee is hereby granted,
+# provided that the above copyright notice and this permission notice
+# appear in all copies.
+#
+# THE SOFTWARE IS PROVIDED "AS IS" AND NOMINUM DISCLAIMS ALL WARRANTIES
+# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL NOMINUM BE LIABLE FOR
+# ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+# ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT
+# OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+
+"""DNS Dynamic Update Support"""
+
+from typing import Any, List, Optional, Union
+
+import dns.message
+import dns.name
+import dns.opcode
+import dns.rdata
+import dns.rdataclass
+import dns.rdataset
+import dns.rdatatype
+import dns.tsig
+
+
+class UpdateSection(dns.enum.IntEnum):
+    """Update sections"""
+
+    ZONE = 0
+    PREREQ = 1
+    UPDATE = 2
+    ADDITIONAL = 3
+
+    @classmethod
+    def _maximum(cls):
+        return 3
+
+
+class UpdateMessage(dns.message.Message):  # lgtm[py/missing-equals]
+    # ignore the mypy error here as we mean to use a different enum
+    _section_enum = UpdateSection  # type: ignore
+
+    def __init__(
+        self,
+        zone: Optional[Union[dns.name.Name, str]] = None,
+        rdclass: dns.rdataclass.RdataClass = dns.rdataclass.IN,
+        keyring: Optional[Any] = None,
+        keyname: Optional[dns.name.Name] = None,
+        keyalgorithm: Union[dns.name.Name, str] = dns.tsig.default_algorithm,
+        id: Optional[int] = None,
+    ):
+        """Initialize a new DNS Update object.
+
+        See the documentation of the Message class for a complete
+        description of the keyring dictionary.
+
+        *zone*, a ``dns.name.Name``, ``str``, or ``None``, the zone
+        which is being updated.  ``None`` should only be used by dnspython's
+        message constructors, as a zone is required for the convenience
+        methods like ``add()``, ``replace()``, etc.
+
+        *rdclass*, an ``int`` or ``str``, the class of the zone.
+
+        The *keyring*, *keyname*, and *keyalgorithm* parameters are passed to
+        ``use_tsig()``; see its documentation for details.
+        """
+        super().__init__(id=id)
+        self.flags |= dns.opcode.to_flags(dns.opcode.UPDATE)
+        if isinstance(zone, str):
+            zone = dns.name.from_text(zone)
+        self.origin = zone
+        rdclass = dns.rdataclass.RdataClass.make(rdclass)
+        self.zone_rdclass = rdclass
+        if self.origin:
+            self.find_rrset(
+                self.zone,
+                self.origin,
+                rdclass,
+                dns.rdatatype.SOA,
+                create=True,
+                force_unique=True,
+            )
+        if keyring is not None:
+            self.use_tsig(keyring, keyname, algorithm=keyalgorithm)
+
+    @property
+    def zone(self) -> List[dns.rrset.RRset]:
+        """The zone section."""
+        return self.sections[0]
+
+    @zone.setter
+    def zone(self, v):
+        self.sections[0] = v
+
+    @property
+    def prerequisite(self) -> List[dns.rrset.RRset]:
+        """The prerequisite section."""
+        return self.sections[1]
+
+    @prerequisite.setter
+    def prerequisite(self, v):
+        self.sections[1] = v
+
+    @property
+    def update(self) -> List[dns.rrset.RRset]:
+        """The update section."""
+        return self.sections[2]
+
+    @update.setter
+    def update(self, v):
+        self.sections[2] = v
+
+    def _add_rr(self, name, ttl, rd, deleting=None, section=None):
+        """Add a single RR to the update section."""
+
+        if section is None:
+            section = self.update
+        covers = rd.covers()
+        rrset = self.find_rrset(
+            section, name, self.zone_rdclass, rd.rdtype, covers, deleting, True, True
+        )
+        rrset.add(rd, ttl)
+
+    def _add(self, replace, section, name, *args):
+        """Add records.
+
+        *replace* is the replacement mode.  If ``False``,
+        RRs are added to an existing RRset; if ``True``, the RRset
+        is replaced with the specified contents.  The second
+        argument is the section to add to.  The third argument
+        is always a name.  The other arguments can be:
+
+                - rdataset...
+
+                - ttl, rdata...
+
+                - ttl, rdtype, string...
+        """
+
+        if isinstance(name, str):
+            name = dns.name.from_text(name, None)
+        if isinstance(args[0], dns.rdataset.Rdataset):
+            for rds in args:
+                if replace:
+                    self.delete(name, rds.rdtype)
+                for rd in rds:
+                    self._add_rr(name, rds.ttl, rd, section=section)
+        else:
+            args = list(args)
+            ttl = int(args.pop(0))
+            if isinstance(args[0], dns.rdata.Rdata):
+                if replace:
+                    self.delete(name, args[0].rdtype)
+                for rd in args:
+                    self._add_rr(name, ttl, rd, section=section)
+            else:
+                rdtype = dns.rdatatype.RdataType.make(args.pop(0))
+                if replace:
+                    self.delete(name, rdtype)
+                for s in args:
+                    rd = dns.rdata.from_text(self.zone_rdclass, rdtype, s, self.origin)
+                    self._add_rr(name, ttl, rd, section=section)
+
+    def add(self, name: Union[dns.name.Name, str], *args: Any) -> None:
+        """Add records.
+
+        The first argument is always a name.  The other
+        arguments can be:
+
+                - rdataset...
+
+                - ttl, rdata...
+
+                - ttl, rdtype, string...
+        """
+
+        self._add(False, self.update, name, *args)
+
+    def delete(self, name: Union[dns.name.Name, str], *args: Any) -> None:
+        """Delete records.
+
+        The first argument is always a name.  The other
+        arguments can be:
+
+                - *empty*
+
+                - rdataset...
+
+                - rdata...
+
+                - rdtype, [string...]
+        """
+
+        if isinstance(name, str):
+            name = dns.name.from_text(name, None)
+        if len(args) == 0:
+            self.find_rrset(
+                self.update,
+                name,
+                dns.rdataclass.ANY,
+                dns.rdatatype.ANY,
+                dns.rdatatype.NONE,
+                dns.rdataclass.ANY,
+                True,
+                True,
+            )
+        elif isinstance(args[0], dns.rdataset.Rdataset):
+            for rds in args:
+                for rd in rds:
+                    self._add_rr(name, 0, rd, dns.rdataclass.NONE)
+        else:
+            largs = list(args)
+            if isinstance(largs[0], dns.rdata.Rdata):
+                for rd in largs:
+                    self._add_rr(name, 0, rd, dns.rdataclass.NONE)
+            else:
+                rdtype = dns.rdatatype.RdataType.make(largs.pop(0))
+                if len(largs) == 0:
+                    self.find_rrset(
+                        self.update,
+                        name,
+                        self.zone_rdclass,
+                        rdtype,
+                        dns.rdatatype.NONE,
+                        dns.rdataclass.ANY,
+                        True,
+                        True,
+                    )
+                else:
+                    for s in largs:
+                        rd = dns.rdata.from_text(
+                            self.zone_rdclass,
+                            rdtype,
+                            s,  # type: ignore[arg-type]
+                            self.origin,
+                        )
+                        self._add_rr(name, 0, rd, dns.rdataclass.NONE)
+
+    def replace(self, name: Union[dns.name.Name, str], *args: Any) -> None:
+        """Replace records.
+
+        The first argument is always a name.  The other
+        arguments can be:
+
+                - rdataset...
+
+                - ttl, rdata...
+
+                - ttl, rdtype, string...
+
+        Note that if you want to replace the entire node, you should do
+        a delete of the name followed by one or more calls to add.
+        """
+
+        self._add(True, self.update, name, *args)
+
+    def present(self, name: Union[dns.name.Name, str], *args: Any) -> None:
+        """Require that an owner name (and optionally an rdata type,
+        or specific rdataset) exists as a prerequisite to the
+        execution of the update.
+
+        The first argument is always a name.
+        The other arguments can be:
+
+                - rdataset...
+
+                - rdata...
+
+                - rdtype, string...
+        """
+
+        if isinstance(name, str):
+            name = dns.name.from_text(name, None)
+        if len(args) == 0:
+            self.find_rrset(
+                self.prerequisite,
+                name,
+                dns.rdataclass.ANY,
+                dns.rdatatype.ANY,
+                dns.rdatatype.NONE,
+                None,
+                True,
+                True,
+            )
+        elif (
+            isinstance(args[0], dns.rdataset.Rdataset)
+            or isinstance(args[0], dns.rdata.Rdata)
+            or len(args) > 1
+        ):
+            if not isinstance(args[0], dns.rdataset.Rdataset):
+                # Add a 0 TTL
+                largs = list(args)
+                largs.insert(0, 0)  # type: ignore[arg-type]
+                self._add(False, self.prerequisite, name, *largs)
+            else:
+                self._add(False, self.prerequisite, name, *args)
+        else:
+            rdtype = dns.rdatatype.RdataType.make(args[0])
+            self.find_rrset(
+                self.prerequisite,
+                name,
+                dns.rdataclass.ANY,
+                rdtype,
+                dns.rdatatype.NONE,
+                None,
+                True,
+                True,
+            )
+
+    def absent(
+        self,
+        name: Union[dns.name.Name, str],
+        rdtype: Optional[Union[dns.rdatatype.RdataType, str]] = None,
+    ) -> None:
+        """Require that an owner name (and optionally an rdata type) does
+        not exist as a prerequisite to the execution of the update."""
+
+        if isinstance(name, str):
+            name = dns.name.from_text(name, None)
+        if rdtype is None:
+            self.find_rrset(
+                self.prerequisite,
+                name,
+                dns.rdataclass.NONE,
+                dns.rdatatype.ANY,
+                dns.rdatatype.NONE,
+                None,
+                True,
+                True,
+            )
+        else:
+            rdtype = dns.rdatatype.RdataType.make(rdtype)
+            self.find_rrset(
+                self.prerequisite,
+                name,
+                dns.rdataclass.NONE,
+                rdtype,
+                dns.rdatatype.NONE,
+                None,
+                True,
+                True,
+            )
+
+    def _get_one_rr_per_rrset(self, value):
+        # Updates are always one_rr_per_rrset
+        return True
+
+    def _parse_rr_header(self, section, name, rdclass, rdtype):
+        deleting = None
+        empty = False
+        if section == UpdateSection.ZONE:
+            if (
+                dns.rdataclass.is_metaclass(rdclass)
+                or rdtype != dns.rdatatype.SOA
+                or self.zone
+            ):
+                raise dns.exception.FormError
+        else:
+            if not self.zone:
+                raise dns.exception.FormError
+            if rdclass in (dns.rdataclass.ANY, dns.rdataclass.NONE):
+                deleting = rdclass
+                rdclass = self.zone[0].rdclass
+                empty = (
+                    deleting == dns.rdataclass.ANY or section == UpdateSection.PREREQ
+                )
+        return (rdclass, rdtype, deleting, empty)
+
+
+# backwards compatibility
+Update = UpdateMessage
+
+### BEGIN generated UpdateSection constants
+
+ZONE = UpdateSection.ZONE
+PREREQ = UpdateSection.PREREQ
+UPDATE = UpdateSection.UPDATE
+ADDITIONAL = UpdateSection.ADDITIONAL
+
+### END generated UpdateSection constants
diff --git a/.venv/lib/python3.12/site-packages/dns/version.py b/.venv/lib/python3.12/site-packages/dns/version.py
new file mode 100644
index 00000000..9ed2ce19
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/dns/version.py
@@ -0,0 +1,58 @@
+# Copyright (C) Dnspython Contributors, see LICENSE for text of ISC license
+
+# Copyright (C) 2003-2017 Nominum, Inc.
+#
+# Permission to use, copy, modify, and distribute this software and its
+# documentation for any purpose with or without fee is hereby granted,
+# provided that the above copyright notice and this permission notice
+# appear in all copies.
+#
+# THE SOFTWARE IS PROVIDED "AS IS" AND NOMINUM DISCLAIMS ALL WARRANTIES
+# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL NOMINUM BE LIABLE FOR
+# ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+# ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT
+# OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+
+"""dnspython release version information."""
+
+#: MAJOR
+MAJOR = 2
+#: MINOR
+MINOR = 7
+#: MICRO
+MICRO = 0
+#: RELEASELEVEL
+RELEASELEVEL = 0x0F
+#: SERIAL
+SERIAL = 0
+
+if RELEASELEVEL == 0x0F:  # pragma: no cover  lgtm[py/unreachable-statement]
+    #: version
+    version = "%d.%d.%d" % (MAJOR, MINOR, MICRO)  # lgtm[py/unreachable-statement]
+elif RELEASELEVEL == 0x00:  # pragma: no cover  lgtm[py/unreachable-statement]
+    version = "%d.%d.%ddev%d" % (
+        MAJOR,
+        MINOR,
+        MICRO,
+        SERIAL,
+    )  # lgtm[py/unreachable-statement]
+elif RELEASELEVEL == 0x0C:  # pragma: no cover  lgtm[py/unreachable-statement]
+    version = "%d.%d.%drc%d" % (
+        MAJOR,
+        MINOR,
+        MICRO,
+        SERIAL,
+    )  # lgtm[py/unreachable-statement]
+else:  # pragma: no cover  lgtm[py/unreachable-statement]
+    version = "%d.%d.%d%x%d" % (
+        MAJOR,
+        MINOR,
+        MICRO,
+        RELEASELEVEL,
+        SERIAL,
+    )  # lgtm[py/unreachable-statement]
+
+#: hexversion
+hexversion = MAJOR << 24 | MINOR << 16 | MICRO << 8 | RELEASELEVEL << 4 | SERIAL
diff --git a/.venv/lib/python3.12/site-packages/dns/versioned.py b/.venv/lib/python3.12/site-packages/dns/versioned.py
new file mode 100644
index 00000000..fd78e674
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/dns/versioned.py
@@ -0,0 +1,318 @@
+# Copyright (C) Dnspython Contributors, see LICENSE for text of ISC license
+
+"""DNS Versioned Zones."""
+
+import collections
+import threading
+from typing import Callable, Deque, Optional, Set, Union
+
+import dns.exception
+import dns.immutable
+import dns.name
+import dns.node
+import dns.rdataclass
+import dns.rdataset
+import dns.rdatatype
+import dns.rdtypes.ANY.SOA
+import dns.zone
+
+
+class UseTransaction(dns.exception.DNSException):
+    """To alter a versioned zone, use a transaction."""
+
+
+# Backwards compatibility
+Node = dns.zone.VersionedNode
+ImmutableNode = dns.zone.ImmutableVersionedNode
+Version = dns.zone.Version
+WritableVersion = dns.zone.WritableVersion
+ImmutableVersion = dns.zone.ImmutableVersion
+Transaction = dns.zone.Transaction
+
+
+class Zone(dns.zone.Zone):  # lgtm[py/missing-equals]
+    __slots__ = [
+        "_versions",
+        "_versions_lock",
+        "_write_txn",
+        "_write_waiters",
+        "_write_event",
+        "_pruning_policy",
+        "_readers",
+    ]
+
+    node_factory = Node
+
+    def __init__(
+        self,
+        origin: Optional[Union[dns.name.Name, str]],
+        rdclass: dns.rdataclass.RdataClass = dns.rdataclass.IN,
+        relativize: bool = True,
+        pruning_policy: Optional[Callable[["Zone", Version], Optional[bool]]] = None,
+    ):
+        """Initialize a versioned zone object.
+
+        *origin* is the origin of the zone.  It may be a ``dns.name.Name``,
+        a ``str``, or ``None``.  If ``None``, then the zone's origin will
+        be set by the first ``$ORIGIN`` line in a zone file.
+
+        *rdclass*, an ``int``, the zone's rdata class; the default is class IN.
+
+        *relativize*, a ``bool``, determine's whether domain names are
+        relativized to the zone's origin.  The default is ``True``.
+
+        *pruning policy*, a function taking a ``Zone`` and a ``Version`` and returning
+        a ``bool``, or ``None``.  Should the version be pruned?  If ``None``,
+        the default policy, which retains one version is used.
+        """
+        super().__init__(origin, rdclass, relativize)
+        self._versions: Deque[Version] = collections.deque()
+        self._version_lock = threading.Lock()
+        if pruning_policy is None:
+            self._pruning_policy = self._default_pruning_policy
+        else:
+            self._pruning_policy = pruning_policy
+        self._write_txn: Optional[Transaction] = None
+        self._write_event: Optional[threading.Event] = None
+        self._write_waiters: Deque[threading.Event] = collections.deque()
+        self._readers: Set[Transaction] = set()
+        self._commit_version_unlocked(
+            None, WritableVersion(self, replacement=True), origin
+        )
+
+    def reader(
+        self, id: Optional[int] = None, serial: Optional[int] = None
+    ) -> Transaction:  # pylint: disable=arguments-differ
+        if id is not None and serial is not None:
+            raise ValueError("cannot specify both id and serial")
+        with self._version_lock:
+            if id is not None:
+                version = None
+                for v in reversed(self._versions):
+                    if v.id == id:
+                        version = v
+                        break
+                if version is None:
+                    raise KeyError("version not found")
+            elif serial is not None:
+                if self.relativize:
+                    oname = dns.name.empty
+                else:
+                    assert self.origin is not None
+                    oname = self.origin
+                version = None
+                for v in reversed(self._versions):
+                    n = v.nodes.get(oname)
+                    if n:
+                        rds = n.get_rdataset(self.rdclass, dns.rdatatype.SOA)
+                        if rds and rds[0].serial == serial:
+                            version = v
+                            break
+                if version is None:
+                    raise KeyError("serial not found")
+            else:
+                version = self._versions[-1]
+            txn = Transaction(self, False, version)
+            self._readers.add(txn)
+            return txn
+
+    def writer(self, replacement: bool = False) -> Transaction:
+        event = None
+        while True:
+            with self._version_lock:
+                # Checking event == self._write_event ensures that either
+                # no one was waiting before we got lucky and found no write
+                # txn, or we were the one who was waiting and got woken up.
+                # This prevents "taking cuts" when creating a write txn.
+                if self._write_txn is None and event == self._write_event:
+                    # Creating the transaction defers version setup
+                    # (i.e.  copying the nodes dictionary) until we
+                    # give up the lock, so that we hold the lock as
+                    # short a time as possible.  This is why we call
+                    # _setup_version() below.
+                    self._write_txn = Transaction(
+                        self, replacement, make_immutable=True
+                    )
+                    # give up our exclusive right to make a Transaction
+                    self._write_event = None
+                    break
+                # Someone else is writing already, so we will have to
+                # wait, but we want to do the actual wait outside the
+                # lock.
+                event = threading.Event()
+                self._write_waiters.append(event)
+            # wait (note we gave up the lock!)
+            #
+            # We only wake one sleeper at a time, so it's important
+            # that no event waiter can exit this method (e.g. via
+            # cancellation) without returning a transaction or waking
+            # someone else up.
+            #
+            # This is not a problem with Threading module threads as
+            # they cannot be canceled, but could be an issue with trio
+            # tasks when we do the async version of writer().
+            # I.e. we'd need to do something like:
+            #
+            # try:
+            #     event.wait()
+            # except trio.Cancelled:
+            #     with self._version_lock:
+            #         self._maybe_wakeup_one_waiter_unlocked()
+            #     raise
+            #
+            event.wait()
+        # Do the deferred version setup.
+        self._write_txn._setup_version()
+        return self._write_txn
+
+    def _maybe_wakeup_one_waiter_unlocked(self):
+        if len(self._write_waiters) > 0:
+            self._write_event = self._write_waiters.popleft()
+            self._write_event.set()
+
+    # pylint: disable=unused-argument
+    def _default_pruning_policy(self, zone, version):
+        return True
+
+    # pylint: enable=unused-argument
+
+    def _prune_versions_unlocked(self):
+        assert len(self._versions) > 0
+        # Don't ever prune a version greater than or equal to one that
+        # a reader has open.  This pins versions in memory while the
+        # reader is open, and importantly lets the reader open a txn on
+        # a successor version (e.g. if generating an IXFR).
+        #
+        # Note our definition of least_kept also ensures we do not try to
+        # delete the greatest version.
+        if len(self._readers) > 0:
+            least_kept = min(txn.version.id for txn in self._readers)
+        else:
+            least_kept = self._versions[-1].id
+        while self._versions[0].id < least_kept and self._pruning_policy(
+            self, self._versions[0]
+        ):
+            self._versions.popleft()
+
+    def set_max_versions(self, max_versions: Optional[int]) -> None:
+        """Set a pruning policy that retains up to the specified number
+        of versions
+        """
+        if max_versions is not None and max_versions < 1:
+            raise ValueError("max versions must be at least 1")
+        if max_versions is None:
+
+            def policy(zone, _):  # pylint: disable=unused-argument
+                return False
+
+        else:
+
+            def policy(zone, _):
+                return len(zone._versions) > max_versions
+
+        self.set_pruning_policy(policy)
+
+    def set_pruning_policy(
+        self, policy: Optional[Callable[["Zone", Version], Optional[bool]]]
+    ) -> None:
+        """Set the pruning policy for the zone.
+
+        The *policy* function takes a `Version` and returns `True` if
+        the version should be pruned, and `False` otherwise.  `None`
+        may also be specified for policy, in which case the default policy
+        is used.
+
+        Pruning checking proceeds from the least version and the first
+        time the function returns `False`, the checking stops.  I.e. the
+        retained versions are always a consecutive sequence.
+        """
+        if policy is None:
+            policy = self._default_pruning_policy
+        with self._version_lock:
+            self._pruning_policy = policy
+            self._prune_versions_unlocked()
+
+    def _end_read(self, txn):
+        with self._version_lock:
+            self._readers.remove(txn)
+            self._prune_versions_unlocked()
+
+    def _end_write_unlocked(self, txn):
+        assert self._write_txn == txn
+        self._write_txn = None
+        self._maybe_wakeup_one_waiter_unlocked()
+
+    def _end_write(self, txn):
+        with self._version_lock:
+            self._end_write_unlocked(txn)
+
+    def _commit_version_unlocked(self, txn, version, origin):
+        self._versions.append(version)
+        self._prune_versions_unlocked()
+        self.nodes = version.nodes
+        if self.origin is None:
+            self.origin = origin
+        # txn can be None in __init__ when we make the empty version.
+        if txn is not None:
+            self._end_write_unlocked(txn)
+
+    def _commit_version(self, txn, version, origin):
+        with self._version_lock:
+            self._commit_version_unlocked(txn, version, origin)
+
+    def _get_next_version_id(self):
+        if len(self._versions) > 0:
+            id = self._versions[-1].id + 1
+        else:
+            id = 1
+        return id
+
+    def find_node(
+        self, name: Union[dns.name.Name, str], create: bool = False
+    ) -> dns.node.Node:
+        if create:
+            raise UseTransaction
+        return super().find_node(name)
+
+    def delete_node(self, name: Union[dns.name.Name, str]) -> None:
+        raise UseTransaction
+
+    def find_rdataset(
+        self,
+        name: Union[dns.name.Name, str],
+        rdtype: Union[dns.rdatatype.RdataType, str],
+        covers: Union[dns.rdatatype.RdataType, str] = dns.rdatatype.NONE,
+        create: bool = False,
+    ) -> dns.rdataset.Rdataset:
+        if create:
+            raise UseTransaction
+        rdataset = super().find_rdataset(name, rdtype, covers)
+        return dns.rdataset.ImmutableRdataset(rdataset)
+
+    def get_rdataset(
+        self,
+        name: Union[dns.name.Name, str],
+        rdtype: Union[dns.rdatatype.RdataType, str],
+        covers: Union[dns.rdatatype.RdataType, str] = dns.rdatatype.NONE,
+        create: bool = False,
+    ) -> Optional[dns.rdataset.Rdataset]:
+        if create:
+            raise UseTransaction
+        rdataset = super().get_rdataset(name, rdtype, covers)
+        if rdataset is not None:
+            return dns.rdataset.ImmutableRdataset(rdataset)
+        else:
+            return None
+
+    def delete_rdataset(
+        self,
+        name: Union[dns.name.Name, str],
+        rdtype: Union[dns.rdatatype.RdataType, str],
+        covers: Union[dns.rdatatype.RdataType, str] = dns.rdatatype.NONE,
+    ) -> None:
+        raise UseTransaction
+
+    def replace_rdataset(
+        self, name: Union[dns.name.Name, str], replacement: dns.rdataset.Rdataset
+    ) -> None:
+        raise UseTransaction
diff --git a/.venv/lib/python3.12/site-packages/dns/win32util.py b/.venv/lib/python3.12/site-packages/dns/win32util.py
new file mode 100644
index 00000000..9ed3f11b
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/dns/win32util.py
@@ -0,0 +1,242 @@
+import sys
+
+import dns._features
+
+if sys.platform == "win32":
+    from typing import Any
+
+    import dns.name
+
+    _prefer_wmi = True
+
+    import winreg  # pylint: disable=import-error
+
+    # Keep pylint quiet on non-windows.
+    try:
+        _ = WindowsError  # pylint: disable=used-before-assignment
+    except NameError:
+        WindowsError = Exception
+
+    if dns._features.have("wmi"):
+        import threading
+
+        import pythoncom  # pylint: disable=import-error
+        import wmi  # pylint: disable=import-error
+
+        _have_wmi = True
+    else:
+        _have_wmi = False
+
+    def _config_domain(domain):
+        # Sometimes DHCP servers add a '.' prefix to the default domain, and
+        # Windows just stores such values in the registry (see #687).
+        # Check for this and fix it.
+        if domain.startswith("."):
+            domain = domain[1:]
+        return dns.name.from_text(domain)
+
+    class DnsInfo:
+        def __init__(self):
+            self.domain = None
+            self.nameservers = []
+            self.search = []
+
+    if _have_wmi:
+
+        class _WMIGetter(threading.Thread):
+            # pylint: disable=possibly-used-before-assignment
+            def __init__(self):
+                super().__init__()
+                self.info = DnsInfo()
+
+            def run(self):
+                pythoncom.CoInitialize()
+                try:
+                    system = wmi.WMI()
+                    for interface in system.Win32_NetworkAdapterConfiguration():
+                        if interface.IPEnabled and interface.DNSServerSearchOrder:
+                            self.info.nameservers = list(interface.DNSServerSearchOrder)
+                            if interface.DNSDomain:
+                                self.info.domain = _config_domain(interface.DNSDomain)
+                            if interface.DNSDomainSuffixSearchOrder:
+                                self.info.search = [
+                                    _config_domain(x)
+                                    for x in interface.DNSDomainSuffixSearchOrder
+                                ]
+                            break
+                finally:
+                    pythoncom.CoUninitialize()
+
+            def get(self):
+                # We always run in a separate thread to avoid any issues with
+                # the COM threading model.
+                self.start()
+                self.join()
+                return self.info
+
+    else:
+
+        class _WMIGetter:  # type: ignore
+            pass
+
+    class _RegistryGetter:
+        def __init__(self):
+            self.info = DnsInfo()
+
+        def _split(self, text):
+            # The windows registry has used both " " and "," as a delimiter, and while
+            # it is currently using "," in Windows 10 and later, updates can seemingly
+            # leave a space in too, e.g. "a, b".  So we just convert all commas to
+            # spaces, and use split() in its default configuration, which splits on
+            # all whitespace and ignores empty strings.
+            return text.replace(",", " ").split()
+
+        def _config_nameservers(self, nameservers):
+            for ns in self._split(nameservers):
+                if ns not in self.info.nameservers:
+                    self.info.nameservers.append(ns)
+
+        def _config_search(self, search):
+            for s in self._split(search):
+                s = _config_domain(s)
+                if s not in self.info.search:
+                    self.info.search.append(s)
+
+        def _config_fromkey(self, key, always_try_domain):
+            try:
+                servers, _ = winreg.QueryValueEx(key, "NameServer")
+            except WindowsError:
+                servers = None
+            if servers:
+                self._config_nameservers(servers)
+            if servers or always_try_domain:
+                try:
+                    dom, _ = winreg.QueryValueEx(key, "Domain")
+                    if dom:
+                        self.info.domain = _config_domain(dom)
+                except WindowsError:
+                    pass
+            else:
+                try:
+                    servers, _ = winreg.QueryValueEx(key, "DhcpNameServer")
+                except WindowsError:
+                    servers = None
+                if servers:
+                    self._config_nameservers(servers)
+                    try:
+                        dom, _ = winreg.QueryValueEx(key, "DhcpDomain")
+                        if dom:
+                            self.info.domain = _config_domain(dom)
+                    except WindowsError:
+                        pass
+            try:
+                search, _ = winreg.QueryValueEx(key, "SearchList")
+            except WindowsError:
+                search = None
+            if search is None:
+                try:
+                    search, _ = winreg.QueryValueEx(key, "DhcpSearchList")
+                except WindowsError:
+                    search = None
+            if search:
+                self._config_search(search)
+
+        def _is_nic_enabled(self, lm, guid):
+            # Look in the Windows Registry to determine whether the network
+            # interface corresponding to the given guid is enabled.
+            #
+            # (Code contributed by Paul Marks, thanks!)
+            #
+            try:
+                # This hard-coded location seems to be consistent, at least
+                # from Windows 2000 through Vista.
+                connection_key = winreg.OpenKey(
+                    lm,
+                    r"SYSTEM\CurrentControlSet\Control\Network"
+                    r"\{4D36E972-E325-11CE-BFC1-08002BE10318}"
+                    rf"\{guid}\Connection",
+                )
+
+                try:
+                    # The PnpInstanceID points to a key inside Enum
+                    (pnp_id, ttype) = winreg.QueryValueEx(
+                        connection_key, "PnpInstanceID"
+                    )
+
+                    if ttype != winreg.REG_SZ:
+                        raise ValueError  # pragma: no cover
+
+                    device_key = winreg.OpenKey(
+                        lm, rf"SYSTEM\CurrentControlSet\Enum\{pnp_id}"
+                    )
+
+                    try:
+                        # Get ConfigFlags for this device
+                        (flags, ttype) = winreg.QueryValueEx(device_key, "ConfigFlags")
+
+                        if ttype != winreg.REG_DWORD:
+                            raise ValueError  # pragma: no cover
+
+                        # Based on experimentation, bit 0x1 indicates that the
+                        # device is disabled.
+                        #
+                        # XXXRTH I suspect we really want to & with 0x03 so
+                        # that CONFIGFLAGS_REMOVED devices are also ignored,
+                        # but we're shifting to WMI as ConfigFlags is not
+                        # supposed to be used.
+                        return not flags & 0x1
+
+                    finally:
+                        device_key.Close()
+                finally:
+                    connection_key.Close()
+            except Exception:  # pragma: no cover
+                return False
+
+        def get(self):
+            """Extract resolver configuration from the Windows registry."""
+
+            lm = winreg.ConnectRegistry(None, winreg.HKEY_LOCAL_MACHINE)
+            try:
+                tcp_params = winreg.OpenKey(
+                    lm, r"SYSTEM\CurrentControlSet\Services\Tcpip\Parameters"
+                )
+                try:
+                    self._config_fromkey(tcp_params, True)
+                finally:
+                    tcp_params.Close()
+                interfaces = winreg.OpenKey(
+                    lm,
+                    r"SYSTEM\CurrentControlSet\Services\Tcpip\Parameters\Interfaces",
+                )
+                try:
+                    i = 0
+                    while True:
+                        try:
+                            guid = winreg.EnumKey(interfaces, i)
+                            i += 1
+                            key = winreg.OpenKey(interfaces, guid)
+                            try:
+                                if not self._is_nic_enabled(lm, guid):
+                                    continue
+                                self._config_fromkey(key, False)
+                            finally:
+                                key.Close()
+                        except OSError:
+                            break
+                finally:
+                    interfaces.Close()
+            finally:
+                lm.Close()
+            return self.info
+
+    _getter_class: Any
+    if _have_wmi and _prefer_wmi:
+        _getter_class = _WMIGetter
+    else:
+        _getter_class = _RegistryGetter
+
+    def get_dns_info():
+        """Extract resolver configuration."""
+        getter = _getter_class()
+        return getter.get()
diff --git a/.venv/lib/python3.12/site-packages/dns/wire.py b/.venv/lib/python3.12/site-packages/dns/wire.py
new file mode 100644
index 00000000..9f9b1573
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/dns/wire.py
@@ -0,0 +1,89 @@
+# Copyright (C) Dnspython Contributors, see LICENSE for text of ISC license
+
+import contextlib
+import struct
+from typing import Iterator, Optional, Tuple
+
+import dns.exception
+import dns.name
+
+
+class Parser:
+    def __init__(self, wire: bytes, current: int = 0):
+        self.wire = wire
+        self.current = 0
+        self.end = len(self.wire)
+        if current:
+            self.seek(current)
+        self.furthest = current
+
+    def remaining(self) -> int:
+        return self.end - self.current
+
+    def get_bytes(self, size: int) -> bytes:
+        assert size >= 0
+        if size > self.remaining():
+            raise dns.exception.FormError
+        output = self.wire[self.current : self.current + size]
+        self.current += size
+        self.furthest = max(self.furthest, self.current)
+        return output
+
+    def get_counted_bytes(self, length_size: int = 1) -> bytes:
+        length = int.from_bytes(self.get_bytes(length_size), "big")
+        return self.get_bytes(length)
+
+    def get_remaining(self) -> bytes:
+        return self.get_bytes(self.remaining())
+
+    def get_uint8(self) -> int:
+        return struct.unpack("!B", self.get_bytes(1))[0]
+
+    def get_uint16(self) -> int:
+        return struct.unpack("!H", self.get_bytes(2))[0]
+
+    def get_uint32(self) -> int:
+        return struct.unpack("!I", self.get_bytes(4))[0]
+
+    def get_uint48(self) -> int:
+        return int.from_bytes(self.get_bytes(6), "big")
+
+    def get_struct(self, format: str) -> Tuple:
+        return struct.unpack(format, self.get_bytes(struct.calcsize(format)))
+
+    def get_name(self, origin: Optional["dns.name.Name"] = None) -> "dns.name.Name":
+        name = dns.name.from_wire_parser(self)
+        if origin:
+            name = name.relativize(origin)
+        return name
+
+    def seek(self, where: int) -> None:
+        # Note that seeking to the end is OK!  (If you try to read
+        # after such a seek, you'll get an exception as expected.)
+        if where < 0 or where > self.end:
+            raise dns.exception.FormError
+        self.current = where
+
+    @contextlib.contextmanager
+    def restrict_to(self, size: int) -> Iterator:
+        assert size >= 0
+        if size > self.remaining():
+            raise dns.exception.FormError
+        saved_end = self.end
+        try:
+            self.end = self.current + size
+            yield
+            # We make this check here and not in the finally as we
+            # don't want to raise if we're already raising for some
+            # other reason.
+            if self.current != self.end:
+                raise dns.exception.FormError
+        finally:
+            self.end = saved_end
+
+    @contextlib.contextmanager
+    def restore_furthest(self) -> Iterator:
+        try:
+            yield None
+        finally:
+            self.current = self.furthest
diff --git a/.venv/lib/python3.12/site-packages/dns/xfr.py b/.venv/lib/python3.12/site-packages/dns/xfr.py
new file mode 100644
index 00000000..520aa32d
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/dns/xfr.py
@@ -0,0 +1,343 @@
+# Copyright (C) Dnspython Contributors, see LICENSE for text of ISC license
+
+# Copyright (C) 2003-2017 Nominum, Inc.
+#
+# Permission to use, copy, modify, and distribute this software and its
+# documentation for any purpose with or without fee is hereby granted,
+# provided that the above copyright notice and this permission notice
+# appear in all copies.
+#
+# THE SOFTWARE IS PROVIDED "AS IS" AND NOMINUM DISCLAIMS ALL WARRANTIES
+# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL NOMINUM BE LIABLE FOR
+# ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+# ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT
+# OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+
+from typing import Any, List, Optional, Tuple, Union
+
+import dns.exception
+import dns.message
+import dns.name
+import dns.rcode
+import dns.rdataset
+import dns.rdatatype
+import dns.serial
+import dns.transaction
+import dns.tsig
+import dns.zone
+
+
+class TransferError(dns.exception.DNSException):
+    """A zone transfer response got a non-zero rcode."""
+
+    def __init__(self, rcode):
+        message = f"Zone transfer error: {dns.rcode.to_text(rcode)}"
+        super().__init__(message)
+        self.rcode = rcode
+
+
+class SerialWentBackwards(dns.exception.FormError):
+    """The current serial number is less than the serial we know."""
+
+
+class UseTCP(dns.exception.DNSException):
+    """This IXFR cannot be completed with UDP."""
+
+
+class Inbound:
+    """
+    State machine for zone transfers.
+    """
+
+    def __init__(
+        self,
+        txn_manager: dns.transaction.TransactionManager,
+        rdtype: dns.rdatatype.RdataType = dns.rdatatype.AXFR,
+        serial: Optional[int] = None,
+        is_udp: bool = False,
+    ):
+        """Initialize an inbound zone transfer.
+
+        *txn_manager* is a :py:class:`dns.transaction.TransactionManager`.
+
+        *rdtype* can be `dns.rdatatype.AXFR` or `dns.rdatatype.IXFR`
+
+        *serial* is the base serial number for IXFRs, and is required in
+        that case.
+
+        *is_udp*, a ``bool`` indidicates if UDP is being used for this
+        XFR.
+        """
+        self.txn_manager = txn_manager
+        self.txn: Optional[dns.transaction.Transaction] = None
+        self.rdtype = rdtype
+        if rdtype == dns.rdatatype.IXFR:
+            if serial is None:
+                raise ValueError("a starting serial must be supplied for IXFRs")
+        elif is_udp:
+            raise ValueError("is_udp specified for AXFR")
+        self.serial = serial
+        self.is_udp = is_udp
+        (_, _, self.origin) = txn_manager.origin_information()
+        self.soa_rdataset: Optional[dns.rdataset.Rdataset] = None
+        self.done = False
+        self.expecting_SOA = False
+        self.delete_mode = False
+
+    def process_message(self, message: dns.message.Message) -> bool:
+        """Process one message in the transfer.
+
+        The message should have the same relativization as was specified when
+        the `dns.xfr.Inbound` was created.  The message should also have been
+        created with `one_rr_per_rrset=True` because order matters.
+
+        Returns `True` if the transfer is complete, and `False` otherwise.
+        """
+        if self.txn is None:
+            replacement = self.rdtype == dns.rdatatype.AXFR
+            self.txn = self.txn_manager.writer(replacement)
+        rcode = message.rcode()
+        if rcode != dns.rcode.NOERROR:
+            raise TransferError(rcode)
+        #
+        # We don't require a question section, but if it is present is
+        # should be correct.
+        #
+        if len(message.question) > 0:
+            if message.question[0].name != self.origin:
+                raise dns.exception.FormError("wrong question name")
+            if message.question[0].rdtype != self.rdtype:
+                raise dns.exception.FormError("wrong question rdatatype")
+        answer_index = 0
+        if self.soa_rdataset is None:
+            #
+            # This is the first message.  We're expecting an SOA at
+            # the origin.
+            #
+            if not message.answer or message.answer[0].name != self.origin:
+                raise dns.exception.FormError("No answer or RRset not for zone origin")
+            rrset = message.answer[0]
+            rdataset = rrset
+            if rdataset.rdtype != dns.rdatatype.SOA:
+                raise dns.exception.FormError("first RRset is not an SOA")
+            answer_index = 1
+            self.soa_rdataset = rdataset.copy()
+            if self.rdtype == dns.rdatatype.IXFR:
+                if self.soa_rdataset[0].serial == self.serial:
+                    #
+                    # We're already up-to-date.
+                    #
+                    self.done = True
+                elif dns.serial.Serial(self.soa_rdataset[0].serial) < self.serial:
+                    # It went backwards!
+                    raise SerialWentBackwards
+                else:
+                    if self.is_udp and len(message.answer[answer_index:]) == 0:
+                        #
+                        # There are no more records, so this is the
+                        # "truncated" response.  Say to use TCP
+                        #
+                        raise UseTCP
+                    #
+                    # Note we're expecting another SOA so we can detect
+                    # if this IXFR response is an AXFR-style response.
+                    #
+                    self.expecting_SOA = True
+        #
+        # Process the answer section (other than the initial SOA in
+        # the first message).
+        #
+        for rrset in message.answer[answer_index:]:
+            name = rrset.name
+            rdataset = rrset
+            if self.done:
+                raise dns.exception.FormError("answers after final SOA")
+            assert self.txn is not None  # for mypy
+            if rdataset.rdtype == dns.rdatatype.SOA and name == self.origin:
+                #
+                # Every time we see an origin SOA delete_mode inverts
+                #
+                if self.rdtype == dns.rdatatype.IXFR:
+                    self.delete_mode = not self.delete_mode
+                #
+                # If this SOA Rdataset is equal to the first we saw
+                # then we're finished. If this is an IXFR we also
+                # check that we're seeing the record in the expected
+                # part of the response.
+                #
+                if rdataset == self.soa_rdataset and (
+                    self.rdtype == dns.rdatatype.AXFR
+                    or (self.rdtype == dns.rdatatype.IXFR and self.delete_mode)
+                ):
+                    #
+                    # This is the final SOA
+                    #
+                    if self.expecting_SOA:
+                        # We got an empty IXFR sequence!
+                        raise dns.exception.FormError("empty IXFR sequence")
+                    if (
+                        self.rdtype == dns.rdatatype.IXFR
+                        and self.serial != rdataset[0].serial
+                    ):
+                        raise dns.exception.FormError("unexpected end of IXFR sequence")
+                    self.txn.replace(name, rdataset)
+                    self.txn.commit()
+                    self.txn = None
+                    self.done = True
+                else:
+                    #
+                    # This is not the final SOA
+                    #
+                    self.expecting_SOA = False
+                    if self.rdtype == dns.rdatatype.IXFR:
+                        if self.delete_mode:
+                            # This is the start of an IXFR deletion set
+                            if rdataset[0].serial != self.serial:
+                                raise dns.exception.FormError(
+                                    "IXFR base serial mismatch"
+                                )
+                        else:
+                            # This is the start of an IXFR addition set
+                            self.serial = rdataset[0].serial
+                            self.txn.replace(name, rdataset)
+                    else:
+                        # We saw a non-final SOA for the origin in an AXFR.
+                        raise dns.exception.FormError("unexpected origin SOA in AXFR")
+                continue
+            if self.expecting_SOA:
+                #
+                # We made an IXFR request and are expecting another
+                # SOA RR, but saw something else, so this must be an
+                # AXFR response.
+                #
+                self.rdtype = dns.rdatatype.AXFR
+                self.expecting_SOA = False
+                self.delete_mode = False
+                self.txn.rollback()
+                self.txn = self.txn_manager.writer(True)
+                #
+                # Note we are falling through into the code below
+                # so whatever rdataset this was gets written.
+                #
+            # Add or remove the data
+            if self.delete_mode:
+                self.txn.delete_exact(name, rdataset)
+            else:
+                self.txn.add(name, rdataset)
+        if self.is_udp and not self.done:
+            #
+            # This is a UDP IXFR and we didn't get to done, and we didn't
+            # get the proper "truncated" response
+            #
+            raise dns.exception.FormError("unexpected end of UDP IXFR")
+        return self.done
+
+    #
+    # Inbounds are context managers.
+    #
+
+    def __enter__(self):
+        return self
+
+    def __exit__(self, exc_type, exc_val, exc_tb):
+        if self.txn:
+            self.txn.rollback()
+        return False
+
+
+def make_query(
+    txn_manager: dns.transaction.TransactionManager,
+    serial: Optional[int] = 0,
+    use_edns: Optional[Union[int, bool]] = None,
+    ednsflags: Optional[int] = None,
+    payload: Optional[int] = None,
+    request_payload: Optional[int] = None,
+    options: Optional[List[dns.edns.Option]] = None,
+    keyring: Any = None,
+    keyname: Optional[dns.name.Name] = None,
+    keyalgorithm: Union[dns.name.Name, str] = dns.tsig.default_algorithm,
+) -> Tuple[dns.message.QueryMessage, Optional[int]]:
+    """Make an AXFR or IXFR query.
+
+    *txn_manager* is a ``dns.transaction.TransactionManager``, typically a
+    ``dns.zone.Zone``.
+
+    *serial* is an ``int`` or ``None``.  If 0, then IXFR will be
+    attempted using the most recent serial number from the
+    *txn_manager*; it is the caller's responsibility to ensure there
+    are no write transactions active that could invalidate the
+    retrieved serial.  If a serial cannot be determined, AXFR will be
+    forced.  Other integer values are the starting serial to use.
+    ``None`` forces an AXFR.
+
+    Please see the documentation for :py:func:`dns.message.make_query` and
+    :py:func:`dns.message.Message.use_tsig` for details on the other parameters
+    to this function.
+
+    Returns a `(query, serial)` tuple.
+    """
+    (zone_origin, _, origin) = txn_manager.origin_information()
+    if zone_origin is None:
+        raise ValueError("no zone origin")
+    if serial is None:
+        rdtype = dns.rdatatype.AXFR
+    elif not isinstance(serial, int):
+        raise ValueError("serial is not an integer")
+    elif serial == 0:
+        with txn_manager.reader() as txn:
+            rdataset = txn.get(origin, "SOA")
+            if rdataset:
+                serial = rdataset[0].serial
+                rdtype = dns.rdatatype.IXFR
+            else:
+                serial = None
+                rdtype = dns.rdatatype.AXFR
+    elif serial > 0 and serial < 4294967296:
+        rdtype = dns.rdatatype.IXFR
+    else:
+        raise ValueError("serial out-of-range")
+    rdclass = txn_manager.get_class()
+    q = dns.message.make_query(
+        zone_origin,
+        rdtype,
+        rdclass,
+        use_edns,
+        False,
+        ednsflags,
+        payload,
+        request_payload,
+        options,
+    )
+    if serial is not None:
+        rdata = dns.rdata.from_text(rdclass, "SOA", f". . {serial} 0 0 0 0")
+        rrset = q.find_rrset(
+            q.authority, zone_origin, rdclass, dns.rdatatype.SOA, create=True
+        )
+        rrset.add(rdata, 0)
+    if keyring is not None:
+        q.use_tsig(keyring, keyname, algorithm=keyalgorithm)
+    return (q, serial)
+
+
+def extract_serial_from_query(query: dns.message.Message) -> Optional[int]:
+    """Extract the SOA serial number from query if it is an IXFR and return
+    it, otherwise return None.
+
+    *query* is a dns.message.QueryMessage that is an IXFR or AXFR request.
+
+    Raises if the query is not an IXFR or AXFR, or if an IXFR doesn't have
+    an appropriate SOA RRset in the authority section.
+    """
+    if not isinstance(query, dns.message.QueryMessage):
+        raise ValueError("query not a QueryMessage")
+    question = query.question[0]
+    if question.rdtype == dns.rdatatype.AXFR:
+        return None
+    elif question.rdtype != dns.rdatatype.IXFR:
+        raise ValueError("query is not an AXFR or IXFR")
+    soa = query.find_rrset(
+        query.authority, question.name, question.rdclass, dns.rdatatype.SOA
+    )
+    return soa[0].serial
diff --git a/.venv/lib/python3.12/site-packages/dns/zone.py b/.venv/lib/python3.12/site-packages/dns/zone.py
new file mode 100644
index 00000000..844919e4
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/dns/zone.py
@@ -0,0 +1,1434 @@
+# Copyright (C) Dnspython Contributors, see LICENSE for text of ISC license
+
+# Copyright (C) 2003-2007, 2009-2011 Nominum, Inc.
+#
+# Permission to use, copy, modify, and distribute this software and its
+# documentation for any purpose with or without fee is hereby granted,
+# provided that the above copyright notice and this permission notice
+# appear in all copies.
+#
+# THE SOFTWARE IS PROVIDED "AS IS" AND NOMINUM DISCLAIMS ALL WARRANTIES
+# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL NOMINUM BE LIABLE FOR
+# ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+# ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT
+# OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+
+"""DNS Zones."""
+
+import contextlib
+import io
+import os
+import struct
+from typing import (
+    Any,
+    Callable,
+    Iterable,
+    Iterator,
+    List,
+    MutableMapping,
+    Optional,
+    Set,
+    Tuple,
+    Union,
+)
+
+import dns.exception
+import dns.grange
+import dns.immutable
+import dns.name
+import dns.node
+import dns.rdata
+import dns.rdataclass
+import dns.rdataset
+import dns.rdatatype
+import dns.rdtypes.ANY.SOA
+import dns.rdtypes.ANY.ZONEMD
+import dns.rrset
+import dns.tokenizer
+import dns.transaction
+import dns.ttl
+import dns.zonefile
+from dns.zonetypes import DigestHashAlgorithm, DigestScheme, _digest_hashers
+
+
+class BadZone(dns.exception.DNSException):
+    """The DNS zone is malformed."""
+
+
+class NoSOA(BadZone):
+    """The DNS zone has no SOA RR at its origin."""
+
+
+class NoNS(BadZone):
+    """The DNS zone has no NS RRset at its origin."""
+
+
+class UnknownOrigin(BadZone):
+    """The DNS zone's origin is unknown."""
+
+
+class UnsupportedDigestScheme(dns.exception.DNSException):
+    """The zone digest's scheme is unsupported."""
+
+
+class UnsupportedDigestHashAlgorithm(dns.exception.DNSException):
+    """The zone digest's origin is unsupported."""
+
+
+class NoDigest(dns.exception.DNSException):
+    """The DNS zone has no ZONEMD RRset at its origin."""
+
+
+class DigestVerificationFailure(dns.exception.DNSException):
+    """The ZONEMD digest failed to verify."""
+
+
+def _validate_name(
+    name: dns.name.Name,
+    origin: Optional[dns.name.Name],
+    relativize: bool,
+) -> dns.name.Name:
+    # This name validation code is shared by Zone and Version
+    if origin is None:
+        # This should probably never happen as other code (e.g.
+        # _rr_line) will notice the lack of an origin before us, but
+        # we check just in case!
+        raise KeyError("no zone origin is defined")
+    if name.is_absolute():
+        if not name.is_subdomain(origin):
+            raise KeyError("name parameter must be a subdomain of the zone origin")
+        if relativize:
+            name = name.relativize(origin)
+    else:
+        # We have a relative name.  Make sure that the derelativized name is
+        # not too long.
+        try:
+            abs_name = name.derelativize(origin)
+        except dns.name.NameTooLong:
+            # We map dns.name.NameTooLong to KeyError to be consistent with
+            # the other exceptions above.
+            raise KeyError("relative name too long for zone")
+        if not relativize:
+            # We have a relative name in a non-relative zone, so use the
+            # derelativized name.
+            name = abs_name
+    return name
+
+
+class Zone(dns.transaction.TransactionManager):
+    """A DNS zone.
+
+    A ``Zone`` is a mapping from names to nodes.  The zone object may be
+    treated like a Python dictionary, e.g. ``zone[name]`` will retrieve
+    the node associated with that name.  The *name* may be a
+    ``dns.name.Name object``, or it may be a string.  In either case,
+    if the name is relative it is treated as relative to the origin of
+    the zone.
+    """
+
+    node_factory: Callable[[], dns.node.Node] = dns.node.Node
+    map_factory: Callable[[], MutableMapping[dns.name.Name, dns.node.Node]] = dict
+    writable_version_factory: Optional[Callable[[], "WritableVersion"]] = None
+    immutable_version_factory: Optional[Callable[[], "ImmutableVersion"]] = None
+
+    __slots__ = ["rdclass", "origin", "nodes", "relativize"]
+
+    def __init__(
+        self,
+        origin: Optional[Union[dns.name.Name, str]],
+        rdclass: dns.rdataclass.RdataClass = dns.rdataclass.IN,
+        relativize: bool = True,
+    ):
+        """Initialize a zone object.
+
+        *origin* is the origin of the zone.  It may be a ``dns.name.Name``,
+        a ``str``, or ``None``.  If ``None``, then the zone's origin will
+        be set by the first ``$ORIGIN`` line in a zone file.
+
+        *rdclass*, an ``int``, the zone's rdata class; the default is class IN.
+
+        *relativize*, a ``bool``, determine's whether domain names are
+        relativized to the zone's origin.  The default is ``True``.
+        """
+
+        if origin is not None:
+            if isinstance(origin, str):
+                origin = dns.name.from_text(origin)
+            elif not isinstance(origin, dns.name.Name):
+                raise ValueError("origin parameter must be convertible to a DNS name")
+            if not origin.is_absolute():
+                raise ValueError("origin parameter must be an absolute name")
+        self.origin = origin
+        self.rdclass = rdclass
+        self.nodes: MutableMapping[dns.name.Name, dns.node.Node] = self.map_factory()
+        self.relativize = relativize
+
+    def __eq__(self, other):
+        """Two zones are equal if they have the same origin, class, and
+        nodes.
+
+        Returns a ``bool``.
+        """
+
+        if not isinstance(other, Zone):
+            return False
+        if (
+            self.rdclass != other.rdclass
+            or self.origin != other.origin
+            or self.nodes != other.nodes
+        ):
+            return False
+        return True
+
+    def __ne__(self, other):
+        """Are two zones not equal?
+
+        Returns a ``bool``.
+        """
+
+        return not self.__eq__(other)
+
+    def _validate_name(self, name: Union[dns.name.Name, str]) -> dns.name.Name:
+        # Note that any changes in this method should have corresponding changes
+        # made in the Version _validate_name() method.
+        if isinstance(name, str):
+            name = dns.name.from_text(name, None)
+        elif not isinstance(name, dns.name.Name):
+            raise KeyError("name parameter must be convertible to a DNS name")
+        return _validate_name(name, self.origin, self.relativize)
+
+    def __getitem__(self, key):
+        key = self._validate_name(key)
+        return self.nodes[key]
+
+    def __setitem__(self, key, value):
+        key = self._validate_name(key)
+        self.nodes[key] = value
+
+    def __delitem__(self, key):
+        key = self._validate_name(key)
+        del self.nodes[key]
+
+    def __iter__(self):
+        return self.nodes.__iter__()
+
+    def keys(self):
+        return self.nodes.keys()
+
+    def values(self):
+        return self.nodes.values()
+
+    def items(self):
+        return self.nodes.items()
+
+    def get(self, key):
+        key = self._validate_name(key)
+        return self.nodes.get(key)
+
+    def __contains__(self, key):
+        key = self._validate_name(key)
+        return key in self.nodes
+
+    def find_node(
+        self, name: Union[dns.name.Name, str], create: bool = False
+    ) -> dns.node.Node:
+        """Find a node in the zone, possibly creating it.
+
+        *name*: the name of the node to find.
+        The value may be a ``dns.name.Name`` or a ``str``.  If absolute, the
+        name must be a subdomain of the zone's origin.  If ``zone.relativize``
+        is ``True``, then the name will be relativized.
+
+        *create*, a ``bool``.  If true, the node will be created if it does
+        not exist.
+
+        Raises ``KeyError`` if the name is not known and create was
+        not specified, or if the name was not a subdomain of the origin.
+
+        Returns a ``dns.node.Node``.
+        """
+
+        name = self._validate_name(name)
+        node = self.nodes.get(name)
+        if node is None:
+            if not create:
+                raise KeyError
+            node = self.node_factory()
+            self.nodes[name] = node
+        return node
+
+    def get_node(
+        self, name: Union[dns.name.Name, str], create: bool = False
+    ) -> Optional[dns.node.Node]:
+        """Get a node in the zone, possibly creating it.
+
+        This method is like ``find_node()``, except it returns None instead
+        of raising an exception if the node does not exist and creation
+        has not been requested.
+
+        *name*: the name of the node to find.
+        The value may be a ``dns.name.Name`` or a ``str``.  If absolute, the
+        name must be a subdomain of the zone's origin.  If ``zone.relativize``
+        is ``True``, then the name will be relativized.
+
+        *create*, a ``bool``.  If true, the node will be created if it does
+        not exist.
+
+        Returns a ``dns.node.Node`` or ``None``.
+        """
+
+        try:
+            node = self.find_node(name, create)
+        except KeyError:
+            node = None
+        return node
+
+    def delete_node(self, name: Union[dns.name.Name, str]) -> None:
+        """Delete the specified node if it exists.
+
+        *name*: the name of the node to find.
+        The value may be a ``dns.name.Name`` or a ``str``.  If absolute, the
+        name must be a subdomain of the zone's origin.  If ``zone.relativize``
+        is ``True``, then the name will be relativized.
+
+        It is not an error if the node does not exist.
+        """
+
+        name = self._validate_name(name)
+        if name in self.nodes:
+            del self.nodes[name]
+
+    def find_rdataset(
+        self,
+        name: Union[dns.name.Name, str],
+        rdtype: Union[dns.rdatatype.RdataType, str],
+        covers: Union[dns.rdatatype.RdataType, str] = dns.rdatatype.NONE,
+        create: bool = False,
+    ) -> dns.rdataset.Rdataset:
+        """Look for an rdataset with the specified name and type in the zone,
+        and return an rdataset encapsulating it.
+
+        The rdataset returned is not a copy; changes to it will change
+        the zone.
+
+        KeyError is raised if the name or type are not found.
+
+        *name*: the name of the node to find.
+        The value may be a ``dns.name.Name`` or a ``str``.  If absolute, the
+        name must be a subdomain of the zone's origin.  If ``zone.relativize``
+        is ``True``, then the name will be relativized.
+
+        *rdtype*, a ``dns.rdatatype.RdataType`` or ``str``, the rdata type desired.
+
+        *covers*, a ``dns.rdatatype.RdataType`` or ``str`` the covered type.
+        Usually this value is ``dns.rdatatype.NONE``, but if the
+        rdtype is ``dns.rdatatype.SIG`` or ``dns.rdatatype.RRSIG``,
+        then the covers value will be the rdata type the SIG/RRSIG
+        covers.  The library treats the SIG and RRSIG types as if they
+        were a family of types, e.g. RRSIG(A), RRSIG(NS), RRSIG(SOA).
+        This makes RRSIGs much easier to work with than if RRSIGs
+        covering different rdata types were aggregated into a single
+        RRSIG rdataset.
+
+        *create*, a ``bool``.  If true, the node will be created if it does
+        not exist.
+
+        Raises ``KeyError`` if the name is not known and create was
+        not specified, or if the name was not a subdomain of the origin.
+
+        Returns a ``dns.rdataset.Rdataset``.
+        """
+
+        name = self._validate_name(name)
+        rdtype = dns.rdatatype.RdataType.make(rdtype)
+        covers = dns.rdatatype.RdataType.make(covers)
+        node = self.find_node(name, create)
+        return node.find_rdataset(self.rdclass, rdtype, covers, create)
+
+    def get_rdataset(
+        self,
+        name: Union[dns.name.Name, str],
+        rdtype: Union[dns.rdatatype.RdataType, str],
+        covers: Union[dns.rdatatype.RdataType, str] = dns.rdatatype.NONE,
+        create: bool = False,
+    ) -> Optional[dns.rdataset.Rdataset]:
+        """Look for an rdataset with the specified name and type in the zone.
+
+        This method is like ``find_rdataset()``, except it returns None instead
+        of raising an exception if the rdataset does not exist and creation
+        has not been requested.
+
+        The rdataset returned is not a copy; changes to it will change
+        the zone.
+
+        *name*: the name of the node to find.
+        The value may be a ``dns.name.Name`` or a ``str``.  If absolute, the
+        name must be a subdomain of the zone's origin.  If ``zone.relativize``
+        is ``True``, then the name will be relativized.
+
+        *rdtype*, a ``dns.rdatatype.RdataType`` or ``str``, the rdata type desired.
+
+        *covers*, a ``dns.rdatatype.RdataType`` or ``str``, the covered type.
+        Usually this value is ``dns.rdatatype.NONE``, but if the
+        rdtype is ``dns.rdatatype.SIG`` or ``dns.rdatatype.RRSIG``,
+        then the covers value will be the rdata type the SIG/RRSIG
+        covers.  The library treats the SIG and RRSIG types as if they
+        were a family of types, e.g. RRSIG(A), RRSIG(NS), RRSIG(SOA).
+        This makes RRSIGs much easier to work with than if RRSIGs
+        covering different rdata types were aggregated into a single
+        RRSIG rdataset.
+
+        *create*, a ``bool``.  If true, the node will be created if it does
+        not exist.
+
+        Raises ``KeyError`` if the name is not known and create was
+        not specified, or if the name was not a subdomain of the origin.
+
+        Returns a ``dns.rdataset.Rdataset`` or ``None``.
+        """
+
+        try:
+            rdataset = self.find_rdataset(name, rdtype, covers, create)
+        except KeyError:
+            rdataset = None
+        return rdataset
+
+    def delete_rdataset(
+        self,
+        name: Union[dns.name.Name, str],
+        rdtype: Union[dns.rdatatype.RdataType, str],
+        covers: Union[dns.rdatatype.RdataType, str] = dns.rdatatype.NONE,
+    ) -> None:
+        """Delete the rdataset matching *rdtype* and *covers*, if it
+        exists at the node specified by *name*.
+
+        It is not an error if the node does not exist, or if there is no matching
+        rdataset at the node.
+
+        If the node has no rdatasets after the deletion, it will itself be deleted.
+
+        *name*: the name of the node to find. The value may be a ``dns.name.Name`` or a
+        ``str``.  If absolute, the name must be a subdomain of the zone's origin.  If
+        ``zone.relativize`` is ``True``, then the name will be relativized.
+
+        *rdtype*, a ``dns.rdatatype.RdataType`` or ``str``, the rdata type desired.
+
+        *covers*, a ``dns.rdatatype.RdataType`` or ``str`` or ``None``, the covered
+        type. Usually this value is ``dns.rdatatype.NONE``, but if the rdtype is
+        ``dns.rdatatype.SIG`` or ``dns.rdatatype.RRSIG``, then the covers value will be
+        the rdata type the SIG/RRSIG covers.  The library treats the SIG and RRSIG types
+        as if they were a family of types, e.g. RRSIG(A), RRSIG(NS), RRSIG(SOA). This
+        makes RRSIGs much easier to work with than if RRSIGs covering different rdata
+        types were aggregated into a single RRSIG rdataset.
+        """
+
+        name = self._validate_name(name)
+        rdtype = dns.rdatatype.RdataType.make(rdtype)
+        covers = dns.rdatatype.RdataType.make(covers)
+        node = self.get_node(name)
+        if node is not None:
+            node.delete_rdataset(self.rdclass, rdtype, covers)
+            if len(node) == 0:
+                self.delete_node(name)
+
+    def replace_rdataset(
+        self, name: Union[dns.name.Name, str], replacement: dns.rdataset.Rdataset
+    ) -> None:
+        """Replace an rdataset at name.
+
+        It is not an error if there is no rdataset matching I{replacement}.
+
+        Ownership of the *replacement* object is transferred to the zone;
+        in other words, this method does not store a copy of *replacement*
+        at the node, it stores *replacement* itself.
+
+        If the node does not exist, it is created.
+
+        *name*: the name of the node to find.
+        The value may be a ``dns.name.Name`` or a ``str``.  If absolute, the
+        name must be a subdomain of the zone's origin.  If ``zone.relativize``
+        is ``True``, then the name will be relativized.
+
+        *replacement*, a ``dns.rdataset.Rdataset``, the replacement rdataset.
+        """
+
+        if replacement.rdclass != self.rdclass:
+            raise ValueError("replacement.rdclass != zone.rdclass")
+        node = self.find_node(name, True)
+        node.replace_rdataset(replacement)
+
+    def find_rrset(
+        self,
+        name: Union[dns.name.Name, str],
+        rdtype: Union[dns.rdatatype.RdataType, str],
+        covers: Union[dns.rdatatype.RdataType, str] = dns.rdatatype.NONE,
+    ) -> dns.rrset.RRset:
+        """Look for an rdataset with the specified name and type in the zone,
+        and return an RRset encapsulating it.
+
+        This method is less efficient than the similar
+        ``find_rdataset()`` because it creates an RRset instead of
+        returning the matching rdataset.  It may be more convenient
+        for some uses since it returns an object which binds the owner
+        name to the rdataset.
+
+        This method may not be used to create new nodes or rdatasets;
+        use ``find_rdataset`` instead.
+
+        *name*: the name of the node to find.
+        The value may be a ``dns.name.Name`` or a ``str``.  If absolute, the
+        name must be a subdomain of the zone's origin.  If ``zone.relativize``
+        is ``True``, then the name will be relativized.
+
+        *rdtype*, a ``dns.rdatatype.RdataType`` or ``str``, the rdata type desired.
+
+        *covers*, a ``dns.rdatatype.RdataType`` or ``str``, the covered type.
+        Usually this value is ``dns.rdatatype.NONE``, but if the
+        rdtype is ``dns.rdatatype.SIG`` or ``dns.rdatatype.RRSIG``,
+        then the covers value will be the rdata type the SIG/RRSIG
+        covers.  The library treats the SIG and RRSIG types as if they
+        were a family of types, e.g. RRSIG(A), RRSIG(NS), RRSIG(SOA).
+        This makes RRSIGs much easier to work with than if RRSIGs
+        covering different rdata types were aggregated into a single
+        RRSIG rdataset.
+
+        *create*, a ``bool``.  If true, the node will be created if it does
+        not exist.
+
+        Raises ``KeyError`` if the name is not known and create was
+        not specified, or if the name was not a subdomain of the origin.
+
+        Returns a ``dns.rrset.RRset`` or ``None``.
+        """
+
+        vname = self._validate_name(name)
+        rdtype = dns.rdatatype.RdataType.make(rdtype)
+        covers = dns.rdatatype.RdataType.make(covers)
+        rdataset = self.nodes[vname].find_rdataset(self.rdclass, rdtype, covers)
+        rrset = dns.rrset.RRset(vname, self.rdclass, rdtype, covers)
+        rrset.update(rdataset)
+        return rrset
+
+    def get_rrset(
+        self,
+        name: Union[dns.name.Name, str],
+        rdtype: Union[dns.rdatatype.RdataType, str],
+        covers: Union[dns.rdatatype.RdataType, str] = dns.rdatatype.NONE,
+    ) -> Optional[dns.rrset.RRset]:
+        """Look for an rdataset with the specified name and type in the zone,
+        and return an RRset encapsulating it.
+
+        This method is less efficient than the similar ``get_rdataset()``
+        because it creates an RRset instead of returning the matching
+        rdataset.  It may be more convenient for some uses since it
+        returns an object which binds the owner name to the rdataset.
+
+        This method may not be used to create new nodes or rdatasets;
+        use ``get_rdataset()`` instead.
+
+        *name*: the name of the node to find.
+        The value may be a ``dns.name.Name`` or a ``str``.  If absolute, the
+        name must be a subdomain of the zone's origin.  If ``zone.relativize``
+        is ``True``, then the name will be relativized.
+
+        *rdtype*, a ``dns.rdataset.Rdataset`` or ``str``, the rdata type desired.
+
+        *covers*, a ``dns.rdataset.Rdataset`` or ``str``, the covered type.
+        Usually this value is ``dns.rdatatype.NONE``, but if the
+        rdtype is ``dns.rdatatype.SIG`` or ``dns.rdatatype.RRSIG``,
+        then the covers value will be the rdata type the SIG/RRSIG
+        covers.  The library treats the SIG and RRSIG types as if they
+        were a family of types, e.g. RRSIG(A), RRSIG(NS), RRSIG(SOA).
+        This makes RRSIGs much easier to work with than if RRSIGs
+        covering different rdata types were aggregated into a single
+        RRSIG rdataset.
+
+        *create*, a ``bool``.  If true, the node will be created if it does
+        not exist.
+
+        Returns a ``dns.rrset.RRset`` or ``None``.
+        """
+
+        try:
+            rrset = self.find_rrset(name, rdtype, covers)
+        except KeyError:
+            rrset = None
+        return rrset
+
+    def iterate_rdatasets(
+        self,
+        rdtype: Union[dns.rdatatype.RdataType, str] = dns.rdatatype.ANY,
+        covers: Union[dns.rdatatype.RdataType, str] = dns.rdatatype.NONE,
+    ) -> Iterator[Tuple[dns.name.Name, dns.rdataset.Rdataset]]:
+        """Return a generator which yields (name, rdataset) tuples for
+        all rdatasets in the zone which have the specified *rdtype*
+        and *covers*.  If *rdtype* is ``dns.rdatatype.ANY``, the default,
+        then all rdatasets will be matched.
+
+        *rdtype*, a ``dns.rdataset.Rdataset`` or ``str``, the rdata type desired.
+
+        *covers*, a ``dns.rdataset.Rdataset`` or ``str``, the covered type.
+        Usually this value is ``dns.rdatatype.NONE``, but if the
+        rdtype is ``dns.rdatatype.SIG`` or ``dns.rdatatype.RRSIG``,
+        then the covers value will be the rdata type the SIG/RRSIG
+        covers.  The library treats the SIG and RRSIG types as if they
+        were a family of types, e.g. RRSIG(A), RRSIG(NS), RRSIG(SOA).
+        This makes RRSIGs much easier to work with than if RRSIGs
+        covering different rdata types were aggregated into a single
+        RRSIG rdataset.
+        """
+
+        rdtype = dns.rdatatype.RdataType.make(rdtype)
+        covers = dns.rdatatype.RdataType.make(covers)
+        for name, node in self.items():
+            for rds in node:
+                if rdtype == dns.rdatatype.ANY or (
+                    rds.rdtype == rdtype and rds.covers == covers
+                ):
+                    yield (name, rds)
+
+    def iterate_rdatas(
+        self,
+        rdtype: Union[dns.rdatatype.RdataType, str] = dns.rdatatype.ANY,
+        covers: Union[dns.rdatatype.RdataType, str] = dns.rdatatype.NONE,
+    ) -> Iterator[Tuple[dns.name.Name, int, dns.rdata.Rdata]]:
+        """Return a generator which yields (name, ttl, rdata) tuples for
+        all rdatas in the zone which have the specified *rdtype*
+        and *covers*.  If *rdtype* is ``dns.rdatatype.ANY``, the default,
+        then all rdatas will be matched.
+
+        *rdtype*, a ``dns.rdataset.Rdataset`` or ``str``, the rdata type desired.
+
+        *covers*, a ``dns.rdataset.Rdataset`` or ``str``, the covered type.
+        Usually this value is ``dns.rdatatype.NONE``, but if the
+        rdtype is ``dns.rdatatype.SIG`` or ``dns.rdatatype.RRSIG``,
+        then the covers value will be the rdata type the SIG/RRSIG
+        covers.  The library treats the SIG and RRSIG types as if they
+        were a family of types, e.g. RRSIG(A), RRSIG(NS), RRSIG(SOA).
+        This makes RRSIGs much easier to work with than if RRSIGs
+        covering different rdata types were aggregated into a single
+        RRSIG rdataset.
+        """
+
+        rdtype = dns.rdatatype.RdataType.make(rdtype)
+        covers = dns.rdatatype.RdataType.make(covers)
+        for name, node in self.items():
+            for rds in node:
+                if rdtype == dns.rdatatype.ANY or (
+                    rds.rdtype == rdtype and rds.covers == covers
+                ):
+                    for rdata in rds:
+                        yield (name, rds.ttl, rdata)
+
+    def to_file(
+        self,
+        f: Any,
+        sorted: bool = True,
+        relativize: bool = True,
+        nl: Optional[str] = None,
+        want_comments: bool = False,
+        want_origin: bool = False,
+    ) -> None:
+        """Write a zone to a file.
+
+        *f*, a file or `str`.  If *f* is a string, it is treated
+        as the name of a file to open.
+
+        *sorted*, a ``bool``.  If True, the default, then the file
+        will be written with the names sorted in DNSSEC order from
+        least to greatest.  Otherwise the names will be written in
+        whatever order they happen to have in the zone's dictionary.
+
+        *relativize*, a ``bool``.  If True, the default, then domain
+        names in the output will be relativized to the zone's origin
+        if possible.
+
+        *nl*, a ``str`` or None.  The end of line string.  If not
+        ``None``, the output will use the platform's native
+        end-of-line marker (i.e. LF on POSIX, CRLF on Windows).
+
+        *want_comments*, a ``bool``.  If ``True``, emit end-of-line comments
+        as part of writing the file.  If ``False``, the default, do not
+        emit them.
+
+        *want_origin*, a ``bool``.  If ``True``, emit a $ORIGIN line at
+        the start of the file.  If ``False``, the default, do not emit
+        one.
+        """
+
+        if isinstance(f, str):
+            cm: contextlib.AbstractContextManager = open(f, "wb")
+        else:
+            cm = contextlib.nullcontext(f)
+        with cm as f:
+            # must be in this way, f.encoding may contain None, or even
+            # attribute may not be there
+            file_enc = getattr(f, "encoding", None)
+            if file_enc is None:
+                file_enc = "utf-8"
+
+            if nl is None:
+                # binary mode, '\n' is not enough
+                nl_b = os.linesep.encode(file_enc)
+                nl = "\n"
+            elif isinstance(nl, str):
+                nl_b = nl.encode(file_enc)
+            else:
+                nl_b = nl
+                nl = nl.decode()
+
+            if want_origin:
+                assert self.origin is not None
+                l = "$ORIGIN " + self.origin.to_text()
+                l_b = l.encode(file_enc)
+                try:
+                    f.write(l_b)
+                    f.write(nl_b)
+                except TypeError:  # textual mode
+                    f.write(l)
+                    f.write(nl)
+
+            if sorted:
+                names = list(self.keys())
+                names.sort()
+            else:
+                names = self.keys()
+            for n in names:
+                l = self[n].to_text(
+                    n,
+                    origin=self.origin,
+                    relativize=relativize,
+                    want_comments=want_comments,
+                )
+                l_b = l.encode(file_enc)
+
+                try:
+                    f.write(l_b)
+                    f.write(nl_b)
+                except TypeError:  # textual mode
+                    f.write(l)
+                    f.write(nl)
+
+    def to_text(
+        self,
+        sorted: bool = True,
+        relativize: bool = True,
+        nl: Optional[str] = None,
+        want_comments: bool = False,
+        want_origin: bool = False,
+    ) -> str:
+        """Return a zone's text as though it were written to a file.
+
+        *sorted*, a ``bool``.  If True, the default, then the file
+        will be written with the names sorted in DNSSEC order from
+        least to greatest.  Otherwise the names will be written in
+        whatever order they happen to have in the zone's dictionary.
+
+        *relativize*, a ``bool``.  If True, the default, then domain
+        names in the output will be relativized to the zone's origin
+        if possible.
+
+        *nl*, a ``str`` or None.  The end of line string.  If not
+        ``None``, the output will use the platform's native
+        end-of-line marker (i.e. LF on POSIX, CRLF on Windows).
+
+        *want_comments*, a ``bool``.  If ``True``, emit end-of-line comments
+        as part of writing the file.  If ``False``, the default, do not
+        emit them.
+
+        *want_origin*, a ``bool``.  If ``True``, emit a $ORIGIN line at
+        the start of the output.  If ``False``, the default, do not emit
+        one.
+
+        Returns a ``str``.
+        """
+        temp_buffer = io.StringIO()
+        self.to_file(temp_buffer, sorted, relativize, nl, want_comments, want_origin)
+        return_value = temp_buffer.getvalue()
+        temp_buffer.close()
+        return return_value
+
+    def check_origin(self) -> None:
+        """Do some simple checking of the zone's origin.
+
+        Raises ``dns.zone.NoSOA`` if there is no SOA RRset.
+
+        Raises ``dns.zone.NoNS`` if there is no NS RRset.
+
+        Raises ``KeyError`` if there is no origin node.
+        """
+        if self.relativize:
+            name = dns.name.empty
+        else:
+            assert self.origin is not None
+            name = self.origin
+        if self.get_rdataset(name, dns.rdatatype.SOA) is None:
+            raise NoSOA
+        if self.get_rdataset(name, dns.rdatatype.NS) is None:
+            raise NoNS
+
+    def get_soa(
+        self, txn: Optional[dns.transaction.Transaction] = None
+    ) -> dns.rdtypes.ANY.SOA.SOA:
+        """Get the zone SOA rdata.
+
+        Raises ``dns.zone.NoSOA`` if there is no SOA RRset.
+
+        Returns a ``dns.rdtypes.ANY.SOA.SOA`` Rdata.
+        """
+        if self.relativize:
+            origin_name = dns.name.empty
+        else:
+            if self.origin is None:
+                # get_soa() has been called very early, and there must not be
+                # an SOA if there is no origin.
+                raise NoSOA
+            origin_name = self.origin
+        soa: Optional[dns.rdataset.Rdataset]
+        if txn:
+            soa = txn.get(origin_name, dns.rdatatype.SOA)
+        else:
+            soa = self.get_rdataset(origin_name, dns.rdatatype.SOA)
+        if soa is None:
+            raise NoSOA
+        return soa[0]
+
+    def _compute_digest(
+        self,
+        hash_algorithm: DigestHashAlgorithm,
+        scheme: DigestScheme = DigestScheme.SIMPLE,
+    ) -> bytes:
+        hashinfo = _digest_hashers.get(hash_algorithm)
+        if not hashinfo:
+            raise UnsupportedDigestHashAlgorithm
+        if scheme != DigestScheme.SIMPLE:
+            raise UnsupportedDigestScheme
+
+        if self.relativize:
+            origin_name = dns.name.empty
+        else:
+            assert self.origin is not None
+            origin_name = self.origin
+        hasher = hashinfo()
+        for name, node in sorted(self.items()):
+            rrnamebuf = name.to_digestable(self.origin)
+            for rdataset in sorted(node, key=lambda rds: (rds.rdtype, rds.covers)):
+                if name == origin_name and dns.rdatatype.ZONEMD in (
+                    rdataset.rdtype,
+                    rdataset.covers,
+                ):
+                    continue
+                rrfixed = struct.pack(
+                    "!HHI", rdataset.rdtype, rdataset.rdclass, rdataset.ttl
+                )
+                rdatas = [rdata.to_digestable(self.origin) for rdata in rdataset]
+                for rdata in sorted(rdatas):
+                    rrlen = struct.pack("!H", len(rdata))
+                    hasher.update(rrnamebuf + rrfixed + rrlen + rdata)
+        return hasher.digest()
+
+    def compute_digest(
+        self,
+        hash_algorithm: DigestHashAlgorithm,
+        scheme: DigestScheme = DigestScheme.SIMPLE,
+    ) -> dns.rdtypes.ANY.ZONEMD.ZONEMD:
+        serial = self.get_soa().serial
+        digest = self._compute_digest(hash_algorithm, scheme)
+        return dns.rdtypes.ANY.ZONEMD.ZONEMD(
+            self.rdclass, dns.rdatatype.ZONEMD, serial, scheme, hash_algorithm, digest
+        )
+
+    def verify_digest(
+        self, zonemd: Optional[dns.rdtypes.ANY.ZONEMD.ZONEMD] = None
+    ) -> None:
+        digests: Union[dns.rdataset.Rdataset, List[dns.rdtypes.ANY.ZONEMD.ZONEMD]]
+        if zonemd:
+            digests = [zonemd]
+        else:
+            assert self.origin is not None
+            rds = self.get_rdataset(self.origin, dns.rdatatype.ZONEMD)
+            if rds is None:
+                raise NoDigest
+            digests = rds
+        for digest in digests:
+            try:
+                computed = self._compute_digest(digest.hash_algorithm, digest.scheme)
+                if computed == digest.digest:
+                    return
+            except Exception:
+                pass
+        raise DigestVerificationFailure
+
+    # TransactionManager methods
+
+    def reader(self) -> "Transaction":
+        return Transaction(self, False, Version(self, 1, self.nodes, self.origin))
+
+    def writer(self, replacement: bool = False) -> "Transaction":
+        txn = Transaction(self, replacement)
+        txn._setup_version()
+        return txn
+
+    def origin_information(
+        self,
+    ) -> Tuple[Optional[dns.name.Name], bool, Optional[dns.name.Name]]:
+        effective: Optional[dns.name.Name]
+        if self.relativize:
+            effective = dns.name.empty
+        else:
+            effective = self.origin
+        return (self.origin, self.relativize, effective)
+
+    def get_class(self):
+        return self.rdclass
+
+    # Transaction methods
+
+    def _end_read(self, txn):
+        pass
+
+    def _end_write(self, txn):
+        pass
+
+    def _commit_version(self, _, version, origin):
+        self.nodes = version.nodes
+        if self.origin is None:
+            self.origin = origin
+
+    def _get_next_version_id(self):
+        # Versions are ephemeral and all have id 1
+        return 1
+
+
+# These classes used to be in dns.versioned, but have moved here so we can use
+# the copy-on-write transaction mechanism for both kinds of zones.  In a
+# regular zone, the version only exists during the transaction, and the nodes
+# are regular dns.node.Nodes.
+
+# A node with a version id.
+
+
+class VersionedNode(dns.node.Node):  # lgtm[py/missing-equals]
+    __slots__ = ["id"]
+
+    def __init__(self):
+        super().__init__()
+        # A proper id will get set by the Version
+        self.id = 0
+
+
+@dns.immutable.immutable
+class ImmutableVersionedNode(VersionedNode):
+    def __init__(self, node):
+        super().__init__()
+        self.id = node.id
+        self.rdatasets = tuple(
+            [dns.rdataset.ImmutableRdataset(rds) for rds in node.rdatasets]
+        )
+
+    def find_rdataset(
+        self,
+        rdclass: dns.rdataclass.RdataClass,
+        rdtype: dns.rdatatype.RdataType,
+        covers: dns.rdatatype.RdataType = dns.rdatatype.NONE,
+        create: bool = False,
+    ) -> dns.rdataset.Rdataset:
+        if create:
+            raise TypeError("immutable")
+        return super().find_rdataset(rdclass, rdtype, covers, False)
+
+    def get_rdataset(
+        self,
+        rdclass: dns.rdataclass.RdataClass,
+        rdtype: dns.rdatatype.RdataType,
+        covers: dns.rdatatype.RdataType = dns.rdatatype.NONE,
+        create: bool = False,
+    ) -> Optional[dns.rdataset.Rdataset]:
+        if create:
+            raise TypeError("immutable")
+        return super().get_rdataset(rdclass, rdtype, covers, False)
+
+    def delete_rdataset(
+        self,
+        rdclass: dns.rdataclass.RdataClass,
+        rdtype: dns.rdatatype.RdataType,
+        covers: dns.rdatatype.RdataType = dns.rdatatype.NONE,
+    ) -> None:
+        raise TypeError("immutable")
+
+    def replace_rdataset(self, replacement: dns.rdataset.Rdataset) -> None:
+        raise TypeError("immutable")
+
+    def is_immutable(self) -> bool:
+        return True
+
+
+class Version:
+    def __init__(
+        self,
+        zone: Zone,
+        id: int,
+        nodes: Optional[MutableMapping[dns.name.Name, dns.node.Node]] = None,
+        origin: Optional[dns.name.Name] = None,
+    ):
+        self.zone = zone
+        self.id = id
+        if nodes is not None:
+            self.nodes = nodes
+        else:
+            self.nodes = zone.map_factory()
+        self.origin = origin
+
+    def _validate_name(self, name: dns.name.Name) -> dns.name.Name:
+        return _validate_name(name, self.origin, self.zone.relativize)
+
+    def get_node(self, name: dns.name.Name) -> Optional[dns.node.Node]:
+        name = self._validate_name(name)
+        return self.nodes.get(name)
+
+    def get_rdataset(
+        self,
+        name: dns.name.Name,
+        rdtype: dns.rdatatype.RdataType,
+        covers: dns.rdatatype.RdataType,
+    ) -> Optional[dns.rdataset.Rdataset]:
+        node = self.get_node(name)
+        if node is None:
+            return None
+        return node.get_rdataset(self.zone.rdclass, rdtype, covers)
+
+    def keys(self):
+        return self.nodes.keys()
+
+    def items(self):
+        return self.nodes.items()
+
+
+class WritableVersion(Version):
+    def __init__(self, zone: Zone, replacement: bool = False):
+        # The zone._versions_lock must be held by our caller in a versioned
+        # zone.
+        id = zone._get_next_version_id()
+        super().__init__(zone, id)
+        if not replacement:
+            # We copy the map, because that gives us a simple and thread-safe
+            # way of doing versions, and we have a garbage collector to help
+            # us.  We only make new node objects if we actually change the
+            # node.
+            self.nodes.update(zone.nodes)
+        # We have to copy the zone origin as it may be None in the first
+        # version, and we don't want to mutate the zone until we commit.
+        self.origin = zone.origin
+        self.changed: Set[dns.name.Name] = set()
+
+    def _maybe_cow(self, name: dns.name.Name) -> dns.node.Node:
+        name = self._validate_name(name)
+        node = self.nodes.get(name)
+        if node is None or name not in self.changed:
+            new_node = self.zone.node_factory()
+            if hasattr(new_node, "id"):
+                # We keep doing this for backwards compatibility, as earlier
+                # code used new_node.id != self.id for the "do we need to CoW?"
+                # test.  Now we use the changed set as this works with both
+                # regular zones and versioned zones.
+                #
+                # We ignore the mypy error as this is safe but it doesn't see it.
+                new_node.id = self.id  # type: ignore
+            if node is not None:
+                # moo!  copy on write!
+                new_node.rdatasets.extend(node.rdatasets)
+            self.nodes[name] = new_node
+            self.changed.add(name)
+            return new_node
+        else:
+            return node
+
+    def delete_node(self, name: dns.name.Name) -> None:
+        name = self._validate_name(name)
+        if name in self.nodes:
+            del self.nodes[name]
+            self.changed.add(name)
+
+    def put_rdataset(
+        self, name: dns.name.Name, rdataset: dns.rdataset.Rdataset
+    ) -> None:
+        node = self._maybe_cow(name)
+        node.replace_rdataset(rdataset)
+
+    def delete_rdataset(
+        self,
+        name: dns.name.Name,
+        rdtype: dns.rdatatype.RdataType,
+        covers: dns.rdatatype.RdataType,
+    ) -> None:
+        node = self._maybe_cow(name)
+        node.delete_rdataset(self.zone.rdclass, rdtype, covers)
+        if len(node) == 0:
+            del self.nodes[name]
+
+
+@dns.immutable.immutable
+class ImmutableVersion(Version):
+    def __init__(self, version: WritableVersion):
+        # We tell super() that it's a replacement as we don't want it
+        # to copy the nodes, as we're about to do that with an
+        # immutable Dict.
+        super().__init__(version.zone, True)
+        # set the right id!
+        self.id = version.id
+        # keep the origin
+        self.origin = version.origin
+        # Make changed nodes immutable
+        for name in version.changed:
+            node = version.nodes.get(name)
+            # it might not exist if we deleted it in the version
+            if node:
+                version.nodes[name] = ImmutableVersionedNode(node)
+        # We're changing the type of the nodes dictionary here on purpose, so
+        # we ignore the mypy error.
+        self.nodes = dns.immutable.Dict(
+            version.nodes, True, self.zone.map_factory
+        )  # type: ignore
+
+
+class Transaction(dns.transaction.Transaction):
+    def __init__(self, zone, replacement, version=None, make_immutable=False):
+        read_only = version is not None
+        super().__init__(zone, replacement, read_only)
+        self.version = version
+        self.make_immutable = make_immutable
+
+    @property
+    def zone(self):
+        return self.manager
+
+    def _setup_version(self):
+        assert self.version is None
+        factory = self.manager.writable_version_factory
+        if factory is None:
+            factory = WritableVersion
+        self.version = factory(self.zone, self.replacement)
+
+    def _get_rdataset(self, name, rdtype, covers):
+        return self.version.get_rdataset(name, rdtype, covers)
+
+    def _put_rdataset(self, name, rdataset):
+        assert not self.read_only
+        self.version.put_rdataset(name, rdataset)
+
+    def _delete_name(self, name):
+        assert not self.read_only
+        self.version.delete_node(name)
+
+    def _delete_rdataset(self, name, rdtype, covers):
+        assert not self.read_only
+        self.version.delete_rdataset(name, rdtype, covers)
+
+    def _name_exists(self, name):
+        return self.version.get_node(name) is not None
+
+    def _changed(self):
+        if self.read_only:
+            return False
+        else:
+            return len(self.version.changed) > 0
+
+    def _end_transaction(self, commit):
+        if self.read_only:
+            self.zone._end_read(self)
+        elif commit and len(self.version.changed) > 0:
+            if self.make_immutable:
+                factory = self.manager.immutable_version_factory
+                if factory is None:
+                    factory = ImmutableVersion
+                version = factory(self.version)
+            else:
+                version = self.version
+            self.zone._commit_version(self, version, self.version.origin)
+        else:
+            # rollback
+            self.zone._end_write(self)
+
+    def _set_origin(self, origin):
+        if self.version.origin is None:
+            self.version.origin = origin
+
+    def _iterate_rdatasets(self):
+        for name, node in self.version.items():
+            for rdataset in node:
+                yield (name, rdataset)
+
+    def _iterate_names(self):
+        return self.version.keys()
+
+    def _get_node(self, name):
+        return self.version.get_node(name)
+
+    def _origin_information(self):
+        (absolute, relativize, effective) = self.manager.origin_information()
+        if absolute is None and self.version.origin is not None:
+            # No origin has been committed yet, but we've learned one as part of
+            # this txn.  Use it.
+            absolute = self.version.origin
+            if relativize:
+                effective = dns.name.empty
+            else:
+                effective = absolute
+        return (absolute, relativize, effective)
+
+
+def _from_text(
+    text: Any,
+    origin: Optional[Union[dns.name.Name, str]] = None,
+    rdclass: dns.rdataclass.RdataClass = dns.rdataclass.IN,
+    relativize: bool = True,
+    zone_factory: Any = Zone,
+    filename: Optional[str] = None,
+    allow_include: bool = False,
+    check_origin: bool = True,
+    idna_codec: Optional[dns.name.IDNACodec] = None,
+    allow_directives: Union[bool, Iterable[str]] = True,
+) -> Zone:
+    # See the comments for the public APIs from_text() and from_file() for
+    # details.
+
+    # 'text' can also be a file, but we don't publish that fact
+    # since it's an implementation detail.  The official file
+    # interface is from_file().
+
+    if filename is None:
+        filename = "<string>"
+    zone = zone_factory(origin, rdclass, relativize=relativize)
+    with zone.writer(True) as txn:
+        tok = dns.tokenizer.Tokenizer(text, filename, idna_codec=idna_codec)
+        reader = dns.zonefile.Reader(
+            tok,
+            rdclass,
+            txn,
+            allow_include=allow_include,
+            allow_directives=allow_directives,
+        )
+        try:
+            reader.read()
+        except dns.zonefile.UnknownOrigin:
+            # for backwards compatibility
+            raise dns.zone.UnknownOrigin
+    # Now that we're done reading, do some basic checking of the zone.
+    if check_origin:
+        zone.check_origin()
+    return zone
+
+
+def from_text(
+    text: str,
+    origin: Optional[Union[dns.name.Name, str]] = None,
+    rdclass: dns.rdataclass.RdataClass = dns.rdataclass.IN,
+    relativize: bool = True,
+    zone_factory: Any = Zone,
+    filename: Optional[str] = None,
+    allow_include: bool = False,
+    check_origin: bool = True,
+    idna_codec: Optional[dns.name.IDNACodec] = None,
+    allow_directives: Union[bool, Iterable[str]] = True,
+) -> Zone:
+    """Build a zone object from a zone file format string.
+
+    *text*, a ``str``, the zone file format input.
+
+    *origin*, a ``dns.name.Name``, a ``str``, or ``None``.  The origin
+    of the zone; if not specified, the first ``$ORIGIN`` statement in the
+    zone file will determine the origin of the zone.
+
+    *rdclass*, a ``dns.rdataclass.RdataClass``, the zone's rdata class; the default is
+    class IN.
+
+    *relativize*, a ``bool``, determine's whether domain names are
+    relativized to the zone's origin.  The default is ``True``.
+
+    *zone_factory*, the zone factory to use or ``None``.  If ``None``, then
+    ``dns.zone.Zone`` will be used.  The value may be any class or callable
+    that returns a subclass of ``dns.zone.Zone``.
+
+    *filename*, a ``str`` or ``None``, the filename to emit when
+    describing where an error occurred; the default is ``'<string>'``.
+
+    *allow_include*, a ``bool``.  If ``True``, the default, then ``$INCLUDE``
+    directives are permitted.  If ``False``, then encoutering a ``$INCLUDE``
+    will raise a ``SyntaxError`` exception.
+
+    *check_origin*, a ``bool``.  If ``True``, the default, then sanity
+    checks of the origin node will be made by calling the zone's
+    ``check_origin()`` method.
+
+    *idna_codec*, a ``dns.name.IDNACodec``, specifies the IDNA
+    encoder/decoder.  If ``None``, the default IDNA 2003 encoder/decoder
+    is used.
+
+    *allow_directives*, a ``bool`` or an iterable of `str`.  If ``True``, the default,
+    then directives are permitted, and the *allow_include* parameter controls whether
+    ``$INCLUDE`` is permitted.  If ``False`` or an empty iterable, then no directive
+    processing is done and any directive-like text will be treated as a regular owner
+    name.  If a non-empty iterable, then only the listed directives (including the
+    ``$``) are allowed.
+
+    Raises ``dns.zone.NoSOA`` if there is no SOA RRset.
+
+    Raises ``dns.zone.NoNS`` if there is no NS RRset.
+
+    Raises ``KeyError`` if there is no origin node.
+
+    Returns a subclass of ``dns.zone.Zone``.
+    """
+    return _from_text(
+        text,
+        origin,
+        rdclass,
+        relativize,
+        zone_factory,
+        filename,
+        allow_include,
+        check_origin,
+        idna_codec,
+        allow_directives,
+    )
+
+
+def from_file(
+    f: Any,
+    origin: Optional[Union[dns.name.Name, str]] = None,
+    rdclass: dns.rdataclass.RdataClass = dns.rdataclass.IN,
+    relativize: bool = True,
+    zone_factory: Any = Zone,
+    filename: Optional[str] = None,
+    allow_include: bool = True,
+    check_origin: bool = True,
+    idna_codec: Optional[dns.name.IDNACodec] = None,
+    allow_directives: Union[bool, Iterable[str]] = True,
+) -> Zone:
+    """Read a zone file and build a zone object.
+
+    *f*, a file or ``str``.  If *f* is a string, it is treated
+    as the name of a file to open.
+
+    *origin*, a ``dns.name.Name``, a ``str``, or ``None``.  The origin
+    of the zone; if not specified, the first ``$ORIGIN`` statement in the
+    zone file will determine the origin of the zone.
+
+    *rdclass*, an ``int``, the zone's rdata class; the default is class IN.
+
+    *relativize*, a ``bool``, determine's whether domain names are
+    relativized to the zone's origin.  The default is ``True``.
+
+    *zone_factory*, the zone factory to use or ``None``.  If ``None``, then
+    ``dns.zone.Zone`` will be used.  The value may be any class or callable
+    that returns a subclass of ``dns.zone.Zone``.
+
+    *filename*, a ``str`` or ``None``, the filename to emit when
+    describing where an error occurred; the default is ``'<string>'``.
+
+    *allow_include*, a ``bool``.  If ``True``, the default, then ``$INCLUDE``
+    directives are permitted.  If ``False``, then encoutering a ``$INCLUDE``
+    will raise a ``SyntaxError`` exception.
+
+    *check_origin*, a ``bool``.  If ``True``, the default, then sanity
+    checks of the origin node will be made by calling the zone's
+    ``check_origin()`` method.
+
+    *idna_codec*, a ``dns.name.IDNACodec``, specifies the IDNA
+    encoder/decoder.  If ``None``, the default IDNA 2003 encoder/decoder
+    is used.
+
+    *allow_directives*, a ``bool`` or an iterable of `str`.  If ``True``, the default,
+    then directives are permitted, and the *allow_include* parameter controls whether
+    ``$INCLUDE`` is permitted.  If ``False`` or an empty iterable, then no directive
+    processing is done and any directive-like text will be treated as a regular owner
+    name.  If a non-empty iterable, then only the listed directives (including the
+    ``$``) are allowed.
+
+    Raises ``dns.zone.NoSOA`` if there is no SOA RRset.
+
+    Raises ``dns.zone.NoNS`` if there is no NS RRset.
+
+    Raises ``KeyError`` if there is no origin node.
+
+    Returns a subclass of ``dns.zone.Zone``.
+    """
+
+    if isinstance(f, str):
+        if filename is None:
+            filename = f
+        cm: contextlib.AbstractContextManager = open(f)
+    else:
+        cm = contextlib.nullcontext(f)
+    with cm as f:
+        return _from_text(
+            f,
+            origin,
+            rdclass,
+            relativize,
+            zone_factory,
+            filename,
+            allow_include,
+            check_origin,
+            idna_codec,
+            allow_directives,
+        )
+    assert False  # make mypy happy  lgtm[py/unreachable-statement]
+
+
+def from_xfr(
+    xfr: Any,
+    zone_factory: Any = Zone,
+    relativize: bool = True,
+    check_origin: bool = True,
+) -> Zone:
+    """Convert the output of a zone transfer generator into a zone object.
+
+    *xfr*, a generator of ``dns.message.Message`` objects, typically
+    ``dns.query.xfr()``.
+
+    *relativize*, a ``bool``, determine's whether domain names are
+    relativized to the zone's origin.  The default is ``True``.
+    It is essential that the relativize setting matches the one specified
+    to the generator.
+
+    *check_origin*, a ``bool``.  If ``True``, the default, then sanity
+    checks of the origin node will be made by calling the zone's
+    ``check_origin()`` method.
+
+    Raises ``dns.zone.NoSOA`` if there is no SOA RRset.
+
+    Raises ``dns.zone.NoNS`` if there is no NS RRset.
+
+    Raises ``KeyError`` if there is no origin node.
+
+    Raises ``ValueError`` if no messages are yielded by the generator.
+
+    Returns a subclass of ``dns.zone.Zone``.
+    """
+
+    z = None
+    for r in xfr:
+        if z is None:
+            if relativize:
+                origin = r.origin
+            else:
+                origin = r.answer[0].name
+            rdclass = r.answer[0].rdclass
+            z = zone_factory(origin, rdclass, relativize=relativize)
+        for rrset in r.answer:
+            znode = z.nodes.get(rrset.name)
+            if not znode:
+                znode = z.node_factory()
+                z.nodes[rrset.name] = znode
+            zrds = znode.find_rdataset(rrset.rdclass, rrset.rdtype, rrset.covers, True)
+            zrds.update_ttl(rrset.ttl)
+            for rd in rrset:
+                zrds.add(rd)
+    if z is None:
+        raise ValueError("empty transfer")
+    if check_origin:
+        z.check_origin()
+    return z
diff --git a/.venv/lib/python3.12/site-packages/dns/zonefile.py b/.venv/lib/python3.12/site-packages/dns/zonefile.py
new file mode 100644
index 00000000..d74510b2
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/dns/zonefile.py
@@ -0,0 +1,744 @@
+# Copyright (C) Dnspython Contributors, see LICENSE for text of ISC license
+
+# Copyright (C) 2003-2007, 2009-2011 Nominum, Inc.
+#
+# Permission to use, copy, modify, and distribute this software and its
+# documentation for any purpose with or without fee is hereby granted,
+# provided that the above copyright notice and this permission notice
+# appear in all copies.
+#
+# THE SOFTWARE IS PROVIDED "AS IS" AND NOMINUM DISCLAIMS ALL WARRANTIES
+# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL NOMINUM BE LIABLE FOR
+# ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+# ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT
+# OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+
+"""DNS Zones."""
+
+import re
+import sys
+from typing import Any, Iterable, List, Optional, Set, Tuple, Union
+
+import dns.exception
+import dns.grange
+import dns.name
+import dns.node
+import dns.rdata
+import dns.rdataclass
+import dns.rdatatype
+import dns.rdtypes.ANY.SOA
+import dns.rrset
+import dns.tokenizer
+import dns.transaction
+import dns.ttl
+
+
+class UnknownOrigin(dns.exception.DNSException):
+    """Unknown origin"""
+
+
+class CNAMEAndOtherData(dns.exception.DNSException):
+    """A node has a CNAME and other data"""
+
+
+def _check_cname_and_other_data(txn, name, rdataset):
+    rdataset_kind = dns.node.NodeKind.classify_rdataset(rdataset)
+    node = txn.get_node(name)
+    if node is None:
+        # empty nodes are neutral.
+        return
+    node_kind = node.classify()
+    if (
+        node_kind == dns.node.NodeKind.CNAME
+        and rdataset_kind == dns.node.NodeKind.REGULAR
+    ):
+        raise CNAMEAndOtherData("rdataset type is not compatible with a CNAME node")
+    elif (
+        node_kind == dns.node.NodeKind.REGULAR
+        and rdataset_kind == dns.node.NodeKind.CNAME
+    ):
+        raise CNAMEAndOtherData(
+            "CNAME rdataset is not compatible with a regular data node"
+        )
+    # Otherwise at least one of the node and the rdataset is neutral, so
+    # adding the rdataset is ok
+
+
+SavedStateType = Tuple[
+    dns.tokenizer.Tokenizer,
+    Optional[dns.name.Name],  # current_origin
+    Optional[dns.name.Name],  # last_name
+    Optional[Any],  # current_file
+    int,  # last_ttl
+    bool,  # last_ttl_known
+    int,  # default_ttl
+    bool,
+]  # default_ttl_known
+
+
+def _upper_dollarize(s):
+    s = s.upper()
+    if not s.startswith("$"):
+        s = "$" + s
+    return s
+
+
+class Reader:
+    """Read a DNS zone file into a transaction."""
+
+    def __init__(
+        self,
+        tok: dns.tokenizer.Tokenizer,
+        rdclass: dns.rdataclass.RdataClass,
+        txn: dns.transaction.Transaction,
+        allow_include: bool = False,
+        allow_directives: Union[bool, Iterable[str]] = True,
+        force_name: Optional[dns.name.Name] = None,
+        force_ttl: Optional[int] = None,
+        force_rdclass: Optional[dns.rdataclass.RdataClass] = None,
+        force_rdtype: Optional[dns.rdatatype.RdataType] = None,
+        default_ttl: Optional[int] = None,
+    ):
+        self.tok = tok
+        (self.zone_origin, self.relativize, _) = txn.manager.origin_information()
+        self.current_origin = self.zone_origin
+        self.last_ttl = 0
+        self.last_ttl_known = False
+        if force_ttl is not None:
+            default_ttl = force_ttl
+        if default_ttl is None:
+            self.default_ttl = 0
+            self.default_ttl_known = False
+        else:
+            self.default_ttl = default_ttl
+            self.default_ttl_known = True
+        self.last_name = self.current_origin
+        self.zone_rdclass = rdclass
+        self.txn = txn
+        self.saved_state: List[SavedStateType] = []
+        self.current_file: Optional[Any] = None
+        self.allowed_directives: Set[str]
+        if allow_directives is True:
+            self.allowed_directives = {"$GENERATE", "$ORIGIN", "$TTL"}
+            if allow_include:
+                self.allowed_directives.add("$INCLUDE")
+        elif allow_directives is False:
+            # allow_include was ignored in earlier releases if allow_directives was
+            # False, so we continue that.
+            self.allowed_directives = set()
+        else:
+            # Note that if directives are explicitly specified, then allow_include
+            # is ignored.
+            self.allowed_directives = set(_upper_dollarize(d) for d in allow_directives)
+        self.force_name = force_name
+        self.force_ttl = force_ttl
+        self.force_rdclass = force_rdclass
+        self.force_rdtype = force_rdtype
+        self.txn.check_put_rdataset(_check_cname_and_other_data)
+
+    def _eat_line(self):
+        while 1:
+            token = self.tok.get()
+            if token.is_eol_or_eof():
+                break
+
+    def _get_identifier(self):
+        token = self.tok.get()
+        if not token.is_identifier():
+            raise dns.exception.SyntaxError
+        return token
+
+    def _rr_line(self):
+        """Process one line from a DNS zone file."""
+        token = None
+        # Name
+        if self.force_name is not None:
+            name = self.force_name
+        else:
+            if self.current_origin is None:
+                raise UnknownOrigin
+            token = self.tok.get(want_leading=True)
+            if not token.is_whitespace():
+                self.last_name = self.tok.as_name(token, self.current_origin)
+            else:
+                token = self.tok.get()
+                if token.is_eol_or_eof():
+                    # treat leading WS followed by EOL/EOF as if they were EOL/EOF.
+                    return
+                self.tok.unget(token)
+            name = self.last_name
+            if not name.is_subdomain(self.zone_origin):
+                self._eat_line()
+                return
+            if self.relativize:
+                name = name.relativize(self.zone_origin)
+
+        # TTL
+        if self.force_ttl is not None:
+            ttl = self.force_ttl
+            self.last_ttl = ttl
+            self.last_ttl_known = True
+        else:
+            token = self._get_identifier()
+            ttl = None
+            try:
+                ttl = dns.ttl.from_text(token.value)
+                self.last_ttl = ttl
+                self.last_ttl_known = True
+                token = None
+            except dns.ttl.BadTTL:
+                self.tok.unget(token)
+
+        # Class
+        if self.force_rdclass is not None:
+            rdclass = self.force_rdclass
+        else:
+            token = self._get_identifier()
+            try:
+                rdclass = dns.rdataclass.from_text(token.value)
+            except dns.exception.SyntaxError:
+                raise
+            except Exception:
+                rdclass = self.zone_rdclass
+                self.tok.unget(token)
+            if rdclass != self.zone_rdclass:
+                raise dns.exception.SyntaxError("RR class is not zone's class")
+
+        if ttl is None:
+            # support for <class> <ttl> <type> syntax
+            token = self._get_identifier()
+            ttl = None
+            try:
+                ttl = dns.ttl.from_text(token.value)
+                self.last_ttl = ttl
+                self.last_ttl_known = True
+                token = None
+            except dns.ttl.BadTTL:
+                if self.default_ttl_known:
+                    ttl = self.default_ttl
+                elif self.last_ttl_known:
+                    ttl = self.last_ttl
+                self.tok.unget(token)
+
+        # Type
+        if self.force_rdtype is not None:
+            rdtype = self.force_rdtype
+        else:
+            token = self._get_identifier()
+            try:
+                rdtype = dns.rdatatype.from_text(token.value)
+            except Exception:
+                raise dns.exception.SyntaxError(f"unknown rdatatype '{token.value}'")
+
+        try:
+            rd = dns.rdata.from_text(
+                rdclass,
+                rdtype,
+                self.tok,
+                self.current_origin,
+                self.relativize,
+                self.zone_origin,
+            )
+        except dns.exception.SyntaxError:
+            # Catch and reraise.
+            raise
+        except Exception:
+            # All exceptions that occur in the processing of rdata
+            # are treated as syntax errors.  This is not strictly
+            # correct, but it is correct almost all of the time.
+            # We convert them to syntax errors so that we can emit
+            # helpful filename:line info.
+            (ty, va) = sys.exc_info()[:2]
+            raise dns.exception.SyntaxError(f"caught exception {str(ty)}: {str(va)}")
+
+        if not self.default_ttl_known and rdtype == dns.rdatatype.SOA:
+            # The pre-RFC2308 and pre-BIND9 behavior inherits the zone default
+            # TTL from the SOA minttl if no $TTL statement is present before the
+            # SOA is parsed.
+            self.default_ttl = rd.minimum
+            self.default_ttl_known = True
+            if ttl is None:
+                # if we didn't have a TTL on the SOA, set it!
+                ttl = rd.minimum
+
+        # TTL check.  We had to wait until now to do this as the SOA RR's
+        # own TTL can be inferred from its minimum.
+        if ttl is None:
+            raise dns.exception.SyntaxError("Missing default TTL value")
+
+        self.txn.add(name, ttl, rd)
+
+    def _parse_modify(self, side: str) -> Tuple[str, str, int, int, str]:
+        # Here we catch everything in '{' '}' in a group so we can replace it
+        # with ''.
+        is_generate1 = re.compile(r"^.*\$({(\+|-?)(\d+),(\d+),(.)}).*$")
+        is_generate2 = re.compile(r"^.*\$({(\+|-?)(\d+)}).*$")
+        is_generate3 = re.compile(r"^.*\$({(\+|-?)(\d+),(\d+)}).*$")
+        # Sometimes there are modifiers in the hostname. These come after
+        # the dollar sign. They are in the form: ${offset[,width[,base]]}.
+        # Make names
+        mod = ""
+        sign = "+"
+        offset = "0"
+        width = "0"
+        base = "d"
+        g1 = is_generate1.match(side)
+        if g1:
+            mod, sign, offset, width, base = g1.groups()
+            if sign == "":
+                sign = "+"
+        else:
+            g2 = is_generate2.match(side)
+            if g2:
+                mod, sign, offset = g2.groups()
+                if sign == "":
+                    sign = "+"
+                width = "0"
+                base = "d"
+            else:
+                g3 = is_generate3.match(side)
+                if g3:
+                    mod, sign, offset, width = g3.groups()
+                    if sign == "":
+                        sign = "+"
+                    base = "d"
+
+        ioffset = int(offset)
+        iwidth = int(width)
+
+        if sign not in ["+", "-"]:
+            raise dns.exception.SyntaxError(f"invalid offset sign {sign}")
+        if base not in ["d", "o", "x", "X", "n", "N"]:
+            raise dns.exception.SyntaxError(f"invalid type {base}")
+
+        return mod, sign, ioffset, iwidth, base
+
+    def _generate_line(self):
+        # range lhs [ttl] [class] type rhs [ comment ]
+        """Process one line containing the GENERATE statement from a DNS
+        zone file."""
+        if self.current_origin is None:
+            raise UnknownOrigin
+
+        token = self.tok.get()
+        # Range (required)
+        try:
+            start, stop, step = dns.grange.from_text(token.value)
+            token = self.tok.get()
+            if not token.is_identifier():
+                raise dns.exception.SyntaxError
+        except Exception:
+            raise dns.exception.SyntaxError
+
+        # lhs (required)
+        try:
+            lhs = token.value
+            token = self.tok.get()
+            if not token.is_identifier():
+                raise dns.exception.SyntaxError
+        except Exception:
+            raise dns.exception.SyntaxError
+
+        # TTL
+        try:
+            ttl = dns.ttl.from_text(token.value)
+            self.last_ttl = ttl
+            self.last_ttl_known = True
+            token = self.tok.get()
+            if not token.is_identifier():
+                raise dns.exception.SyntaxError
+        except dns.ttl.BadTTL:
+            if not (self.last_ttl_known or self.default_ttl_known):
+                raise dns.exception.SyntaxError("Missing default TTL value")
+            if self.default_ttl_known:
+                ttl = self.default_ttl
+            elif self.last_ttl_known:
+                ttl = self.last_ttl
+        # Class
+        try:
+            rdclass = dns.rdataclass.from_text(token.value)
+            token = self.tok.get()
+            if not token.is_identifier():
+                raise dns.exception.SyntaxError
+        except dns.exception.SyntaxError:
+            raise dns.exception.SyntaxError
+        except Exception:
+            rdclass = self.zone_rdclass
+        if rdclass != self.zone_rdclass:
+            raise dns.exception.SyntaxError("RR class is not zone's class")
+        # Type
+        try:
+            rdtype = dns.rdatatype.from_text(token.value)
+            token = self.tok.get()
+            if not token.is_identifier():
+                raise dns.exception.SyntaxError
+        except Exception:
+            raise dns.exception.SyntaxError(f"unknown rdatatype '{token.value}'")
+
+        # rhs (required)
+        rhs = token.value
+
+        def _calculate_index(counter: int, offset_sign: str, offset: int) -> int:
+            """Calculate the index from the counter and offset."""
+            if offset_sign == "-":
+                offset *= -1
+            return counter + offset
+
+        def _format_index(index: int, base: str, width: int) -> str:
+            """Format the index with the given base, and zero-fill it
+            to the given width."""
+            if base in ["d", "o", "x", "X"]:
+                return format(index, base).zfill(width)
+
+            # base can only be n or N here
+            hexa = _format_index(index, "x", width)
+            nibbles = ".".join(hexa[::-1])[:width]
+            if base == "N":
+                nibbles = nibbles.upper()
+            return nibbles
+
+        lmod, lsign, loffset, lwidth, lbase = self._parse_modify(lhs)
+        rmod, rsign, roffset, rwidth, rbase = self._parse_modify(rhs)
+        for i in range(start, stop + 1, step):
+            # +1 because bind is inclusive and python is exclusive
+
+            lindex = _calculate_index(i, lsign, loffset)
+            rindex = _calculate_index(i, rsign, roffset)
+
+            lzfindex = _format_index(lindex, lbase, lwidth)
+            rzfindex = _format_index(rindex, rbase, rwidth)
+
+            name = lhs.replace(f"${lmod}", lzfindex)
+            rdata = rhs.replace(f"${rmod}", rzfindex)
+
+            self.last_name = dns.name.from_text(
+                name, self.current_origin, self.tok.idna_codec
+            )
+            name = self.last_name
+            if not name.is_subdomain(self.zone_origin):
+                self._eat_line()
+                return
+            if self.relativize:
+                name = name.relativize(self.zone_origin)
+
+            try:
+                rd = dns.rdata.from_text(
+                    rdclass,
+                    rdtype,
+                    rdata,
+                    self.current_origin,
+                    self.relativize,
+                    self.zone_origin,
+                )
+            except dns.exception.SyntaxError:
+                # Catch and reraise.
+                raise
+            except Exception:
+                # All exceptions that occur in the processing of rdata
+                # are treated as syntax errors.  This is not strictly
+                # correct, but it is correct almost all of the time.
+                # We convert them to syntax errors so that we can emit
+                # helpful filename:line info.
+                (ty, va) = sys.exc_info()[:2]
+                raise dns.exception.SyntaxError(
+                    f"caught exception {str(ty)}: {str(va)}"
+                )
+
+            self.txn.add(name, ttl, rd)
+
+    def read(self) -> None:
+        """Read a DNS zone file and build a zone object.
+
+        @raises dns.zone.NoSOA: No SOA RR was found at the zone origin
+        @raises dns.zone.NoNS: No NS RRset was found at the zone origin
+        """
+
+        try:
+            while 1:
+                token = self.tok.get(True, True)
+                if token.is_eof():
+                    if self.current_file is not None:
+                        self.current_file.close()
+                    if len(self.saved_state) > 0:
+                        (
+                            self.tok,
+                            self.current_origin,
+                            self.last_name,
+                            self.current_file,
+                            self.last_ttl,
+                            self.last_ttl_known,
+                            self.default_ttl,
+                            self.default_ttl_known,
+                        ) = self.saved_state.pop(-1)
+                        continue
+                    break
+                elif token.is_eol():
+                    continue
+                elif token.is_comment():
+                    self.tok.get_eol()
+                    continue
+                elif token.value[0] == "$" and len(self.allowed_directives) > 0:
+                    # Note that we only run directive processing code if at least
+                    # one directive is allowed in order to be backwards compatible
+                    c = token.value.upper()
+                    if c not in self.allowed_directives:
+                        raise dns.exception.SyntaxError(
+                            f"zone file directive '{c}' is not allowed"
+                        )
+                    if c == "$TTL":
+                        token = self.tok.get()
+                        if not token.is_identifier():
+                            raise dns.exception.SyntaxError("bad $TTL")
+                        self.default_ttl = dns.ttl.from_text(token.value)
+                        self.default_ttl_known = True
+                        self.tok.get_eol()
+                    elif c == "$ORIGIN":
+                        self.current_origin = self.tok.get_name()
+                        self.tok.get_eol()
+                        if self.zone_origin is None:
+                            self.zone_origin = self.current_origin
+                        self.txn._set_origin(self.current_origin)
+                    elif c == "$INCLUDE":
+                        token = self.tok.get()
+                        filename = token.value
+                        token = self.tok.get()
+                        new_origin: Optional[dns.name.Name]
+                        if token.is_identifier():
+                            new_origin = dns.name.from_text(
+                                token.value, self.current_origin, self.tok.idna_codec
+                            )
+                            self.tok.get_eol()
+                        elif not token.is_eol_or_eof():
+                            raise dns.exception.SyntaxError("bad origin in $INCLUDE")
+                        else:
+                            new_origin = self.current_origin
+                        self.saved_state.append(
+                            (
+                                self.tok,
+                                self.current_origin,
+                                self.last_name,
+                                self.current_file,
+                                self.last_ttl,
+                                self.last_ttl_known,
+                                self.default_ttl,
+                                self.default_ttl_known,
+                            )
+                        )
+                        self.current_file = open(filename)
+                        self.tok = dns.tokenizer.Tokenizer(self.current_file, filename)
+                        self.current_origin = new_origin
+                    elif c == "$GENERATE":
+                        self._generate_line()
+                    else:
+                        raise dns.exception.SyntaxError(
+                            f"Unknown zone file directive '{c}'"
+                        )
+                    continue
+                self.tok.unget(token)
+                self._rr_line()
+        except dns.exception.SyntaxError as detail:
+            (filename, line_number) = self.tok.where()
+            if detail is None:
+                detail = "syntax error"
+            ex = dns.exception.SyntaxError(
+                "%s:%d: %s" % (filename, line_number, detail)
+            )
+            tb = sys.exc_info()[2]
+            raise ex.with_traceback(tb) from None
+
+
+class RRsetsReaderTransaction(dns.transaction.Transaction):
+    def __init__(self, manager, replacement, read_only):
+        assert not read_only
+        super().__init__(manager, replacement, read_only)
+        self.rdatasets = {}
+
+    def _get_rdataset(self, name, rdtype, covers):
+        return self.rdatasets.get((name, rdtype, covers))
+
+    def _get_node(self, name):
+        rdatasets = []
+        for (rdataset_name, _, _), rdataset in self.rdatasets.items():
+            if name == rdataset_name:
+                rdatasets.append(rdataset)
+        if len(rdatasets) == 0:
+            return None
+        node = dns.node.Node()
+        node.rdatasets = rdatasets
+        return node
+
+    def _put_rdataset(self, name, rdataset):
+        self.rdatasets[(name, rdataset.rdtype, rdataset.covers)] = rdataset
+
+    def _delete_name(self, name):
+        # First remove any changes involving the name
+        remove = []
+        for key in self.rdatasets:
+            if key[0] == name:
+                remove.append(key)
+        if len(remove) > 0:
+            for key in remove:
+                del self.rdatasets[key]
+
+    def _delete_rdataset(self, name, rdtype, covers):
+        try:
+            del self.rdatasets[(name, rdtype, covers)]
+        except KeyError:
+            pass
+
+    def _name_exists(self, name):
+        for n, _, _ in self.rdatasets:
+            if n == name:
+                return True
+        return False
+
+    def _changed(self):
+        return len(self.rdatasets) > 0
+
+    def _end_transaction(self, commit):
+        if commit and self._changed():
+            rrsets = []
+            for (name, _, _), rdataset in self.rdatasets.items():
+                rrset = dns.rrset.RRset(
+                    name, rdataset.rdclass, rdataset.rdtype, rdataset.covers
+                )
+                rrset.update(rdataset)
+                rrsets.append(rrset)
+            self.manager.set_rrsets(rrsets)
+
+    def _set_origin(self, origin):
+        pass
+
+    def _iterate_rdatasets(self):
+        raise NotImplementedError  # pragma: no cover
+
+    def _iterate_names(self):
+        raise NotImplementedError  # pragma: no cover
+
+
+class RRSetsReaderManager(dns.transaction.TransactionManager):
+    def __init__(
+        self, origin=dns.name.root, relativize=False, rdclass=dns.rdataclass.IN
+    ):
+        self.origin = origin
+        self.relativize = relativize
+        self.rdclass = rdclass
+        self.rrsets = []
+
+    def reader(self):  # pragma: no cover
+        raise NotImplementedError
+
+    def writer(self, replacement=False):
+        assert replacement is True
+        return RRsetsReaderTransaction(self, True, False)
+
+    def get_class(self):
+        return self.rdclass
+
+    def origin_information(self):
+        if self.relativize:
+            effective = dns.name.empty
+        else:
+            effective = self.origin
+        return (self.origin, self.relativize, effective)
+
+    def set_rrsets(self, rrsets):
+        self.rrsets = rrsets
+
+
+def read_rrsets(
+    text: Any,
+    name: Optional[Union[dns.name.Name, str]] = None,
+    ttl: Optional[int] = None,
+    rdclass: Optional[Union[dns.rdataclass.RdataClass, str]] = dns.rdataclass.IN,
+    default_rdclass: Union[dns.rdataclass.RdataClass, str] = dns.rdataclass.IN,
+    rdtype: Optional[Union[dns.rdatatype.RdataType, str]] = None,
+    default_ttl: Optional[Union[int, str]] = None,
+    idna_codec: Optional[dns.name.IDNACodec] = None,
+    origin: Optional[Union[dns.name.Name, str]] = dns.name.root,
+    relativize: bool = False,
+) -> List[dns.rrset.RRset]:
+    """Read one or more rrsets from the specified text, possibly subject
+    to restrictions.
+
+    *text*, a file object or a string, is the input to process.
+
+    *name*, a string, ``dns.name.Name``, or ``None``, is the owner name of
+    the rrset.  If not ``None``, then the owner name is "forced", and the
+    input must not specify an owner name.  If ``None``, then any owner names
+    are allowed and must be present in the input.
+
+    *ttl*, an ``int``, string, or None.  If not ``None``, the the TTL is
+    forced to be the specified value and the input must not specify a TTL.
+    If ``None``, then a TTL may be specified in the input.  If it is not
+    specified, then the *default_ttl* will be used.
+
+    *rdclass*, a ``dns.rdataclass.RdataClass``, string, or ``None``.  If
+    not ``None``, then the class is forced to the specified value, and the
+    input must not specify a class.  If ``None``, then the input may specify
+    a class that matches *default_rdclass*.  Note that it is not possible to
+    return rrsets with differing classes; specifying ``None`` for the class
+    simply allows the user to optionally type a class as that may be convenient
+    when cutting and pasting.
+
+    *default_rdclass*, a ``dns.rdataclass.RdataClass`` or string.  The class
+    of the returned rrsets.
+
+    *rdtype*, a ``dns.rdatatype.RdataType``, string, or ``None``.  If not
+    ``None``, then the type is forced to the specified value, and the
+    input must not specify a type.  If ``None``, then a type must be present
+    for each RR.
+
+    *default_ttl*, an ``int``, string, or ``None``.  If not ``None``, then if
+    the TTL is not forced and is not specified, then this value will be used.
+    if ``None``, then if the TTL is not forced an error will occur if the TTL
+    is not specified.
+
+    *idna_codec*, a ``dns.name.IDNACodec``, specifies the IDNA
+    encoder/decoder.  If ``None``, the default IDNA 2003 encoder/decoder
+    is used.  Note that codecs only apply to the owner name; dnspython does
+    not do IDNA for names in rdata, as there is no IDNA zonefile format.
+
+    *origin*, a string, ``dns.name.Name``, or ``None``, is the origin for any
+    relative names in the input, and also the origin to relativize to if
+    *relativize* is ``True``.
+
+    *relativize*, a bool.  If ``True``, names are relativized to the *origin*;
+    if ``False`` then any relative names in the input are made absolute by
+    appending the *origin*.
+    """
+    if isinstance(origin, str):
+        origin = dns.name.from_text(origin, dns.name.root, idna_codec)
+    if isinstance(name, str):
+        name = dns.name.from_text(name, origin, idna_codec)
+    if isinstance(ttl, str):
+        ttl = dns.ttl.from_text(ttl)
+    if isinstance(default_ttl, str):
+        default_ttl = dns.ttl.from_text(default_ttl)
+    if rdclass is not None:
+        rdclass = dns.rdataclass.RdataClass.make(rdclass)
+    else:
+        rdclass = None
+    default_rdclass = dns.rdataclass.RdataClass.make(default_rdclass)
+    if rdtype is not None:
+        rdtype = dns.rdatatype.RdataType.make(rdtype)
+    else:
+        rdtype = None
+    manager = RRSetsReaderManager(origin, relativize, default_rdclass)
+    with manager.writer(True) as txn:
+        tok = dns.tokenizer.Tokenizer(text, "<input>", idna_codec=idna_codec)
+        reader = Reader(
+            tok,
+            default_rdclass,
+            txn,
+            allow_directives=False,
+            force_name=name,
+            force_ttl=ttl,
+            force_rdclass=rdclass,
+            force_rdtype=rdtype,
+            default_ttl=default_ttl,
+        )
+        reader.read()
+    return manager.rrsets
diff --git a/.venv/lib/python3.12/site-packages/dns/zonetypes.py b/.venv/lib/python3.12/site-packages/dns/zonetypes.py
new file mode 100644
index 00000000..195ee2ec
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/dns/zonetypes.py
@@ -0,0 +1,37 @@
+# Copyright (C) Dnspython Contributors, see LICENSE for text of ISC license
+
+"""Common zone-related types."""
+
+# This is a separate file to avoid import circularity between dns.zone and
+# the implementation of the ZONEMD type.
+
+import hashlib
+
+import dns.enum
+
+
+class DigestScheme(dns.enum.IntEnum):
+    """ZONEMD Scheme"""
+
+    SIMPLE = 1
+
+    @classmethod
+    def _maximum(cls):
+        return 255
+
+
+class DigestHashAlgorithm(dns.enum.IntEnum):
+    """ZONEMD Hash Algorithm"""
+
+    SHA384 = 1
+    SHA512 = 2
+
+    @classmethod
+    def _maximum(cls):
+        return 255
+
+
+_digest_hashers = {
+    DigestHashAlgorithm.SHA384: hashlib.sha384,
+    DigestHashAlgorithm.SHA512: hashlib.sha512,
+}