Script 'mail_helper' called by obssrc Hello community, here is the log from the commit of package dnsdiag for openSUSE:Factory checked in at 2026-10-17 21:25:05 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ Comparing /work/SRC/openSUSE:Factory/dnsdiag (Old) and /work/SRC/openSUSE:Factory/.dnsdiag.new.3698736 (New) ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Package is "dnsdiag" Sat Oct 17 21:25:05 2026 rev:15 rq:1385376 version:2.10.0 Changes: -------- --- /work/SRC/openSUSE:Factory/dnsdiag/dnsdiag.changes 2026-07-17 18:50:42.427744886 +0200 +++ /work/SRC/openSUSE:Factory/.dnsdiag.new.3698736/dnsdiag.changes 2026-10-17 21:25:07.672193109 +0200 @@ -1,0 +2,47 @@ +Sat Oct 17 09:16:03 UTC 2026 - Dirk Müller <[email protected]> + +- fix requirements + +------------------------------------------------------------------- +Wed Oct 14 18:46:58 UTC 2026 - Martin Hauke <[email protected]> + +- Update to version 2.10.0 + Breaking Changes + * Python 3.11 or newer is required. Python 3.10 is EoL since + 2026-10-01. + * dnspython 2.9.0 or newer is required. dnspython now does DoH + through httpx2 instead of httpx. This changes the dependencies + to dnspython[doh,doq] and httpx2. The dependency on httpx, h2 + and aioquic is therefore removed. + * DoH verifies server certificates against the OS trust store, + instead of certificates that are bundled with certifi. This + means you will need to have system CA certificates installed + (for example the ca-certificates package) on Linux. + Security + * dnspython 2.9.0 limits DNS name decompression chains, so a + crafted response can no longer make name parsing use excessive + CPU (GHSA-45c3-73f7-pv4m). This also impacts dnsdiag, since it + parses responses from arbitrary servers. + New Features + * Added Python 3.15 support + Bug Fixes + * Fixed DoH and DoQ in the binary packages. In 2.9.4, --doh + reported "python httpx module not available" and --quic crashed, + because the packages did not include the package metadata + dnspython checks before enabling these protocols. + * Fixed a crash in dnsping -v on EDNS options that dnspython + parses into typed classes, such as Report-Channel and the EDE + language and filtering options added in dnspython 2.9. + * dnsping now prints "Malformed response" and counts the query as + lost when a response cannot be parsed, and dnseval shows + "Invalid Response". dnspython 2.9 rejects more malformed + responses than before, for example ECS options with non-zero + padding bits. + * dnsping exits with an error message instead of a traceback when + DoQ or DoH3 support is unavailable. + Improvements + * With --flags, the EDNS CO flag (Compact Answers OK, RFC 9824) is + now shown by name, and flag bits without a name appear as FLAGn + instead of being dropped. + +------------------------------------------------------------------- Old: ---- dnsdiag-2.9.4.tar.gz New: ---- dnsdiag-2.10.0.tar.gz ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ Other differences: ------------------ ++++++ dnsdiag.spec ++++++ --- /var/tmp/diff_new_pack.QyessM/_old 2026-10-17 21:25:08.577230864 +0200 +++ /var/tmp/diff_new_pack.QyessM/_new 2026-10-17 21:25:08.579230947 +0200 @@ -20,7 +20,7 @@ %define pythons python3 %bcond_with test Name: dnsdiag -Version: 2.9.4 +Version: 2.10.0 Release: 0 Summary: DNS request auditing toolset License: BSD-3-Clause @@ -32,19 +32,17 @@ Source2: dnsping.1 Source3: dnstraceroute.1 BuildRequires: %{python_module pip} -BuildRequires: %{python_module setuptools} +BuildRequires: %{python_module setuptools >= 61.0} BuildRequires: fdupes BuildRequires: python-rpm-macros -Requires: python3-aioquic >= 1.2.0 Requires: python3-cryptography >= 42.0.5 Requires: python3-cymruwhois >= 1.6 -Requires: python3-dnspython >= 2.8.0 -Requires: python3-h2 >= 4.1.0 -Requires: python3-httpx >= 0.27.0 +Requires: python3-dnspython >= 2.9.0 +Requires: python3-httpx2 >= 2.13.0 BuildArch: noarch %if %{with test} BuildRequires: python3-cymruwhois >= 1.6 -BuildRequires: python3-dnspython >= 2.6.1 +BuildRequires: python3-dnspython >= 2.9.0 BuildRequires: python3-pytest %endif @@ -59,9 +57,9 @@ its destination. Its purpose is to detect whether a request is redirected or hijacked. This can be done by comparing different DNS queries being sent to the same DNS server using dnstraceroute and -observe if there is any difference between the path. +observe if there is any difference between the paths. -dnseval evaluates multiple DNS resolvers and helps choosing the best +dnseval evaluates multiple DNS resolvers and helps in choosing the best DNS server for the network. It is recommended to use one's own DNS resolver as opposed to a third-party DNS server. dnseval can compare different DNS servers from a performance (latency) and reliability ++++++ dnsdiag-2.9.4.tar.gz -> dnsdiag-2.10.0.tar.gz ++++++ diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/dnsdiag-2.9.4/.github/workflows/build-release.yml new/dnsdiag-2.10.0/.github/workflows/build-release.yml --- old/dnsdiag-2.9.4/.github/workflows/build-release.yml 2026-07-15 09:43:43.000000000 +0200 +++ new/dnsdiag-2.10.0/.github/workflows/build-release.yml 2026-10-14 11:54:32.000000000 +0200 @@ -22,7 +22,7 @@ steps: - uses: actions/checkout@v7 - - uses: actions/setup-python@v6 + - uses: actions/setup-python@v7 with: python-version: "3.14" cache: 'pip' diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/dnsdiag-2.9.4/.github/workflows/tests.yml new/dnsdiag-2.10.0/.github/workflows/tests.yml --- old/dnsdiag-2.9.4/.github/workflows/tests.yml 2026-07-15 09:43:43.000000000 +0200 +++ new/dnsdiag-2.10.0/.github/workflows/tests.yml 2026-10-14 11:54:32.000000000 +0200 @@ -22,7 +22,7 @@ - uses: actions/checkout@v7 - name: Set up Python 3.14 - uses: actions/setup-python@v6 + uses: actions/setup-python@v7 with: python-version: "3.14" cache: 'pip' @@ -31,7 +31,7 @@ - name: Install linting dependencies run: | python -m pip install --upgrade pip || exit 1 - pip install mypy flake8 Flake8-pyproject ruff || exit 1 + pip install mypy flake8 Flake8-pyproject ruff==0.16.1 || exit 1 pip install -r requirements.txt || exit 1 - name: Run mypy type checks @@ -53,13 +53,13 @@ strategy: fail-fast: false matrix: - python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"] + python-version: ["3.11", "3.12", "3.13", "3.14", "3.15"] steps: - uses: actions/checkout@v7 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v6 + uses: actions/setup-python@v7 with: python-version: ${{ matrix.python-version }} cache: 'pip' @@ -73,7 +73,7 @@ - name: Run unit tests (no network) run: | - python -m pytest tests/test_shared.py tests/test_packaging.py -v --tb=short + python -m pytest tests/test_shared.py tests/test_packaging.py tests/test_exception_handling.py tests/test_local_responses.py -v --tb=short env: PYTHONPATH: . @@ -86,13 +86,13 @@ matrix: # Full OS coverage for critical Python versions os: [ubuntu-latest, ubuntu-24.04-arm, macos-15-intel, macos-latest, windows-latest] - python-version: ["3.10", "3.13", "3.14"] + python-version: ["3.11", "3.14", "3.15"] steps: - uses: actions/checkout@v7 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v6 + uses: actions/setup-python@v7 with: python-version: ${{ matrix.python-version }} cache: 'pip' @@ -107,7 +107,8 @@ - name: Verify critical dependencies run: | python -c "import dns; print(f'dnspython version: {dns.version.version}')" || exit 1 - python -c "import httpx; print(f'httpx version: {httpx.__version__}')" || exit 1 + python -c "import httpx2; print(f'httpx2 version: {httpx2.__version__}')" || exit 1 + python -c "import dns.query, dns.quic; assert dns.query.have_doh and dns.quic.have_quic" || exit 1 - name: Run integration tests run: | @@ -136,13 +137,13 @@ matrix: # Reduced OS coverage for stable Python versions os: [ubuntu-latest, macos-latest] - python-version: ["3.11", "3.12"] + python-version: ["3.12", "3.13"] steps: - uses: actions/checkout@v7 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v6 + uses: actions/setup-python@v7 with: python-version: ${{ matrix.python-version }} cache: 'pip' diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/dnsdiag-2.9.4/CHANGELOG.md new/dnsdiag-2.10.0/CHANGELOG.md --- old/dnsdiag-2.9.4/CHANGELOG.md 2026-07-15 09:43:43.000000000 +0200 +++ new/dnsdiag-2.10.0/CHANGELOG.md 2026-10-14 11:54:32.000000000 +0200 @@ -6,6 +6,28 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). <!-- insertion marker --> +## [v2.10.0](https://github.com/farrokhi/dnsdiag/releases/tag/v2.10.0) - 2026-10-14 + +<small>[Compare with v2.9.4](https://github.com/farrokhi/dnsdiag/compare/v2.9.4...v2.10.0)</small> + +### Build + +- check DoH, DoH3 and DoQ in frozen binaries before packaging ([d18fea3](https://github.com/farrokhi/dnsdiag/commit/d18fea353241eedcb9fe33085a7fda3cfe2774cb) by [@farrokhi](https://github.com/farrokhi)). +- move to dnspython 2.9 and httpx2, require Python 3.11 ([461dafd](https://github.com/farrokhi/dnsdiag/commit/461dafd160d7d1e4bd7543ae6f6652ea998f4f3f) by [@farrokhi](https://github.com/farrokhi)). +- update pytest-benchmark requirement from >=5.2.3 to >=5.3.0 ([57406f2](https://github.com/farrokhi/dnsdiag/commit/57406f295b22a2875d90435f72520d981703aae4) by [@dependabot[bot]](https://github.com/dependabot[bot])). +- update cryptography requirement ([166a7f7](https://github.com/farrokhi/dnsdiag/commit/166a7f7d806be5e1367e4eeb74247aede10e824d) by [@dependabot[bot]](https://github.com/dependabot[bot])). +- bump actions/setup-python from 6 to 7 ([46214f1](https://github.com/farrokhi/dnsdiag/commit/46214f152ef8209970de516e970e334df75ebca9) by [@dependabot[bot]](https://github.com/dependabot[bot])). + +### Bug Fixes + +- report malformed responses and missing QUIC support instead of crash ([ccafbef](https://github.com/farrokhi/dnsdiag/commit/ccafbefbd8c54f9e164cee10e3919101edcec69b) by [@farrokhi](https://github.com/farrokhi)). +- don't crash on typed EDNS options in verbose output ([835883f](https://github.com/farrokhi/dnsdiag/commit/835883fa765b5e63952b9ab934b8b6dd969ff1d6) by [@farrokhi](https://github.com/farrokhi)). +- bundle package metadata to fix frozen binaries (DoQ and DoH) ([c576fd3](https://github.com/farrokhi/dnsdiag/commit/c576fd3352e65a04259888636441fe33deffd4af) by [@farrokhi](https://github.com/farrokhi)). + +### Docs + +- note Python 3.11 requirement and document offline tests ([f404682](https://github.com/farrokhi/dnsdiag/commit/f404682e2b755b5f1b5040e9daf514e6eb508299) by [@farrokhi](https://github.com/farrokhi)). + ## [v2.9.4](https://github.com/farrokhi/dnsdiag/releases/tag/v2.9.4) - 2026-07-01 <small>[Compare with v2.9.3](https://github.com/farrokhi/dnsdiag/compare/v2.9.3...v2.9.4)</small> diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/dnsdiag-2.9.4/README.md new/dnsdiag-2.10.0/README.md --- old/dnsdiag-2.9.4/README.md 2026-07-15 09:43:43.000000000 +0200 +++ new/dnsdiag-2.10.0/README.md 2026-10-14 11:54:32.000000000 +0200 @@ -31,7 +31,8 @@ # Installation There are several ways to use this toolset, though we recommend running it -directly from the source code for optimal flexibility and control. +directly from the source code for optimal flexibility and control. Running from +source or installing with pip requires Python 3.11 or newer. ## Source Code diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/dnsdiag-2.9.4/build-pkgs.sh new/dnsdiag-2.10.0/build-pkgs.sh --- old/dnsdiag-2.9.4/build-pkgs.sh 2026-07-15 09:43:43.000000000 +0200 +++ new/dnsdiag-2.10.0/build-pkgs.sh 2026-10-14 11:54:32.000000000 +0200 @@ -89,11 +89,17 @@ for i in dnsping.py dnstraceroute.py dnseval.py; do msg "Building package for ${i}" + # dnspython enables DoH/DoQ only after checking these packages' versions via + # importlib.metadata, so their dist-info must be bundled or both report unavailable. pyinstaller ${i} -y --onefile --clean \ --log-level=ERROR \ --distpath="${PKG_PATH}" \ --hidden-import=dns \ - --hidden-import=httpx + --hidden-import=httpx2 \ + --copy-metadata=httpx2 \ + --copy-metadata=httpcore2 \ + --copy-metadata=h2 \ + --copy-metadata=aioquic done msg "Verifying built binaries..." @@ -114,6 +120,19 @@ die "Binary failed to execute: ${BINARY}" fi done + +# --help passes even when dnspython has DoH/DoQ disabled in the frozen binary, as +# happened in 2.9.4. A query to a closed local port (9/discard) should end in a +# connection error; dnsping exits non-zero only if the protocol is unavailable or it crashed. +DNSPING="${PKG_PATH}/dnsping" +[ "${PLATFORM}" = "windows" ] && DNSPING="${DNSPING}.exe" +for proto in --doh --http3 --quic; do + msg "Testing dnsping ${proto}..." + if ! OUTPUT=$("${DNSPING}" ${proto} -s 127.0.0.1 -p 9 -c 1 -w 1 example.com 2>&1); then + echo "${OUTPUT}" 1>&2 + die "dnsping ${proto} is not functional in the built binary" + fi +done msg "All binaries verified successfully" msg "Adding extra files..." diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/dnsdiag-2.9.4/dnsdiag/dns.py new/dnsdiag-2.10.0/dnsdiag/dns.py --- old/dnsdiag-2.9.4/dnsdiag/dns.py 2026-07-15 09:43:43.000000000 +0200 +++ new/dnsdiag-2.10.0/dnsdiag/dns.py 2026-10-14 11:54:32.000000000 +0200 @@ -1,4 +1,3 @@ -#!/usr/bin/env python3 # # Copyright (c) 2016-2026, Babak Farrokhi # All rights reserved. @@ -30,14 +29,15 @@ from statistics import stdev from typing import Any -import httpx import dns.edns +import dns.exception import dns.message import dns.query import dns.rcode import dns.rdataclass +import httpx2 -from dnsdiag.shared import random_string, err, unsupported_feature +from dnsdiag.shared import err, random_string, unsupported_feature # Transport protocols PROTO_UDP: int = 0 @@ -173,12 +173,12 @@ else: unsupported_feature() - except dns.query.NoDOH: + except (dns.query.NoDOH, dns.query.NoDOQ): raise - except (httpx.ConnectTimeout, httpx.ReadTimeout, - httpx.ConnectError): + except (httpx2.ConnectTimeout, httpx2.ReadTimeout, + httpx2.ConnectError): raise ConnectionError('Connection failed') - except ValueError: + except (ValueError, dns.exception.FormError): retval.rcode_text = "Invalid Response" break except dns.exception.Timeout: diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/dnsdiag-2.9.4/dnsdiag/shared.py new/dnsdiag-2.10.0/dnsdiag/shared.py --- old/dnsdiag-2.9.4/dnsdiag/shared.py 2026-07-15 09:43:43.000000000 +0200 +++ new/dnsdiag-2.10.0/dnsdiag/shared.py 2026-10-14 11:54:32.000000000 +0200 @@ -1,4 +1,3 @@ -#!/usr/bin/env python3 # # Copyright (c) 2016-2026, Babak Farrokhi # All rights reserved. @@ -34,7 +33,7 @@ shutdown: bool = False -__version__ = '2.9.4' +__version__ = '2.10.0' def signal_handler(sig: int, frame: Any) -> None: @@ -156,8 +155,7 @@ return False # Remove trailing dot if present (FQDN notation) - if hostname.endswith('.'): - hostname = hostname[:-1] + hostname = hostname.removesuffix('.') # Check overall length (max 253 characters for FQDN) if len(hostname) > 253: diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/dnsdiag-2.9.4/dnsdiag/whois.py new/dnsdiag-2.10.0/dnsdiag/whois.py --- old/dnsdiag-2.9.4/dnsdiag/whois.py 2026-07-15 09:43:43.000000000 +0200 +++ new/dnsdiag-2.10.0/dnsdiag/whois.py 2026-10-14 11:54:32.000000000 +0200 @@ -1,4 +1,3 @@ -#!/usr/bin/env python3 # # Copyright (c) 2016-2026, Babak Farrokhi # All rights reserved. @@ -55,7 +54,7 @@ with open(WHOIS_CACHE_FILE, 'rb') as pkl_file: whois: dict[str, Any] = pickle.load(pkl_file) return whois - except (IOError, EOFError, pickle.UnpicklingError): + except (OSError, EOFError, pickle.UnpicklingError): return {} diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/dnsdiag-2.9.4/dnseval.py new/dnsdiag-2.10.0/dnseval.py --- old/dnsdiag-2.9.4/dnseval.py 2026-07-15 09:43:43.000000000 +0200 +++ new/dnsdiag-2.10.0/dnseval.py 2026-10-14 11:54:32.000000000 +0200 @@ -42,10 +42,27 @@ import dns.resolver import dnsdiag.dns -from dnsdiag.dns import PROTO_UDP, PROTO_TCP, PROTO_TLS, PROTO_HTTPS, PROTO_QUIC, PROTO_HTTP3, flags_to_text, get_default_port -import dnsdiag.shared as shared -from dnsdiag.shared import __version__, Colors, valid_hostname, die, err, set_protocol_exclusive, parse_ip_address, \ - setup_signal_handler +from dnsdiag import shared +from dnsdiag.dns import ( + PROTO_HTTP3, + PROTO_HTTPS, + PROTO_QUIC, + PROTO_TCP, + PROTO_TLS, + PROTO_UDP, + flags_to_text, + get_default_port, +) +from dnsdiag.shared import ( + Colors, + __version__, + die, + err, + parse_ip_address, + set_protocol_exclusive, + setup_signal_handler, + valid_hostname, +) __author__ = 'Babak Farrokhi ([email protected])' __license__ = 'BSD' @@ -161,10 +178,9 @@ if json_filename == '-': output_lines.append(json.dumps(outer_data)) else: - with print_lock: - with open(json_filename, 'a+') as outfile: - json.dump(outer_data, outfile) - outfile.write('\n') + with print_lock, open(json_filename, 'a+') as outfile: + json.dump(outer_data, outfile) + outfile.write('\n') else: result = "%s %-7.2f %-7.2f %-7.2f %-10.2f %s%%%-3d%s %-7s %-26s %-12s" % ( diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/dnsdiag-2.9.4/dnsping.py new/dnsdiag-2.10.0/dnsping.py --- old/dnsdiag-2.9.4/dnsping.py 2026-07-15 09:43:43.000000000 +0200 +++ new/dnsdiag-2.10.0/dnsping.py 2026-10-14 11:54:32.000000000 +0200 @@ -46,13 +46,33 @@ import dns.rdataclass import dns.rdatatype import dns.resolver -import httpx +import httpx2 -from dnsdiag.dns import PROTO_UDP, PROTO_TCP, PROTO_TLS, PROTO_HTTPS, PROTO_QUIC, PROTO_HTTP3, proto_to_text, \ - get_default_port, valid_rdatatype, CustomSocket -import dnsdiag.shared as shared -from dnsdiag.shared import __version__, valid_hostname, unsupported_feature, random_string, die, err, \ - set_protocol_exclusive, parse_ip_address, setup_signal_handler, resolve_server_address +from dnsdiag import shared +from dnsdiag.dns import ( + PROTO_HTTP3, + PROTO_HTTPS, + PROTO_QUIC, + PROTO_TCP, + PROTO_TLS, + PROTO_UDP, + CustomSocket, + get_default_port, + proto_to_text, + valid_rdatatype, +) +from dnsdiag.shared import ( + __version__, + die, + err, + parse_ip_address, + random_string, + resolve_server_address, + set_protocol_exclusive, + setup_signal_handler, + unsupported_feature, + valid_hostname, +) __author__ = 'Babak Farrokhi ([email protected])' __license__ = 'BSD' @@ -439,8 +459,8 @@ source=src_ip, source_port=src_port, http_version=dns.query.HTTPVersion.HTTP_2) except dns.query.NoDOH: - die("ERROR: python httpx module not available") - except httpx.ConnectError: + die("ERROR: DNS-over-HTTPS requires the httpx2, httpcore2 and h2 modules") + except httpx2.ConnectError: if not quiet: print("Connection refused", flush=True) continue @@ -454,7 +474,7 @@ continue except ssl.SSLCertVerificationError as e: die(f"Certificate verification failed: {e}") - except (ssl.SSLError, httpx.WriteTimeout, httpx.StreamError, httpx.ProtocolError): + except (ssl.SSLError, httpx2.WriteTimeout, httpx2.StreamError, httpx2.ProtocolError): if not quiet: print("Connection failed", flush=True) continue @@ -472,6 +492,8 @@ answers = dns.query.https(query, https_server, timeout=timeout, port=dst_port, source=src_ip, source_port=src_port, http_version=dns.query.HTTPVersion.H3) + except dns.query.NoDOH: + die("ERROR: DNS-over-HTTP/3 requires the aioquic module") except ConnectionRefusedError: if not quiet: print("Connection refused", flush=True) @@ -486,7 +508,7 @@ continue except ssl.SSLCertVerificationError as e: die(f"Certificate verification failed: {e}") - except (ssl.SSLError, httpx.WriteTimeout, httpx.StreamError, httpx.ProtocolError): + except (ssl.SSLError, httpx2.WriteTimeout, httpx2.StreamError, httpx2.ProtocolError): if not quiet: print("Connection failed", flush=True) continue @@ -509,6 +531,8 @@ answers = dns.query.quic(query, dnsserver_ip, timeout=timeout, port=dst_port, source=src_ip, source_port=src_port, server_hostname=server_hostname) + except dns.query.NoDOQ: + die("ERROR: DNS-over-QUIC requires the aioquic module") except dns.exception.Timeout: if not quiet: print("Request timeout", flush=True) @@ -549,10 +573,10 @@ if verbose: err(f"ERROR: {e}") sys.exit(1) - except (httpx.ConnectTimeout, dns.exception.Timeout): + except (httpx2.ConnectTimeout, dns.exception.Timeout): if not quiet: print("Request timeout", flush=True) - except httpx.ReadTimeout: + except httpx2.ReadTimeout: if not quiet: print("Read timeout", flush=True) except EOFError: @@ -574,6 +598,9 @@ die(f"ERROR: {e}") else: sys.exit(1) + except dns.exception.FormError: + if not quiet: + print("Malformed response", flush=True) except ValueError: if not quiet: err("ERROR: invalid response") @@ -702,8 +729,12 @@ tag = struct.unpack('!H', ans_opt.data[idx:idx + 2])[0] key_tags.append(str(tag)) option_details = "tags=[%s]" % ",".join(key_tags) - else: + elif isinstance(ans_opt, dns.edns.GenericOption): option_details = "type=%d, length=%d" % (ans_opt.otype, len(ans_opt.data)) + else: + # Options dnspython parses into a typed class carry no raw .data; their + # to_text() is "NAME value", which fits the "NAME (code): value" layout. + option_name, _, option_details = ans_opt.to_text().partition(" ") print("%s (%d): %s" % (option_name, ans_opt.otype, option_details), flush=True) diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/dnsdiag-2.9.4/dnstraceroute.py new/dnsdiag-2.10.0/dnstraceroute.py --- old/dnsdiag-2.9.4/dnstraceroute.py 2026-07-15 09:43:43.000000000 +0200 +++ new/dnsdiag-2.10.0/dnstraceroute.py 2026-10-14 11:54:32.000000000 +0200 @@ -40,10 +40,19 @@ import dns.resolver import dnsdiag.whois -from dnsdiag.dns import PROTO_UDP, PROTO_TCP, PROTO_QUIC, PROTO_HTTP3, get_default_port -import dnsdiag.shared as shared -from dnsdiag.shared import __version__, Colors, valid_hostname, die, err, set_protocol_exclusive, parse_ip_address, \ - setup_signal_handler, resolve_server_address +from dnsdiag import shared +from dnsdiag.dns import PROTO_HTTP3, PROTO_QUIC, PROTO_TCP, PROTO_UDP, get_default_port +from dnsdiag.shared import ( + Colors, + __version__, + die, + err, + parse_ip_address, + resolve_server_address, + set_protocol_exclusive, + setup_signal_handler, + valid_hostname, +) # Global Variables quiet = False @@ -396,7 +405,7 @@ curr_addr = None else: curr_addr = None - except socket.error: + except OSError: pass except KeyboardInterrupt: shared.shutdown = True @@ -429,7 +438,7 @@ try: if curr_addr: curr_name = socket.gethostbyaddr(curr_addr)[0] - except socket.error: + except OSError: curr_name = curr_addr except KeyboardInterrupt: shared.shutdown = True diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/dnsdiag-2.9.4/pyproject.toml new/dnsdiag-2.10.0/pyproject.toml --- old/dnsdiag-2.9.4/pyproject.toml 2026-07-15 09:43:43.000000000 +0200 +++ new/dnsdiag-2.10.0/pyproject.toml 2026-10-14 11:54:32.000000000 +0200 @@ -4,10 +4,10 @@ [project] name = "dnsdiag" -version = "2.9.4" +version = "2.10.0" description = "DNS Measurement, Troubleshooting and Security Auditing Toolset (ping, traceroute)" readme = "README.md" -requires-python = ">=3.10" +requires-python = ">=3.11" license = "BSD-2-Clause" license-files = ["LICENSE"] authors = [ @@ -18,22 +18,20 @@ "Topic :: System :: Networking", "Environment :: Console", "Intended Audience :: Developers", - "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.14", + "Programming Language :: Python :: 3.15", "Topic :: Internet :: Name Service (DNS)", "Development Status :: 5 - Production/Stable", "Operating System :: OS Independent", ] dependencies = [ - "aioquic>=1.2.0", - "cryptography>=42.0.5,<50", + "cryptography>=42.0.5,<51", "cymruwhois>=1.6", - "dnspython>=2.8.0", - "h2>=4.1.0", - "httpx>=0.27.0", + "dnspython[doh,doq]>=2.9.0", + "httpx2>=2.13.0", ] [project.urls] @@ -51,7 +49,8 @@ py-modules = ["dnsping", "dnstraceroute", "dnseval"] [tool.git-changelog] -convention = "basic" +convention = "conventional" +sections = ["build", "feat", "fix", "docs"] in-place = true marker-line = "<!-- insertion marker -->" output = "CHANGELOG.md" @@ -59,7 +58,7 @@ parse-trailers = false [tool.mypy] -python_version = "3.10" +python_version = "3.11" mypy_path = "." namespace_packages = true ignore_missing_imports = true @@ -91,7 +90,7 @@ ignore_errors = false [[tool.mypy.overrides]] -module = ["cymruwhois", "dns.*", "httpx"] +module = ["cymruwhois", "dns.*", "httpx2"] ignore_missing_imports = true [tool.flake8] @@ -102,7 +101,10 @@ ignore = ["E501", "E741"] [tool.ruff] -target-version = "py310" +target-version = "py311" [tool.ruff.lint] +# Pin the rule set explicitly so it does not shift when ruff's defaults change. +# Beyond the E/F/W baseline, only rules we have cleaned up are enabled. +select = ["E", "F", "W", "I", "EXE001", "PLR0402", "UP024", "FURB188", "SIM117"] ignore = ["E501", "E741"] diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/dnsdiag-2.9.4/requirements.txt new/dnsdiag-2.10.0/requirements.txt --- old/dnsdiag-2.9.4/requirements.txt 2026-07-15 09:43:43.000000000 +0200 +++ new/dnsdiag-2.10.0/requirements.txt 2026-10-14 11:54:32.000000000 +0200 @@ -1,6 +1,4 @@ -aioquic>=1.2.0 -cryptography>=42.0.5,<50 +cryptography>=42.0.5,<51 cymruwhois>=1.6 -dnspython>=2.8.0 -h2>=4.1.0 -httpx>=0.27.0 +dnspython[doh,doq]>=2.9.0 +httpx2>=2.13.0 diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/dnsdiag-2.9.4/tests/README.md new/dnsdiag-2.10.0/tests/README.md --- old/dnsdiag-2.9.4/tests/README.md 2026-07-15 09:43:43.000000000 +0200 +++ new/dnsdiag-2.10.0/tests/README.md 2026-10-14 11:54:32.000000000 +0200 @@ -7,6 +7,7 @@ This directory contains automated tests for: - **dnsping** - DNS latency and response testing - **dnstraceroute** - DNS path tracing with IPv4/IPv6 support +- **transport error handling** - offline tests that need no network access ## Quick Start @@ -90,6 +91,24 @@ pytest tests/test_dnstraceroute_pytest.py::TestAddressFamilyHandling ``` +### Offline Error Handling Tests + +These run without network access and are part of the CI unit test job. + +`test_exception_handling.py` replaces dnspython's query functions with stubs that +raise a chosen exception, so every error handler in `dnsping` and +`dnsdiag.dns.ping()` is exercised: httpx2 connection and timeout errors on DoH, +QUIC errors on DoQ and DoH3, malformed responses, and missing DoH/DoQ support. + +`test_local_responses.py` runs `dnsping` and `dnseval` against local stand-in +servers: a UDP responder that sends unusual EDNS options or malformed replies, a +closed port that refuses DoH connections, and a listener that never completes the +TLS handshake. + +```bash +pytest tests/test_exception_handling.py tests/test_local_responses.py +``` + ## Common Usage Patterns ### Run Tests by Pattern @@ -197,10 +216,10 @@ test: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 - - uses: actions/setup-python@v4 + - uses: actions/checkout@v7 + - uses: actions/setup-python@v7 with: - python-version: '3.9' + python-version: '3.15' - name: Install dependencies run: | pip install -r tests/test-requirements.txt diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/dnsdiag-2.9.4/tests/test-requirements.txt new/dnsdiag-2.10.0/tests/test-requirements.txt --- old/dnsdiag-2.9.4/tests/test-requirements.txt 2026-07-15 09:43:43.000000000 +0200 +++ new/dnsdiag-2.10.0/tests/test-requirements.txt 2026-10-14 11:54:32.000000000 +0200 @@ -14,7 +14,7 @@ pytest-github-actions-annotate-failures>=0.4.2 # GitHub Actions integration (optional) # Performance and profiling (optional) -pytest-benchmark>=5.2.3 # Performance benchmarking (optional) +pytest-benchmark>=5.3.0 # Performance benchmarking (optional) # Better assertion introspection pytest-clarity>=1.0.1 # Better assertion failure messages (optional) \ No newline at end of file diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/dnsdiag-2.9.4/tests/test_exception_handling.py new/dnsdiag-2.10.0/tests/test_exception_handling.py --- old/dnsdiag-2.9.4/tests/test_exception_handling.py 1970-01-01 01:00:00.000000000 +0100 +++ new/dnsdiag-2.10.0/tests/test_exception_handling.py 2026-10-14 11:54:32.000000000 +0200 @@ -0,0 +1,214 @@ +""" +Offline tests for how dnsping and dnsdiag.dns.ping() react to transport errors. + +The dnspython query functions are replaced with stubs that raise a chosen +exception, so every handler can be reached without a network or a server that +misbehaves on demand. test_local_responses.py covers the subset that can be +triggered for real. +""" + +import signal +import sys +from collections.abc import Callable, Iterator +from typing import NoReturn + +import dns.exception +import dns.query +import httpx2 +import pytest +from aioquic.h3.exceptions import H3Error +from aioquic.quic.connection import QuicConnectionError +from aioquic.quic.stream import StreamFinishedError +from dns.quic._common import UnexpectedEOF + +import dnsdiag.dns +import dnsping +from dnsdiag.dns import PROTO_HTTP3, PROTO_HTTPS, PROTO_QUIC, PROTO_UDP + +ALL_LOST = "1 requests transmitted, 0 responses received, 100% lost" + + +def raiser(exc: BaseException) -> Callable[..., NoReturn]: + def query(*args: object, **kwargs: object) -> NoReturn: + raise exc + return query + + +class DNSPingRun: + def __init__(self, exit_code: int | str | None, stdout: str, stderr: str) -> None: + self.exit_code = exit_code + self.stdout = stdout + self.stderr = stderr + + [email protected] +def run_dnsping(monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str]) -> Iterator[Callable[..., DNSPingRun]]: + """Run dnsping.main() in-process with one dns.query function raising exc.""" + + def run(query_function: str, exc: BaseException, *args: str) -> DNSPingRun: + monkeypatch.setattr(dns.query, query_function, raiser(exc)) + monkeypatch.setattr(sys, "argv", ["dnsping.py", "-s", "127.0.0.1", "-c", "1", "-w", "1", *args, + "example.com"]) + exit_code: int | str | None = 0 + try: + dnsping.main() + except SystemExit as e: + exit_code = e.code + captured = capsys.readouterr() + return DNSPingRun(exit_code, captured.out, captured.err) + + # main() installs its own SIGINT handler; give pytest its handler back afterwards + previous_sigint = signal.getsignal(signal.SIGINT) + yield run + signal.signal(signal.SIGINT, previous_sigint) + + +CONNECTION_CLOSED = "Connection closed unexpectedly" +QUIC_CONNECTION_ERROR = QuicConnectionError(error_code=0, frame_type=0, reason_phrase="closed") + + +class TestDNSPingDoH: + """dnsping --doh, where dnspython raises httpx2 exceptions""" + + @pytest.mark.parametrize("exc,message", [ + (httpx2.ConnectError("refused"), "Connection refused"), + (httpx2.ConnectTimeout("timed out"), "Request timeout"), + (httpx2.ReadTimeout("timed out"), "Read timeout"), + (httpx2.WriteTimeout("timed out"), "Connection failed"), + (httpx2.StreamError("stream"), "Connection failed"), + (httpx2.ProtocolError("protocol"), "Connection failed"), + (dns.exception.FormError("bad wire"), "Malformed response"), + ], ids=lambda v: type(v).__name__ if isinstance(v, BaseException) else None) + def test_transient_errors(self, run_dnsping: Callable[..., DNSPingRun], exc: BaseException, + message: str) -> None: + result = run_dnsping("https", exc, "--doh") + assert result.exit_code == 0, result.stderr + assert message in result.stdout + assert ALL_LOST in result.stdout + + def test_invalid_response(self, run_dnsping: Callable[..., DNSPingRun]) -> None: + result = run_dnsping("https", ValueError("responded with status code 500"), "--doh") + assert result.exit_code == 0 + assert "ERROR: invalid response" in result.stderr + assert ALL_LOST in result.stdout + + def test_doh_unavailable(self, run_dnsping: Callable[..., DNSPingRun]) -> None: + result = run_dnsping("https", dns.query.NoDOH(), "--doh") + assert result.exit_code == 1 + assert "requires the httpx2, httpcore2 and h2 modules" in result.stderr + + +class TestDNSPingDoH3: + """dnsping --http3, which goes through aioquic and dnspython's QUIC layer""" + + @pytest.mark.parametrize("exc,message", [ + (httpx2.WriteTimeout("timed out"), "Connection failed"), + (httpx2.StreamError("stream"), "Connection failed"), + (httpx2.ProtocolError("protocol"), "Connection failed"), + (httpx2.ConnectTimeout("timed out"), "Request timeout"), + (UnexpectedEOF(), CONNECTION_CLOSED), + (QUIC_CONNECTION_ERROR, CONNECTION_CLOSED), + (H3Error(), CONNECTION_CLOSED), + (dns.exception.FormError("bad wire"), "Malformed response"), + ], ids=lambda v: type(v).__name__ if isinstance(v, BaseException) else None) + def test_transient_errors(self, run_dnsping: Callable[..., DNSPingRun], exc: BaseException, + message: str) -> None: + result = run_dnsping("https", exc, "--http3") + assert result.exit_code == 0, result.stderr + assert message in result.stdout + assert ALL_LOST in result.stdout + + def test_doh3_unavailable(self, run_dnsping: Callable[..., DNSPingRun]) -> None: + result = run_dnsping("https", dns.query.NoDOH("DNS-over-HTTP3 is not available."), "--http3") + assert result.exit_code == 1 + assert "DNS-over-HTTP/3 requires the aioquic module" in result.stderr + + +class TestDNSPingDoQ: + """dnsping --quic""" + + @pytest.mark.parametrize("exc,message", [ + (dns.exception.Timeout(), "Request timeout"), + (UnexpectedEOF(), CONNECTION_CLOSED), + (QUIC_CONNECTION_ERROR, CONNECTION_CLOSED), + (StreamFinishedError(), CONNECTION_CLOSED), + (dns.exception.FormError("bad wire"), "Malformed response"), + ], ids=lambda v: type(v).__name__ if isinstance(v, BaseException) else None) + def test_transient_errors(self, run_dnsping: Callable[..., DNSPingRun], exc: BaseException, + message: str) -> None: + result = run_dnsping("quic", exc, "--quic") + assert result.exit_code == 0, result.stderr + assert message in result.stdout + assert ALL_LOST in result.stdout + + def test_doq_unavailable(self, run_dnsping: Callable[..., DNSPingRun]) -> None: + result = run_dnsping("quic", dns.query.NoDOQ(), "--quic") + assert result.exit_code == 1 + assert "DNS-over-QUIC requires the aioquic module" in result.stderr + + +class TestDNSPingUDP: + """dnsping over plain UDP""" + + def test_malformed_response(self, run_dnsping: Callable[..., DNSPingRun]) -> None: + result = run_dnsping("udp", dns.exception.FormError("bad wire")) + assert result.exit_code == 0, result.stderr + assert "Malformed response" in result.stdout + assert ALL_LOST in result.stdout + + def test_malformed_response_quiet(self, run_dnsping: Callable[..., DNSPingRun]) -> None: + result = run_dnsping("udp", dns.exception.FormError("bad wire"), "-q") + assert result.exit_code == 0, result.stderr + assert "Malformed response" not in result.stdout + assert ALL_LOST in result.stdout + + +def ping(proto: int) -> dnsdiag.dns.PingResponse: + return dnsdiag.dns.ping("example.com", "127.0.0.1", 53, "A", 1, 1, proto, None) + + +class TestPingFunction: + """dnsdiag.dns.ping(), shared by dnseval and dnstraceroute""" + + @pytest.mark.parametrize("exc", [ + httpx2.ConnectError("refused"), + httpx2.ConnectTimeout("timed out"), + httpx2.ReadTimeout("timed out"), + ], ids=lambda e: type(e).__name__) + def test_doh_connection_failures(self, monkeypatch: pytest.MonkeyPatch, exc: BaseException) -> None: + monkeypatch.setattr(dns.query, "https", raiser(exc)) + with pytest.raises(ConnectionError, match="Connection failed"): + ping(PROTO_HTTPS) + + @pytest.mark.parametrize("query_function,proto", [ + ("udp", PROTO_UDP), + ("https", PROTO_HTTPS), + ("_http3", PROTO_HTTP3), + ("quic", PROTO_QUIC), + ]) + @pytest.mark.parametrize("exc", [ValueError("bad status"), dns.exception.FormError("bad wire")], + ids=lambda e: type(e).__name__) + def test_invalid_response(self, monkeypatch: pytest.MonkeyPatch, query_function: str, proto: int, + exc: BaseException) -> None: + monkeypatch.setattr(dns.query, query_function, raiser(exc)) + result = ping(proto) + assert result.rcode_text == "Invalid Response" + assert result.r_lost_percent == 100.0 + + def test_timeout(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(dns.query, "udp", raiser(dns.exception.Timeout())) + result = ping(PROTO_UDP) + assert result.rcode_text == "No Response" + assert result.r_lost_percent == 100.0 + + @pytest.mark.parametrize("query_function,proto,exc", [ + ("https", PROTO_HTTPS, dns.query.NoDOH), + ("_http3", PROTO_HTTP3, dns.query.NoDOH), + ("quic", PROTO_QUIC, dns.query.NoDOQ), + ]) + def test_transport_unavailable(self, monkeypatch: pytest.MonkeyPatch, query_function: str, proto: int, + exc: type[BaseException]) -> None: + monkeypatch.setattr(dns.query, query_function, raiser(exc())) + with pytest.raises(exc): + ping(proto) diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/dnsdiag-2.9.4/tests/test_local_responses.py new/dnsdiag-2.10.0/tests/test_local_responses.py --- old/dnsdiag-2.9.4/tests/test_local_responses.py 1970-01-01 01:00:00.000000000 +0100 +++ new/dnsdiag-2.10.0/tests/test_local_responses.py 2026-10-14 11:54:32.000000000 +0200 @@ -0,0 +1,199 @@ +""" +Offline tests that run the CLI tools against local stand-in servers. + +Public resolvers rarely send unusual EDNS options or malformed replies, and +refused or stalled DoH connections depend on the network. Local servers make +these cases reproducible while still going through dnspython's real parser +and transports, so the tests also catch changes in what dnspython raises. +""" + +import json +import socket +import subprocess +import sys +import threading +from collections.abc import Callable, Iterator +from pathlib import Path + +import dns.edns +import dns.message +import dns.name +import dns.rrset +import pytest + +PROJECT_ROOT = Path(__file__).resolve().parent.parent +ALL_LOST = "1 requests transmitted, 0 responses received, 100% lost" + +ReplyBuilder = Callable[[bytes], bytes] + + +class UDPResponder: + """Answer every UDP query on 127.0.0.1 with bytes from a reply builder.""" + + def __init__(self, build_reply: ReplyBuilder) -> None: + self.sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + self.sock.bind(("127.0.0.1", 0)) + self.sock.settimeout(0.1) + self.port: int = self.sock.getsockname()[1] + self._build_reply = build_reply + self._stop = threading.Event() + self._thread = threading.Thread(target=self._serve, daemon=True) + + def __enter__(self) -> "UDPResponder": + self._thread.start() + return self + + def __exit__(self, *exc_info: object) -> None: + self._stop.set() + self._thread.join() + self.sock.close() + + def _serve(self) -> None: + while not self._stop.is_set(): + try: + wire, peer = self.sock.recvfrom(65535) + except TimeoutError: + continue + self.sock.sendto(self._build_reply(wire), peer) + + +def answer_with_options(*options: dns.edns.Option) -> ReplyBuilder: + def build(wire: bytes) -> bytes: + query = dns.message.from_wire(wire) + response = dns.message.make_response(query) + response.use_edns(0, options=list(options)) + response.answer.append(dns.rrset.from_text(query.question[0].name, 300, "IN", "A", "192.0.2.1")) + return response.to_wire() + return build + + +# ECS for 192.0.0.0/20 whose last address octet carries bits beyond the +# source prefix length; RFC 7871 section 6 requires them to be zero. +NONZERO_PAD_ECS = dns.edns.GenericOption(dns.edns.OptionType.ECS, b"\x00\x01\x14\x00\xc0\x00\x0f") + + +def nonzero_pad_ecs_reply(wire: bytes) -> bytes: + return answer_with_options(NONZERO_PAD_ECS)(wire) + + +def truncated_reply(wire: bytes) -> bytes: + return answer_with_options()(wire)[:-2] + + +def run_tool(script: str, *args: str) -> subprocess.CompletedProcess[str]: + return subprocess.run([sys.executable, str(PROJECT_ROOT / script), *args], + capture_output=True, text=True, timeout=30) + + +def assert_clean_exit(result: subprocess.CompletedProcess[str]) -> None: + output = result.stdout + result.stderr + assert "Traceback" not in output, output + assert result.returncode == 0, output + + [email protected] +def closed_tcp_port() -> int: + s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + s.bind(("127.0.0.1", 0)) + port: int = s.getsockname()[1] + s.close() + return port + + [email protected] +def silent_tcp_port() -> Iterator[int]: + """A listener the kernel completes handshakes for, but which never speaks TLS.""" + s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + s.bind(("127.0.0.1", 0)) + s.listen(8) + yield s.getsockname()[1] + s.close() + + [email protected] +def server_list(tmp_path: Path) -> Path: + path = tmp_path / "servers.txt" + path.write_text("127.0.0.1\n") + return path + + +class TestEDNSOptionDisplay: + """dnsping --verbose must print every EDNS option, typed or generic""" + + def test_typed_and_generic_options(self) -> None: + options = [ + dns.edns.GenericOption(dns.edns.OptionType.REPORTCHANNEL, dns.name.from_text("agent.example.").to_wire()), + dns.edns.GenericOption(dns.edns.OptionType.EDE_EXTRA_TEXT_LANGUAGE, b"en"), + dns.edns.GenericOption(dns.edns.OptionType.FILTERING_CONTACT, b"mailto:[email protected]"), + dns.edns.GenericOption(65001, b"\x01\x02\x03"), + ] + with UDPResponder(answer_with_options(*options)) as server: + result = run_tool("dnsping.py", "-s", "127.0.0.1", "-p", str(server.port), + "-c", "1", "-w", "2", "-e", "-v", "example.com") + + assert_clean_exit(result) + assert "REPORTCHANNEL (18): agent.example." in result.stdout + assert "EDE-EXTRA-TEXT-LANGUAGE (22): en" in result.stdout + assert "FILTERING-CONTACT (23): mailto:[email protected]" in result.stdout + assert "UNKNOWN (65001): type=65001, length=3" in result.stdout + + +class TestMalformedResponses: + """Replies that dnspython refuses to parse count as lost, not as crashes""" + + @pytest.mark.parametrize("build_reply", [nonzero_pad_ecs_reply, truncated_reply], + ids=["ecs-nonzero-pad", "truncated"]) + def test_dnsping(self, build_reply: ReplyBuilder) -> None: + with UDPResponder(build_reply) as server: + result = run_tool("dnsping.py", "-s", "127.0.0.1", "-p", str(server.port), + "-c", "1", "-w", "2", "-e", "example.com") + + assert_clean_exit(result) + assert "Malformed response" in result.stdout + assert ALL_LOST in result.stdout + + @pytest.mark.parametrize("build_reply", [nonzero_pad_ecs_reply, truncated_reply], + ids=["ecs-nonzero-pad", "truncated"]) + def test_dnseval(self, build_reply: ReplyBuilder, server_list: Path) -> None: + with UDPResponder(build_reply) as server: + result = run_tool("dnseval.py", "-f", str(server_list), "-p", str(server.port), + "-c", "1", "-w", "2", "-e", "--skip-warmup", "-j", "-", "example.com") + + assert_clean_exit(result) + records = [json.loads(line) for line in result.stdout.splitlines() if line.startswith("{")] + assert len(records) == 1, result.stdout + assert records[0]["data"]["rcode_text"] == "Invalid Response" + + +class TestDoHConnectionFailures: + """Failures raised by dnspython's DoH transport (httpx2) are reported, not raised""" + + def test_dnsping_connection_refused(self, closed_tcp_port: int) -> None: + result = run_tool("dnsping.py", "--doh", "-s", "127.0.0.1", "-p", str(closed_tcp_port), + "-c", "1", "-w", "2", "example.com") + + assert_clean_exit(result) + assert "Connection refused" in result.stdout + assert ALL_LOST in result.stdout + + def test_dnsping_connect_timeout(self, silent_tcp_port: int) -> None: + result = run_tool("dnsping.py", "--doh", "-s", "127.0.0.1", "-p", str(silent_tcp_port), + "-c", "1", "-w", "1", "example.com") + + assert_clean_exit(result) + assert "Request timeout" in result.stdout + assert ALL_LOST in result.stdout + + def test_dnseval_connection_refused(self, closed_tcp_port: int, server_list: Path) -> None: + result = run_tool("dnseval.py", "--doh", "-f", str(server_list), "-p", str(closed_tcp_port), + "-c", "1", "-w", "2", "--skip-warmup", "example.com") + + assert_clean_exit(result) + assert "127.0.0.1: Connection failed" in result.stdout + + def test_dnseval_connect_timeout(self, silent_tcp_port: int, server_list: Path) -> None: + result = run_tool("dnseval.py", "--doh", "-f", str(server_list), "-p", str(silent_tcp_port), + "-c", "1", "-w", "1", "--skip-warmup", "example.com") + + assert_clean_exit(result) + assert "127.0.0.1: Connection failed" in result.stdout
