From 2ed921140279abfb18caddeb0cf505ccdd4c0fbf Mon Sep 17 00:00:00 2001 From: Ryan Fowler Date: Sun, 30 Aug 2026 14:48:22 +0000 Subject: [PATCH] feat(dns): clarify resolver transport security --- docs/advanced-features.md | 2 +- docs/cli-reference.md | 2 +- internal/dnsinspect/dnsinspect.go | 42 ++++++++++++---- internal/dnsinspect/dnsinspect_test.go | 69 ++++++++++++++++++++++++++ skills/fetch/references/diagnostics.md | 2 +- 5 files changed, 103 insertions(+), 14 deletions(-) diff --git a/docs/advanced-features.md b/docs/advanced-features.md index 8539af3b..a6f21e48 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. +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. ### Configuration File diff --git a/docs/cli-reference.md b/docs/cli-reference.md index b9f7bc76..233e5d66 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. +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. ```sh fetch --inspect-dns example.com diff --git a/internal/dnsinspect/dnsinspect.go b/internal/dnsinspect/dnsinspect.go index 90519492..f60842e0 100644 --- a/internal/dnsinspect/dnsinspect.go +++ b/internal/dnsinspect/dnsinspect.go @@ -249,7 +249,7 @@ func lookup(ctx context.Context, cfg *Config, host string, start time.Time) (*re host: host, resolver: target.label, transport: inspectionTransport(cfg, server), - security: resolverSecurity(cfg, server), + security: resolverTransportSecurity(cfg, server), source: inspectionSource(server), records: make(map[string][]record), silent: cfg.Silent, @@ -1502,24 +1502,44 @@ func dnsLabelPresentation(label []byte) string { return b.String() } -func resolverSecurity(cfg *Config, server *url.URL) string { +// resolverTransportSecurity reports protection for the connection to the +// resolver. It intentionally says nothing about DNSSEC: fetch does not +// validate DNSSEC chains locally. +func resolverTransportSecurity(cfg *Config, server *url.URL) string { if server == nil { return "platform resolver (OS-managed security)" } + + // Endpoint is the authoritative representation used by production callers. + // Derive the result from VerifyTLS instead of the display URL so --insecure + // is reflected without changing endpoint configuration. An HTTPS transport + // used by an explicit local test endpoint can intentionally have TLS + // verification disabled and is therefore plaintext, not unverified TLS. if cfg.Endpoint != nil { - security := cfg.Endpoint.Security - if cfg.Insecure && security == resolver.SecurityVerifiedEncrypted { - security = resolver.SecurityUnverifiedEncrypt + if cfg.Endpoint.VerifyTLS { + if cfg.Insecure { + return string(resolver.SecurityUnverifiedEncrypt) + } + return string(resolver.SecurityVerifiedEncrypted) } - return string(security) - } - if cfg.Insecure && (strings.EqualFold(server.Scheme, "https") || strings.EqualFold(server.Scheme, "http")) { - return string(resolver.SecurityUnverifiedEncrypt) + return string(resolver.SecurityPlaintext) } - if strings.EqualFold(server.Scheme, "https") { + + // DNSServer remains supported for older internal callers. Account for all + // resolver URL schemes here; treating only https:// as encrypted would + // misreport legacy DoT and DoQ configurations. + scheme := strings.ToLower(server.Scheme) + switch scheme { + case "tls", "dot", "quic", "doq", "https": + if cfg.Insecure { + return string(resolver.SecurityUnverifiedEncrypt) + } return string(resolver.SecurityVerifiedEncrypted) + default: + // UDP, TCP, and plain HTTP expose DNS on the wire. --insecure has no + // certificate verification to disable for these transports. + return string(resolver.SecurityPlaintext) } - return string(resolver.SecurityPlaintext) } func resolverTarget(server *url.URL) resolverTargetInfo { diff --git a/internal/dnsinspect/dnsinspect_test.go b/internal/dnsinspect/dnsinspect_test.go index 45886061..15044f35 100644 --- a/internal/dnsinspect/dnsinspect_test.go +++ b/internal/dnsinspect/dnsinspect_test.go @@ -95,6 +95,75 @@ func TestFlushInspectionOutputIgnoresBrokenPipe(t *testing.T) { } } +func TestResolverTransportSecurityReportsTransportProtection(t *testing.T) { + tests := []struct { + name string + endpoint string + insecure bool + transport string + security string + }{ + {name: "UDP", endpoint: "udp://192.0.2.53", transport: "UDP", security: "plaintext"}, + {name: "TCP", endpoint: "tcp://192.0.2.53", transport: "TCP", security: "plaintext"}, + {name: "DoT", endpoint: "dot://resolver.example", transport: "TLS (DoT)", security: "verified TLS"}, + {name: "DoQ", endpoint: "doq://resolver.example", transport: "QUIC (DoQ)", security: "verified TLS"}, + {name: "DoH", endpoint: "https://resolver.example/dns-query", transport: "HTTPS (DoH)", security: "verified TLS"}, + {name: "DoT insecure", endpoint: "dot://resolver.example", insecure: true, transport: "TLS (DoT)", security: "encrypted, certificate verification disabled"}, + {name: "DoQ insecure", endpoint: "doq://resolver.example", insecure: true, transport: "QUIC (DoQ)", security: "encrypted, certificate verification disabled"}, + {name: "DoH insecure", endpoint: "https://resolver.example/dns-query", insecure: true, transport: "HTTPS (DoH)", security: "encrypted, certificate verification disabled"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ep, err := resolver.ParseEndpoint(tt.endpoint) + if err != nil { + t.Fatal(err) + } + cfg := &Config{Endpoint: ep, Insecure: tt.insecure} + if got := inspectionTransport(cfg, ep.URL()); got != tt.transport { + t.Errorf("inspectionTransport() = %q, want %q", got, tt.transport) + } + if got := displaySecurity(resolverTransportSecurity(cfg, ep.URL())); got != tt.security { + t.Errorf("resolver transport security = %q, want %q", got, tt.security) + } + }) + } +} + +func TestResolverTransportSecuritySupportsLegacyResolverURLs(t *testing.T) { + tests := []struct { + name string + scheme string + security string + }{ + {name: "UDP", scheme: "", security: "plaintext"}, + {name: "TCP", scheme: "tcp", security: "plaintext"}, + {name: "DoT", scheme: "tls", security: "verified TLS"}, + {name: "DoT alias", scheme: "dot", security: "verified TLS"}, + {name: "DoQ", scheme: "quic", security: "verified TLS"}, + {name: "DoQ alias", scheme: "doq", security: "verified TLS"}, + {name: "DoH", scheme: "https", security: "verified TLS"}, + {name: "HTTP", scheme: "http", security: "plaintext"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + server := mustURL(t, "//resolver.example:53") + server.Scheme = tt.scheme + cfg := &Config{Insecure: tt.name == "HTTP"} + if got := displaySecurity(resolverTransportSecurity(cfg, server)); got != tt.security { + t.Errorf("resolver transport security = %q, want %q", got, tt.security) + } + }) + } +} + +func TestResolverTransportSecurityReportsPlatformResolver(t *testing.T) { + if got, want := displaySecurity(resolverTransportSecurity(&Config{}, nil)), "OS-managed / unknown to fetch"; got != want { + t.Fatalf("platform resolver security = %q, want %q", got, want) + } +} + func TestInspectDOHShowsAAndAAAATTLs(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Query().Get("type") { diff --git a/skills/fetch/references/diagnostics.md b/skills/fetch/references/diagnostics.md index d4755ddd..677ca929 100644 --- a/skills/fetch/references/diagnostics.md +++ b/skills/fetch/references/diagnostics.md @@ -26,7 +26,7 @@ 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 command exits nonzero. Inspection output, including the `Failures` section, goes -to stdout. Invocation warnings and setup/configuration errors go to stderr. +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. ## TLS