macOS: use the networksetup service name for USB CLAT targets

This commit is contained in:
Dev Scribe authored and Cuong Manh Le committed 2026-09-28 16:55:27 +07:00
1 parent 7613c05076
commit d779b5374c
7 files changed
+285 -11

No files matched your search

+9 -1
View File
@@ -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
}
+4 -1
View File
@@ -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")
+6 -4
View File
@@ -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 {
+10 -5
View File
@@ -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(), "<dictionary> {\n UserDefinedName : Primary Wi-Fi\n}", global}
return []string{global, nativeTargetTestState(), "<dictionary> {\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)
+73
View File
@@ -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
}
+94
View File
@@ -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"), "<dictionary> {\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)
}
}
+89
View File
@@ -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"), "<dictionary> {\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")
}
})
}
}