From d779b5374cd6e31c379238d26b5e83072c211d72 Mon Sep 17 00:00:00 2001 From: Dev Scribe Date: Thu, 24 Sep 2026 00:24:31 +0000 Subject: [PATCH] macOS: use the networksetup service name for USB CLAT targets --- cmd/cli/dns_target_native.go | 10 ++- cmd/cli/dns_target_native_capture_test.go | 5 +- cmd/cli/dns_target_native_darwin_test.go | 10 ++- cmd/cli/dns_target_native_reader_test.go | 15 ++-- cmd/cli/dns_target_service.go | 73 ++++++++++++++++++ cmd/cli/dns_target_service_test.go | 94 +++++++++++++++++++++++ cmd/cli/dns_target_usb_darwin_test.go | 89 +++++++++++++++++++++ 7 files changed, 285 insertions(+), 11 deletions(-) create mode 100644 cmd/cli/dns_target_service.go create mode 100644 cmd/cli/dns_target_service_test.go create mode 100644 cmd/cli/dns_target_usb_darwin_test.go diff --git a/cmd/cli/dns_target_native.go b/cmd/cli/dns_target_native.go index f07e033..bf8431b 100644 --- a/cmd/cli/dns_target_native.go +++ b/cmd/cli/dns_target_native.go @@ -134,7 +134,15 @@ func (r nativeTargetReader) defaultService(ctx context.Context, device string) ( if err != nil { return unknown, err } - name, err := parseNativeServiceName(string(out)) + _, err = parseNativeServiceName(string(out)) + if 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 } diff --git a/cmd/cli/dns_target_native_capture_test.go b/cmd/cli/dns_target_native_capture_test.go index 084e9f7..7605106 100644 --- a/cmd/cli/dns_target_native_capture_test.go +++ b/cmd/cli/dns_target_native_capture_test.go @@ -47,6 +47,9 @@ func TestCapturedCLATNativeReader(t *testing.T) { reader := nativeTargetReader{ run: func(_ context.Context, input, path string, args ...string) ([]byte, error) { calls++ + if path == "/usr/sbin/networksetup" && len(args) == 1 && args[0] == "-listnetworkserviceorder" && input == "" { + return []byte(nativeTargetTestServiceOrder("Wi-Fi", "en0")), nil + } if path != "/usr/sbin/scutil" || len(args) != 0 { t.Fatal("unexpected command") } @@ -77,7 +80,7 @@ func TestCapturedCLATNativeReader(t *testing.T) { } got, err := reader.defaultService(context.Background(), "en0") want := nativeTargetService{ID: id, Name: "Wi-Fi", Device: "en0"} - if err != nil || got != want || calls != 4 { + if err != nil || got != want || calls != 5 { t.Fatalf("got=%+v err=%v reads=%d", got, err, calls) } t.Log("Captured native dictionaries accepted; primary Wi-Fi service on en0 selected") diff --git a/cmd/cli/dns_target_native_darwin_test.go b/cmd/cli/dns_target_native_darwin_test.go index e6b866a..dca408c 100644 --- a/cmd/cli/dns_target_native_darwin_test.go +++ b/cmd/cli/dns_target_native_darwin_test.go @@ -177,8 +177,8 @@ func TestNativeCLATTargetTransitionAndReadFailure(t *testing.T) { } } -// Use the real evidence reader: en1 has two configured services, and the -// second is primary. Legacy first-match mapping must not even be consulted. +// Use the real evidence reader: the native primary UUID is still checked, +// and the command-facing service is unambiguously mapped to its device. func TestNativeCLATTargetExactPrimaryService(t *testing.T) { for _, static := range [][]string{{"9.9.9.9"}, {"2001:db8::53"}} { t.Run(strings.Join(static, ","), func(t *testing.T) { @@ -188,7 +188,9 @@ func TestNativeCLATTargetExactPrimaryService(t *testing.T) { h.dns["First Wi-Fi"] = nil h.dns["Primary Wi-Fi"] = slices.Clone(static) var calls []string - outputs := append(nativeTargetTestOutputs(), nativeTargetTestOutputs()...) + out := nativeTargetTestOutputs() + out[3] = nativeTargetTestServiceOrder("First Wi-Fi", "en9") + "(2) Primary Wi-Fi\n(Hardware Port: Wi-Fi, Device: en1)\n" + outputs := append(out, out...) r := nativeTargetTestReader(t, outputs, &calls) interceptNativeCLATDefaultServiceFn = r.defaultService interceptPatchNetIfaceNameFn = func(*net.Interface) (bool, error) { @@ -203,7 +205,7 @@ func TestNativeCLATTargetExactPrimaryService(t *testing.T) { } p := newInterceptTargetProg() p.ensureInterceptDNSTarget([]string{}) - if !slices.Equal(reads, []string{"Primary Wi-Fi", "Primary Wi-Fi"}) || len(calls) != 8 { + if !slices.Equal(reads, []string{"Primary Wi-Fi", "Primary Wi-Fi"}) || len(calls) != 10 { t.Fatalf("reads=%v calls=%v", reads, calls) } if len(h.dns["First Wi-Fi"]) != 0 { diff --git a/cmd/cli/dns_target_native_reader_test.go b/cmd/cli/dns_target_native_reader_test.go index 62c33e2..1acf7a1 100644 --- a/cmd/cli/dns_target_native_reader_test.go +++ b/cmd/cli/dns_target_native_reader_test.go @@ -44,7 +44,9 @@ func nativeTargetTestReader(t *testing.T, outputs []string, calls *[]string) nat if ctx.Err() != nil { return nil, ctx.Err() } - if path != "/usr/sbin/scutil" || len(args) != 0 { + if path == "/usr/sbin/networksetup" && slices.Equal(args, []string{"-listnetworkserviceorder"}) && input == "" { + input = "networksetup -listnetworkserviceorder" + } else if path != "/usr/sbin/scutil" || len(args) != 0 { t.Fatalf("unexpected command %q %v", path, args) } *calls = append(*calls, input) @@ -62,7 +64,7 @@ func nativeTargetTestReader(t *testing.T, outputs []string, calls *[]string) nat func nativeTargetTestOutputs() []string { global := nativeTargetTestGlobal(nativeTargetTestServiceID, "en1") - return []string{global, nativeTargetTestState(), " {\n UserDefinedName : Primary Wi-Fi\n}", global} + return []string{global, nativeTargetTestState(), " {\n UserDefinedName : Primary Wi-Fi\n}", nativeTargetTestServiceOrder("Primary Wi-Fi", "en1"), global} } func TestNativeTargetReaderIdentityAndEvidence(t *testing.T) { @@ -72,6 +74,9 @@ func TestNativeTargetReaderIdentityAndEvidence(t *testing.T) { want bool }{ {name: "primary UUID exact service", want: true}, + {name: "matching native name does not disambiguate another service on same device", want: false, change: func(_ *nativeTargetReader, out []string) { + out[3] += "(2) Secondary Wi-Fi\n(Hardware Port: Wi-Fi, Device: en1)\n" + }}, {name: "ULA with native proof", want: true, change: func(r *nativeTargetReader, _ []string) { r.interfaceAddrs = func(*net.Interface) ([]net.Addr, error) { return nativeTargetTestAddrs("192.0.0.2/32", "fd12:3456::1/64"), nil @@ -101,7 +106,7 @@ func TestNativeTargetReaderIdentityAndEvidence(t *testing.T) { {name: "wrong service device", change: func(_ *nativeTargetReader, out []string) { out[1] = strings.Replace(out[1], "en1", "en9", 1) }}, {name: "invalid UUID", change: func(_ *nativeTargetReader, out []string) { out[0] = nativeTargetTestGlobal("untrusted", "en1") }}, {name: "primary changed", change: func(_ *nativeTargetReader, out []string) { - out[3] = nativeTargetTestGlobal(nativeTargetOtherServiceID, "en1") + out[4] = nativeTargetTestGlobal(nativeTargetOtherServiceID, "en1") }}, {name: "missing name", change: func(_ *nativeTargetReader, out []string) { out[2] = "No such key" }}, {name: "DHCP embedded dictionary", change: func(_ *nativeTargetReader, out []string) { out[1] = "DHCPPacket : " + out[1] }}, @@ -125,7 +130,7 @@ func TestNativeTargetReaderIdentityAndEvidence(t *testing.T) { if got != want { t.Fatalf("got %+v want %+v", got, want) } - wantCalls := []string{"show State:/Network/Global/IPv4\nquit\n", "show State:/Network/Service/" + nativeTargetTestServiceID + "/IPv4\nquit\n", "show Setup:/Network/Service/" + nativeTargetTestServiceID + "\nquit\n", "show State:/Network/Global/IPv4\nquit\n"} + wantCalls := []string{"show State:/Network/Global/IPv4\nquit\n", "show State:/Network/Service/" + nativeTargetTestServiceID + "/IPv4\nquit\n", "show Setup:/Network/Service/" + nativeTargetTestServiceID + "\nquit\n", "networksetup -listnetworkserviceorder", "show State:/Network/Global/IPv4\nquit\n"} if !slices.Equal(calls, wantCalls) { t.Fatalf("calls=%v", calls) } @@ -135,7 +140,7 @@ func TestNativeTargetReaderIdentityAndEvidence(t *testing.T) { } func TestNativeTargetReaderFailuresAndSharedBudget(t *testing.T) { - for failAt := 1; failAt <= 4; failAt++ { + for failAt := 1; failAt <= 5; failAt++ { t.Run(fmt.Sprint(failAt), func(t *testing.T) { var calls []string r := nativeTargetTestReader(t, nativeTargetTestOutputs(), &calls) diff --git a/cmd/cli/dns_target_service.go b/cmd/cli/dns_target_service.go new file mode 100644 index 0000000..273cc8a --- /dev/null +++ b/cmd/cli/dns_target_service.go @@ -0,0 +1,73 @@ +package cli + +import ( + "errors" + "regexp" + "strings" +) + +var targetServiceOrderEntry = regexp.MustCompile(`^\(([1-9][0-9]*|\*)\) (.+)$`) +var targetServiceDevice = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9]*$`) + +// networksetup uses its own service name, which can differ from the Dynamic +// Store's UserDefinedName (for example, iPhone USB versus iPhone). +// Its listing has no UUID, so require exactly one service on the proven +// device, and require that service to be enabled. +func targetNetworkServiceName(output, device string) (string, error) { + const header = "An asterisk (*) denotes that a network service is disabled." + lines := strings.Split(strings.TrimSpace(output), "\n") + if len(lines) == 0 || lines[0] != header || !targetServiceDevice.MatchString(device) { + return "", errors.New("invalid network service listing") + } + seen := make(map[string]bool) + name, pending := "", "" + disabled := false + matches := 0 + for _, line := range lines[1:] { + if strings.TrimSpace(line) == "" { + continue + } + if pending == "" { + m := targetServiceOrderEntry.FindStringSubmatch(line) + if m == nil { + return "", errors.New("invalid network service entry") + } + pending = m[2] + disabled = m[1] == "*" + if strings.HasPrefix(pending, "*") { + pending = strings.TrimPrefix(pending, "*") + disabled = true + } + if pending == "" || strings.ContainsAny(pending, "\r\n\x00") || seen[strings.ToLower(pending)] { + return "", errors.New("ambiguous network service name") + } + seen[strings.ToLower(pending)] = true + continue + } + if !strings.HasPrefix(line, "(Hardware Port: ") || !strings.HasSuffix(line, ")") { + return "", errors.New("invalid network service device") + } + at := strings.LastIndex(line, ", Device: ") + if at < 0 { + return "", errors.New("missing network service device") + } + dev := line[at+len(", Device: ") : len(line)-1] + if dev != "" && !targetServiceDevice.MatchString(dev) { + return "", errors.New("invalid network device name") + } + if dev == device { + if strings.ContainsAny(pending, "/{}") { + return "", errors.New("unsafe target service name") + } + matches++ + if !disabled { + name = pending + } + } + pending = "" + } + if pending != "" || matches != 1 || name == "" { + return "", errors.New("network service mapping is missing or ambiguous") + } + return name, nil +} diff --git a/cmd/cli/dns_target_service_test.go b/cmd/cli/dns_target_service_test.go new file mode 100644 index 0000000..83a84d9 --- /dev/null +++ b/cmd/cli/dns_target_service_test.go @@ -0,0 +1,94 @@ +package cli + +import ( + "context" + "fmt" + "strings" + "testing" +) + +func nativeTargetTestServiceOrder(name, device string) string { + return fmt.Sprintf("An asterisk (*) denotes that a network service is disabled.\n(1) %s\n(Hardware Port: Test, Device: %s)\n", name, device) +} + +// networksetup output captured on the failing USB fixture. The Dynamic Store +// name below models the name observed in ctrld's native-reader diagnostic. +const capturedUSBServiceOrder = `An asterisk (*) denotes that a network service is disabled. +(1) USB 10/100/1000 LAN +(Hardware Port: USB 10/100/1000 LAN, Device: en7) + +(2) Thunderbolt Bridge +(Hardware Port: Thunderbolt Bridge, Device: bridge0) + +(3) Wi-Fi +(Hardware Port: Wi-Fi, Device: en0) + +(4) iPhone USB +(Hardware Port: iPhone USB, Device: en8) + +(5) Tailscale +(Hardware Port: io.tailscale.ipn.macos, Device: ) +` + +func TestNativeUSBServiceName(t *testing.T) { + global := nativeTargetTestGlobal(nativeTargetTestServiceID, "en8") + outputs := []string{global, strings.ReplaceAll(nativeTargetTestState(), "en1", "en8"), " {\n UserDefinedName : iPhone\n}", capturedUSBServiceOrder, global} + var calls []string + reader := nativeTargetTestReader(t, outputs, &calls) + got, err := reader.defaultService(context.Background(), "en8") + if err != nil || got.ID != nativeTargetTestServiceID || got.Device != "en8" || got.Name != "iPhone USB" { + t.Fatalf("service=%+v err=%v", got, err) + } + dns, err := parseTargetStaticDNS("There aren't any DNS Servers set on iPhone USB.\n", got.Name) + if err != nil || len(dns) != 0 { + t.Fatalf("recognized USB DNS response rejected: %v %v", dns, err) + } +} + +func TestTargetNetworkServiceName(t *testing.T) { + base := nativeTargetTestServiceOrder("iPhone USB", "en8") + for _, tc := range []struct{ name, listing, want string }{ + {"captured USB", capturedUSBServiceOrder, "iPhone USB"}, + {"renamed", nativeTargetTestServiceOrder("Téléphone personnel", "en8"), "Téléphone personnel"}, + {"spaces preserved", nativeTargetTestServiceOrder(" iPhone USB ", "en8"), " iPhone USB "}, + {"enabled and disabled same device", base + "(*) Old USB\n(Hardware Port: Test, Device: en8)\n", ""}, + {"disabled duplicate on another device", base + "(*) iPhone USB\n(Hardware Port: Test, Device: en9)\n", ""}, + {"device boundary", nativeTargetTestServiceOrder("Other", "en80"), ""}, + {"disabled", strings.Replace(base, "(1)", "(*)", 1), ""}, + {"two enabled same device", base + "(2) Backup USB\n(Hardware Port: Test, Device: en8)\n", ""}, + {"duplicate command name", base + "(2) iPhone USB\n(Hardware Port: Test, Device: en9)\n", ""}, + {"truncated", base + "(2) Incomplete\n", ""}, + {"malformed device", strings.Replace(base, "Device: en8", "Device: en8 extra", 1), ""}, + {"unrecognized output", "Error: invalid parameter", ""}, + {"unsafe name", nativeTargetTestServiceOrder("../other", "en8"), ""}, + } { + t.Run(tc.name, func(t *testing.T) { + got, err := targetNetworkServiceName(tc.listing, "en8") + if tc.want == "" { + if err == nil { + t.Fatalf("accepted %q", got) + } + return + } + if err != nil || got != tc.want { + t.Fatalf("got=%q err=%v", got, err) + } + }) + } +} + +func TestNativeUSBServiceMappingRechecked(t *testing.T) { + outputs := nativeTargetTestOutputs() + var calls []string + reader := nativeTargetTestReader(t, outputs, &calls) + first, err := reader.defaultService(context.Background(), "en1") + if err != nil { + t.Fatal(err) + } + outputs[3] = nativeTargetTestServiceOrder("Renamed Wi-Fi", "en1") + calls = nil + second, err := reader.defaultService(context.Background(), "en1") + if err != nil || first == second || second.Name != "Renamed Wi-Fi" { + t.Fatalf("mapping change invisible: %+v %+v %v", first, second, err) + } +} diff --git a/cmd/cli/dns_target_usb_darwin_test.go b/cmd/cli/dns_target_usb_darwin_test.go new file mode 100644 index 0000000..b86a914 --- /dev/null +++ b/cmd/cli/dns_target_usb_darwin_test.go @@ -0,0 +1,89 @@ +//go:build darwin + +package cli + +import ( + "context" + "fmt" + "net" + "slices" + "strings" + "testing" +) + +func usbTargetTestReader(t *testing.T, listing string) nativeTargetReader { + t.Helper() + global := nativeTargetTestGlobal(nativeTargetTestServiceID, "en8") + outputs := []string{global, strings.ReplaceAll(nativeTargetTestState(), "en1", "en8"), " {\n UserDefinedName : iPhone\n}", listing, global} + var calls []string + return nativeTargetTestReader(t, outputs, &calls) +} + +func TestUSBServiceReaderDrivesTargetLifecycle(t *testing.T) { + for _, initial := range [][]string{nil, {"2001:db8::53"}} { + for _, port := range []int{53, 5353, 5354} { + t.Run(fmt.Sprintf("%d/%v", port, initial), func(t *testing.T) { + h := newInterceptTargetHarness(t) + h.serviceByDev["en8"] = "iPhone USB" + h.dns["iPhone USB"] = slices.Clone(initial) + interceptDefaultRouteInterfaceFn = func() (string, error) { return "en8", nil } + interceptNativeCLATDefaultServiceFn = func(ctx context.Context, device string) (nativeTargetService, error) { + return usbTargetTestReader(t, capturedUSBServiceOrder).defaultService(ctx, device) + } + interceptNativeStaticDNSFn = func(_ context.Context, iface *net.Interface) ([]string, error) { + if iface.Name != "iPhone USB" { + t.Fatalf("invalid command service %q", iface.Name) + } + return slices.Clone(h.dns[iface.Name]), nil + } + p := newInterceptTargetProg() + p.cfg.Listener["0"].IP = "127.0.0.2" + p.cfg.Listener["0"].Port = port + p.ensureInterceptDNSTarget([]string{}) + p.ensureInterceptDNSTarget([]string{}) + if !slices.Equal(h.dns["iPhone USB"], []string{p.interceptDNSTargetValue()}) || len(h.setCalls) != 1 { + t.Fatalf("USB target=%v writes=%v", h.dns, h.setCalls) + } + if _, ok := h.dns["iPhone"]; ok { + t.Fatal("wrote display-name service") + } + p.removeInterceptDNSTarget("USB test cleanup") + if !slices.Equal(h.dns["iPhone USB"], initial) || p.interceptDNSTargetService != "" { + t.Fatal("USB DNS not restored") + } + }) + } + } +} + +func TestUSBServiceMappingChangeAndAmbiguityDoNotWrite(t *testing.T) { + for _, scenario := range []string{"renamed on recheck", "ambiguous device"} { + t.Run(scenario, func(t *testing.T) { + h := newInterceptTargetHarness(t) + h.serviceByDev["en8"] = "iPhone USB" + original := []string{"2001:db8::53"} + h.dns["iPhone USB"] = slices.Clone(original) + interceptDefaultRouteInterfaceFn = func() (string, error) { return "en8", nil } + reads := 0 + interceptNativeCLATDefaultServiceFn = func(ctx context.Context, device string) (nativeTargetService, error) { + reads++ + listing := capturedUSBServiceOrder + if scenario == "renamed on recheck" && reads == 2 { + listing = strings.Replace(listing, "(4) iPhone USB", "(4) Renamed USB", 1) + } + if scenario == "ambiguous device" { + listing += "(6) iPhone\n(Hardware Port: iPhone USB, Device: en8)\n" + } + return usbTargetTestReader(t, listing).defaultService(ctx, device) + } + p := newInterceptTargetProg() + p.ensureInterceptDNSTarget([]string{}) + if len(h.setCalls) != 0 || len(h.resetCalls) != 0 || p.interceptDNSTargetService != "" || !slices.Equal(h.dns["iPhone USB"], original) { + t.Fatalf("uncertain mapping changed DNS: %+v", h) + } + if scenario == "renamed on recheck" && reads != 2 { + t.Fatal("did not exercise final mapping recheck") + } + }) + } +}