From 6646dfb6b2fc05588a740a0e7d0ac6320e75fbc0 Mon Sep 17 00:00:00 2001 From: Ryan Fowler Date: Sun, 30 Aug 2026 20:22:47 +0000 Subject: [PATCH] fix(dns): preserve scoped platform addresses --- docs/cli-reference.md | 4 ++- internal/dnsinspect/dnsinspect.go | 1 + internal/dnsinspect/dnsinspect_test.go | 37 ++++++++++++++++++++++++++ internal/dnsinspect/lookup.go | 14 ++++++++-- internal/dnsinspect/records.go | 4 +++ internal/dnsinspect/render_records.go | 2 +- 6 files changed, 58 insertions(+), 4 deletions(-) diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 65afc0c7..673f1bd4 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -550,7 +550,9 @@ 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. +`TTL unavailable`; scoped IPv6 addresses retain their interface zone, and +duplicate platform addresses are collapsed. `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 diff --git a/internal/dnsinspect/dnsinspect.go b/internal/dnsinspect/dnsinspect.go index 53bd71fa..cb3ee38d 100644 --- a/internal/dnsinspect/dnsinspect.go +++ b/internal/dnsinspect/dnsinspect.go @@ -89,6 +89,7 @@ type record struct { hasTTL bool source recordSource address net.IP + zone string target string target2 string preference uint16 diff --git a/internal/dnsinspect/dnsinspect_test.go b/internal/dnsinspect/dnsinspect_test.go index 6af935c0..be801d09 100644 --- a/internal/dnsinspect/dnsinspect_test.go +++ b/internal/dnsinspect/dnsinspect_test.go @@ -819,6 +819,43 @@ func TestLookupUsesPlatformResolver(t *testing.T) { } } +func TestPlatformResolverPreservesIPv6ZonesAndDeduplicatesAddresses(t *testing.T) { + origDefaultLookupIPAddr := defaultLookupIPAddr + t.Cleanup(func() { + defaultLookupIPAddr = origDefaultLookupIPAddr + }) + + defaultLookupIPAddr = func(ctx context.Context, host string) ([]net.IPAddr, error) { + return []net.IPAddr{ + {IP: net.ParseIP("192.0.2.44")}, + {IP: net.ParseIP("192.0.2.44")}, + {IP: net.ParseIP("fe80::1"), Zone: "en0"}, + {IP: net.ParseIP("fe80::1"), Zone: "en0"}, + {IP: net.ParseIP("fe80::1"), Zone: "en1"}, + }, nil + } + + res, err := lookup(context.Background(), &Config{ResolvConfPath: emptyResolvConf(t)}, "printer.local", time.Now()) + if err != nil { + t.Fatal(err) + } + if got, want := len(res.records["A"]), 1; got != want { + t.Fatalf("A record count = %d, want %d", got, want) + } + if got, want := len(res.records["AAAA"]), 2; got != want { + t.Fatalf("AAAA record count = %d, want %d", got, want) + } + + p := core.TestPrinter(false) + render(p, res) + out := string(p.Bytes()) + for _, want := range []string{"fe80::1%en0", "fe80::1%en1"} { + if strings.Count(out, want) != 1 { + t.Fatalf("output count for %q = %d, want 1:\n%s", want, strings.Count(out, want), out) + } + } +} + func TestLookupSystemNameserverReturnsTTL(t *testing.T) { addr, stop := startUDPServer(t) defer stop() diff --git a/internal/dnsinspect/lookup.go b/internal/dnsinspect/lookup.go index b8dd9482..afb82642 100644 --- a/internal/dnsinspect/lookup.go +++ b/internal/dnsinspect/lookup.go @@ -472,15 +472,25 @@ func lookupDefaultResolverRecords(ctx context.Context, host string) ([]record, e } records := make([]record, 0, len(addrs)) + seen := make(map[string]struct{}, len(addrs)) owner := normalizedOwner(host) for _, addr := range addrs { ip := addr.IP + var rec record switch { case ip.To4() != nil: - records = append(records, record{owner: owner, typ: dnsmessage.TypeA, address: append(net.IP(nil), ip.To4()...), source: recordSourcePlatform}) + rec = 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}) + rec = record{owner: owner, typ: dnsmessage.TypeAAAA, address: append(net.IP(nil), ip.To16()...), zone: addr.Zone, source: recordSourcePlatform} + default: + continue + } + key := strconv.Itoa(int(rec.typ)) + "\x00" + rec.semanticKey() + if _, ok := seen[key]; ok { + continue } + seen[key] = struct{}{} + records = append(records, rec) } return records, nil } diff --git a/internal/dnsinspect/records.go b/internal/dnsinspect/records.go index ed5e66cc..08c4b322 100644 --- a/internal/dnsinspect/records.go +++ b/internal/dnsinspect/records.go @@ -300,6 +300,10 @@ func (rec record) semanticKey() string { switch rec.typ { case dnsmessage.TypeA, dnsmessage.TypeAAAA: fmt.Fprintf(&b, "%x", []byte(rec.address)) + if rec.zone != "" { + b.WriteByte('%') + b.WriteString(rec.zone) + } case dnsmessage.TypeCNAME, dnsmessage.TypeNS: if rec.target == "" && rec.presentation != "" { return rec.presentation diff --git a/internal/dnsinspect/render_records.go b/internal/dnsinspect/render_records.go index b90db369..a94ea3bc 100644 --- a/internal/dnsinspect/render_records.go +++ b/internal/dnsinspect/render_records.go @@ -31,7 +31,7 @@ func (rec record) renderValue() string { switch rec.typ { case dnsmessage.TypeA, dnsmessage.TypeAAAA: if len(rec.address) > 0 { - return rec.address.String() + return (&net.IPAddr{IP: rec.address, Zone: rec.zone}).String() } case dnsmessage.TypeCNAME, dnsmessage.TypeNS: if rec.target != "" {