mirror of
https://github.com/Control-D-Inc/ctrld.git
synced 2026-10-12 01:58:46 +02:00
350 lines
11 KiB
Go
350 lines
11 KiB
Go
//go:build darwin
|
|
|
|
package cli
|
|
|
|
import (
|
|
"errors"
|
|
"net"
|
|
"os"
|
|
"path/filepath"
|
|
"slices"
|
|
"strings"
|
|
"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: &pfState{},
|
|
}
|
|
}
|
|
|
|
func persistInterceptTargetForTest(t *testing.T, p *prog, service, value string) {
|
|
t.Helper()
|
|
p.interceptDNSTargetMu.Lock()
|
|
defer p.interceptDNSTargetMu.Unlock()
|
|
p.interceptDNSTargetLoaded = true
|
|
p.setInterceptDNSTargetLocked(service, 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)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// interceptTargetEvent returns the single event whose message starts with the
|
|
// given text. The two target events name the service and the value in their
|
|
// message, so no test can match a whole message.
|
|
func interceptTargetEvent(t *testing.T, logs *syncBuffer, prefix string) map[string]any {
|
|
t.Helper()
|
|
var found []map[string]any
|
|
for _, event := range jsonLogEvents(t, logs, "") {
|
|
message, _ := event["message"].(string)
|
|
if strings.HasPrefix(message, prefix) {
|
|
found = append(found, event)
|
|
}
|
|
}
|
|
if len(found) != 1 {
|
|
t.Fatalf("events with prefix %q: got %d, want 1", prefix, len(found))
|
|
}
|
|
return found[0]
|
|
}
|
|
|
|
func TestEnsureInterceptDNSTargetJournalsTheSetTarget(t *testing.T) {
|
|
logs := captureDebugMainLog(t)
|
|
newInterceptTargetHarness(t)
|
|
p := newInterceptTargetProg()
|
|
|
|
p.ensureInterceptDNSTarget([]string{})
|
|
|
|
event := interceptTargetEvent(t, logs, "intercept DNS target: service ")
|
|
wantField(t, event, "journal", true)
|
|
wantField(t, event, "service", "Wi-Fi")
|
|
wantField(t, event, "target", "127.0.0.53")
|
|
wantField(t, event, "reason", "dns_less_network")
|
|
}
|
|
|
|
func TestRemoveInterceptDNSTargetJournalsTheRemovedTarget(t *testing.T) {
|
|
logs := captureDebugMainLog(t)
|
|
h := newInterceptTargetHarness(t)
|
|
p := newInterceptTargetProg()
|
|
persistInterceptTargetForTest(t, p, "Wi-Fi", "127.0.0.53")
|
|
h.dns["Wi-Fi"] = []string{"127.0.0.53"}
|
|
|
|
p.removeInterceptDNSTarget("network has usable static IPv4 DNS")
|
|
|
|
event := interceptTargetEvent(t, logs, "intercept DNS target: removed ")
|
|
wantField(t, event, "journal", true)
|
|
wantField(t, event, "service", "Wi-Fi")
|
|
wantField(t, event, "target", "127.0.0.53")
|
|
wantField(t, event, "reason", "network has usable static IPv4 DNS")
|
|
}
|
|
|
|
// TestInterceptDNSTargetSnapshotFollowsTheWritesWithoutTheLock covers the read
|
|
// that a network report uses. The target mutex is held across networksetup
|
|
// calls that take seconds, so a report that takes it waits for them.
|
|
func TestInterceptDNSTargetSnapshotFollowsTheWritesWithoutTheLock(t *testing.T) {
|
|
h := newInterceptTargetHarness(t)
|
|
p := newInterceptTargetProg()
|
|
h.dns["Wi-Fi"] = nil
|
|
|
|
p.ensureInterceptDNSTarget([]string{})
|
|
|
|
p.interceptDNSTargetMu.Lock()
|
|
written := p.interceptDNSTargetSetValue
|
|
got := p.interceptTargetSnapshot()
|
|
p.interceptDNSTargetMu.Unlock()
|
|
if written == "" {
|
|
t.Fatal("the pass wrote no target")
|
|
}
|
|
if got != written {
|
|
t.Fatalf("target snapshot = %q, want %q", got, written)
|
|
}
|
|
|
|
p.removeInterceptDNSTarget("test")
|
|
|
|
p.interceptDNSTargetMu.Lock()
|
|
got = p.interceptTargetSnapshot()
|
|
p.interceptDNSTargetMu.Unlock()
|
|
if got != "" {
|
|
t.Fatalf("target snapshot after the removal = %q, want an empty value", got)
|
|
}
|
|
}
|
|
|
|
// TestRemoveInterceptDNSTargetJournalsAnExternalChange covers the branch that
|
|
// drops the ownership record. The user or another program owns the DNS of the
|
|
// service from that moment, and the journal held nothing about it.
|
|
func TestRemoveInterceptDNSTargetJournalsAnExternalChange(t *testing.T) {
|
|
logs := captureDebugMainLog(t)
|
|
h := newInterceptTargetHarness(t)
|
|
p := newInterceptTargetProg()
|
|
persistInterceptTargetForTest(t, p, "Wi-Fi", "127.0.0.53")
|
|
h.dns["Wi-Fi"] = []string{"9.9.9.9"}
|
|
|
|
p.removeInterceptDNSTarget("intercept shutdown")
|
|
|
|
event := oneRecoveryEvent(t, logs, `intercept DNS target: "Wi-Fi" DNS changed externally; not removing (intercept shutdown)`)
|
|
wantField(t, event, "journal", true)
|
|
wantField(t, event, "service", "Wi-Fi")
|
|
wantField(t, event, "target", "127.0.0.53")
|
|
wantField(t, event, "reason", "external_change")
|
|
if p.interceptTargetSnapshot() != "" {
|
|
t.Fatal("the ownership record survived an external change")
|
|
}
|
|
}
|