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