From 50caf516bdbbfdd03d49f47161d6d0aacbe7c0ee Mon Sep 17 00:00:00 2001 From: Dev Scribe Date: Tue, 6 Oct 2026 13:06:24 -0400 Subject: [PATCH] fix(cli): set the Intercept DNS target on IPv6-only networks before CLAT starts On macOS in Intercept mode, an IPv6-only network that offers no DHCPv4 and no PREF64 ended in a DNS outage. The DHCP option-6 read fails there because no lease exists, and the native-CLAT evidence does not exist yet because macOS starts its CLAT only after RFC 7050 prefix discovery (ipv4only.arpa) succeeds. pf blocks IPv6 port 53 and the service has no IPv4 DNS, so that lookup can never be sent. ctrld waited for evidence that only DNS could produce. A failed DHCP read now admits the target with a second kind of positive native evidence: configd publishes no global or per-service IPv4 state, the IPv6 primary service is this device, and the interface holds only IPv6 addresses (at least one routable). The service mapping, unique enabled networksetup name, two-second shared budget, tuple recheck, validated snapshot backup and ownership rules are the same as on the CLAT path. Unknown or changing state still admits nothing. A readable DHCP lease never consults the new evidence, and CLAT evidence keeps precedence. The journal line records which evidence admitted the target. --- cmd/cli/dns_target_darwin.go | 29 ++++- cmd/cli/dns_target_darwin_test.go | 12 ++ cmd/cli/dns_target_ipv6only_darwin_test.go | 103 +++++++++++++++ cmd/cli/dns_target_ipv6only_reader_test.go | 134 +++++++++++++++++++ cmd/cli/dns_target_native.go | 143 +++++++++++++++++++++ cmd/cli/dns_target_native_darwin.go | 12 +- 6 files changed, 430 insertions(+), 3 deletions(-) create mode 100644 cmd/cli/dns_target_ipv6only_darwin_test.go create mode 100644 cmd/cli/dns_target_ipv6only_reader_test.go diff --git a/cmd/cli/dns_target_darwin.go b/cmd/cli/dns_target_darwin.go index 95cb79b..21101c1 100644 --- a/cmd/cli/dns_target_darwin.go +++ b/cmd/cli/dns_target_darwin.go @@ -27,6 +27,7 @@ var ( interceptPatchNetIfaceNameFn = patchNetIfaceName interceptCurrentStaticDNSFn = readTargetStaticDNS interceptNativeCLATDefaultServiceFn = nativeCLATDefaultService + interceptNativeIPv6OnlyServiceFn = nativeIPv6OnlyDefaultService interceptNativeStaticDNSFn = readNativeTargetStaticDNS interceptSaveStaticDNSSnapshotFn = func(iface *net.Interface, dns []string, owned string) error { return saveTargetStaticDNSSnapshot(savedStaticDnsSettingsFilePath(iface), dns, owned) @@ -135,6 +136,13 @@ func (p *prog) persistInterceptDNSTargetStateLocked() error { // IPv4 DNS and on intercept shutdown. Networks that provide IPv4 DNS are // never modified. // +// A failed DHCP option-6 read is not absence on its own. It admits a target +// only with positive native evidence that the network has no IPv4: either +// macOS's own CLAT on the primary service, or (before macOS has started a +// CLAT) no IPv4 state at all with the device as the IPv6 primary. Without a +// target there, macOS cannot emit the ipv4only.arpa lookup it needs to start +// the CLAT, so the CLAT evidence alone can never arrive (issue #629). +// // Callers pass a non-nil raw system discovery result to prove discovery ran; // an empty slice is a valid DNS-less result. The decision itself uses static // DNS plus DHCP option 6 from the default-route interface, so resolvers on a @@ -187,6 +195,8 @@ func (p *prog) ensureInterceptDNSTarget(systemDiscovery []string) { ctx, cancel := context.WithTimeout(context.Background(), nativeTargetReadBudget) defer cancel() var nativeService nativeTargetService + nativeEvidence := "" + nativeServiceFn := interceptNativeCLATDefaultServiceFn readStatic := interceptCurrentStaticDNSFn if dhcpErr != nil { // Resolve the exact primary UUID before any expanded mutation. A @@ -196,6 +206,22 @@ func (p *prog) ensureInterceptDNSTarget(systemDiscovery []string) { nativeFallback = nativeErr == nil && nativeService.ID != "" && nativeService.Name != "" && nativeService.Device == drIfaceName && !hasIPv4DNS(routeDHCPDNS) mainLog.Load().Debug().Err(nativeErr).Bool("native_clat_verified", nativeFallback). Str("interface", drIfaceName).Msg("intercept DNS target: native fallback evidence") + if nativeFallback { + nativeEvidence = "clat" + } else { + // Before macOS starts its CLAT, an IPv6-only network without + // DHCPv4 has neither a lease to read nor a CLAT to prove. Its + // absence of IPv4 is the evidence instead (issue #629). + var v6Err error + nativeService, v6Err = interceptNativeIPv6OnlyServiceFn(ctx, drIfaceName) + nativeFallback = v6Err == nil && nativeService.ID != "" && nativeService.Name != "" && nativeService.Device == drIfaceName && !hasIPv4DNS(routeDHCPDNS) + mainLog.Load().Debug().Err(v6Err).Bool("native_ipv6_only_verified", nativeFallback). + Str("interface", drIfaceName).Msg("intercept DNS target: native fallback evidence") + if nativeFallback { + nativeEvidence = "ipv6_only" + nativeServiceFn = interceptNativeIPv6OnlyServiceFn + } + } } if nativeFallback { iface.Name = nativeService.Name @@ -218,7 +244,7 @@ func (p *prog) ensureInterceptDNSTarget(systemDiscovery []string) { snapshot := staticDNS if nativeFallback { device, routeErr := interceptDefaultRouteInterfaceFn() - check, serviceErr := interceptNativeCLATDefaultServiceFn(ctx, drIfaceName) + check, serviceErr := nativeServiceFn(ctx, drIfaceName) if routeErr != nil || serviceErr != nil || device != drIfaceName || check != nativeService { diagnostic.failed(decision, "native_recheck", errors.Join(routeErr, serviceErr)) return @@ -318,6 +344,7 @@ func (p *prog) ensureInterceptDNSTarget(systemDiscovery []string) { } journal(mainLog.Load().Warn()).Str("service", iface.Name).Str("target", target). Bool("native_clat_fallback", nativeFallback). + Str("native_evidence", nativeEvidence). Str("reason", "dns_less_network"). Msgf("intercept DNS target: service %q provides no usable IPv4 DNS; set %s so macOS can emit DNS queries (removed automatically when the network provides IPv4 DNS)", iface.Name, target) } diff --git a/cmd/cli/dns_target_darwin_test.go b/cmd/cli/dns_target_darwin_test.go index c7e882c..c71a65c 100644 --- a/cmd/cli/dns_target_darwin_test.go +++ b/cmd/cli/dns_target_darwin_test.go @@ -23,6 +23,9 @@ type interceptTargetHarness struct { dhcpErr error nativeCLAT bool nativeErr error + ipv6Only bool + ipv6OnlyErr error + ipv6Calls int saveErr error readErr error setErr error @@ -55,6 +58,7 @@ func newInterceptTargetHarness(t *testing.T) *interceptTargetHarness { origReset := interceptResetDNSIgnoreUnusableIfaceFn origDHCP := interceptDHCPNameserversForInterfaceFn origNative := interceptNativeCLATDefaultServiceFn + origIPv6Only := interceptNativeIPv6OnlyServiceFn origNativeStatic := interceptNativeStaticDNSFn origSnapshot := interceptSaveStaticDNSSnapshotFn origIntercept := dnsIntercept @@ -70,6 +74,7 @@ func newInterceptTargetHarness(t *testing.T) *interceptTargetHarness { interceptResetDNSIgnoreUnusableIfaceFn = origReset interceptDHCPNameserversForInterfaceFn = origDHCP interceptNativeCLATDefaultServiceFn = origNative + interceptNativeIPv6OnlyServiceFn = origIPv6Only interceptNativeStaticDNSFn = origNativeStatic interceptSaveStaticDNSSnapshotFn = origSnapshot dnsIntercept = origIntercept @@ -81,6 +86,13 @@ func newInterceptTargetHarness(t *testing.T) *interceptTargetHarness { } return nativeTargetService{ID: nativeTargetTestServiceID, Name: h.serviceByDev[device], Device: device}, nil } + interceptNativeIPv6OnlyServiceFn = func(_ context.Context, device string) (nativeTargetService, error) { + h.ipv6Calls++ + if !h.ipv6Only || h.ipv6OnlyErr != nil { + return nativeTargetService{}, h.ipv6OnlyErr + } + return nativeTargetService{ID: nativeTargetTestServiceID, Name: h.serviceByDev[device], Device: device}, nil + } interceptNativeStaticDNSFn = func(_ context.Context, iface *net.Interface) ([]string, error) { return interceptCurrentStaticDNSFn(iface) } diff --git a/cmd/cli/dns_target_ipv6only_darwin_test.go b/cmd/cli/dns_target_ipv6only_darwin_test.go new file mode 100644 index 0000000..846c3bd --- /dev/null +++ b/cmd/cli/dns_target_ipv6only_darwin_test.go @@ -0,0 +1,103 @@ +//go:build darwin + +package cli + +import ( + "context" + "errors" + "slices" + "testing" +) + +// Issue #629: an IPv6-only network without DHCPv4 and before macOS starts +// its CLAT has neither a lease to read nor a CLAT to prove. +func TestIPv6OnlyTargetAdmission(t *testing.T) { + for _, tc := range []struct { + name string + ipv6Only, clat bool + ipv6Err error + static, dhcp []string + dhcpErr error + want bool + wantIPv6Checked bool + }{ + {name: "no IPv4 at all", ipv6Only: true, dhcpErr: errors.New("getoption: exit status 1; getpacket: exit status 1"), want: true, wantIPv6Checked: true}, + {name: "IPv6 static DNS kept for restore", ipv6Only: true, static: []string{"2001:db8::53"}, dhcpErr: errors.New("no lease"), want: true, wantIPv6Checked: true}, + {name: "unknown native state", ipv6Err: errors.New("scutil failed"), dhcpErr: errors.New("no lease"), wantIPv6Checked: true}, + {name: "IPv4 present", dhcpErr: errors.New("no lease"), wantIPv6Checked: true}, + {name: "static IPv4 wins", ipv6Only: true, static: []string{"9.9.9.9"}, dhcpErr: errors.New("no lease"), wantIPv6Checked: true}, + {name: "readable DHCP never consults IPv6-only evidence", ipv6Only: true, dhcp: []string{"192.168.1.1"}}, + {name: "CLAT evidence takes precedence", clat: true, ipv6Only: true, dhcpErr: errors.New("no lease"), want: true}, + } { + t.Run(tc.name, func(t *testing.T) { + h := newInterceptTargetHarness(t) + h.ipv6Only, h.ipv6OnlyErr, h.nativeCLAT = tc.ipv6Only, tc.ipv6Err, tc.clat + h.dns["Wi-Fi"], h.dhcp, h.dhcpErr = slices.Clone(tc.static), tc.dhcp, tc.dhcpErr + p := newInterceptTargetProg() + p.ensureInterceptDNSTarget([]string{}) + if got := len(h.setCalls) > 0; got != tc.want || len(h.resetCalls) != 0 { + t.Fatalf("set=%v reset=%v, want set=%v", h.setCalls, h.resetCalls, tc.want) + } + if (h.ipv6Calls > 0) != tc.wantIPv6Checked { + t.Fatalf("IPv6-only evidence consulted %d times, want consulted=%v", h.ipv6Calls, tc.wantIPv6Checked) + } + if tc.want && !slices.Equal(h.dns["Wi-Fi"], []string{"127.0.0.53"}) { + t.Fatalf("target = %v, want [127.0.0.53]", h.dns["Wi-Fi"]) + } + }) + } +} + +// The admitted target is removed when the network gains IPv4, and the +// original IPv6 static DNS comes back. +func TestIPv6OnlyTargetLifecycle(t *testing.T) { + h := newInterceptTargetHarness(t) + h.ipv6Only = true + h.dhcpErr = errors.New("no lease") + h.dns["Wi-Fi"] = []string{"2001:db8::53"} + p := newInterceptTargetProg() + p.ensureInterceptDNSTarget([]string{}) + if p.interceptDNSTargetService != "Wi-Fi" { + t.Fatal("no target on IPv6-only network") + } + // Repeated reconciliation is idempotent. + p.ensureInterceptDNSTarget([]string{}) + if len(h.setCalls) != 1 { + t.Fatalf("target churn: %v", h.setCalls) + } + // Unknown native state later never removes or rewrites ownership. + h.ipv6Only, h.ipv6OnlyErr = false, errors.New("store unavailable") + p.ensureInterceptDNSTarget([]string{}) + if p.interceptDNSTargetService != "Wi-Fi" || len(h.setCalls) != 1 || len(h.resetCalls) != 0 { + t.Fatal("unknown state changed ownership") + } + // Back on a DHCPv4 network: the target goes, the user's IPv6 DNS returns. + h.ipv6OnlyErr, h.dhcpErr, h.dhcp = nil, nil, []string{"192.168.1.1"} + p.ensureInterceptDNSTarget([]string{}) + if p.interceptDNSTargetService != "" || !slices.Equal(h.dns["Wi-Fi"], []string{"2001:db8::53"}) { + t.Fatalf("target not removed on DHCPv4 return: owner=%q dns=%v", p.interceptDNSTargetService, h.dns["Wi-Fi"]) + } +} + +// The IPv6-only path rechecks with its own reader, not the CLAT reader, and a +// changed tuple during the transaction blocks the write. +func TestIPv6OnlyTargetRecheck(t *testing.T) { + h := newInterceptTargetHarness(t) + h.ipv6Only = true + h.dhcpErr = errors.New("no lease") + read := interceptNativeIPv6OnlyServiceFn + calls := 0 + interceptNativeIPv6OnlyServiceFn = func(ctx context.Context, device string) (nativeTargetService, error) { + calls++ + svc, err := read(ctx, device) + if calls == 2 { + svc.ID = nativeTargetOtherServiceID + } + return svc, err + } + p := newInterceptTargetProg() + p.ensureInterceptDNSTarget([]string{}) + if calls != 2 || len(h.setCalls) != 0 || p.interceptDNSTargetService != "" { + t.Fatalf("changed IPv6-only tuple admitted a target: calls=%d set=%v", calls, h.setCalls) + } +} diff --git a/cmd/cli/dns_target_ipv6only_reader_test.go b/cmd/cli/dns_target_ipv6only_reader_test.go new file mode 100644 index 0000000..893750e --- /dev/null +++ b/cmd/cli/dns_target_ipv6only_reader_test.go @@ -0,0 +1,134 @@ +package cli + +import ( + "context" + "errors" + "net" + "slices" + "strings" + "testing" +) + +// Captured on the ctrld-qa macOS 26 guest on an IPv6-only network without +// DHCPv4 or PREF64, before the CLAT started (ctrld#629). +const ( + ipv6OnlyTestGlobal6 = " {\n PrimaryInterface : en1\n PrimaryService : " + nativeTargetTestServiceID + "\n Router : fe80::5054:ff:fe51:3\n}" + ipv6OnlyTestNoKey = " No such key" +) + +func ipv6OnlyTestOutputs() []string { + return []string{ + ipv6OnlyTestNoKey, // State:/Network/Global/IPv4 + ipv6OnlyTestGlobal6, // State:/Network/Global/IPv6 + ipv6OnlyTestNoKey, // State:/Network/Service//IPv4 + " {\n UserDefinedName : Primary Wi-Fi\n}", + nativeTargetTestServiceOrder("Primary Wi-Fi", "en1"), + ipv6OnlyTestNoKey, // recheck Global/IPv4 + ipv6OnlyTestGlobal6, // recheck Global/IPv6 + } +} + +func ipv6OnlyTestReader(t *testing.T, outputs []string, calls *[]string, addrs ...string) nativeTargetReader { + t.Helper() + r := nativeTargetTestReader(t, outputs, calls) + if len(addrs) == 0 { + addrs = []string{"fd66:6:6:0:1876:fb78:b312:107b/64", "fe80::440:5b5a:3e67:23fe/64"} + } + r.interfaceAddrs = func(*net.Interface) ([]net.Addr, error) { return nativeTargetTestAddrs(addrs...), nil } + return r +} + +func TestNativeIPv6OnlyReader(t *testing.T) { + for _, tc := range []struct { + name string + change func(*nativeTargetReader, []string) + addrs []string + want bool + }{ + {name: "captured IPv6-only state", want: true}, + {name: "global IPv4 present", change: func(_ *nativeTargetReader, out []string) { + out[0] = nativeTargetTestGlobal(nativeTargetTestServiceID, "en1") + }}, + {name: "service IPv4 present", change: func(_ *nativeTargetReader, out []string) { + out[2] = nativeTargetTestState() + }}, + {name: "IPv6 primary is another device", change: func(_ *nativeTargetReader, out []string) { + out[1] = strings.Replace(ipv6OnlyTestGlobal6, "en1", "en9", 1) + }}, + {name: "CLAT address on the interface", addrs: []string{"192.0.0.2/32", "2001:db8::1/64"}}, + {name: "any IPv4 address on the interface", addrs: []string{"169.254.10.10/16", "2001:db8::1/64"}}, + {name: "link-local IPv6 only", addrs: []string{"fe80::1/64"}}, + {name: "unknown store response", change: func(_ *nativeTargetReader, out []string) { + out[0] = "scutil: permission denied" + }}, + {name: "unknown IPv6 field", change: func(_ *nativeTargetReader, out []string) { + out[1] = strings.Replace(ipv6OnlyTestGlobal6, "}", " Surprise : 1\n}", 1) + }}, + {name: "IPv4 appears before recheck", change: func(_ *nativeTargetReader, out []string) { + out[5] = nativeTargetTestGlobal(nativeTargetTestServiceID, "en1") + }}, + {name: "primary changes before recheck", change: func(_ *nativeTargetReader, out []string) { + out[6] = strings.Replace(ipv6OnlyTestGlobal6, nativeTargetTestServiceID, nativeTargetOtherServiceID, 1) + }}, + {name: "ambiguous service mapping", change: func(_ *nativeTargetReader, out []string) { + out[4] += "(2) Secondary Wi-Fi\n(Hardware Port: Wi-Fi, Device: en1)\n" + }}, + } { + t.Run(tc.name, func(t *testing.T) { + var calls []string + out := ipv6OnlyTestOutputs() + r := ipv6OnlyTestReader(t, out, &calls, tc.addrs...) + if tc.change != nil { + tc.change(&r, out) + } + svc, err := r.ipv6OnlyService(context.Background(), "en1") + got := err == nil && svc.ID == nativeTargetTestServiceID && svc.Name == "Primary Wi-Fi" && svc.Device == "en1" + if got != tc.want { + t.Fatalf("service=%+v err=%v, want admitted=%v", svc, err, tc.want) + } + if !tc.want && svc != (nativeTargetService{}) { + t.Fatalf("rejected state returned a service: %+v", svc) + } + }) + } +} + +func TestNativeIPv6OnlyReaderHonorsBudget(t *testing.T) { + var calls []string + r := ipv6OnlyTestReader(t, ipv6OnlyTestOutputs(), &calls) + ctx, cancel := context.WithCancel(context.Background()) + cancel() + if svc, err := r.ipv6OnlyService(ctx, "en1"); !errors.Is(err, context.Canceled) || svc != (nativeTargetService{}) || len(calls) != 0 { + t.Fatalf("expired budget read state: svc=%+v err=%v calls=%v", svc, err, calls) + } +} + +func TestNativeStoreAbsent(t *testing.T) { + for out, want := range map[string]bool{" No such key\n": true, " {\n}": false} { + if got, err := nativeStoreAbsent(out); err != nil || got != want { + t.Fatalf("%q: got %v %v", out, got, err) + } + } + for _, out := range []string{"", "No such key here", "error"} { + if _, err := nativeStoreAbsent(out); err == nil { + t.Fatalf("%q accepted as known", out) + } + } +} + +func TestParseNativeIPv6GlobalRejectsUnsafe(t *testing.T) { + for _, out := range []string{ + strings.Replace(ipv6OnlyTestGlobal6, "fe80::5054:ff:fe51:3", "192.168.1.1", 1), + strings.Replace(ipv6OnlyTestGlobal6, nativeTargetTestServiceID, "not-a-uuid", 1), + strings.Replace(ipv6OnlyTestGlobal6, "PrimaryInterface : en1", "PrimaryInterface : en1 en2", 1), + strings.Replace(ipv6OnlyTestGlobal6, "}", " PrimaryInterface : en1\n}", 1), + " {\n Router : fe80::1\n}", + } { + if _, _, err := parseNativeIPv6Global(out); err == nil { + t.Fatalf("accepted %q", out) + } + } + if !slices.Equal([]bool{nativeIPv6OnlyAddresses(nativeTargetTestAddrs("2001:db8::1/64"))}, []bool{true}) { + t.Fatal("global IPv6 rejected") + } +} diff --git a/cmd/cli/dns_target_native.go b/cmd/cli/dns_target_native.go index bf8431b..d442915 100644 --- a/cmd/cli/dns_target_native.go +++ b/cmd/cli/dns_target_native.go @@ -323,3 +323,146 @@ func nativeCLATAddresses(addrs []net.Addr) bool { } return clat && ipv6 } + +// nativeStoreAbsent reports whether scutil printed its exact missing-key +// response. Absence is evidence only in that exact form; a dictionary means +// present, and anything else is unknown. +func nativeStoreAbsent(out string) (bool, error) { + s := strings.TrimSpace(out) + if s == "No such key" { + return true, nil + } + if strings.HasPrefix(s, " {") { + return false, nil + } + return false, errors.New("unknown native store response") +} + +func (r nativeTargetReader) absent(ctx context.Context, key string) (bool, error) { + if err := ctx.Err(); err != nil { + return false, err + } + out, err := r.run(ctx, "show "+key+"\nquit\n", "/usr/sbin/scutil") + if err != nil { + return false, err + } + return nativeStoreAbsent(string(out)) +} + +// parseNativeIPv6Global reads only State:/Network/Global/IPv6. Unknown, +// nested or duplicate fields fail closed, like the IPv4 reader. +func parseNativeIPv6Global(out string) (id, device string, err error) { + lines := strings.Split(strings.TrimSpace(out), "\n") + if len(lines) < 2 || lines[0] != " {" || lines[len(lines)-1] != "}" { + return "", "", errors.New("invalid native IPv6 dictionary") + } + seen := make(map[string]bool) + for _, line := range lines[1 : len(lines)-1] { + key, value, ok := strings.Cut(strings.TrimSpace(line), " : ") + if !ok || strings.HasSuffix(value, "{") { + return "", "", errors.New("invalid native IPv6 field") + } + if seen[key] { + return "", "", errors.New("duplicate native IPv6 field") + } + seen[key] = true + switch key { + case "PrimaryService": + if !nativeServiceID.MatchString(value) { + return "", "", errors.New("invalid service ID") + } + id = value + case "PrimaryInterface": + if value == "" || strings.ContainsAny(value, " \r\n{}") { + return "", "", errors.New("invalid interface") + } + device = value + case "Router": + if ip := net.ParseIP(value); ip == nil || ip.To4() != nil { + return "", "", errors.New("invalid IPv6 router") + } + default: + return "", "", errors.New("unknown native IPv6 field") + } + } + if id == "" || device == "" { + return "", "", errors.New("incomplete native IPv6 dictionary") + } + return id, device, nil +} + +// nativeIPv6OnlyAddresses requires routable IPv6 and no IPv4 address at all. +func nativeIPv6OnlyAddresses(addrs []net.Addr) bool { + ipv6 := false + for _, addr := range addrs { + ip, _, err := net.ParseCIDR(addr.String()) + if err != nil || ip.To4() != nil { + return false + } + if ip.IsGlobalUnicast() { + ipv6 = true + } + } + return ipv6 +} + +// ipv6OnlyService returns the primary service of a network that gives the +// device no IPv4 at all: configd publishes no global or per-service IPv4 +// state, the IPv6 primary is this device, and the interface holds only IPv6. +// Before macOS starts its CLAT, such a network has no DHCPv4 lease to read +// and no CLAT to prove, yet it is just as DNS-less (issue #629). Unknown or +// changing state is never absence evidence; the caller rechecks the tuple. +func (r nativeTargetReader) ipv6OnlyService(ctx context.Context, device string) (nativeTargetService, error) { + unknown := nativeTargetService{} + if gone, err := r.absent(ctx, "State:/Network/Global/IPv4"); err != nil || !gone { + return unknown, err + } + out, err := r.run(ctx, "show State:/Network/Global/IPv6\nquit\n", "/usr/sbin/scutil") + if err != nil { + return unknown, err + } + id, primary, err := parseNativeIPv6Global(string(out)) + if err != nil || primary != device { + return unknown, err + } + if gone, err := r.absent(ctx, "State:/Network/Service/"+id+"/IPv4"); err != nil || !gone { + return unknown, err + } + iface, err := r.interfaceByName(device) + if err != nil { + return unknown, err + } + if iface == nil || iface.Name != device || iface.Flags&net.FlagUp == 0 { + return unknown, nil + } + addrs, err := r.interfaceAddrs(iface) + if err != nil || !nativeIPv6OnlyAddresses(addrs) { + return unknown, err + } + out, err = r.run(ctx, "show Setup:/Network/Service/"+id+"\nquit\n", "/usr/sbin/scutil") + if err != nil { + return unknown, err + } + if _, err = parseNativeServiceName(string(out)); err != nil { + return unknown, err + } + out, err = r.run(ctx, "", "/usr/sbin/networksetup", "-listnetworkserviceorder") + if err != nil { + return unknown, err + } + name, err := targetNetworkServiceName(string(out), device) + if err != nil { + return unknown, err + } + if gone, err := r.absent(ctx, "State:/Network/Global/IPv4"); err != nil || !gone { + return unknown, errors.Join(err, errors.New("IPv4 appeared during IPv6-only discovery")) + } + out, err = r.run(ctx, "show State:/Network/Global/IPv6\nquit\n", "/usr/sbin/scutil") + if err != nil { + return unknown, err + } + if afterID, afterDevice, err := parseNativeIPv6Global(string(out)); err != nil || afterID != id || afterDevice != device { + return unknown, errors.Join(err, errors.New("default service changed during IPv6-only discovery")) + } + return nativeTargetService{ID: id, Name: name, Device: device}, nil +} diff --git a/cmd/cli/dns_target_native_darwin.go b/cmd/cli/dns_target_native_darwin.go index a8700a4..12869cb 100644 --- a/cmd/cli/dns_target_native_darwin.go +++ b/cmd/cli/dns_target_native_darwin.go @@ -33,9 +33,17 @@ func readNativeTargetStaticDNS(ctx context.Context, iface *net.Interface) ([]str } func nativeCLATDefaultService(ctx context.Context, device string) (nativeTargetService, error) { - return (nativeTargetReader{ + return newNativeTargetReader().defaultService(ctx, device) +} + +func nativeIPv6OnlyDefaultService(ctx context.Context, device string) (nativeTargetService, error) { + return newNativeTargetReader().ipv6OnlyService(ctx, device) +} + +func newNativeTargetReader() nativeTargetReader { + return nativeTargetReader{ run: runNativeTargetCommand, interfaceByName: net.InterfaceByName, interfaceAddrs: (*net.Interface).Addrs, - }).defaultService(ctx, device) + } }