mirror of
https://github.com/Control-D-Inc/ctrld.git
synced 2026-09-04 13:36:35 +02:00
Port !997 from v1.0 onto the context-aware master recovery lifecycle. Preserve master logging and resolver APIs while adding macOS default-route DHCP detection, temporary DNS-target cleanup, and atomic recovery ownership. Includes parser, lifecycle, failure, and concurrency regressions plus the corrected macOS QA helper. Relates to #533 and #597.
249 lines
7.9 KiB
Go
249 lines
7.9 KiB
Go
//go:build darwin
|
|
|
|
package cli
|
|
|
|
import (
|
|
"errors"
|
|
"net"
|
|
"os"
|
|
"path/filepath"
|
|
"slices"
|
|
"testing"
|
|
|
|
"github.com/Control-D-Inc/ctrld"
|
|
)
|
|
|
|
type interceptTargetHarness struct {
|
|
dns map[string][]string
|
|
saved map[string][]string
|
|
serviceByDev map[string]string
|
|
dhcp []string
|
|
dhcpErr error
|
|
readErr error
|
|
setErr error
|
|
resetErr error
|
|
setCalls []string
|
|
resetCalls []string
|
|
statePath string
|
|
}
|
|
|
|
func newInterceptTargetHarness(t *testing.T) *interceptTargetHarness {
|
|
t.Helper()
|
|
h := &interceptTargetHarness{
|
|
dns: make(map[string][]string),
|
|
saved: make(map[string][]string),
|
|
serviceByDev: map[string]string{"en1": "Wi-Fi"},
|
|
statePath: filepath.Join(t.TempDir(), interceptDNSTargetStateFile),
|
|
}
|
|
|
|
origPath := interceptDNSTargetStatePathFn
|
|
origRoute := interceptDefaultRouteInterfaceFn
|
|
origIface := interceptInterfaceByNameFn
|
|
origPatch := interceptPatchNetIfaceNameFn
|
|
origCurrent := interceptCurrentStaticDNSFn
|
|
origSave := interceptSaveCurrentStaticDNSFn
|
|
origSet := interceptSetDNSFn
|
|
origSaved := interceptSavedStaticNameserversFn
|
|
origReset := interceptResetDNSIgnoreUnusableIfaceFn
|
|
origDHCP := interceptDHCPNameserversForInterfaceFn
|
|
origIntercept := dnsIntercept
|
|
t.Cleanup(func() {
|
|
interceptDNSTargetStatePathFn = origPath
|
|
interceptDefaultRouteInterfaceFn = origRoute
|
|
interceptInterfaceByNameFn = origIface
|
|
interceptPatchNetIfaceNameFn = origPatch
|
|
interceptCurrentStaticDNSFn = origCurrent
|
|
interceptSaveCurrentStaticDNSFn = origSave
|
|
interceptSetDNSFn = origSet
|
|
interceptSavedStaticNameserversFn = origSaved
|
|
interceptResetDNSIgnoreUnusableIfaceFn = origReset
|
|
interceptDHCPNameserversForInterfaceFn = origDHCP
|
|
dnsIntercept = origIntercept
|
|
})
|
|
|
|
dnsIntercept = true
|
|
interceptDNSTargetStatePathFn = func() string { return h.statePath }
|
|
interceptDefaultRouteInterfaceFn = func() (string, error) { return "en1", nil }
|
|
interceptInterfaceByNameFn = func(name string) (*net.Interface, error) { return &net.Interface{Name: name}, nil }
|
|
interceptPatchNetIfaceNameFn = func(iface *net.Interface) (bool, error) {
|
|
service, ok := h.serviceByDev[iface.Name]
|
|
if !ok {
|
|
return false, errors.New("unknown network service")
|
|
}
|
|
iface.Name = service
|
|
return true, nil
|
|
}
|
|
interceptCurrentStaticDNSFn = func(iface *net.Interface) ([]string, error) {
|
|
if h.readErr != nil {
|
|
return nil, h.readErr
|
|
}
|
|
return slices.Clone(h.dns[iface.Name]), nil
|
|
}
|
|
interceptSaveCurrentStaticDNSFn = func(iface *net.Interface) error {
|
|
h.saved[iface.Name] = slices.Clone(h.dns[iface.Name])
|
|
return nil
|
|
}
|
|
interceptSetDNSFn = func(iface *net.Interface, nameservers []string) error {
|
|
h.setCalls = append(h.setCalls, iface.Name)
|
|
if h.setErr != nil {
|
|
return h.setErr
|
|
}
|
|
h.dns[iface.Name] = slices.Clone(nameservers)
|
|
return nil
|
|
}
|
|
interceptSavedStaticNameserversFn = func(iface *net.Interface) []string {
|
|
return slices.Clone(h.saved[iface.Name])
|
|
}
|
|
interceptResetDNSIgnoreUnusableIfaceFn = func(iface *net.Interface) error {
|
|
h.resetCalls = append(h.resetCalls, iface.Name)
|
|
if h.resetErr != nil {
|
|
return h.resetErr
|
|
}
|
|
h.dns[iface.Name] = nil
|
|
return nil
|
|
}
|
|
interceptDHCPNameserversForInterfaceFn = func(iface string) ([]string, error) {
|
|
if iface != "en1" {
|
|
return nil, errors.New("DHCP lookup used a non-default interface")
|
|
}
|
|
return slices.Clone(h.dhcp), h.dhcpErr
|
|
}
|
|
return h
|
|
}
|
|
|
|
func newInterceptTargetProg() *prog {
|
|
return &prog{
|
|
cfg: &ctrld.Config{Listener: map[string]*ctrld.ListenerConfig{
|
|
"0": {IP: "127.0.0.1", Port: 5354},
|
|
}},
|
|
dnsInterceptState: &interceptStateStub{},
|
|
}
|
|
}
|
|
|
|
func persistInterceptTargetForTest(t *testing.T, p *prog, service, value string) {
|
|
t.Helper()
|
|
p.interceptDNSTargetMu.Lock()
|
|
defer p.interceptDNSTargetMu.Unlock()
|
|
p.interceptDNSTargetLoaded = true
|
|
p.interceptDNSTargetService = service
|
|
p.interceptDNSTargetSetValue = value
|
|
p.persistInterceptDNSTargetStateLocked()
|
|
}
|
|
|
|
func TestEnsureInterceptDNSTargetRequiresCompletedDiscovery(t *testing.T) {
|
|
h := newInterceptTargetHarness(t)
|
|
p := newInterceptTargetProg()
|
|
p.ensureInterceptDNSTarget(nil)
|
|
if len(h.setCalls) != 0 || len(h.resetCalls) != 0 {
|
|
t.Fatal("nil system discovery changed service DNS")
|
|
}
|
|
}
|
|
|
|
func TestEnsureInterceptDNSTargetMigratesService(t *testing.T) {
|
|
h := newInterceptTargetHarness(t)
|
|
p := newInterceptTargetProg()
|
|
persistInterceptTargetForTest(t, p, "iPhone USB", "127.0.0.53")
|
|
h.dns["iPhone USB"] = []string{"127.0.0.53"}
|
|
h.dns["Wi-Fi"] = nil
|
|
|
|
p.ensureInterceptDNSTarget([]string{})
|
|
|
|
if len(h.dns["iPhone USB"]) != 0 {
|
|
t.Fatalf("old service DNS = %v, want empty", h.dns["iPhone USB"])
|
|
}
|
|
if got := h.dns["Wi-Fi"]; !slices.Equal(got, []string{"127.0.0.53"}) {
|
|
t.Fatalf("new service DNS = %v, want [127.0.0.53]", got)
|
|
}
|
|
if p.interceptDNSTargetService != "Wi-Fi" || p.interceptDNSTargetSetValue != "127.0.0.53" {
|
|
t.Fatalf("tracking = %q/%q, want Wi-Fi/127.0.0.53", p.interceptDNSTargetService, p.interceptDNSTargetSetValue)
|
|
}
|
|
}
|
|
|
|
func TestEnsureInterceptDNSTargetUsesDefaultRouteDHCPOnly(t *testing.T) {
|
|
t.Run("other interface IPv4 does not suppress target", func(t *testing.T) {
|
|
h := newInterceptTargetHarness(t)
|
|
p := newInterceptTargetProg()
|
|
p.ensureInterceptDNSTarget([]string{"10.10.10.1"})
|
|
if got := h.dns["Wi-Fi"]; !slices.Equal(got, []string{"127.0.0.53"}) {
|
|
t.Fatalf("other interface DNS suppressed target: %v", got)
|
|
}
|
|
})
|
|
|
|
t.Run("returned default route DHCP removes target", func(t *testing.T) {
|
|
h := newInterceptTargetHarness(t)
|
|
p := newInterceptTargetProg()
|
|
persistInterceptTargetForTest(t, p, "Wi-Fi", "127.0.0.53")
|
|
h.dns["Wi-Fi"] = []string{"127.0.0.53"}
|
|
h.dhcp = []string{"192.168.10.1"}
|
|
|
|
p.ensureInterceptDNSTarget([]string{"10.10.10.1"})
|
|
|
|
if len(h.dns["Wi-Fi"]) != 0 || p.interceptDNSTargetService != "" {
|
|
t.Fatalf("returned default-route DHCP DNS did not remove target: dns=%v service=%q", h.dns["Wi-Fi"], p.interceptDNSTargetService)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestRemoveInterceptDNSTargetRestoresStateFileAfterRestart(t *testing.T) {
|
|
h := newInterceptTargetHarness(t)
|
|
h.dns["iPhone USB"] = []string{"127.0.0.53"}
|
|
if err := os.WriteFile(h.statePath, []byte(`{"service":"iPhone USB","value":"127.0.0.53"}`), 0600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
p := newInterceptTargetProg()
|
|
|
|
p.removeInterceptDNSTarget("intercept mode inactive")
|
|
|
|
if len(h.dns["iPhone USB"]) != 0 || p.interceptDNSTargetService != "" {
|
|
t.Fatalf("restart cleanup failed: dns=%v service=%q", h.dns["iPhone USB"], p.interceptDNSTargetService)
|
|
}
|
|
if _, err := os.Stat(h.statePath); !os.IsNotExist(err) {
|
|
t.Fatalf("state file still exists after cleanup: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestRemoveInterceptDNSTargetKeepsExternalDNS(t *testing.T) {
|
|
h := newInterceptTargetHarness(t)
|
|
p := newInterceptTargetProg()
|
|
persistInterceptTargetForTest(t, p, "Wi-Fi", "127.0.0.53")
|
|
h.dns["Wi-Fi"] = []string{"8.8.8.8"}
|
|
|
|
p.removeInterceptDNSTarget("test")
|
|
|
|
if !slices.Equal(h.dns["Wi-Fi"], []string{"8.8.8.8"}) || len(h.setCalls) != 0 || len(h.resetCalls) != 0 {
|
|
t.Fatalf("external DNS was changed: dns=%v set=%v reset=%v", h.dns["Wi-Fi"], h.setCalls, h.resetCalls)
|
|
}
|
|
if p.interceptDNSTargetService != "" {
|
|
t.Fatal("external change left stale ownership tracking")
|
|
}
|
|
}
|
|
|
|
func TestRemoveInterceptDNSTargetRetainsStateOnFailure(t *testing.T) {
|
|
for _, tc := range []struct {
|
|
name string
|
|
readErr error
|
|
resetErr error
|
|
}{
|
|
{"read failure", errors.New("networksetup read failed"), nil},
|
|
{"restore failure", nil, errors.New("networksetup reset failed")},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
h := newInterceptTargetHarness(t)
|
|
p := newInterceptTargetProg()
|
|
persistInterceptTargetForTest(t, p, "Wi-Fi", "127.0.0.53")
|
|
h.dns["Wi-Fi"] = []string{"127.0.0.53"}
|
|
h.readErr = tc.readErr
|
|
h.resetErr = tc.resetErr
|
|
|
|
p.removeInterceptDNSTarget("test")
|
|
|
|
if p.interceptDNSTargetService != "Wi-Fi" || p.interceptDNSTargetSetValue != "127.0.0.53" {
|
|
t.Fatal("failed cleanup discarded retry state")
|
|
}
|
|
if _, err := os.Stat(h.statePath); err != nil {
|
|
t.Fatalf("failed cleanup removed persisted retry state: %v", err)
|
|
}
|
|
})
|
|
}
|
|
}
|