package cli import ( "context" "errors" "sync" "sync/atomic" "testing" "time" "github.com/Control-D-Inc/ctrld" ) func withVPNDNSSettlingEnabled(t *testing.T) { t.Helper() old := vpnDNSSettlingEnabled vpnDNSSettlingEnabled = true t.Cleanup(func() { vpnDNSSettlingEnabled = old }) } func TestVPNDNSRefreshCoalescesConcurrentTrailingRefresh(t *testing.T) { m := newVPNDNSManager(&mainLog, nil) started := make(chan struct{}) release := make(chan struct{}) done := make(chan struct{}) var once sync.Once var calls atomic.Int32 m.discoverVPNDNS = func(context.Context) []ctrld.VPNDNSConfig { call := calls.Add(1) once.Do(func() { close(started) }) <-release if call == 2 { return []ctrld.VPNDNSConfig{{ InterfaceName: "utun-latest", Servers: []string{"10.0.0.2"}, Domains: []string{"latest.internal"}, }} } return nil } go func() { defer close(done) m.Refresh(context.Background(), true) }() <-started m.Refresh(context.Background(), true) close(release) <-done if calls.Load() != 2 { t.Fatalf("expected one active and one trailing discovery call, got %d", calls.Load()) } if got := m.Routes()["latest.internal"]; len(got) != 1 || got[0] != "10.0.0.2" { t.Fatalf("trailing refresh did not publish latest OS snapshot: %v", got) } } func TestVPNDNSRefreshRetainsStateForOneGuardedEmptyDiscovery(t *testing.T) { withVPNDNSSettlingEnabled(t) var gotExemptions []vpnDNSExemption m := newVPNDNSManager(&mainLog, func(exemptions []vpnDNSExemption) error { gotExemptions = exemptions return nil }) m.discoverVPNDNS = func(context.Context) []ctrld.VPNDNSConfig { return nil } m.configs = []ctrld.VPNDNSConfig{{ InterfaceName: "Ethernet 6", Servers: []string{"10.25.37.21", "10.25.37.22"}, }} m.domainlessServers = []string{"10.25.37.21", "10.25.37.22"} m.Refresh(context.Background(), true) if got := m.DomainlessServers(); len(got) != 2 { t.Fatalf("expected retained domainless servers, got %v", got) } if len(gotExemptions) != 2 { t.Fatalf("expected retained exemptions to be re-applied, got %v", gotExemptions) } if !m.retainedAfterEmptyDiscovery { t.Fatal("expected empty discovery retention to be marked") } } func TestVPNDNSRefreshClearsOnSecondGuardedEmptyDiscovery(t *testing.T) { withVPNDNSSettlingEnabled(t) var gotExemptions []vpnDNSExemption updates := 0 m := newVPNDNSManager(&mainLog, func(exemptions []vpnDNSExemption) error { updates++ gotExemptions = exemptions return nil }) m.discoverVPNDNS = func(context.Context) []ctrld.VPNDNSConfig { return nil } m.configs = []ctrld.VPNDNSConfig{{ InterfaceName: "Ethernet 6", Servers: []string{"10.25.37.21"}, }} m.domainlessServers = []string{"10.25.37.21"} m.appliedExemptions = []vpnDNSExemption{{Server: "10.25.37.21", Interface: "Ethernet 6"}} m.retainedAfterEmptyDiscovery = true m.Refresh(context.Background(), true) if got := m.DomainlessServers(); len(got) != 0 { t.Fatalf("expected domainless servers to be cleared on second empty discovery, got %v", got) } if updates != 1 || len(gotExemptions) != 0 { t.Fatalf("expected one empty exemption update after clearing stale state, calls=%d exemptions=%v", updates, gotExemptions) } if m.retainedAfterEmptyDiscovery { t.Fatal("expected retained empty-discovery marker to be cleared with stale state") } } func TestVPNDNSRefreshSkipsUnchangedInterceptExemptions(t *testing.T) { var updates [][]vpnDNSExemption m := newVPNDNSManager(&mainLog, func(exemptions []vpnDNSExemption) error { updates = append(updates, append([]vpnDNSExemption{}, exemptions...)) return nil }) m.discoverVPNDNS = func(context.Context) []ctrld.VPNDNSConfig { return []ctrld.VPNDNSConfig{{ InterfaceName: "utun-test", Servers: []string{"10.102.26.10"}, Domains: []string{"example.internal"}, }} } m.Refresh(context.Background(), true) m.Refresh(context.Background(), true) if len(updates) != 1 { t.Fatalf("expected exactly one intercept exemption update for unchanged VPN DNS state, got %d", len(updates)) } if len(updates[0]) != 1 || updates[0][0].Server != "10.102.26.10" || updates[0][0].Interface != "utun-test" { t.Fatalf("unexpected exemption update: %+v", updates[0]) } } func TestVPNDNSRefreshRetriesFailedInterceptExemptionUpdate(t *testing.T) { attempts := 0 m := newVPNDNSManager(&mainLog, func([]vpnDNSExemption) error { attempts++ if attempts == 1 { return errors.New("pf update failed") } return nil }) m.discoverVPNDNS = func(context.Context) []ctrld.VPNDNSConfig { return []ctrld.VPNDNSConfig{{ InterfaceName: "utun-test", Servers: []string{"10.102.26.10"}, Domains: []string{"internal.test"}, }} } m.Refresh(context.Background(), true) if !m.interceptExemptionsPending() { t.Fatal("failed intercept exemption update was not retained for retry") } m.Refresh(context.Background(), true) if m.interceptExemptionsPending() { t.Fatal("successful intercept exemption retry did not advance applied state") } m.Refresh(context.Background(), true) if attempts != 2 { t.Fatalf("intercept exemption update attempts = %d, want failed attempt plus one retry", attempts) } if len(m.appliedExemptions) != 1 || m.appliedExemptions[0].Server != "10.102.26.10" { t.Fatalf("applied exemptions = %+v, want successful retry state", m.appliedExemptions) } } func TestVPNDNSMarkAppliedExemptionsRejectsStaleSnapshot(t *testing.T) { m := newVPNDNSManager(&mainLog, nil) m.configs = []ctrld.VPNDNSConfig{{InterfaceName: "utun-new", Servers: []string{"10.0.0.2"}}} m.markInterceptExemptionsApplied([]vpnDNSExemption{{Server: "10.0.0.1", Interface: "utun-old"}}) if !m.interceptExemptionsPending() { t.Fatal("stale PF snapshot incorrectly advanced applied exemptions") } m.markInterceptExemptionsApplied([]vpnDNSExemption{{Server: "10.0.0.2", Interface: "utun-new"}}) if m.interceptExemptionsPending() { t.Fatal("current PF snapshot did not advance applied exemptions") } } func TestVPNDNSTransportFailureSuppressesFallbackOnlyWhileRetainingState(t *testing.T) { withVPNDNSSettlingEnabled(t) m := newVPNDNSManager(&mainLog, nil) m.domainlessServers = []string{"10.25.37.21"} if m.ShouldFailClosedAfterVPNDNSTransportFailure("splunk.aws.arena.net.", []string{"10.25.37.21"}) { t.Fatal("did not expect transport failure to suppress OS fallback outside retained empty-discovery state") } m.retainedAfterEmptyDiscovery = true if !m.ShouldFailClosedAfterVPNDNSTransportFailure("splunk.aws.arena.net.", []string{"10.25.37.21"}) { t.Fatal("expected transport failure to suppress OS fallback while retained state is active") } m.VPNDNSReachable() if m.retainedAfterEmptyDiscovery { t.Fatal("expected reachable DNS response to clear retained empty-discovery state") } } func TestVPNDNSFullAndRouteOnlyDiscoveryAreSerialized(t *testing.T) { var updateMu sync.Mutex var exemptionUpdates []string m := newVPNDNSManager(&mainLog, func(exemptions []vpnDNSExemption) error { updateMu.Lock() defer updateMu.Unlock() if len(exemptions) == 0 { exemptionUpdates = append(exemptionUpdates, "") } else { exemptionUpdates = append(exemptionUpdates, exemptions[0].Server) } return nil }) firstStarted := make(chan struct{}) releaseFirst := make(chan struct{}) secondStarted := make(chan struct{}) var calls atomic.Int32 m.discoverVPNDNS = func(context.Context) []ctrld.VPNDNSConfig { switch calls.Add(1) { case 1: close(firstStarted) <-releaseFirst return []ctrld.VPNDNSConfig{{ InterfaceName: "utun-old", Servers: []string{"10.0.0.1"}, Domains: []string{"old.internal"}, }} case 2: close(secondStarted) return []ctrld.VPNDNSConfig{{ InterfaceName: "utun-new", Servers: []string{"10.0.0.2"}, Domains: []string{"new.internal"}, }} default: t.Fatalf("unexpected discovery call %d", calls.Load()) return nil } } routesDone := make(chan struct{}) go func() { defer close(routesDone) m.RefreshRoutesOnly() }() <-firstStarted fullDone := make(chan struct{}) go func() { defer close(fullDone) m.Refresh(context.Background(), false) }() select { case <-secondStarted: t.Fatal("full and route-only VPN DNS discovery overlapped") case <-time.After(50 * time.Millisecond): } close(releaseFirst) select { case <-routesDone: case <-time.After(time.Second): t.Fatal("route-only refresh did not finish") } select { case <-fullDone: case <-time.After(time.Second): t.Fatal("full refresh did not finish") } routes := m.Routes() if _, ok := routes["old.internal"]; ok { t.Fatalf("older route-only snapshot overwrote newer full refresh: %v", routes) } if got := routes["new.internal"]; len(got) != 1 || got[0] != "10.0.0.2" { t.Fatalf("final VPN DNS routes = %v, want new.internal -> 10.0.0.2", routes) } updateMu.Lock() defer updateMu.Unlock() if len(exemptionUpdates) != 2 || exemptionUpdates[0] != "10.0.0.1" || exemptionUpdates[1] != "10.0.0.2" { t.Fatalf("serialized exemption updates = %v, want old then new", exemptionUpdates) } }