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) + } }