diff --git a/docs/advanced-features.md b/docs/advanced-features.md index ecbfb8df..458b8c95 100644 --- a/docs/advanced-features.md +++ b/docs/advanced-features.md @@ -84,6 +84,8 @@ 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. 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. +At `-vv`, the `System resolver` section describes the direct system path. It shows the resolver configuration file, direct nameserver routing, and that search domains are not applied. On macOS it also reports that scoped, VPN, per-interface, and `/etc/resolver` routing is not applied. On other platforms it reports that OS resolver routing is not applied by direct queries. These caveats describe the direct DNS portion only; a platform fallback uses the OS resolver for addresses. + ### Configuration File ```ini diff --git a/docs/cli-reference.md b/docs/cli-reference.md index de6b94fe..710a7f7f 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -533,6 +533,8 @@ platform bootstrap and negotiate the standard `doq` ALPN. 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. +At `-vv`, the `System resolver` section describes the direct system path. It shows the resolver configuration file, direct nameserver routing, and that search domains are not applied. On macOS it also reports that scoped, VPN, per-interface, and `/etc/resolver` routing is not applied. On other platforms it reports that OS resolver routing is not applied by direct queries. These caveats describe the direct DNS portion only; a platform fallback uses the OS resolver for addresses. + ```sh fetch --inspect-dns example.com fetch --inspect-dns --dns-server https://1.1.1.1/dns-query example.com diff --git a/internal/dnsinspect/dnsinspect.go b/internal/dnsinspect/dnsinspect.go index 1dadbc0a..62161b83 100644 --- a/internal/dnsinspect/dnsinspect.go +++ b/internal/dnsinspect/dnsinspect.go @@ -134,15 +134,16 @@ type result struct { // The following fields are only rendered at -vv. Keeping them in the // result, rather than deriving them in the renderer, preserves the // resolver policy that was used for this operation. - configuredNameservers []string - resolverAttempts int - resolverTimeout time.Duration - resolverRotation string - resolverConfiguration string - resolverRouting string - resolverSearchDomains string - resolverOSRouting string - resolverBootstrap string + configuredNameservers []string + resolverAttempts int + resolverTimeout time.Duration + resolverRotation string + resolverConfiguration string + resolverRouting string + resolverSearchDomains string + resolverOSRouting string + resolverPlatformRouting string + resolverBootstrap string } type queryStatus uint8 @@ -226,9 +227,36 @@ func setSystemResolverDetails(out *result, policy resolver.SystemResolverPolicy) out.resolverRotation = "disabled" } out.resolverConfiguration = policy.ResolvConfPath - out.resolverRouting = "direct nameserver queries" - out.resolverSearchDomains = "not applied" - out.resolverOSRouting = "not applied by direct queries" + caveats := directSystemResolverCaveats(runtime.GOOS) + out.resolverRouting = caveats.routing + out.resolverSearchDomains = caveats.searchDomains + out.resolverOSRouting = caveats.osRouting + out.resolverPlatformRouting = caveats.platformRouting +} + +type systemResolverCaveats struct { + routing string + searchDomains string + osRouting string + platformRouting string +} + +// directSystemResolverCaveats describes the behavior that differs from the +// platform resolver when inspection sends DNS packets to configured +// nameservers. Keep platform-specific caveats separate: macOS has resolver +// scopes and /etc/resolver routing that are not represented by resolv.conf, +// while other supported platforms need the more general direct-query warning. +func directSystemResolverCaveats(goos string) systemResolverCaveats { + caveats := systemResolverCaveats{ + routing: "direct nameserver queries", + searchDomains: "not applied", + } + if goos == "darwin" { + caveats.platformRouting = "scoped/VPN/per-interface and /etc/resolver routing not applied" + } else { + caveats.osRouting = "not applied by direct queries" + } + return caveats } func endpointBootstrapDescription(endpoint *resolver.Endpoint) string { @@ -703,32 +731,33 @@ func isASCII(value string) bool { // explicit. func platformResult(orig *result, records []record, start time.Time) *result { out := &result{ - host: orig.host, - queryName: orig.queryName, - resolver: "system nameservers + platform resolver", - transport: "mixed", - security: "mixed", - source: "system resolver configuration + platform resolver", - responders: append(slices.Clone(orig.responders), "platform resolver"), - records: make(map[string][]record, len(orig.records)), - queries: slices.Clone(orig.queries), - failures: slices.Clone(orig.failures), - queryTotal: orig.queryTotal, - queryWithData: orig.queryWithData, - queryNoData: orig.queryNoData, - tcpFallback: orig.tcpFallback, - platformFallback: true, - verbosity: orig.verbosity, - configuredNameservers: slices.Clone(orig.configuredNameservers), - resolverAttempts: orig.resolverAttempts, - resolverTimeout: orig.resolverTimeout, - resolverRotation: orig.resolverRotation, - resolverConfiguration: orig.resolverConfiguration, - resolverRouting: orig.resolverRouting, - resolverSearchDomains: orig.resolverSearchDomains, - resolverOSRouting: orig.resolverOSRouting, - resolverBootstrap: orig.resolverBootstrap, - duration: time.Since(start), + host: orig.host, + queryName: orig.queryName, + resolver: "system nameservers + platform resolver", + transport: "mixed", + security: "mixed", + source: "system resolver configuration + platform resolver", + responders: append(slices.Clone(orig.responders), "platform resolver"), + records: make(map[string][]record, len(orig.records)), + queries: slices.Clone(orig.queries), + failures: slices.Clone(orig.failures), + queryTotal: orig.queryTotal, + queryWithData: orig.queryWithData, + queryNoData: orig.queryNoData, + tcpFallback: orig.tcpFallback, + platformFallback: true, + verbosity: orig.verbosity, + configuredNameservers: slices.Clone(orig.configuredNameservers), + resolverAttempts: orig.resolverAttempts, + resolverTimeout: orig.resolverTimeout, + resolverRotation: orig.resolverRotation, + resolverConfiguration: orig.resolverConfiguration, + resolverRouting: orig.resolverRouting, + resolverSearchDomains: orig.resolverSearchDomains, + resolverOSRouting: orig.resolverOSRouting, + resolverPlatformRouting: orig.resolverPlatformRouting, + resolverBootstrap: orig.resolverBootstrap, + duration: time.Since(start), } for typ, values := range orig.records { out.records[typ] = slices.Clone(values) @@ -1842,7 +1871,11 @@ func renderResolverDetails(p *core.Printer, res *result) { } if fields == 0 { writeInspectionBlankLine(p) - renderInspectionSection(p, "Resolver details") + heading := "Resolver details" + if res.resolverRouting != "" || len(res.configuredNameservers) > 0 { + heading = "System resolver" + } + renderInspectionSection(p, heading) } fields++ writeInspectionField(p, label, value) @@ -1867,6 +1900,7 @@ func renderResolverDetails(p *core.Printer, res *result) { write("Routing", res.resolverRouting) write("Search domains", res.resolverSearchDomains) write("OS resolver routing", res.resolverOSRouting) + write("macOS routing", res.resolverPlatformRouting) write("Bootstrap", res.resolverBootstrap) } diff --git a/internal/dnsinspect/dnsinspect_test.go b/internal/dnsinspect/dnsinspect_test.go index 31544520..3507c204 100644 --- a/internal/dnsinspect/dnsinspect_test.go +++ b/internal/dnsinspect/dnsinspect_test.go @@ -1012,6 +1012,41 @@ func TestDNSInspectionVerboseParity(t *testing.T) { } } +func TestDirectSystemResolverCaveatsArePlatformSpecific(t *testing.T) { + generic := directSystemResolverCaveats("linux") + if generic.routing != "direct nameserver queries" || generic.searchDomains != "not applied" { + t.Fatalf("generic resolver caveats = %#v", generic) + } + if generic.osRouting != "not applied by direct queries" || generic.platformRouting != "" { + t.Fatalf("generic platform caveat = %#v", generic) + } + + macOS := directSystemResolverCaveats("darwin") + if macOS.osRouting != "" { + t.Fatalf("macOS unexpectedly has generic OS caveat = %q", macOS.osRouting) + } + if want := "scoped/VPN/per-interface and /etc/resolver routing not applied"; macOS.platformRouting != want { + t.Fatalf("macOS platform caveat = %q, want %q", macOS.platformRouting, want) + } +} + +func TestSetSystemResolverDetailsReportsDirectQueryCaveats(t *testing.T) { + out := &result{} + setSystemResolverDetails(out, resolver.SystemResolverPolicy{ + Nameservers: []string{"192.0.2.53:53"}, + ResolvConfPath: "/etc/resolv.conf", + }) + if out.resolverConfiguration != "/etc/resolv.conf" { + t.Fatalf("configuration = %q, want /etc/resolv.conf", out.resolverConfiguration) + } + if out.resolverRouting != "direct nameserver queries" || out.resolverSearchDomains != "not applied" { + t.Fatalf("direct resolver path = %#v", out) + } + if out.resolverOSRouting == "" && out.resolverPlatformRouting == "" { + t.Fatalf("direct resolver caveat is missing: %#v", out) + } +} + func TestRenderExtraVerboseIncludesResolverInternals(t *testing.T) { p := core.TestPrinter(false) render(p, &result{ @@ -1038,7 +1073,7 @@ func TestRenderExtraVerboseIncludesResolverInternals(t *testing.T) { }) out := string(p.Bytes()) for _, want := range []string{ - "Resolver details", + "System resolver", "Query name: example.com", "Configured nameservers: 192.0.2.53:53, 192.0.2.54:53", "Resolver attempts: 3 per nameserver", diff --git a/skills/fetch/references/diagnostics.md b/skills/fetch/references/diagnostics.md index c2f2887d..23685956 100644 --- a/skills/fetch/references/diagnostics.md +++ b/skills/fetch/references/diagnostics.md @@ -33,6 +33,8 @@ identifies the failed types. The command exits nonzero. Inspection output, including the `Failures` section, goes to stdout. Invocation warnings and setup/configuration errors go to stderr. `Transport security` describes the resolver connection only; it is not DNSSEC validation, which fetch does not perform. A truncated UDP response is retried over TCP and is reported as transport metadata (`Transport: UDP → TCP fallback`), not as a warning. Use `-vv` to identify the record-type queries that used this fallback. +For direct system DNS, `-vv` reports the configuration file, direct nameserver routing, and that search domains are not applied. On macOS, it also reports that scoped, VPN, per-interface, and `/etc/resolver` routing is not applied. On other platforms, it reports that OS resolver routing is not applied by direct queries. These limitations apply to the direct DNS path; platform-fallback addresses use the OS resolver. Do not discard useful stdout only because a partial inspection exits with status 1. + ## TLS ```sh