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-11-07 23:32:49
++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Comparing /work/SRC/openSUSE:Factory/dnsdiag (Old)
 and      /work/SRC/openSUSE:Factory/.dnsdiag.new.3759290 (New)
++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++

Package is "dnsdiag"

Sat Nov  7 23:32:49 2026 rev:16 rq:1395979 version:2.10.1

Changes:
--------
--- /work/SRC/openSUSE:Factory/dnsdiag/dnsdiag.changes  2026-10-17 
21:25:07.672193109 +0200
+++ /work/SRC/openSUSE:Factory/.dnsdiag.new.3759290/dnsdiag.changes     
2026-11-07 23:33:22.428433161 +0100
@@ -1,0 +2,14 @@
+Fri Nov  6 07:15:45 UTC 2026 - Martin Hauke <[email protected]>
+
+- Update to version 2.10.1
+  Build
+  * require dnspython 2.9.2, drop direct cryptography.
+  Bug Fixes
+  * escape reverse DNS names in hop lines.
+  * escape text from the whois service.
+  * don't report every DoH connect failure as refused.
+  * stop loading whois.cache from the working directory.
+  * send the server name on encrypted transports.
+  * escape server-supplied text and handle non-UTF-8 NSID.
+
+-------------------------------------------------------------------

Old:
----
  dnsdiag-2.10.0.tar.gz

New:
----
  dnsdiag-2.10.1.tar.gz

++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++

Other differences:
------------------
++++++ dnsdiag.spec ++++++
--- /var/tmp/diff_new_pack.1dy6Sm/_old  2026-11-07 23:33:23.578480680 +0100
+++ /var/tmp/diff_new_pack.1dy6Sm/_new  2026-11-07 23:33:23.580480762 +0100
@@ -20,7 +20,7 @@
 %define pythons python3
 %bcond_with test
 Name:           dnsdiag
-Version:        2.10.0
+Version:        2.10.1
 Release:        0
 Summary:        DNS request auditing toolset
 License:        BSD-3-Clause
@@ -35,14 +35,13 @@
 BuildRequires:  %{python_module setuptools >= 61.0}
 BuildRequires:  fdupes
 BuildRequires:  python-rpm-macros
-Requires:       python3-cryptography >= 42.0.5
 Requires:       python3-cymruwhois >= 1.6
 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.9.0
+BuildRequires:  python3-dnspython >= 2.9.2
 BuildRequires:  python3-pytest
 %endif
 

++++++ dnsdiag-2.10.0.tar.gz -> dnsdiag-2.10.1.tar.gz ++++++
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/dnsdiag-2.10.0/.github/workflows/tests.yml 
new/dnsdiag-2.10.1/.github/workflows/tests.yml
--- old/dnsdiag-2.10.0/.github/workflows/tests.yml      2026-10-14 
11:54:32.000000000 +0200
+++ new/dnsdiag-2.10.1/.github/workflows/tests.yml      2026-11-05 
09:47:46.000000000 +0100
@@ -73,7 +73,7 @@
 
       - name: Run unit tests (no network)
         run: |
