mirror of
https://github.com/Control-D-Inc/ctrld.git
synced 2026-09-04 13:36:35 +02:00
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.
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)
|
|
}
|
|
})
|
|
}
|
|
}
|