Files
ctrld/cmd/cli/dns_target_darwin_test.go
T
Anthony Wong 8ae09ac98e feat(cli): put the network state of the host in the log journal
The journal records the network state of the host and each change of
that state. The support team reads one file to see the interfaces, the
routes, the resolvers, and the recoveries around an incident. The same
file shows the health of the query path. A change that touches only
AirDrop or virtual interfaces runs no DNS work.
2026-09-28 16:49:05 +07:00

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: &interceptStateStub{},
}
}
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")
}
}