-          python -m pytest tests/test_shared.py tests/test_packaging.py 
tests/test_exception_handling.py tests/test_local_responses.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 
tests/test_whois.py -v --tb=short
         env:
           PYTHONPATH: .
 
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/dnsdiag-2.10.0/CHANGELOG.md 
new/dnsdiag-2.10.1/CHANGELOG.md
--- old/dnsdiag-2.10.0/CHANGELOG.md     2026-10-14 11:54:32.000000000 +0200
+++ new/dnsdiag-2.10.1/CHANGELOG.md     2026-11-05 09:47:46.000000000 +0100
@@ -6,6 +6,23 @@
 and this project adheres to [Semantic 
Versioning](http://semver.org/spec/v2.0.0.html).
 
 <!-- insertion marker -->
+## [v2.10.1](https://github.com/farrokhi/dnsdiag/releases/tag/v2.10.1) - 
2026-11-05
+
+<small>[Compare with 
v2.10.0](https://github.com/farrokhi/dnsdiag/compare/v2.10.0...v2.10.1)</small>
+
+### Build
+
+- require dnspython 2.9.2, drop direct cryptography 
([4ed348c](https://github.com/farrokhi/dnsdiag/commit/4ed348c60a3afd8bf0bc74e00f7f71525916bc1d)
 by [@farrokhi](https://github.com/farrokhi)).
+
+### Bug Fixes
+
+- escape reverse DNS names in hop lines 
([1ea85a0](https://github.com/farrokhi/dnsdiag/commit/1ea85a090097b2c92b9bdc5fe384dffab6696583)
 by [@farrokhi](https://github.com/farrokhi)).
+- escape text from the whois service 
([f1c553a](https://github.com/farrokhi/dnsdiag/commit/f1c553a9e60a690e8ddc80f090e99d934d404cdf)
 by [@farrokhi](https://github.com/farrokhi)).
+- don't report every DoH connect failure as refused 
([c49d687](https://github.com/farrokhi/dnsdiag/commit/c49d687fdb8f1f5077360aff9fa1851fc6fe6041)
 by [@farrokhi](https://github.com/farrokhi)).
+- stop loading whois.cache from the working directory 
([d5737ee](https://github.com/farrokhi/dnsdiag/commit/d5737ee6992e5593c800132dd4385d2eacc24aa2)
 by [@farrokhi](https://github.com/farrokhi)).
+- send the server name on encrypted transports 
([5645fcd](https://github.com/farrokhi/dnsdiag/commit/5645fcd1079183134559db5e7b0d9825a6c48df1)
 by [@farrokhi](https://github.com/farrokhi)).
+- escape server-supplied text and handle non-UTF-8 NSID 
([02fb160](https://github.com/farrokhi/dnsdiag/commit/02fb1605a0bc218c0ddf2519c80569541fa5f3de)
 by [@farrokhi](https://github.com/farrokhi)).
+
 ## [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>
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/dnsdiag-2.10.0/dnsdiag/dns.py 
new/dnsdiag-2.10.1/dnsdiag/dns.py
--- old/dnsdiag-2.10.0/dnsdiag/dns.py   2026-10-14 11:54:32.000000000 +0200
+++ new/dnsdiag-2.10.1/dnsdiag/dns.py   2026-11-05 09:47:46.000000000 +0100
@@ -105,13 +105,24 @@
                 self.setsockopt(socket.IPPROTO_IPV6, socket.IPV6_UNICAST_HOPS, 
_TTL)
 
 
+def doh_target(server: str, server_hostname: str | None, port: int) -> 
tuple[str, str | None]:
+    """Return the where and bootstrap_address arguments for 
dns.query.https()."""
+    if server_hostname is None:
+        return server, None
+    # The URL supplies SNI and the Host header; bootstrap_address stops 
dnspython
+    # looking the name up again on every query.
+    return f"https://{server_hostname}:{port}/dns-query";, server
+
+
 def ping(qname: str, server: str, dst_port: int, rdtype: str, timeout: float, 
count: int, proto: int,
          src_ip: str | None, use_edns: bool = False, force_miss: bool = False,
-         want_dnssec: bool = False, want_nsid: bool = False, socket_ttl: int | 
None = None) -> PingResponse:
+         want_dnssec: bool = False, want_nsid: bool = False, socket_ttl: int | 
None = None,
+         server_hostname: str | None = None) -> PingResponse:
     retval = PingResponse()
     retval.rcode_text = "No Response"
 
     response_times: list[float] = []
+    doh_where, doh_bootstrap = doh_target(server, server_hostname, dst_port)
 
     if socket_ttl is not None:
         global _TTL
@@ -152,24 +163,30 @@
                 response = dns.query.tcp(query, server, timeout=timeout, 
port=dst_port, source=src_ip)
             elif proto == PROTO_TLS:
                 if hasattr(dns.query, 'tls'):
-                    response = dns.query.tls(query, server, timeout, dst_port, 
src_ip)
+                    # TODO: DoT fails in the macOS binaries, whose OpenSSL 
looks for CAs under the build
+                    # machine's Python framework. dnsping.py has the same 
call. Workaround: SSL_CERT_FILE.
+                    response = dns.query.tls(query, server, timeout, dst_port, 
src_ip,
+                                             server_hostname=server_hostname)
                 else:
                     unsupported_feature()
             elif proto == PROTO_HTTPS:
                 if hasattr(dns.query, 'https'):
-                    response = dns.query.https(query, server, timeout, 
dst_port, src_ip,
-                                              
http_version=dns.query.HTTPVersion.HTTP_2)
+                    response = dns.query.https(query, doh_where, timeout, 
dst_port, src_ip,
+                                               bootstrap_address=doh_bootstrap,
+                                               
http_version=dns.query.HTTPVersion.HTTP_2)
                 else:
                     unsupported_feature()
             elif proto == PROTO_QUIC:
                 if hasattr(dns.query, 'quic'):
-                    response = dns.query.quic(query, server, timeout, 
dst_port, src_ip)
+                    response = dns.query.quic(query, server, timeout, 
dst_port, src_ip,
+                                              hostname=server_hostname)
                 else:
                     unsupported_feature()
             elif proto == PROTO_HTTP3:
-                if hasattr(dns.query, '_http3'):
-                    url = f"https://{server}:{dst_port}/dns-query";
-                    response = dns.query._http3(query, server, url, timeout, 
dst_port, src_ip)
+                if hasattr(dns.query, 'https'):
+                    response = dns.query.https(query, doh_where, timeout, 
dst_port, src_ip,
+                                               bootstrap_address=doh_bootstrap,
+                                               
http_version=dns.query.HTTPVersion.H3)
                 else:
                     unsupported_feature()
 
@@ -276,3 +293,26 @@
         else:
             text_flags.append('--')
     return ' '.join(text_flags)
+
+
+def escape_text(text: str) -> str:
+    """Escape backslashes and unprintable characters in text taken from a 
response."""
+    # \DDD is the form dnspython's to_text() prints for the same characters
+    escaped = []
+    for c in text:
+        if c == '\\':
+            escaped.append('\\\\')
+        elif c.isprintable():
+            escaped.append(c)
+        elif ord(c) < 0x100:
+            escaped.append(f'\\{ord(c):03d}')
+        else:
+            escaped.append(f'\\u{ord(c):04x}')
+    return ''.join(escaped)
+
+
+def nsid_to_text(nsid: bytes) -> str:
+    try:
+        return escape_text(nsid.decode('utf-8'))
+    except UnicodeDecodeError:
+        return nsid.hex()
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/dnsdiag-2.10.0/dnsdiag/shared.py 
new/dnsdiag-2.10.1/dnsdiag/shared.py
--- old/dnsdiag-2.10.0/dnsdiag/shared.py        2026-10-14 11:54:32.000000000 
+0200
+++ new/dnsdiag-2.10.1/dnsdiag/shared.py        2026-11-05 09:47:46.000000000 
+0100
@@ -33,7 +33,7 @@
 
 shutdown: bool = False
 
-__version__ = '2.10.0'
+__version__ = '2.10.1'
 
 
 def signal_handler(sig: int, frame: Any) -> None:
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/dnsdiag-2.10.0/dnsdiag/whois.py 
new/dnsdiag-2.10.1/dnsdiag/whois.py
--- old/dnsdiag-2.10.0/dnsdiag/whois.py 2026-10-14 11:54:32.000000000 +0200
+++ new/dnsdiag-2.10.1/dnsdiag/whois.py 2026-11-05 09:47:46.000000000 +0100
@@ -23,41 +23,21 @@
 # OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
 # OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
 
-import pickle
-import time
 from typing import Any
 
 import cymruwhois
 
-WHOIS_CACHE_FILE = 'whois.cache'
-
 
 def asn_lookup(ip: str, whois_cache: dict[str, Any]) -> tuple[Any | None, 
dict[str, Any]]:
+    # The cache lasts for one run. dnstraceroute usually runs as root from any 
directory,
+    # so it must not load cached objects from disk.
+    if ip in whois_cache:
+        return whois_cache[ip], whois_cache
     asn: Any | None = None
     try:
-        currenttime = time.time()
-        if ip in whois_cache:
-            asn, ts = whois_cache[ip]
-        else:
-            ts = 0
-        if (currenttime - ts) > 36000:
-            c = cymruwhois.Client()
-            asn = c.lookup(ip)
-            whois_cache[ip] = (asn, currenttime)
+        # cymruwhois would otherwise use memcached on localhost whenever 
python-memcached is installed
+        asn = cymruwhois.Client(memcache_host=None).lookup(ip)
+        whois_cache[ip] = asn
     except Exception:
         pass
     return asn, whois_cache
-
-
-def restore() -> dict[str, Any]:
-    try:
-        with open(WHOIS_CACHE_FILE, 'rb') as pkl_file:
-            whois: dict[str, Any] = pickle.load(pkl_file)
-            return whois
-    except (OSError, EOFError, pickle.UnpicklingError):
-        return {}
-
-
-def save(whois_data: dict[str, Any]) -> None:
-    with open(WHOIS_CACHE_FILE, 'wb') as pkl_file:
-        pickle.dump(whois_data, pkl_file)
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/dnsdiag-2.10.0/dnseval.py 
new/dnsdiag-2.10.1/dnseval.py
--- old/dnsdiag-2.10.0/dnseval.py       2026-10-14 11:54:32.000000000 +0200
+++ new/dnsdiag-2.10.1/dnseval.py       2026-11-05 09:47:46.000000000 +0100
@@ -130,7 +130,8 @@
 
     try:
         retval = dnsdiag.dns.ping(qname, resolver, dst_port, rdatatype, 
waittime, count, proto, src_ip,
-                                  use_edns=use_edns, force_miss=force_miss, 
want_dnssec=want_dnssec)
+                                  use_edns=use_edns, force_miss=force_miss, 
want_dnssec=want_dnssec,
+                                  server_hostname=server if server != resolver 
else None)
 
     except (KeyboardInterrupt, SystemExit):
         raise
@@ -354,7 +355,8 @@
                 if resolver is not None:
                     try:
                         dnsdiag.dns.ping(qname, resolver, dst_port, rdatatype, 
waittime, 1, proto, src_ip,
-                                         use_edns=use_edns, 
force_miss=force_miss, want_dnssec=want_dnssec)
+                                         use_edns=use_edns, 
force_miss=force_miss, want_dnssec=want_dnssec,
+                                         server_hostname=server if server != 
resolver else None)
                     except (KeyboardInterrupt, SystemExit):
                         shared.shutdown = True
                         break
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/dnsdiag-2.10.0/dnsping.py 
new/dnsdiag-2.10.1/dnsping.py
--- old/dnsdiag-2.10.0/dnsping.py       2026-10-14 11:54:32.000000000 +0200
+++ new/dnsdiag-2.10.1/dnsping.py       2026-11-05 09:47:46.000000000 +0100
@@ -57,7 +57,10 @@
     PROTO_TLS,
     PROTO_UDP,
     CustomSocket,
+    doh_target,
+    escape_text,
     get_default_port,
+    nsid_to_text,
     proto_to_text,
     valid_rdatatype,
 )
@@ -342,6 +345,10 @@
     dnsserver_hostname = dnsserver  # keep original name for display and SNI
     dnsserver_ip = resolve_server_address(dnsserver, af)
 
+    # Use resolved IP for connection, but provide hostname for SNI/certificate 
validation
+    server_hostname = dnsserver_hostname if dnsserver_hostname != dnsserver_ip 
else None
+    doh_where, doh_bootstrap = doh_target(dnsserver_ip, server_hostname, 
dst_port)
+
     response_time = []
     i = 0
 
@@ -421,8 +428,6 @@
             elif proto is PROTO_TLS:
                 if hasattr(dns.query, 'tls'):
                     try:
-                        # Use resolved IP for connection, but provide hostname 
for SNI/certificate validation
-                        server_hostname = dnsserver_hostname if 
dnsserver_hostname != dnsserver_ip else None
                         answers = dns.query.tls(query, dnsserver_ip, 
timeout=timeout, port=dst_port,
                                                 source=src_ip, 
source_port=src_port,
                                                 
server_hostname=server_hostname)
@@ -450,19 +455,16 @@
             elif proto is PROTO_HTTPS:
                 if hasattr(dns.query, 'https'):
                     try:
-                        # For hostnames, construct full URL; for IPs, use IP 
directly
-                        if dnsserver_hostname != dnsserver_ip:
-                            https_server = 
f"https://{dnsserver_hostname}/dns-query";
-                        else:
-                            https_server = dnsserver_ip
-                        answers = dns.query.https(query, https_server, 
timeout=timeout, port=dst_port,
+                        answers = dns.query.https(query, doh_where, 
timeout=timeout, port=dst_port,
                                                   source=src_ip, 
source_port=src_port,
+                                                  
bootstrap_address=doh_bootstrap,
                                                   
http_version=dns.query.HTTPVersion.HTTP_2)
                     except dns.query.NoDOH:
                         die("ERROR: DNS-over-HTTPS requires the httpx2, 
httpcore2 and h2 modules")
                     except httpx2.ConnectError:
+                        # dnspython drops each attempt's error, so this may 
not be a refusal
                         if not quiet:
-                            print("Connection refused", flush=True)
+                            print("Connection failed", flush=True)
                         continue
                     except (ConnectionResetError, BrokenPipeError):
                         if not quiet:
@@ -484,13 +486,9 @@
             elif proto is PROTO_HTTP3:
                 if hasattr(dns.query, 'https') and hasattr(dns.query, 
'HTTPVersion') and hasattr(dns.query.HTTPVersion, 'H3'):
                     try:
-                        # For hostnames, construct full URL; for IPs, use IP 
directly
-                        if dnsserver_hostname != dnsserver_ip:
-                            https_server = 
f"https://{dnsserver_hostname}/dns-query";
-                        else:
-                            https_server = dnsserver_ip
-                        answers = dns.query.https(query, https_server, 
timeout=timeout, port=dst_port,
+                        answers = dns.query.https(query, doh_where, 
timeout=timeout, port=dst_port,
                                                   source=src_ip, 
source_port=src_port,
+                                                  
bootstrap_address=doh_bootstrap,
                                                   
http_version=dns.query.HTTPVersion.H3)
                     except dns.query.NoDOH:
                         die("ERROR: DNS-over-HTTP/3 requires the aioquic 
module")
@@ -526,8 +524,6 @@
             elif proto is PROTO_QUIC:
                 if hasattr(dns.query, 'quic'):
                     try:
-                        # Use resolved IP for connection, but provide hostname 
for SNI/certificate validation
-                        server_hostname = dnsserver_hostname if 
dnsserver_hostname != dnsserver_ip else None
                         answers = dns.query.quic(query, dnsserver_ip, 
timeout=timeout, port=dst_port,
                                                  source=src_ip, 
source_port=src_port,
                                                  
server_hostname=server_hostname)
@@ -637,7 +633,7 @@
                         if ans_opt.otype == dns.edns.OptionType.NSID:
                             nsid_val = ans_opt.nsid
                             if nsid_val:
-                                edns_parts.append("NSID:%s" % 
nsid_val.decode("utf-8"))
+                                edns_parts.append("NSID:%s" % 
nsid_to_text(nsid_val))
 
                 # Always show EDE when present (server-initiated error 
responses)
                 if answers.options:
@@ -646,7 +642,7 @@
                             if ans_opt.text:
                                 # Truncate EDE text for ping output to prevent 
display issues
                                 truncated_text = ans_opt.text[:50] + "..." if 
len(ans_opt.text) > 50 else ans_opt.text
-                                edns_parts.append("EDE:%d(\"%s\")" % 
(ans_opt.code, truncated_text))
+                                edns_parts.append("EDE:%d(\"%s\")" % 
(ans_opt.code, escape_text(truncated_text)))
                             else:
                                 edns_parts.append("EDE:%d" % ans_opt.code)
 
@@ -695,7 +691,7 @@
                         if ans_opt.otype == dns.edns.OptionType.NSID:
                             option_name = "NSID"
                             nsid_val = ans_opt.nsid
-                            option_details = nsid_val.decode("utf-8") if 
nsid_val else ""
+                            option_details = nsid_to_text(nsid_val) if 
nsid_val else ""
                         elif ans_opt.otype == dns.edns.OptionType.ECS:
                             option_name = "ECS"
                             option_details = "family=%d, source=%d, scope=%d, 
address=%s" % (
@@ -703,7 +699,7 @@
                                 ans_opt.address if ans_opt.address else "None")
                         elif ans_opt.otype == dns.edns.EDE:
                             option_name = "EDE"
-                            option_details = "code=%d, text=\"%s\"" % 
(ans_opt.code, ans_opt.text or "")
+                            option_details = "code=%d, text=\"%s\"" % 
(ans_opt.code, escape_text(ans_opt.text or ""))
                         elif ans_opt.otype == 10:  # COOKIE
                             option_name = "COOKIE"
                             client_hex = ans_opt.client.hex()
@@ -719,7 +715,7 @@
                             option_details = "length=%d" % len(ans_opt.data)
                         elif ans_opt.otype == 13:  # CHAIN
                             option_name = "CHAIN"
-                            option_details = "closest_encloser=%s" % 
ans_opt.data.decode('utf-8', errors='ignore')
+                            option_details = "closest_encloser=%s" % 
escape_text(ans_opt.data.decode('utf-8', errors='ignore'))
                         elif ans_opt.otype == 14:  # KEY-TAG
                             option_name = "KEY-TAG"
                             if len(ans_opt.data) >= 2:
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/dnsdiag-2.10.0/dnstraceroute.py 
new/dnsdiag-2.10.1/dnstraceroute.py
--- old/dnsdiag-2.10.0/dnstraceroute.py 2026-10-14 11:54:32.000000000 +0200
+++ new/dnsdiag-2.10.1/dnstraceroute.py 2026-11-05 09:47:46.000000000 +0100
@@ -41,7 +41,15 @@
 
 import dnsdiag.whois
 from dnsdiag import shared
-from dnsdiag.dns import PROTO_HTTP3, PROTO_QUIC, PROTO_TCP, PROTO_UDP, 
get_default_port
+from dnsdiag.dns import (
+    PROTO_HTTP3,
+    PROTO_QUIC,
+    PROTO_TCP,
+    PROTO_UDP,
+    escape_text,
+    get_default_port,
+    nsid_to_text,
+)
 from dnsdiag.shared import (
     Colors,
     __version__,
@@ -131,7 +139,8 @@
 
 
 def ping(qname: str, server: str, rdtype: str, proto: int, port: int, ttl: int,
-         timeout: int, src_ip: str | None, use_edns: bool, want_nsid: bool) -> 
tuple[bool, float | None, str | None]:
+         timeout: int, src_ip: str | None, use_edns: bool, want_nsid: bool,
+         server_hostname: str | None = None) -> tuple[bool, float | None, str 
| None]:
     reached = False
     resp_time = None
     nsid_value = None
@@ -139,7 +148,8 @@
 
     try:
         resp = dnsdiag.dns.ping(qname, server, port, rdtype, timeout, 1, 
proto, src_ip, use_edns, force_miss=False,
-                                want_dnssec=False, want_nsid=want_nsid, 
socket_ttl=ttl)
+                                want_dnssec=False, want_nsid=want_nsid, 
socket_ttl=ttl,
+                                server_hostname=server_hostname)
 
     except SystemExit:
         raise
@@ -154,12 +164,8 @@
             if want_nsid and resp.response and resp.response.options:
                 for option in resp.response.options:
                     if option.otype == dns.edns.OptionType.NSID:
-                        nsid_bytes = option.nsid
-                        if nsid_bytes:
-                            try:
-                                nsid_value = nsid_bytes.decode("utf-8")
-                            except UnicodeDecodeError:
-                                nsid_value = nsid_bytes.hex()
+                        if option.nsid:
+                            nsid_value = nsid_to_text(option.nsid)
                         break
 
     return reached, resp_time, nsid_value
@@ -309,6 +315,8 @@
         else:
             dnsserver = str(nameservers[0])
 
+    server_hostname = None
+
     # Detect address family from IP, or resolve hostname and default af to IPv4
     try:
         addr = ipaddress.ip_address(dnsserver)
@@ -323,6 +331,7 @@
     except ValueError:  # hostname — resolve it
         if af is None:
             af = socket.AF_INET
+        server_hostname = dnsserver
         dnsserver = resolve_server_address(dnsserver, af)
 
     # Validate source IP address family against the resolved server family
@@ -375,7 +384,7 @@
         with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool:  # 
dispatch dns lookup to another thread
             stime = time.perf_counter()
             thr = pool.submit(ping, qname, dnsserver, rdatatype, proto, 
dest_port, ttl, timeout, src_ip=src_ip,
-                              use_edns=use_edns, want_nsid=want_nsid)
+                              use_edns=use_edns, want_nsid=want_nsid, 
server_hostname=server_hostname)
 
             try:  # expect ICMP response
                 packet, curr_addr = icmp_socket.recvfrom(512)
@@ -437,7 +446,7 @@
         if should_resolve:
             try:
                 if curr_addr:
-                    curr_name = socket.gethostbyaddr(curr_addr)[0]
+                    curr_name = escape_text(socket.gethostbyaddr(curr_addr)[0])
             except OSError:
                 curr_name = curr_addr
             except KeyboardInterrupt:
@@ -460,7 +469,7 @@
                 as_name = ''
                 try:
                     if asn and asn.asn != "NA":
-                        as_name = "[AS%s %s] " % (asn.asn, asn.owner)
+                        as_name = "[AS%s %s] " % (escape_text(asn.asn), 
escape_text(asn.owner))
                 except AttributeError:
                     if shared.shutdown:
                         sys.exit(0)
@@ -498,8 +507,4 @@
 
 
 if __name__ == '__main__':
-    try:
-        whois_cache = dnsdiag.whois.restore()
-        main()
-    finally:
-        dnsdiag.whois.save(whois_cache)
+    main()
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/dnsdiag-2.10.0/public-servers.txt 
new/dnsdiag-2.10.1/public-servers.txt
--- old/dnsdiag-2.10.0/public-servers.txt       2026-10-14 11:54:32.000000000 
+0200
+++ new/dnsdiag-2.10.1/public-servers.txt       2026-11-05 09:47:46.000000000 
+0100
@@ -172,6 +172,20 @@
 2606:1a40::11
 2606:1a40:1::11
 
+## CleanBrowsing
+# DNS filtering service with free security, adult and family tiers
+# Variants: .9 (security), .10 (adult), .168 (family)
+# Website: https://cleanbrowsing.org/
+# DNSSEC: NO | ECS: NO | EDE: NO
+# Protocols: UDP, TCP, DoT, DoH
+# DoT: security-filter-dns.cleanbrowsing.org (port 853)
+# DoH: https://doh.cleanbrowsing.org/doh/security-filter/
+185.228.168.9
+185.228.169.9
+185.228.168.10
+185.228.168.168
+185.228.169.168
+
 ## NextDNS
 # Highly customizable DNS service with extensive filtering options
 # Cloud-based DNS with detailed analytics and custom block lists
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/dnsdiag-2.10.0/public-v4.txt 
new/dnsdiag-2.10.1/public-v4.txt
--- old/dnsdiag-2.10.0/public-v4.txt    2026-10-14 11:54:32.000000000 +0200
+++ new/dnsdiag-2.10.1/public-v4.txt    2026-11-05 09:47:46.000000000 +0100
@@ -90,6 +90,14 @@
 76.76.2.11
 76.76.10.11
 
+## CleanBrowsing
+# DNS filtering service with security, adult and family filters
+185.228.168.9
+185.228.169.9
+185.228.168.10
+185.228.168.168
+185.228.169.168
+
 ## NextDNS
 # Highly customizable DNS service
 45.90.28.157
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/dnsdiag-2.10.0/pyproject.toml 
new/dnsdiag-2.10.1/pyproject.toml
--- old/dnsdiag-2.10.0/pyproject.toml   2026-10-14 11:54:32.000000000 +0200
+++ new/dnsdiag-2.10.1/pyproject.toml   2026-11-05 09:47:46.000000000 +0100
@@ -4,7 +4,7 @@
 
 [project]
 name = "dnsdiag"
-version = "2.10.0"
+version = "2.10.1"
 description = "DNS Measurement, Troubleshooting and Security Auditing Toolset 
(ping, traceroute)"
 readme = "README.md"
 requires-python = ">=3.11"
@@ -28,9 +28,8 @@
     "Operating System :: OS Independent",
 ]
 dependencies = [
-    "cryptography>=42.0.5,<51",
     "cymruwhois>=1.6",
-    "dnspython[doh,doq]>=2.9.0",
+    "dnspython[doh,doq]>=2.9.2",
     "httpx2>=2.13.0",
 ]
 
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/dnsdiag-2.10.0/requirements.txt 
new/dnsdiag-2.10.1/requirements.txt
--- old/dnsdiag-2.10.0/requirements.txt 2026-10-14 11:54:32.000000000 +0200
+++ new/dnsdiag-2.10.1/requirements.txt 2026-11-05 09:47:46.000000000 +0100
@@ -1,4 +1,3 @@
-cryptography>=42.0.5,<51
 cymruwhois>=1.6
-dnspython[doh,doq]>=2.9.0
+dnspython[doh,doq]>=2.9.2
 httpx2>=2.13.0
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/dnsdiag-2.10.0/tests/README.md 
new/dnsdiag-2.10.1/tests/README.md
--- old/dnsdiag-2.10.0/tests/README.md  2026-10-14 11:54:32.000000000 +0200
+++ new/dnsdiag-2.10.1/tests/README.md  2026-11-05 09:47:46.000000000 +0100
@@ -99,14 +99,20 @@
 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.
+Other stubs record their arguments, to check that the tools send the server 
name
+on encrypted transports.
 
 `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.
 
+`test_whois.py` covers AS number lookups and checks that `dnstraceroute` 
neither
+loads nor writes anything in its working directory. It runs the trace loop 
with a
+fake ICMP socket, so it does not need root.
+
 ```bash
-pytest tests/test_exception_handling.py tests/test_local_responses.py
+pytest tests/test_exception_handling.py tests/test_local_responses.py 
tests/test_whois.py
 ```
 
 ## Common Usage Patterns
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/dnsdiag-2.10.0/tests/test_dnseval_pytest.py 
new/dnsdiag-2.10.1/tests/test_dnseval_pytest.py
--- old/dnsdiag-2.10.0/tests/test_dnseval_pytest.py     2026-10-14 
11:54:32.000000000 +0200
+++ new/dnsdiag-2.10.1/tests/test_dnseval_pytest.py     2026-11-05 
09:47:46.000000000 +0100
@@ -184,6 +184,23 @@
         assert result.success, f"HTTP/3 evaluation failed: {result.error}"
         assert result.has_results
 
+    @pytest.mark.parametrize("server", ["dns.adguard-dns.com", 
"dns.nextdns.io"])
+    @pytest.mark.parametrize("flag", ["--tls", "--doh", "--http3", "--quic"])
+    def test_hostname_entry_gets_answers(self, runner, flag, server):
+        """Servers that need the name for SNI must get it from the server 
list"""
+        result = runner.run([flag, '-c', '2', '-f', '-', '-j', '-', 
'google.com'],
+                           stdin=f'{server}\n'.encode())
+        assert result.success, f"{flag} evaluation of {server} failed: 
{result.error}"
+        assert json.loads(result.output)['data']['rcode_text'] == 'NOERROR', 
result.output
+
+    @pytest.mark.ipv6
+    def test_http3_on_ipv6_address(self, runner):
+        """DoH3 URLs must bracket IPv6 addresses"""
+        result = runner.run(['--http3', '-c', '2', '-f', '-', '-j', '-', 
'google.com'],
+                           stdin=b'2606:4700:4700::1111\n')
+        assert result.success, f"HTTP/3 evaluation failed: {result.error}"
+        assert json.loads(result.output)['data']['rcode_text'] == 'NOERROR', 
result.output
+
 
 class TestRecordTypes:
     """Tests for different DNS record types"""
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/dnsdiag-2.10.0/tests/test_dnstraceroute_pytest.py 
new/dnsdiag-2.10.1/tests/test_dnstraceroute_pytest.py
--- old/dnsdiag-2.10.0/tests/test_dnstraceroute_pytest.py       2026-10-14 
11:54:32.000000000 +0200
+++ new/dnsdiag-2.10.1/tests/test_dnstraceroute_pytest.py       2026-11-05 
09:47:46.000000000 +0100
@@ -360,6 +360,17 @@
         assert not result.success, "Empty hostname should fail"
 
 
+class TestASNLookup:
+    """Tests for AS number lookups against the real whois service"""
+
+    def test_destination_asn_is_printed(self):
+        """A real trace with -a names the AS of the server it reaches"""
+        result = DNSTracerouteRunner(timeout=90).run(['-a', '-n', '-w', '1', 
'-s', RESOLVERS_IPV4['google'],
+                                                      'google.com'])
+        assert result.success, f"ASN trace failed: {result.error}"
+        assert '[AS15169 ' in result.output
+
+
 class TestRegressionBugs:
     """Tests for specific regression bugs and fixes"""
 
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/dnsdiag-2.10.0/tests/test_exception_handling.py 
new/dnsdiag-2.10.1/tests/test_exception_handling.py
--- old/dnsdiag-2.10.0/tests/test_exception_handling.py 2026-10-14 
11:54:32.000000000 +0200
+++ new/dnsdiag-2.10.1/tests/test_exception_handling.py 2026-11-05 
09:47:46.000000000 +0100
@@ -1,18 +1,22 @@
 """
-Offline tests for how dnsping and dnsdiag.dns.ping() react to transport errors.
+Offline tests for how dnsping and dnsdiag.dns.ping() react to transport errors,
+and for what the tools pass to dnspython's encrypted transports.
 
 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.
+exception or record their arguments, 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 inspect
 import signal
 import sys
 from collections.abc import Callable, Iterator
-from typing import NoReturn
+from pathlib import Path
+from typing import Any, NoReturn
 
 import dns.exception
+import dns.message
 import dns.query
 import httpx2
 import pytest
@@ -22,10 +26,17 @@
 from dns.quic._common import UnexpectedEOF
 
 import dnsdiag.dns
+import dnseval
 import dnsping
-from dnsdiag.dns import PROTO_HTTP3, PROTO_HTTPS, PROTO_QUIC, PROTO_UDP
+import dnstraceroute
+from dnsdiag.dns import PROTO_HTTP3, PROTO_HTTPS, PROTO_QUIC, PROTO_TLS, 
PROTO_UDP
 
 ALL_LOST = "1 requests transmitted, 0 responses received, 100% lost"
+SERVER_IP = "192.0.2.1"
+SERVER_NAME = "dns.example"
+DOH_URL = "https://dns.example:8443/dns-query";
+H2 = dns.query.HTTPVersion.HTTP_2
+H3 = dns.query.HTTPVersion.H3
 
 
 def raiser(exc: BaseException) -> Callable[..., NoReturn]:
@@ -42,14 +53,20 @@
 
 
 @pytest.fixture
-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 restore_sigint() -> Iterator[None]:
+    # main() installs its own SIGINT handler; give pytest its handler back 
afterwards
+    previous_sigint = signal.getsignal(signal.SIGINT)
+    yield
+    signal.signal(signal.SIGINT, previous_sigint)
 
-    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"])
+
[email protected]
+def run_dnsping_main(monkeypatch: pytest.MonkeyPatch, capsys: 
pytest.CaptureFixture[str],
+                     restore_sigint: None) -> Callable[..., DNSPingRun]:
+    """Run dnsping.main() in-process for one query with the given arguments."""
+
+    def run(*args: str) -> DNSPingRun:
+        monkeypatch.setattr(sys, "argv", ["dnsping.py", "-c", "1", "-w", "1", 
*args, "example.com"])
         exit_code: int | str | None = 0
         try:
             dnsping.main()
@@ -58,10 +75,36 @@
         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)
+    return run
+
+
[email protected]
+def run_dnsping(monkeypatch: pytest.MonkeyPatch,
+                run_dnsping_main: Callable[..., DNSPingRun]) -> 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))
+        return run_dnsping_main("-s", "127.0.0.1", *args)
+
+    return run
+
+
+def record_query(monkeypatch: pytest.MonkeyPatch, query_function: str) -> 
list[dict[str, Any]]:
+    """Replace a dns.query function with one that records its arguments by 
parameter name."""
+    signature = inspect.signature(getattr(dns.query, query_function))
+    calls: list[dict[str, Any]] = []
+
+    def query(*args: Any, **kwargs: Any) -> dns.message.Message:
+        calls.append(dict(signature.bind(*args, **kwargs).arguments))
+        return dns.message.make_response(args[0])
+
+    monkeypatch.setattr(dns.query, query_function, query)
+    return calls
+
+
+def selected(call: dict[str, Any], expected: dict[str, Any]) -> dict[str, Any]:
+    return {name: call.get(name) for name in expected}
 
 
 CONNECTION_CLOSED = "Connection closed unexpectedly"
@@ -72,7 +115,7 @@
     """dnsping --doh, where dnspython raises httpx2 exceptions"""
 
     @pytest.mark.parametrize("exc,message", [
-        (httpx2.ConnectError("refused"), "Connection refused"),
+        (httpx2.ConnectError("no address worked"), "Connection failed"),
         (httpx2.ConnectTimeout("timed out"), "Request timeout"),
         (httpx2.ReadTimeout("timed out"), "Read timeout"),
         (httpx2.WriteTimeout("timed out"), "Connection failed"),
@@ -184,7 +227,7 @@
     @pytest.mark.parametrize("query_function,proto", [
         ("udp", PROTO_UDP),
         ("https", PROTO_HTTPS),
-        ("_http3", PROTO_HTTP3),
+        ("https", PROTO_HTTP3),
         ("quic", PROTO_QUIC),
     ])
     @pytest.mark.parametrize("exc", [ValueError("bad status"), 
dns.exception.FormError("bad wire")],
@@ -204,7 +247,7 @@
 
     @pytest.mark.parametrize("query_function,proto,exc", [
         ("https", PROTO_HTTPS, dns.query.NoDOH),
-        ("_http3", PROTO_HTTP3, dns.query.NoDOH),
+        ("https", PROTO_HTTP3, dns.query.NoDOH),
         ("quic", PROTO_QUIC, dns.query.NoDOQ),
     ])
     def test_transport_unavailable(self, monkeypatch: pytest.MonkeyPatch, 
query_function: str, proto: int,
@@ -212,3 +255,103 @@
         monkeypatch.setattr(dns.query, query_function, raiser(exc()))
         with pytest.raises(exc):
             ping(proto)
+
+
+class TestPingServerName:
+    """ping() connects to the address it is given and sends the name for SNI 
and certificates"""
+
+    @pytest.mark.parametrize("query_function,proto,expected", [
+        ("tls", PROTO_TLS, {"where": SERVER_IP, "port": 8443, 
"server_hostname": SERVER_NAME}),
+        ("quic", PROTO_QUIC, {"where": SERVER_IP, "port": 8443, "hostname": 
SERVER_NAME}),
+        ("https", PROTO_HTTPS, {"where": DOH_URL, "bootstrap_address": 
SERVER_IP, "http_version": H2}),
+        ("https", PROTO_HTTP3, {"where": DOH_URL, "bootstrap_address": 
SERVER_IP, "http_version": H3}),
+    ], ids=["dot", "doq", "doh", "doh3"])
+    def test_with_hostname(self, monkeypatch: pytest.MonkeyPatch, 
query_function: str, proto: int,
+                           expected: dict[str, Any]) -> None:
+        calls = record_query(monkeypatch, query_function)
+        dnsdiag.dns.ping("example.com", SERVER_IP, 8443, "A", 1, 1, proto, 
None, server_hostname=SERVER_NAME)
+        assert selected(calls[0], expected) == expected
+
+    @pytest.mark.parametrize("query_function,proto,expected", [
+        ("tls", PROTO_TLS, {"where": SERVER_IP, "port": 8443, 
"server_hostname": None}),
+        ("quic", PROTO_QUIC, {"where": SERVER_IP, "port": 8443, "hostname": 
None}),
+        ("https", PROTO_HTTPS, {"where": SERVER_IP, "port": 8443, 
"bootstrap_address": None, "http_version": H2}),
+        ("https", PROTO_HTTP3, {"where": SERVER_IP, "port": 8443, 
"bootstrap_address": None, "http_version": H3}),
+    ], ids=["dot", "doq", "doh", "doh3"])
+    def test_without_hostname(self, monkeypatch: pytest.MonkeyPatch, 
query_function: str, proto: int,
+                              expected: dict[str, Any]) -> None:
+        calls = record_query(monkeypatch, query_function)
+        dnsdiag.dns.ping("example.com", SERVER_IP, 8443, "A", 1, 1, proto, 
None)
+        assert selected(calls[0], expected) == expected
+
+    def test_http3_brackets_ipv6_address(self, monkeypatch: 
pytest.MonkeyPatch) -> None:
+        # Stops one level below https(), so dnspython's own URL building is 
exercised
+        calls = record_query(monkeypatch, "_http3")
+        dnsdiag.dns.ping("example.com", "2001:db8::1", 443, "A", 1, 1, 
PROTO_HTTP3, None)
+        assert calls[0]["url"] == "https://[2001:db8::1]:443/dns-query";
+        assert calls[0]["where"] == "2001:db8::1"
+
+
+class TestToolsPassServerName:
+    """Each tool resolves a hostname itself and must keep the name for the 
transports"""
+
+    @pytest.mark.parametrize("entry,expected", [(SERVER_NAME, SERVER_NAME), 
(SERVER_IP, None)],
+                             ids=["hostname", "address"])
+    def test_dnseval(self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path, 
restore_sigint: None,
+                     entry: str, expected: str | None) -> None:
+        seen = []
+
+        def fake_ping(qname: str, server: str, *args: Any, **kwargs: Any) -> 
dnsdiag.dns.PingResponse:
+            seen.append((server, kwargs.get("server_hostname")))
+            return dnsdiag.dns.PingResponse()
+
+        monkeypatch.setattr(dnsdiag.dns, "ping", fake_ping)
+        monkeypatch.setattr(dnseval, "_resolve_server", lambda server: 
SERVER_IP)
+        monkeypatch.setattr(dnseval.time, "sleep", lambda seconds: None)
+        servers = tmp_path / "servers.txt"
+        servers.write_text(f"{entry}\n")
+        monkeypatch.setattr(sys, "argv", ["dnseval.py", "--quic", "-f", 
str(servers), "-c", "1", "example.com"])
+
+        dnseval.main()
+
+        # Once for the cache warmup and once for the measurement
+        assert seen == [(SERVER_IP, expected), (SERVER_IP, expected)]
+
+    def test_dnstraceroute(self, monkeypatch: pytest.MonkeyPatch) -> None:
+        seen: dict[str, Any] = {}
+
+        def fake_ping(*args: Any, **kwargs: Any) -> dnsdiag.dns.PingResponse:
+            seen.update(kwargs)
+            return dnsdiag.dns.PingResponse()
+
+        monkeypatch.setattr(dnsdiag.dns, "ping", fake_ping)
+
+        dnstraceroute.ping("example.com", SERVER_IP, "A", PROTO_QUIC, 853, 64, 
1, None, False, False,
+                           server_hostname=SERVER_NAME)
+
+        assert seen["server_hostname"] == SERVER_NAME
+
+    @pytest.mark.parametrize("flag,version", [("--doh", H2), ("--http3", H3)], 
ids=["doh", "doh3"])
+    def test_dnsping_connects_to_the_resolved_address(self, monkeypatch: 
pytest.MonkeyPatch,
+                                                      run_dnsping_main: 
Callable[..., DNSPingRun],
+                                                      flag: str, version: 
dns.query.HTTPVersion) -> None:
+        calls = record_query(monkeypatch, "https")
+        monkeypatch.setattr(dnsping, "resolve_server_address", lambda server, 
af: SERVER_IP)
+
+        result = run_dnsping_main(flag, "-s", SERVER_NAME, "-p", "8443")
+
+        assert result.exit_code == 0, result.stderr
+        expected = {"where": DOH_URL, "bootstrap_address": SERVER_IP, 
"http_version": version}
+        assert selected(calls[0], expected) == expected
+
+    @pytest.mark.parametrize("flag,version", [("--doh", H2), ("--http3", H3)], 
ids=["doh", "doh3"])
+    def test_dnsping_with_address_sends_no_name(self, monkeypatch: 
pytest.MonkeyPatch,
+                                                run_dnsping_main: 
Callable[..., DNSPingRun],
+                                                flag: str, version: 
dns.query.HTTPVersion) -> None:
+        calls = record_query(monkeypatch, "https")
+
+        result = run_dnsping_main(flag, "-s", SERVER_IP, "-p", "8443")
+
+        assert result.exit_code == 0, result.stderr
+        expected = {"where": SERVER_IP, "port": 8443, "bootstrap_address": 
None, "http_version": version}
+        assert selected(calls[0], expected) == expected
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/dnsdiag-2.10.0/tests/test_local_responses.py 
new/dnsdiag-2.10.1/tests/test_local_responses.py
--- old/dnsdiag-2.10.0/tests/test_local_responses.py    2026-10-14 
11:54:32.000000000 +0200
+++ new/dnsdiag-2.10.1/tests/test_local_responses.py    2026-11-05 
09:47:46.000000000 +0100
@@ -21,9 +21,17 @@
 import dns.rrset
 import pytest
 
+import dnsdiag.dns
+import dnstraceroute
+from dnsdiag.dns import PROTO_UDP
+
 PROJECT_ROOT = Path(__file__).resolve().parent.parent
 ALL_LOST = "1 requests transmitted, 0 responses received, 100% lost"
 
+# A terminal escape sequence and a line break, which a server can put in any 
text field
+HOSTILE_TEXT = "\x1b[31mred\nforged"
+ESCAPED_TEXT = "\\027[31mred\\010forged"
+
 ReplyBuilder = Callable[[bytes], bytes]
 
 
@@ -138,6 +146,88 @@
         assert "UNKNOWN (65001): type=65001, length=3" in result.stdout
 
 
+class TestServerSuppliedText:
+    """Text from a response must not reach the terminal as control 
characters"""
+
+    @pytest.mark.parametrize("option,args", [
+        (dns.edns.EDEOption(dns.edns.EDECode.BLOCKED, HOSTILE_TEXT), []),
+        (dns.edns.EDEOption(dns.edns.EDECode.BLOCKED, HOSTILE_TEXT), ["-v"]),
+        (dns.edns.NSIDOption(HOSTILE_TEXT.encode()), ["--nsid"]),
+        (dns.edns.NSIDOption(HOSTILE_TEXT.encode()), ["--nsid", "-v"]),
+        (dns.edns.GenericOption(dns.edns.OptionType.CHAIN, 
HOSTILE_TEXT.encode()), ["-v"]),
+    ], ids=["ede", "ede-verbose", "nsid", "nsid-verbose", "chain-verbose"])
+    def test_dnsping_escapes_control_characters(self, option: dns.edns.Option, 
args: list[str]) -> None:
+        with UDPResponder(answer_with_options(option)) as server:
+            result = run_tool("dnsping.py", "-s", "127.0.0.1", "-p", 
str(server.port),
+                              "-c", "1", "-w", "2", *args, "example.com")
+
+        assert_clean_exit(result)
+        assert "\x1b" not in result.stdout
+        assert "\nforged" not in result.stdout
+        assert ESCAPED_TEXT in result.stdout
+
+    def test_dnsping_truncates_ede_text_before_escaping(self) -> None:
+        option = dns.edns.EDEOption(dns.edns.EDECode.BLOCKED, "a" * 49 + "\n" 
+ "b")
+        with UDPResponder(answer_with_options(option)) as server:
+            result = run_tool("dnsping.py", "-s", "127.0.0.1", "-p", 
str(server.port),
+                              "-c", "1", "-w", "2", "example.com")
+
+        assert_clean_exit(result)
+        assert 'EDE:15("' + "a" * 49 + '\\010...")' in result.stdout
+
+    @pytest.mark.parametrize("args,expected", [
+        (["--nsid"], "NSID:fffe"),
+        (["--nsid", "-v"], "NSID (3): fffe"),
+    ], ids=["compact", "verbose"])
+    def test_dnsping_prints_non_utf8_nsid_as_hex(self, args: list[str], 
expected: str) -> None:
+        option = dns.edns.NSIDOption(b"\xff\xfe")
+        with UDPResponder(answer_with_options(option)) as server:
+            result = run_tool("dnsping.py", "-s", "127.0.0.1", "-p", 
str(server.port),
+                              "-c", "1", "-w", "2", *args, "example.com")
+
+        assert_clean_exit(result)
+        assert expected in result.stdout
+
+    def test_dnstraceroute_escapes_nsid(self, monkeypatch: pytest.MonkeyPatch) 
-> None:
+        # Running dnstraceroute needs root, so stop at the function that reads 
the NSID.
+        response = 
dns.message.make_response(dns.message.make_query("example.com", "A"))
+        response.use_edns(0, 
options=[dns.edns.NSIDOption(HOSTILE_TEXT.encode())])
+        ping_result = dnsdiag.dns.PingResponse()
+        ping_result.answer = ["example.com. 300 IN A 192.0.2.1"]
+        ping_result.response = response
+        monkeypatch.setattr(dnsdiag.dns, "ping", lambda *args, **kwargs: 
ping_result)
+
+        _, _, nsid = dnstraceroute.ping("example.com", "127.0.0.1", "A", 
PROTO_UDP, 53, 64, 1, None, True, True)
+
+        assert nsid == ESCAPED_TEXT
+
+
+class TestEscapeHelpers:
+    @pytest.mark.parametrize("text,expected", [
+        ("ns1.example", "ns1.example"),
+        ("two words", "two words"),
+        ("café", "café"),
+        ("back\\slash", "back\\\\slash"),
+        ("\x00", "\\000"),
+        ("\x1b", "\\027"),
+        ("\x7f", "\\127"),
+        ("\x85", "\\133"),
+        ("‮", "\\u202e"),  # right-to-left override
+        ("
", "\\u2028"),  # line separator
+    ])
+    def test_escape_text(self, text: str, expected: str) -> None:
+        assert dnsdiag.dns.escape_text(text) == expected
+
+    @pytest.mark.parametrize("nsid,expected", [
+        (b"ns1.example", "ns1.example"),
+        (b"caf\xc3\xa9", "café"),
+        (b"\x1b", "\\027"),
+        (b"\xff\xfe", "fffe"),
+    ])
+    def test_nsid_to_text(self, nsid: bytes, expected: str) -> None:
+        assert dnsdiag.dns.nsid_to_text(nsid) == expected
+
+
 class TestMalformedResponses:
     """Replies that dnspython refuses to parse count as lost, not as crashes"""
 
@@ -173,7 +263,8 @@
                           "-c", "1", "-w", "2", "example.com")
 
         assert_clean_exit(result)
-        assert "Connection refused" in result.stdout
+        # dnspython drops the reason, so a closed port gets the same message 
as any failed connect
+        assert "Connection failed" in result.stdout
         assert ALL_LOST in result.stdout
 
     def test_dnsping_connect_timeout(self, silent_tcp_port: int) -> None:
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/dnsdiag-2.10.0/tests/test_whois.py 
new/dnsdiag-2.10.1/tests/test_whois.py
--- old/dnsdiag-2.10.0/tests/test_whois.py      1970-01-01 01:00:00.000000000 
+0100
+++ new/dnsdiag-2.10.1/tests/test_whois.py      2026-11-05 09:47:46.000000000 
+0100
@@ -0,0 +1,260 @@
+"""
+Offline tests for dnstraceroute's AS number and reverse DNS lookups and its 
use of the working directory.
+
+dnstraceroute normally runs as root from whatever directory the user is in, so
+it must not load or write anything there. The trace tests run main() in-process
+with a fake ICMP socket, and lookups use the real cymruwhois client with only
+its network call replaced.
+"""
+
+import itertools
+import os
+import pickle
+import signal
+import subprocess
+import sys
+from collections.abc import Callable, Iterator
+from pathlib import Path
+from types import SimpleNamespace
+from typing import Any
+
+import cymruwhois
+import pytest
+
+import dnsdiag.whois
+import dnstraceroute
+from dnsdiag import shared
+
+PROJECT_ROOT = Path(__file__).resolve().parent.parent
+SERVER = "192.0.2.1"
+OTHER_ADDRESS = "192.0.2.2"
+CACHE_FILE = "whois.cache"
+MARKER_FILE = "cache-was-loaded"
+GOOGLE = cymruwhois.record("15169", SERVER, "192.0.2.0/24", "US", "GOOGLE, US")
+UNROUTED = cymruwhois.record("NA", SERVER, "NA", "", "NA")
+
+
+class MarkerPayload:
+    """Creates a file when unpickled, so a test can tell whether the cache was 
loaded."""
+
+    def __init__(self, path: Path) -> None:
+        self.path = path
+
+    def __reduce__(self) -> tuple[Any, ...]:
+        return (open, (str(self.path), "w"))
+
+
+def plant_cache(directory: Path) -> bytes:
+    """Put a hostile cache file in a directory, as an older version's file 
would sit there."""
+    planted = pickle.dumps(MarkerPayload(directory / MARKER_FILE))
+    (directory / CACHE_FILE).write_bytes(planted)
+    return planted
+
+
[email protected]
+def whois_answers(monkeypatch: pytest.MonkeyPatch) -> Iterator[list[str]]:
+    """Answer cymruwhois lookups with GOOGLE and return the addresses that 
reached the network."""
+    asked: list[str] = []
+
+    def lookup_raw(self: cymruwhois.Client, ips: list[str]) -> 
Iterator[cymruwhois.record]:
+        asked.extend(ips)
+        return iter(cymruwhois.record(GOOGLE.asn, ip, GOOGLE.prefix, 
GOOGLE.cc, GOOGLE.owner) for ip in ips)
+
+    monkeypatch.setattr(cymruwhois.Client, "_lookupmany_raw", lookup_raw)
+    yield asked
+
+
+def failing_lookup(self: cymruwhois.Client, ips: list[str]) -> 
Iterator[cymruwhois.record]:
+    raise OSError("whois.cymru.com unreachable")
+
+
+class TestAsnLookup:
+    def test_returns_the_record(self, whois_answers: list[str]) -> None:
+        asn, _ = dnsdiag.whois.asn_lookup(SERVER, {})
+        assert (asn.asn, asn.owner) == ("15169", "GOOGLE, US")
+
+    def test_repeated_address_is_looked_up_once(self, whois_answers: 
list[str]) -> None:
+        cache: dict[str, Any] = {}
+        first, cache = dnsdiag.whois.asn_lookup(SERVER, cache)
+        second, cache = dnsdiag.whois.asn_lookup(SERVER, cache)
+        assert whois_answers == [SERVER]
+        assert second is first
+
+    def test_each_address_gets_its_own_lookup(self, whois_answers: list[str]) 
-> None:
+        cache: dict[str, Any] = {}
+        _, cache = dnsdiag.whois.asn_lookup(SERVER, cache)
+        other, cache = dnsdiag.whois.asn_lookup(OTHER_ADDRESS, cache)
+        assert whois_answers == [SERVER, OTHER_ADDRESS]
+        assert other.ip == OTHER_ADDRESS
+
+    def test_failed_lookup_returns_none(self, monkeypatch: pytest.MonkeyPatch) 
-> None:
+        monkeypatch.setattr(cymruwhois.Client, "_lookupmany_raw", 
failing_lookup)
+        assert dnsdiag.whois.asn_lookup(SERVER, {}) == (None, {})
+
+    def test_failed_lookup_is_retried(self, monkeypatch: pytest.MonkeyPatch, 
whois_answers: list[str]) -> None:
+        answering_lookup = cymruwhois.Client._lookupmany_raw
+        monkeypatch.setattr(cymruwhois.Client, "_lookupmany_raw", 
failing_lookup)
+        _, cache = dnsdiag.whois.asn_lookup(SERVER, {})
+
+        monkeypatch.setattr(cymruwhois.Client, "_lookupmany_raw", 
answering_lookup)
+        asn, _ = dnsdiag.whois.asn_lookup(SERVER, cache)
+
+        assert asn.asn == "15169"
+
+    def test_memcached_is_not_used(self, monkeypatch: pytest.MonkeyPatch, 
whois_answers: list[str]) -> None:
+        # python-memcached unpickles what the server returns, and any local 
user can listen on its port
+        servers: list[list[str]] = []
+
+        def memcache_client(hosts: list[str]) -> SimpleNamespace:
+            servers.append(hosts)
+            return SimpleNamespace(get_multi=lambda keys: {}, set=lambda 
*args: None)
+
+        monkeypatch.setattr(cymruwhois, "HAVE_MEMCACHE", True)
+        monkeypatch.setattr(cymruwhois, "memcache", 
SimpleNamespace(Client=memcache_client), raising=False)
+
+        asn, _ = dnsdiag.whois.asn_lookup(SERVER, {})
+
+        assert servers == []
+        assert asn.asn == "15169"
+
+
+STARTS = {
+    # How the source tree, the Docker image and the standalone binaries start 
the tool
+    "script": [sys.executable, str(PROJECT_ROOT / "dnstraceroute.py")],
+    # How the console script from a pip install starts it
+    "entry point": [sys.executable, "-c",
+                    "import sys, dnstraceroute; sys.argv[0] = 'dnstraceroute'; 
dnstraceroute.main()"],
+}
+# -h exits before any trace, so start-up code runs without root or a network. 
The -a case
+# reaches it with the flag on the command line; the trace tests below cover -a 
inside main().
+ARGUMENTS = {"-h": ["-h"], "-a -h": ["-a", "-h"]}
+
+
[email protected](params=list(itertools.product(STARTS, ARGUMENTS)), ids=lambda 
param: " ".join(param))
+def start_tool(request: pytest.FixtureRequest) -> Callable[[Path], 
subprocess.CompletedProcess[str]]:
+    start_with, arguments = request.param
+
+    def start(cwd: Path) -> subprocess.CompletedProcess[str]:
+        env = {**os.environ, "PYTHONPATH": str(PROJECT_ROOT), 
"PYTHONDONTWRITEBYTECODE": "1"}
+        return subprocess.run([*STARTS[start_with], *ARGUMENTS[arguments]], 
cwd=cwd, env=env,
+                              capture_output=True, text=True, timeout=30)
+
+    return start
+
+
[email protected]
+def read_only_dir(tmp_path: Path) -> Iterator[Path]:
+    if not hasattr(os, "geteuid") or os.geteuid() == 0:
+        pytest.skip("directory permissions do not stop root, or do not apply 
on this platform")
+    tmp_path.chmod(0o555)
+    yield tmp_path
+    tmp_path.chmod(0o755)
+
+
+class TestWorkingDirectory:
+    def test_cache_file_is_not_loaded(self, start_tool: Callable[..., Any], 
tmp_path: Path) -> None:
+        plant_cache(tmp_path)
+        start_tool(tmp_path)
+        assert not (tmp_path / MARKER_FILE).exists()
+
+    def test_cache_file_from_older_version_is_left_alone(self, start_tool: 
Callable[..., Any],
+                                                         tmp_path: Path) -> 
None:
+        planted = plant_cache(tmp_path)
+        start_tool(tmp_path)
+        assert (tmp_path / CACHE_FILE).read_bytes() == planted
+
+    def test_no_file_is_created(self, start_tool: Callable[..., Any], 
tmp_path: Path) -> None:
+        start_tool(tmp_path)
+        assert list(tmp_path.iterdir()) == []
+
+    def test_read_only_directory_gives_clean_exit(self, start_tool: 
Callable[..., Any],
+                                                  read_only_dir: Path) -> None:
+        result = start_tool(read_only_dir)
+        assert result.returncode == 0, result.stderr
+        assert "Traceback" not in result.stderr
+
+
+class FakeIcmpSocket:
+    """Stands in for the raw socket that needs root. No ICMP arrives, so only 
the server is a hop."""
+
+    def __init__(self, *args: Any, **kwargs: Any) -> None:
+        pass
+
+    def bind(self, address: Any) -> None:
+        pass
+
+    def settimeout(self, timeout: float) -> None:
+        pass
+
+    def recvfrom(self, size: int) -> tuple[bytes, Any]:
+        raise TimeoutError
+
+    def close(self) -> None:
+        pass
+
+
[email protected]
+def trace(monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str],
+          tmp_path: Path) -> Iterator[Callable[..., str]]:
+    """Run dnstraceroute.main() in-process for a trace that reaches the server 
at the first hop."""
+
+    def run(*args: str, resolve: bool = False) -> str:
+        no_resolve = [] if resolve else ["-n"]
+        monkeypatch.setattr(sys, "argv", ["dnstraceroute.py", *no_resolve, 
"-c", "1", "-s", SERVER, *args, "example.com"])
+        dnstraceroute.main()
+        return capsys.readouterr().out
+
+    monkeypatch.setattr(dnstraceroute.socket, "socket", FakeIcmpSocket)
+    monkeypatch.setattr(dnstraceroute, "ping", lambda *args, **kwargs: (True, 
12.5, None))
+    monkeypatch.setattr(dnstraceroute, "whois_cache", {})
+    monkeypatch.setattr(shared, "shutdown", False)
+    monkeypatch.chdir(tmp_path)
+    # 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)
+
+
+class TestTraceWithAsn:
+    def test_asn_and_owner_are_printed(self, trace: Callable[..., str], 
whois_answers: list[str]) -> None:
+        assert f"1\t{SERVER} ({SERVER}) [AS15169 GOOGLE, US] 12.500 ms\n" in 
trace("-a")
+
+    def test_no_lookup_without_the_flag(self, trace: Callable[..., str], 
whois_answers: list[str]) -> None:
+        assert f"1\t{SERVER} ({SERVER}) 12.500 ms\n" in trace()
+        assert whois_answers == []
+
+    def test_unrouted_address_prints_no_asn(self, monkeypatch: 
pytest.MonkeyPatch,
+                                            trace: Callable[..., str]) -> None:
+        monkeypatch.setattr(cymruwhois.Client, "_lookupmany_raw", lambda self, 
ips: iter([UNROUTED]))
+        assert f"1\t{SERVER} ({SERVER}) 12.500 ms\n" in trace("-a")
+
+    def test_failed_lookup_still_prints_the_hop(self, monkeypatch: 
pytest.MonkeyPatch,
+                                                trace: Callable[..., str]) -> 
None:
+        monkeypatch.setattr(cymruwhois.Client, "_lookupmany_raw", 
failing_lookup)
+        assert f"1\t{SERVER} ({SERVER}) 12.500 ms\n" in trace("-a")
+
+    def test_text_from_the_whois_service_is_escaped(self, monkeypatch: 
pytest.MonkeyPatch,
+                                                    trace: Callable[..., str]) 
-> None:
+        hostile = cymruwhois.record("15169\x1b[2J", SERVER, "192.0.2.0/24", 
"US", "EVIL\x1b]0;title\x07 ‮CORP")
+        monkeypatch.setattr(cymruwhois.Client, "_lookupmany_raw", lambda self, 
ips: iter([hostile]))
+        expected = f"1\t{SERVER} ({SERVER}) [AS15169\\027[2J 
EVIL\\027]0;title\\007 \\u202eCORP] 12.500 ms\n"
+        assert expected in trace("-a")
+
+    def test_trace_leaves_the_working_directory_alone(self, trace: 
Callable[..., str], whois_answers: list[str],
+                                                      tmp_path: Path) -> None:
+        planted = plant_cache(tmp_path)
+        trace("-a")
+        assert [path.name for path in tmp_path.iterdir()] == [CACHE_FILE]
+        assert (tmp_path / CACHE_FILE).read_bytes() == planted
+
+
+class TestTraceWithReverseDns:
+    @pytest.mark.parametrize(("name", "printed"), [
+        ("dns.example", "dns.example"),
+        # Whoever controls a hop's reverse DNS sets this name, and musl 
returns it unchecked
+        ("evil\nFORGED\x1b[2J.example", "evil\\010FORGED\\027[2J.example"),
+    ], ids=["plain", "hostile"])
+    def test_hop_name(self, monkeypatch: pytest.MonkeyPatch, trace: 
Callable[..., str],
+                      name: str, printed: str) -> None:
+        monkeypatch.setattr(dnstraceroute.socket, "gethostbyaddr", lambda 
address: (name, [], [address]))
+        assert f"1\t{printed} ({SERVER}) 12.500 ms\n" in trace(resolve=True)

Reply via email to