diff --git a/docs/advanced-features.md b/docs/advanced-features.md index 79d53d93..ecbfb8df 100644 --- a/docs/advanced-features.md +++ b/docs/advanced-features.md @@ -82,7 +82,7 @@ fetch --inspect-dns example.com fetch --inspect-dns --dns-server https://1.1.1.1/dns-query example.com ``` -Without `--dns-server`, inspection queries the nameservers listed in the system resolver configuration (`/etc/resolv.conf`) directly, including on macOS. It reports every record type (A, AAAA, CNAME, TXT, MX, NS, SOA, SRV, CAA, SVCB, and HTTPS) with per-record TTLs, but does not apply macOS scoped, per-interface, VPN, or `/etc/resolver` routing. On platforms without a usable resolver file (notably Windows), or when the name is resolved only through OS mechanisms (the hosts file, NSS modules, or mDNS), it uses the platform resolver for A and AAAA records without per-record TTLs. Platform-resolver records show their source and `TTL unavailable` individually. If direct DNS returns no address records, platform-resolver addresses are added while any records already returned by direct DNS remain visible. The `Lookup` section identifies this mixed resolver path and reports the platform fallback. With an explicit resolver, inspection queries the same record types concurrently. When system failover occurs, `Resolver` or `Resolvers` reports the nameserver(s) that actually answered. The default output is complete, and `-v` has no effect in DNS inspection mode. Use `-vv` for resolver and query internals, including the configured nameserver list, policy limits, normalization, caveats, responders, transport, duration, and failover attempts. The default output uses `Lookup` and `Records` sections with the inspected name, resolver path, transport, transport security, source, status, result counts, query counts, and duration. Each record shows its normalized, fully qualified owner name before its value. Inspection output is written to stdout; invocation warnings and setup/configuration errors are written to stderr. If a query fails, successful records are retained, a `Failures` section reports the incomplete record types on stdout, and the command exits with status 1. `Transport security` describes encryption and certificate verification between fetch and the resolver; it does not indicate DNSSEC validation, which fetch does not perform. If a UDP response is truncated, fetch retries the query over TCP and reports the normal protocol fallback as `Transport: UDP → TCP fallback`, not as a warning. Use `-vv` to see which record-type queries used the fallback. +Without `--dns-server`, inspection queries the nameservers listed in the system resolver configuration (`/etc/resolv.conf`) directly, including on macOS. It reports every record type (A, AAAA, CNAME, TXT, MX, NS, SOA, SRV, CAA, SVCB, and HTTPS) with per-record TTLs, but does not apply macOS scoped, per-interface, VPN, or `/etc/resolver` routing. On platforms without a usable resolver file (notably Windows), or when the name is resolved only through OS mechanisms (the hosts file, NSS modules, or mDNS), it uses the platform resolver for A and AAAA records without per-record TTLs. Platform-resolver records show their source and `TTL unavailable` individually. If direct DNS returns no address records, platform-resolver addresses are added while any records already returned by direct DNS remain visible. The `Lookup` section identifies this mixed resolver path and reports the platform fallback. With an explicit resolver, inspection queries the same record types concurrently. When system failover occurs, `Resolver` or `Resolvers` reports the nameserver(s) that actually answered. The default output is complete, and `-v` has no effect in DNS inspection mode. Use `-vv` for resolver and query internals, including the configured nameserver list, policy limits, normalization, caveats, responders, transport, duration, and failover attempts. For direct DNS lookups, when IDNA normalization changes the name, normal output includes `Query name` with the absolute punycode name sent to DNS. Single-label names also show their absolute query name; the root terminator is omitted for ordinary multi-label hostnames when it is the only difference. The default output uses `Lookup` and `Records` sections with the inspected name, resolver path, transport, transport security, source, status, result counts, query counts, and duration. Each record shows its normalized, fully qualified owner name before its value. Inspection output is written to stdout; invocation warnings and setup/configuration errors are written to stderr. If a query fails, successful records are retained, a `Failures` section reports the incomplete record types on stdout, and the command exits with status 1. `Transport security` describes encryption and certificate verification between fetch and the resolver; it does not indicate DNSSEC validation, which fetch does not perform. If a UDP response is truncated, fetch retries the query over TCP and reports the normal protocol fallback as `Transport: UDP → TCP fallback`, not as a warning. Use `-vv` to see which record-type queries used the fallback. ### Configuration File diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 73260639..de6b94fe 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -531,7 +531,7 @@ platform bootstrap and negotiate the standard `doq` ALPN. ### `--inspect-dns` -Inspect DNS resolution for the URL hostname only (no HTTP request is made). Without `--dns-server`, it queries the nameservers listed in the system resolver configuration (`/etc/resolv.conf`) directly, including on macOS. It reports every record type (A, AAAA, CNAME, TXT, MX, NS, SOA, SRV, CAA, SVCB, and HTTPS) with per-record TTLs, but does not apply macOS scoped, per-interface, VPN, or `/etc/resolver` routing. On platforms without a usable resolver file (notably Windows), or when the name is resolved only through OS mechanisms (the hosts file, NSS modules, or mDNS), it uses the platform resolver for A and AAAA records without per-record TTLs. Platform-resolver records show their source and `TTL unavailable` individually. If direct DNS returns no address records, platform-resolver addresses are added while any records already returned by direct DNS remain visible. The `Lookup` section identifies this mixed resolver path and reports the platform fallback. With an explicit resolver it queries the same record types concurrently. When system failover occurs, `Resolver` or `Resolvers` reports the nameserver(s) that actually answered. The default output is complete, and `-v` has no effect in DNS inspection mode. Use `-vv` for resolver and query internals, including the configured nameserver list, policy limits, normalization, caveats, responders, transport, duration, and failover attempts. The default output uses `Lookup` and `Records` sections and includes the inspected name, resolver path, transport, transport security, source, status, result counts, query counts, and duration. Each record shows its normalized, fully qualified owner name before its value. Inspection output is written to stdout; invocation warnings and setup/configuration errors are written to stderr. If one query fails, successful records remain visible, a `Failures` section identifies the incomplete record types on stdout, and the command exits with status 1. `Transport security` describes encryption and certificate verification between fetch and the resolver; it does not indicate DNSSEC validation, which fetch does not perform. If a UDP response is truncated, fetch retries the query over TCP and reports the normal protocol fallback as `Transport: UDP → TCP fallback`, not as a warning. Use `-vv` to see which record-type queries used the fallback. +Inspect DNS resolution for the URL hostname only (no HTTP request is made). Without `--dns-server`, it queries the nameservers listed in the system resolver configuration (`/etc/resolv.conf`) directly, including on macOS. It reports every record type (A, AAAA, CNAME, TXT, MX, NS, SOA, SRV, CAA, SVCB, and HTTPS) with per-record TTLs, but does not apply macOS scoped, per-interface, VPN, or `/etc/resolver` routing. On platforms without a usable resolver file (notably Windows), or when the name is resolved only through OS mechanisms (the hosts file, NSS modules, or mDNS), it uses the platform resolver for A and AAAA records without per-record TTLs. Platform-resolver records show their source and `TTL unavailable` individually. If direct DNS returns no address records, platform-resolver addresses are added while any records already returned by direct DNS remain visible. The `Lookup` section identifies this mixed resolver path and reports the platform fallback. With an explicit resolver it queries the same record types concurrently. When system failover occurs, `Resolver` or `Resolvers` reports the nameserver(s) that actually answered. The default output is complete, and `-v` has no effect in DNS inspection mode. Use `-vv` for resolver and query internals, including the configured nameserver list, policy limits, normalization, caveats, responders, transport, duration, and failover attempts. For direct DNS lookups, when IDNA normalization changes the name, normal output includes `Query name` with the absolute punycode name sent to DNS. Single-label names also show their absolute query name; the root terminator is omitted for ordinary multi-label hostnames when it is the only difference. The default output uses `Lookup` and `Records` sections and includes the inspected name, resolver path, transport, transport security, source, status, result counts, query counts, and duration. Each record shows its normalized, fully qualified owner name before its value. Inspection output is written to stdout; invocation warnings and setup/configuration errors are written to stderr. If one query fails, successful records remain visible, a `Failures` section identifies the incomplete record types on stdout, and the command exits with status 1. `Transport security` describes encryption and certificate verification between fetch and the resolver; it does not indicate DNSSEC validation, which fetch does not perform. If a UDP response is truncated, fetch retries the query over TCP and reports the normal protocol fallback as `Transport: UDP → TCP fallback`, not as a warning. Use `-vv` to see which record-type queries used the fallback. ```sh fetch --inspect-dns example.com diff --git a/docs/configuration.md b/docs/configuration.md index 8e7eff7f..20b9eea7 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -268,7 +268,7 @@ ca-cert = ca-cert.pem **Type**: Resolver endpoint **Default**: System default -Use a custom DNS server for hostname resolution. Without this option, DNS inspection queries the nameservers listed in the system resolver configuration (`/etc/resolv.conf`) directly, including on macOS, and reports all supported record types with per-record TTLs. This does not apply macOS scoped, per-interface, VPN, or `/etc/resolver` routing. On platforms without a usable resolver file, or when a name is resolved only through OS mechanisms (the hosts file, NSS modules, or mDNS), it uses the platform resolver for A and AAAA records without per-record TTLs. Records already returned by direct DNS remain visible. The inspection summary identifies the nameserver that actually answered each query. DNS inspection output is complete by default, and `-v` has no effect in inspection mode. Use `-vv` for resolver internals, including the configured nameserver list, policy limits, normalized query name, direct-resolution caveats, bootstrap details, per-query responder, transport, timing, and attempts. Supported custom forms are bare IPv4 or +Use a custom DNS server for hostname resolution. Without this option, DNS inspection queries the nameservers listed in the system resolver configuration (`/etc/resolv.conf`) directly, including on macOS, and reports all supported record types with per-record TTLs. This does not apply macOS scoped, per-interface, VPN, or `/etc/resolver` routing. On platforms without a usable resolver file, or when a name is resolved only through OS mechanisms (the hosts file, NSS modules, or mDNS), it uses the platform resolver for A and AAAA records without per-record TTLs. Records already returned by direct DNS remain visible. The inspection summary identifies the nameserver that actually answered each query. For direct DNS lookups, normal output shows `Query name` when IDNA normalization changes the input, using the absolute punycode name sent to DNS. Single-label names also show their absolute query name; the root terminator is omitted for ordinary multi-label hostnames when it is the only difference. DNS inspection output is complete by default, and `-v` has no effect in inspection mode. Use `-vv` for resolver internals, including the configured nameserver list, policy limits, normalized query name, direct-resolution caveats, bootstrap details, per-query responder, transport, timing, and attempts. Supported custom forms are bare IPv4 or bracketed IPv6 UDP addresses, `host:port`, `udp://`, `tcp://`, `tls://`/`dot://`, `quic://`/`doq://`, and HTTPS DoH URLs. UDP/TCP default to port 53, while DoT/DoQ default to 853. Non-DoH paths and queries, userinfo, diff --git a/internal/dnsinspect/dnsinspect.go b/internal/dnsinspect/dnsinspect.go index c32a5cce..1dadbc0a 100644 --- a/internal/dnsinspect/dnsinspect.go +++ b/internal/dnsinspect/dnsinspect.go @@ -676,11 +676,16 @@ func dnsQueryHost(host string) (string, error) { } labels[i] = ascii } - ascii := strings.Join(labels, ".") - if trailingDot { - return ascii + ".", nil + + // DNS wire names are absolute. Keep the trailing root label in the + // inspection result so it describes the name sent to a raw resolver. The + // resolver parser also enforces the DNS label and total-name size limits + // after IDNA expansion. + queryName := strings.Join(labels, ".") + "." + if _, err := resolver.ParseName(queryName); err != nil { + return "", err } - return ascii, nil + return queryName, nil } func isASCII(value string) bool { @@ -1843,10 +1848,9 @@ func renderResolverDetails(p *core.Printer, res *result) { writeInspectionField(p, label, value) } - // The normal view only shows a query name when normalization changed the - // input. Extra verbosity also records the ordinary case so the exact name - // sent to the resolver is always visible when debugging. - if res.queryName != "" && res.queryName == res.host { + // Extra verbosity always records the exact absolute name sent to the + // resolver, including when it is equivalent to the user-facing name. + if res.queryName != "" { write("Query name", res.queryName) } if len(res.configuredNameservers) > 0 { @@ -1908,7 +1912,7 @@ func renderQueryDetails(p *core.Printer, queries []queryResult) { func renderInspection(p *core.Printer, res *result) { renderInspectionSection(p, "Lookup") writeInspectionField(p, "Name", res.host) - if res.queryName != "" && res.queryName != res.host { + if queryNameDiffers(res.host, res.queryName) { writeInspectionField(p, "Query name", res.queryName) } if res.platformFallback { @@ -2062,6 +2066,25 @@ func compareInspectionLabels(a, b string) int { return strings.Compare(a, b) } +// queryNameDiffers reports whether the absolute DNS name is meaningfully +// different from the name supplied by the user. The root terminator is +// implicit for ordinary multi-label hostnames, so it is not useful to repeat +// it in normal output. Single-label names are different: adding the root +// terminator makes the qualification explicit and avoids implying search +// domain behavior. +func queryNameDiffers(host, queryName string) bool { + if queryName == "" { + return false + } + if host == "." || strings.HasSuffix(host, ".") { + return !strings.EqualFold(host, queryName) + } + if !strings.Contains(host, ".") && strings.EqualFold(absoluteName(host), queryName) { + return true + } + return !strings.EqualFold(absoluteName(host), queryName) +} + func renderInspectionSection(p *core.Printer, heading string) { p.WriteInfoPrefix() p.Set(core.Bold) diff --git a/internal/dnsinspect/dnsinspect_test.go b/internal/dnsinspect/dnsinspect_test.go index 7293d99e..31544520 100644 --- a/internal/dnsinspect/dnsinspect_test.go +++ b/internal/dnsinspect/dnsinspect_test.go @@ -254,13 +254,68 @@ func TestLookupDOHJSONPreservesTypedRecordData(t *testing.T) { } } -func TestDNSQueryHostPreservesASCIIServiceLabels(t *testing.T) { - got, err := dnsQueryHost("_acme-challenge.example") - if err != nil { - t.Fatal(err) +func TestDNSQueryHostNormalizesToAbsoluteName(t *testing.T) { + tests := []struct { + name string + want string + }{ + {name: "ordinary hostname", want: "example.com."}, + {name: "IDN", want: "xn--mnich-kva.example."}, + {name: "trailing dot", want: "example.com."}, + {name: "service label", want: "_acme-challenge.example."}, + {name: "IDN after service label", want: "_acme-challenge.xn--mnich-kva.example."}, + {name: "root", want: "."}, + } + inputs := []string{ + "example.com", + "münich.example", + "example.com.", + "_acme-challenge.example", + "_acme-challenge.münich.example", + ".", + } + for i, input := range inputs { + t.Run(tests[i].name, func(t *testing.T) { + got, err := dnsQueryHost(input) + if err != nil { + t.Fatal(err) + } + if got != tests[i].want { + t.Fatalf("DNS query host = %q, want %q", got, tests[i].want) + } + }) } - if want := "_acme-challenge.example"; got != want { - t.Fatalf("DNS query host = %q, want %q", got, want) +} + +func TestDNSQueryHostRejectsInvalidNames(t *testing.T) { + tests := []string{"", "example..com", strings.Repeat("a", 64) + ".example"} + for _, input := range tests { + t.Run(input, func(t *testing.T) { + if _, err := dnsQueryHost(input); err == nil { + t.Fatalf("dnsQueryHost(%q) succeeded, want invalid-name error", input) + } + }) + } +} + +func TestQueryNameDiffersOnlyForMeaningfulNormalization(t *testing.T) { + tests := []struct { + host, queryName string + want bool + }{ + {host: "example.com", queryName: "example.com.", want: false}, + {host: "EXAMPLE.COM", queryName: "EXAMPLE.COM.", want: false}, + {host: "example.com.", queryName: "example.com.", want: false}, + {host: "internal-service", queryName: "internal-service.", want: true}, + {host: "münich.example", queryName: "xn--mnich-kva.example.", want: true}, + {host: ".", queryName: ".", want: false}, + } + for _, tt := range tests { + t.Run(tt.host, func(t *testing.T) { + if got := queryNameDiffers(tt.host, tt.queryName); got != tt.want { + t.Fatalf("queryNameDiffers(%q, %q) = %t, want %t", tt.host, tt.queryName, got, tt.want) + } + }) } } @@ -270,7 +325,7 @@ func TestInspectNormalizesIDNForDNSQueries(t *testing.T) { w.WriteHeader(http.StatusUnsupportedMediaType) return } - if got, want := r.URL.Query().Get("name"), "xn--mnich-kva.example"; got != want { + if got, want := r.URL.Query().Get("name"), "xn--mnich-kva.example."; got != want { http.Error(w, "unexpected DNS name", http.StatusBadRequest) return } @@ -290,9 +345,13 @@ func TestInspectNormalizesIDNForDNSQueries(t *testing.T) { if status != 0 { t.Fatalf("status = %d, want 0\n%s", status, p.Bytes()) } - if !strings.Contains(string(p.Bytes()), "192.0.2.1") { + out := string(p.Bytes()) + if !strings.Contains(out, "192.0.2.1") { t.Fatalf("output missing IDN A record:\n%s", p.Bytes()) } + if !strings.Contains(out, "Query name: xn--mnich-kva.example.") { + t.Fatalf("output missing normalized query name:\n%s", out) + } } func TestInspectIPLiteralSkipsLookup(t *testing.T) { diff --git a/skills/fetch/references/diagnostics.md b/skills/fetch/references/diagnostics.md index 9e2b2514..c2f2887d 100644 --- a/skills/fetch/references/diagnostics.md +++ b/skills/fetch/references/diagnostics.md @@ -27,7 +27,7 @@ failover occurs, `Resolver` or `Resolvers` identifies the nameserver(s) that actually answered. The default output is complete, and `-v` has no effect in DNS inspection mode. Use `-vv` for resolver and query internals, including the configured nameserver list, policy limits, normalization, caveats, responders, transport, duration, -and failover attempts. Successful records remain visible when one query fails; +and failover attempts. For direct DNS lookups, when IDNA normalization changes the name, normal output includes `Query name` with the absolute punycode name sent to DNS. Single-label names also show their absolute query name; the root terminator is omitted for ordinary multi-label hostnames when it is the only difference. Successful records remain visible when one query fails; the `Lookup` section reports an incomplete status and the `Failures` section identifies the failed types. The command exits nonzero. Inspection output, including the `Failures` section, goes