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)
