From ea079987c25b83b8832bb48f69a0d643d3c91e78 Mon Sep 17 00:00:00 2001 From: Ryan Fowler Date: Sun, 30 Aug 2026 20:07:14 +0000 Subject: [PATCH] feat(dns): complete inspection UX --- docs/advanced-features.md | 41 +- docs/cli-reference.md | 33 +- integration/integration_test.go | 41 + internal/dnsinspect/dnsinspect.go | 2223 +----------------------- internal/dnsinspect/dnsinspect_test.go | 748 -------- internal/dnsinspect/lookup.go | 637 +++++++ internal/dnsinspect/records.go | 764 ++++++++ internal/dnsinspect/render.go | 352 ++++ internal/dnsinspect/render_records.go | 546 ++++++ internal/dnsinspect/render_test.go | 862 +++++++++ skills/fetch/references/diagnostics.md | 9 +- 11 files changed, 3297 insertions(+), 2959 deletions(-) create mode 100644 internal/dnsinspect/lookup.go create mode 100644 internal/dnsinspect/records.go create mode 100644 internal/dnsinspect/render.go create mode 100644 internal/dnsinspect/render_records.go create mode 100644 internal/dnsinspect/render_test.go diff --git a/docs/advanced-features.md b/docs/advanced-features.md index 369a4beb..2e6346ff 100644 --- a/docs/advanced-features.md +++ b/docs/advanced-features.md @@ -87,7 +87,46 @@ perform a resolver query. It reports `Status: IP literal — DNS not performed`, and omits resolver, transport, security, and DNS timing fields. The command returns success. -Without `--dns-server`, inspection queries the nameservers listed in the system resolver configuration (`/etc/resolv.conf`) directly, including on macOS. It reports every record type (A, AAAA, CNAME, TXT, MX, NS, SOA, SRV, CAA, SVCB, and HTTPS) with per-record TTLs, but does not apply macOS scoped, per-interface, VPN, or `/etc/resolver` routing. On platforms without a usable resolver file (notably Windows), or when the name is resolved only through OS mechanisms (the hosts file, NSS modules, or mDNS), it uses the platform resolver for A and AAAA records without per-record TTLs. Platform-resolver records show their source and `TTL unavailable` individually. If direct DNS returns no address records, platform-resolver addresses are added while any records already returned by direct DNS remain visible. The `Lookup` section identifies this mixed resolver path and reports the platform fallback. With an explicit resolver, inspection queries the same record types concurrently. When system failover occurs, `Resolver` or `Resolvers` reports the nameserver(s) that actually answered. The default output is complete, and `-v` has no effect in DNS inspection mode. Use `-vv` for resolver and query internals, including the configured nameserver list, policy limits, normalization, caveats, responders, transport, duration, and failover attempts. For direct DNS lookups, when IDNA normalization changes the name, normal output includes `Query name` with the absolute punycode name sent to DNS. Single-label names also show their absolute query name; the root terminator is omitted for ordinary multi-label hostnames when it is the only difference. 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. +The default result is a complete structured diagnostic on stdout. `Lookup` +reports the inspected and normalized query names, resolver path, DNS transport, +transport security, completion status, counts, and timing. An optional +`Failures` section describes failed record-type queries, and `Records` contains +the successful answers. Invocation warnings and setup/configuration errors go +to stderr. A partial result keeps useful records on stdout, reports +`Status: incomplete`, and exits with status 1; do not treat that nonzero status +as proof that stdout is empty. + +Inspection queries A, AAAA, CNAME, TXT, MX, NS, SOA, SRV, CAA, SVCB, and +HTTPS concurrently. Records retain their owner and per-record TTL and are +rendered according to their DNS semantics. Ordering is deterministic and +type-aware: addresses use numeric bytes, MX and SRV use their numeric priority +fields, and HTTPS/SVCB parameters use a canonical representation. + +Without `--dns-server`, fetch first queries the nameservers listed in the +system resolver configuration (`/etc/resolv.conf`) directly, including on +macOS. `Resolver` or `Resolvers` identifies the nameserver(s) that actually +answered. Direct queries do not apply macOS scoped, per-interface, VPN, or +`/etc/resolver` routing. On platforms without a usable resolver file (notably +Windows), or for names supplied only through the hosts file, NSS, or mDNS, the +platform resolver supplies A and AAAA records. If direct DNS returned other +records but no addresses, those records remain visible and platform addresses +are added. Platform records identify their source and show `TTL unavailable`; +the `Lookup` section identifies a platform-only or mixed path. + +An explicit resolver uses the same concurrent record queries over UDP, TCP, +DoT, DoQ, or DoH. `Transport security` describes encryption and certificate +verification only between fetch and that resolver. It does not indicate +DNSSEC validation; fetch does not validate DNSSEC chains locally. A truncated +UDP response is retried over TCP and reported as normal transport metadata +(`Transport: UDP → TCP fallback`), not as a warning. + +The default output is complete, and `-v` has no effect in DNS inspection mode. +Use `-vv` for configured nameservers, policy limits, normalization, resolver +caveats, responders, transport, per-query duration, failover attempts, and the +record types that required TCP fallback. When IDNA normalization changes a +direct DNS name, normal output includes the absolute punycode `Query name`. +Single-label names also show their absolute query name; the root terminator is +omitted for ordinary multi-label hostnames when it is the only difference. At `-vv`, the `System resolver` section describes the direct system path. It shows the resolver configuration file, direct nameserver routing, and that search domains are not applied. On macOS it also reports that scoped, VPN, per-interface, and `/etc/resolver` routing is not applied. On other platforms it reports that OS resolver routing is not applied by direct queries. These caveats describe the direct DNS portion only; a platform fallback uses the OS resolver for addresses. diff --git a/docs/cli-reference.md b/docs/cli-reference.md index a4b26c08..65afc0c7 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -531,7 +531,38 @@ platform bootstrap and negotiate the standard `doq` ALPN. ### `--inspect-dns` -Inspect DNS resolution for the URL hostname only (no HTTP request is made). Without `--dns-server`, it queries the nameservers listed in the system resolver configuration (`/etc/resolv.conf`) directly, including on macOS. It reports every record type (A, AAAA, CNAME, TXT, MX, NS, SOA, SRV, CAA, SVCB, and HTTPS) with per-record TTLs, but does not apply macOS scoped, per-interface, VPN, or `/etc/resolver` routing. On platforms without a usable resolver file (notably Windows), or when the name is resolved only through OS mechanisms (the hosts file, NSS modules, or mDNS), it uses the platform resolver for A and AAAA records without per-record TTLs. Platform-resolver records show their source and `TTL unavailable` individually. If direct DNS returns no address records, platform-resolver addresses are added while any records already returned by direct DNS remain visible. The `Lookup` section identifies this mixed resolver path and reports the platform fallback. With an explicit resolver it queries the same record types concurrently. When system failover occurs, `Resolver` or `Resolvers` reports the nameserver(s) that actually answered. The default output is complete, and `-v` has no effect in DNS inspection mode. Use `-vv` for resolver and query internals, including the configured nameserver list, policy limits, normalization, caveats, responders, transport, duration, and failover attempts. For direct DNS lookups, when IDNA normalization changes the name, normal output includes `Query name` with the absolute punycode name sent to DNS. Single-label names also show their absolute query name; the root terminator is omitted for ordinary multi-label hostnames when it is the only difference. 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. +Primary results go to stdout, while invocation warnings and setup/configuration +errors go to stderr. The complete default view contains `Lookup`, optional +`Failures`, and `Records` sections. A partial inspection retains successful +records, reports `Status: incomplete`, and exits with status 1. + +Fetch concurrently queries A, AAAA, CNAME, TXT, MX, NS, SOA, SRV, CAA, SVCB, +and HTTPS. It preserves owner names and per-record TTLs, renders typed record +fields, and sorts each family deterministically by DNS semantics rather than +display text. + +Without `--dns-server`, fetch queries the nameservers in the system resolver +configuration (`/etc/resolv.conf`) directly, including on macOS, and reports +the server(s) that actually answered. Direct queries do not apply macOS scoped, +per-interface, VPN, or `/etc/resolver` routing. Where no usable resolver file +exists (notably Windows), or a name is available only from the hosts file, NSS, +or mDNS, the platform resolver supplies A/AAAA records without TTLs. If direct +DNS returned non-address records, they remain visible alongside platform +addresses. Each platform record identifies its source and shows +`TTL unavailable`; `Lookup` describes a platform-only or mixed path. + +Explicit resolvers support UDP, TCP, DoT, DoQ, and DoH. `Transport security` +describes only encryption and certificate verification to the resolver, not +DNSSEC validation; fetch does not validate DNSSEC locally. Successful UDP +truncation fallback is reported as `Transport: UDP → TCP fallback`, not as a +warning. + +The default output is complete, and `-v` has no effect. Use `-vv` for configured +nameservers, resolver policy and routing caveats, normalized query names, +responders, per-query transport and timing, failover attempts, and TCP fallback +details. Normal output also shows `Query name` when IDNA conversion materially +changes the direct DNS name, and for single-label absolute queries. At `-vv`, the `System resolver` section describes the direct system path. It shows the resolver configuration file, direct nameserver routing, and that search domains are not applied. On macOS it also reports that scoped, VPN, per-interface, and `/etc/resolver` routing is not applied. On other platforms it reports that OS resolver routing is not applied by direct queries. These caveats describe the direct DNS portion only; a platform fallback uses the OS resolver for addresses. diff --git a/integration/integration_test.go b/integration/integration_test.go index c3cc80ab..7b27970f 100644 --- a/integration/integration_test.go +++ b/integration/integration_test.go @@ -615,6 +615,47 @@ func TestMain(t *testing.T) { } }) + t.Run("dns inspection keeps structured results and diagnostics on separate streams", func(t *testing.T) { + t.Parallel() + server := startServer(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + w.WriteHeader(http.StatusUnsupportedMediaType) + return + } + w.Header().Set("Content-Type", "application/dns-json") + if r.URL.Path == "/partial" && r.URL.Query().Get("type") == "TXT" { + io.WriteString(w, `{"Status":2}`) + return + } + if r.URL.Query().Get("type") == "A" { + io.WriteString(w, `{"Status":0,"Answer":[{"name":"example.test.","type":1,"TTL":60,"data":"192.0.2.10"}]}`) + return + } + io.WriteString(w, `{"Status":0}`) + }) + defer server.Close() + + opts := fetchOpts{env: []string{"FETCH_TEST_ALLOW_INSECURE_DNS=1"}} + res := runFetchOpts(t, fetchPath, opts, "--inspect-dns", "--dns-server", server.URL+"/complete", "example.test") + assertExitCode(t, 0, res) + assertBufEmpty(t, res.stderr) + for _, want := range []string{"Lookup\n", "Name: example.test", "Status: complete", "Records\n", "example.test. → 192.0.2.10"} { + assertBufContains(t, res.stdout, want) + } + + res = runFetchOpts(t, fetchPath, opts, "--inspect-dns", "--dns-server", server.URL+"/partial", "example.test") + assertExitCode(t, 1, res) + assertBufEmpty(t, res.stderr) + for _, want := range []string{"Status: incomplete", "Failures\n", "TXT:", "example.test. → 192.0.2.10"} { + assertBufContains(t, res.stdout, want) + } + + res = runFetch(t, fetchPath, "--inspect-dns", "--timing", "127.0.0.1") + assertExitCode(t, 0, res) + assertBufContains(t, res.stdout, "Status: IP literal — DNS not performed") + assertBufContains(t, res.stderr, "warning: --inspect-dns ignores: --timing") + }) + t.Run("dns over https", func(t *testing.T) { t.Parallel() server := startServer(func(w http.ResponseWriter, r *http.Request) { diff --git a/internal/dnsinspect/dnsinspect.go b/internal/dnsinspect/dnsinspect.go index f4ad0cf5..53bd71fa 100644 --- a/internal/dnsinspect/dnsinspect.go +++ b/internal/dnsinspect/dnsinspect.go @@ -1,32 +1,22 @@ package dnsinspect import ( - "bytes" - "cmp" "context" "crypto/tls" "crypto/x509" - "encoding/base64" - "encoding/binary" - "encoding/hex" "errors" - "fmt" "net" "net/url" "runtime" "slices" "strconv" "strings" - "sync" "time" - "unicode/utf8" - "github.com/ryanfowler/fetch/internal/client" "github.com/ryanfowler/fetch/internal/core" "github.com/ryanfowler/fetch/internal/resolver" "golang.org/x/net/dns/dnsmessage" - "golang.org/x/net/idna" ) const dnsTypeCAA dnsmessage.Type = 257 @@ -93,23 +83,24 @@ const ( // presentation is only a fallback for DoH JSON and unknown record types whose // provider did not supply wire-format RDATA. type record struct { - owner string - typ dnsmessage.Type - ttl uint32 - hasTTL bool - source recordSource - address net.IP - target string - target2 string - preference uint16 - priority uint16 - weight uint16 - port uint16 - soa [5]uint32 - txt [][]byte - params []resolver.SVCParam - rawRData []byte - presentation string + owner string + typ dnsmessage.Type + ttl uint32 + hasTTL bool + source recordSource + address net.IP + target string + target2 string + preference uint16 + priority uint16 + weight uint16 + port uint16 + soa [5]uint32 + txt [][]byte + params []resolver.SVCParam + rawRData []byte + malformedRData bool + presentation string } type result struct { @@ -323,1351 +314,6 @@ func InspectWithError(ctx context.Context, output, errorOutput *core.Printer, cf return 0 } -func lookup(ctx context.Context, cfg *Config, host string, start time.Time) (*result, error) { - server := cfg.DNSServer - if cfg.Endpoint != nil { - server = cfg.Endpoint.URL() - } - target := resolverTarget(server) - out := &result{ - host: host, - resolver: target.label, - transport: inspectionTransport(cfg, server), - security: resolverTransportSecurity(cfg, server), - source: inspectionSource(server), - records: make(map[string][]record), - verbosity: cfg.Verbosity, - } - if cfg.Endpoint != nil { - out.resolverBootstrap = endpointBootstrapDescription(cfg.Endpoint) - } - - // A missing --dns-server prefers the resolv.conf nameservers, which expose - // every record type and per-record TTLs. The platform API is only the - // 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 { - setSystemResolverDetails(out, *policy) - ordered := resolver.RotateSystemResolverPolicy(*policy) - systemPolicy = &ordered - target = resolverTargetInfo{label: ordered.Nameservers[0], udpAddr: ordered.Nameservers[0]} - out.resolver = target.label - out.transport = "UDP" - out.security = string(resolver.SecurityPlaintext) - out.source = "system resolver configuration" - } else { - systemPolicy = nil - target = resolverTargetInfo{label: "system resolver", useDefault: true} - out.resolver = "platform resolver" - out.transport = "platform resolver" - out.security = "platform resolver (OS-managed security)" - out.source = "platform resolver" - } - } - - 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 { - return platformLookup(ctx, out, host, start) - } - - var streamClient *resolver.StreamClient - var doqClient *resolver.DoQClient - var dohClient *resolver.DOHClient - var err error - if cfg.Endpoint != nil && (cfg.Endpoint.Transport == resolver.TransportTCP || cfg.Endpoint.Transport == resolver.TransportTLS) { - streamClient, err = resolver.NewStreamClient(ctx, resolver.StreamConfig{ - Endpoint: cfg.Endpoint, - TLSConfig: cfg.TLSConfig, - CACerts: cfg.CACerts, - ClientCert: cfg.ClientCert, - Insecure: cfg.Insecure, - TLSMin: cfg.TLSMin, - TLSMax: cfg.TLSMax, - }) - if err != nil { - return nil, fmt.Errorf("connect to resolver: %w", err) - } - defer streamClient.Close() - } - if cfg.Endpoint != nil && cfg.Endpoint.Transport == resolver.TransportQUIC { - doqClient, err = resolver.NewDoQClient(ctx, resolver.DoQConfig{ - Endpoint: cfg.Endpoint, - TLSConfig: cfg.TLSConfig, - CACerts: cfg.CACerts, - ClientCert: cfg.ClientCert, - Insecure: cfg.Insecure, - TLSMin: cfg.TLSMin, - TLSMax: cfg.TLSMax, - }) - if err != nil { - return nil, fmt.Errorf("connect to resolver: %w", err) - } - defer doqClient.Close() - } - if server != nil && server.Scheme != "" && streamClient == nil && doqClient == nil { - proxy := client.ProxyFunc(cfg.Proxy) - dohClient, err = resolver.NewDOHClient(resolver.DOHConfig{ - Endpoint: cfg.Endpoint, - ServerURL: server, - Proxy: proxy, - TLSConfig: cfg.TLSConfig, - CACerts: cfg.CACerts, - ClientCert: cfg.ClientCert, - Insecure: cfg.Insecure, - TLSMin: cfg.TLSMin, - TLSMax: cfg.TLSMax, - Timeout: cfg.Timeout, - }) - if err != nil { - return nil, fmt.Errorf("connect to resolver: %w", err) - } - defer dohClient.Close() - } - - queryHost, err := dnsQueryHost(host) - if err != nil { - return nil, fmt.Errorf("normalize hostname %s: %w", host, err) - } - out.queryName = queryHost - queryCtx, cancelQuery := contextForDirectLookup(ctx, systemPolicy != nil) - defer cancelQuery() - queryTransport := resolver.TransportUDP - if cfg.Endpoint != nil { - queryTransport = cfg.Endpoint.Transport - } else if server != nil { - queryTransport = resolverURLTransport(server) - } - queryResponder := target.label - if target.udpAddr != "" { - queryResponder = target.udpAddr - } - results := runFanOut(queryCtx, queryHost, target, systemPolicy, queryTransport, queryResponder, streamClient, doqClient, dohClient) - firstResult := aggregate(out, results, start) - if systemPolicy != nil { - setSystemResponderSummary(out) - } - - // 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 || out.queryTotal > 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, queryTransport resolver.Transport, queryResponder string, streamClient *resolver.StreamClient, doqClient *resolver.DoQClient, dohClient *resolver.DOHClient) []queryResult { - results := make([]queryResult, len(inspectTypes)) - var wg sync.WaitGroup - for i, qt := range inspectTypes { - wg.Add(1) - go func(i int, qt queryType) { - defer wg.Done() - queryStart := time.Now() - results[i].typ = qt - if systemPolicy == nil { - // Explicit resolver backends do not return QueryMetadata, but their - // transport is known before the query starts. Set the responder only - // after a query succeeds; an endpoint is not proof that it answered. - results[i].transport = queryTransport - } - switch { - case systemPolicy != nil: - var metadata resolver.QueryMetadata - 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: - results[i].records, results[i].err = lookupDoQRecords(ctx, doqClient, host, qt) - case dohClient != nil: - results[i].records, results[i].err = lookupDOHRecordsWithClient(ctx, dohClient, host, qt) - default: - results[i].records, results[i].tcpFallback, results[i].err = lookupUDPRecordsWithFallback(ctx, target.udpAddr, host, qt) - } - if systemPolicy == nil && results[i].err == nil { - results[i].responder = queryResponder - } - // System-nameserver queries expose resolver metadata that includes - // failover and retry time. The other backends do not, so measure - // their query operation here. This starts after shared resolver - // setup, which prevents bootstrap/connect time from being charged - // to every concurrently issued query. - if results[i].duration <= 0 { - results[i].duration = time.Since(queryStart) - } - }(i, qt) - } - wg.Wait() - return results -} - -// lookupSystemRecords resolves host for one record type through the system -// 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, metadata, err := resolver.QuerySystemTypeDetailed(ctx, inspectionPolicy, host, uint16(qt.dnsType)) - if err != nil { - 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, 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 -// 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) - out.queryTotal = len(results) - out.queries = make([]queryResult, 0, len(results)) - for _, query := range results { - query.status = classifyQuery(query) - out.queries = append(out.queries, query) - out.tcpFallback = out.tcpFallback || query.tcpFallback - switch query.status { - case queryStatusFailed: - out.failures = append(out.failures, queryFailure{label: query.typ.label, err: query.err}) - if firstResult == nil { - firstResult = query.err - } - case queryStatusNoData: - out.queryNoData++ - case queryStatusData: - out.queryWithData++ - } - for _, rec := range query.records { - label := typeLabel(rec.typ) - key := canonicalOwnerKey(rec.owner) + "\x00" + strconv.Itoa(int(rec.typ)) + "\x00" + rec.semanticKey() - if idx, ok := seen[key]; ok { - records := out.records[label] - existing := &records[idx] - switch { - case rec.hasTTL && !existing.hasTTL: - existing.ttl = rec.ttl - existing.hasTTL = true - case rec.hasTTL && existing.hasTTL && rec.ttl < existing.ttl: - existing.ttl = rec.ttl - } - continue - } - seen[key] = len(out.records[label]) - out.records[label] = append(out.records[label], rec) - } - } - out.duration = time.Since(start) - return firstResult -} - -func canonicalOwnerKey(owner string) string { - return strings.ToLower(owner) -} - -func classifyQuery(query queryResult) queryStatus { - if query.err != nil { - if errors.Is(query.err, resolver.ErrDNSNoData) { - return queryStatusNoData - } - return queryStatusFailed - } - if len(query.records) == 0 { - return queryStatusNoData - } - return queryStatusData -} - -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) - } - for _, rec := range records { - label := typeLabel(rec.typ) - out.records[label] = append(out.records[label], rec) - } - 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 - } - - // DNS wire names are absolute. Keep the trailing root label in the - // inspection result so it describes the name sent to a raw resolver. The - // resolver parser also enforces the DNS label and total-name size limits - // after IDNA expansion. - queryName := strings.Join(labels, ".") + "." - if _, err := resolver.ParseName(queryName); err != nil { - return "", err - } - return queryName, 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, - queryName: orig.queryName, - resolver: "system nameservers + platform resolver", - transport: "mixed", - security: "mixed", - source: "system resolver configuration + platform resolver", - responders: append(slices.Clone(orig.responders), "platform resolver"), - records: make(map[string][]record, len(orig.records)), - queries: slices.Clone(orig.queries), - failures: slices.Clone(orig.failures), - queryTotal: orig.queryTotal, - queryWithData: orig.queryWithData, - queryNoData: orig.queryNoData, - tcpFallback: orig.tcpFallback, - platformFallback: true, - verbosity: orig.verbosity, - configuredNameservers: slices.Clone(orig.configuredNameservers), - resolverAttempts: orig.resolverAttempts, - resolverTimeout: orig.resolverTimeout, - resolverRotation: orig.resolverRotation, - resolverConfiguration: orig.resolverConfiguration, - resolverRouting: orig.resolverRouting, - resolverSearchDomains: orig.resolverSearchDomains, - resolverOSRouting: orig.resolverOSRouting, - resolverPlatformRouting: orig.resolverPlatformRouting, - resolverBootstrap: orig.resolverBootstrap, - duration: time.Since(start), - } - for typ, values := range orig.records { - out.records[typ] = slices.Clone(values) - } - for _, rec := range records { - label := typeLabel(rec.typ) - out.records[label] = append(out.records[label], rec) - } - return out -} - -func lookupDefaultResolverRecords(ctx context.Context, host string) ([]record, error) { - addrs, err := defaultLookupIPAddr(ctx, host) - if err != nil { - return nil, err - } - - records := make([]record, 0, len(addrs)) - owner := normalizedOwner(host) - for _, addr := range addrs { - ip := addr.IP - switch { - case ip.To4() != nil: - records = append(records, record{owner: owner, typ: dnsmessage.TypeA, address: append(net.IP(nil), ip.To4()...), source: recordSourcePlatform}) - case ip.To16() != nil: - records = append(records, record{owner: owner, typ: dnsmessage.TypeAAAA, address: append(net.IP(nil), ip.To16()...), source: recordSourcePlatform}) - } - } - return records, nil -} - -func lookupStreamRecords(ctx context.Context, client *resolver.StreamClient, host string, qt queryType) ([]record, error) { - name, err := resolver.ParseName(absoluteName(host)) - if err != nil { - return nil, err - } - question := resolver.Question{Name: name, Type: uint16(qt.dnsType), Class: 1} - message, err := client.Query(ctx, absoluteName(host), uint16(qt.dnsType)) - if err != nil { - return nil, err - } - if message.Header.RCode != 0 { - return nil, fmt.Errorf("no DNS records found: %s", resolver.RCodeName(message.Header.RCode)) - } - authorized, err := resolver.AuthorizeAnswers(message, question) - if err != nil { - return nil, err - } - records := make([]record, 0, len(authorized)) - for _, answer := range authorized { - if converted, ok := recordFromWire(answer); ok { - records = append(records, converted) - } - } - return records, nil -} - -func lookupDoQRecords(ctx context.Context, client *resolver.DoQClient, host string, qt queryType) ([]record, error) { - name, err := resolver.ParseName(absoluteName(host)) - if err != nil { - return nil, err - } - question := resolver.Question{Name: name, Type: uint16(qt.dnsType), Class: 1} - message, err := client.Query(ctx, absoluteName(host), uint16(qt.dnsType)) - if err != nil { - return nil, err - } - if message.Header.RCode != 0 { - return nil, fmt.Errorf("no DNS records found: %s", resolver.RCodeName(message.Header.RCode)) - } - authorized, err := resolver.AuthorizeAnswers(message, question) - if err != nil { - return nil, err - } - records := make([]record, 0, len(authorized)) - for _, answer := range authorized { - if converted, ok := recordFromWire(answer); ok { - records = append(records, converted) - } - } - return records, nil -} - -func lookupDOHRecordsWithClient(ctx context.Context, client *resolver.DOHClient, host string, qt queryType) ([]record, error) { - answers, err := client.LookupInspectionType(ctx, host, qt.dohType, int(qt.dnsType)) - if err != nil { - return nil, err - } - out := make([]record, 0, len(answers)) - for _, answer := range answers { - if rec, ok := recordFromDOH(answer); ok { - out = append(out, rec) - } - } - return out, nil -} - -func lookupUDPRecords(ctx context.Context, serverAddr, host string, qt queryType) ([]record, error) { - records, _, err := lookupUDPRecordsWithFallback(ctx, serverAddr, host, qt) - return records, err -} - -func lookupUDPRecordsWithFallback(ctx context.Context, serverAddr, host string, qt queryType) ([]record, bool, error) { - name, err := resolver.ParseName(absoluteName(host)) - if err != nil { - return nil, false, err - } - question := resolver.Question{Name: name, Type: uint16(qt.dnsType), Class: 1} - res, fallback, err := resolver.LookupUDPMessage(ctx, serverAddr, absoluteName(host), uint16(qt.dnsType)) - if err != nil { - return nil, fallback, err - } - if res.Header.RCode != 0 { - return nil, fallback, fmt.Errorf("no DNS records found: %s", resolver.RCodeName(res.Header.RCode)) - } - - authorized, err := resolver.AuthorizeAnswers(res, question) - if err != nil { - return nil, fallback, err - } - records := make([]record, 0, len(authorized)) - for _, answer := range authorized { - if converted, ok := recordFromWire(answer); ok { - records = append(records, converted) - } - } - return records, fallback, nil -} - -func recordFromWire(res resolver.Record) (record, bool) { - return semanticRecord(res, "", res.TTLPresent), true -} - -func recordFromDOH(answer resolver.DOHRecord) (record, bool) { - rec := semanticRecord(answer.Record, answer.Data, answer.TTLPresent) - return rec, rec.hasSemanticData() -} - -func (rec record) hasSemanticData() bool { - switch rec.typ { - case dnsmessage.TypeA, dnsmessage.TypeAAAA: - return len(rec.address) > 0 - case dnsmessage.TypeCNAME, dnsmessage.TypeNS: - return rec.target != "" - case dnsmessage.TypeTXT: - return rec.txt != nil - case dnsmessage.TypeMX, dnsmessage.TypeSRV: - return rec.target != "" - case dnsmessage.TypeSOA: - return rec.target != "" && rec.target2 != "" - case dnsTypeCAA: - return len(rec.rawRData) >= 2 - case dnsmessage.TypeSVCB, dnsmessage.TypeHTTPS: - return rec.target != "" - default: - return rec.presentation != "" || rec.rawRData != nil - } -} - -func semanticRecord(res resolver.Record, presentation string, ttlPresent bool) record { - rec := record{ - owner: normalizeOwnerPresentation(res.Owner.String()), - typ: dnsmessage.Type(res.Type), - ttl: res.TTL, - hasTTL: ttlPresent, - source: recordSourceDNS, - preference: res.Preference, - priority: res.Priority, - weight: res.Weight, - port: res.Port, - soa: res.SOAValues, - rawRData: append([]byte(nil), res.RData...), - presentation: presentation, - } - if ip := resolver.RecordAddress(res); ip != nil { - rec.address = append(net.IP(nil), ip...) - } - if res.Target != nil { - rec.target = res.Target.String() - } - if res.Target2 != nil { - rec.target2 = res.Target2.String() - } - for _, chunk := range res.TXT { - rec.txt = append(rec.txt, append([]byte(nil), chunk...)) - } - for _, param := range res.Params { - rec.params = append(rec.params, resolver.SVCParam{Key: param.Key, Value: append([]byte(nil), param.Value...)}) - } - populateRecordData(&rec) - return rec -} - -func populateRecordData(rec *record) { - if len(rec.rawRData) > 0 { - populateRecordFromRaw(rec) - } - if rec.presentation == "" { - return - } - if _, generic := parseGenericRDATA(rec.presentation); generic { - return - } - populateRecordFromPresentation(rec) -} - -func populateRecordFromRaw(rec *record) { - raw := rec.rawRData - switch rec.typ { - case dnsmessage.TypeNS: - if target, end, ok := unpackDNSName(raw, 0); ok && end == len(raw) { - rec.target = target - } - case dnsmessage.TypeMX: - if len(raw) >= 3 { - if target, end, ok := unpackDNSName(raw, 2); ok && end == len(raw) { - rec.preference = binary.BigEndian.Uint16(raw) - rec.target = target - } - } - case dnsmessage.TypeSOA: - if first, off, ok := unpackDNSName(raw, 0); ok { - if second, off2, ok := unpackDNSName(raw, off); ok && len(raw)-off2 == 20 { - rec.target, rec.target2 = first, second - for i := range rec.soa { - rec.soa[i] = binary.BigEndian.Uint32(raw[off2+i*4:]) - } - } - } - case dnsmessage.TypeTXT: - var chunks [][]byte - for off := 0; off < len(raw); { - length := int(raw[off]) - off++ - if length > len(raw)-off { - return - } - chunks = append(chunks, append([]byte(nil), raw[off:off+length]...)) - off += length - } - rec.txt = chunks - case dnsmessage.TypeSRV: - if len(raw) >= 7 { - if target, end, ok := unpackDNSName(raw, 6); ok && end == len(raw) { - rec.priority = binary.BigEndian.Uint16(raw) - rec.weight = binary.BigEndian.Uint16(raw[2:]) - rec.port = binary.BigEndian.Uint16(raw[4:]) - rec.target = target - } - } - case dnsmessage.TypeSVCB, dnsmessage.TypeHTTPS: - // DoH JSON and wire responses normally provide Params directly. Parse - // raw RDATA as well so records from generic fixtures remain semantic - // and malformed values can still be shown safely by the renderer. - priority, target, params, _ := parseRawSVCB(rec.rawRData) - if target != "" { - rec.priority = priority - rec.target = target - } - if len(params) > 0 { - rec.params = params - } - } -} - -func populateRecordFromPresentation(rec *record) { - fields := strings.Fields(rec.presentation) - parseUint16 := func(value string) (uint16, bool) { - parsed, err := strconv.ParseUint(value, 10, 16) - return uint16(parsed), err == nil - } - parseUint32 := func(value string) (uint32, bool) { - parsed, err := strconv.ParseUint(value, 10, 32) - return uint32(parsed), err == nil - } - name := func(value string) (string, bool) { - parsed, err := resolver.ParseName(value) - if err != nil { - return "", false - } - return parsed.String(), true - } - - switch rec.typ { - case dnsmessage.TypeNS: - if len(fields) == 1 { - rec.target, _ = name(fields[0]) - } - case dnsmessage.TypeTXT: - if chunks, ok := parseDNSCharacterStrings(rec.presentation); ok { - rec.txt = chunks - } else { - // Some JSON resolvers omit the presentation quotes for a single - // TXT character-string. Preserve that response as one chunk. - rec.txt = [][]byte{[]byte(rec.presentation)} - } - case dnsmessage.TypeMX: - if len(fields) == 2 { - preference, numberOK := parseUint16(fields[0]) - target, nameOK := name(fields[1]) - if numberOK && nameOK { - rec.preference, rec.target = preference, target - } - } - case dnsmessage.TypeSOA: - if len(fields) == 7 { - primary, primaryOK := name(fields[0]) - mailbox, mailboxOK := name(fields[1]) - values := [5]uint32{} - valuesOK := true - for i := range values { - values[i], valuesOK = parseUint32(fields[i+2]) - if !valuesOK { - break - } - } - if primaryOK && mailboxOK && valuesOK { - rec.target, rec.target2, rec.soa = primary, mailbox, values - } - } - case dnsmessage.TypeSRV: - if len(fields) == 4 { - priority, priorityOK := parseUint16(fields[0]) - weight, weightOK := parseUint16(fields[1]) - port, portOK := parseUint16(fields[2]) - target, targetOK := name(fields[3]) - if priorityOK && weightOK && portOK && targetOK { - rec.priority, rec.weight, rec.port, rec.target = priority, weight, port, target - } - } - case dnsTypeCAA: - flagsText, rest, flagsFieldOK := cutDNSField(rec.presentation) - tag, valueText, tagFieldOK := cutDNSField(rest) - flags, flagsOK := parseUint16(flagsText) - if flagsFieldOK && tagFieldOK && flagsOK && flags <= 255 && len(tag) <= 255 { - if values, ok := parseDNSCharacterStrings(valueText); ok && len(values) == 1 { - rec.rawRData = append([]byte{byte(flags), byte(len(tag))}, []byte(tag)...) - rec.rawRData = append(rec.rawRData, values[0]...) - } - } - } -} - -func cutDNSField(text string) (field, rest string, ok bool) { - text = strings.TrimLeft(text, " \t") - if text == "" { - return "", "", false - } - end := strings.IndexAny(text, " \t") - if end < 0 { - return text, "", true - } - return text[:end], strings.TrimLeft(text[end:], " \t"), true -} - -func parseDNSCharacterStrings(text string) ([][]byte, bool) { - var out [][]byte - for offset := 0; ; { - for offset < len(text) && (text[offset] == ' ' || text[offset] == '\t') { - offset++ - } - if offset == len(text) { - return out, len(out) > 0 - } - if text[offset] != '"' { - return nil, false - } - offset++ - var value []byte - closed := false - for offset < len(text) { - if text[offset] == '"' { - offset++ - closed = true - break - } - if text[offset] != '\\' { - value = append(value, text[offset]) - offset++ - continue - } - offset++ - if offset == len(text) { - return nil, false - } - if offset+3 <= len(text) && text[offset] >= '0' && text[offset] <= '9' && text[offset+1] >= '0' && text[offset+1] <= '9' && text[offset+2] >= '0' && text[offset+2] <= '9' { - octet, err := strconv.ParseUint(text[offset:offset+3], 10, 8) - if err != nil { - return nil, false - } - value = append(value, byte(octet)) - offset += 3 - continue - } - value = append(value, text[offset]) - offset++ - } - if !closed || len(value) > 255 { - return nil, false - } - out = append(out, value) - } -} - -func (rec record) semanticKey() string { - var b strings.Builder - switch rec.typ { - case dnsmessage.TypeA, dnsmessage.TypeAAAA: - fmt.Fprintf(&b, "%x", []byte(rec.address)) - case dnsmessage.TypeCNAME, dnsmessage.TypeNS: - if rec.target == "" && rec.presentation != "" { - return rec.presentation - } - b.WriteString(strings.ToLower(rec.target)) - case dnsmessage.TypeTXT: - if rec.txt == nil && rec.presentation != "" { - return rec.presentation - } - for _, chunk := range rec.txt { - fmt.Fprintf(&b, "%d:%x,", len(chunk), chunk) - } - case dnsmessage.TypeMX: - if rec.target == "" && rec.presentation != "" { - return rec.presentation - } - fmt.Fprintf(&b, "%d|%s", rec.preference, strings.ToLower(rec.target)) - case dnsmessage.TypeSOA: - if (rec.target == "" || rec.target2 == "") && rec.presentation != "" { - return rec.presentation - } - fmt.Fprintf(&b, "%s|%s|", strings.ToLower(rec.target), strings.ToLower(rec.target2)) - for _, value := range rec.soa { - fmt.Fprintf(&b, "%d,", value) - } - case dnsmessage.TypeSRV: - if rec.target == "" && rec.presentation != "" { - return rec.presentation - } - fmt.Fprintf(&b, "%d|%d|%d|%s", rec.priority, rec.weight, rec.port, strings.ToLower(rec.target)) - case dnsTypeCAA: - if len(rec.rawRData) == 0 && rec.presentation != "" { - return rec.presentation - } - fmt.Fprintf(&b, "%x", rec.rawRData) - case dnsmessage.TypeSVCB, dnsmessage.TypeHTTPS: - fmt.Fprintf(&b, "%d|%s|", rec.priority, strings.ToLower(rec.target)) - for _, param := range rec.params { - fmt.Fprintf(&b, "%d:%d:%x,", param.Key, len(param.Value), param.Value) - } - default: - fmt.Fprintf(&b, "%x", rec.rawRData) - } - if b.Len() == 0 { - b.WriteString(rec.presentation) - } - return b.String() -} - -// renderValue is the only place that turns semantic record data into terminal -// presentation. DoH JSON text is used only when that protocol did not provide -// parsed fields or generic wire-format RDATA. -func (rec record) renderValue() string { - fallback := func() string { - if rec.presentation != "" { - return safeRecordText(normalizeDOHValue(rec.typ, rec.presentation)) - } - return "0x" + hex.EncodeToString(rec.rawRData) - } - - switch rec.typ { - case dnsmessage.TypeA, dnsmessage.TypeAAAA: - if len(rec.address) > 0 { - return rec.address.String() - } - case dnsmessage.TypeCNAME, dnsmessage.TypeNS: - if rec.target != "" { - return rec.target - } - case dnsmessage.TypeTXT: - if len(rec.txt) == 1 { - return formatTXTChunk(rec.txt[0]) - } - case dnsmessage.TypeMX: - if rec.target != "" { - return fmt.Sprintf("%d %s", rec.preference, rec.target) - } - case dnsmessage.TypeSOA: - if rec.target != "" && rec.target2 != "" { - return fmt.Sprintf("%s %s serial=%d refresh=%d retry=%d expire=%d minttl=%d", rec.target, rec.target2, rec.soa[0], rec.soa[1], rec.soa[2], rec.soa[3], rec.soa[4]) - } - case dnsmessage.TypeSRV: - if rec.target != "" { - return fmt.Sprintf("%d %d %d %s", rec.priority, rec.weight, rec.port, rec.target) - } - case dnsTypeCAA: - if len(rec.rawRData) > 0 { - return formatCAA(rec.rawRData) - } - case dnsmessage.TypeSVCB, dnsmessage.TypeHTTPS: - if rec.target != "" { - params := make([]dnsmessage.SVCParam, 0, len(rec.params)) - for _, param := range rec.params { - params = append(params, dnsmessage.SVCParam{Key: dnsmessage.SVCParamKey(param.Key), Value: param.Value}) - } - return formatSVCBValue(rec.priority, rec.target, params) - } - } - return fallback() -} - -func normalizedOwner(host string) string { - if queryHost, err := dnsQueryHost(host); err == nil { - return normalizeOwnerPresentation(queryHost) - } - return normalizeOwnerPresentation(host) -} - -func normalizeOwnerPresentation(owner string) string { - return strings.ToLower(absoluteName(owner)) -} - -func safeRecordText(text string) string { - for _, r := range text { - if r == '\n' || r == '\t' || r == '\r' || r == '\\' || r == '"' || r < 0x20 || r >= 0x7f && r <= 0x9f { - return strconv.Quote(text) - } - } - return text -} - -func formatCAA(raw []byte) string { - flags, tag, value, ok := caaFields(raw) - if !ok { - return "0x" + hex.EncodeToString(raw) - } - return fmt.Sprintf("%d %s %q", flags, safeRecordText(tag), value) -} - -func formatSVCBValue(priority uint16, target string, params []dnsmessage.SVCParam) string { - parts := []string{fmt.Sprintf("%d", priority), target} - for _, param := range params { - parts = append(parts, formatSVCParam(param)) - } - return strings.Join(parts, " ") -} - -func svcParamRenderOrder(key uint16) int { - // This order follows the diagnostic fields rather than the wire key order: - // address hints stay together and ECH remains easy to find after them. - switch key { - case uint16(dnsmessage.SVCParamMandatory): - return 0 - case uint16(dnsmessage.SVCParamALPN): - return 1 - case uint16(dnsmessage.SVCParamNoDefaultALPN): - return 2 - case uint16(dnsmessage.SVCParamPort): - return 3 - case uint16(dnsmessage.SVCParamIPv4Hint): - return 4 - case uint16(dnsmessage.SVCParamIPv6Hint): - return 5 - case uint16(dnsmessage.SVCParamECH): - return 6 - case uint16(dnsmessage.SVCParamDOHPath): - return 7 - case uint16(dnsmessage.SVCParamOHTTP): - return 8 - case uint16(dnsmessage.SVCParamTLSSupportedGroups): - return 9 - default: - return 10 - } -} - -func formatStructuredSVCParam(param resolver.SVCParam) (label, value string) { - switch dnsmessage.SVCParamKey(param.Key) { - case dnsmessage.SVCParamMandatory: - return "Mandatory", formatSVCBKeyList(param.Value) - case dnsmessage.SVCParamALPN: - if value, ok := formatSVCBALPN(param.Value); ok { - return "ALPN", value - } - return "ALPN", formatSVCBBytes(param.Value) - case dnsmessage.SVCParamNoDefaultALPN: - if len(param.Value) == 0 { - return "No default ALPN", "true" - } - return "No default ALPN", formatSVCBBytes(param.Value) - case dnsmessage.SVCParamPort: - if len(param.Value) == 2 { - return "Port", strconv.Itoa(int(binary.BigEndian.Uint16(param.Value))) - } - return "Port", formatSVCBBytes(param.Value) - case dnsmessage.SVCParamIPv4Hint: - if value, ok := formatSVCBHints(param.Value, 4); ok { - return "IPv4 hints", value - } - return "IPv4 hints", formatSVCBBytes(param.Value) - case dnsmessage.SVCParamIPv6Hint: - if value, ok := formatSVCBHints(param.Value, 16); ok { - return "IPv6 hints", value - } - return "IPv6 hints", formatSVCBBytes(param.Value) - case dnsmessage.SVCParamECH: - // ECH is already an opaque, length-prefixed binary value. Preserve its - // complete base64 representation; do not expose only a preview. - return "ECH", base64.StdEncoding.EncodeToString(param.Value) - case dnsmessage.SVCParamDOHPath: - return "DoH path", string(param.Value) - case dnsmessage.SVCParamOHTTP: - if len(param.Value) == 0 { - return "OHTTP", "true" - } - return "OHTTP", formatSVCBBytes(param.Value) - case dnsmessage.SVCParamTLSSupportedGroups: - if value, ok := formatSVCBUint16List(param.Value); ok { - return "TLS supported groups", value - } - return "TLS supported groups", formatSVCBBytes(param.Value) - default: - return formatSVCBParamName(param.Key), formatSVCBBytes(param.Value) - } -} - -func formatSVCBParamName(key uint16) string { - name := dnsmessage.SVCParamKey(key).String() - // x/net prints unknown SvcParam keys as bare numbers. The key prefix makes - // those values unambiguous and matches DNS presentation terminology. - if _, err := strconv.ParseUint(name, 10, 16); err == nil { - return "key" + name - } - return name -} - -func formatSVCBBytes(value []byte) string { - return "0x" + hex.EncodeToString(value) -} - -func formatSVCBALPN(value []byte) (string, bool) { - var values []string - for offset := 0; offset < len(value); { - length := int(value[offset]) - offset++ - if length == 0 || length > len(value)-offset { - return "", false - } - values = append(values, string(value[offset:offset+length])) - offset += length - } - if len(values) == 0 { - return "", false - } - return strings.Join(values, ", "), true -} - -func formatSVCBUint16List(value []byte) (string, bool) { - if len(value) == 0 || len(value)%2 != 0 { - return "", false - } - values := make([]string, 0, len(value)/2) - for offset := 0; offset < len(value); offset += 2 { - values = append(values, strconv.Itoa(int(binary.BigEndian.Uint16(value[offset:])))) - } - return strings.Join(values, ", "), true -} - -func formatSVCBHints(value []byte, width int) (string, bool) { - if len(value) == 0 || len(value)%width != 0 { - return "", false - } - values := make([]string, 0, len(value)/width) - for offset := 0; offset < len(value); offset += width { - values = append(values, net.IP(value[offset:offset+width]).String()) - } - return strings.Join(values, ", "), true -} - -func formatSVCBKeyList(value []byte) string { - if len(value) == 0 || len(value)%2 != 0 { - return formatSVCBBytes(value) - } - keys := make([]string, 0, len(value)/2) - for offset := 0; offset < len(value); offset += 2 { - key := binary.BigEndian.Uint16(value[offset:]) - keys = append(keys, formatSVCBKey(key)) - } - return strings.Join(keys, ", ") -} - -func formatSVCBKey(key uint16) string { - switch dnsmessage.SVCParamKey(key) { - case dnsmessage.SVCParamMandatory: - return "mandatory" - case dnsmessage.SVCParamALPN: - return "alpn" - case dnsmessage.SVCParamNoDefaultALPN: - return "no-default-alpn" - case dnsmessage.SVCParamPort: - return "port" - case dnsmessage.SVCParamIPv4Hint: - return "ipv4hint" - case dnsmessage.SVCParamECH: - return "ech" - case dnsmessage.SVCParamIPv6Hint: - return "ipv6hint" - case dnsmessage.SVCParamDOHPath: - return "dohpath" - case dnsmessage.SVCParamOHTTP: - return "ohttp" - case dnsmessage.SVCParamTLSSupportedGroups: - return "tls-supported-groups" - default: - return formatSVCBParamName(key) - } -} - -// parseRawSVCB returns as much of a generic SVCB/HTTPS RDATA value as can be -// safely decoded. The final boolean reports whether the complete value is -// well-formed, which lets the renderer retain malformed data as raw hex. -func parseRawSVCB(raw []byte) (priority uint16, target string, params []resolver.SVCParam, ok bool) { - if len(raw) < 3 { - return 0, "", nil, false - } - - // Use the resolver's strict parser for the validity bit. It checks more - // than framing, including parameter ordering, duplicate keys, reserved - // keys, and the semantics of known values. The local decode below still - // recovers a target and any complete parameters for a useful fallback. - if parsed, err := resolver.ParseSVCBRData(raw); err == nil { - params = make([]resolver.SVCParam, len(parsed.Params)) - for i, param := range parsed.Params { - params[i] = resolver.SVCParam{Key: param.Key, Value: append([]byte(nil), param.Value...)} - } - return parsed.Priority, parsed.Target.String(), params, true - } - - priority = binary.BigEndian.Uint16(raw) - var offset int - target, offset, ok = unpackDNSName(raw, 2) - if !ok { - return priority, "", nil, false - } - for offset < len(raw) { - if len(raw)-offset < 4 { - return priority, target, params, false - } - key := binary.BigEndian.Uint16(raw[offset:]) - length := int(binary.BigEndian.Uint16(raw[offset+2:])) - offset += 4 - if length > len(raw)-offset { - return priority, target, params, false - } - params = append(params, resolver.SVCParam{Key: key, Value: append([]byte(nil), raw[offset:offset+length]...)}) - offset += length - } - // Reaching this point means strict semantic validation failed. Keep the - // recovered fields for display, but make the caller retain the raw value. - return priority, target, params, false -} - -func formatSVCParam(param dnsmessage.SVCParam) string { - switch param.Key { - case dnsmessage.SVCParamALPN: - var alpns []string - for i := 0; i < len(param.Value); { - ln := int(param.Value[i]) - i++ - if i+ln > len(param.Value) { - return fmt.Sprintf("%s=0x%s", param.Key.String(), hex.EncodeToString(param.Value)) - } - alpns = append(alpns, safeRecordText(string(param.Value[i:i+ln]))) - i += ln - } - return param.Key.String() + "=" + strings.Join(alpns, ",") - case dnsmessage.SVCParamNoDefaultALPN: - return param.Key.String() - case dnsmessage.SVCParamECH: - return "ECH=" + base64.StdEncoding.EncodeToString(param.Value) - case dnsmessage.SVCParamPort: - if len(param.Value) != 2 { - return fmt.Sprintf("%s=0x%s", param.Key.String(), hex.EncodeToString(param.Value)) - } - port := uint16(param.Value[0])<<8 | uint16(param.Value[1]) - return fmt.Sprintf("%s=%d", param.Key.String(), port) - case dnsmessage.SVCParamIPv4Hint: - if len(param.Value)%4 != 0 { - return fmt.Sprintf("%s=0x%s", param.Key.String(), hex.EncodeToString(param.Value)) - } - var ips []string - for i := 0; i < len(param.Value); i += 4 { - ips = append(ips, net.IP(param.Value[i:i+4]).String()) - } - return param.Key.String() + "=" + strings.Join(ips, ",") - case dnsmessage.SVCParamIPv6Hint: - if len(param.Value)%16 != 0 { - return fmt.Sprintf("%s=0x%s", param.Key.String(), hex.EncodeToString(param.Value)) - } - var ips []string - for i := 0; i < len(param.Value); i += 16 { - ips = append(ips, net.IP(param.Value[i:i+16]).String()) - } - return param.Key.String() + "=" + strings.Join(ips, ",") - case dnsmessage.SVCParamDOHPath: - return param.Key.String() + "=" + strconv.Quote(string(param.Value)) - default: - return fmt.Sprintf("%s=0x%s", param.Key.String(), hex.EncodeToString(param.Value)) - } -} - -func normalizeDOHValue(typ dnsmessage.Type, value string) string { - raw, ok := parseGenericRDATA(value) - if !ok { - return value - } - - switch typ { - case dnsmessage.TypeSVCB, dnsmessage.TypeHTTPS: - if text, ok := parseSVCBRDATA(raw); ok { - return text - } - case dnsTypeCAA: - return formatCAA(raw) - } - return "0x" + hex.EncodeToString(raw) -} - -func parseGenericRDATA(value string) ([]byte, bool) { - fields := strings.Fields(value) - if len(fields) < 3 || fields[0] != "\\#" { - return nil, false - } - wantLen, err := strconv.Atoi(fields[1]) - if err != nil || wantLen < 0 { - return nil, false - } - raw, err := hex.DecodeString(strings.Join(fields[2:], "")) - if err != nil || len(raw) != wantLen { - return nil, false - } - return raw, true -} - -func parseSVCBRDATA(raw []byte) (string, bool) { - if len(raw) < 3 { - return "", false - } - priority := uint16(raw[0])<<8 | uint16(raw[1]) - target, off, ok := unpackDNSName(raw, 2) - if !ok { - return "", false - } - - var params []dnsmessage.SVCParam - for off < len(raw) { - if off+4 > len(raw) { - return "", false - } - key := uint16(raw[off])<<8 | uint16(raw[off+1]) - ln := int(raw[off+2])<<8 | int(raw[off+3]) - off += 4 - if off+ln > len(raw) { - return "", false - } - value := append([]byte(nil), raw[off:off+ln]...) - params = append(params, dnsmessage.SVCParam{Key: dnsmessage.SVCParamKey(key), Value: value}) - off += ln - } - return formatSVCBValue(priority, target, params), true -} - -func unpackDNSName(raw []byte, off int) (string, int, bool) { - var labels []string - wireSize := 1 - for { - if off >= len(raw) { - return "", 0, false - } - ln := int(raw[off]) - off++ - if ln == 0 { - if len(labels) == 0 { - return ".", off, true - } - return strings.Join(labels, ".") + ".", off, true - } - if ln&0xc0 != 0 || ln > 63 || off+ln > len(raw) { - return "", 0, false - } - wireSize += 1 + ln - if wireSize > 255 { - return "", 0, false - } - labels = append(labels, dnsLabelPresentation(raw[off:off+ln])) - off += ln - } -} - -func dnsLabelPresentation(label []byte) string { - var b strings.Builder - for _, value := range label { - if value >= 'a' && value <= 'z' || value >= 'A' && value <= 'Z' || value >= '0' && value <= '9' || value == '-' || value == '_' || value >= 0x21 && value <= 0x7e && value != '.' && value != '\\' { - b.WriteByte(value) - continue - } - fmt.Fprintf(&b, "\\%03d", value) - } - return b.String() -} - // resolverTransportSecurity reports protection for the connection to the // resolver. It intentionally says nothing about DNSSEC: fetch does not // validate DNSSEC chains locally. @@ -1797,839 +443,6 @@ func displaySecurity(security string) string { } } -func absoluteName(host string) string { - if strings.HasSuffix(host, ".") { - return host - } - return host + "." -} - -func typeLabel(typ dnsmessage.Type) string { - switch typ { - case dnsmessage.TypeA: - return "A" - case dnsmessage.TypeAAAA: - return "AAAA" - case dnsmessage.TypeCNAME: - return "CNAME" - case dnsmessage.TypeTXT: - return "TXT" - case dnsmessage.TypeMX: - return "MX" - case dnsmessage.TypeNS: - return "NS" - case dnsmessage.TypeSOA: - return "SOA" - case dnsmessage.TypeSRV: - return "SRV" - case dnsTypeCAA: - return "CAA" - case dnsmessage.TypeSVCB: - return "SVCB" - case dnsmessage.TypeHTTPS: - return "HTTPS" - default: - return fmt.Sprintf("TYPE%d", uint16(typ)) - } -} - -// isIPLiteral reports IPv4, IPv6, and scoped IPv6 literals. URL.Hostname -// removes brackets from IPv6 authorities and decodes the zone separator, so -// check the address without its optional interface zone. A scoped IPv6 -// literal is still an address that must not trigger DNS inspection. -func isIPLiteral(host string) bool { - if net.ParseIP(host) != nil { - return true - } - if zone := strings.IndexByte(host, '%'); zone > 0 && zone+1 < len(host) { - ip := net.ParseIP(host[:zone]) - // A zone is valid only on an IPv6 spelling. IPv4-mapped IPv6 - // addresses retain the colon syntax even though To4 reports true. - return ip != nil && strings.Contains(host[:zone], ":") - } - return false -} - -func renderIPLiteral(p *core.Printer, host string) { - renderInspectionSection(p, "Lookup") - writeInspectionField(p, "Name", host) - writeInspectionField(p, "Status", "IP literal — DNS not performed") -} - -const maxPartialErrorBytes = 256 - -func conciseDiagnostic(text string) string { - if len(text) <= maxPartialErrorBytes { - return text - } - cut := maxPartialErrorBytes - for cut > 0 && !utf8.RuneStart(text[cut]) { - cut-- - } - return text[:cut] + "..." -} - -func render(p *core.Printer, res *result) { - renderInspection(p, res) -} - -func inspectionTransportSummary(res *result) string { - if res.tcpFallback && res.transport == "UDP" { - return "UDP → TCP fallback" - } - return res.transport -} - -func renderResolverDetails(p *core.Printer, res *result) { - var fields int - write := func(label, value string) { - if value == "" { - return - } - if fields == 0 { - writeInspectionBlankLine(p) - heading := "Resolver details" - if res.resolverRouting != "" || len(res.configuredNameservers) > 0 { - heading = "System resolver" - } - renderInspectionSection(p, heading) - } - fields++ - writeInspectionField(p, label, value) - } - - // Extra verbosity always records the exact absolute name sent to the - // resolver, including when it is equivalent to the user-facing name. - if res.queryName != "" { - write("Query name", res.queryName) - } - if len(res.configuredNameservers) > 0 { - write("Configured nameservers", strings.Join(res.configuredNameservers, ", ")) - } - if res.resolverAttempts > 0 { - write("Resolver attempts", countPhrase(res.resolverAttempts, "per nameserver", "per nameserver")) - } - if res.resolverTimeout > 0 { - write("Resolver timeout", formatDuration(res.resolverTimeout)) - } - write("Resolver rotation", res.resolverRotation) - write("Configuration", res.resolverConfiguration) - write("Routing", res.resolverRouting) - write("Search domains", res.resolverSearchDomains) - write("OS resolver routing", res.resolverOSRouting) - write("macOS routing", res.resolverPlatformRouting) - write("Bootstrap", res.resolverBootstrap) -} - -func renderQueryDetails(p *core.Printer, queries []queryResult) { - if len(queries) == 0 { - return - } - - writeInspectionBlankLine(p) - renderInspectionSection(p, "Queries") - for _, query := range queries { - status := "no data" - switch query.status { - case queryStatusData: - status = countPhrase(len(query.records), "record", "records") - case queryStatusFailed: - status = "failed" - } - 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, " · ")) - } -} - -// renderInspection writes the structured DNS diagnostic view. The lookup -// summary is deliberately separate from record rendering so that the output -// remains useful even when no record data is available. -func renderInspection(p *core.Printer, res *result) { - renderInspectionSection(p, "Lookup") - writeInspectionField(p, "Name", res.host) - if queryNameDiffers(res.host, res.queryName) { - writeInspectionField(p, "Query name", res.queryName) - } - 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 != "" { - writeInspectionField(p, "Transport", transport) - } - if res.security != "" { - writeInspectionField(p, "Transport security", displaySecurity(res.security)) - } - if res.source != "" { - writeInspectionField(p, "Source", res.source) - } - if res.platformFallback { - writeInspectionField(p, "Fallback", "platform resolver used for addresses") - } - writeInspectionField(p, "Status", inspectionStatus(res)) - if summary := resultSummary(res); summary != "" { - writeInspectionField(p, "Results", summary) - } - if summary := querySummary(res); summary != "" { - writeInspectionField(p, "Queries", summary) - } - if res.duration > 0 { - writeInspectionField(p, "Timing", formatDuration(res.duration)) - } - if res.tcpFallback && res.transport != "UDP" { - writeInspectionField(p, "TCP fallback", "used for truncated UDP response") - } - - if len(res.failures) > 0 { - writeInspectionBlankLine(p) - renderInspectionSection(p, "Failures") - renderFailures(p, res.failures) - } - if res.verbosity >= core.VExtraVerbose { - renderResolverDetails(p, res) - renderQueryDetails(p, res.queries) - } - writeInspectionBlankLine(p) - renderInspectionSection(p, "Records") - if recordCount(res) == 0 { - return - } - for _, qt := range inspectTypes { - renderSection(p, qt.label, res.records[qt.label]) - } - renderOtherSections(p, res.records) -} - -func renderFailures(p *core.Printer, failures []queryFailure) { - type failureGroup struct { - labels []string - // Keep the complete error as the grouping key. The displayed value is - // bounded, so a long resolver diagnostic cannot make the output grow - // without limit while two distinct errors are not accidentally merged - // because their prefixes happen to match. - key string - err string - } - groups := make([]failureGroup, 0, len(failures)) - indices := make(map[string]int, len(failures)) - for _, failure := range failures { - key, errText := failureDiagnostic(failure.err) - idx, ok := indices[key] - if !ok { - indices[key] = len(groups) - groups = append(groups, failureGroup{key: key, err: errText}) - idx = len(groups) - 1 - } - groups[idx].labels = append(groups[idx].labels, failure.label) - } - - // Aggregation normally supplies failures in inspection order. Sort here as - // well because renderFailures is also used by focused tests and should be - // deterministic for any input order. - for i := range groups { - slices.SortFunc(groups[i].labels, compareInspectionLabels) - } - slices.SortFunc(groups, func(a, b failureGroup) int { - if cmp := compareInspectionLabels(a.labels[0], b.labels[0]); cmp != 0 { - return cmp - } - return strings.Compare(a.key, b.key) - }) - - for _, group := range groups { - label := strings.Join(group.labels, ", ") - if allInspectionTypesFailed(group.labels) { - label = "All record types" - } - writeInspectionField(p, label, group.err) - } -} - -func failureDiagnostic(err error) (key, display string) { - if err == nil { - return "query failed", "query failed" - } - key = err.Error() - if key == "" { - return "query failed", "query failed" - } - return key, conciseDiagnostic(key) -} - -func allInspectionTypesFailed(labels []string) bool { - if len(labels) != len(inspectTypes) { - return false - } - seen := make(map[string]struct{}, len(labels)) - for _, label := range labels { - if _, ok := seen[label]; ok { - return false - } - seen[label] = struct{}{} - } - for _, typ := range inspectTypes { - if _, ok := seen[typ.label]; !ok { - return false - } - } - return true -} - -func compareInspectionLabels(a, b string) int { - rank := func(label string) int { - for i, typ := range inspectTypes { - if label == typ.label { - return i - } - } - return len(inspectTypes) - } - if aRank, bRank := rank(a), rank(b); aRank != bRank { - if aRank < bRank { - return -1 - } - return 1 - } - return strings.Compare(a, b) -} - -// queryNameDiffers reports whether the absolute DNS name is meaningfully -// different from the name supplied by the user. The root terminator is -// implicit for ordinary multi-label hostnames, so it is not useful to repeat -// it in normal output. Single-label names are different: adding the root -// terminator makes the qualification explicit and avoids implying search -// domain behavior. -func queryNameDiffers(host, queryName string) bool { - if queryName == "" { - return false - } - if host == "." || strings.HasSuffix(host, ".") { - return !strings.EqualFold(host, queryName) - } - if !strings.Contains(host, ".") && strings.EqualFold(absoluteName(host), queryName) { - return true - } - return !strings.EqualFold(absoluteName(host), queryName) -} - -func renderInspectionSection(p *core.Printer, heading string) { - p.WriteInfoPrefix() - p.Set(core.Bold) - p.WriteString(core.TerminalSafeText(heading)) - p.Reset() - p.WriteString("\n") -} - -func writeInspectionField(p *core.Printer, label, value string) { - p.WriteInfoPrefix() - p.WriteString(" ") - p.WriteString(label) - p.WriteString(": ") - p.WriteString(core.TerminalSafeText(value)) - p.WriteString("\n") -} - -func writeInspectionBlankLine(p *core.Printer) { - p.WriteInfoPrefix() - p.WriteString("\n") -} - -func inspectionStatus(res *result) string { - if len(res.failures) == 0 { - return "complete" - } - if res.queryTotal > 0 { - return fmt.Sprintf("incomplete — %d of %d queries failed", len(res.failures), res.queryTotal) - } - return "incomplete" -} - -func resultSummary(res *result) string { - addresses := len(res.records["A"]) + len(res.records["AAAA"]) - return strings.Join([]string{ - countPhrase(addresses, "address", "addresses"), - countPhrase(recordCount(res), "record", "records"), - countPhrase(recordTypeCount(res), "record type", "record types"), - }, " · ") -} - -func querySummary(res *result) string { - if res.queryTotal == 0 { - return "" - } - parts := []string{ - queryCountPhrase(res.queryTotal, "total"), - queryCountPhrase(res.queryWithData, "with data"), - queryCountPhrase(res.queryNoData, "no data"), - } - if len(res.failures) > 0 { - parts = append(parts, queryCountPhrase(len(res.failures), "failed")) - } - return strings.Join(parts, " · ") -} - -func countPhrase(count int, singular, plural string) string { - if count == 1 { - return fmt.Sprintf("1 %s", singular) - } - return fmt.Sprintf("%d %s", count, plural) -} - -func queryCountPhrase(count int, label string) string { - return fmt.Sprintf("%d %s", count, label) -} - -func recordTypeCount(res *result) int { - count := 0 - for _, records := range res.records { - if len(records) > 0 { - count++ - } - } - return count -} - -func renderOtherSections(p *core.Printer, records map[string][]record) { - known := make(map[string]bool, len(inspectTypes)) - for _, qt := range inspectTypes { - known[qt.label] = true - } - var types []string - for typ := range records { - if known[typ] { - continue - } - types = append(types, typ) - } - slices.Sort(types) - for _, typ := range types { - renderSection(p, typ, records[typ]) - } -} - -func compareRecords(a, b record) int { - text := func(left, right string) int { - return strings.Compare(strings.ToLower(left), strings.ToLower(right)) - } - if a.typ != b.typ { - return cmp.Compare(a.typ, b.typ) - } - - switch a.typ { - case dnsmessage.TypeA, dnsmessage.TypeAAAA: - if order := bytes.Compare(a.address, b.address); order != 0 { - return order - } - case dnsmessage.TypeCNAME, dnsmessage.TypeNS: - if order := text(a.target, b.target); order != 0 { - return order - } - case dnsmessage.TypeTXT: - for i := 0; i < min(len(a.txt), len(b.txt)); i++ { - if order := bytes.Compare(a.txt[i], b.txt[i]); order != 0 { - return order - } - } - if order := cmp.Compare(len(a.txt), len(b.txt)); order != 0 { - return order - } - case dnsmessage.TypeMX: - if order := cmp.Compare(a.preference, b.preference); order != 0 { - return order - } - if order := text(a.target, b.target); order != 0 { - return order - } - case dnsmessage.TypeSOA: - if order := text(a.owner, b.owner); order != 0 { - return order - } - case dnsmessage.TypeSRV: - for _, order := range []int{ - cmp.Compare(a.priority, b.priority), - cmp.Compare(a.weight, b.weight), - cmp.Compare(a.port, b.port), - text(a.target, b.target), - } { - if order != 0 { - return order - } - } - case dnsTypeCAA: - aFlags, aTag, aValue := caaSortFields(a.rawRData) - bFlags, bTag, bValue := caaSortFields(b.rawRData) - for _, order := range []int{text(aTag, bTag), cmp.Compare(aFlags, bFlags), bytes.Compare(aValue, bValue)} { - if order != 0 { - return order - } - } - case dnsmessage.TypeSVCB, dnsmessage.TypeHTTPS: - if order := cmp.Compare(a.priority, b.priority); order != 0 { - return order - } - if order := text(a.target, b.target); order != 0 { - return order - } - default: - if order := bytes.Compare(a.rawRData, b.rawRData); order != 0 { - return order - } - } - return strings.Compare(a.semanticKey(), b.semanticKey()) -} - -func caaSortFields(raw []byte) (uint8, string, []byte) { - if len(raw) < 2 || int(raw[1]) > len(raw)-2 { - return 0, "", raw - } - tagEnd := 2 + int(raw[1]) - return raw[0], string(raw[2:tagEnd]), raw[tagEnd:] -} - -func renderSection(p *core.Printer, name string, records []record) { - if len(records) == 0 { - return - } - records = slices.Clone(records) - slices.SortFunc(records, func(a, b record) int { - if order := compareRecords(a, b); order != 0 { - return order - } - if a.ttl < b.ttl { - return -1 - } - if a.ttl > b.ttl { - return 1 - } - return 0 - }) - - p.WriteInfoPrefix() - p.Set(core.Bold) - p.WriteString(" " + name) - p.Reset() - p.WriteString("\n") - - for i, rec := range records { - last := i == len(records)-1 - switch { - case rec.typ == dnsmessage.TypeTXT && len(rec.txt) > 1: - renderTXTRecord(p, rec, last) - case rec.hasComplexRendering(): - renderComplexRecord(p, rec, last) - default: - renderRecordLine(p, rec, last) - } - } - - p.WriteInfoPrefix() - p.WriteString("\n") -} - -func (rec record) hasComplexRendering() bool { - switch rec.typ { - case dnsmessage.TypeMX: - return rec.target != "" - case dnsmessage.TypeSRV: - return rec.target != "" - case dnsmessage.TypeSOA: - return rec.target != "" && rec.target2 != "" - case dnsTypeCAA: - _, _, _, ok := caaFields(rec.rawRData) - return ok - case dnsmessage.TypeSVCB, dnsmessage.TypeHTTPS: - return rec.target != "" - default: - return false - } -} - -// renderComplexRecord keeps the structured fields of complex resource records -// visible. The first line identifies the owner and target (when one exists), -// while the indented fields explain the numeric and type-specific values. -func renderComplexRecord(p *core.Printer, rec record, last bool) { - writeRecordPrefix(p, last) - p.Set(core.Green) - if rec.owner != "" { - p.WriteString(core.TerminalSafeText(rec.owner)) - if rec.typ == dnsmessage.TypeSVCB || rec.typ == dnsmessage.TypeHTTPS { - p.WriteString(" ") - } else if rec.typ != dnsmessage.TypeSOA && rec.typ != dnsTypeCAA { - p.WriteString(" → ") - } - } - switch rec.typ { - case dnsmessage.TypeMX: - p.WriteString(core.TerminalSafeText(rec.target)) - case dnsmessage.TypeSRV: - p.WriteString(core.TerminalSafeText(rec.target)) - p.WriteString(fmt.Sprintf(":%d", rec.port)) - case dnsmessage.TypeSVCB, dnsmessage.TypeHTTPS: - p.WriteString(fmt.Sprintf("priority %d → ", rec.priority)) - p.WriteString(core.TerminalSafeText(safeRecordText(rec.target))) - case dnsmessage.TypeSOA, dnsTypeCAA: - // These records list their semantic values on indented lines below. - } - p.Reset() - p.WriteString("\n") - - continued := !last - switch rec.typ { - case dnsmessage.TypeMX: - writeRecordDetail(p, "Priority", strconv.FormatUint(uint64(rec.preference), 10), continued) - case dnsmessage.TypeSRV: - writeRecordDetail(p, "Priority", strconv.FormatUint(uint64(rec.priority), 10), continued) - writeRecordDetail(p, "Weight", strconv.FormatUint(uint64(rec.weight), 10), continued) - case dnsmessage.TypeSOA: - writeRecordDetail(p, "Primary NS", rec.target, continued) - writeRecordDetail(p, "Responsible", rec.target2, continued) - writeRecordDetail(p, "Serial", strconv.FormatUint(uint64(rec.soa[0]), 10), continued) - writeRecordDetail(p, "Refresh", formatTTL(rec.soa[1]), continued) - writeRecordDetail(p, "Retry", formatTTL(rec.soa[2]), continued) - writeRecordDetail(p, "Expire", formatTTL(rec.soa[3]), continued) - writeRecordDetail(p, "Minimum TTL", formatTTL(rec.soa[4]), continued) - case dnsTypeCAA: - flags, tag, value, ok := caaFields(rec.rawRData) - if !ok { - renderRecordLine(p, rec, last) - return - } - writeRecordDetail(p, "Flags", strconv.Itoa(int(flags)), continued) - writeRecordDetail(p, "Tag", tag, continued) - writeRecordDetail(p, "Value", value, continued) - case dnsmessage.TypeSVCB, dnsmessage.TypeHTTPS: - renderServiceBindingDetails(p, rec, continued) - return - } - writeRecordSourceAndTTL(p, rec, continued) -} - -// renderServiceBindingDetails expands HTTPS/SVCB parameters into stable, -// human-readable fields. Parameter values stay bytes until this point so the -// renderer can distinguish valid values from malformed or unknown ones. -func renderServiceBindingDetails(p *core.Printer, rec record, continued bool) { - if rec.priority == 0 { - writeRecordDetail(p, "Mode", "AliasMode", continued) - } - - params := slices.Clone(rec.params) - slices.SortStableFunc(params, func(a, b resolver.SVCParam) int { - if order := cmp.Compare(svcParamRenderOrder(a.Key), svcParamRenderOrder(b.Key)); order != 0 { - return order - } - if order := cmp.Compare(a.Key, b.Key); order != 0 { - return order - } - return bytes.Compare(a.Value, b.Value) - }) - for _, param := range params { - label, value := formatStructuredSVCParam(param) - writeRecordDetail(p, label, value, continued) - } - - // A malformed generic RDATA must remain inspectable. Valid responses have - // already been decoded into Params, but a generic fixture or an unusual - // provider can still leave only raw bytes available. - if len(rec.rawRData) > 0 { - _, _, _, valid := parseRawSVCB(rec.rawRData) - if !valid { - writeRecordDetail(p, "Raw RDATA", "0x"+hex.EncodeToString(rec.rawRData), continued) - } - } - writeRecordSourceAndTTL(p, rec, continued) -} - -func writeRecordDetail(p *core.Printer, label, value string, continued bool) { - writeRecordContinuationPrefix(p, continued) - p.WriteString(label) - p.WriteString(": ") - p.WriteString(core.TerminalSafeText(safeRecordText(value))) - p.WriteString("\n") -} - -// writeRecordContinuationPrefix keeps detail lines connected to the record -// branch. Without the vertical continuation, the indentation looks like a -// large gap between the tree marker and the field text. -func writeRecordContinuationPrefix(p *core.Printer, continued bool) { - p.WriteInfoPrefix() - if continued { - p.WriteString(" \u2502 ") - return - } - p.WriteString(" ") -} - -func writeRecordSourceAndTTL(p *core.Printer, rec record, continued bool) { - if rec.source == recordSourcePlatform { - writeRecordDetail(p, "Source", "platform resolver", continued) - } - if rec.hasTTL { - writeRecordDetail(p, "TTL", formatTTL(rec.ttl), continued) - } else { - writeRecordDetail(p, "TTL", "unavailable", continued) - } -} - -func caaFields(raw []byte) (flags uint8, tag, value string, ok bool) { - if len(raw) < 2 { - return 0, "", "", false - } - tagLen := int(raw[1]) - if tagLen > len(raw)-2 { - return 0, "", "", false - } - return raw[0], string(raw[2 : 2+tagLen]), string(raw[2+tagLen:]), true -} - -func formatTXTChunk(chunk []byte) string { - // strconv.Quote escapes controls, invalid UTF-8, and quotes, so TXT data - // cannot inject terminal control sequences or output lines. - return strconv.Quote(string(chunk)) -} - -// renderTXTRecord renders each TXT character-string on its own line. This -// avoids making adjacent DNS character-strings look like one string with a -// synthetic space between their contents. -func renderTXTRecord(p *core.Printer, rec record, last bool) { - writeRecordPrefix(p, last) - p.Set(core.Green) - if rec.owner != "" { - p.WriteString(core.TerminalSafeText(rec.owner)) - } - p.Reset() - p.WriteString("\n") - - for _, chunk := range rec.txt { - writeRecordContinuationPrefix(p, !last) - p.Set(core.Green) - p.WriteString(formatTXTChunk(chunk)) - p.Reset() - p.WriteString("\n") - } - - writeRecordContinuationPrefix(p, !last) - p.Set(core.Dim) - if rec.source == recordSourcePlatform { - p.WriteString("Source: platform resolver; ") - } - if rec.hasTTL { - p.WriteString("TTL: ") - p.WriteString(formatTTL(rec.ttl)) - } else { - p.WriteString("TTL: unavailable") - } - p.Reset() - p.WriteString("\n") -} - -func renderRecordLine(p *core.Printer, rec record, last bool) { - writeRecordPrefix(p, last) - p.Set(core.Green) - if rec.owner != "" { - p.WriteString(core.TerminalSafeText(rec.owner)) - p.WriteString(" → ") - } - p.WriteString(core.TerminalSafeText(rec.renderValue())) - p.Reset() - p.WriteString(" ") - writeRecordMetadata(p, rec) - p.WriteString("\n") -} - -func writeRecordPrefix(p *core.Printer, last bool) { - p.WriteInfoPrefix() - if last { - p.WriteString(" \u2514\u2500 ") - } else { - p.WriteString(" \u251c\u2500 ") - } -} - -func writeRecordMetadata(p *core.Printer, rec record) { - p.Set(core.Dim) - p.WriteString("(") - if rec.source == recordSourcePlatform { - p.WriteString("platform resolver; ") - } - if rec.hasTTL { - p.WriteString("TTL ") - p.WriteString(formatTTL(rec.ttl)) - } else { - p.WriteString("TTL unavailable") - } - p.WriteString(")") - p.Reset() -} - -func recordCount(res *result) int { - var count int - for _, records := range res.records { - count += len(records) - } - return count -} - -func formatDuration(d time.Duration) string { - if d < time.Millisecond { - return d.Round(time.Microsecond).String() - } - return d.Round(100 * time.Microsecond).String() -} - -func formatTTL(ttl uint32) string { - if ttl == 0 { - return "0s" - } - - // DNS TTLs are seconds. Use compact whole-unit components so SOA - // durations such as expire=604800 are readable as 1w instead of 168h. - remaining := uint64(ttl) - units := []struct { - seconds uint64 - suffix string - }{ - {7 * 24 * 60 * 60, "w"}, - {24 * 60 * 60, "d"}, - {60 * 60, "h"}, - {60, "m"}, - {1, "s"}, - } - var b strings.Builder - for _, unit := range units { - if remaining < unit.seconds { - continue - } - count := remaining / unit.seconds - remaining %= unit.seconds - fmt.Fprintf(&b, "%d%s", count, unit.suffix) - } - return b.String() -} - func flushInspectionOutput(output, errorOutput *core.Printer) int { if err := output.Flush(); err != nil { if core.IsBrokenPipe(err) { diff --git a/internal/dnsinspect/dnsinspect_test.go b/internal/dnsinspect/dnsinspect_test.go index 88c87d8b..6af935c0 100644 --- a/internal/dnsinspect/dnsinspect_test.go +++ b/internal/dnsinspect/dnsinspect_test.go @@ -2,7 +2,6 @@ package dnsinspect import ( "context" - "encoding/hex" "errors" "io" "net" @@ -1081,753 +1080,6 @@ func TestSetSystemResolverDetailsReportsDirectQueryCaveats(t *testing.T) { } } -func TestRenderExtraVerboseIncludesResolverInternals(t *testing.T) { - p := core.TestPrinter(false) - render(p, &result{ - host: "example.com", - queryName: "example.com", - verbosity: core.VExtraVerbose, - configuredNameservers: []string{"192.0.2.53:53", "192.0.2.54:53"}, - resolverAttempts: 3, - resolverTimeout: 2 * time.Second, - resolverRotation: "enabled", - resolverConfiguration: "/etc/resolv.conf", - resolverRouting: "direct nameserver queries", - resolverSearchDomains: "not applied", - resolverOSRouting: "not applied by direct queries", - queries: []queryResult{{ - typ: inspectTypes[0], - status: queryStatusNoData, - responder: "192.0.2.54:53", - transport: resolver.TransportUDP, - duration: 4 * time.Millisecond, - attempts: 2, - }}, - records: map[string][]record{}, - }) - out := string(p.Bytes()) - for _, want := range []string{ - "System resolver", - "Query name: example.com", - "Configured nameservers: 192.0.2.53:53, 192.0.2.54:53", - "Resolver attempts: 3 per nameserver", - "Resolver timeout: 2s", - "Resolver rotation: enabled", - "Configuration: /etc/resolv.conf", - "Routing: direct nameserver queries", - "Search domains: not applied", - "OS resolver routing: not applied by direct queries", - "A: no data · UDP · 192.0.2.54:53 · 4ms · 2 attempts", - } { - if !strings.Contains(out, want) { - t.Fatalf("extra verbose output missing %q:\n%s", want, out) - } - } -} - -func TestRenderQueryDetailsIncludesResponderMetadata(t *testing.T) { - p := core.TestPrinter(false) - render(p, &result{ - 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", - transport: "UDP", - records: make(map[string][]record), - } - aggregate(res, []queryResult{{ - typ: inspectTypes[0], - records: []record{{typ: dnsmessage.TypeA, address: net.ParseIP("192.0.2.1")}}, - tcpFallback: true, - }}, time.Now()) - - p := core.TestPrinter(false) - render(p, res) - out := string(p.Bytes()) - if !strings.Contains(out, "Transport: UDP → TCP fallback") { - t.Fatalf("fallback transport metadata missing:\n%s", out) - } - if strings.Contains(out, "warning:") || strings.Contains(out, "truncated") { - t.Fatalf("fallback was rendered as a warning:\n%s", out) - } -} - -func TestAggregateFailedTCPFallbackRemainsFailure(t *testing.T) { - res := &result{transport: "UDP", records: make(map[string][]record)} - if err := aggregate(res, []queryResult{{ - typ: inspectTypes[3], - err: errors.New("DNS TCP fallback: connection refused"), - tcpFallback: true, - }}, time.Now()); err == nil { - t.Fatal("aggregate() error = nil, want failed TCP retry error") - } - if len(res.failures) != 1 || res.queries[0].status != queryStatusFailed { - t.Fatalf("failed fallback result = %#v, want one failed query", res) - } - - p := core.TestPrinter(false) - render(p, res) - out := string(p.Bytes()) - if !strings.Contains(out, "Status: incomplete — 1 of 1 queries failed") || !strings.Contains(out, "DNS TCP fallback: connection refused") { - t.Fatalf("failed fallback was not rendered as a failure:\n%s", out) - } -} - -func TestRenderTCPFallbackDetailsAtExtraVerbose(t *testing.T) { - p := core.TestPrinter(false) - render(p, &result{ - host: "example.com", - transport: "UDP", - verbosity: core.VExtraVerbose, - queries: []queryResult{ - {typ: inspectTypes[0], status: queryStatusData, records: []record{{typ: dnsmessage.TypeA}}, tcpFallback: true}, - {typ: inspectTypes[3], status: queryStatusNoData, tcpFallback: true}, - }, - records: map[string][]record{}, - }) - - out := string(p.Bytes()) - for _, want := range []string{ - "Queries\n", - "A: 1 record · UDP → TCP fallback", - "TXT: no data · UDP → TCP fallback", - } { - if !strings.Contains(out, want) { - t.Fatalf("verbose fallback details missing %q:\n%s", want, out) - } - } -} - -func TestRenderWithoutTCPFallbackKeepsTransportUnchanged(t *testing.T) { - p := core.TestPrinter(false) - render(p, &result{ - host: "example.com", - transport: "UDP", - records: map[string][]record{}, - }) - - out := string(p.Bytes()) - if !strings.Contains(out, "Transport: UDP\n") { - t.Fatalf("transport changed without fallback:\n%s", out) - } - if strings.Contains(out, "TCP fallback") { - t.Fatalf("output mentions fallback when none was used:\n%s", out) - } -} - -func TestRenderStructuredLookupOmitsUnavailableFields(t *testing.T) { - p := core.TestPrinter(false) - render(p, &result{host: "example.com", records: map[string][]record{}}) - - out := string(p.Bytes()) - for _, want := range []string{ - "Lookup\n", - "Name: example.com", - "Status: complete", - "Results: 0 addresses · 0 records · 0 record types", - "Records\n", - } { - if !strings.Contains(out, want) { - t.Fatalf("structured output missing %q:\n%s", want, out) - } - } - for _, unwanted := range []string{"Resolver:", "Transport:", "Source:", "Queries:", "Timing:"} { - if strings.Contains(out, unwanted) { - t.Fatalf("structured output contains empty field %q:\n%s", unwanted, out) - } - } -} - -func TestRenderStructuredLookupShowsNormalizedQueryName(t *testing.T) { - p := core.TestPrinter(false) - render(p, &result{ - host: "münich.example", - queryName: "xn--mnich-kva.example", - records: map[string][]record{}, - }) - - if out := string(p.Bytes()); !strings.Contains(out, "Query name: xn--mnich-kva.example") { - t.Fatalf("structured output missing normalized query name:\n%s", out) - } -} - -func TestRenderStructuredLookupUsesSingularGrammar(t *testing.T) { - p := core.TestPrinter(false) - render(p, &result{ - host: "example.com", - resolver: "192.0.2.53:53", - transport: "UDP", - security: string(resolver.SecurityPlaintext), - source: "system resolver configuration", - queryTotal: 1, - queryWithData: 1, - duration: time.Millisecond, - records: map[string][]record{ - "A": {{typ: dnsmessage.TypeA, address: net.ParseIP("192.0.2.1"), hasTTL: true, ttl: 60}}, - }, - }) - - out := string(p.Bytes()) - for _, want := range []string{ - "Results: 1 address · 1 record · 1 record type", - "Queries: 1 total · 1 with data · 0 no data", - } { - if !strings.Contains(out, want) { - t.Fatalf("structured output missing %q:\n%s", want, out) - } - } -} - -func TestRenderShowsRecordOwner(t *testing.T) { - p := core.TestPrinter(false) - render(p, &result{ - host: "www.example.com", - records: map[string][]record{ - "CNAME": {{owner: "www.example.com.", typ: dnsmessage.TypeCNAME, target: "cdn.example.net.", ttl: 300, hasTTL: true}}, - }, - }) - - if out := string(p.Bytes()); !strings.Contains(out, "www.example.com. → cdn.example.net. (TTL 5m)") { - t.Fatalf("output missing record owner and target:\n%s", out) - } -} - -func TestRenderShowsUnavailableTTLPerRecord(t *testing.T) { - p := core.TestPrinter(false) - render(p, &result{ - host: "example.com", - resolver: "system", - records: map[string][]record{ - "A": {{typ: dnsmessage.TypeA, address: net.ParseIP("192.0.2.1"), ttl: 60, hasTTL: true}}, - }, - }) - - out := string(p.Bytes()) - if !strings.Contains(out, "\u2514\u2500 192.0.2.1 (TTL 1m)") { - t.Fatalf("output missing tree-formatted TTL:\n%s", out) - } -} - -func TestRenderShowsPlatformSourceAndUnavailableTTLOnRecord(t *testing.T) { - p := core.TestPrinter(false) - render(p, &result{ - host: "printer.local", - records: map[string][]record{ - "A": {{ - owner: "printer.local.", - typ: dnsmessage.TypeA, - address: net.ParseIP("192.0.2.1"), - source: recordSourcePlatform, - }}, - }, - }) - - if out := string(p.Bytes()); !strings.Contains(out, "printer.local. → 192.0.2.1 (platform resolver; TTL unavailable)") { - t.Fatalf("output missing platform provenance and unavailable TTL:\n%s", out) - } -} - -func TestRenderMixedResolverSummaryAndPerRecordProvenance(t *testing.T) { - p := core.TestPrinter(false) - render(p, &result{ - host: "printer.local", - resolver: "system nameservers + platform resolver", - transport: "mixed", - security: "mixed", - source: "system resolver configuration + platform resolver", - platformFallback: true, - records: map[string][]record{ - "A": {{ - owner: "printer.local.", - typ: dnsmessage.TypeA, - address: net.ParseIP("192.0.2.1"), - source: recordSourcePlatform, - }}, - "TXT": {{ - owner: "printer.local.", - typ: dnsmessage.TypeTXT, - txt: [][]byte{[]byte("device=printer")}, - ttl: 120, - hasTTL: true, - source: recordSourceDNS, - }}, - }, - }) - - out := string(p.Bytes()) - for _, want := range []string{ - "Resolver: system nameservers + platform resolver", - "Transport: mixed", - "Transport security: mixed", - "Fallback: platform resolver used for addresses", - "192.0.2.1 (platform resolver; TTL unavailable)", - `"device=printer" (TTL 2m)`, - } { - if !strings.Contains(out, want) { - t.Fatalf("mixed output missing %q:\n%s", want, out) - } - } - if strings.Contains(out, "device=printer (platform resolver") { - t.Fatalf("direct record incorrectly marked as platform data:\n%s", out) - } -} - -func TestRenderSortsRecordsWithinType(t *testing.T) { - p := core.TestPrinter(false) - render(p, &result{ - host: "example.com", - resolver: "system", - records: map[string][]record{ - "A": { - {typ: dnsmessage.TypeA, address: net.ParseIP("192.0.2.20"), ttl: 60, hasTTL: true}, - {typ: dnsmessage.TypeA, address: net.ParseIP("192.0.2.10"), ttl: 60, hasTTL: true}, - }, - }, - }) - - out := string(p.Bytes()) - first := strings.Index(out, "192.0.2.10") - second := strings.Index(out, "192.0.2.20") - if first == -1 || second == -1 || first > second { - t.Fatalf("records not sorted within type:\n%s", out) - } -} - -func TestFormatTTLTrimsZeroUnits(t *testing.T) { - tests := map[int]string{ - 1: "1s", - 60: "1m", - 300: "5m", - 3600: "1h", - 3660: "1h1m", - } - for ttl, want := range tests { - if got := formatTTL(uint32(ttl)); got != want { - t.Fatalf("formatTTL(%d) = %q, want %q", ttl, got, want) - } - } -} - -func TestFormatCAA(t *testing.T) { - raw := append([]byte{0, 5}, []byte("issueletsencrypt.org")...) - if got, want := formatCAA(raw), `0 issue "letsencrypt.org"`; got != want { - t.Fatalf("formatCAA = %q, want %q", got, want) - } -} - -func TestRecordFromWirePreservesTypedDNSData(t *testing.T) { - owner, err := resolver.ParseName("example.com.") - if err != nil { - t.Fatal(err) - } - target, err := resolver.ParseName("service.example.net.") - if err != nil { - t.Fatal(err) - } - mailbox, err := resolver.ParseName("hostmaster.example.com.") - if err != nil { - t.Fatal(err) - } - - input := resolver.Record{ - Owner: owner, - Type: uint16(dnsmessage.TypeSOA), - TTL: 300, - TTLPresent: true, - Target: &target, - Target2: &mailbox, - Preference: 10, - Priority: 20, - Weight: 30, - Port: 443, - SOAValues: [5]uint32{1, 2, 3, 4, 5}, - TXT: [][]byte{[]byte("first"), []byte("second")}, - Params: []resolver.SVCParam{{Key: uint16(dnsmessage.SVCParamALPN), Value: []byte{2, 'h', '2'}}}, - RData: []byte{0xde, 0xad}, - } - rec, ok := recordFromWire(input) - if !ok { - t.Fatal("recordFromWire() rejected a valid record") - } - input.TXT[0][0] = 'X' - input.Params[0].Value[1] = 'X' - input.RData[0] = 0 - - if rec.typ != dnsmessage.TypeSOA || rec.owner != "example.com." || rec.target != "service.example.net." || rec.target2 != "hostmaster.example.com." { - t.Fatalf("record identity and targets were not preserved: %#v", rec) - } - if rec.preference != 10 || rec.priority != 20 || rec.weight != 30 || rec.port != 443 || rec.soa != [5]uint32{1, 2, 3, 4, 5} { - t.Fatalf("numeric DNS fields were not preserved: %#v", rec) - } - if got := string(rec.txt[0]); got != "first" { - t.Fatalf("TXT data aliases resolver storage: %q", got) - } - if got := string(rec.params[0].Value); got != "\x02h2" { - t.Fatalf("SVCB parameter aliases resolver storage: %q", got) - } - if got := hex.EncodeToString(rec.rawRData); got != "dead" { - t.Fatalf("raw RDATA aliases resolver storage: %q", got) - } -} - -func TestWireNameTargetsRemainEscapedInTypedRecords(t *testing.T) { - target, err := resolver.ParseName(`bad\010dot\046slash\092.example.`) - if err != nil { - t.Fatal(err) - } - wire, err := target.Wire() - if err != nil { - t.Fatal(err) - } - mailbox, err := resolver.ParseName("hostmaster.example.") - if err != nil { - t.Fatal(err) - } - mailboxWire, err := mailbox.Wire() - if err != nil { - t.Fatal(err) - } - - records := []resolver.Record{ - {Type: uint16(dnsmessage.TypeNS), Target: &target, RData: wire}, - {Type: uint16(dnsmessage.TypeMX), Target: &target, RData: append([]byte{0, 10}, wire...)}, - {Type: uint16(dnsmessage.TypeSOA), Target: &target, Target2: &mailbox, RData: append(append(append([]byte(nil), wire...), mailboxWire...), make([]byte, 20)...)}, - {Type: uint16(dnsmessage.TypeSRV), Target: &target, RData: append(make([]byte, 6), wire...)}, - } - for _, input := range records { - rec, ok := recordFromWire(input) - if !ok { - t.Fatalf("recordFromWire() rejected type %d", input.Type) - } - if rec.target != target.String() { - t.Errorf("type %d target = %q, want escaped %q", input.Type, rec.target, target.String()) - } - if strings.ContainsAny(rec.target, "\n\r") { - t.Errorf("type %d target contains a raw line break: %q", input.Type, rec.target) - } - } -} - -func TestRenderUsesTypedDNSRecordData(t *testing.T) { - p := core.TestPrinter(false) - records := map[string][]record{ - "A": {{typ: dnsmessage.TypeA, address: net.ParseIP("192.0.2.1")}}, - "AAAA": {{typ: dnsmessage.TypeAAAA, address: net.ParseIP("2001:db8::1")}}, - "CNAME": {{typ: dnsmessage.TypeCNAME, target: "alias.example."}}, - "TXT": {{typ: dnsmessage.TypeTXT, txt: [][]byte{[]byte("first"), []byte("second")}}}, - "MX": {{typ: dnsmessage.TypeMX, preference: 10, target: "mail.example."}}, - "NS": {{typ: dnsmessage.TypeNS, target: "ns1.example."}}, - "SOA": {{ - typ: dnsmessage.TypeSOA, target: "ns1.example.", target2: "hostmaster.example.", - soa: [5]uint32{2026082901, 3600, 600, 604800, 300}, - }}, - "SRV": {{typ: dnsmessage.TypeSRV, priority: 10, weight: 5, port: 443, target: "service.example."}}, - "CAA": {{typ: dnsTypeCAA, rawRData: append([]byte{0, 5}, []byte("issueletsencrypt.org")...)}}, - "SVCB": {{typ: dnsmessage.TypeSVCB, priority: 0, target: "."}}, - "HTTPS": {{ - typ: dnsmessage.TypeHTTPS, priority: 1, target: ".", - params: []resolver.SVCParam{{Key: uint16(dnsmessage.SVCParamALPN), Value: []byte{2, 'h', '2'}}}, - }}, - "TYPE99": {{typ: dnsmessage.Type(99), rawRData: []byte{0xde, 0xad}}}, - } - render(p, &result{host: "example.com", records: records}) - out := string(p.Bytes()) - for _, want := range []string{ - "192.0.2.1", "2001:db8::1", "alias.example.", "\"first\"\n", "\"second\"\n", - "Priority: 10", "mail.example.", "ns1.example.", - "Primary NS: ns1.example.", "Responsible: hostmaster.example.", - "Serial: 2026082901", "Refresh: 1h", "Retry: 10m", "Expire: 1w", "Minimum TTL: 5m", - "Weight: 5", "service.example.:443", - "Flags: 0", "Tag: issue", "Value: letsencrypt.org", "priority 0 → .", "priority 1 → .", "ALPN: h2", - "TYPE99", "0xdead", - } { - if !strings.Contains(out, want) { - t.Fatalf("typed record output missing %q:\n%s", want, out) - } - } -} - -func TestRenderComplexRecordsUseLabeledFields(t *testing.T) { - p := core.TestPrinter(false) - rawCAA := append([]byte{1, 5}, []byte("issueacme.org")...) - render(p, &result{host: "example.com", records: map[string][]record{ - "MX": {{ - owner: "example.com.", typ: dnsmessage.TypeMX, preference: 10, - target: "mail.example.com.", ttl: 3600, hasTTL: true, - }}, - "SRV": {{ - owner: "_https._tcp.example.com.", typ: dnsmessage.TypeSRV, - priority: 20, weight: 5, port: 443, target: "service.example.com.", - ttl: 300, hasTTL: true, - }}, - "SOA": {{ - owner: "example.com.", typ: dnsmessage.TypeSOA, - target: "ns1.example.com.", target2: "hostmaster.example.com.", - soa: [5]uint32{2026082901, 3600, 600, 604800, 300}, ttl: 3600, hasTTL: true, - }}, - "CAA": {{ - owner: "example.com.", typ: dnsTypeCAA, rawRData: rawCAA, - ttl: 3600, hasTTL: true, - }}, - }}) - - out := string(p.Bytes()) - for _, want := range []string{ - "example.com. → mail.example.com.", " Priority: 10", " TTL: 1h", - "_https._tcp.example.com. → service.example.com.:443", " Weight: 5", - "example.com.\n", "Primary NS: ns1.example.com.", "Responsible: hostmaster.example.com.", - "Serial: 2026082901", "Refresh: 1h", "Retry: 10m", "Expire: 1w", "Minimum TTL: 5m", - "Flags: 1", "Tag: issue", "Value: acme.org", - } { - if !strings.Contains(out, want) { - t.Fatalf("complex record output missing %q:\n%s", want, out) - } - } -} - -func TestRenderComplexRecordTreeContinuationStopsAtLastRecord(t *testing.T) { - p := core.TestPrinter(false) - render(p, &result{host: "example.com", records: map[string][]record{ - "MX": { - {typ: dnsmessage.TypeMX, preference: 2, target: "first.example."}, - {typ: dnsmessage.TypeMX, preference: 10, target: "last.example."}, - }, - }}) - - out := string(p.Bytes()) - if !strings.Contains(out, " │ Priority: 2") { - t.Fatalf("non-final record details lost tree continuation:\n%s", out) - } - if strings.Contains(out, " │ Priority: 10") || !strings.Contains(out, " Priority: 10") { - t.Fatalf("final record details retained tree continuation:\n%s", out) - } -} - -func TestAggregateKeepsDistinctDOHPresentationRecords(t *testing.T) { - out := &result{records: make(map[string][]record)} - results := []queryResult{ - {typ: inspectTypes[4], records: []record{ - {typ: dnsmessage.TypeMX, presentation: "10 first.example."}, - {typ: dnsmessage.TypeMX, presentation: "20 second.example."}, - }}, - {typ: inspectTypes[6], records: []record{ - {typ: dnsmessage.TypeSOA, presentation: "ns1.example. hostmaster.example. 1 2 3 4 5"}, - {typ: dnsmessage.TypeSOA, presentation: "ns2.example. hostmaster.example. 2 3 4 5 6"}, - }}, - {typ: inspectTypes[7], records: []record{ - {typ: dnsmessage.TypeSRV, presentation: "10 5 443 first.example."}, - {typ: dnsmessage.TypeSRV, presentation: "20 5 443 second.example."}, - }}, - } - aggregate(out, results, time.Now()) - for _, typ := range []string{"MX", "SOA", "SRV"} { - if got := len(out.records[typ]); got != 2 { - t.Fatalf("%s records = %d, want 2 distinct DoH records: %#v", typ, got, out.records[typ]) - } - } -} - -func TestRenderTXTChunksAreQuotedAndCannotInjectLines(t *testing.T) { - p := core.TestPrinter(false) - render(p, &result{host: "example.com", records: map[string][]record{ - "TXT": {{typ: dnsmessage.TypeTXT, txt: [][]byte{[]byte("line\nnext"), {0x1b, '[', 'A'}}}}, - }}) - out := string(p.Bytes()) - for _, want := range []string{"\"line\\nnext\"\n", "\"\\x1b[A\"\n"} { - if !strings.Contains(out, want) { - t.Fatalf("TXT chunk was not safely quoted: missing %q\n%s", want, out) - } - } - if strings.Contains(out, `"line\nnext" "\x1b[A"`) { - t.Fatalf("TXT chunks were rendered as one space-joined value: %q", out) - } - if strings.Contains(out, "line\nnext") || strings.ContainsRune(out, '\x1b') { - t.Fatalf("TXT data injected terminal layout or controls: %q", out) - } -} - -func TestRecordFromDOHRejectsMissingTXTDataButKeepsEmptyChunk(t *testing.T) { - owner, err := resolver.ParseName("example.com.") - if err != nil { - t.Fatal(err) - } - answer := resolver.DOHRecord{Record: resolver.Record{Owner: owner, Type: uint16(dnsmessage.TypeTXT)}} - if _, ok := recordFromDOH(answer); ok { - t.Fatal("recordFromDOH() accepted a TXT answer with missing data") - } - answer.Data = `""` - rec, ok := recordFromDOH(answer) - if !ok || len(rec.txt) != 1 || len(rec.txt[0]) != 0 { - t.Fatalf("empty TXT chunk was not preserved: %#v, %t", rec, ok) - } -} - -func TestDOHCAANumericTagIsParsedSemantically(t *testing.T) { - rec := semanticRecord(resolver.Record{Type: uint16(dnsTypeCAA)}, `0 0 "value"`, false) - if got := formatCAA(rec.rawRData); got != `0 0 "value"` { - t.Fatalf("numeric CAA tag was not parsed: %q", got) - } -} - -func TestRenderEscapesCAAAndSVCBFields(t *testing.T) { - p := core.TestPrinter(false) - render(p, &result{host: "example.com", records: map[string][]record{ - "CAA": {{typ: dnsTypeCAA, rawRData: append([]byte{0, 8}, []byte("bad\nnamevalue")...)}}, - "HTTPS": {{ - typ: dnsmessage.TypeHTTPS, priority: 1, target: ".", - params: []resolver.SVCParam{{Key: uint16(dnsmessage.SVCParamALPN), Value: []byte{8, 'b', 'a', 'd', '\n', 'n', 'a', 'm', 'e'}}}, - }}, - }}) - out := string(p.Bytes()) - for _, want := range []string{`"bad\nname"`, `ALPN: "bad\nname"`} { - if !strings.Contains(out, want) { - t.Fatalf("record field was not escaped as %q:\n%s", want, out) - } - } - if strings.Contains(out, "bad\nname") { - t.Fatalf("record field injected an output line: %q", out) - } -} - -func TestRenderHTTPSExpandsServiceBindingParameters(t *testing.T) { - p := core.TestPrinter(false) - render(p, &result{host: "example.com", records: map[string][]record{ - "HTTPS": {{ - owner: "example.com.", typ: dnsmessage.TypeHTTPS, priority: 1, target: ".", - params: []resolver.SVCParam{ - {Key: uint16(dnsmessage.SVCParamMandatory), Value: []byte{0, 1, 0, 4}}, - {Key: uint16(dnsmessage.SVCParamDOHPath), Value: []byte("/dns-query{?dns}")}, - {Key: uint16(dnsmessage.SVCParamECH), Value: []byte{1, 2, 3}}, - {Key: uint16(dnsmessage.SVCParamIPv6Hint), Value: net.ParseIP("2001:db8::1")}, - {Key: uint16(dnsmessage.SVCParamPort), Value: []byte{1, 0xbb}}, - {Key: uint16(dnsmessage.SVCParamIPv4Hint), Value: []byte{192, 0, 2, 1, 192, 0, 2, 2}}, - {Key: uint16(dnsmessage.SVCParamALPN), Value: []byte{2, 'h', '2', 2, 'h', '3'}}, - {Key: uint16(dnsmessage.SVCParamOHTTP)}, - {Key: uint16(dnsmessage.SVCParamTLSSupportedGroups), Value: []byte{0, 23, 0, 29}}, - {Key: 10, Value: []byte{0xde, 0xad}}, - }, - ttl: 300, hasTTL: true, - }}, - }}) - - out := string(p.Bytes()) - for _, want := range []string{ - "example.com. priority 1 → .", - "Mandatory: alpn, ipv4hint", - "ALPN: h2, h3", - "Port: 443", - "IPv4 hints: 192.0.2.1, 192.0.2.2", - "IPv6 hints: 2001:db8::1", - "ECH: AQID", - "DoH path: /dns-query{?dns}", - "OHTTP: true", - "TLS supported groups: 23, 29", - "key10: 0xdead", - "TTL: 5m", - } { - if !strings.Contains(out, want) { - t.Fatalf("HTTPS output missing %q:\n%s", want, out) - } - } - if strings.Contains(out, "ALPN=h2") || strings.Contains(out, "IPv4Hint=") { - t.Fatalf("HTTPS parameters were flattened:\n%s", out) - } -} - -func TestRenderSVCBAliasModeAndMalformedParameters(t *testing.T) { - p := core.TestPrinter(false) - render(p, &result{host: "example.com", records: map[string][]record{ - "SVCB": {{ - owner: "example.com.", typ: dnsmessage.TypeSVCB, priority: 0, target: ".", - }}, - "HTTPS": {{ - owner: "example.com.", typ: dnsmessage.TypeHTTPS, priority: 1, target: ".", - params: []resolver.SVCParam{{Key: uint16(dnsmessage.SVCParamALPN), Value: []byte{3, 'h', '2'}}}, - }}, - }}) - - out := string(p.Bytes()) - for _, want := range []string{ - "example.com. priority 0 → .", - "Mode: AliasMode", - "ALPN: 0x036832", - } { - if !strings.Contains(out, want) { - t.Fatalf("SVCB output missing %q:\n%s", want, out) - } - } -} - -func TestRenderSVCBUnknownRawDataWhenMalformed(t *testing.T) { - p := core.TestPrinter(false) - render(p, &result{host: "example.com", records: map[string][]record{ - "HTTPS": {{ - owner: "example.com.", typ: dnsmessage.TypeHTTPS, priority: 1, target: ".", - rawRData: []byte{0, 1, 1, 'x', 0, 9, 0, 4, 0xde}, - }}, - }}) - if out := string(p.Bytes()); !strings.Contains(out, "Raw RDATA: 0x0001017800090004de") { - t.Fatalf("malformed HTTPS RDATA was not retained:\n%s", out) - } -} - -func TestRenderSortsTypedNumericFieldsNumerically(t *testing.T) { - p := core.TestPrinter(false) - render(p, &result{host: "example.com", records: map[string][]record{ - "MX": { - {typ: dnsmessage.TypeMX, preference: 10, target: "ten.example."}, - {typ: dnsmessage.TypeMX, preference: 2, target: "two.example."}, - }, - }}) - out := string(p.Bytes()) - two := strings.Index(out, "two.example.") - ten := strings.Index(out, "ten.example.") - if two < 0 || ten < 0 || two > ten { - t.Fatalf("MX records are not sorted by numeric preference:\n%s", out) - } -} - -func TestNormalizeDOHHTTPSGenericRDATA(t *testing.T) { - got := normalizeDOHValue(dnsmessage.TypeHTTPS, `\# 24 000100000100030268330003000201bb00040004c0000201`) - for _, want := range []string{ - "1 .", - "ALPN=h3", - "Port=443", - "IPv4Hint=192.0.2.1", - } { - if !strings.Contains(got, want) { - t.Fatalf("decoded HTTPS value missing %q: %q", want, got) - } - } -} - -func TestNormalizeDOHCAAGenericRDATA(t *testing.T) { - got := normalizeDOHValue(dnsTypeCAA, `\# 22 000569737375656c657473656e63727970742e6f7267`) - if want := `0 issue "letsencrypt.org"`; got != want { - t.Fatalf("decoded CAA = %q, want %q", got, want) - } -} - func TestAggregatePreservesEveryQueryStatus(t *testing.T) { out := &result{records: make(map[string][]record)} results := []queryResult{ diff --git a/internal/dnsinspect/lookup.go b/internal/dnsinspect/lookup.go new file mode 100644 index 00000000..b8dd9482 --- /dev/null +++ b/internal/dnsinspect/lookup.go @@ -0,0 +1,637 @@ +package dnsinspect + +import ( + "context" + "errors" + "fmt" + "net" + "slices" + "strconv" + "strings" + "sync" + "time" + "unicode/utf8" + + "github.com/ryanfowler/fetch/internal/client" + "github.com/ryanfowler/fetch/internal/resolver" + + "golang.org/x/net/dns/dnsmessage" + "golang.org/x/net/idna" +) + +func lookup(ctx context.Context, cfg *Config, host string, start time.Time) (*result, error) { + server := cfg.DNSServer + if cfg.Endpoint != nil { + server = cfg.Endpoint.URL() + } + target := resolverTarget(server) + out := &result{ + host: host, + resolver: target.label, + transport: inspectionTransport(cfg, server), + security: resolverTransportSecurity(cfg, server), + source: inspectionSource(server), + records: make(map[string][]record), + verbosity: cfg.Verbosity, + } + if cfg.Endpoint != nil { + out.resolverBootstrap = endpointBootstrapDescription(cfg.Endpoint) + } + + // A missing --dns-server prefers the resolv.conf nameservers, which expose + // every record type and per-record TTLs. The platform API is only the + // 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 { + setSystemResolverDetails(out, *policy) + ordered := resolver.RotateSystemResolverPolicy(*policy) + systemPolicy = &ordered + target = resolverTargetInfo{label: ordered.Nameservers[0], udpAddr: ordered.Nameservers[0]} + out.resolver = target.label + out.transport = "UDP" + out.security = string(resolver.SecurityPlaintext) + out.source = "system resolver configuration" + } else { + systemPolicy = nil + target = resolverTargetInfo{label: "system resolver", useDefault: true} + out.resolver = "platform resolver" + out.transport = "platform resolver" + out.security = "platform resolver (OS-managed security)" + out.source = "platform resolver" + } + } + + 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 { + return platformLookup(ctx, out, host, start) + } + + var streamClient *resolver.StreamClient + var doqClient *resolver.DoQClient + var dohClient *resolver.DOHClient + var err error + if cfg.Endpoint != nil && (cfg.Endpoint.Transport == resolver.TransportTCP || cfg.Endpoint.Transport == resolver.TransportTLS) { + streamClient, err = resolver.NewStreamClient(ctx, resolver.StreamConfig{ + Endpoint: cfg.Endpoint, + TLSConfig: cfg.TLSConfig, + CACerts: cfg.CACerts, + ClientCert: cfg.ClientCert, + Insecure: cfg.Insecure, + TLSMin: cfg.TLSMin, + TLSMax: cfg.TLSMax, + }) + if err != nil { + return nil, fmt.Errorf("connect to resolver: %w", err) + } + defer streamClient.Close() + } + if cfg.Endpoint != nil && cfg.Endpoint.Transport == resolver.TransportQUIC { + doqClient, err = resolver.NewDoQClient(ctx, resolver.DoQConfig{ + Endpoint: cfg.Endpoint, + TLSConfig: cfg.TLSConfig, + CACerts: cfg.CACerts, + ClientCert: cfg.ClientCert, + Insecure: cfg.Insecure, + TLSMin: cfg.TLSMin, + TLSMax: cfg.TLSMax, + }) + if err != nil { + return nil, fmt.Errorf("connect to resolver: %w", err) + } + defer doqClient.Close() + } + if server != nil && server.Scheme != "" && streamClient == nil && doqClient == nil { + proxy := client.ProxyFunc(cfg.Proxy) + dohClient, err = resolver.NewDOHClient(resolver.DOHConfig{ + Endpoint: cfg.Endpoint, + ServerURL: server, + Proxy: proxy, + TLSConfig: cfg.TLSConfig, + CACerts: cfg.CACerts, + ClientCert: cfg.ClientCert, + Insecure: cfg.Insecure, + TLSMin: cfg.TLSMin, + TLSMax: cfg.TLSMax, + Timeout: cfg.Timeout, + }) + if err != nil { + return nil, fmt.Errorf("connect to resolver: %w", err) + } + defer dohClient.Close() + } + + queryHost, err := dnsQueryHost(host) + if err != nil { + return nil, fmt.Errorf("normalize hostname %s: %w", host, err) + } + out.queryName = queryHost + queryCtx, cancelQuery := contextForDirectLookup(ctx, systemPolicy != nil) + defer cancelQuery() + queryTransport := resolver.TransportUDP + if cfg.Endpoint != nil { + queryTransport = cfg.Endpoint.Transport + } else if server != nil { + queryTransport = resolverURLTransport(server) + } + queryResponder := target.label + if target.udpAddr != "" { + queryResponder = target.udpAddr + } + results := runFanOut(queryCtx, queryHost, target, systemPolicy, queryTransport, queryResponder, streamClient, doqClient, dohClient) + firstResult := aggregate(out, results, start) + if systemPolicy != nil { + setSystemResponderSummary(out) + } + + // 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 || out.queryTotal > 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, queryTransport resolver.Transport, queryResponder string, streamClient *resolver.StreamClient, doqClient *resolver.DoQClient, dohClient *resolver.DOHClient) []queryResult { + results := make([]queryResult, len(inspectTypes)) + var wg sync.WaitGroup + for i, qt := range inspectTypes { + wg.Add(1) + go func(i int, qt queryType) { + defer wg.Done() + queryStart := time.Now() + results[i].typ = qt + if systemPolicy == nil { + // Explicit resolver backends do not return QueryMetadata, but their + // transport is known before the query starts. Set the responder only + // after a query succeeds; an endpoint is not proof that it answered. + results[i].transport = queryTransport + } + switch { + case systemPolicy != nil: + var metadata resolver.QueryMetadata + 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: + results[i].records, results[i].err = lookupDoQRecords(ctx, doqClient, host, qt) + case dohClient != nil: + results[i].records, results[i].err = lookupDOHRecordsWithClient(ctx, dohClient, host, qt) + default: + results[i].records, results[i].tcpFallback, results[i].err = lookupUDPRecordsWithFallback(ctx, target.udpAddr, host, qt) + } + if systemPolicy == nil && results[i].err == nil { + results[i].responder = queryResponder + } + // System-nameserver queries expose resolver metadata that includes + // failover and retry time. The other backends do not, so measure + // their query operation here. This starts after shared resolver + // setup, which prevents bootstrap/connect time from being charged + // to every concurrently issued query. + if results[i].duration <= 0 { + results[i].duration = time.Since(queryStart) + } + }(i, qt) + } + wg.Wait() + return results +} + +// lookupSystemRecords resolves host for one record type through the system +// 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, metadata, err := resolver.QuerySystemTypeDetailed(ctx, inspectionPolicy, host, uint16(qt.dnsType)) + if err != nil { + 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, 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 +// 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) + out.queryTotal = len(results) + out.queries = make([]queryResult, 0, len(results)) + for _, query := range results { + query.status = classifyQuery(query) + out.queries = append(out.queries, query) + out.tcpFallback = out.tcpFallback || query.tcpFallback + switch query.status { + case queryStatusFailed: + out.failures = append(out.failures, queryFailure{label: query.typ.label, err: query.err}) + if firstResult == nil { + firstResult = query.err + } + case queryStatusNoData: + out.queryNoData++ + case queryStatusData: + out.queryWithData++ + } + for _, rec := range query.records { + label := typeLabel(rec.typ) + key := canonicalOwnerKey(rec.owner) + "\x00" + strconv.Itoa(int(rec.typ)) + "\x00" + rec.semanticKey() + if idx, ok := seen[key]; ok { + records := out.records[label] + existing := &records[idx] + switch { + case rec.hasTTL && !existing.hasTTL: + existing.ttl = rec.ttl + existing.hasTTL = true + case rec.hasTTL && existing.hasTTL && rec.ttl < existing.ttl: + existing.ttl = rec.ttl + } + continue + } + seen[key] = len(out.records[label]) + out.records[label] = append(out.records[label], rec) + } + } + out.duration = time.Since(start) + return firstResult +} + +func canonicalOwnerKey(owner string) string { + return strings.ToLower(owner) +} + +func classifyQuery(query queryResult) queryStatus { + if query.err != nil { + if errors.Is(query.err, resolver.ErrDNSNoData) { + return queryStatusNoData + } + return queryStatusFailed + } + if len(query.records) == 0 { + return queryStatusNoData + } + return queryStatusData +} + +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) + } + for _, rec := range records { + label := typeLabel(rec.typ) + out.records[label] = append(out.records[label], rec) + } + 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 + } + + // DNS wire names are absolute. Keep the trailing root label in the + // inspection result so it describes the name sent to a raw resolver. The + // resolver parser also enforces the DNS label and total-name size limits + // after IDNA expansion. + queryName := strings.Join(labels, ".") + "." + if _, err := resolver.ParseName(queryName); err != nil { + return "", err + } + return queryName, 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, + queryName: orig.queryName, + resolver: "system nameservers + platform resolver", + transport: "mixed", + security: "mixed", + source: "system resolver configuration + platform resolver", + responders: append(slices.Clone(orig.responders), "platform resolver"), + records: make(map[string][]record, len(orig.records)), + queries: slices.Clone(orig.queries), + failures: slices.Clone(orig.failures), + queryTotal: orig.queryTotal, + queryWithData: orig.queryWithData, + queryNoData: orig.queryNoData, + tcpFallback: orig.tcpFallback, + platformFallback: true, + verbosity: orig.verbosity, + configuredNameservers: slices.Clone(orig.configuredNameservers), + resolverAttempts: orig.resolverAttempts, + resolverTimeout: orig.resolverTimeout, + resolverRotation: orig.resolverRotation, + resolverConfiguration: orig.resolverConfiguration, + resolverRouting: orig.resolverRouting, + resolverSearchDomains: orig.resolverSearchDomains, + resolverOSRouting: orig.resolverOSRouting, + resolverPlatformRouting: orig.resolverPlatformRouting, + resolverBootstrap: orig.resolverBootstrap, + duration: time.Since(start), + } + for typ, values := range orig.records { + out.records[typ] = slices.Clone(values) + } + for _, rec := range records { + label := typeLabel(rec.typ) + out.records[label] = append(out.records[label], rec) + } + return out +} + +func lookupDefaultResolverRecords(ctx context.Context, host string) ([]record, error) { + addrs, err := defaultLookupIPAddr(ctx, host) + if err != nil { + return nil, err + } + + records := make([]record, 0, len(addrs)) + owner := normalizedOwner(host) + for _, addr := range addrs { + ip := addr.IP + switch { + case ip.To4() != nil: + records = append(records, record{owner: owner, typ: dnsmessage.TypeA, address: append(net.IP(nil), ip.To4()...), source: recordSourcePlatform}) + case ip.To16() != nil: + records = append(records, record{owner: owner, typ: dnsmessage.TypeAAAA, address: append(net.IP(nil), ip.To16()...), source: recordSourcePlatform}) + } + } + return records, nil +} + +func lookupStreamRecords(ctx context.Context, client *resolver.StreamClient, host string, qt queryType) ([]record, error) { + name, err := resolver.ParseName(absoluteName(host)) + if err != nil { + return nil, err + } + question := resolver.Question{Name: name, Type: uint16(qt.dnsType), Class: 1} + message, err := client.Query(ctx, absoluteName(host), uint16(qt.dnsType)) + if err != nil { + return nil, err + } + if message.Header.RCode != 0 { + return nil, fmt.Errorf("no DNS records found: %s", resolver.RCodeName(message.Header.RCode)) + } + authorized, err := resolver.AuthorizeAnswers(message, question) + if err != nil { + return nil, err + } + records := make([]record, 0, len(authorized)) + for _, answer := range authorized { + if converted, ok := recordFromWire(answer); ok { + records = append(records, converted) + } + } + return records, nil +} + +func lookupDoQRecords(ctx context.Context, client *resolver.DoQClient, host string, qt queryType) ([]record, error) { + name, err := resolver.ParseName(absoluteName(host)) + if err != nil { + return nil, err + } + question := resolver.Question{Name: name, Type: uint16(qt.dnsType), Class: 1} + message, err := client.Query(ctx, absoluteName(host), uint16(qt.dnsType)) + if err != nil { + return nil, err + } + if message.Header.RCode != 0 { + return nil, fmt.Errorf("no DNS records found: %s", resolver.RCodeName(message.Header.RCode)) + } + authorized, err := resolver.AuthorizeAnswers(message, question) + if err != nil { + return nil, err + } + records := make([]record, 0, len(authorized)) + for _, answer := range authorized { + if converted, ok := recordFromWire(answer); ok { + records = append(records, converted) + } + } + return records, nil +} + +func lookupDOHRecordsWithClient(ctx context.Context, client *resolver.DOHClient, host string, qt queryType) ([]record, error) { + answers, err := client.LookupInspectionType(ctx, host, qt.dohType, int(qt.dnsType)) + if err != nil { + return nil, err + } + out := make([]record, 0, len(answers)) + for _, answer := range answers { + if rec, ok := recordFromDOH(answer); ok { + out = append(out, rec) + } + } + return out, nil +} + +func lookupUDPRecords(ctx context.Context, serverAddr, host string, qt queryType) ([]record, error) { + records, _, err := lookupUDPRecordsWithFallback(ctx, serverAddr, host, qt) + return records, err +} + +func lookupUDPRecordsWithFallback(ctx context.Context, serverAddr, host string, qt queryType) ([]record, bool, error) { + name, err := resolver.ParseName(absoluteName(host)) + if err != nil { + return nil, false, err + } + question := resolver.Question{Name: name, Type: uint16(qt.dnsType), Class: 1} + res, fallback, err := resolver.LookupUDPMessage(ctx, serverAddr, absoluteName(host), uint16(qt.dnsType)) + if err != nil { + return nil, fallback, err + } + if res.Header.RCode != 0 { + return nil, fallback, fmt.Errorf("no DNS records found: %s", resolver.RCodeName(res.Header.RCode)) + } + + authorized, err := resolver.AuthorizeAnswers(res, question) + if err != nil { + return nil, fallback, err + } + records := make([]record, 0, len(authorized)) + for _, answer := range authorized { + if converted, ok := recordFromWire(answer); ok { + records = append(records, converted) + } + } + return records, fallback, nil +} + +func absoluteName(host string) string { + if strings.HasSuffix(host, ".") { + return host + } + return host + "." +} + +func typeLabel(typ dnsmessage.Type) string { + switch typ { + case dnsmessage.TypeA: + return "A" + case dnsmessage.TypeAAAA: + return "AAAA" + case dnsmessage.TypeCNAME: + return "CNAME" + case dnsmessage.TypeTXT: + return "TXT" + case dnsmessage.TypeMX: + return "MX" + case dnsmessage.TypeNS: + return "NS" + case dnsmessage.TypeSOA: + return "SOA" + case dnsmessage.TypeSRV: + return "SRV" + case dnsTypeCAA: + return "CAA" + case dnsmessage.TypeSVCB: + return "SVCB" + case dnsmessage.TypeHTTPS: + return "HTTPS" + default: + return fmt.Sprintf("TYPE%d", uint16(typ)) + } +} + +// isIPLiteral reports IPv4, IPv6, and scoped IPv6 literals. URL.Hostname +// removes brackets from IPv6 authorities and decodes the zone separator, so +// check the address without its optional interface zone. A scoped IPv6 +// literal is still an address that must not trigger DNS inspection. +func isIPLiteral(host string) bool { + if net.ParseIP(host) != nil { + return true + } + if zone := strings.IndexByte(host, '%'); zone > 0 && zone+1 < len(host) { + ip := net.ParseIP(host[:zone]) + // A zone is valid only on an IPv6 spelling. IPv4-mapped IPv6 + // addresses retain the colon syntax even though To4 reports true. + return ip != nil && strings.Contains(host[:zone], ":") + } + return false +} diff --git a/internal/dnsinspect/records.go b/internal/dnsinspect/records.go new file mode 100644 index 00000000..ed5e66cc --- /dev/null +++ b/internal/dnsinspect/records.go @@ -0,0 +1,764 @@ +package dnsinspect + +import ( + "bytes" + "cmp" + "encoding/base64" + "encoding/binary" + "encoding/hex" + "fmt" + "net" + "slices" + "strconv" + "strings" + + "github.com/ryanfowler/fetch/internal/resolver" + + "golang.org/x/net/dns/dnsmessage" +) + +func recordFromWire(res resolver.Record) (record, bool) { + return semanticRecord(res, "", res.TTLPresent), true +} + +func recordFromDOH(answer resolver.DOHRecord) (record, bool) { + rec := semanticRecord(answer.Record, answer.Data, answer.TTLPresent) + return rec, rec.hasSemanticData() +} + +func (rec record) hasSemanticData() bool { + switch rec.typ { + case dnsmessage.TypeA, dnsmessage.TypeAAAA: + return len(rec.address) > 0 + case dnsmessage.TypeCNAME, dnsmessage.TypeNS: + return rec.target != "" + case dnsmessage.TypeTXT: + return rec.txt != nil + case dnsmessage.TypeMX, dnsmessage.TypeSRV: + return rec.target != "" + case dnsmessage.TypeSOA: + return rec.target != "" && rec.target2 != "" + case dnsTypeCAA: + return len(rec.rawRData) >= 2 + case dnsmessage.TypeSVCB, dnsmessage.TypeHTTPS: + return rec.target != "" + default: + return rec.presentation != "" || rec.rawRData != nil + } +} + +func semanticRecord(res resolver.Record, presentation string, ttlPresent bool) record { + rec := record{ + owner: normalizeOwnerPresentation(res.Owner.String()), + typ: dnsmessage.Type(res.Type), + ttl: res.TTL, + hasTTL: ttlPresent, + source: recordSourceDNS, + preference: res.Preference, + priority: res.Priority, + weight: res.Weight, + port: res.Port, + soa: res.SOAValues, + rawRData: append([]byte(nil), res.RData...), + presentation: presentation, + } + if ip := resolver.RecordAddress(res); ip != nil { + rec.address = append(net.IP(nil), ip...) + } + if res.Target != nil { + rec.target = res.Target.String() + } + if res.Target2 != nil { + rec.target2 = res.Target2.String() + } + for _, chunk := range res.TXT { + rec.txt = append(rec.txt, append([]byte(nil), chunk...)) + } + for _, param := range res.Params { + rec.params = append(rec.params, resolver.SVCParam{Key: param.Key, Value: append([]byte(nil), param.Value...)}) + } + populateRecordData(&rec) + rec.target = strings.ToLower(rec.target) + rec.target2 = strings.ToLower(rec.target2) + return rec +} + +func populateRecordData(rec *record) { + if len(rec.rawRData) > 0 { + populateRecordFromRaw(rec) + } + if rec.presentation == "" { + return + } + if _, generic := parseGenericRDATA(rec.presentation); generic { + return + } + populateRecordFromPresentation(rec) +} + +func populateRecordFromRaw(rec *record) { + raw := rec.rawRData + switch rec.typ { + case dnsmessage.TypeNS: + if target, end, ok := unpackDNSName(raw, 0); ok && end == len(raw) { + rec.target = target + } + case dnsmessage.TypeMX: + if len(raw) >= 3 { + if target, end, ok := unpackDNSName(raw, 2); ok && end == len(raw) { + rec.preference = binary.BigEndian.Uint16(raw) + rec.target = target + } + } + case dnsmessage.TypeSOA: + if first, off, ok := unpackDNSName(raw, 0); ok { + if second, off2, ok := unpackDNSName(raw, off); ok && len(raw)-off2 == 20 { + rec.target, rec.target2 = first, second + for i := range rec.soa { + rec.soa[i] = binary.BigEndian.Uint32(raw[off2+i*4:]) + } + } + } + case dnsmessage.TypeTXT: + var chunks [][]byte + for off := 0; off < len(raw); { + length := int(raw[off]) + off++ + if length > len(raw)-off { + return + } + chunks = append(chunks, append([]byte(nil), raw[off:off+length]...)) + off += length + } + rec.txt = chunks + case dnsmessage.TypeSRV: + if len(raw) >= 7 { + if target, end, ok := unpackDNSName(raw, 6); ok && end == len(raw) { + rec.priority = binary.BigEndian.Uint16(raw) + rec.weight = binary.BigEndian.Uint16(raw[2:]) + rec.port = binary.BigEndian.Uint16(raw[4:]) + rec.target = target + } + } + case dnsmessage.TypeSVCB, dnsmessage.TypeHTTPS: + // DoH JSON and wire responses normally provide Params directly. Parse + // raw RDATA as well so records from generic fixtures remain semantic + // and malformed values can still be shown safely by the renderer. + priority, target, params, ok := parseRawSVCB(rec.rawRData) + rec.malformedRData = !ok + if target != "" { + rec.priority = priority + rec.target = target + } + if len(params) > 0 { + rec.params = params + } + } +} + +func populateRecordFromPresentation(rec *record) { + fields := strings.Fields(rec.presentation) + parseUint16 := func(value string) (uint16, bool) { + parsed, err := strconv.ParseUint(value, 10, 16) + return uint16(parsed), err == nil + } + parseUint32 := func(value string) (uint32, bool) { + parsed, err := strconv.ParseUint(value, 10, 32) + return uint32(parsed), err == nil + } + name := func(value string) (string, bool) { + parsed, err := resolver.ParseName(value) + if err != nil { + return "", false + } + return parsed.String(), true + } + + switch rec.typ { + case dnsmessage.TypeNS: + if len(fields) == 1 { + rec.target, _ = name(fields[0]) + } + case dnsmessage.TypeTXT: + if chunks, ok := parseDNSCharacterStrings(rec.presentation); ok { + rec.txt = chunks + } else { + // Some JSON resolvers omit the presentation quotes for a single + // TXT character-string. Preserve that response as one chunk. + rec.txt = [][]byte{[]byte(rec.presentation)} + } + case dnsmessage.TypeMX: + if len(fields) == 2 { + preference, numberOK := parseUint16(fields[0]) + target, nameOK := name(fields[1]) + if numberOK && nameOK { + rec.preference, rec.target = preference, target + } + } + case dnsmessage.TypeSOA: + if len(fields) == 7 { + primary, primaryOK := name(fields[0]) + mailbox, mailboxOK := name(fields[1]) + values := [5]uint32{} + valuesOK := true + for i := range values { + values[i], valuesOK = parseUint32(fields[i+2]) + if !valuesOK { + break + } + } + if primaryOK && mailboxOK && valuesOK { + rec.target, rec.target2, rec.soa = primary, mailbox, values + } + } + case dnsmessage.TypeSRV: + if len(fields) == 4 { + priority, priorityOK := parseUint16(fields[0]) + weight, weightOK := parseUint16(fields[1]) + port, portOK := parseUint16(fields[2]) + target, targetOK := name(fields[3]) + if priorityOK && weightOK && portOK && targetOK { + rec.priority, rec.weight, rec.port, rec.target = priority, weight, port, target + } + } + case dnsTypeCAA: + flagsText, rest, flagsFieldOK := cutDNSField(rec.presentation) + tag, valueText, tagFieldOK := cutDNSField(rest) + flags, flagsOK := parseUint16(flagsText) + if flagsFieldOK && tagFieldOK && flagsOK && flags <= 255 && len(tag) <= 255 { + if values, ok := parseDNSCharacterStrings(valueText); ok && len(values) == 1 { + rec.rawRData = append([]byte{byte(flags), byte(len(tag))}, []byte(tag)...) + rec.rawRData = append(rec.rawRData, values[0]...) + } + } + } +} + +func cutDNSField(text string) (field, rest string, ok bool) { + text = strings.TrimLeft(text, " \t") + if text == "" { + return "", "", false + } + end := strings.IndexAny(text, " \t") + if end < 0 { + return text, "", true + } + return text[:end], strings.TrimLeft(text[end:], " \t"), true +} + +func parseDNSCharacterStrings(text string) ([][]byte, bool) { + var out [][]byte + for offset := 0; ; { + for offset < len(text) && (text[offset] == ' ' || text[offset] == '\t') { + offset++ + } + if offset == len(text) { + return out, len(out) > 0 + } + if text[offset] != '"' { + return nil, false + } + offset++ + var value []byte + closed := false + for offset < len(text) { + if text[offset] == '"' { + offset++ + closed = true + break + } + if text[offset] != '\\' { + value = append(value, text[offset]) + offset++ + continue + } + offset++ + if offset == len(text) { + return nil, false + } + if offset+3 <= len(text) && text[offset] >= '0' && text[offset] <= '9' && text[offset+1] >= '0' && text[offset+1] <= '9' && text[offset+2] >= '0' && text[offset+2] <= '9' { + octet, err := strconv.ParseUint(text[offset:offset+3], 10, 8) + if err != nil { + return nil, false + } + value = append(value, byte(octet)) + offset += 3 + continue + } + value = append(value, text[offset]) + offset++ + } + if !closed || len(value) > 255 { + return nil, false + } + out = append(out, value) + } +} + +func (rec record) semanticKey() string { + var b strings.Builder + switch rec.typ { + case dnsmessage.TypeA, dnsmessage.TypeAAAA: + fmt.Fprintf(&b, "%x", []byte(rec.address)) + case dnsmessage.TypeCNAME, dnsmessage.TypeNS: + if rec.target == "" && rec.presentation != "" { + return rec.presentation + } + b.WriteString(strings.ToLower(rec.target)) + case dnsmessage.TypeTXT: + if rec.txt == nil && rec.presentation != "" { + return rec.presentation + } + for _, chunk := range rec.txt { + fmt.Fprintf(&b, "%d:%x,", len(chunk), chunk) + } + case dnsmessage.TypeMX: + if rec.target == "" && rec.presentation != "" { + return rec.presentation + } + fmt.Fprintf(&b, "%d|%s", rec.preference, strings.ToLower(rec.target)) + case dnsmessage.TypeSOA: + if (rec.target == "" || rec.target2 == "") && rec.presentation != "" { + return rec.presentation + } + fmt.Fprintf(&b, "%s|%s|", strings.ToLower(rec.target), strings.ToLower(rec.target2)) + for _, value := range rec.soa { + fmt.Fprintf(&b, "%d,", value) + } + case dnsmessage.TypeSRV: + if rec.target == "" && rec.presentation != "" { + return rec.presentation + } + fmt.Fprintf(&b, "%d|%d|%d|%s", rec.priority, rec.weight, rec.port, strings.ToLower(rec.target)) + case dnsTypeCAA: + if len(rec.rawRData) == 0 && rec.presentation != "" { + return rec.presentation + } + fmt.Fprintf(&b, "%x", rec.rawRData) + case dnsmessage.TypeSVCB, dnsmessage.TypeHTTPS: + fmt.Fprintf(&b, "%d|%s|", rec.priority, strings.ToLower(rec.target)) + for _, param := range canonicalSVCParams(rec.params) { + fmt.Fprintf(&b, "%d:%d:%x,", param.Key, len(param.Value), param.Value) + } + if rec.malformedRData { + fmt.Fprintf(&b, "malformed:%x", rec.rawRData) + } + default: + fmt.Fprintf(&b, "%x", rec.rawRData) + } + if b.Len() == 0 { + b.WriteString(rec.presentation) + } + return b.String() +} + +func canonicalSVCParams(params []resolver.SVCParam) []resolver.SVCParam { + params = slices.Clone(params) + slices.SortFunc(params, func(a, b resolver.SVCParam) int { + if order := cmp.Compare(a.Key, b.Key); order != 0 { + return order + } + return bytes.Compare(a.Value, b.Value) + }) + return params +} + +func normalizedOwner(host string) string { + if queryHost, err := dnsQueryHost(host); err == nil { + return normalizeOwnerPresentation(queryHost) + } + return normalizeOwnerPresentation(host) +} + +func normalizeOwnerPresentation(owner string) string { + return strings.ToLower(absoluteName(owner)) +} + +func safeRecordText(text string) string { + for _, r := range text { + if r == '\n' || r == '\t' || r == '\r' || r == '\\' || r == '"' || r < 0x20 || r >= 0x7f && r <= 0x9f { + return strconv.Quote(text) + } + } + return text +} + +func formatCAA(raw []byte) string { + flags, tag, value, ok := caaFields(raw) + if !ok { + return "0x" + hex.EncodeToString(raw) + } + return fmt.Sprintf("%d %s %q", flags, safeRecordText(tag), value) +} + +func formatSVCBValue(priority uint16, target string, params []dnsmessage.SVCParam) string { + parts := []string{fmt.Sprintf("%d", priority), target} + for _, param := range params { + parts = append(parts, formatSVCParam(param)) + } + return strings.Join(parts, " ") +} + +func svcParamRenderOrder(key uint16) int { + // This order follows the diagnostic fields rather than the wire key order: + // address hints stay together and ECH remains easy to find after them. + switch key { + case uint16(dnsmessage.SVCParamMandatory): + return 0 + case uint16(dnsmessage.SVCParamALPN): + return 1 + case uint16(dnsmessage.SVCParamNoDefaultALPN): + return 2 + case uint16(dnsmessage.SVCParamPort): + return 3 + case uint16(dnsmessage.SVCParamIPv4Hint): + return 4 + case uint16(dnsmessage.SVCParamIPv6Hint): + return 5 + case uint16(dnsmessage.SVCParamECH): + return 6 + case uint16(dnsmessage.SVCParamDOHPath): + return 7 + case uint16(dnsmessage.SVCParamOHTTP): + return 8 + case uint16(dnsmessage.SVCParamTLSSupportedGroups): + return 9 + default: + return 10 + } +} + +func formatStructuredSVCParam(param resolver.SVCParam) (label, value string) { + switch dnsmessage.SVCParamKey(param.Key) { + case dnsmessage.SVCParamMandatory: + return "Mandatory", formatSVCBKeyList(param.Value) + case dnsmessage.SVCParamALPN: + if value, ok := formatSVCBALPN(param.Value); ok { + return "ALPN", value + } + return "ALPN", formatSVCBBytes(param.Value) + case dnsmessage.SVCParamNoDefaultALPN: + if len(param.Value) == 0 { + return "No default ALPN", "true" + } + return "No default ALPN", formatSVCBBytes(param.Value) + case dnsmessage.SVCParamPort: + if len(param.Value) == 2 { + return "Port", strconv.Itoa(int(binary.BigEndian.Uint16(param.Value))) + } + return "Port", formatSVCBBytes(param.Value) + case dnsmessage.SVCParamIPv4Hint: + if value, ok := formatSVCBHints(param.Value, 4); ok { + return "IPv4 hints", value + } + return "IPv4 hints", formatSVCBBytes(param.Value) + case dnsmessage.SVCParamIPv6Hint: + if value, ok := formatSVCBHints(param.Value, 16); ok { + return "IPv6 hints", value + } + return "IPv6 hints", formatSVCBBytes(param.Value) + case dnsmessage.SVCParamECH: + // ECH is already an opaque, length-prefixed binary value. Preserve its + // complete base64 representation; do not expose only a preview. + return "ECH", base64.StdEncoding.EncodeToString(param.Value) + case dnsmessage.SVCParamDOHPath: + return "DoH path", string(param.Value) + case dnsmessage.SVCParamOHTTP: + if len(param.Value) == 0 { + return "OHTTP", "true" + } + return "OHTTP", formatSVCBBytes(param.Value) + case dnsmessage.SVCParamTLSSupportedGroups: + if value, ok := formatSVCBUint16List(param.Value); ok { + return "TLS supported groups", value + } + return "TLS supported groups", formatSVCBBytes(param.Value) + default: + return formatSVCBParamName(param.Key), formatSVCBBytes(param.Value) + } +} + +func formatSVCBParamName(key uint16) string { + name := dnsmessage.SVCParamKey(key).String() + // x/net prints unknown SvcParam keys as bare numbers. The key prefix makes + // those values unambiguous and matches DNS presentation terminology. + if _, err := strconv.ParseUint(name, 10, 16); err == nil { + return "key" + name + } + return name +} + +func formatSVCBBytes(value []byte) string { + return "0x" + hex.EncodeToString(value) +} + +func formatSVCBALPN(value []byte) (string, bool) { + var values []string + for offset := 0; offset < len(value); { + length := int(value[offset]) + offset++ + if length == 0 || length > len(value)-offset { + return "", false + } + values = append(values, string(value[offset:offset+length])) + offset += length + } + if len(values) == 0 { + return "", false + } + return strings.Join(values, ", "), true +} + +func formatSVCBUint16List(value []byte) (string, bool) { + if len(value) == 0 || len(value)%2 != 0 { + return "", false + } + values := make([]string, 0, len(value)/2) + for offset := 0; offset < len(value); offset += 2 { + values = append(values, strconv.Itoa(int(binary.BigEndian.Uint16(value[offset:])))) + } + return strings.Join(values, ", "), true +} + +func formatSVCBHints(value []byte, width int) (string, bool) { + if len(value) == 0 || len(value)%width != 0 { + return "", false + } + values := make([]string, 0, len(value)/width) + for offset := 0; offset < len(value); offset += width { + values = append(values, net.IP(value[offset:offset+width]).String()) + } + return strings.Join(values, ", "), true +} + +func formatSVCBKeyList(value []byte) string { + if len(value) == 0 || len(value)%2 != 0 { + return formatSVCBBytes(value) + } + keys := make([]string, 0, len(value)/2) + for offset := 0; offset < len(value); offset += 2 { + key := binary.BigEndian.Uint16(value[offset:]) + keys = append(keys, formatSVCBKey(key)) + } + return strings.Join(keys, ", ") +} + +func formatSVCBKey(key uint16) string { + switch dnsmessage.SVCParamKey(key) { + case dnsmessage.SVCParamMandatory: + return "mandatory" + case dnsmessage.SVCParamALPN: + return "alpn" + case dnsmessage.SVCParamNoDefaultALPN: + return "no-default-alpn" + case dnsmessage.SVCParamPort: + return "port" + case dnsmessage.SVCParamIPv4Hint: + return "ipv4hint" + case dnsmessage.SVCParamECH: + return "ech" + case dnsmessage.SVCParamIPv6Hint: + return "ipv6hint" + case dnsmessage.SVCParamDOHPath: + return "dohpath" + case dnsmessage.SVCParamOHTTP: + return "ohttp" + case dnsmessage.SVCParamTLSSupportedGroups: + return "tls-supported-groups" + default: + return formatSVCBParamName(key) + } +} + +// parseRawSVCB returns as much of a generic SVCB/HTTPS RDATA value as can be +// safely decoded. The final boolean reports whether the complete value is +// well-formed, which lets the renderer retain malformed data as raw hex. +func parseRawSVCB(raw []byte) (priority uint16, target string, params []resolver.SVCParam, ok bool) { + if len(raw) < 3 { + return 0, "", nil, false + } + + // Use the resolver's strict parser for the validity bit. It checks more + // than framing, including parameter ordering, duplicate keys, reserved + // keys, and the semantics of known values. The local decode below still + // recovers a target and any complete parameters for a useful fallback. + if parsed, err := resolver.ParseSVCBRData(raw); err == nil { + params = make([]resolver.SVCParam, len(parsed.Params)) + for i, param := range parsed.Params { + params[i] = resolver.SVCParam{Key: param.Key, Value: append([]byte(nil), param.Value...)} + } + return parsed.Priority, parsed.Target.String(), params, true + } + + priority = binary.BigEndian.Uint16(raw) + var offset int + target, offset, ok = unpackDNSName(raw, 2) + if !ok { + return priority, "", nil, false + } + for offset < len(raw) { + if len(raw)-offset < 4 { + return priority, target, params, false + } + key := binary.BigEndian.Uint16(raw[offset:]) + length := int(binary.BigEndian.Uint16(raw[offset+2:])) + offset += 4 + if length > len(raw)-offset { + return priority, target, params, false + } + params = append(params, resolver.SVCParam{Key: key, Value: append([]byte(nil), raw[offset:offset+length]...)}) + offset += length + } + // Reaching this point means strict semantic validation failed. Keep the + // recovered fields for display, but make the caller retain the raw value. + return priority, target, params, false +} + +func formatSVCParam(param dnsmessage.SVCParam) string { + switch param.Key { + case dnsmessage.SVCParamALPN: + var alpns []string + for i := 0; i < len(param.Value); { + ln := int(param.Value[i]) + i++ + if i+ln > len(param.Value) { + return fmt.Sprintf("%s=0x%s", param.Key.String(), hex.EncodeToString(param.Value)) + } + alpns = append(alpns, safeRecordText(string(param.Value[i:i+ln]))) + i += ln + } + return param.Key.String() + "=" + strings.Join(alpns, ",") + case dnsmessage.SVCParamNoDefaultALPN: + return param.Key.String() + case dnsmessage.SVCParamECH: + return "ECH=" + base64.StdEncoding.EncodeToString(param.Value) + case dnsmessage.SVCParamPort: + if len(param.Value) != 2 { + return fmt.Sprintf("%s=0x%s", param.Key.String(), hex.EncodeToString(param.Value)) + } + port := uint16(param.Value[0])<<8 | uint16(param.Value[1]) + return fmt.Sprintf("%s=%d", param.Key.String(), port) + case dnsmessage.SVCParamIPv4Hint: + if len(param.Value)%4 != 0 { + return fmt.Sprintf("%s=0x%s", param.Key.String(), hex.EncodeToString(param.Value)) + } + var ips []string + for i := 0; i < len(param.Value); i += 4 { + ips = append(ips, net.IP(param.Value[i:i+4]).String()) + } + return param.Key.String() + "=" + strings.Join(ips, ",") + case dnsmessage.SVCParamIPv6Hint: + if len(param.Value)%16 != 0 { + return fmt.Sprintf("%s=0x%s", param.Key.String(), hex.EncodeToString(param.Value)) + } + var ips []string + for i := 0; i < len(param.Value); i += 16 { + ips = append(ips, net.IP(param.Value[i:i+16]).String()) + } + return param.Key.String() + "=" + strings.Join(ips, ",") + case dnsmessage.SVCParamDOHPath: + return param.Key.String() + "=" + strconv.Quote(string(param.Value)) + default: + return fmt.Sprintf("%s=0x%s", param.Key.String(), hex.EncodeToString(param.Value)) + } +} + +func normalizeDOHValue(typ dnsmessage.Type, value string) string { + raw, ok := parseGenericRDATA(value) + if !ok { + return value + } + + switch typ { + case dnsmessage.TypeSVCB, dnsmessage.TypeHTTPS: + if text, ok := parseSVCBRDATA(raw); ok { + return text + } + case dnsTypeCAA: + return formatCAA(raw) + } + return "0x" + hex.EncodeToString(raw) +} + +func parseGenericRDATA(value string) ([]byte, bool) { + fields := strings.Fields(value) + if len(fields) < 3 || fields[0] != "\\#" { + return nil, false + } + wantLen, err := strconv.Atoi(fields[1]) + if err != nil || wantLen < 0 { + return nil, false + } + raw, err := hex.DecodeString(strings.Join(fields[2:], "")) + if err != nil || len(raw) != wantLen { + return nil, false + } + return raw, true +} + +func parseSVCBRDATA(raw []byte) (string, bool) { + if len(raw) < 3 { + return "", false + } + priority := uint16(raw[0])<<8 | uint16(raw[1]) + target, off, ok := unpackDNSName(raw, 2) + if !ok { + return "", false + } + + var params []dnsmessage.SVCParam + for off < len(raw) { + if off+4 > len(raw) { + return "", false + } + key := uint16(raw[off])<<8 | uint16(raw[off+1]) + ln := int(raw[off+2])<<8 | int(raw[off+3]) + off += 4 + if off+ln > len(raw) { + return "", false + } + value := append([]byte(nil), raw[off:off+ln]...) + params = append(params, dnsmessage.SVCParam{Key: dnsmessage.SVCParamKey(key), Value: value}) + off += ln + } + return formatSVCBValue(priority, target, params), true +} + +func unpackDNSName(raw []byte, off int) (string, int, bool) { + var labels []string + wireSize := 1 + for { + if off >= len(raw) { + return "", 0, false + } + ln := int(raw[off]) + off++ + if ln == 0 { + if len(labels) == 0 { + return ".", off, true + } + return strings.Join(labels, ".") + ".", off, true + } + if ln&0xc0 != 0 || ln > 63 || off+ln > len(raw) { + return "", 0, false + } + wireSize += 1 + ln + if wireSize > 255 { + return "", 0, false + } + labels = append(labels, dnsLabelPresentation(raw[off:off+ln])) + off += ln + } +} + +func dnsLabelPresentation(label []byte) string { + var b strings.Builder + for _, value := range label { + if value >= 'a' && value <= 'z' || value >= 'A' && value <= 'Z' || value >= '0' && value <= '9' || value == '-' || value == '_' || value >= 0x21 && value <= 0x7e && value != '.' && value != '\\' { + b.WriteByte(value) + continue + } + fmt.Fprintf(&b, "\\%03d", value) + } + return b.String() +} diff --git a/internal/dnsinspect/render.go b/internal/dnsinspect/render.go new file mode 100644 index 00000000..90d9feae --- /dev/null +++ b/internal/dnsinspect/render.go @@ -0,0 +1,352 @@ +package dnsinspect + +import ( + "fmt" + "slices" + "strings" + "unicode/utf8" + + "github.com/ryanfowler/fetch/internal/core" +) + +func renderIPLiteral(p *core.Printer, host string) { + renderInspectionSection(p, "Lookup") + writeInspectionField(p, "Name", host) + writeInspectionField(p, "Status", "IP literal — DNS not performed") +} + +const maxPartialErrorBytes = 256 + +func conciseDiagnostic(text string) string { + if len(text) <= maxPartialErrorBytes { + return text + } + cut := maxPartialErrorBytes + for cut > 0 && !utf8.RuneStart(text[cut]) { + cut-- + } + return text[:cut] + "..." +} + +func render(p *core.Printer, res *result) { + renderInspection(p, res) +} + +func inspectionTransportSummary(res *result) string { + if res.tcpFallback && res.transport == "UDP" { + return "UDP → TCP fallback" + } + return res.transport +} + +func renderResolverDetails(p *core.Printer, res *result) { + var fields int + write := func(label, value string) { + if value == "" { + return + } + if fields == 0 { + writeInspectionBlankLine(p) + heading := "Resolver details" + if res.resolverRouting != "" || len(res.configuredNameservers) > 0 { + heading = "System resolver" + } + renderInspectionSection(p, heading) + } + fields++ + writeInspectionField(p, label, value) + } + + // Extra verbosity always records the exact absolute name sent to the + // resolver, including when it is equivalent to the user-facing name. + if res.queryName != "" { + write("Query name", res.queryName) + } + if len(res.configuredNameservers) > 0 { + write("Configured nameservers", strings.Join(res.configuredNameservers, ", ")) + } + if res.resolverAttempts > 0 { + write("Resolver attempts", countPhrase(res.resolverAttempts, "per nameserver", "per nameserver")) + } + if res.resolverTimeout > 0 { + write("Resolver timeout", formatDuration(res.resolverTimeout)) + } + write("Resolver rotation", res.resolverRotation) + write("Configuration", res.resolverConfiguration) + write("Routing", res.resolverRouting) + write("Search domains", res.resolverSearchDomains) + write("OS resolver routing", res.resolverOSRouting) + write("macOS routing", res.resolverPlatformRouting) + write("Bootstrap", res.resolverBootstrap) +} + +func renderQueryDetails(p *core.Printer, queries []queryResult) { + if len(queries) == 0 { + return + } + + writeInspectionBlankLine(p) + renderInspectionSection(p, "Queries") + for _, query := range queries { + status := "no data" + switch query.status { + case queryStatusData: + status = countPhrase(len(query.records), "record", "records") + case queryStatusFailed: + status = "failed" + } + 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, " · ")) + } +} + +// renderInspection writes the structured DNS diagnostic view. The lookup +// summary is deliberately separate from record rendering so that the output +// remains useful even when no record data is available. +func renderInspection(p *core.Printer, res *result) { + renderInspectionSection(p, "Lookup") + writeInspectionField(p, "Name", res.host) + if queryNameDiffers(res.host, res.queryName) { + writeInspectionField(p, "Query name", res.queryName) + } + 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 != "" { + writeInspectionField(p, "Transport", transport) + } + if res.security != "" { + writeInspectionField(p, "Transport security", displaySecurity(res.security)) + } + if res.source != "" { + writeInspectionField(p, "Source", res.source) + } + if res.platformFallback { + writeInspectionField(p, "Fallback", "platform resolver used for addresses") + } + writeInspectionField(p, "Status", inspectionStatus(res)) + if summary := resultSummary(res); summary != "" { + writeInspectionField(p, "Results", summary) + } + if summary := querySummary(res); summary != "" { + writeInspectionField(p, "Queries", summary) + } + if res.duration > 0 { + writeInspectionField(p, "Timing", formatDuration(res.duration)) + } + if res.tcpFallback && res.transport != "UDP" { + writeInspectionField(p, "TCP fallback", "used for truncated UDP response") + } + + if len(res.failures) > 0 { + writeInspectionBlankLine(p) + renderInspectionSection(p, "Failures") + renderFailures(p, res.failures) + } + if res.verbosity >= core.VExtraVerbose { + renderResolverDetails(p, res) + renderQueryDetails(p, res.queries) + } + writeInspectionBlankLine(p) + renderInspectionSection(p, "Records") + if recordCount(res) == 0 { + return + } + for _, qt := range inspectTypes { + renderSection(p, qt.label, res.records[qt.label]) + } + renderOtherSections(p, res.records) +} + +func renderFailures(p *core.Printer, failures []queryFailure) { + type failureGroup struct { + labels []string + // Keep the complete error as the grouping key. The displayed value is + // bounded, so a long resolver diagnostic cannot make the output grow + // without limit while two distinct errors are not accidentally merged + // because their prefixes happen to match. + key string + err string + } + groups := make([]failureGroup, 0, len(failures)) + indices := make(map[string]int, len(failures)) + for _, failure := range failures { + key, errText := failureDiagnostic(failure.err) + idx, ok := indices[key] + if !ok { + indices[key] = len(groups) + groups = append(groups, failureGroup{key: key, err: errText}) + idx = len(groups) - 1 + } + groups[idx].labels = append(groups[idx].labels, failure.label) + } + + // Aggregation normally supplies failures in inspection order. Sort here as + // well because renderFailures is also used by focused tests and should be + // deterministic for any input order. + for i := range groups { + slices.SortFunc(groups[i].labels, compareInspectionLabels) + } + slices.SortFunc(groups, func(a, b failureGroup) int { + if cmp := compareInspectionLabels(a.labels[0], b.labels[0]); cmp != 0 { + return cmp + } + return strings.Compare(a.key, b.key) + }) + + for _, group := range groups { + label := strings.Join(group.labels, ", ") + if allInspectionTypesFailed(group.labels) { + label = "All record types" + } + writeInspectionField(p, label, group.err) + } +} + +func failureDiagnostic(err error) (key, display string) { + if err == nil { + return "query failed", "query failed" + } + key = err.Error() + if key == "" { + return "query failed", "query failed" + } + return key, conciseDiagnostic(key) +} + +func allInspectionTypesFailed(labels []string) bool { + if len(labels) != len(inspectTypes) { + return false + } + seen := make(map[string]struct{}, len(labels)) + for _, label := range labels { + if _, ok := seen[label]; ok { + return false + } + seen[label] = struct{}{} + } + for _, typ := range inspectTypes { + if _, ok := seen[typ.label]; !ok { + return false + } + } + return true +} + +func compareInspectionLabels(a, b string) int { + rank := func(label string) int { + for i, typ := range inspectTypes { + if label == typ.label { + return i + } + } + return len(inspectTypes) + } + if aRank, bRank := rank(a), rank(b); aRank != bRank { + if aRank < bRank { + return -1 + } + return 1 + } + return strings.Compare(a, b) +} + +// queryNameDiffers reports whether the absolute DNS name is meaningfully +// different from the name supplied by the user. The root terminator is +// implicit for ordinary multi-label hostnames, so it is not useful to repeat +// it in normal output. Single-label names are different: adding the root +// terminator makes the qualification explicit and avoids implying search +// domain behavior. +func queryNameDiffers(host, queryName string) bool { + if queryName == "" { + return false + } + if host == "." || strings.HasSuffix(host, ".") { + return !strings.EqualFold(host, queryName) + } + if !strings.Contains(host, ".") && strings.EqualFold(absoluteName(host), queryName) { + return true + } + return !strings.EqualFold(absoluteName(host), queryName) +} + +func renderInspectionSection(p *core.Printer, heading string) { + p.WriteInfoPrefix() + p.Set(core.Bold) + p.WriteString(core.TerminalSafeText(heading)) + p.Reset() + p.WriteString("\n") +} + +func writeInspectionField(p *core.Printer, label, value string) { + p.WriteInfoPrefix() + p.WriteString(" ") + p.WriteString(label) + p.WriteString(": ") + p.WriteString(core.TerminalSafeText(value)) + p.WriteString("\n") +} + +func writeInspectionBlankLine(p *core.Printer) { + p.WriteInfoPrefix() + p.WriteString("\n") +} + +func inspectionStatus(res *result) string { + if len(res.failures) == 0 { + return "complete" + } + if res.queryTotal > 0 { + return fmt.Sprintf("incomplete — %d of %d queries failed", len(res.failures), res.queryTotal) + } + return "incomplete" +} + +func resultSummary(res *result) string { + addresses := len(res.records["A"]) + len(res.records["AAAA"]) + return strings.Join([]string{ + countPhrase(addresses, "address", "addresses"), + countPhrase(recordCount(res), "record", "records"), + countPhrase(recordTypeCount(res), "record type", "record types"), + }, " · ") +} + +func querySummary(res *result) string { + if res.queryTotal == 0 { + return "" + } + parts := []string{ + queryCountPhrase(res.queryTotal, "total"), + queryCountPhrase(res.queryWithData, "with data"), + queryCountPhrase(res.queryNoData, "no data"), + } + if len(res.failures) > 0 { + parts = append(parts, queryCountPhrase(len(res.failures), "failed")) + } + return strings.Join(parts, " · ") +} diff --git a/internal/dnsinspect/render_records.go b/internal/dnsinspect/render_records.go new file mode 100644 index 00000000..b90db369 --- /dev/null +++ b/internal/dnsinspect/render_records.go @@ -0,0 +1,546 @@ +package dnsinspect + +import ( + "bytes" + "cmp" + "encoding/hex" + "fmt" + "net" + "slices" + "strconv" + "strings" + "time" + + "github.com/ryanfowler/fetch/internal/core" + "github.com/ryanfowler/fetch/internal/resolver" + + "golang.org/x/net/dns/dnsmessage" +) + +// renderValue is the only place that turns semantic record data into terminal +// presentation. DoH JSON text is used only when that protocol did not provide +// parsed fields or generic wire-format RDATA. +func (rec record) renderValue() string { + fallback := func() string { + if rec.presentation != "" { + return safeRecordText(normalizeDOHValue(rec.typ, rec.presentation)) + } + return "0x" + hex.EncodeToString(rec.rawRData) + } + + switch rec.typ { + case dnsmessage.TypeA, dnsmessage.TypeAAAA: + if len(rec.address) > 0 { + return rec.address.String() + } + case dnsmessage.TypeCNAME, dnsmessage.TypeNS: + if rec.target != "" { + return rec.target + } + case dnsmessage.TypeTXT: + if len(rec.txt) == 1 { + return formatTXTChunk(rec.txt[0]) + } + case dnsmessage.TypeMX: + if rec.target != "" { + return fmt.Sprintf("%d %s", rec.preference, rec.target) + } + case dnsmessage.TypeSOA: + if rec.target != "" && rec.target2 != "" { + return fmt.Sprintf("%s %s serial=%d refresh=%d retry=%d expire=%d minttl=%d", rec.target, rec.target2, rec.soa[0], rec.soa[1], rec.soa[2], rec.soa[3], rec.soa[4]) + } + case dnsmessage.TypeSRV: + if rec.target != "" { + return fmt.Sprintf("%d %d %d %s", rec.priority, rec.weight, rec.port, rec.target) + } + case dnsTypeCAA: + if len(rec.rawRData) > 0 { + return formatCAA(rec.rawRData) + } + case dnsmessage.TypeSVCB, dnsmessage.TypeHTTPS: + if rec.target != "" { + params := make([]dnsmessage.SVCParam, 0, len(rec.params)) + for _, param := range rec.params { + params = append(params, dnsmessage.SVCParam{Key: dnsmessage.SVCParamKey(param.Key), Value: param.Value}) + } + return formatSVCBValue(rec.priority, rec.target, params) + } + } + return fallback() +} + +func countPhrase(count int, singular, plural string) string { + if count == 1 { + return fmt.Sprintf("1 %s", singular) + } + return fmt.Sprintf("%d %s", count, plural) +} + +func queryCountPhrase(count int, label string) string { + return fmt.Sprintf("%d %s", count, label) +} + +func recordTypeCount(res *result) int { + count := 0 + for _, records := range res.records { + if len(records) > 0 { + count++ + } + } + return count +} + +func renderOtherSections(p *core.Printer, records map[string][]record) { + known := make(map[string]bool, len(inspectTypes)) + for _, qt := range inspectTypes { + known[qt.label] = true + } + var types []string + for typ := range records { + if known[typ] { + continue + } + types = append(types, typ) + } + slices.SortFunc(types, func(a, b string) int { + aRecords, bRecords := records[a], records[b] + if len(aRecords) > 0 && len(bRecords) > 0 { + if order := cmp.Compare(aRecords[0].typ, bRecords[0].typ); order != 0 { + return order + } + } + return strings.Compare(a, b) + }) + for _, typ := range types { + renderSection(p, typ, records[typ]) + } +} + +func compareRecords(a, b record) int { + text := func(left, right string) int { + return strings.Compare(strings.ToLower(left), strings.ToLower(right)) + } + if a.typ != b.typ { + return cmp.Compare(a.typ, b.typ) + } + + switch a.typ { + case dnsmessage.TypeA, dnsmessage.TypeAAAA: + if order := bytes.Compare(canonicalAddressBytes(a), canonicalAddressBytes(b)); order != 0 { + return order + } + case dnsmessage.TypeCNAME, dnsmessage.TypeNS: + if order := text(a.target, b.target); order != 0 { + return order + } + case dnsmessage.TypeTXT: + for i := 0; i < min(len(a.txt), len(b.txt)); i++ { + if order := bytes.Compare(a.txt[i], b.txt[i]); order != 0 { + return order + } + } + if order := cmp.Compare(len(a.txt), len(b.txt)); order != 0 { + return order + } + case dnsmessage.TypeMX: + if order := cmp.Compare(a.preference, b.preference); order != 0 { + return order + } + if order := text(a.target, b.target); order != 0 { + return order + } + case dnsmessage.TypeSOA: + if order := text(a.owner, b.owner); order != 0 { + return order + } + case dnsmessage.TypeSRV: + for _, order := range []int{ + cmp.Compare(a.priority, b.priority), + cmp.Compare(a.weight, b.weight), + cmp.Compare(a.port, b.port), + text(a.target, b.target), + } { + if order != 0 { + return order + } + } + case dnsTypeCAA: + aFlags, aTag, aValue := caaSortFields(a.rawRData) + bFlags, bTag, bValue := caaSortFields(b.rawRData) + for _, order := range []int{text(aTag, bTag), cmp.Compare(aFlags, bFlags), bytes.Compare(aValue, bValue)} { + if order != 0 { + return order + } + } + case dnsmessage.TypeSVCB, dnsmessage.TypeHTTPS: + if order := cmp.Compare(a.priority, b.priority); order != 0 { + return order + } + if order := text(a.target, b.target); order != 0 { + return order + } + default: + if order := bytes.Compare(a.rawRData, b.rawRData); order != 0 { + return order + } + } + for _, order := range []int{ + strings.Compare(a.semanticKey(), b.semanticKey()), + text(a.owner, b.owner), + cmp.Compare(a.source, b.source), + compareBool(a.hasTTL, b.hasTTL), + cmp.Compare(a.ttl, b.ttl), + strings.Compare(a.presentation, b.presentation), + } { + if order != 0 { + return order + } + } + return 0 +} + +func compareBool(a, b bool) int { + if a == b { + return 0 + } + if !a { + return -1 + } + return 1 +} + +func canonicalAddressBytes(rec record) []byte { + ip := net.IP(rec.address) + if rec.typ == dnsmessage.TypeA { + return ip.To4() + } + return ip.To16() +} + +func caaSortFields(raw []byte) (uint8, string, []byte) { + if len(raw) < 2 || int(raw[1]) > len(raw)-2 { + return 0, "", raw + } + tagEnd := 2 + int(raw[1]) + return raw[0], string(raw[2:tagEnd]), raw[tagEnd:] +} + +func renderSection(p *core.Printer, name string, records []record) { + if len(records) == 0 { + return + } + records = slices.Clone(records) + slices.SortFunc(records, func(a, b record) int { + if order := compareRecords(a, b); order != 0 { + return order + } + if a.ttl < b.ttl { + return -1 + } + if a.ttl > b.ttl { + return 1 + } + return 0 + }) + + p.WriteInfoPrefix() + p.Set(core.Bold) + p.WriteString(" " + name) + p.Reset() + p.WriteString("\n") + + for i, rec := range records { + last := i == len(records)-1 + switch { + case rec.typ == dnsmessage.TypeTXT && len(rec.txt) > 1: + renderTXTRecord(p, rec, last) + case rec.hasComplexRendering(): + renderComplexRecord(p, rec, last) + default: + renderRecordLine(p, rec, last) + } + } + + p.WriteInfoPrefix() + p.WriteString("\n") +} + +func (rec record) hasComplexRendering() bool { + switch rec.typ { + case dnsmessage.TypeMX: + return rec.target != "" + case dnsmessage.TypeSRV: + return rec.target != "" + case dnsmessage.TypeSOA: + return rec.target != "" && rec.target2 != "" + case dnsTypeCAA: + _, _, _, ok := caaFields(rec.rawRData) + return ok + case dnsmessage.TypeSVCB, dnsmessage.TypeHTTPS: + return rec.target != "" + default: + return false + } +} + +// renderComplexRecord keeps the structured fields of complex resource records +// visible. The first line identifies the owner and target (when one exists), +// while the indented fields explain the numeric and type-specific values. +func renderComplexRecord(p *core.Printer, rec record, last bool) { + writeRecordPrefix(p, last) + p.Set(core.Green) + if rec.owner != "" { + p.WriteString(core.TerminalSafeText(rec.owner)) + if rec.typ == dnsmessage.TypeSVCB || rec.typ == dnsmessage.TypeHTTPS { + p.WriteString(" ") + } else if rec.typ != dnsmessage.TypeSOA && rec.typ != dnsTypeCAA { + p.WriteString(" → ") + } + } + switch rec.typ { + case dnsmessage.TypeMX: + p.WriteString(core.TerminalSafeText(rec.target)) + case dnsmessage.TypeSRV: + p.WriteString(core.TerminalSafeText(rec.target)) + p.WriteString(fmt.Sprintf(":%d", rec.port)) + case dnsmessage.TypeSVCB, dnsmessage.TypeHTTPS: + p.WriteString(fmt.Sprintf("priority %d → ", rec.priority)) + p.WriteString(core.TerminalSafeText(safeRecordText(rec.target))) + case dnsmessage.TypeSOA, dnsTypeCAA: + // These records list their semantic values on indented lines below. + } + p.Reset() + p.WriteString("\n") + + continued := !last + switch rec.typ { + case dnsmessage.TypeMX: + writeRecordDetail(p, "Priority", strconv.FormatUint(uint64(rec.preference), 10), continued) + case dnsmessage.TypeSRV: + writeRecordDetail(p, "Priority", strconv.FormatUint(uint64(rec.priority), 10), continued) + writeRecordDetail(p, "Weight", strconv.FormatUint(uint64(rec.weight), 10), continued) + case dnsmessage.TypeSOA: + writeRecordDetail(p, "Primary NS", rec.target, continued) + writeRecordDetail(p, "Responsible", rec.target2, continued) + writeRecordDetail(p, "Serial", strconv.FormatUint(uint64(rec.soa[0]), 10), continued) + writeRecordDetail(p, "Refresh", formatTTL(rec.soa[1]), continued) + writeRecordDetail(p, "Retry", formatTTL(rec.soa[2]), continued) + writeRecordDetail(p, "Expire", formatTTL(rec.soa[3]), continued) + writeRecordDetail(p, "Minimum TTL", formatTTL(rec.soa[4]), continued) + case dnsTypeCAA: + flags, tag, value, ok := caaFields(rec.rawRData) + if !ok { + renderRecordLine(p, rec, last) + return + } + writeRecordDetail(p, "Flags", strconv.Itoa(int(flags)), continued) + writeRecordDetail(p, "Tag", tag, continued) + writeRecordDetail(p, "Value", value, continued) + case dnsmessage.TypeSVCB, dnsmessage.TypeHTTPS: + renderServiceBindingDetails(p, rec, continued) + return + } + writeRecordSourceAndTTL(p, rec, continued) +} + +// renderServiceBindingDetails expands HTTPS/SVCB parameters into stable, +// human-readable fields. Parameter values stay bytes until this point so the +// renderer can distinguish valid values from malformed or unknown ones. +func renderServiceBindingDetails(p *core.Printer, rec record, continued bool) { + if rec.priority == 0 { + writeRecordDetail(p, "Mode", "AliasMode", continued) + } + + params := slices.Clone(rec.params) + slices.SortStableFunc(params, func(a, b resolver.SVCParam) int { + if order := cmp.Compare(svcParamRenderOrder(a.Key), svcParamRenderOrder(b.Key)); order != 0 { + return order + } + if order := cmp.Compare(a.Key, b.Key); order != 0 { + return order + } + return bytes.Compare(a.Value, b.Value) + }) + for _, param := range params { + label, value := formatStructuredSVCParam(param) + writeRecordDetail(p, label, value, continued) + } + + // A malformed generic RDATA must remain inspectable. Valid responses have + // already been decoded into Params, but a generic fixture or an unusual + // provider can still leave only raw bytes available. + if len(rec.rawRData) > 0 { + _, _, _, valid := parseRawSVCB(rec.rawRData) + if !valid { + writeRecordDetail(p, "Raw RDATA", "0x"+hex.EncodeToString(rec.rawRData), continued) + } + } + writeRecordSourceAndTTL(p, rec, continued) +} + +func writeRecordDetail(p *core.Printer, label, value string, continued bool) { + writeRecordContinuationPrefix(p, continued) + p.WriteString(label) + p.WriteString(": ") + p.WriteString(core.TerminalSafeText(safeRecordText(value))) + p.WriteString("\n") +} + +// writeRecordContinuationPrefix keeps detail lines connected to the record +// branch. Without the vertical continuation, the indentation looks like a +// large gap between the tree marker and the field text. +func writeRecordContinuationPrefix(p *core.Printer, continued bool) { + p.WriteInfoPrefix() + if continued { + p.WriteString(" \u2502 ") + return + } + p.WriteString(" ") +} + +func writeRecordSourceAndTTL(p *core.Printer, rec record, continued bool) { + if rec.source == recordSourcePlatform { + writeRecordDetail(p, "Source", "platform resolver", continued) + } + if rec.hasTTL { + writeRecordDetail(p, "TTL", formatTTL(rec.ttl), continued) + } else { + writeRecordDetail(p, "TTL", "unavailable", continued) + } +} + +func caaFields(raw []byte) (flags uint8, tag, value string, ok bool) { + if len(raw) < 2 { + return 0, "", "", false + } + tagLen := int(raw[1]) + if tagLen > len(raw)-2 { + return 0, "", "", false + } + return raw[0], string(raw[2 : 2+tagLen]), string(raw[2+tagLen:]), true +} + +func formatTXTChunk(chunk []byte) string { + // strconv.Quote escapes controls, invalid UTF-8, and quotes, so TXT data + // cannot inject terminal control sequences or output lines. + return strconv.Quote(string(chunk)) +} + +// renderTXTRecord renders each TXT character-string on its own line. This +// avoids making adjacent DNS character-strings look like one string with a +// synthetic space between their contents. +func renderTXTRecord(p *core.Printer, rec record, last bool) { + writeRecordPrefix(p, last) + p.Set(core.Green) + if rec.owner != "" { + p.WriteString(core.TerminalSafeText(rec.owner)) + } + p.Reset() + p.WriteString("\n") + + for _, chunk := range rec.txt { + writeRecordContinuationPrefix(p, !last) + p.Set(core.Green) + p.WriteString(formatTXTChunk(chunk)) + p.Reset() + p.WriteString("\n") + } + + writeRecordContinuationPrefix(p, !last) + p.Set(core.Dim) + if rec.source == recordSourcePlatform { + p.WriteString("Source: platform resolver; ") + } + if rec.hasTTL { + p.WriteString("TTL: ") + p.WriteString(formatTTL(rec.ttl)) + } else { + p.WriteString("TTL: unavailable") + } + p.Reset() + p.WriteString("\n") +} + +func renderRecordLine(p *core.Printer, rec record, last bool) { + writeRecordPrefix(p, last) + p.Set(core.Green) + if rec.owner != "" { + p.WriteString(core.TerminalSafeText(rec.owner)) + p.WriteString(" → ") + } + p.WriteString(core.TerminalSafeText(rec.renderValue())) + p.Reset() + p.WriteString(" ") + writeRecordMetadata(p, rec) + p.WriteString("\n") +} + +func writeRecordPrefix(p *core.Printer, last bool) { + p.WriteInfoPrefix() + if last { + p.WriteString(" \u2514\u2500 ") + } else { + p.WriteString(" \u251c\u2500 ") + } +} + +func writeRecordMetadata(p *core.Printer, rec record) { + p.Set(core.Dim) + p.WriteString("(") + if rec.source == recordSourcePlatform { + p.WriteString("platform resolver; ") + } + if rec.hasTTL { + p.WriteString("TTL ") + p.WriteString(formatTTL(rec.ttl)) + } else { + p.WriteString("TTL unavailable") + } + p.WriteString(")") + p.Reset() +} + +func recordCount(res *result) int { + var count int + for _, records := range res.records { + count += len(records) + } + return count +} + +func formatDuration(d time.Duration) string { + if d < time.Millisecond { + return d.Round(time.Microsecond).String() + } + return d.Round(100 * time.Microsecond).String() +} + +func formatTTL(ttl uint32) string { + if ttl == 0 { + return "0s" + } + + // DNS TTLs are seconds. Use compact whole-unit components so SOA + // durations such as expire=604800 are readable as 1w instead of 168h. + remaining := uint64(ttl) + units := []struct { + seconds uint64 + suffix string + }{ + {7 * 24 * 60 * 60, "w"}, + {24 * 60 * 60, "d"}, + {60 * 60, "h"}, + {60, "m"}, + {1, "s"}, + } + var b strings.Builder + for _, unit := range units { + if remaining < unit.seconds { + continue + } + count := remaining / unit.seconds + remaining %= unit.seconds + fmt.Fprintf(&b, "%d%s", count, unit.suffix) + } + return b.String() +} diff --git a/internal/dnsinspect/render_test.go b/internal/dnsinspect/render_test.go new file mode 100644 index 00000000..fc1e6164 --- /dev/null +++ b/internal/dnsinspect/render_test.go @@ -0,0 +1,862 @@ +package dnsinspect + +import ( + "encoding/hex" + "errors" + "net" + "strings" + "testing" + "time" + + "github.com/ryanfowler/fetch/internal/core" + "github.com/ryanfowler/fetch/internal/resolver" + + "golang.org/x/net/dns/dnsmessage" +) + +func TestRenderExtraVerboseIncludesResolverInternals(t *testing.T) { + p := core.TestPrinter(false) + render(p, &result{ + host: "example.com", + queryName: "example.com", + verbosity: core.VExtraVerbose, + configuredNameservers: []string{"192.0.2.53:53", "192.0.2.54:53"}, + resolverAttempts: 3, + resolverTimeout: 2 * time.Second, + resolverRotation: "enabled", + resolverConfiguration: "/etc/resolv.conf", + resolverRouting: "direct nameserver queries", + resolverSearchDomains: "not applied", + resolverOSRouting: "not applied by direct queries", + queries: []queryResult{{ + typ: inspectTypes[0], + status: queryStatusNoData, + responder: "192.0.2.54:53", + transport: resolver.TransportUDP, + duration: 4 * time.Millisecond, + attempts: 2, + }}, + records: map[string][]record{}, + }) + out := string(p.Bytes()) + for _, want := range []string{ + "System resolver", + "Query name: example.com", + "Configured nameservers: 192.0.2.53:53, 192.0.2.54:53", + "Resolver attempts: 3 per nameserver", + "Resolver timeout: 2s", + "Resolver rotation: enabled", + "Configuration: /etc/resolv.conf", + "Routing: direct nameserver queries", + "Search domains: not applied", + "OS resolver routing: not applied by direct queries", + "A: no data · UDP · 192.0.2.54:53 · 4ms · 2 attempts", + } { + if !strings.Contains(out, want) { + t.Fatalf("extra verbose output missing %q:\n%s", want, out) + } + } +} + +func TestRenderQueryDetailsIncludesResponderMetadata(t *testing.T) { + p := core.TestPrinter(false) + render(p, &result{ + 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", + transport: "UDP", + records: make(map[string][]record), + } + aggregate(res, []queryResult{{ + typ: inspectTypes[0], + records: []record{{typ: dnsmessage.TypeA, address: net.ParseIP("192.0.2.1")}}, + tcpFallback: true, + }}, time.Now()) + + p := core.TestPrinter(false) + render(p, res) + out := string(p.Bytes()) + if !strings.Contains(out, "Transport: UDP → TCP fallback") { + t.Fatalf("fallback transport metadata missing:\n%s", out) + } + if strings.Contains(out, "warning:") || strings.Contains(out, "truncated") { + t.Fatalf("fallback was rendered as a warning:\n%s", out) + } +} + +func TestAggregateFailedTCPFallbackRemainsFailure(t *testing.T) { + res := &result{transport: "UDP", records: make(map[string][]record)} + if err := aggregate(res, []queryResult{{ + typ: inspectTypes[3], + err: errors.New("DNS TCP fallback: connection refused"), + tcpFallback: true, + }}, time.Now()); err == nil { + t.Fatal("aggregate() error = nil, want failed TCP retry error") + } + if len(res.failures) != 1 || res.queries[0].status != queryStatusFailed { + t.Fatalf("failed fallback result = %#v, want one failed query", res) + } + + p := core.TestPrinter(false) + render(p, res) + out := string(p.Bytes()) + if !strings.Contains(out, "Status: incomplete — 1 of 1 queries failed") || !strings.Contains(out, "DNS TCP fallback: connection refused") { + t.Fatalf("failed fallback was not rendered as a failure:\n%s", out) + } +} + +func TestRenderTCPFallbackDetailsAtExtraVerbose(t *testing.T) { + p := core.TestPrinter(false) + render(p, &result{ + host: "example.com", + transport: "UDP", + verbosity: core.VExtraVerbose, + queries: []queryResult{ + {typ: inspectTypes[0], status: queryStatusData, records: []record{{typ: dnsmessage.TypeA}}, tcpFallback: true}, + {typ: inspectTypes[3], status: queryStatusNoData, tcpFallback: true}, + }, + records: map[string][]record{}, + }) + + out := string(p.Bytes()) + for _, want := range []string{ + "Queries\n", + "A: 1 record · UDP → TCP fallback", + "TXT: no data · UDP → TCP fallback", + } { + if !strings.Contains(out, want) { + t.Fatalf("verbose fallback details missing %q:\n%s", want, out) + } + } +} + +func TestRenderWithoutTCPFallbackKeepsTransportUnchanged(t *testing.T) { + p := core.TestPrinter(false) + render(p, &result{ + host: "example.com", + transport: "UDP", + records: map[string][]record{}, + }) + + out := string(p.Bytes()) + if !strings.Contains(out, "Transport: UDP\n") { + t.Fatalf("transport changed without fallback:\n%s", out) + } + if strings.Contains(out, "TCP fallback") { + t.Fatalf("output mentions fallback when none was used:\n%s", out) + } +} + +func TestRenderStructuredLookupOmitsUnavailableFields(t *testing.T) { + p := core.TestPrinter(false) + render(p, &result{host: "example.com", records: map[string][]record{}}) + + out := string(p.Bytes()) + for _, want := range []string{ + "Lookup\n", + "Name: example.com", + "Status: complete", + "Results: 0 addresses · 0 records · 0 record types", + "Records\n", + } { + if !strings.Contains(out, want) { + t.Fatalf("structured output missing %q:\n%s", want, out) + } + } + for _, unwanted := range []string{"Resolver:", "Transport:", "Source:", "Queries:", "Timing:"} { + if strings.Contains(out, unwanted) { + t.Fatalf("structured output contains empty field %q:\n%s", unwanted, out) + } + } +} + +func TestRenderStructuredLookupShowsNormalizedQueryName(t *testing.T) { + p := core.TestPrinter(false) + render(p, &result{ + host: "münich.example", + queryName: "xn--mnich-kva.example", + records: map[string][]record{}, + }) + + if out := string(p.Bytes()); !strings.Contains(out, "Query name: xn--mnich-kva.example") { + t.Fatalf("structured output missing normalized query name:\n%s", out) + } +} + +func TestRenderStructuredLookupUsesSingularGrammar(t *testing.T) { + p := core.TestPrinter(false) + render(p, &result{ + host: "example.com", + resolver: "192.0.2.53:53", + transport: "UDP", + security: string(resolver.SecurityPlaintext), + source: "system resolver configuration", + queryTotal: 1, + queryWithData: 1, + duration: time.Millisecond, + records: map[string][]record{ + "A": {{typ: dnsmessage.TypeA, address: net.ParseIP("192.0.2.1"), hasTTL: true, ttl: 60}}, + }, + }) + + out := string(p.Bytes()) + for _, want := range []string{ + "Results: 1 address · 1 record · 1 record type", + "Queries: 1 total · 1 with data · 0 no data", + } { + if !strings.Contains(out, want) { + t.Fatalf("structured output missing %q:\n%s", want, out) + } + } +} + +func TestRenderShowsRecordOwner(t *testing.T) { + p := core.TestPrinter(false) + render(p, &result{ + host: "www.example.com", + records: map[string][]record{ + "CNAME": {{owner: "www.example.com.", typ: dnsmessage.TypeCNAME, target: "cdn.example.net.", ttl: 300, hasTTL: true}}, + }, + }) + + if out := string(p.Bytes()); !strings.Contains(out, "www.example.com. → cdn.example.net. (TTL 5m)") { + t.Fatalf("output missing record owner and target:\n%s", out) + } +} + +func TestRenderShowsUnavailableTTLPerRecord(t *testing.T) { + p := core.TestPrinter(false) + render(p, &result{ + host: "example.com", + resolver: "system", + records: map[string][]record{ + "A": {{typ: dnsmessage.TypeA, address: net.ParseIP("192.0.2.1"), ttl: 60, hasTTL: true}}, + }, + }) + + out := string(p.Bytes()) + if !strings.Contains(out, "\u2514\u2500 192.0.2.1 (TTL 1m)") { + t.Fatalf("output missing tree-formatted TTL:\n%s", out) + } +} + +func TestRenderShowsPlatformSourceAndUnavailableTTLOnRecord(t *testing.T) { + p := core.TestPrinter(false) + render(p, &result{ + host: "printer.local", + records: map[string][]record{ + "A": {{ + owner: "printer.local.", + typ: dnsmessage.TypeA, + address: net.ParseIP("192.0.2.1"), + source: recordSourcePlatform, + }}, + }, + }) + + if out := string(p.Bytes()); !strings.Contains(out, "printer.local. → 192.0.2.1 (platform resolver; TTL unavailable)") { + t.Fatalf("output missing platform provenance and unavailable TTL:\n%s", out) + } +} + +func TestRenderMixedResolverSummaryAndPerRecordProvenance(t *testing.T) { + p := core.TestPrinter(false) + render(p, &result{ + host: "printer.local", + resolver: "system nameservers + platform resolver", + transport: "mixed", + security: "mixed", + source: "system resolver configuration + platform resolver", + platformFallback: true, + records: map[string][]record{ + "A": {{ + owner: "printer.local.", + typ: dnsmessage.TypeA, + address: net.ParseIP("192.0.2.1"), + source: recordSourcePlatform, + }}, + "TXT": {{ + owner: "printer.local.", + typ: dnsmessage.TypeTXT, + txt: [][]byte{[]byte("device=printer")}, + ttl: 120, + hasTTL: true, + source: recordSourceDNS, + }}, + }, + }) + + out := string(p.Bytes()) + for _, want := range []string{ + "Resolver: system nameservers + platform resolver", + "Transport: mixed", + "Transport security: mixed", + "Fallback: platform resolver used for addresses", + "192.0.2.1 (platform resolver; TTL unavailable)", + `"device=printer" (TTL 2m)`, + } { + if !strings.Contains(out, want) { + t.Fatalf("mixed output missing %q:\n%s", want, out) + } + } + if strings.Contains(out, "device=printer (platform resolver") { + t.Fatalf("direct record incorrectly marked as platform data:\n%s", out) + } +} + +func TestRenderSortsRecordsWithinType(t *testing.T) { + p := core.TestPrinter(false) + render(p, &result{ + host: "example.com", + resolver: "system", + records: map[string][]record{ + "A": { + {typ: dnsmessage.TypeA, address: net.ParseIP("192.0.2.20"), ttl: 60, hasTTL: true}, + {typ: dnsmessage.TypeA, address: net.ParseIP("192.0.2.10"), ttl: 60, hasTTL: true}, + }, + }, + }) + + out := string(p.Bytes()) + first := strings.Index(out, "192.0.2.10") + second := strings.Index(out, "192.0.2.20") + if first == -1 || second == -1 || first > second { + t.Fatalf("records not sorted within type:\n%s", out) + } +} + +func TestFormatTTLTrimsZeroUnits(t *testing.T) { + tests := map[int]string{ + 1: "1s", + 60: "1m", + 300: "5m", + 3600: "1h", + 3660: "1h1m", + } + for ttl, want := range tests { + if got := formatTTL(uint32(ttl)); got != want { + t.Fatalf("formatTTL(%d) = %q, want %q", ttl, got, want) + } + } +} + +func TestFormatCAA(t *testing.T) { + raw := append([]byte{0, 5}, []byte("issueletsencrypt.org")...) + if got, want := formatCAA(raw), `0 issue "letsencrypt.org"`; got != want { + t.Fatalf("formatCAA = %q, want %q", got, want) + } +} + +func TestRecordFromWirePreservesTypedDNSData(t *testing.T) { + owner, err := resolver.ParseName("example.com.") + if err != nil { + t.Fatal(err) + } + target, err := resolver.ParseName("service.example.net.") + if err != nil { + t.Fatal(err) + } + mailbox, err := resolver.ParseName("hostmaster.example.com.") + if err != nil { + t.Fatal(err) + } + + input := resolver.Record{ + Owner: owner, + Type: uint16(dnsmessage.TypeSOA), + TTL: 300, + TTLPresent: true, + Target: &target, + Target2: &mailbox, + Preference: 10, + Priority: 20, + Weight: 30, + Port: 443, + SOAValues: [5]uint32{1, 2, 3, 4, 5}, + TXT: [][]byte{[]byte("first"), []byte("second")}, + Params: []resolver.SVCParam{{Key: uint16(dnsmessage.SVCParamALPN), Value: []byte{2, 'h', '2'}}}, + RData: []byte{0xde, 0xad}, + } + rec, ok := recordFromWire(input) + if !ok { + t.Fatal("recordFromWire() rejected a valid record") + } + input.TXT[0][0] = 'X' + input.Params[0].Value[1] = 'X' + input.RData[0] = 0 + + if rec.typ != dnsmessage.TypeSOA || rec.owner != "example.com." || rec.target != "service.example.net." || rec.target2 != "hostmaster.example.com." { + t.Fatalf("record identity and targets were not preserved: %#v", rec) + } + if rec.preference != 10 || rec.priority != 20 || rec.weight != 30 || rec.port != 443 || rec.soa != [5]uint32{1, 2, 3, 4, 5} { + t.Fatalf("numeric DNS fields were not preserved: %#v", rec) + } + if got := string(rec.txt[0]); got != "first" { + t.Fatalf("TXT data aliases resolver storage: %q", got) + } + if got := string(rec.params[0].Value); got != "\x02h2" { + t.Fatalf("SVCB parameter aliases resolver storage: %q", got) + } + if got := hex.EncodeToString(rec.rawRData); got != "dead" { + t.Fatalf("raw RDATA aliases resolver storage: %q", got) + } +} + +func TestWireNameTargetsRemainEscapedInTypedRecords(t *testing.T) { + target, err := resolver.ParseName(`bad\010dot\046slash\092.example.`) + if err != nil { + t.Fatal(err) + } + wire, err := target.Wire() + if err != nil { + t.Fatal(err) + } + mailbox, err := resolver.ParseName("hostmaster.example.") + if err != nil { + t.Fatal(err) + } + mailboxWire, err := mailbox.Wire() + if err != nil { + t.Fatal(err) + } + + records := []resolver.Record{ + {Type: uint16(dnsmessage.TypeNS), Target: &target, RData: wire}, + {Type: uint16(dnsmessage.TypeMX), Target: &target, RData: append([]byte{0, 10}, wire...)}, + {Type: uint16(dnsmessage.TypeSOA), Target: &target, Target2: &mailbox, RData: append(append(append([]byte(nil), wire...), mailboxWire...), make([]byte, 20)...)}, + {Type: uint16(dnsmessage.TypeSRV), Target: &target, RData: append(make([]byte, 6), wire...)}, + } + for _, input := range records { + rec, ok := recordFromWire(input) + if !ok { + t.Fatalf("recordFromWire() rejected type %d", input.Type) + } + if rec.target != target.String() { + t.Errorf("type %d target = %q, want escaped %q", input.Type, rec.target, target.String()) + } + if strings.ContainsAny(rec.target, "\n\r") { + t.Errorf("type %d target contains a raw line break: %q", input.Type, rec.target) + } + } +} + +func TestRenderUsesTypedDNSRecordData(t *testing.T) { + p := core.TestPrinter(false) + records := map[string][]record{ + "A": {{typ: dnsmessage.TypeA, address: net.ParseIP("192.0.2.1")}}, + "AAAA": {{typ: dnsmessage.TypeAAAA, address: net.ParseIP("2001:db8::1")}}, + "CNAME": {{typ: dnsmessage.TypeCNAME, target: "alias.example."}}, + "TXT": {{typ: dnsmessage.TypeTXT, txt: [][]byte{[]byte("first"), []byte("second")}}}, + "MX": {{typ: dnsmessage.TypeMX, preference: 10, target: "mail.example."}}, + "NS": {{typ: dnsmessage.TypeNS, target: "ns1.example."}}, + "SOA": {{ + typ: dnsmessage.TypeSOA, target: "ns1.example.", target2: "hostmaster.example.", + soa: [5]uint32{2026082901, 3600, 600, 604800, 300}, + }}, + "SRV": {{typ: dnsmessage.TypeSRV, priority: 10, weight: 5, port: 443, target: "service.example."}}, + "CAA": {{typ: dnsTypeCAA, rawRData: append([]byte{0, 5}, []byte("issueletsencrypt.org")...)}}, + "SVCB": {{typ: dnsmessage.TypeSVCB, priority: 0, target: "."}}, + "HTTPS": {{ + typ: dnsmessage.TypeHTTPS, priority: 1, target: ".", + params: []resolver.SVCParam{{Key: uint16(dnsmessage.SVCParamALPN), Value: []byte{2, 'h', '2'}}}, + }}, + "TYPE99": {{typ: dnsmessage.Type(99), rawRData: []byte{0xde, 0xad}}}, + } + render(p, &result{host: "example.com", records: records}) + out := string(p.Bytes()) + for _, want := range []string{ + "192.0.2.1", "2001:db8::1", "alias.example.", "\"first\"\n", "\"second\"\n", + "Priority: 10", "mail.example.", "ns1.example.", + "Primary NS: ns1.example.", "Responsible: hostmaster.example.", + "Serial: 2026082901", "Refresh: 1h", "Retry: 10m", "Expire: 1w", "Minimum TTL: 5m", + "Weight: 5", "service.example.:443", + "Flags: 0", "Tag: issue", "Value: letsencrypt.org", "priority 0 → .", "priority 1 → .", "ALPN: h2", + "TYPE99", "0xdead", + } { + if !strings.Contains(out, want) { + t.Fatalf("typed record output missing %q:\n%s", want, out) + } + } +} + +func TestRenderComplexRecordsUseLabeledFields(t *testing.T) { + p := core.TestPrinter(false) + rawCAA := append([]byte{1, 5}, []byte("issueacme.org")...) + render(p, &result{host: "example.com", records: map[string][]record{ + "MX": {{ + owner: "example.com.", typ: dnsmessage.TypeMX, preference: 10, + target: "mail.example.com.", ttl: 3600, hasTTL: true, + }}, + "SRV": {{ + owner: "_https._tcp.example.com.", typ: dnsmessage.TypeSRV, + priority: 20, weight: 5, port: 443, target: "service.example.com.", + ttl: 300, hasTTL: true, + }}, + "SOA": {{ + owner: "example.com.", typ: dnsmessage.TypeSOA, + target: "ns1.example.com.", target2: "hostmaster.example.com.", + soa: [5]uint32{2026082901, 3600, 600, 604800, 300}, ttl: 3600, hasTTL: true, + }}, + "CAA": {{ + owner: "example.com.", typ: dnsTypeCAA, rawRData: rawCAA, + ttl: 3600, hasTTL: true, + }}, + }}) + + out := string(p.Bytes()) + for _, want := range []string{ + "example.com. → mail.example.com.", " Priority: 10", " TTL: 1h", + "_https._tcp.example.com. → service.example.com.:443", " Weight: 5", + "example.com.\n", "Primary NS: ns1.example.com.", "Responsible: hostmaster.example.com.", + "Serial: 2026082901", "Refresh: 1h", "Retry: 10m", "Expire: 1w", "Minimum TTL: 5m", + "Flags: 1", "Tag: issue", "Value: acme.org", + } { + if !strings.Contains(out, want) { + t.Fatalf("complex record output missing %q:\n%s", want, out) + } + } +} + +func TestRenderComplexRecordTreeContinuationStopsAtLastRecord(t *testing.T) { + p := core.TestPrinter(false) + render(p, &result{host: "example.com", records: map[string][]record{ + "MX": { + {typ: dnsmessage.TypeMX, preference: 2, target: "first.example."}, + {typ: dnsmessage.TypeMX, preference: 10, target: "last.example."}, + }, + }}) + + out := string(p.Bytes()) + if !strings.Contains(out, " │ Priority: 2") { + t.Fatalf("non-final record details lost tree continuation:\n%s", out) + } + if strings.Contains(out, " │ Priority: 10") || !strings.Contains(out, " Priority: 10") { + t.Fatalf("final record details retained tree continuation:\n%s", out) + } +} + +func TestAggregateKeepsDistinctDOHPresentationRecords(t *testing.T) { + out := &result{records: make(map[string][]record)} + results := []queryResult{ + {typ: inspectTypes[4], records: []record{ + {typ: dnsmessage.TypeMX, presentation: "10 first.example."}, + {typ: dnsmessage.TypeMX, presentation: "20 second.example."}, + }}, + {typ: inspectTypes[6], records: []record{ + {typ: dnsmessage.TypeSOA, presentation: "ns1.example. hostmaster.example. 1 2 3 4 5"}, + {typ: dnsmessage.TypeSOA, presentation: "ns2.example. hostmaster.example. 2 3 4 5 6"}, + }}, + {typ: inspectTypes[7], records: []record{ + {typ: dnsmessage.TypeSRV, presentation: "10 5 443 first.example."}, + {typ: dnsmessage.TypeSRV, presentation: "20 5 443 second.example."}, + }}, + } + aggregate(out, results, time.Now()) + for _, typ := range []string{"MX", "SOA", "SRV"} { + if got := len(out.records[typ]); got != 2 { + t.Fatalf("%s records = %d, want 2 distinct DoH records: %#v", typ, got, out.records[typ]) + } + } +} + +func TestRenderTXTChunksAreQuotedAndCannotInjectLines(t *testing.T) { + p := core.TestPrinter(false) + render(p, &result{host: "example.com", records: map[string][]record{ + "TXT": {{typ: dnsmessage.TypeTXT, txt: [][]byte{[]byte("line\nnext"), {0x1b, '[', 'A'}}}}, + }}) + out := string(p.Bytes()) + for _, want := range []string{"\"line\\nnext\"\n", "\"\\x1b[A\"\n"} { + if !strings.Contains(out, want) { + t.Fatalf("TXT chunk was not safely quoted: missing %q\n%s", want, out) + } + } + if strings.Contains(out, `"line\nnext" "\x1b[A"`) { + t.Fatalf("TXT chunks were rendered as one space-joined value: %q", out) + } + if strings.Contains(out, "line\nnext") || strings.ContainsRune(out, '\x1b') { + t.Fatalf("TXT data injected terminal layout or controls: %q", out) + } +} + +func TestRecordFromDOHRejectsMissingTXTDataButKeepsEmptyChunk(t *testing.T) { + owner, err := resolver.ParseName("example.com.") + if err != nil { + t.Fatal(err) + } + answer := resolver.DOHRecord{Record: resolver.Record{Owner: owner, Type: uint16(dnsmessage.TypeTXT)}} + if _, ok := recordFromDOH(answer); ok { + t.Fatal("recordFromDOH() accepted a TXT answer with missing data") + } + answer.Data = `""` + rec, ok := recordFromDOH(answer) + if !ok || len(rec.txt) != 1 || len(rec.txt[0]) != 0 { + t.Fatalf("empty TXT chunk was not preserved: %#v, %t", rec, ok) + } +} + +func TestDOHCAANumericTagIsParsedSemantically(t *testing.T) { + rec := semanticRecord(resolver.Record{Type: uint16(dnsTypeCAA)}, `0 0 "value"`, false) + if got := formatCAA(rec.rawRData); got != `0 0 "value"` { + t.Fatalf("numeric CAA tag was not parsed: %q", got) + } +} + +func TestRenderEscapesCAAAndSVCBFields(t *testing.T) { + p := core.TestPrinter(false) + render(p, &result{host: "example.com", records: map[string][]record{ + "CAA": {{typ: dnsTypeCAA, rawRData: append([]byte{0, 8}, []byte("bad\nnamevalue")...)}}, + "HTTPS": {{ + typ: dnsmessage.TypeHTTPS, priority: 1, target: ".", + params: []resolver.SVCParam{{Key: uint16(dnsmessage.SVCParamALPN), Value: []byte{8, 'b', 'a', 'd', '\n', 'n', 'a', 'm', 'e'}}}, + }}, + }}) + out := string(p.Bytes()) + for _, want := range []string{`"bad\nname"`, `ALPN: "bad\nname"`} { + if !strings.Contains(out, want) { + t.Fatalf("record field was not escaped as %q:\n%s", want, out) + } + } + if strings.Contains(out, "bad\nname") { + t.Fatalf("record field injected an output line: %q", out) + } +} + +func TestRenderHTTPSExpandsServiceBindingParameters(t *testing.T) { + p := core.TestPrinter(false) + render(p, &result{host: "example.com", records: map[string][]record{ + "HTTPS": {{ + owner: "example.com.", typ: dnsmessage.TypeHTTPS, priority: 1, target: ".", + params: []resolver.SVCParam{ + {Key: uint16(dnsmessage.SVCParamMandatory), Value: []byte{0, 1, 0, 4}}, + {Key: uint16(dnsmessage.SVCParamDOHPath), Value: []byte("/dns-query{?dns}")}, + {Key: uint16(dnsmessage.SVCParamECH), Value: []byte{1, 2, 3}}, + {Key: uint16(dnsmessage.SVCParamIPv6Hint), Value: net.ParseIP("2001:db8::1")}, + {Key: uint16(dnsmessage.SVCParamPort), Value: []byte{1, 0xbb}}, + {Key: uint16(dnsmessage.SVCParamIPv4Hint), Value: []byte{192, 0, 2, 1, 192, 0, 2, 2}}, + {Key: uint16(dnsmessage.SVCParamALPN), Value: []byte{2, 'h', '2', 2, 'h', '3'}}, + {Key: uint16(dnsmessage.SVCParamOHTTP)}, + {Key: uint16(dnsmessage.SVCParamTLSSupportedGroups), Value: []byte{0, 23, 0, 29}}, + {Key: 10, Value: []byte{0xde, 0xad}}, + }, + ttl: 300, hasTTL: true, + }}, + }}) + + out := string(p.Bytes()) + for _, want := range []string{ + "example.com. priority 1 → .", + "Mandatory: alpn, ipv4hint", + "ALPN: h2, h3", + "Port: 443", + "IPv4 hints: 192.0.2.1, 192.0.2.2", + "IPv6 hints: 2001:db8::1", + "ECH: AQID", + "DoH path: /dns-query{?dns}", + "OHTTP: true", + "TLS supported groups: 23, 29", + "key10: 0xdead", + "TTL: 5m", + } { + if !strings.Contains(out, want) { + t.Fatalf("HTTPS output missing %q:\n%s", want, out) + } + } + if strings.Contains(out, "ALPN=h2") || strings.Contains(out, "IPv4Hint=") { + t.Fatalf("HTTPS parameters were flattened:\n%s", out) + } +} + +func TestRenderSVCBAliasModeAndMalformedParameters(t *testing.T) { + p := core.TestPrinter(false) + render(p, &result{host: "example.com", records: map[string][]record{ + "SVCB": {{ + owner: "example.com.", typ: dnsmessage.TypeSVCB, priority: 0, target: ".", + }}, + "HTTPS": {{ + owner: "example.com.", typ: dnsmessage.TypeHTTPS, priority: 1, target: ".", + params: []resolver.SVCParam{{Key: uint16(dnsmessage.SVCParamALPN), Value: []byte{3, 'h', '2'}}}, + }}, + }}) + + out := string(p.Bytes()) + for _, want := range []string{ + "example.com. priority 0 → .", + "Mode: AliasMode", + "ALPN: 0x036832", + } { + if !strings.Contains(out, want) { + t.Fatalf("SVCB output missing %q:\n%s", want, out) + } + } +} + +func TestRenderSVCBUnknownRawDataWhenMalformed(t *testing.T) { + p := core.TestPrinter(false) + render(p, &result{host: "example.com", records: map[string][]record{ + "HTTPS": {{ + owner: "example.com.", typ: dnsmessage.TypeHTTPS, priority: 1, target: ".", + rawRData: []byte{0, 1, 1, 'x', 0, 9, 0, 4, 0xde}, + }}, + }}) + if out := string(p.Bytes()); !strings.Contains(out, "Raw RDATA: 0x0001017800090004de") { + t.Fatalf("malformed HTTPS RDATA was not retained:\n%s", out) + } +} + +func TestRenderSortsTypedNumericFieldsNumerically(t *testing.T) { + p := core.TestPrinter(false) + render(p, &result{host: "example.com", records: map[string][]record{ + "MX": { + {typ: dnsmessage.TypeMX, preference: 10, target: "ten.example."}, + {typ: dnsmessage.TypeMX, preference: 2, target: "two.example."}, + }, + }}) + out := string(p.Bytes()) + two := strings.Index(out, "two.example.") + ten := strings.Index(out, "ten.example.") + if two < 0 || ten < 0 || two > ten { + t.Fatalf("MX records are not sorted by numeric preference:\n%s", out) + } +} + +func TestCompareRecordsUsesSemanticOrdering(t *testing.T) { + caa := func(flags byte, tag, value string) []byte { + return append([]byte{flags, byte(len(tag))}, append([]byte(tag), []byte(value)...)...) + } + tests := []struct { + name string + a record + b record + }{ + {name: "A numeric bytes", a: record{typ: dnsmessage.TypeA, address: net.ParseIP("192.0.2.2")}, b: record{typ: dnsmessage.TypeA, address: net.ParseIP("192.0.2.10")}}, + {name: "AAAA numeric bytes", a: record{typ: dnsmessage.TypeAAAA, address: net.ParseIP("2001:db8::2")}, b: record{typ: dnsmessage.TypeAAAA, address: net.ParseIP("2001:db8::10")}}, + {name: "CNAME canonical target", a: record{typ: dnsmessage.TypeCNAME, target: "a.example."}, b: record{typ: dnsmessage.TypeCNAME, target: "B.example."}}, + {name: "NS canonical target", a: record{typ: dnsmessage.TypeNS, target: "ns10.example."}, b: record{typ: dnsmessage.TypeNS, target: "ns2.example."}}, + {name: "MX preference", a: record{typ: dnsmessage.TypeMX, preference: 2, target: "z.example."}, b: record{typ: dnsmessage.TypeMX, preference: 10, target: "a.example."}}, + {name: "MX target", a: record{typ: dnsmessage.TypeMX, preference: 10, target: "a.example."}, b: record{typ: dnsmessage.TypeMX, preference: 10, target: "b.example."}}, + {name: "SRV priority", a: record{typ: dnsmessage.TypeSRV, priority: 2, weight: 10, port: 9000, target: "z.example."}, b: record{typ: dnsmessage.TypeSRV, priority: 10, target: "a.example."}}, + {name: "SRV weight", a: record{typ: dnsmessage.TypeSRV, priority: 10, weight: 2, port: 9000}, b: record{typ: dnsmessage.TypeSRV, priority: 10, weight: 10, port: 1}}, + {name: "SRV port", a: record{typ: dnsmessage.TypeSRV, priority: 10, weight: 10, port: 2}, b: record{typ: dnsmessage.TypeSRV, priority: 10, weight: 10, port: 10}}, + {name: "SRV target", a: record{typ: dnsmessage.TypeSRV, priority: 10, weight: 10, port: 443, target: "a.example."}, b: record{typ: dnsmessage.TypeSRV, priority: 10, weight: 10, port: 443, target: "b.example."}}, + {name: "CAA tag", a: record{typ: dnsTypeCAA, rawRData: caa(128, "a", "z")}, b: record{typ: dnsTypeCAA, rawRData: caa(0, "b", "a")}}, + {name: "CAA flags", a: record{typ: dnsTypeCAA, rawRData: caa(0, "issue", "z")}, b: record{typ: dnsTypeCAA, rawRData: caa(128, "issue", "a")}}, + {name: "CAA value", a: record{typ: dnsTypeCAA, rawRData: caa(0, "issue", "a")}, b: record{typ: dnsTypeCAA, rawRData: caa(0, "issue", "b")}}, + {name: "SVCB priority", a: record{typ: dnsmessage.TypeSVCB, priority: 2, target: "z.example."}, b: record{typ: dnsmessage.TypeSVCB, priority: 10, target: "a.example."}}, + {name: "HTTPS target", a: record{typ: dnsmessage.TypeHTTPS, priority: 1, target: "a.example."}, b: record{typ: dnsmessage.TypeHTTPS, priority: 1, target: "b.example."}}, + {name: "SVCB canonical params", a: record{typ: dnsmessage.TypeSVCB, priority: 1, target: ".", params: []resolver.SVCParam{{Key: 4, Value: []byte{1}}, {Key: 1, Value: []byte("h2")}}}, b: record{typ: dnsmessage.TypeSVCB, priority: 1, target: ".", params: []resolver.SVCParam{{Key: 1, Value: []byte("h2")}, {Key: 4, Value: []byte{2}}}}}, + {name: "TXT chunk bytes", a: record{typ: dnsmessage.TypeTXT, txt: [][]byte{[]byte("10"), []byte("tail")}}, b: record{typ: dnsmessage.TypeTXT, txt: [][]byte{[]byte("2")}}}, + {name: "SOA owner", a: record{typ: dnsmessage.TypeSOA, owner: "a.example."}, b: record{typ: dnsmessage.TypeSOA, owner: "b.example."}}, + {name: "unknown type", a: record{typ: dnsmessage.Type(65280), rawRData: []byte{2}}, b: record{typ: dnsmessage.Type(65280), rawRData: []byte{10}}}, + {name: "unknown type number", a: record{typ: dnsmessage.Type(65280), rawRData: []byte{255}}, b: record{typ: dnsmessage.Type(65281), rawRData: []byte{0}}}, + {name: "owner tie breaker", a: record{owner: "a.example.", typ: dnsmessage.TypeA, address: net.ParseIP("192.0.2.1")}, b: record{owner: "b.example.", typ: dnsmessage.TypeA, address: net.ParseIP("192.0.2.1")}}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if got := compareRecords(test.a, test.b); got >= 0 { + t.Fatalf("compareRecords(a, b) = %d, want < 0", got) + } + if got := compareRecords(test.b, test.a); got <= 0 { + t.Fatalf("compareRecords(b, a) = %d, want > 0", got) + } + }) + } +} + +func TestSVCBParameterOrderIsCanonicalForDeduplication(t *testing.T) { + a := record{typ: dnsmessage.TypeHTTPS, priority: 1, target: ".", params: []resolver.SVCParam{ + {Key: 4, Value: []byte{192, 0, 2, 1}}, + {Key: 1, Value: []byte{2, 'h', '2'}}, + }} + b := record{typ: dnsmessage.TypeHTTPS, priority: 1, target: ".", params: []resolver.SVCParam{ + {Key: 1, Value: []byte{2, 'h', '2'}}, + {Key: 4, Value: []byte{192, 0, 2, 1}}, + }} + if a.semanticKey() != b.semanticKey() { + t.Fatalf("equivalent SVCB parameter sets have different semantic keys:\n%s\n%s", a.semanticKey(), b.semanticKey()) + } +} + +func TestRenderSortsUnknownSectionsByNumericType(t *testing.T) { + p := core.TestPrinter(false) + render(p, &result{host: "example.com", records: map[string][]record{ + "TYPE100": {{typ: dnsmessage.Type(100), rawRData: []byte{1}}}, + "TYPE20": {{typ: dnsmessage.Type(20), rawRData: []byte{1}}}, + }}) + out := string(p.Bytes()) + type20, type100 := strings.Index(out, " TYPE20\n"), strings.Index(out, " TYPE100\n") + if type20 < 0 || type100 < 0 || type20 > type100 { + t.Fatalf("unknown record sections are not sorted by numeric type:\n%s", out) + } +} + +func TestAggregatePreservesDistinctMalformedSVCBRData(t *testing.T) { + makeRecord := func(tail byte) record { + return semanticRecord(resolver.Record{ + Type: uint16(dnsmessage.TypeHTTPS), + RData: []byte{0, 1, 0, 0, 1, 0, 2, tail}, + }, "", true) + } + out := &result{records: make(map[string][]record)} + aggregate(out, []queryResult{{typ: inspectTypes[10], records: []record{makeRecord(0xaa), makeRecord(0xbb)}}}, time.Now()) + if got := len(out.records["HTTPS"]); got != 2 { + t.Fatalf("malformed HTTPS records = %d, want 2 distinct raw values: %#v", got, out.records["HTTPS"]) + } +} + +func TestSemanticRecordCanonicalizesDNSTargetCase(t *testing.T) { + upper, err := resolver.ParseName("MAIL.Example.COM.") + if err != nil { + t.Fatal(err) + } + lower, err := resolver.ParseName("mail.example.com.") + if err != nil { + t.Fatal(err) + } + a := semanticRecord(resolver.Record{Type: uint16(dnsmessage.TypeMX), Preference: 10, Target: &upper}, "", true) + b := semanticRecord(resolver.Record{Type: uint16(dnsmessage.TypeMX), Preference: 10, Target: &lower}, "", true) + if a.target != "mail.example.com." || b.target != a.target || a.semanticKey() != b.semanticKey() { + t.Fatalf("target names were not canonicalized: %#v %#v", a, b) + } +} + +func TestNormalizeDOHHTTPSGenericRDATA(t *testing.T) { + got := normalizeDOHValue(dnsmessage.TypeHTTPS, `\# 24 000100000100030268330003000201bb00040004c0000201`) + for _, want := range []string{ + "1 .", + "ALPN=h3", + "Port=443", + "IPv4Hint=192.0.2.1", + } { + if !strings.Contains(got, want) { + t.Fatalf("decoded HTTPS value missing %q: %q", want, got) + } + } +} + +func TestNormalizeDOHCAAGenericRDATA(t *testing.T) { + got := normalizeDOHValue(dnsTypeCAA, `\# 22 000569737375656c657473656e63727970742e6f7267`) + if want := `0 issue "letsencrypt.org"`; got != want { + t.Fatalf("decoded CAA = %q, want %q", got, want) + } +} diff --git a/skills/fetch/references/diagnostics.md b/skills/fetch/references/diagnostics.md index e82aeb7d..1c77bfdf 100644 --- a/skills/fetch/references/diagnostics.md +++ b/skills/fetch/references/diagnostics.md @@ -10,8 +10,9 @@ fetch --inspect-dns example.com fetch --inspect-dns --dns-server https://1.1.1.1/dns-query example.com ``` -Use DNS inspection to distinguish resolution failures, record-family issues, and -resolver-specific behavior. It performs inspection rather than an HTTP request. +Use DNS inspection as the first network diagnostic layer. It distinguishes +resolution failures, record-family issues, and resolver-specific behavior +without making an HTTP request. Without `--dns-server`, nameservers from the system resolver configuration are queried directly when available. This shows supported record types and TTLs. If that configuration is unavailable, the platform resolver provides A/AAAA records @@ -30,10 +31,10 @@ nameserver list, policy limits, normalization, caveats, responders, transport, d and failover attempts. For direct DNS lookups, when IDNA normalization changes the name, normal output includes `Query name` with the absolute punycode name sent to DNS. Single-label names also show their absolute query name; the root terminator is omitted for ordinary multi-label hostnames when it is the only difference. 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 +command exits with status 1. 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. -For an IPv4 or IPv6 URL literal, DNS is not performed. The result reports `Status: IP literal — DNS not performed`, omits resolver and transport fields, and exits successfully. For direct system DNS, `-vv` reports the configuration file, direct nameserver routing, and that search domains are not applied. On macOS, it also reports that scoped, VPN, per-interface, and `/etc/resolver` routing is not applied. On other platforms, it reports that OS resolver routing is not applied by direct queries. These limitations apply to the direct DNS path; platform-fallback addresses use the OS resolver. Do not discard useful stdout only because a partial inspection exits with status 1. +For an IPv4 or IPv6 URL literal, DNS is not performed. The result reports `Status: IP literal — DNS not performed`, omits resolver and transport fields, and exits successfully. For direct system DNS, `-vv` reports the configuration file, direct nameserver routing, and that search domains are not applied. On macOS, it also reports that scoped, VPN, per-interface, and `/etc/resolver` routing is not applied. On other platforms, it reports that OS resolver routing is not applied by direct queries. These limitations apply to the direct DNS path; platform-fallback addresses use the OS resolver and may lack TTLs. Treat stdout as a useful diagnostic artifact even when a partial inspection exits with status 1; never discard it solely because the exit status is nonzero. ## TLS