From 9384f062226b7e3484cf15f1be0143df2c803f3c Mon Sep 17 00:00:00 2001 From: Ryan Fowler Date: Thu, 27 Aug 2026 12:55:21 -0400 Subject: [PATCH 1/2] feat(dns): query system nameservers during inspection --- docs/advanced-features.md | 2 +- docs/cli-reference.md | 2 +- docs/configuration.md | 4 +- internal/dnsinspect/dnsinspect.go | 284 +++++++++++++++++++++---- internal/dnsinspect/dnsinspect_test.go | 140 +++++++++++- internal/resolver/system.go | 83 ++++++-- internal/resolver/system_test.go | 53 +++++ 7 files changed, 505 insertions(+), 63 deletions(-) diff --git a/docs/advanced-features.md b/docs/advanced-features.md index d28f515c..5a4b5d99 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 uses the platform resolver and shows only A and AAAA records. Per-record TTLs are unavailable through that resolver. With an explicit resolver, inspection queries A, AAAA, CNAME, TXT, MX, NS, SOA, SRV, CAA, SVCB, and HTTPS concurrently. The output shows the resolver security classification, address and record counts, and duration. If a query fails, successful records are retained, the incomplete record types are reported, and the command exits with status 1. +Without `--dns-server`, inspection queries the nameservers listed in the system resolver configuration (`/etc/resolv.conf`) directly, so it reports every record type (A, AAAA, CNAME, TXT, MX, NS, SOA, SRV, CAA, SVCB, and HTTPS) with per-record TTLs. On platforms without a usable resolver file (notably Windows and macOS with scoped resolver configuration), 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. Any records already returned by direct DNS remain visible. With an explicit resolver, inspection queries the same record types concurrently. The output shows the resolver security classification, address and record counts, and duration. If a query fails, successful records are retained, the incomplete record types are reported, and the command exits with status 1. ### Configuration File diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 081067d3..207d01a6 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -519,7 +519,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 uses the platform resolver and displays A and AAAA records; the platform resolver does not provide per-record TTLs. With an explicit resolver, it queries A, AAAA, CNAME, TXT, MX, NS, SOA, SRV, CAA, SVCB, and HTTPS concurrently. The output includes resolver security, record counts, and duration. If one query fails, successful records remain visible, a warning identifies the incomplete record types, and the command exits with status 1. +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, so it reports every record type (A, AAAA, CNAME, TXT, MX, NS, SOA, SRV, CAA, SVCB, and HTTPS) with per-record TTLs. On platforms without a usable resolver file (notably Windows and macOS with scoped resolver configuration), 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. Any records already returned by direct DNS remain visible. With an explicit resolver it queries the same record types concurrently. The output includes resolver security, record counts, and duration. If one query fails, successful records remain visible, a warning identifies the incomplete record types, and the command exits with status 1. ```sh fetch --inspect-dns example.com diff --git a/docs/configuration.md b/docs/configuration.md index 758d0bd5..b58a92d4 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -268,9 +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 uses the platform resolver and shows only A and AAAA records; its -per-record TTLs are unavailable. 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 and reports all supported record types with per-record TTLs. On platforms without a usable resolver file, including macOS with scoped resolver configuration, 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 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 902ebddf..6cc6da06 100644 --- a/internal/dnsinspect/dnsinspect.go +++ b/internal/dnsinspect/dnsinspect.go @@ -10,6 +10,7 @@ import ( "fmt" "net" "net/url" + "runtime" "slices" "strconv" "strings" @@ -22,6 +23,7 @@ import ( "github.com/ryanfowler/fetch/internal/resolver" "golang.org/x/net/dns/dnsmessage" + "golang.org/x/net/idna" ) const dnsTypeCAA dnsmessage.Type = 257 @@ -56,6 +58,18 @@ type Config struct { Timeout time.Duration URL *url.URL Silent bool + + // ResolvConfPath overrides the resolver configuration file consulted when + // no --dns-server is set. An empty value uses the platform default + // (/etc/resolv.conf on supported platforms). Tests use it to avoid + // depending on the host's resolver configuration. + ResolvConfPath string + + // SystemPolicy supplies the system resolver policy directly. When set it + // takes precedence over ResolvConfPath. Production callers leave it nil so + // the policy is loaded from the resolver configuration file; tests use it + // to select a nameserver with an arbitrary port. + SystemPolicy *resolver.SystemResolverPolicy } type queryType struct { @@ -103,6 +117,40 @@ type resolverTargetInfo struct { var defaultLookupIPAddr = net.DefaultResolver.LookupIPAddr +// systemResolvConfPath is the resolver configuration file used when no +// --dns-server is set. It is a variable so tests can point it at a fixture. +// Windows has no portable resolv.conf to enumerate, and macOS has scoped +// resolver configuration that is not represented by this file. +var systemResolvConfPath = func() string { + // macOS can have per-interface and VPN-specific resolvers that are not + // represented by /etc/resolv.conf. Use its platform resolver API instead + // of sending those names to an unrelated nameserver. + if runtime.GOOS == "windows" || runtime.GOOS == "darwin" { + return "" + } + return "/etc/resolv.conf" +} + +// loadSystemResolverPolicy reads the system resolver policy. It returns nil +// when the file is unavailable or lists no nameservers. +func loadSystemResolverPolicy(cfg *Config) *resolver.SystemResolverPolicy { + if cfg.SystemPolicy != nil { + return cfg.SystemPolicy + } + path := cfg.ResolvConfPath + if path == "" { + path = systemResolvConfPath() + } + if path == "" { + return nil + } + policy, err := resolver.LoadSystemResolverPolicy(path) + if err != nil { + return nil + } + return &policy +} + // 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 { @@ -166,32 +214,34 @@ func lookup(ctx context.Context, cfg *Config, host string, start time.Time) (*re silent: cfg.Silent, } - // A missing --dns-server means the platform resolver, not the first - // nameserver listed in resolv.conf. The platform API exposes addresses but - // not per-record TTLs, and it cannot provide the additional record types. - if server == nil { - target = resolverTargetInfo{label: "system resolver", useDefault: true} - out.resolver = target.label - out.ttlUnavailable = true + // A missing --dns-server prefers the resolv.conf nameservers, which expose + // every record type and per-record TTLs. The platform API is only the + // fallback: it surfaces A/AAAA and no per-record TTLs. + systemDefault := server == nil + var systemPolicy *resolver.SystemResolverPolicy + if systemDefault { + policy := loadSystemResolverPolicy(cfg) + if policy != nil && len(policy.Nameservers) > 0 { + ordered := resolver.RotateSystemResolverPolicy(*policy) + systemPolicy = &ordered + target = resolverTargetInfo{label: "udp " + ordered.Nameservers[0], udpAddr: ordered.Nameservers[0]} + out.resolver = target.label + out.security = string(resolver.SecurityPlaintext) + } else { + systemPolicy = nil + target = resolverTargetInfo{label: "system resolver", useDefault: true} + out.resolver = target.label + out.ttlUnavailable = true + } } if cfg.Endpoint != nil && cfg.Endpoint.Transport != resolver.TransportUDP && cfg.Endpoint.Transport != resolver.TransportTCP && cfg.Endpoint.Transport != resolver.TransportTLS && cfg.Endpoint.Transport != resolver.TransportQUIC && cfg.Endpoint.Transport != resolver.TransportHTTPS { return nil, fmt.Errorf("resolver transport %s is not implemented", cfg.Endpoint.Transport) } + // No usable system policy: fall back to the platform resolver (A/AAAA only). if target.useDefault { - records, err := lookupDefaultResolverRecords(ctx, host) - out.duration = time.Since(start) - if err != nil { - return nil, fmt.Errorf("lookup %s: %w", host, err) - } - for _, rec := range records { - out.records[rec.typ] = append(out.records[rec.typ], rec) - } - if recordCount(out) == 0 { - return nil, fmt.Errorf("lookup %s: no DNS records found", host) - } - return out, nil + return platformLookup(ctx, out, host, start) } var streamClient *resolver.StreamClient @@ -248,6 +298,44 @@ func lookup(ctx context.Context, cfg *Config, host string, start time.Time) (*re defer dohClient.Close() } + queryHost, err := dnsQueryHost(host) + if err != nil { + return nil, fmt.Errorf("normalize hostname %s: %w", host, err) + } + queryCtx, cancelQuery := contextForDirectLookup(ctx, systemPolicy != nil) + 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)" + } + + // A system-nameserver query that returned no address records (for example a + // .local/mDNS or a host resolved only via NSS or the hosts file) falls back + // to the OS resolver so those names still resolve. Keep the original query + // context for this operation; contextForDirectLookup reserves time for it. + if systemPolicy != nil && !hasAddressRecords(out) { + if platformAddrs, err := lookupDefaultResolverRecords(ctx, host); err == nil && len(platformAddrs) > 0 { + return platformResult(out, platformAddrs, start), nil + } + } + + if recordCount(out) > 0 || len(out.failures) > 0 { + return out, nil + } + if firstResult != nil { + return nil, fmt.Errorf("lookup %s: %w", host, firstResult) + } + return nil, fmt.Errorf("lookup %s: no DNS records found", host) +} + +// 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 { results := make([]queryResult, len(inspectTypes)) var wg sync.WaitGroup for i, qt := range inspectTypes { @@ -255,31 +343,57 @@ func lookup(ctx context.Context, cfg *Config, host string, start time.Time) (*re go func(i int, qt queryType) { defer wg.Done() results[i].label = qt.label - if streamClient != nil { + switch { + case systemPolicy != nil: + results[i].records, results[i].tcpFallback, results[i].err = lookupSystemRecords(ctx, systemPolicy, host, qt) + case streamClient != nil: results[i].records, results[i].err = lookupStreamRecords(ctx, streamClient, host, qt) - return - } - if doqClient != nil { + case doqClient != nil: results[i].records, results[i].err = lookupDoQRecords(ctx, doqClient, host, qt) - return - } - if dohClient != nil { + case dohClient != nil: results[i].records, results[i].err = lookupDOHRecordsWithClient(ctx, dohClient, host, qt) - return + default: + results[i].records, results[i].tcpFallback, results[i].err = lookupUDPRecordsWithFallback(ctx, target.udpAddr, host, qt) } - results[i].records, results[i].tcpFallback, results[i].err = lookupUDPRecordsWithFallback(ctx, target.udpAddr, host, qt) }(i, qt) } wg.Wait() + return results +} - var firstErr error +// 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) { + // 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)) + if err != nil { + return nil, fallback, err + } + records := make([]record, 0, len(resolved)) + for _, rec := range resolved { + value, ok := wireRecordValue(rec) + if !ok { + continue + } + records = append(records, record{typ: typeLabel(dnsmessage.Type(rec.Type)), value: value, ttl: rec.TTL, hasTTL: rec.TTLPresent}) + } + return records, fallback, nil +} + +// aggregate merges per-type query results into out. It returns the first +// non-NODATA error so callers that produce nothing can explain the failure. +func aggregate(out *result, results []queryResult, start time.Time) error { + var firstResult error seen := make(map[string]int) for _, query := range results { out.tcpFallback = out.tcpFallback || query.tcpFallback if query.err != nil && !errors.Is(query.err, resolver.ErrDNSNoData) { out.failures = append(out.failures, queryFailure{label: query.label, err: query.err}) - if firstErr == nil { - firstErr = query.err + if firstResult == nil { + firstResult = query.err } } for _, rec := range query.records { @@ -301,14 +415,112 @@ func lookup(ctx context.Context, cfg *Config, host string, start time.Time) (*re } } out.duration = time.Since(start) + return firstResult +} - if recordCount(out) > 0 || len(out.failures) > 0 { - return out, nil +func hasAddressRecords(out *result) bool { + return len(out.records["A"]) > 0 || len(out.records["AAAA"]) > 0 +} + +// platformLookup resolves host through the platform resolver and fills out, +// reporting per-record TTLs as unavailable. +func platformLookup(ctx context.Context, out *result, host string, start time.Time) (*result, error) { + records, err := lookupDefaultResolverRecords(ctx, host) + out.duration = time.Since(start) + if err != nil { + return nil, fmt.Errorf("lookup %s: %w", host, err) } - if firstErr != nil { - return nil, fmt.Errorf("lookup %s: %w", host, firstErr) + for _, rec := range records { + out.records[rec.typ] = append(out.records[rec.typ], rec) } - return nil, fmt.Errorf("lookup %s: no DNS records found", host) + if recordCount(out) == 0 { + return nil, fmt.Errorf("lookup %s: no DNS records found", host) + } + return out, nil +} + +// contextForDirectLookup reserves one quarter of a timed inspection for +// the platform fallback. Direct queries remain concurrent, so this only +// affects slow or unreachable system nameservers. +func contextForDirectLookup(ctx context.Context, reserve bool) (context.Context, context.CancelFunc) { + if !reserve { + return ctx, func() {} + } + deadline, ok := ctx.Deadline() + if !ok { + return ctx, func() {} + } + remaining := time.Until(deadline) + if remaining <= 0 { + return ctx, func() {} + } + return context.WithDeadline(ctx, time.Now().Add(remaining*3/4)) +} + +func dnsQueryHost(host string) (string, error) { + trailingDot := strings.HasSuffix(host, ".") + base := strings.TrimSuffix(host, ".") + if base == "" { + if trailingDot { + return ".", nil + } + return "", errors.New("hostname is empty") + } + labels := strings.Split(base, ".") + for i, label := range labels { + if label == "" { + return "", errors.New("hostname contains an empty label") + } + if isASCII(label) { + // DNS service labels such as _acme-challenge are valid ASCII + // labels but are not valid IDNA labels. + continue + } + ascii, err := idna.Lookup.ToASCII(label) + if err != nil { + return "", err + } + labels[i] = ascii + } + ascii := strings.Join(labels, ".") + if trailingDot { + return ascii + ".", nil + } + return ascii, nil +} + +func isASCII(value string) bool { + for i := 0; i < len(value); i++ { + if value[i] >= utf8.RuneSelf { + return false + } + } + return true +} + +// platformResult combines platform A/AAAA records with records already +// returned by the system nameserver. This keeps useful non-address records +// visible while making the mixed resolver provenance and unavailable TTLs +// explicit. +func platformResult(orig *result, records []record, start time.Time) *result { + out := &result{ + host: orig.host, + resolver: orig.resolver + " (platform fallback)", + security: "mixed (direct nameserver and platform resolver)", + records: make(map[string][]record, len(orig.records)), + failures: slices.Clone(orig.failures), + tcpFallback: orig.tcpFallback, + silent: orig.silent, + duration: time.Since(start), + ttlUnavailable: true, + } + for typ, values := range orig.records { + out.records[typ] = slices.Clone(values) + } + for _, rec := range records { + out.records[rec.typ] = append(out.records[rec.typ], rec) + } + return out } func lookupDefaultResolverRecords(ctx context.Context, host string) ([]record, error) { diff --git a/internal/dnsinspect/dnsinspect_test.go b/internal/dnsinspect/dnsinspect_test.go index a600586f..53f16a01 100644 --- a/internal/dnsinspect/dnsinspect_test.go +++ b/internal/dnsinspect/dnsinspect_test.go @@ -7,12 +7,14 @@ import ( "net/http" "net/http/httptest" "net/url" + "os" "strings" "sync" "testing" "time" "github.com/ryanfowler/fetch/internal/core" + "github.com/ryanfowler/fetch/internal/resolver" "golang.org/x/net/dns/dnsmessage" ) @@ -60,6 +62,47 @@ func TestInspectDOHShowsAAndAAAATTLs(t *testing.T) { } } +func TestDNSQueryHostPreservesASCIIServiceLabels(t *testing.T) { + got, err := dnsQueryHost("_acme-challenge.example") + if err != nil { + t.Fatal(err) + } + if want := "_acme-challenge.example"; got != want { + t.Fatalf("DNS query host = %q, want %q", got, want) + } +} + +func TestInspectNormalizesIDNForDNSQueries(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodPost { + w.WriteHeader(http.StatusUnsupportedMediaType) + return + } + if got, want := r.URL.Query().Get("name"), "xn--mnich-kva.example"; got != want { + http.Error(w, "unexpected DNS name", http.StatusBadRequest) + return + } + if r.URL.Query().Get("type") == "A" { + io.WriteString(w, `{"Status":0,"Answer":[{"name":"xn--mnich-kva.example.","type":1,"data":"192.0.2.1","TTL":60}]}`) + return + } + io.WriteString(w, `{"Status":0}`) + })) + defer server.Close() + + p := core.TestPrinter(false) + status := Inspect(context.Background(), p, &Config{ + DNSServer: mustURL(t, server.URL), + URL: mustURL(t, "https://münich.example"), + }) + if status != 0 { + t.Fatalf("status = %d, want 0\n%s", status, p.Bytes()) + } + if !strings.Contains(string(p.Bytes()), "192.0.2.1") { + t.Fatalf("output missing IDN A record:\n%s", p.Bytes()) + } +} + func TestInspectIPLiteralSkipsLookup(t *testing.T) { p := core.TestPrinter(false) status := Inspect(context.Background(), p, &Config{ @@ -255,7 +298,8 @@ func TestLookupWithoutExplicitServerUsesPlatformResolver(t *testing.T) { }, nil } - res, err := lookup(context.Background(), &Config{}, "example.com", time.Now()) + // A resolv.conf with no nameservers forces the platform fallback path. + res, err := lookup(context.Background(), &Config{ResolvConfPath: emptyResolvConf(t)}, "example.com", time.Now()) if err != nil { t.Fatal(err) } @@ -288,7 +332,7 @@ func TestLookupUsesPlatformResolver(t *testing.T) { }, nil } - res, err := lookup(context.Background(), &Config{}, "example.com", time.Now()) + res, err := lookup(context.Background(), &Config{ResolvConfPath: emptyResolvConf(t)}, "example.com", time.Now()) if err != nil { t.Fatal(err) } @@ -309,6 +353,98 @@ func TestLookupUsesPlatformResolver(t *testing.T) { } } +func TestLookupSystemNameserverReturnsTTL(t *testing.T) { + addr, stop := startUDPServer(t) + defer stop() + + // Select the test UDP server as the system nameserver via a direct policy, + // so inspection queries a real nameserver and keeps TTLs. + res, err := lookup(context.Background(), &Config{SystemPolicy: &resolver.SystemResolverPolicy{ + Nameservers: []string{addr}, + Attempts: 1, + Timeout: time.Second, + }}, "example.com", time.Now()) + if err != nil { + t.Fatal(err) + } + if res.resolver != "udp "+addr { + t.Fatalf("resolver label = %q, want %q", res.resolver, "udp "+addr) + } + if res.ttlUnavailable { + t.Fatal("ttlUnavailable = true, want false for a direct nameserver query") + } + if got := len(res.records["A"]); got != 1 { + t.Fatalf("A records = %d, want 1", got) + } + if got, want := res.records["A"][0].value, "192.0.2.10"; got != want { + t.Fatalf("A value = %q, want %q", got, want) + } + if got, want := res.records["A"][0].ttl, uint32(42); got != want { + t.Fatalf("A TTL = %d, want %d", got, want) + } +} + +func TestLookupSystemFallsBackToPlatform(t *testing.T) { + origDefaultLookupIPAddr := defaultLookupIPAddr + t.Cleanup(func() { + defaultLookupIPAddr = origDefaultLookupIPAddr + }) + + // A policy pointing at a dead nameserver: every record type fails, so + // inspection must fall back to the platform resolver for A/AAAA. + defaultLookupIPAddr = func(ctx context.Context, host string) ([]net.IPAddr, error) { + return []net.IPAddr{{IP: net.ParseIP("192.0.2.99")}}, nil + } + + res, err := lookup(context.Background(), &Config{SystemPolicy: &resolver.SystemResolverPolicy{ + Nameservers: []string{"127.0.0.1:1"}, + Attempts: 1, + Timeout: 50 * time.Millisecond, + }}, "example.com", time.Now()) + if err != nil { + t.Fatal(err) + } + if res.resolver != "system resolver (configured nameservers) (platform fallback)" { + t.Fatalf("resolver label = %q, want platform fallback label", res.resolver) + } + if !res.ttlUnavailable { + t.Fatal("ttlUnavailable = false, want true for platform fallback") + } + if got := len(res.records["A"]); got != 1 { + t.Fatalf("A records = %d, want 1", got) + } + if got, want := res.records["A"][0].value, "192.0.2.99"; got != want { + t.Fatalf("A value = %q, want %q", got, want) + } + if len(res.failures) == 0 { + t.Fatal("platform fallback discarded direct nameserver failures") + } + + p := core.TestPrinter(false) + status := Inspect(context.Background(), p, &Config{ + SystemPolicy: &resolver.SystemResolverPolicy{ + Nameservers: []string{"127.0.0.1:1"}, + Attempts: 1, + Timeout: 50 * time.Millisecond, + }, + URL: mustURL(t, "https://example.com"), + }) + if status != 1 || !strings.Contains(string(p.Bytes()), "DNS inspection incomplete") { + t.Fatalf("platform fallback status/output = %d/%s, want status 1 and warning", status, p.Bytes()) + } +} + +// emptyResolvConf returns a resolv.conf path that lists no nameservers, so +// lookup() takes the platform-resolver fallback path deterministically. +func emptyResolvConf(t *testing.T) string { + t.Helper() + path := t.TempDir() + "/resolv.conf" + if err := os.WriteFile(path, []byte("search example.com\n"), 0o600); err != nil { + t.Fatal(err) + } + return path +} + func TestResolverTargetUsesPlatformResolver(t *testing.T) { target := resolverTarget(nil) if !target.useDefault { diff --git a/internal/resolver/system.go b/internal/resolver/system.go index ef404af2..33129e07 100644 --- a/internal/resolver/system.go +++ b/internal/resolver/system.go @@ -8,6 +8,7 @@ import ( "os" "os/exec" "runtime" + "slices" "strconv" "strings" "sync/atomic" @@ -20,8 +21,9 @@ const ( ) // SystemResolverPolicy describes the portable subset of resolv.conf policy -// that can be applied to raw HTTPS/SVCB queries. A/AAAA queries still use the -// platform resolver API, which preserves NSS and OS-specific routing. +// that can be applied to raw DNS queries. The ordinary resolver still uses the +// platform API for A/AAAA lookups, which preserves NSS and OS-specific +// routing. type SystemResolverPolicy struct { Nameservers []string Attempts int @@ -114,20 +116,57 @@ func parseSystemNameserver(value string) string { var systemResolverRotation atomic.Uint32 -// QuerySystemHTTPS queries the configured system resolver policy. It is kept -// separate from Resolver so callers can use it for automatic HTTP/3/ECH -// discovery without changing ordinary platform A/AAAA resolution. +// QuerySystemHTTPS queries the configured system resolver policy for an +// HTTPS or SVCB record. It is kept separate from Resolver so callers can use +// it for automatic HTTP/3/ECH discovery without changing ordinary platform +// A/AAAA resolution. func QuerySystemHTTPS(ctx context.Context, policy SystemResolverPolicy, host string, typ uint16) ([]Record, error) { if typ != dnsTypeHTTPS && typ != dnsTypeSVCB { return nil, fmt.Errorf("system service lookup does not support DNS type %d", typ) } - if policy.UseSystemdResolved && runtime.GOOS == "linux" { + records, _, err := QuerySystemType(ctx, policy, host, typ) + if err != nil { + return nil, err + } + out := make([]Record, 0, len(records)) + for _, record := range records { + if record.Type == typ { + out = append(out, record) + } + } + if len(out) == 0 { + return nil, errDNSNoData + } + return out, nil +} + +// RotateSystemResolverPolicy applies resolv.conf rotation once and disables +// per-query rotation. This is useful for one diagnostic operation whose +// concurrent queries should use the same ordered nameserver set. +func RotateSystemResolverPolicy(policy SystemResolverPolicy) SystemResolverPolicy { + if !policy.Rotate || len(policy.Nameservers) < 2 { + policy.Rotate = false + return policy + } + start := int(systemResolverRotation.Add(1)-1) % len(policy.Nameservers) + policy.Nameservers = append(slices.Clone(policy.Nameservers[start:]), policy.Nameservers[:start]...) + policy.Rotate = false + return policy +} + +// 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. +func QuerySystemType(ctx context.Context, policy SystemResolverPolicy, host string, typ uint16) ([]Record, bool, error) { + if policy.UseSystemdResolved && runtime.GOOS == "linux" && (typ == dnsTypeHTTPS || typ == dnsTypeSVCB) { if records, err := querySystemdResolved(ctx, host, typ); err == nil && len(records) > 0 { - return records, nil + return records, false, nil } } if len(policy.Nameservers) == 0 { - return nil, ErrHTTPSRecordsUnavailable + return nil, false, ErrHTTPSRecordsUnavailable } attempts := policy.Attempts if attempts <= 0 { @@ -142,10 +181,11 @@ func QuerySystemHTTPS(ctx context.Context, policy SystemResolverPolicy, host str start = int(systemResolverRotation.Add(1)-1) % len(policy.Nameservers) } var lastErr error + var totalFallback bool for offset := range len(policy.Nameservers) { index := (start + offset) % len(policy.Nameservers) queryCtx, cancel := context.WithTimeout(ctx, timeout) - message, _, err := lookupUDPMessage(queryCtx, policy.Nameservers[index], host, typ, attempts) + message, fallback, err := lookupUDPMessage(queryCtx, policy.Nameservers[index], host, typ, attempts) cancel() if err != nil { lastErr = err @@ -153,33 +193,36 @@ func QuerySystemHTTPS(ctx context.Context, policy SystemResolverPolicy, host str } name, err := ParseName(host) if err != nil { - return nil, err + return nil, fallback, err } if message.Header.RCode != 0 { - return nil, fmt.Errorf("DNS response: %s", RCodeName(message.Header.RCode)) + return nil, fallback, 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, err + return nil, fallback, err } out := make([]Record, 0, len(authorized)) + hasRequestedType := false for _, record := range authorized { - if record.Type == typ { - out = append(out, record) - } + out = append(out, record) + hasRequestedType = hasRequestedType || record.Type == typ } - if len(out) == 0 { - return nil, errDNSNoData + if !hasRequestedType { + return nil, fallback, errDNSNoData } - return out, nil + if fallback { + totalFallback = true + } + return out, totalFallback, nil } if err := contextError(ctx); err != nil { - return nil, err + return nil, totalFallback, err } if lastErr == nil { lastErr = errors.New("system resolver query failed") } - return nil, lastErr + return nil, totalFallback, 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 7c0afdc9..d4afb480 100644 --- a/internal/resolver/system_test.go +++ b/internal/resolver/system_test.go @@ -24,6 +24,59 @@ func TestLoadSystemResolverPolicy(t *testing.T) { } } +func TestQuerySystemTypeRetriesAcrossNameservers(t *testing.T) { + // A silent UDP socket as the first nameserver forces a retry to the + // working second nameserver. + blackHole, err := net.ListenPacket("udp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + defer blackHole.Close() + + server := newUDPTestServer(t) + defer server.close() + + done := make(chan error, 1) + go func() { + query, client, err := server.readQuery() + if err != nil { + done <- err + return + } + message, err := DecodeMessage(query) + if err != nil { + done <- err + return + } + question := message.Questions[0] + answer := makeRecord(question.Name, dnsTypeA, net.IPv4(192, 0, 2, 55).To4()) + _, err = server.udp.WriteToUDP(responsePacket(query, message.Header.ID, question, []Record{answer}), client) + done <- err + }() + + policy := SystemResolverPolicy{ + Nameservers: []string{blackHole.LocalAddr().String(), server.addr()}, + Attempts: 1, + Timeout: 100 * time.Millisecond, + } + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + + records, _, err := QuerySystemType(ctx, policy, "example.com", dnsTypeA) + if err != nil { + t.Fatal(err) + } + if len(records) != 1 || records[0].Type != dnsTypeA { + t.Fatalf("records = %#v, want one A record", records) + } + if got, want := net.IP(records[0].RData).String(), "192.0.2.55"; got != want { + t.Fatalf("A address = %s, want %s", got, want) + } + if err := <-done; err != nil { + t.Fatal(err) + } +} + func TestParseResolvConfSkipsMalformedNameserversAndReadsPolicy(t *testing.T) { policy := ParseResolvConf(strings.TrimSpace(` # comments and malformed entries are ignored From aa4255ec42fb04c8e40421f148afafaacf3ab92d Mon Sep 17 00:00:00 2001 From: Ryan Fowler Date: Thu, 27 Aug 2026 13:22:26 -0400 Subject: [PATCH 2/2] fix(dns): use resolv.conf for macOS inspection --- docs/advanced-features.md | 2 +- docs/cli-reference.md | 2 +- docs/configuration.md | 2 +- internal/dnsinspect/dnsinspect.go | 8 ++------ 4 files changed, 5 insertions(+), 9 deletions(-) diff --git a/docs/advanced-features.md b/docs/advanced-features.md index 5a4b5d99..38de3240 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, so it reports every record type (A, AAAA, CNAME, TXT, MX, NS, SOA, SRV, CAA, SVCB, and HTTPS) with per-record TTLs. On platforms without a usable resolver file (notably Windows and macOS with scoped resolver configuration), 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. Any records already returned by direct DNS remain visible. With an explicit resolver, inspection queries the same record types concurrently. The output shows the resolver security classification, address and record counts, and duration. If a query fails, successful records are retained, the incomplete record types are reported, and the command exits with status 1. +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. If direct DNS returns no address records, platform-resolver addresses are added while any records already returned by direct DNS remain visible. With an explicit resolver, inspection queries the same record types concurrently. The output shows the resolver security classification, address and record counts, and duration. If a query fails, successful records are retained, the incomplete record types are reported, and the command exits with status 1. ### Configuration File diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 207d01a6..a414c7cc 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -519,7 +519,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, so it reports every record type (A, AAAA, CNAME, TXT, MX, NS, SOA, SRV, CAA, SVCB, and HTTPS) with per-record TTLs. On platforms without a usable resolver file (notably Windows and macOS with scoped resolver configuration), 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. Any records already returned by direct DNS remain visible. With an explicit resolver it queries the same record types concurrently. The output includes resolver security, record counts, and duration. If one query fails, successful records remain visible, a warning identifies the incomplete record types, and the command exits with status 1. +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. If direct DNS returns no address records, platform-resolver addresses are added while any records already returned by direct DNS remain visible. With an explicit resolver it queries the same record types concurrently. The output includes resolver security, record counts, and duration. If one query fails, successful records remain visible, a warning identifies the incomplete record types, and the command exits with status 1. ```sh fetch --inspect-dns example.com diff --git a/docs/configuration.md b/docs/configuration.md index b58a92d4..be6f888a 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 and reports all supported record types with per-record TTLs. On platforms without a usable resolver file, including macOS with scoped resolver configuration, 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. 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 6cc6da06..23f5c59b 100644 --- a/internal/dnsinspect/dnsinspect.go +++ b/internal/dnsinspect/dnsinspect.go @@ -119,13 +119,9 @@ var defaultLookupIPAddr = net.DefaultResolver.LookupIPAddr // systemResolvConfPath is the resolver configuration file used when no // --dns-server is set. It is a variable so tests can point it at a fixture. -// Windows has no portable resolv.conf to enumerate, and macOS has scoped -// resolver configuration that is not represented by this file. +// Windows has no portable resolv.conf to enumerate. var systemResolvConfPath = func() string { - // macOS can have per-interface and VPN-specific resolvers that are not - // represented by /etc/resolv.conf. Use its platform resolver API instead - // of sending those names to an unrelated nameserver. - if runtime.GOOS == "windows" || runtime.GOOS == "darwin" { + if runtime.GOOS == "windows" { return "" } return "/etc/resolv.conf"