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

Reply via email to