mirror of
https://github.com/Control-D-Inc/ctrld.git
synced 2026-10-12 01:58:46 +02:00
macOS: use the networksetup service name for USB CLAT targets
This commit is contained in:
1 parent
55190faaee
commit
f5a4cb69ed
7 files changed
+285
-11
No files matched your search
@@ -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
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user