mirror of
https://github.com/Control-D-Inc/ctrld.git
synced 2026-09-04 13:36:35 +02:00
fix: validate pf state before stabilization
This commit is contained in:
@@ -3,9 +3,16 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"tailscale.com/net/netmon"
|
||||
|
||||
"github.com/Control-D-Inc/ctrld"
|
||||
)
|
||||
@@ -215,3 +222,609 @@ func TestIsResourceExhaustion(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func stubPFAnchorCheckCommand(t *testing.T, outputs map[string]string) {
|
||||
t.Helper()
|
||||
original := runPFAnchorCheckCommand
|
||||
runPFAnchorCheckCommand = func(args ...string) ([]byte, error) {
|
||||
key := strings.Join(args, " ")
|
||||
output, ok := outputs[key]
|
||||
if !ok {
|
||||
t.Fatalf("unexpected pf anchor check command: pfctl %s", key)
|
||||
}
|
||||
return []byte(output), nil
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
runPFAnchorCheckCommand = original
|
||||
})
|
||||
}
|
||||
|
||||
func TestEnsurePFAnchorActiveRecentRestoreWithIntactRulesDoesNotStabilize(t *testing.T) {
|
||||
stubPFAnchorCheckCommand(t, map[string]string{
|
||||
"-sn": `rdr-anchor "com.controld.ctrld"`,
|
||||
"-sr": `anchor "com.controld.ctrld"`,
|
||||
"-a com.controld.ctrld -sr": "pass in quick on lo0",
|
||||
"-a com.controld.ctrld -sn": "rdr on lo0",
|
||||
})
|
||||
|
||||
p := &prog{
|
||||
dnsInterceptState: &pfState{},
|
||||
stopCh: make(chan struct{}),
|
||||
}
|
||||
restoredAt := time.Now().Add(-time.Second).UnixMilli()
|
||||
p.pfLastRestoreTime.Store(restoredAt)
|
||||
|
||||
if result := p.ensurePFAnchorActive(); result != pfAnchorCheckIntact {
|
||||
t.Fatalf("intact rules result = %v, want intact", result)
|
||||
}
|
||||
if p.pfBackoffMultiplier.Load() != 0 {
|
||||
t.Fatalf("intact rules incremented backoff to %d", p.pfBackoffMultiplier.Load())
|
||||
}
|
||||
if p.pfStabilizing.Load() {
|
||||
t.Fatal("intact rules must not enter stabilization")
|
||||
}
|
||||
if got := p.pfLastRestoreTime.Load(); got != restoredAt {
|
||||
t.Fatalf("intact check changed restore timestamp: got %d, want %d", got, restoredAt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsurePFAnchorActiveCheckFailureIsNotIntact(t *testing.T) {
|
||||
original := runPFAnchorCheckCommand
|
||||
runPFAnchorCheckCommand = func(...string) ([]byte, error) {
|
||||
return nil, errors.New("pfctl unavailable")
|
||||
}
|
||||
t.Cleanup(func() { runPFAnchorCheckCommand = original })
|
||||
|
||||
p := &prog{dnsInterceptState: &pfState{}}
|
||||
if result := p.ensurePFAnchorActive(); result != pfAnchorCheckFailed {
|
||||
t.Fatalf("failed PF inspection result = %v, want failed", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsurePFAnchorActiveRecentActualWipeStartsStabilization(t *testing.T) {
|
||||
stubPFAnchorCheckCommand(t, map[string]string{
|
||||
"-sn": "",
|
||||
})
|
||||
|
||||
stopCh := make(chan struct{})
|
||||
close(stopCh)
|
||||
p := &prog{
|
||||
dnsInterceptState: &pfState{},
|
||||
stopCh: stopCh,
|
||||
}
|
||||
restoredAt := time.Now().Add(-time.Second).UnixMilli()
|
||||
p.pfLastRestoreTime.Store(restoredAt)
|
||||
|
||||
if result := p.ensurePFAnchorActive(); result != pfAnchorCheckDeferred {
|
||||
t.Fatalf("recent repeated wipe result = %v, want deferred", result)
|
||||
}
|
||||
if got := p.pfBackoffMultiplier.Load(); got != 1 {
|
||||
t.Fatalf("recent repeated wipe backoff = %d, want 1", got)
|
||||
}
|
||||
if got := p.pfLastRestoreTime.Load(); got != restoredAt {
|
||||
t.Fatalf("deferred restore changed restore timestamp: got %d, want %d", got, restoredAt)
|
||||
}
|
||||
deadline := time.Now().Add(time.Second)
|
||||
for p.pfStabilizing.Load() && time.Now().Before(deadline) {
|
||||
time.Sleep(time.Millisecond)
|
||||
}
|
||||
if p.pfStabilizing.Load() {
|
||||
t.Fatal("stabilization goroutine did not observe closed stop channel")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDNSInterceptIgnoredChangeReconcileDue(t *testing.T) {
|
||||
p := &prog{}
|
||||
start := time.Unix(1_000_000, 0)
|
||||
|
||||
if !p.dnsInterceptIgnoredChangeReconcileDue(start) {
|
||||
t.Fatal("first ignored change must reconcile immediately")
|
||||
}
|
||||
if p.dnsInterceptIgnoredChangeReconcileDue(start.Add(pfIgnoredChangeReconcileInterval - time.Millisecond)) {
|
||||
t.Fatal("ignored changes inside the interval must be coalesced")
|
||||
}
|
||||
if !p.dnsInterceptIgnoredChangeReconcileDue(start.Add(pfIgnoredChangeReconcileInterval)) {
|
||||
t.Fatal("continuous ignored changes must reconcile again at the interval boundary")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIgnoredNetworkChangeCallbackBoundsWorkWithoutBurningStabilizedSlot(t *testing.T) {
|
||||
outputs := map[string]string{
|
||||
"-sn": `rdr-anchor "com.controld.ctrld"`,
|
||||
"-sr": `anchor "com.controld.ctrld"`,
|
||||
"-a com.controld.ctrld -sr": "pass in quick on lo0",
|
||||
"-a com.controld.ctrld -sn": "rdr on lo0",
|
||||
}
|
||||
originalCheck := runPFAnchorCheckCommand
|
||||
pfChecks := 0
|
||||
runPFAnchorCheckCommand = func(args ...string) ([]byte, error) {
|
||||
key := strings.Join(args, " ")
|
||||
output, ok := outputs[key]
|
||||
if !ok {
|
||||
t.Fatalf("unexpected pf anchor check command: pfctl %s", key)
|
||||
}
|
||||
if key == "-sn" {
|
||||
pfChecks++
|
||||
}
|
||||
return []byte(output), nil
|
||||
}
|
||||
originalDiscover := discoverTunnelInterfacesForReconcile
|
||||
discoverTunnelInterfacesForReconcile = func() []string { return nil }
|
||||
t.Cleanup(func() {
|
||||
runPFAnchorCheckCommand = originalCheck
|
||||
discoverTunnelInterfacesForReconcile = originalDiscover
|
||||
})
|
||||
|
||||
refreshes := 0
|
||||
vpnDNS := newVPNDNSManager(&mainLog, nil)
|
||||
vpnDNS.discoverVPNDNS = func(context.Context) []ctrld.VPNDNSConfig {
|
||||
refreshes++
|
||||
return nil
|
||||
}
|
||||
p := &prog{dnsInterceptState: &pfState{}, vpnDNS: vpnDNS}
|
||||
t.Cleanup(func() {
|
||||
p.pfDelayedRecheckMu.Lock()
|
||||
defer p.pfDelayedRecheckMu.Unlock()
|
||||
for _, timer := range p.pfDelayedRecheckTimers {
|
||||
if timer != nil {
|
||||
timer.Stop()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
delta := &netmon.ChangeDelta{
|
||||
Old: &netmon.State{Interface: map[string]netmon.Interface{}},
|
||||
New: &netmon.State{Interface: map[string]netmon.Interface{}},
|
||||
}
|
||||
start := time.Unix(1_000_000, 0)
|
||||
p.handleDNSInterceptIgnoredNetworkChange(delta, start)
|
||||
if pfChecks != 1 || refreshes != 1 {
|
||||
t.Fatalf("first ignored delta work: pf checks=%d refreshes=%d, want 1 each", pfChecks, refreshes)
|
||||
}
|
||||
|
||||
p.pfStabilizing.Store(true)
|
||||
p.handleDNSInterceptIgnoredNetworkChange(delta, start.Add(pfIgnoredChangeReconcileInterval))
|
||||
if pfChecks != 1 || refreshes != 1 {
|
||||
t.Fatalf("stabilized delta ran leading reconciliation: pf checks=%d refreshes=%d", pfChecks, refreshes)
|
||||
}
|
||||
|
||||
p.pfStabilizing.Store(false)
|
||||
resumeAt := start.Add(pfIgnoredChangeReconcileInterval + time.Millisecond)
|
||||
p.handleDNSInterceptIgnoredNetworkChange(delta, resumeAt)
|
||||
if pfChecks != 2 || refreshes != 2 {
|
||||
t.Fatalf("first post-stabilization delta did not reconcile immediately: pf checks=%d refreshes=%d", pfChecks, refreshes)
|
||||
}
|
||||
|
||||
for i := 1; i <= 8; i++ {
|
||||
p.handleDNSInterceptIgnoredNetworkChange(delta, resumeAt.Add(time.Duration(i)*100*time.Millisecond))
|
||||
}
|
||||
if pfChecks != 2 || refreshes != 2 {
|
||||
t.Fatalf("ignored delta burst was not coalesced: pf checks=%d refreshes=%d", pfChecks, refreshes)
|
||||
}
|
||||
|
||||
p.handleDNSInterceptIgnoredNetworkChange(delta, resumeAt.Add(pfIgnoredChangeReconcileInterval))
|
||||
if pfChecks != 3 || refreshes != 3 {
|
||||
t.Fatalf("interval boundary did not reconcile: pf checks=%d refreshes=%d, want 3 each", pfChecks, refreshes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRestorePFAnchorFailureIsNotReportedOrTimestamped(t *testing.T) {
|
||||
originalReference := ensurePFAnchorReferenceForRestore
|
||||
originalRebuild := rebuildPFAnchorRulesForReconcile
|
||||
ensurePFAnchorReferenceForRestore = func(*prog) error { return nil }
|
||||
rebuildPFAnchorRulesForReconcile = func(*prog, []vpnDNSExemption) ([]string, error) {
|
||||
return nil, errors.New("pf load failed")
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
ensurePFAnchorReferenceForRestore = originalReference
|
||||
rebuildPFAnchorRulesForReconcile = originalRebuild
|
||||
})
|
||||
|
||||
p := &prog{dnsInterceptState: &pfState{}}
|
||||
if result := p.restorePFAnchor("test"); result != pfAnchorCheckFailed {
|
||||
t.Fatalf("failed restore result = %v, want failed", result)
|
||||
}
|
||||
if got := p.pfLastRestoreTime.Load(); got != 0 {
|
||||
t.Fatalf("failed restore changed timestamp to %d", got)
|
||||
}
|
||||
if len(p.lastTunnelIfaces) != 0 {
|
||||
t.Fatalf("failed restore committed tunnel state: %v", p.lastTunnelIfaces)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPFStabilizationTimeoutReturnsOwnershipToDelayedRecovery(t *testing.T) {
|
||||
p := &prog{dnsInterceptState: &pfState{}}
|
||||
p.pfStabilizing.Store(true)
|
||||
p.pfStabilizationLoopWithMaxWait(t.Context(), time.Hour, 25*time.Millisecond)
|
||||
|
||||
if p.pfStabilizing.Load() {
|
||||
t.Fatal("stabilization retained ownership after the maximum wait")
|
||||
}
|
||||
p.pfDelayedRecheckMu.Lock()
|
||||
timers := append([]*time.Timer(nil), p.pfDelayedRecheckTimers...)
|
||||
p.pfDelayedRecheckTimers = nil
|
||||
p.pfDelayedRecheckMu.Unlock()
|
||||
if len(timers) != 2 {
|
||||
t.Fatalf("expected bounded timeout to schedule delayed recovery, got %d timers", len(timers))
|
||||
}
|
||||
for _, timer := range timers {
|
||||
timer.Stop()
|
||||
}
|
||||
}
|
||||
|
||||
func TestStopDNSInterceptWaitsForInFlightPFMutation(t *testing.T) {
|
||||
binDir := t.TempDir()
|
||||
pfctlPath := filepath.Join(binDir, "pfctl")
|
||||
if err := os.WriteFile(pfctlPath, []byte("#!/bin/sh\nexit 0\n"), 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv("PATH", binDir+":"+os.Getenv("PATH"))
|
||||
|
||||
anchorFile := filepath.Join(t.TempDir(), "anchor")
|
||||
if err := os.WriteFile(anchorFile, []byte("rules"), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
p := &prog{dnsInterceptState: &pfState{anchorName: pfAnchorName, anchorFile: anchorFile}}
|
||||
p.pfEnsureRunning.Store(true)
|
||||
|
||||
revoked := make(chan struct{})
|
||||
originalRevokedHook := pfShutdownStateRevokedForTest
|
||||
pfShutdownStateRevokedForTest = func() { close(revoked) }
|
||||
t.Cleanup(func() { pfShutdownStateRevokedForTest = originalRevokedHook })
|
||||
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- p.stopDNSIntercept() }()
|
||||
|
||||
select {
|
||||
case <-revoked:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("shutdown did not revoke PF lifecycle state before waiting")
|
||||
}
|
||||
select {
|
||||
case err := <-done:
|
||||
t.Fatalf("shutdown completed before in-flight PF owner released: %v", err)
|
||||
case <-time.After(25 * time.Millisecond):
|
||||
}
|
||||
|
||||
p.pfEnsureRunning.Store(false)
|
||||
if err := <-done; err != nil {
|
||||
t.Fatalf("stopDNSIntercept() error: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(anchorFile); !os.IsNotExist(err) {
|
||||
t.Fatalf("anchor file remained after serialized shutdown: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPostStabilizationReconcileRetainsOwnershipAndForcesRebuild(t *testing.T) {
|
||||
stubPFAnchorCheckCommand(t, map[string]string{
|
||||
"-sn": `rdr-anchor "com.controld.ctrld"`,
|
||||
"-sr": `anchor "com.controld.ctrld"`,
|
||||
"-a com.controld.ctrld -sr": "pass in quick on lo0",
|
||||
"-a com.controld.ctrld -sn": "rdr on lo0",
|
||||
})
|
||||
originalRestore := restorePFAnchorForReconcile
|
||||
calls := 0
|
||||
restorePFAnchorForReconcile = func(*prog, string) pfAnchorCheckResult {
|
||||
calls++
|
||||
return pfAnchorCheckRestored
|
||||
}
|
||||
t.Cleanup(func() { restorePFAnchorForReconcile = originalRestore })
|
||||
|
||||
p := &prog{
|
||||
dnsInterceptState: &pfState{},
|
||||
pendingTunnelIfaces: []string{"utun9"},
|
||||
hasPendingTunnelIfaces: true,
|
||||
}
|
||||
p.pfStabilizing.Store(true)
|
||||
if result := p.reconcilePFAnchorAfterStabilization(); result != pfAnchorCheckRestored {
|
||||
t.Fatalf("post-stabilization result = %v, want restored", result)
|
||||
}
|
||||
if calls != 1 {
|
||||
t.Fatalf("post-stabilization restore calls = %d, want 1", calls)
|
||||
}
|
||||
if !p.pfStabilizing.Load() {
|
||||
t.Fatal("post-stabilization reconcile released loop ownership")
|
||||
}
|
||||
if p.pfBackoffMultiplier.Load() != 0 {
|
||||
t.Fatalf("post-stabilization reconcile changed backoff to %d", p.pfBackoffMultiplier.Load())
|
||||
}
|
||||
}
|
||||
|
||||
func TestPostStabilizationIntactWithoutPendingAvoidsRebuild(t *testing.T) {
|
||||
stubPFAnchorCheckCommand(t, map[string]string{
|
||||
"-sn": `rdr-anchor "com.controld.ctrld"`,
|
||||
"-sr": `anchor "com.controld.ctrld"`,
|
||||
"-a com.controld.ctrld -sr": "pass in quick on lo0",
|
||||
"-a com.controld.ctrld -sn": "rdr on lo0",
|
||||
})
|
||||
originalRestore := restorePFAnchorForReconcile
|
||||
calls := 0
|
||||
restorePFAnchorForReconcile = func(*prog, string) pfAnchorCheckResult {
|
||||
calls++
|
||||
return pfAnchorCheckRestored
|
||||
}
|
||||
t.Cleanup(func() { restorePFAnchorForReconcile = originalRestore })
|
||||
|
||||
p := &prog{dnsInterceptState: &pfState{}}
|
||||
p.pfStabilizing.Store(true)
|
||||
if result := p.reconcilePFAnchorAfterStabilization(); result != pfAnchorCheckIntact {
|
||||
t.Fatalf("post-stabilization result = %v, want intact", result)
|
||||
}
|
||||
if calls != 0 {
|
||||
t.Fatalf("intact post-stabilization anchor rebuilt %d times", calls)
|
||||
}
|
||||
if !p.pfStabilizing.Load() {
|
||||
t.Fatal("intact post-stabilization reconcile released loop ownership")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTunnelRemovalFailureRetriesBeforeCommittingBaseline(t *testing.T) {
|
||||
originalDiscover := discoverTunnelInterfacesForReconcile
|
||||
originalRestore := restorePFAnchorForReconcile
|
||||
current := []string{}
|
||||
discoverTunnelInterfacesForReconcile = func() []string {
|
||||
return append([]string(nil), current...)
|
||||
}
|
||||
calls := 0
|
||||
restorePFAnchorForReconcile = func(p *prog, _ string) pfAnchorCheckResult {
|
||||
calls++
|
||||
if calls == 1 {
|
||||
return pfAnchorCheckFailed
|
||||
}
|
||||
p.commitPFReconcileState(current)
|
||||
return pfAnchorCheckRestored
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
discoverTunnelInterfacesForReconcile = originalDiscover
|
||||
restorePFAnchorForReconcile = originalRestore
|
||||
})
|
||||
|
||||
p := &prog{
|
||||
dnsInterceptState: &pfState{},
|
||||
lastTunnelIfaces: []string{"utun7"},
|
||||
}
|
||||
if !p.checkTunnelInterfaceChanges() {
|
||||
t.Fatal("first tunnel removal was not detected")
|
||||
}
|
||||
if !stringSlicesEqual(p.lastTunnelIfaces, []string{"utun7"}) {
|
||||
t.Fatalf("failed removal committed baseline: %v", p.lastTunnelIfaces)
|
||||
}
|
||||
if !p.hasPendingTunnelReconcile() {
|
||||
t.Fatal("failed removal did not retain desired tunnel state for retry")
|
||||
}
|
||||
if !p.checkTunnelInterfaceChanges() {
|
||||
t.Fatal("failed tunnel removal was not retried")
|
||||
}
|
||||
if len(p.lastTunnelIfaces) != 0 {
|
||||
t.Fatalf("successful retry did not commit empty tunnel baseline: %v", p.lastTunnelIfaces)
|
||||
}
|
||||
if calls != 2 {
|
||||
t.Fatalf("restore calls = %d, want 2", calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPendingTunnelStateRetriesAfterStabilization(t *testing.T) {
|
||||
originalDiscover := discoverTunnelInterfacesForReconcile
|
||||
originalRestore := restorePFAnchorForReconcile
|
||||
current := []string{}
|
||||
discoverTunnelInterfacesForReconcile = func() []string { return nil }
|
||||
calls := 0
|
||||
restorePFAnchorForReconcile = func(p *prog, _ string) pfAnchorCheckResult {
|
||||
calls++
|
||||
p.commitPFReconcileState(current)
|
||||
return pfAnchorCheckRestored
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
discoverTunnelInterfacesForReconcile = originalDiscover
|
||||
restorePFAnchorForReconcile = originalRestore
|
||||
})
|
||||
|
||||
p := &prog{
|
||||
dnsInterceptState: &pfState{},
|
||||
lastTunnelIfaces: []string{"utun7"},
|
||||
pendingTunnelIfaces: current,
|
||||
hasPendingTunnelIfaces: true,
|
||||
}
|
||||
if !p.checkTunnelInterfaceChanges() {
|
||||
t.Fatal("pending tunnel removal was not retried after stabilization")
|
||||
}
|
||||
if calls != 1 || len(p.lastTunnelIfaces) != 0 || p.hasPendingTunnelReconcile() {
|
||||
t.Fatalf("pending retry result: calls=%d baseline=%v pending=%v", calls, p.lastTunnelIfaces, p.hasPendingTunnelReconcile())
|
||||
}
|
||||
}
|
||||
|
||||
func TestTunnelReconcileHonorsPFExecBackoff(t *testing.T) {
|
||||
originalDiscover := discoverTunnelInterfacesForReconcile
|
||||
originalRestore := restorePFAnchorForReconcile
|
||||
current := []string{}
|
||||
discoverTunnelInterfacesForReconcile = func() []string { return nil }
|
||||
calls := 0
|
||||
restorePFAnchorForReconcile = func(p *prog, _ string) pfAnchorCheckResult {
|
||||
calls++
|
||||
p.commitPFReconcileState(current)
|
||||
return pfAnchorCheckRestored
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
discoverTunnelInterfacesForReconcile = originalDiscover
|
||||
restorePFAnchorForReconcile = originalRestore
|
||||
})
|
||||
|
||||
p := &prog{dnsInterceptState: &pfState{}, lastTunnelIfaces: []string{"utun7"}}
|
||||
p.pfExecBackoffUntil.Store(time.Now().Add(time.Minute).UnixMilli())
|
||||
if !p.checkTunnelInterfaceChanges() {
|
||||
t.Fatal("tunnel removal was not detected during PF exec backoff")
|
||||
}
|
||||
if calls != 0 || !stringSlicesEqual(p.lastTunnelIfaces, []string{"utun7"}) {
|
||||
t.Fatalf("PF restore ran during exec backoff: calls=%d baseline=%v", calls, p.lastTunnelIfaces)
|
||||
}
|
||||
if p.checkTunnelInterfaceChanges() {
|
||||
t.Fatal("identical deferred tunnel retry bypassed the ignored-event limiter")
|
||||
}
|
||||
p.pfExecBackoffUntil.Store(0)
|
||||
if !p.checkTunnelInterfaceChanges() || calls != 1 || len(p.lastTunnelIfaces) != 0 {
|
||||
t.Fatalf("tunnel removal did not retry after backoff: calls=%d baseline=%v", calls, p.lastTunnelIfaces)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTunnelRapidReversalClearsUnappliedPendingState(t *testing.T) {
|
||||
originalDiscover := discoverTunnelInterfacesForReconcile
|
||||
discoverTunnelInterfacesForReconcile = func() []string { return nil }
|
||||
t.Cleanup(func() { discoverTunnelInterfacesForReconcile = originalDiscover })
|
||||
|
||||
p := &prog{
|
||||
dnsInterceptState: &pfState{},
|
||||
pendingTunnelIfaces: []string{"utun9"},
|
||||
hasPendingTunnelIfaces: true,
|
||||
}
|
||||
p.pfStabilizing.Store(true)
|
||||
if !p.checkTunnelInterfaceChanges() {
|
||||
t.Fatal("rapid tunnel reversal was not observed")
|
||||
}
|
||||
if p.hasPendingTunnelReconcile() || len(p.lastTunnelIfaces) != 0 {
|
||||
t.Fatalf("rapid reversal left unapplied tunnel state: baseline=%v pending=%v", p.lastTunnelIfaces, p.hasPendingTunnelReconcile())
|
||||
}
|
||||
}
|
||||
|
||||
func TestTunnelAdditionIsCoalescedUntilSuccessfulRebuild(t *testing.T) {
|
||||
originalDiscover := discoverTunnelInterfacesForReconcile
|
||||
current := []string{"utun9"}
|
||||
discoverTunnelInterfacesForReconcile = func() []string {
|
||||
return append([]string(nil), current...)
|
||||
}
|
||||
t.Cleanup(func() { discoverTunnelInterfacesForReconcile = originalDiscover })
|
||||
|
||||
p := &prog{dnsInterceptState: &pfState{}}
|
||||
p.pfStabilizing.Store(true)
|
||||
if !p.checkTunnelInterfaceChanges() {
|
||||
t.Fatal("new tunnel was not detected")
|
||||
}
|
||||
if p.checkTunnelInterfaceChanges() {
|
||||
t.Fatal("identical pending tunnel state was not coalesced")
|
||||
}
|
||||
if len(p.lastTunnelIfaces) != 0 {
|
||||
t.Fatalf("pending tunnel was committed before PF rebuild: %v", p.lastTunnelIfaces)
|
||||
}
|
||||
if !p.hasPendingTunnelReconcile() {
|
||||
t.Fatal("new tunnel was not retained as pending")
|
||||
}
|
||||
|
||||
p.commitPFReconcileState(current)
|
||||
if !stringSlicesEqual(p.lastTunnelIfaces, current) {
|
||||
t.Fatalf("successful rebuild baseline = %v, want %v", p.lastTunnelIfaces, current)
|
||||
}
|
||||
if p.hasPendingTunnelReconcile() {
|
||||
t.Fatal("successful rebuild did not clear pending tunnel state")
|
||||
}
|
||||
}
|
||||
|
||||
// TestVPNDNSRefreshDeferredWhileStabilizing covers the ignored network-change path,
|
||||
// which can trigger a VPN DNS refresh from outside stabilization.
|
||||
//
|
||||
// A refresh rebuilds and reloads the pf anchor. Stabilization owns pf while a VPN's
|
||||
// ruleset is still settling, so refreshing then is the mutual-overwrite collision
|
||||
// stabilization exists to prevent - and these deltas arrive exactly when a VPN is
|
||||
// coming up. Deferring is safe: checkTunnelInterfaceChanges keeps the observation
|
||||
// pending, so the transition is retried afterwards.
|
||||
//
|
||||
// The watchdog tick carries the same guard for the same reason; it is not driven here
|
||||
// because that would mean running its 30s loop.
|
||||
func TestVPNDNSRefreshDeferredWhileStabilizing(t *testing.T) {
|
||||
newProg := func(t *testing.T, refreshes *int, tunnels []string) *prog {
|
||||
t.Helper()
|
||||
outputs := map[string]string{
|
||||
"-sn": `rdr-anchor "com.controld.ctrld"`,
|
||||
"-sr": `anchor "com.controld.ctrld"`,
|
||||
"-a com.controld.ctrld -sr": "pass in quick on lo0",
|
||||
"-a com.controld.ctrld -sn": "rdr on lo0",
|
||||
}
|
||||
originalCheck := runPFAnchorCheckCommand
|
||||
runPFAnchorCheckCommand = func(args ...string) ([]byte, error) {
|
||||
output, ok := outputs[strings.Join(args, " ")]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("unexpected pf anchor check command")
|
||||
}
|
||||
return []byte(output), nil
|
||||
}
|
||||
// Discovery reports no tunnels. With a seeded baseline that is a removal, which
|
||||
// checkTunnelInterfaceChanges reports as a change without touching pf while
|
||||
// stabilizing - so this fixture never reaches a real pfctl write.
|
||||
originalDiscover := discoverTunnelInterfacesForReconcile
|
||||
discoverTunnelInterfacesForReconcile = func() []string { return nil }
|
||||
t.Cleanup(func() {
|
||||
runPFAnchorCheckCommand = originalCheck
|
||||
discoverTunnelInterfacesForReconcile = originalDiscover
|
||||
})
|
||||
|
||||
vpnDNS := newVPNDNSManager(&mainLog, nil)
|
||||
vpnDNS.discoverVPNDNS = func(context.Context) []ctrld.VPNDNSConfig {
|
||||
*refreshes++
|
||||
return nil
|
||||
}
|
||||
p := &prog{dnsInterceptState: &pfState{}, vpnDNS: vpnDNS, lastTunnelIfaces: tunnels}
|
||||
t.Cleanup(func() {
|
||||
p.pfDelayedRecheckMu.Lock()
|
||||
defer p.pfDelayedRecheckMu.Unlock()
|
||||
for _, timer := range p.pfDelayedRecheckTimers {
|
||||
if timer != nil {
|
||||
timer.Stop()
|
||||
}
|
||||
}
|
||||
})
|
||||
return p
|
||||
}
|
||||
delta := func() *netmon.ChangeDelta {
|
||||
return &netmon.ChangeDelta{
|
||||
Old: &netmon.State{Interface: map[string]netmon.Interface{}},
|
||||
New: &netmon.State{Interface: map[string]netmon.Interface{}},
|
||||
}
|
||||
}
|
||||
|
||||
t.Run("tunnel change during stabilization does not refresh", func(t *testing.T) {
|
||||
refreshes := 0
|
||||
// Seeded baseline plus empty discovery = a tunnel transition to report, so the
|
||||
// refresh is eligible on everything except the stabilization guard.
|
||||
p := newProg(t, &refreshes, []string{"utun9"})
|
||||
p.pfStabilizing.Store(true)
|
||||
|
||||
p.handleDNSInterceptIgnoredNetworkChange(delta(), time.Unix(1_000_000, 0))
|
||||
|
||||
if refreshes != 0 {
|
||||
t.Errorf("refreshed %d time(s) while stabilizing — that rebuilds the anchor under a settling VPN ruleset", refreshes)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("refresh still happens outside stabilization", func(t *testing.T) {
|
||||
refreshes := 0
|
||||
p := newProg(t, &refreshes, nil)
|
||||
|
||||
p.handleDNSInterceptIgnoredNetworkChange(delta(), time.Unix(1_000_000, 0))
|
||||
|
||||
if refreshes == 0 {
|
||||
t.Error("no refresh outside stabilization — the guard must defer, not disable")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestExemptVPNDNSServersDeferredWhileStabilizing checks the mutation point itself,
|
||||
// not just the call sites: any future caller reaching it during stabilization is
|
||||
// refused before the anchor is rewritten.
|
||||
//
|
||||
// It returns before pfEnsureRunning is taken and before any pfctl work, so this drives
|
||||
// the real function without touching the host's pf state.
|
||||
func TestExemptVPNDNSServersDeferredWhileStabilizing(t *testing.T) {
|
||||
p := &prog{dnsInterceptState: &pfState{}}
|
||||
p.pfStabilizing.Store(true)
|
||||
|
||||
err := p.exemptVPNDNSServers([]vpnDNSExemption{{Server: "192.168.1.1"}})
|
||||
if err == nil {
|
||||
t.Fatal("exemption applied while stabilizing — that rewrites the anchor under a settling VPN ruleset")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "stabilization") {
|
||||
t.Errorf("error does not name the reason: %v", err)
|
||||
}
|
||||
// The refusal must happen before the reconcile latch is claimed, or a deferral
|
||||
// would lock out the reconcile that runs once stabilization ends.
|
||||
if p.pfEnsureRunning.Load() {
|
||||
t.Error("pfEnsureRunning was left held by a deferred exemption")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user