mirror of
https://github.com/Control-D-Inc/ctrld.git
synced 2026-10-12 01:58:46 +02:00
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.
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: &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")
|
|
}
|
|
}
|