Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/advanced-features.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion docs/cli-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
108 changes: 85 additions & 23 deletions internal/dnsinspect/dnsinspect.go
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,7 @@ type result struct {
host string
queryName string
resolver string
responders []string
transport string
security string
source string
Expand Down Expand Up @@ -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
}

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand All @@ -409,23 +417,54 @@ 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 {
if converted, ok := recordFromWire(rec); ok {
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
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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"
}
Expand Down Expand Up @@ -1678,28 +1720,39 @@ 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:
status = countPhrase(len(query.records), "record", "records")
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, " · "))
}
}

Expand All @@ -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 != "" {
Expand Down Expand Up @@ -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")
Expand Down
47 changes: 47 additions & 0 deletions internal/dnsinspect/dnsinspect_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)`,
Expand Down Expand Up @@ -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",
Expand Down
Loading
Loading