Files
ctrld/cmd/cli/dns_target_darwin_test.go
T
Dev Scribe 8013bb72d2 dns intercept: handle DNS-less networks and canceled-recovery state leak
macOS intercept mode fails totally on networks that provide no usable
IPv4 DNS (e.g. IPv6-only iPhone tethering with 464XLAT): the pf ruleset
blocks all outbound IPv6 port 53, and with no IPv4 DNS configured
mDNSResponder emits no DNS packets at all, so pf has nothing to
intercept while the Control D upstream stays provably healthy.

When network-change recovery discovers no usable IPv4 DNS on the
default-route service, set 127.0.0.1 as that service DNS so macOS can
emit queries that land directly on the listener. The entry is removed
when the network regains IPv4 DNS and on intercept shutdown; networks
that provide IPv4 DNS are never modified. Runs on the already-debounced
recovery path so interface flaps do not churn networksetup.

Also fix the canceled-recovery state leak: the cancellation early
return never reset recoveryBypass/recoveryRunning, so a flap burst
ending in a canceled recovery left the daemon in bypass forever with
the DNS watchdog disabled. Cleanup is generation-gated so a superseded
recovery never clears state owned by its successor.
2026-09-01 11:08:13 +07:00

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