Skip to content

Commit

Permalink
Add support for DNS over QUIC (DoQ) protocol (#118)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
farrokhi authored Oct 25, 2024
1 parent 51924c6 commit bb73898
Show file tree
Hide file tree
Showing 6 changed files with 95 additions and 27 deletions.
6 changes: 3 additions & 3 deletions .github/workflows/packages.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions build-pkgs.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
84 changes: 68 additions & 16 deletions dnsping.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)'
Expand All @@ -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
Expand All @@ -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)
Expand Down Expand Up @@ -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')
Expand All @@ -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
Expand All @@ -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"
Expand All @@ -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)
Expand All @@ -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()

Expand All @@ -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]" %
Expand Down Expand Up @@ -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:
Expand Down
7 changes: 4 additions & 3 deletions requirements.txt
Original file line number Diff line number Diff line change
@@ -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
4 changes: 2 additions & 2 deletions setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
17 changes: 16 additions & 1 deletion util/dns.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@
PROTO_TCP = 1
PROTO_TLS = 2
PROTO_HTTPS = 3
PROTO_QUIC = 4

_TTL = None

Expand All @@ -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)
Expand Down Expand Up @@ -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)


Expand Down

0 comments on commit bb73898

Please sign in to comment.