From 0f1f918fba019e19ee7001240beb166bada66ed0 Mon Sep 17 00:00:00 2001 From: Ryan Fowler Date: Sun, 30 Aug 2026 17:25:15 +0000 Subject: [PATCH] feat(dns): add inspection verbosity parity --- docs/advanced-features.md | 2 +- docs/cli-reference.md | 2 +- docs/configuration.md | 2 +- internal/dnsinspect/dnsinspect.go | 176 ++++++++++++++++++++++--- internal/dnsinspect/dnsinspect_test.go | 70 ++++++++++ skills/fetch/references/diagnostics.md | 4 +- 6 files changed, 233 insertions(+), 23 deletions(-) diff --git a/docs/advanced-features.md b/docs/advanced-features.md index 943c74dc..79d53d93 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. Use `-vv` to see each query's responder, 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. 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 5661a5ab..73260639 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. Use `-vv` to see each query's responder, 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. 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 fb8b9bf4..8e7eff7f 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; use `-vv` to see per-query responder and attempt details. 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. 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 36debb94..c32a5cce 100644 --- a/internal/dnsinspect/dnsinspect.go +++ b/internal/dnsinspect/dnsinspect.go @@ -130,6 +130,19 @@ type result struct { tcpFallback bool platformFallback bool verbosity core.Verbosity + + // 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 } type queryStatus uint8 @@ -195,6 +208,43 @@ func loadSystemResolverPolicy(cfg *Config) *resolver.SystemResolverPolicy { return &policy } +func setSystemResolverDetails(out *result, policy resolver.SystemResolverPolicy) { + out.configuredNameservers = slices.Clone(policy.Nameservers) + if policy.Attempts > 0 { + out.resolverAttempts = policy.Attempts + } else { + out.resolverAttempts = 2 + } + if policy.Timeout > 0 { + out.resolverTimeout = policy.Timeout + } else { + out.resolverTimeout = 5 * time.Second + } + if policy.Rotate && len(policy.Nameservers) > 1 { + out.resolverRotation = "enabled" + } else { + out.resolverRotation = "disabled" + } + out.resolverConfiguration = policy.ResolvConfPath + out.resolverRouting = "direct nameserver queries" + out.resolverSearchDomains = "not applied" + out.resolverOSRouting = "not applied by direct queries" +} + +func endpointBootstrapDescription(endpoint *resolver.Endpoint) string { + if endpoint == nil { + return "" + } + if len(endpoint.BootstrapAddrs) == 0 { + return "platform resolver for " + endpoint.ConnectHost + } + addresses := make([]string, 0, len(endpoint.BootstrapAddrs)) + for _, address := range endpoint.BootstrapAddrs { + addresses = append(addresses, net.JoinHostPort(address.String(), strconv.Itoa(int(endpoint.Port)))) + } + return "configured address: " + strings.Join(addresses, ", ") +} + // Inspect resolves the configured URL hostname and renders DNS information to // the printer. It returns a non-zero exit code on failure. func Inspect(ctx context.Context, p *core.Printer, cfg *Config) int { @@ -260,6 +310,9 @@ func lookup(ctx context.Context, cfg *Config, host string, start time.Time) (*re records: make(map[string][]record), verbosity: cfg.Verbosity, } + if cfg.Endpoint != nil { + out.resolverBootstrap = endpointBootstrapDescription(cfg.Endpoint) + } // A missing --dns-server prefers the resolv.conf nameservers, which expose // every record type and per-record TTLs. The platform API is only the @@ -269,6 +322,7 @@ func lookup(ctx context.Context, cfg *Config, host string, start time.Time) (*re if systemDefault { policy := loadSystemResolverPolicy(cfg) if policy != nil && len(policy.Nameservers) > 0 { + setSystemResolverDetails(out, *policy) ordered := resolver.RotateSystemResolverPolicy(*policy) systemPolicy = &ordered target = resolverTargetInfo{label: ordered.Nameservers[0], udpAddr: ordered.Nameservers[0]} @@ -356,7 +410,17 @@ func lookup(ctx context.Context, cfg *Config, host string, start time.Time) (*re out.queryName = queryHost queryCtx, cancelQuery := contextForDirectLookup(ctx, systemPolicy != nil) defer cancelQuery() - results := runFanOut(queryCtx, queryHost, target, systemPolicy, streamClient, doqClient, dohClient) + queryTransport := resolver.TransportUDP + if cfg.Endpoint != nil { + queryTransport = cfg.Endpoint.Transport + } else if server != nil { + queryTransport = resolverURLTransport(server) + } + queryResponder := target.label + if target.udpAddr != "" { + queryResponder = target.udpAddr + } + results := runFanOut(queryCtx, queryHost, target, systemPolicy, queryTransport, queryResponder, streamClient, doqClient, dohClient) firstResult := aggregate(out, results, start) if systemPolicy != nil { setSystemResponderSummary(out) @@ -384,7 +448,7 @@ func lookup(ctx context.Context, cfg *Config, host string, start time.Time) (*re // runFanOut queries every inspection record type concurrently. Exactly one // backend is active: the system policy nameservers, or the selected stream, // DoQ, DoH, or UDP resolver. -func runFanOut(ctx context.Context, host string, target resolverTargetInfo, systemPolicy *resolver.SystemResolverPolicy, streamClient *resolver.StreamClient, doqClient *resolver.DoQClient, dohClient *resolver.DOHClient) []queryResult { +func runFanOut(ctx context.Context, host string, target resolverTargetInfo, systemPolicy *resolver.SystemResolverPolicy, queryTransport resolver.Transport, queryResponder string, streamClient *resolver.StreamClient, doqClient *resolver.DoQClient, dohClient *resolver.DOHClient) []queryResult { results := make([]queryResult, len(inspectTypes)) var wg sync.WaitGroup for i, qt := range inspectTypes { @@ -393,6 +457,12 @@ func runFanOut(ctx context.Context, host string, target resolverTargetInfo, syst defer wg.Done() queryStart := time.Now() results[i].typ = qt + if systemPolicy == nil { + // Explicit resolver backends do not return QueryMetadata, but their + // transport is known before the query starts. Set the responder only + // after a query succeeds; an endpoint is not proof that it answered. + results[i].transport = queryTransport + } switch { case systemPolicy != nil: var metadata resolver.QueryMetadata @@ -411,6 +481,9 @@ func runFanOut(ctx context.Context, host string, target resolverTargetInfo, syst default: results[i].records, results[i].tcpFallback, results[i].err = lookupUDPRecordsWithFallback(ctx, target.udpAddr, host, qt) } + if systemPolicy == nil && results[i].err == nil { + results[i].responder = queryResponder + } // System-nameserver queries expose resolver metadata that includes // failover and retry time. The other backends do not, so measure // their query operation here. This starts after shared resolver @@ -625,23 +698,32 @@ 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, - 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, + resolverBootstrap: orig.resolverBootstrap, + duration: time.Since(start), } for typ, values := range orig.records { out.records[typ] = slices.Clone(values) @@ -1592,6 +1674,24 @@ func resolverTransportSecurity(cfg *Config, server *url.URL) string { } } +func resolverURLTransport(server *url.URL) resolver.Transport { + if server == nil { + return resolver.TransportUDP + } + switch strings.ToLower(server.Scheme) { + case "tcp": + return resolver.TransportTCP + case "tls", "dot": + return resolver.TransportTLS + case "quic", "doq": + return resolver.TransportQUIC + case "http", "https": + return resolver.TransportHTTPS + default: + return resolver.TransportUDP + } +} + func resolverTarget(server *url.URL) resolverTargetInfo { switch { case server == nil: @@ -1729,6 +1829,43 @@ func inspectionTransportSummary(res *result) string { return res.transport } +func renderResolverDetails(p *core.Printer, res *result) { + var fields int + write := func(label, value string) { + if value == "" { + return + } + if fields == 0 { + writeInspectionBlankLine(p) + renderInspectionSection(p, "Resolver details") + } + fields++ + 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 { + write("Query name", res.queryName) + } + if len(res.configuredNameservers) > 0 { + write("Configured nameservers", strings.Join(res.configuredNameservers, ", ")) + } + if res.resolverAttempts > 0 { + write("Resolver attempts", countPhrase(res.resolverAttempts, "per nameserver", "per nameserver")) + } + if res.resolverTimeout > 0 { + write("Resolver timeout", formatDuration(res.resolverTimeout)) + } + write("Resolver rotation", res.resolverRotation) + write("Configuration", res.resolverConfiguration) + write("Routing", res.resolverRouting) + write("Search domains", res.resolverSearchDomains) + write("OS resolver routing", res.resolverOSRouting) + write("Bootstrap", res.resolverBootstrap) +} + func renderQueryDetails(p *core.Printer, queries []queryResult) { if len(queries) == 0 { return @@ -1818,6 +1955,7 @@ func renderInspection(p *core.Printer, res *result) { renderFailures(p, res.failures) } if res.verbosity >= core.VExtraVerbose { + renderResolverDetails(p, res) renderQueryDetails(p, res.queries) } writeInspectionBlankLine(p) diff --git a/internal/dnsinspect/dnsinspect_test.go b/internal/dnsinspect/dnsinspect_test.go index f06d87d2..7293d99e 100644 --- a/internal/dnsinspect/dnsinspect_test.go +++ b/internal/dnsinspect/dnsinspect_test.go @@ -927,6 +927,76 @@ func TestSystemResolverSummaryUsesActualResponders(t *testing.T) { } } +func TestDNSInspectionVerboseParity(t *testing.T) { + base := &result{ + host: "example.com", + queryName: "example.com", + transport: "UDP", + security: string(resolver.SecurityPlaintext), + records: map[string][]record{ + "A": {{owner: "example.com.", typ: dnsmessage.TypeA, address: net.ParseIP("192.0.2.1"), hasTTL: true, ttl: 60}}, + }, + } + + rendered := func(verbosity core.Verbosity) string { + res := *base + res.verbosity = verbosity + p := core.TestPrinter(false) + render(p, &res) + return string(p.Bytes()) + } + if normal, verbose := rendered(core.VNormal), rendered(core.VVerbose); normal != verbose { + t.Fatalf("-v changed DNS inspection output:\nnormal:\n%s\nverbose:\n%s", normal, verbose) + } + if silent := rendered(core.VSilent); silent != rendered(core.VNormal) { + t.Fatalf("silent mode changed structured DNS inspection output") + } +} + +func TestRenderExtraVerboseIncludesResolverInternals(t *testing.T) { + p := core.TestPrinter(false) + render(p, &result{ + host: "example.com", + queryName: "example.com", + verbosity: core.VExtraVerbose, + configuredNameservers: []string{"192.0.2.53:53", "192.0.2.54:53"}, + resolverAttempts: 3, + resolverTimeout: 2 * time.Second, + resolverRotation: "enabled", + resolverConfiguration: "/etc/resolv.conf", + resolverRouting: "direct nameserver queries", + resolverSearchDomains: "not applied", + resolverOSRouting: "not applied by direct queries", + queries: []queryResult{{ + typ: inspectTypes[0], + status: queryStatusNoData, + responder: "192.0.2.54:53", + transport: resolver.TransportUDP, + duration: 4 * time.Millisecond, + attempts: 2, + }}, + records: map[string][]record{}, + }) + out := string(p.Bytes()) + for _, want := range []string{ + "Resolver details", + "Query name: example.com", + "Configured nameservers: 192.0.2.53:53, 192.0.2.54:53", + "Resolver attempts: 3 per nameserver", + "Resolver timeout: 2s", + "Resolver rotation: enabled", + "Configuration: /etc/resolv.conf", + "Routing: direct nameserver queries", + "Search domains: not applied", + "OS resolver routing: not applied by direct queries", + "A: no data · UDP · 192.0.2.54:53 · 4ms · 2 attempts", + } { + if !strings.Contains(out, want) { + t.Fatalf("extra verbose output missing %q:\n%s", want, out) + } + } +} + func TestRenderQueryDetailsIncludesResponderMetadata(t *testing.T) { p := core.TestPrinter(false) render(p, &result{ diff --git a/skills/fetch/references/diagnostics.md b/skills/fetch/references/diagnostics.md index b908ee7c..9e2b2514 100644 --- a/skills/fetch/references/diagnostics.md +++ b/skills/fetch/references/diagnostics.md @@ -24,7 +24,9 @@ record types are queried concurrently. The default output uses `Lookup` and security, source, status, result counts, query counts, and timing. Each record shows its normalized, fully qualified owner name before its value. When system failover occurs, `Resolver` or `Resolvers` identifies the nameserver(s) that -actually answered. Use `-vv` for each query's responder, transport, duration, +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; the `Lookup` section reports an incomplete status and the `Failures` section identifies the failed types. The