From bb7389851d95027bd6e40af7b6f4e8cfecd77e3c Mon Sep 17 00:00:00 2001 From: Babak Farrokhi <118838+farrokhi@users.noreply.github.com> Date: Fri, 25 Oct 2024 23:39:40 +0200 Subject: [PATCH] Add support for DNS over QUIC (DoQ) protocol (#118) * Add DNS over QUIC (DoQ) support * Improve custom destination port logic * Fixed a bug in which if you uses `-p` argument (to specify destination part) _after_ choosing protocol (e.g. `-T` or `-X`), custom destination port was ignored and we used default instead. * Improved warning and error message * Update dependencies * Remove support for python 3.8 * Bail if service does not respond to DoH --- .github/workflows/packages.yml | 6 +-- build-pkgs.sh | 4 +- dnsping.py | 84 +++++++++++++++++++++++++++------- requirements.txt | 7 +-- setup.py | 4 +- util/dns.py | 17 ++++++- 6 files changed, 95 insertions(+), 27 deletions(-) diff --git a/.github/workflows/packages.yml b/.github/workflows/packages.yml index a143ccd..2664ea6 100644 --- a/.github/workflows/packages.yml +++ b/.github/workflows/packages.yml @@ -8,12 +8,12 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"] + python-version: ["3.9", "3.10", "3.11", "3.12", "3.13", "pypy3.10"] steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} - name: Install dependencies diff --git a/build-pkgs.sh b/build-pkgs.sh index 166db28..b3427f5 100755 --- a/build-pkgs.sh +++ b/build-pkgs.sh @@ -35,7 +35,7 @@ msg "Starting to build dnsdiag package version ${DDVER} for ${PLATFORM}-${ARCH}" ## main if [ $# -gt 0 ]; then - if [ $1 == "--venv" ]; then + if [ "$1" = "--venv" ]; then msg "Initializing virtualenv" checkbin "virtualenv" virtualenv -q --clear .venv @@ -69,7 +69,7 @@ for i in public-servers.txt public-v4.txt rootservers.txt; do done cd pkg -if [ "${PLATFORM}" == "windows" ]; then +if [ "${PLATFORM:-}" = "windows" ]; then msg "Creating archive: ${PKG_NAME}.zip" powershell Compress-Archive -Force "${PKG_NAME}" "${PKG_NAME}.zip" else diff --git a/dnsping.py b/dnsping.py index e6c2d68..0938633 100755 --- a/dnsping.py +++ b/dnsping.py @@ -39,8 +39,8 @@ import dns.flags import dns.resolver -import util.dns -from util.dns import PROTO_UDP, PROTO_TCP, PROTO_TLS, PROTO_HTTPS, proto_to_text, unsupported_feature, random_string +from util.dns import PROTO_UDP, PROTO_TCP, PROTO_TLS, PROTO_HTTPS, PROTO_QUIC, proto_to_text, unsupported_feature, \ + random_string, getDefaultPort, valid_rdatatype from util.shared import __version__ __author__ = 'Babak Farrokhi (babak@farrokhi.net)' @@ -51,7 +51,7 @@ def usage(): print("""%s version %s -Usage: %s [-46aDeEFhLmqnrvTxXH] [-i interval] [-w wait] [-p dst_port] [-P src_port] [-S src_ip] +Usage: %s [-46aDeEFhLmqnrvTQxXH] [-i interval] [-w wait] [-p dst_port] [-P src_port] [-S src_ip] %s [-c count] [-t qtype] [-C class] [-s server] hostname -h, --help Show this help message @@ -62,6 +62,7 @@ def usage(): -T, --tcp Use TCP as the transport protocol -X, --tls Use TLS as the transport protocol -H, --doh Use HTTPS as the transport protocol (DoH) + -Q, --doq Use QUIC as the transport protocol (DoQ) -4, --ipv4 Use IPv4 as the network protocol -6, --ipv6 Use IPv6 as the network protocol -P, --srcport Specify the source port number for the query (default: 0) @@ -125,6 +126,7 @@ def main(): if len(sys.argv) == 1: usage() + dns.rdata.load_all_types() # defaults rdatatype = 'A' rdata_class = dns.rdataclass.from_text('IN') @@ -136,10 +138,11 @@ def main(): show_flags = False show_ede = False dnsserver = None # do not try to use system resolver by default - dst_port = 53 # default for UDP and TCP + proto = PROTO_UDP + dst_port = getDefaultPort(proto) + use_default_dst_port = True src_port = 0 src_ip = None - proto = PROTO_UDP use_edns = False want_nsid = False want_dnssec = False @@ -151,11 +154,11 @@ def main(): qname = 'wikipedia.org' try: - opts, args = getopt.getopt(sys.argv[1:], "qhc:s:t:w:i:vp:P:S:T46meDFXHrnEC:Lxa", + opts, args = getopt.getopt(sys.argv[1:], "qhc:s:t:w:i:vp:P:S:TQ46meDFXHrnEC:Lxa", ["help", "count=", "server=", "quiet", "type=", "wait=", "interval=", "verbose", "port=", "srcip=", "tcp", "ipv4", "ipv6", "cache-miss", "srcport=", "edns", "dnssec", "flags", "norecurse", "tls", "doh", "nsid", "ede", "class=", "ttl", - "expert", "answer"]) + "expert", "answer", "quic"]) except getopt.GetoptError as err: # print help information and exit: print_stderr(err, False) # will print something like "option -a not recognized" @@ -169,34 +172,46 @@ def main(): for o, a in opts: if o in ("-h", "--help"): usage() + elif o in ("-c", "--count"): if a.isdigit(): count = abs(int(a)) else: print_stderr("Invalid count of requests: %s" % a, True) + elif o in ("-v", "--verbose"): verbose = True + elif o in ("-s", "--server"): dnsserver = a + elif o in ("-q", "--quiet"): quiet = True verbose = False + elif o in ("-w", "--wait"): timeout = int(a) + elif o in ("-a", "--answer"): show_answer = True + elif o in ("-x", "--expert"): show_flags = True show_ede = True show_ttl = True + elif o in ("-m", "--cache-miss"): force_miss = True + elif o in ("-i", "--interval"): interval = float(a) + elif o in ("-L", "--ttl"): show_ttl = True + elif o in ("-t", "--type"): rdatatype = a + elif o in ("-C", "--class"): try: rdata_class = dns.rdataclass.from_text(a) @@ -205,38 +220,62 @@ def main(): elif o in ("-T", "--tcp"): proto = PROTO_TCP + if use_default_dst_port: + dst_port = getDefaultPort(proto) + elif o in ("-X", "--tls"): proto = PROTO_TLS - dst_port = 853 # default for DoT, unless overridden using -p + if use_default_dst_port: + dst_port = getDefaultPort(proto) + elif o in ("-H", "--doh"): proto = PROTO_HTTPS - dst_port = 443 # default for DoH, unless overridden using -p + if use_default_dst_port: + dst_port = getDefaultPort(proto) + + elif o in ("-Q", "--quic"): + proto = PROTO_QUIC + if use_default_dst_port: + dst_port = getDefaultPort(proto) + elif o in ("-4", "--ipv4"): af = socket.AF_INET + elif o in ("-6", "--ipv6"): af = socket.AF_INET6 + elif o in ("-e", "--edns"): use_edns = True + elif o in ("-n", "--nsid"): use_edns = True # required want_nsid = True + elif o in ("-r", "--norecurse"): request_flags = dns.flags.from_text('') + elif o in ("-D", "--dnssec"): use_edns = True # required want_dnssec = True + elif o in ("-F", "--flags"): show_flags = True + elif o in ("-E", "--ede"): show_ede = True + elif o in ("-p", "--port"): dst_port = int(a) + use_default_dst_port = False + elif o in ("-P", "--srcport"): src_port = int(a) if src_port < 1024 and not quiet: print_stderr("WARNING: Source ports below 1024 are only available to superuser", False) + elif o in ("-S", "--srcip"): src_ip = a + else: usage() @@ -251,7 +290,7 @@ def main(): i = 0 # validate RR type - if not util.dns.valid_rdatatype(rdatatype): + if not valid_rdatatype(rdatatype): print_stderr('Error: Invalid record type: %s ' % rdatatype, True) print("%s DNS: %s:%d, hostname: %s, proto: %s, class: %s, type: %s, flags: [%s]" % @@ -292,17 +331,30 @@ def main(): source=src_ip, source_port=src_port) elif proto is PROTO_TLS: if hasattr(dns.query, 'tls'): - answers = dns.query.tls(query, dnsserver, timeout, dst_port, - src_ip, src_port) + answers = dns.query.tls(query, dnsserver, timeout=timeout, port=dst_port, + source=src_ip, source_port=src_port) else: - unsupported_feature() + unsupported_feature("DNS-over-TLS") elif proto is PROTO_HTTPS: if hasattr(dns.query, 'https'): - answers = dns.query.https(query, dnsserver, timeout, dst_port, - src_ip, src_port) + try: + answers = dns.query.https(query, dnsserver, timeout=timeout, port=dst_port, + source=src_ip, source_port=src_port) + except httpx.ConnectError: + print_stderr(f"The server did not respond to DoH on port {dst_port}", should_die=True) + else: + unsupported_feature("DNS-over-HTTPS (DoH)") + + elif proto is PROTO_QUIC: + if hasattr(dns.query, 'quic'): + try: + answers = dns.query.quic(query, dnsserver, timeout=timeout, port=dst_port, + source=src_ip, source_port=src_port) + except dns.exception.Timeout: + print_stderr(f"The server did not respond to DoQ on port {dst_port}", should_die=True) else: - unsupported_feature() + unsupported_feature("DNS-over-QUIC (DoQ)") etime = time.perf_counter() except dns.resolver.NoNameservers as e: diff --git a/requirements.txt b/requirements.txt index 30b352e..b16a570 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,6 @@ -dnspython>=2.6.1 -cymruwhois>=1.6 -httpx>=0.27.0 +aioquic>=1.2.0 cryptography>=42.0.7 +cymruwhois>=1.6 +dnspython>=2.7.0 h2>=4.1.0 +httpx>=0.27.0 diff --git a/setup.py b/setup.py index be438e0..03988fe 100644 --- a/setup.py +++ b/setup.py @@ -6,18 +6,18 @@ version=__version__, packages=find_packages(), scripts=["dnseval.py", "dnsping.py", "dnstraceroute.py"], - install_requires=['dnspython>=2.6.1', 'cymruwhois>=1.6', 'httpx>=0.27.0', 'cryptography>=42.0.7', 'h2>=4.1.0'], + install_requires=['aioquic>=1.2.0', 'cryptography>=42.0.7', 'cymruwhois>=1.6', 'dnspython>=2.7.0', 'h2>=4.1.0', 'httpx>=0.27.0'], classifiers=[ "Topic :: System :: Networking", "Environment :: Console", "Intended Audience :: Developers", "License :: OSI Approved :: BSD License", - "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", "Programming Language :: Python :: Implementation :: PyPy", "Topic :: Internet :: Name Service (DNS)", "Development Status :: 5 - Production/Stable", diff --git a/util/dns.py b/util/dns.py index 33f05e6..7449779 100644 --- a/util/dns.py +++ b/util/dns.py @@ -46,6 +46,7 @@ PROTO_TCP = 1 PROTO_TLS = 2 PROTO_HTTPS = 3 +PROTO_QUIC = 4 _TTL = None @@ -70,10 +71,22 @@ def proto_to_text(proto): PROTO_TCP: 'TCP', PROTO_TLS: 'TLS', PROTO_HTTPS: 'HTTPS', + PROTO_QUIC: 'QUIC', } return _proto_name[proto] +def getDefaultPort(proto): + _proto_port = { + PROTO_UDP: 53, + PROTO_TCP: 53, + PROTO_TLS: 853, # RFC 7858, Secion 3.1 + PROTO_HTTPS: 443, + PROTO_QUIC: 853, # RFC 9250, Section 4.1.1 + } + return _proto_port[proto] + + class CustomSocket(socket.socket): def __init__(self, *args, **kwargs): super(CustomSocket, self).__init__(*args, **kwargs) @@ -192,10 +205,12 @@ def signal_handler(sig, frame): shutdown = True # pressed once, exit gracefully -def unsupported_feature(): +def unsupported_feature(feature=""): print("Error: You have an unsupported version of Python interpreter dnspython library.") print(" Some features such as DoT and DoH are not available. You should upgrade") print(" the Python interpreter to at least 3.7 and reinstall dependencies.") + if feature: + print("Missing Feature: %s" % feature) sys.exit(127)