From fc532d4311df7ecc956f1d5840836849e49786a9 Mon Sep 17 00:00:00 2001 From: Ryan Fowler Date: Sun, 30 Aug 2026 16:29:41 +0000 Subject: [PATCH] feat(dns): report actual system resolver responders --- docs/advanced-features.md | 2 +- docs/cli-reference.md | 2 +- docs/configuration.md | 2 +- internal/dnsinspect/dnsinspect.go | 108 +++++++++++++++++++------ internal/dnsinspect/dnsinspect_test.go | 47 +++++++++++ internal/resolver/endpoint.go | 11 +-- internal/resolver/system.go | 76 +++++++++++++---- internal/resolver/system_test.go | 39 ++++++++- internal/resolver/udp_test.go | 15 +++- skills/fetch/references/diagnostics.md | 9 ++- 10 files changed, 256 insertions(+), 55 deletions(-) diff --git a/docs/advanced-features.md b/docs/advanced-features.md index 08d7f5e0..943c74dc 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. 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. 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. ### Configuration File diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 321f3dc1..5661a5ab 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. 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. 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. ```sh fetch --inspect-dns example.com diff --git a/docs/configuration.md b/docs/configuration.md index 9a7145e9..fb8b9bf4 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. 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; use `-vv` to see per-query responder and attempt details. 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 4fd33185..4d1ab5b4 100644 --- a/internal/dnsinspect/dnsinspect.go +++ b/internal/dnsinspect/dnsinspect.go @@ -116,6 +116,7 @@ type result struct { host string queryName string resolver string + responders []string transport string security string source string @@ -144,6 +145,10 @@ type queryResult struct { status queryStatus records []record err error + responder string + transport resolver.Transport + attempts int + duration time.Duration tcpFallback bool } @@ -353,11 +358,8 @@ func lookup(ctx context.Context, cfg *Config, host string, start time.Time) (*re defer cancelQuery() results := runFanOut(queryCtx, queryHost, target, systemPolicy, streamClient, doqClient, dohClient) firstResult := aggregate(out, results, start) - if systemPolicy != nil && (len(out.failures) > 0 || len(systemPolicy.Nameservers) > 1) { - // Different record types can come from different configured servers. - // With more than one server, the query layer may fail over silently, - // so do not claim one server supplied the complete result. - out.resolver = "system resolver (configured nameservers)" + if systemPolicy != nil { + setSystemResponderSummary(out) } // A system-nameserver query that returned no address records (for example a @@ -392,7 +394,13 @@ func runFanOut(ctx context.Context, host string, target resolverTargetInfo, syst results[i].typ = qt switch { case systemPolicy != nil: - results[i].records, results[i].tcpFallback, results[i].err = lookupSystemRecords(ctx, systemPolicy, host, qt) + var metadata resolver.QueryMetadata + results[i].records, metadata, results[i].err = lookupSystemRecords(ctx, systemPolicy, host, qt) + results[i].responder = metadata.Server + results[i].transport = metadata.Transport + results[i].attempts = metadata.Attempts + results[i].duration = metadata.Duration + results[i].tcpFallback = metadata.TCPFallback case streamClient != nil: results[i].records, results[i].err = lookupStreamRecords(ctx, streamClient, host, qt) case doqClient != nil: @@ -409,15 +417,17 @@ func runFanOut(ctx context.Context, host string, target resolverTargetInfo, syst } // lookupSystemRecords resolves host for one record type through the system -// nameservers, retrying across them per the resolv.conf policy. -func lookupSystemRecords(ctx context.Context, policy *resolver.SystemResolverPolicy, host string, qt queryType) ([]record, bool, error) { +// nameservers, retrying across them per the resolv.conf policy. The metadata +// identifies the nameserver that produced the response, not merely the first +// configured nameserver. +func lookupSystemRecords(ctx context.Context, policy *resolver.SystemResolverPolicy, host string, qt queryType) ([]record, resolver.QueryMetadata, error) { // resolvectl does not expose TTLs. DNS inspection must query the configured // nameserver directly so every displayed record has authoritative TTL data. inspectionPolicy := *policy inspectionPolicy.UseSystemdResolved = false - resolved, fallback, err := resolver.QuerySystemType(ctx, inspectionPolicy, host, uint16(qt.dnsType)) + resolved, metadata, err := resolver.QuerySystemTypeDetailed(ctx, inspectionPolicy, host, uint16(qt.dnsType)) if err != nil { - return nil, fallback, err + return nil, metadata, err } records := make([]record, 0, len(resolved)) for _, rec := range resolved { @@ -425,7 +435,36 @@ func lookupSystemRecords(ctx context.Context, policy *resolver.SystemResolverPol records = append(records, converted) } } - return records, fallback, nil + return records, metadata, nil +} + +// setSystemResponderSummary replaces the configured-nameserver placeholder +// with the exact responders observed during this inspection. A failed query +// has no responder, so it cannot make the summary claim that a server replied. +func setSystemResponderSummary(out *result) { + responders := make([]string, 0, len(out.queries)) + seen := make(map[string]struct{}, len(out.queries)) + for _, query := range out.queries { + if query.responder == "" { + continue + } + if _, ok := seen[query.responder]; ok { + continue + } + seen[query.responder] = struct{}{} + responders = append(responders, query.responder) + } + slices.Sort(responders) + out.responders = responders + switch len(responders) { + case 0: + out.resolver = "system resolver (configured nameservers)" + case 1: + out.resolver = responders[0] + default: + out.resolver = "" + out.responders = responders + } } // aggregate merges per-type query results into out. It returns the first @@ -583,6 +622,7 @@ func platformResult(orig *result, records []record, start time.Time) *result { 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), @@ -1592,6 +1632,8 @@ func displayTransport(transport resolver.Transport) string { return "QUIC (DoQ)" case resolver.TransportHTTPS: return "HTTPS (DoH)" + case resolver.TransportSystem: + return "platform resolver" default: return "UDP" } @@ -1678,20 +1720,14 @@ func inspectionTransportSummary(res *result) string { return res.transport } -func renderFallbackQueries(p *core.Printer, queries []queryResult) { - fallbacks := make([]queryResult, 0) - for _, query := range queries { - if query.tcpFallback { - fallbacks = append(fallbacks, query) - } - } - if len(fallbacks) == 0 { +func renderQueryDetails(p *core.Printer, queries []queryResult) { + if len(queries) == 0 { return } writeInspectionBlankLine(p) renderInspectionSection(p, "Queries") - for _, query := range fallbacks { + for _, query := range queries { status := "no data" switch query.status { case queryStatusData: @@ -1699,7 +1735,24 @@ func renderFallbackQueries(p *core.Printer, queries []queryResult) { case queryStatusFailed: status = "failed" } - writeInspectionField(p, query.typ.label, status+" · UDP → TCP fallback") + parts := []string{status} + // Keep the fallback immediately after the status so the legacy focused + // output remains easy to scan, then append the exact responder details. + if query.tcpFallback { + parts = append(parts, "UDP → TCP fallback") + } else if query.transport != "" { + parts = append(parts, displayTransport(query.transport)) + } + if query.responder != "" { + parts = append(parts, query.responder) + } + if query.duration > 0 { + parts = append(parts, formatDuration(query.duration)) + } + if query.attempts > 0 { + parts = append(parts, countPhrase(query.attempts, "attempt", "attempts")) + } + writeInspectionField(p, query.typ.label, strings.Join(parts, " · ")) } } @@ -1712,7 +1765,16 @@ func renderInspection(p *core.Printer, res *result) { if res.queryName != "" && res.queryName != res.host { writeInspectionField(p, "Query name", res.queryName) } - if res.resolver != "" { + if res.platformFallback { + if res.resolver != "" { + writeInspectionField(p, "Resolver", res.resolver) + } + if len(res.responders) > 0 { + writeInspectionField(p, "Resolvers", strings.Join(res.responders, ", ")) + } + } else if len(res.responders) > 1 { + writeInspectionField(p, "Resolvers", strings.Join(res.responders, ", ")) + } else if res.resolver != "" { writeInspectionField(p, "Resolver", res.resolver) } if transport := inspectionTransportSummary(res); transport != "" { @@ -1747,7 +1809,7 @@ func renderInspection(p *core.Printer, res *result) { renderFailures(p, res.failures) } if res.verbosity >= core.VExtraVerbose { - renderFallbackQueries(p, res.queries) + renderQueryDetails(p, res.queries) } writeInspectionBlankLine(p) renderInspectionSection(p, "Records") diff --git a/internal/dnsinspect/dnsinspect_test.go b/internal/dnsinspect/dnsinspect_test.go index 7237a48c..ab012e28 100644 --- a/internal/dnsinspect/dnsinspect_test.go +++ b/internal/dnsinspect/dnsinspect_test.go @@ -804,6 +804,7 @@ func TestLookupSystemCombinesDirectRecordsWithPlatformAddresses(t *testing.T) { out := string(p.Bytes()) for _, want := range []string{ "Resolver: system nameservers + platform resolver", + "Resolvers: " + addr + ", platform resolver", "Fallback: platform resolver used for addresses", "192.0.2.42 (platform resolver; TTL unavailable)", `"device=printer" (TTL 2m)`, @@ -896,6 +897,52 @@ func TestResolverTargetUsesPlatformResolver(t *testing.T) { } } +func TestSystemResolverSummaryUsesActualResponders(t *testing.T) { + res := &result{ + resolver: "configured-first:53", + records: make(map[string][]record), + } + aggregate(res, []queryResult{ + {typ: inspectTypes[0], responder: "192.0.2.2:53", transport: resolver.TransportUDP, records: []record{{typ: dnsmessage.TypeA, address: net.ParseIP("192.0.2.1")}}}, + {typ: inspectTypes[1], responder: "192.0.2.1:53", transport: resolver.TransportUDP, records: []record{{typ: dnsmessage.TypeAAAA, address: net.ParseIP("2001:db8::1")}}}, + }, time.Now()) + setSystemResponderSummary(res) + + p := core.TestPrinter(false) + render(p, res) + out := string(p.Bytes()) + if !strings.Contains(out, "Resolvers: 192.0.2.1:53, 192.0.2.2:53") { + t.Fatalf("actual responder summary missing or unsorted:\n%s", out) + } + if strings.Contains(out, "Resolver: configured-first:53") { + t.Fatalf("configured resolver placeholder was rendered:\n%s", out) + } +} + +func TestRenderQueryDetailsIncludesResponderMetadata(t *testing.T) { + p := core.TestPrinter(false) + render(p, &result{ + host: "example.com", + verbosity: core.VExtraVerbose, + queries: []queryResult{{ + typ: inspectTypes[0], + status: queryStatusData, + responder: "192.0.2.53:53", + transport: resolver.TransportUDP, + duration: 4 * time.Millisecond, + attempts: 1, + records: []record{{typ: dnsmessage.TypeA}}, + }}, + records: map[string][]record{}, + }) + out := string(p.Bytes()) + for _, want := range []string{"A: 1 record · UDP · 192.0.2.53:53", "4ms", "1 attempt"} { + if !strings.Contains(out, want) { + t.Fatalf("query metadata missing %q:\n%s", want, out) + } + } +} + func TestRenderTCPFallbackAsTransportMetadata(t *testing.T) { res := &result{ host: "example.com", diff --git a/internal/resolver/endpoint.go b/internal/resolver/endpoint.go index a1ee621a..50ac03a3 100644 --- a/internal/resolver/endpoint.go +++ b/internal/resolver/endpoint.go @@ -12,11 +12,12 @@ import ( type Transport string const ( - TransportUDP Transport = "udp" - TransportTCP Transport = "tcp" - TransportTLS Transport = "tls" - TransportQUIC Transport = "quic" - TransportHTTPS Transport = "https" + TransportUDP Transport = "udp" + TransportTCP Transport = "tcp" + TransportTLS Transport = "tls" + TransportQUIC Transport = "quic" + TransportHTTPS Transport = "https" + TransportSystem Transport = "system" ) // Security describes the transport's protection against network observers and diff --git a/internal/resolver/system.go b/internal/resolver/system.go index 1acd256f..fa9b9821 100644 --- a/internal/resolver/system.go +++ b/internal/resolver/system.go @@ -154,19 +154,50 @@ func RotateSystemResolverPolicy(policy SystemResolverPolicy) SystemResolverPolic return policy } +// QueryMetadata describes how a system resolver query completed. Server is +// set only when a nameserver produced a response, so callers do not mistake a +// configured-but-unreachable nameserver for the responder. Attempts counts +// configured nameservers tried, including the successful one. +type QueryMetadata struct { + Server string + Transport Transport + TCPFallback bool + Attempts int + Duration time.Duration +} + // QuerySystemType resolves host for an arbitrary DNS record type using the -// configured system nameservers, honoring the resolv.conf attempts, rotate, -// and timeout policy. The boolean reports whether any nameserver required a -// TCP fallback. systemd-resolved is consulted only for HTTPS/SVCB because -// resolvectl does not expose other record types. +// configured system nameservers. It retains the original compact API for +// callers that only need the TCP fallback flag. func QuerySystemType(ctx context.Context, policy SystemResolverPolicy, host string, typ uint16) ([]Record, bool, error) { + records, metadata, err := QuerySystemTypeDetailed(ctx, policy, host, typ) + return records, metadata.TCPFallback, err +} + +// QuerySystemTypeDetailed resolves host and reports the nameserver that +// answered the query. It honors the resolv.conf attempts, rotate, and timeout +// policy. The transport is UDP unless TCP was needed as a fallback. The +// detailed result lets diagnostics distinguish failover from the configured +// nameserver list without changing the compatibility API above. +// +// systemd-resolved is consulted only for HTTPS/SVCB because resolvectl does +// not expose other record types. Its local service identity is reported as +// the server when that path succeeds; it cannot expose the upstream server. +func QuerySystemTypeDetailed(ctx context.Context, policy SystemResolverPolicy, host string, typ uint16) (records []Record, metadata QueryMetadata, err error) { + metadata = QueryMetadata{Transport: TransportUDP} + started := time.Now() + defer func() { metadata.Duration = time.Since(started) }() + if policy.UseSystemdResolved && runtime.GOOS == "linux" && (typ == dnsTypeHTTPS || typ == dnsTypeSVCB) { if records, err := querySystemdResolved(ctx, host, typ); err == nil && len(records) > 0 { - return records, false, nil + metadata.Server = "systemd-resolved" + metadata.Transport = TransportSystem + metadata.Attempts = 1 + return records, metadata, nil } } if len(policy.Nameservers) == 0 { - return nil, false, ErrHTTPSRecordsUnavailable + return nil, metadata, ErrHTTPSRecordsUnavailable } attempts := policy.Attempts if attempts <= 0 { @@ -183,25 +214,42 @@ func QuerySystemType(ctx context.Context, policy SystemResolverPolicy, host stri var lastErr error var totalFallback bool for offset := range len(policy.Nameservers) { + if err := contextError(ctx); err != nil { + return nil, metadata, err + } index := (start + offset) % len(policy.Nameservers) + server := policy.Nameservers[index] + metadata.Attempts++ queryCtx, cancel := context.WithTimeout(ctx, timeout) - message, fallback, err := lookupUDPMessage(queryCtx, policy.Nameservers[index], host, typ, attempts) + message, fallback, err := lookupUDPMessage(queryCtx, server, host, typ, attempts) cancel() totalFallback = totalFallback || fallback + metadata.TCPFallback = totalFallback + if fallback { + metadata.Transport = TransportTCP + } if err != nil { + if fallback { + // A truncated UDP response proves that this nameserver + // answered, even when its TCP retry fails. + metadata.Server = server + } lastErr = err continue } + // This server returned a correlated DNS response, even if the response + // later fails RCODE or answer authorization checks. + metadata.Server = server name, err := ParseName(host) if err != nil { - return nil, totalFallback, err + return nil, metadata, err } if message.Header.RCode != 0 { - return nil, totalFallback, fmt.Errorf("DNS response: %s", RCodeName(message.Header.RCode)) + return nil, metadata, fmt.Errorf("DNS response: %s", RCodeName(message.Header.RCode)) } authorized, err := AuthorizeAnswers(message, Question{Name: name, Type: typ, Class: 1}) if err != nil { - return nil, totalFallback, err + return nil, metadata, err } out := make([]Record, 0, len(authorized)) hasRequestedType := false @@ -210,17 +258,17 @@ func QuerySystemType(ctx context.Context, policy SystemResolverPolicy, host stri hasRequestedType = hasRequestedType || record.Type == typ } if !hasRequestedType { - return nil, totalFallback, errDNSNoData + return nil, metadata, errDNSNoData } - return out, totalFallback, nil + return out, metadata, nil } if err := contextError(ctx); err != nil { - return nil, totalFallback, err + return nil, metadata, err } if lastErr == nil { lastErr = errors.New("system resolver query failed") } - return nil, totalFallback, lastErr + return nil, metadata, lastErr } func querySystemdResolved(ctx context.Context, host string, typ uint16) ([]Record, error) { diff --git a/internal/resolver/system_test.go b/internal/resolver/system_test.go index e2b641c3..5cd08cc7 100644 --- a/internal/resolver/system_test.go +++ b/internal/resolver/system_test.go @@ -62,10 +62,19 @@ func TestQuerySystemTypeRetriesAcrossNameservers(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) defer cancel() - records, _, err := QuerySystemType(ctx, policy, "example.com", dnsTypeA) + records, metadata, err := QuerySystemTypeDetailed(ctx, policy, "example.com", dnsTypeA) if err != nil { t.Fatal(err) } + if metadata.Server != server.addr() { + t.Fatalf("responder = %q, want %q", metadata.Server, server.addr()) + } + if metadata.Transport != TransportUDP || metadata.TCPFallback || metadata.Attempts != 2 { + t.Fatalf("query metadata = %+v, want second UDP responder after two attempts", metadata) + } + if metadata.Duration <= 0 { + t.Fatalf("query metadata duration = %s, want positive duration", metadata.Duration) + } if len(records) != 1 || records[0].Type != dnsTypeA { t.Fatalf("records = %#v, want one A record", records) } @@ -111,7 +120,7 @@ func TestQuerySystemTypePropagatesFailedTCPFallback(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) defer cancel() - _, fallback, err := QuerySystemType(ctx, SystemResolverPolicy{ + _, metadata, err := QuerySystemTypeDetailed(ctx, SystemResolverPolicy{ Nameservers: []string{server.addr()}, Attempts: 1, Timeout: time.Second, @@ -119,9 +128,16 @@ func TestQuerySystemTypePropagatesFailedTCPFallback(t *testing.T) { if err == nil || !strings.Contains(err.Error(), "DNS TCP fallback") { t.Fatalf("error = %v, want failed TCP fallback", err) } - if !fallback { + if !metadata.TCPFallback { t.Fatal("fallback = false, want attempted TCP fallback") } + if metadata.Server != server.addr() { + t.Fatalf("responder = %q, want %q after failed fallback", metadata.Server, server.addr()) + } + if metadata.Attempts != 1 || metadata.Duration <= 0 { + t.Fatalf("query metadata = %+v, want one attempted responder and duration", metadata) + } + if err := <-done; err != nil { t.Fatal(err) } @@ -136,6 +152,23 @@ func withTruncatedFlag(packet []byte) []byte { return packet } +func TestQuerySystemTypeDetailedStopsAfterContextCancellation(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + _, metadata, err := QuerySystemTypeDetailed(ctx, SystemResolverPolicy{ + Nameservers: []string{"192.0.2.1:53", "192.0.2.2:53"}, + Attempts: 1, + Timeout: time.Second, + }, "example.com", dnsTypeA) + if err == nil { + t.Fatal("error = nil, want canceled context") + } + if metadata.Attempts != 0 || metadata.Server != "" { + t.Fatalf("query metadata = %+v, want no attempted or responding nameserver", metadata) + } +} + func TestParseResolvConfSkipsMalformedNameserversAndReadsPolicy(t *testing.T) { policy := ParseResolvConf(strings.TrimSpace(` # comments and malformed entries are ignored diff --git a/internal/resolver/udp_test.go b/internal/resolver/udp_test.go index 8bb69853..c1435209 100644 --- a/internal/resolver/udp_test.go +++ b/internal/resolver/udp_test.go @@ -247,7 +247,7 @@ func TestLookupWireTypeAcceptsResponseAfterMalformedMatchingBurst(t *testing.T) } } -func TestLookupWireTypeFallsBackToTCPWhenUDPIsTruncated(t *testing.T) { +func TestQuerySystemTypeDetailedReportsTCPFallback(t *testing.T) { server, tcp := newUDPAndTCPTestServer(t) defer server.close() defer tcp.Close() @@ -309,12 +309,19 @@ func TestLookupWireTypeFallsBackToTCPWhenUDPIsTruncated(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) defer cancel() - addrs, err := lookupWireType(ctx, server.addr(), "example.com", dnsTypeA) + records, metadata, err := QuerySystemTypeDetailed(ctx, SystemResolverPolicy{ + Nameservers: []string{server.addr()}, + Attempts: 1, + Timeout: time.Second, + }, "example.com", dnsTypeA) if err != nil { t.Fatal(err) } - if len(addrs) != 1 || !addrs[0].IP.Equal(net.IPv4(192, 0, 2, 13)) { - t.Fatalf("addresses = %v", addrs) + if len(records) != 1 || !net.IP(records[0].RData).Equal(net.IPv4(192, 0, 2, 13)) { + t.Fatalf("records = %v", records) + } + if metadata.Server != server.addr() || metadata.Transport != TransportTCP || !metadata.TCPFallback || metadata.Attempts != 1 || metadata.Duration <= 0 { + t.Fatalf("query metadata = %+v, want successful TCP fallback from %s", metadata, server.addr()) } if err := <-done; err != nil { t.Fatal(err) diff --git a/skills/fetch/references/diagnostics.md b/skills/fetch/references/diagnostics.md index 3f177864..b908ee7c 100644 --- a/skills/fetch/references/diagnostics.md +++ b/skills/fetch/references/diagnostics.md @@ -22,9 +22,12 @@ explicit UDP, TCP, DoT, DoQ, or DoH resolver, supported record types are queried concurrently. The default output uses `Lookup` and `Records` sections and reports the name, resolver path, transport, transport security, source, status, result counts, query counts, and timing. Each record -shows its normalized, fully qualified owner name before its value. Successful -records remain visible when one query fails; the `Lookup` section reports an -incomplete status and the `Failures` section identifies the failed types. The +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, +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 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.