fix: validate pf state before stabilization

This commit is contained in:
Dev Scribe
2026-08-21 14:45:56 +07:00
committed by Cuong Manh Le
parent 246c1b9691
commit 779fe015f0
15 changed files with 2036 additions and 488 deletions
File diff suppressed because it is too large Load Diff
+613
View File
@@ -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(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(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")
}
}
@@ -0,0 +1,20 @@
//go:build windows
package cli
import (
"testing"
"time"
)
func TestDNSInterceptIgnoredChangeReconcileDueWindowsPreservesImmediateBehavior(t *testing.T) {
p := &prog{}
now := time.Now()
if !p.dnsInterceptIgnoredChangeReconcileDue(now) {
t.Fatal("first ignored Windows change must reconcile immediately")
}
if !p.dnsInterceptIgnoredChangeReconcileDue(now) {
t.Fatal("Windows ignored changes must not inherit the macOS pf rate limit")
}
}
+7 -2
View File
@@ -4,6 +4,7 @@ package cli
import (
"fmt"
"time"
)
// startDNSIntercept is not supported on this platform.
@@ -23,8 +24,8 @@ func (p *prog) exemptVPNDNSServers(exemptions []vpnDNSExemption) error {
}
// ensurePFAnchorActive is a no-op on unsupported platforms.
func (p *prog) ensurePFAnchorActive() bool {
return false
func (p *prog) ensurePFAnchorActive() pfAnchorCheckResult {
return pfAnchorCheckSkipped
}
// checkTunnelInterfaceChanges is a no-op on unsupported platforms.
@@ -32,6 +33,10 @@ func (p *prog) checkTunnelInterfaceChanges() bool {
return false
}
func (p *prog) dnsInterceptIgnoredChangeReconcileDue(time.Time) bool {
return false
}
// scheduleDelayedRechecks is a no-op on unsupported platforms.
func (p *prog) scheduleDelayedRechecks() {}
+2 -14
View File
@@ -14,21 +14,9 @@ func (p *prog) refreshDNSAfterVPNSettle(reason string) (routes, domainlessServer
return 0, 0, 0
}
beforeExemptions := p.vpnDNS.CurrentExemptions()
routes, domainlessServers, exemptions = p.vpnDNS.RefreshRoutesOnly()
afterExemptions := p.vpnDNS.CurrentExemptions()
if vpnDNSExemptionsEqual(beforeExemptions, afterExemptions) {
mainLog.Load().Info().Msgf("DNS intercept: post-settle VPN DNS route refresh completed — %d routes, %d domainless servers, %d exemptions (pf unchanged)",
routes, domainlessServers, exemptions)
return routes, domainlessServers, exemptions
}
if err := p.exemptVPNDNSServers(afterExemptions); err != nil {
mainLog.Load().Warn().Err(err).Msg("DNS intercept: post-settle VPN DNS exemption update failed")
} else {
mainLog.Load().Info().Msgf("DNS intercept: post-settle VPN DNS exemptions changed — updated pf/WFP with %d exemptions", len(afterExemptions))
}
mainLog.Load().Info().Msgf("DNS intercept: post-settle VPN DNS route refresh completed — %d routes, %d domainless servers, %d exemptions",
routes, domainlessServers, exemptions)
return routes, domainlessServers, exemptions
}
+7 -2
View File
@@ -43,7 +43,12 @@ func TestRefreshDNSAfterVPNSettleRefreshesOSResolverAndVPNRoutes(t *testing.T) {
if got := p.vpnDNS.UpstreamForDomain("jira.cc.bmwgroup.net."); len(got) != 1 || got[0] != "10.102.26.10" {
t.Fatalf("expected refreshed VPN DNS route, got %v", got)
}
if len(exemptionUpdates) != 0 {
t.Fatalf("expected route-only refresh to avoid pf exemption updates, got %+v", exemptionUpdates)
if len(exemptionUpdates) != 1 || len(exemptionUpdates[0]) != 1 || exemptionUpdates[0][0].Server != "10.102.26.10" {
t.Fatalf("expected one serialized pf exemption update for the late VPN DNS server, got %+v", exemptionUpdates)
}
p.refreshDNSAfterVPNSettle("test-repeat")
if len(exemptionUpdates) != 1 {
t.Fatalf("unchanged post-settle VPN DNS state rewrote pf: %+v", exemptionUpdates)
}
}
+8 -2
View File
@@ -1526,8 +1526,8 @@ func parseIPv4AsUint32(ipStr string) uint32 {
}
// ensurePFAnchorActive is a no-op on Windows (WFP handles intercept differently).
func (p *prog) ensurePFAnchorActive() bool {
return false
func (p *prog) ensurePFAnchorActive() pfAnchorCheckResult {
return pfAnchorCheckSkipped
}
// checkTunnelInterfaceChanges is a no-op on Windows (WFP handles intercept differently).
@@ -1535,6 +1535,12 @@ func (p *prog) checkTunnelInterfaceChanges() bool {
return false
}
// Windows preserves the existing immediate reconciliation behavior. NRPT/WFP
// and adapter DNS settling have different lifecycle requirements from macOS pf.
func (p *prog) dnsInterceptIgnoredChangeReconcileDue(time.Time) bool {
return true
}
// pfAnchorRecheckDelay is the delay for deferred pf anchor re-checks.
// Defined here as a stub for Windows (referenced from dns_proxy.go).
const pfAnchorRecheckDelay = 2 * time.Second
+76 -57
View File
@@ -1541,64 +1541,13 @@ func (p *prog) monitorNetworkChanges() error {
mainLog.Load().Debug().Msg("Ignoring interface change - no valid interfaces affected")
// check if the default IPs are still on an interface that is up
ValidateDefaultLocalIPsFromDelta(delta.New)
// Even minor interface changes can trigger macOS pf reloads — verify anchor.
// We check immediately AND schedule delayed re-checks (2s + 4s) to catch
// programs like Windscribe that modify pf rules and DNS settings
// asynchronously after the network change event fires.
// Minor interface changes can still accompany pf/WFP or VPN DNS changes.
// On macOS, bound the immediate full reconciliation so link-local-only
// notification storms do not run pfctl/scutil work for every event.
// Windows keeps the existing immediate behavior. Tunnel changes always
// bypass the macOS limit, and delayed checks provide a trailing refresh.
if dnsIntercept && p.dnsInterceptState != nil {
if !p.pfStabilizing.Load() {
p.ensurePFAnchorActive()
}
// Check tunnel interfaces unconditionally — it decides internally
// whether to enter stabilization or rebuild immediately.
p.checkTunnelInterfaceChanges()
// Schedule delayed re-checks to catch async VPN teardown changes.
// These also refresh the OS resolver and VPN DNS routes.
p.scheduleDelayedRechecks()
// Detect interface appearance/disappearance — hypervisors (Parallels,
// VMware, VirtualBox) reload pf when creating/destroying virtual network
// interfaces, which can corrupt pf's internal translation state. The rdr
// rules survive in text form (watchdog says "intact") but stop evaluating.
// Spawn an async monitor that probes pf interception with backoff and
// forces a full pf reload if broken.
if delta.Old != nil {
interfaceChanged := false
var changedIface string
for ifaceName := range delta.Old.Interface {
if ifaceName == "lo0" {
continue
}
if _, exists := delta.New.Interface[ifaceName]; !exists {
interfaceChanged = true
changedIface = ifaceName
break
}
}
if !interfaceChanged {
for ifaceName := range delta.New.Interface {
if ifaceName == "lo0" {
continue
}
if _, exists := delta.Old.Interface[ifaceName]; !exists {
interfaceChanged = true
changedIface = ifaceName
break
}
}
}
if interfaceChanged {
mainLog.Load().Info().Str("interface", changedIface).
Msg("DNS intercept: interface appeared/disappeared — starting interception probe monitor")
go p.pfInterceptMonitor()
}
}
}
// Refresh VPN DNS on tunnel interface changes (e.g., Tailscale connect/disconnect)
// even though the physical interface didn't change. Runs after tunnel checks
// so the pf anchor rebuild includes current VPN DNS exemptions.
if dnsIntercept && p.vpnDNS != nil {
p.vpnDNS.Refresh(true)
p.handleDNSInterceptIgnoredNetworkChange(delta, time.Now())
}
return
}
@@ -1700,6 +1649,76 @@ func (p *prog) monitorNetworkChanges() error {
return nil
}
// handleDNSInterceptIgnoredNetworkChange runs the DNS-intercept work for a
// network delta that did not affect a usable interface. Keeping this path in a
// method lets tests exercise the callback wiring with synthetic deltas.
func (p *prog) handleDNSInterceptIgnoredNetworkChange(delta *netmon.ChangeDelta, now time.Time) {
reconcileNow := false
// Stabilization owns PF repair. Do not consume the next leading-edge slot
// until an ignored delta can actually perform the corresponding PF check.
if !p.pfStabilizing.Load() {
reconcileNow = p.dnsInterceptIgnoredChangeReconcileDue(now)
if reconcileNow {
p.ensurePFAnchorActive()
}
}
// Check tunnel interfaces unconditionally — it decides internally whether
// to enter stabilization or rebuild immediately.
tunnelChanged := p.checkTunnelInterfaceChanges()
// Schedule delayed re-checks to catch async VPN teardown changes. These also
// refresh the OS resolver and VPN DNS routes.
p.scheduleDelayedRechecks()
// Detect interface appearance/disappearance — hypervisors (Parallels,
// VMware, VirtualBox) reload pf when creating/destroying virtual network
// interfaces, which can corrupt pf's internal translation state. The rdr
// rules survive in text form (watchdog says "intact") but stop evaluating.
// Spawn an async monitor that probes pf interception with backoff and forces
// a full pf reload if broken.
if delta.Old != nil {
interfaceChanged := false
var changedIface string
for ifaceName := range delta.Old.Interface {
if ifaceName == "lo0" {
continue
}
if _, exists := delta.New.Interface[ifaceName]; !exists {
interfaceChanged = true
changedIface = ifaceName
break
}
}
if !interfaceChanged {
for ifaceName := range delta.New.Interface {
if ifaceName == "lo0" {
continue
}
if _, exists := delta.Old.Interface[ifaceName]; !exists {
interfaceChanged = true
changedIface = ifaceName
break
}
}
}
if interfaceChanged {
mainLog.Load().Info().Str("interface", changedIface).
Msg("DNS intercept: interface appeared/disappeared — starting interception probe monitor")
go p.pfInterceptMonitor()
}
}
// Refresh VPN DNS immediately for real tunnel changes even when the periodic
// ignored-change reconciliation is currently rate-limited - but not while
// stabilization owns pf. A refresh rebuilds the anchor, and these deltas arrive
// exactly when a VPN is bringing its own ruleset up, which is the collision
// stabilization is there to prevent. checkTunnelInterfaceChanges keeps the
// observation pending, so the transition is retried rather than dropped.
if p.vpnDNS != nil && (reconcileNow || tunnelChanged) && !p.pfStabilizing.Load() {
p.vpnDNS.Refresh(true)
}
}
// interfaceStatesEqual compares two interface states
func interfaceStatesEqual(a, b *netmon.Interface) bool {
if a == nil || b == nil {
+79
View File
@@ -0,0 +1,79 @@
package cli
import (
"fmt"
"strings"
)
// pfNoRulesMarker is what pfctl prints for a ruleset that contains nothing.
const pfNoRulesMarker = "(no rules)"
// pfFilterRuleLines reduces pfctl output to the lines that are actually pf rules.
//
// It exists because every pfctl reader here uses CombinedOutput, and pfctl on macOS
// writes "No ALTQ support in kernel" and "ALTQ related functions disabled" to stderr on
// essentially every show command, so raw output is never a clean rule list. An empty
// ruleset can also report "(no rules)", which is a status line rather than a rule.
//
// Two consequences follow from getting this wrong, and both have bitten this file:
// callers that test the output for emptiness can never see empty, and callers that feed
// the lines back into "pfctl -f -" would splice non-rule text into a ruleset and have
// the reload rejected.
//
// Registry access and platform specifics stay elsewhere; this is pure string handling
// so it can be tested on any host.
func pfFilterRuleLines(output string) []string {
var rules []string
for _, line := range strings.Split(output, "\n") {
line = strings.TrimSpace(line)
if line == "" {
continue
}
// pfctl stderr warnings, merged in by CombinedOutput.
if strings.Contains(line, "ALTQ") {
continue
}
// Status line for an empty ruleset, not a rule.
if line == pfNoRulesMarker {
continue
}
rules = append(rules, line)
}
return rules
}
// pfRulesetEmpty reports whether pfctl output describes a ruleset with no rules.
//
// Use this rather than testing the raw output for emptiness: the merged stderr warnings
// described above mean a raw test is always false, so the condition it guards - an
// anchor whose contents were flushed - would never be detected.
func pfRulesetEmpty(output string) bool {
return len(pfFilterRuleLines(output)) == 0
}
// pfContainsRule checks if any line in the slice contains the given rule string.
// Uses substring matching because pfctl may append extra tokens like " all" to rules
// (e.g., `rdr-anchor "com.controld.ctrld" all`), which would fail exact matching.
func pfContainsRule(lines []string, rule string) bool {
for _, line := range lines {
if strings.Contains(line, rule) {
return true
}
}
return false
}
// pfAnchorReferencesPresent reports whether ctrld's anchor references appear in the
// running ruleset, given the output of "pfctl -sn" and "pfctl -sr".
//
// Removing the references means reloading the entire main ruleset, and that reload
// carries no options section - so it resets system-wide pf options, including any
// third-party "set skip" directives. Doing that when there is nothing of ours to
// remove is pure collateral damage, which is what a startup rollback would otherwise
// cause after failing before the references were ever added.
func pfAnchorReferencesPresent(natOutput, filterOutput, anchorName string) bool {
rdrAnchorRef := fmt.Sprintf("rdr-anchor %q", anchorName)
anchorRef := fmt.Sprintf("anchor %q", anchorName)
return pfContainsRule(pfFilterRuleLines(natOutput), rdrAnchorRef) ||
pfContainsRule(pfFilterRuleLines(filterOutput), anchorRef)
}
+157
View File
@@ -0,0 +1,157 @@
package cli
import "testing"
// altqNoise is what macOS pfctl writes to stderr on show commands. Because every
// pfctl reader here uses CombinedOutput, it lands in the middle of the data being
// parsed — which is why these helpers exist.
const altqNoise = "No ALTQ support in kernel\nALTQ related functions disabled\n"
// TestPFRulesetEmpty is the regression guard for a flushed anchor being undetectable.
//
// The anchor-content checks in verifyPFState and ensurePFAnchorActive decide whether pf
// still has ctrld's rules. Testing the raw pfctl output for emptiness can never be true
// on macOS, because the merged ALTQ warnings are always present — so a genuinely flushed
// anchor reads as healthy and neither the startup gate nor the watchdog restore fires.
func TestPFRulesetEmpty(t *testing.T) {
tests := []struct {
name string
output string
want bool
}{
{
// The case that was broken: nothing but merged stderr.
name: "only ALTQ warnings",
output: altqNoise,
want: true,
},
{
// As captured on macOS 26.6 from "pfctl -sn -a com.controld.ctrld".
name: "ALTQ warnings plus the empty-ruleset marker",
output: altqNoise + "(no rules)\n",
want: true,
},
{
name: "empty output",
output: "",
want: true,
},
{
name: "whitespace only",
output: "\n \n\t\n",
want: true,
},
{
name: "a real rdr rule behind the warnings",
output: altqNoise + "rdr on lo0 inet proto udp from any to ! 127.0.0.1 port = 53 -> 127.0.0.1 port 5354\n",
want: false,
},
{
name: "a real filter rule behind the warnings",
output: altqNoise + "pass in quick on lo0 reply-to lo0 inet proto udp from any to 127.0.0.1 port = 5354\n",
want: false,
},
{
name: "rule with no warnings at all",
output: "anchor \"com.controld.ctrld\" all\n",
want: false,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
if got := pfRulesetEmpty(tc.output); got != tc.want {
t.Errorf("pfRulesetEmpty() = %v, want %v\noutput:\n%s", got, tc.want, tc.output)
}
})
}
}
// TestPFFilterRuleLines checks what survives filtering, since these lines are fed back
// into "pfctl -f -" by the ruleset-rebuild paths. Splicing a warning or the
// empty-ruleset marker into a ruleset would have the reload rejected outright.
func TestPFFilterRuleLines(t *testing.T) {
got := pfFilterRuleLines(altqNoise + "(no rules)\nrdr-anchor \"com.controld.ctrld\" all\n\nanchor \"com.controld.ctrld\" all\n")
want := []string{
`rdr-anchor "com.controld.ctrld" all`,
`anchor "com.controld.ctrld" all`,
}
if len(got) != len(want) {
t.Fatalf("got %d lines %q, want %d %q", len(got), got, len(want), want)
}
for i := range want {
if got[i] != want[i] {
t.Errorf("line %d = %q, want %q", i, got[i], want[i])
}
}
if lines := pfFilterRuleLines(altqNoise); lines != nil {
t.Errorf("warnings alone must yield no rule lines, got %q", lines)
}
}
// TestPFAnchorReferencesPresent guards when the main ruleset may be rewritten.
//
// Removing our anchor references means reloading the whole main ruleset, and that
// reload carries no options section — so it resets system-wide pf options, including
// third-party "set skip" directives. Startup rollback runs after failures that happen
// before the references were ever added, so without this check it would reset another
// application's pf options while removing nothing of ours.
func TestPFAnchorReferencesPresent(t *testing.T) {
const anchor = "com.controld.ctrld"
const otherAppRules = "scrub-anchor \"com.apple/*\" all fragment reassemble\nanchor \"com.vendor.vpn\" all\n"
tests := []struct {
name string
nat string
filter string
want bool
}{
{
name: "both references present",
nat: altqNoise + "rdr-anchor \"com.controld.ctrld\" all\n",
filter: altqNoise + "anchor \"com.controld.ctrld\" all\n",
want: true,
},
{
// pfctl appends tokens like " all", so matching is substring-based.
name: "rdr reference only",
nat: altqNoise + "rdr-anchor \"com.controld.ctrld\" all\n",
filter: altqNoise + otherAppRules,
want: true,
},
{
name: "filter reference only",
nat: altqNoise,
filter: altqNoise + "anchor \"com.controld.ctrld\"\n",
want: true,
},
{
// The rollback case: we failed before adding anything, and another
// application owns the ruleset. Rewriting it would be pure collateral.
name: "someone else's ruleset, none of ours",
nat: altqNoise,
filter: altqNoise + otherAppRules,
want: false,
},
{
name: "empty ruleset",
nat: altqNoise + "(no rules)\n",
filter: altqNoise + "(no rules)\n",
want: false,
},
{
// A different anchor whose name merely contains ours must not count.
name: "another anchor with a similar name",
nat: altqNoise,
filter: altqNoise + "anchor \"com.vendor.controld-shim\" all\n",
want: false,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
if got := pfAnchorReferencesPresent(tc.nat, tc.filter, anchor); got != tc.want {
t.Errorf("pfAnchorReferencesPresent() = %v, want %v", got, tc.want)
}
})
}
}
+90 -12
View File
@@ -92,6 +92,16 @@ var svcConfig = &service.Config{
var useSystemdResolved = false
type pfAnchorCheckResult uint8
const (
pfAnchorCheckSkipped pfAnchorCheckResult = iota
pfAnchorCheckIntact
pfAnchorCheckRestored
pfAnchorCheckDeferred
pfAnchorCheckFailed
)
type prog struct {
mu sync.Mutex
waitCh chan struct{}
@@ -162,11 +172,12 @@ type prog struct {
// On Windows: *wfpState, on macOS: *pfState, nil on other platforms.
dnsInterceptState any
// lastTunnelIfaces tracks the set of active VPN/tunnel interfaces (utun*, ipsec*, etc.)
// discovered during the last pf anchor rule build. When the set changes (e.g., a VPN
// connects and creates utun420), we rebuild the pf anchor to add interface-specific
// intercept rules for the new interface. Protected by mu.
lastTunnelIfaces []string //lint:ignore U1000 used on darwin
// lastTunnelIfaces tracks the tunnel set included in the last successfully loaded
// pf anchor. Pending tunnel state is kept separately so failed PF work is retried
// instead of being mistaken for an applied update. Protected by mu.
lastTunnelIfaces []string //lint:ignore U1000 used on darwin
pendingTunnelIfaces []string //lint:ignore U1000 used on darwin
hasPendingTunnelIfaces bool //lint:ignore U1000 used on darwin
// pfStabilizing is true while we're waiting for a VPN's pf ruleset to settle.
// While true, the watchdog and network change callbacks do NOT restore our rules.
@@ -189,10 +200,10 @@ type prog struct {
// interception with exponential backoff and auto-heals if broken.
pfMonitorRunning atomic.Bool //lint:ignore U1000 used on darwin
// pfEnsureRunning ensures only one pf anchor validation/restoration runs at a time.
// Network-change callbacks, delayed rechecks, and the periodic watchdog can all
// converge during macOS interface churn; concurrent pfctl/scutil exec storms can
// exhaust process/file limits and make the outage worse.
// pfEnsureRunning ensures only one pf validation or mutation runs at a time.
// Network callbacks, VPN exemption updates, delayed rechecks, probes, and the
// watchdog can converge during macOS churn; concurrent pfctl/scutil work can
// exhaust process/file limits or interleave anchor snapshots.
pfEnsureRunning atomic.Bool //lint:ignore U1000 used on darwin
// pfExecBackoffUntil suppresses pf anchor validation after pfctl/scutil execs
@@ -204,6 +215,11 @@ type prog struct {
pfDelayedRecheckMu sync.Mutex //lint:ignore U1000 used on darwin
pfDelayedRecheckTimers []*time.Timer //lint:ignore U1000 used on darwin
// pfIgnoredChangeLastReconcile bounds immediate pf/VPN-DNS work for noisy
// ignored macOS network deltas. Tunnel changes bypass this limit, and the
// existing delayed checks provide a trailing reconciliation after churn.
pfIgnoredChangeLastReconcile atomic.Int64 //lint:ignore U1000 used on darwin
// pfProbeExpected holds the domain name of a pending pf interception probe.
// When non-empty, the DNS handler checks incoming queries against this value
// and signals pfProbeCh if matched. The probe verifies that pf's rdr rules
@@ -839,6 +855,45 @@ func (p *prog) deAllocateIP() error {
return nil
}
// Seams for the intercept-start failure lifecycle. Choosing between the interface-DNS
// fallback and refusing it has side effects - restoring the host's DNS, then
// terminating - which a test has to observe without reconfiguring the host or exiting
// the test binary. The intercept start itself is indirected for the same reason: it is
// the real platform interceptor, which on macOS mutates pf and on Windows installs an
// NRPT rule, so a test of what happens *after* it fails must not be the thing that
// runs it.
var (
localResolverIPFn = router.LocalResolverIP
startDNSInterceptFn = (*prog).startDNSIntercept
setDnsForRunningIfaceFn = (*prog).setDnsForRunningIface
resetDNSFn = (*prog).resetDNS
refuseFallbackFatal = func(format string, v ...any) {
mainLog.Load().Fatal().Msgf(format, v...)
}
)
// interfaceDNSFallbackViable reports whether the interface-DNS fallback can actually
// direct queries to ctrld's listener.
//
// Interface DNS names a resolver by IP and has no port field - true of macOS interface
// settings and of Windows NRPT rules - so pointing the system straight at a listener
// that did not bind :53 sends queries to whatever owns :53 instead, and that resolver's
// upstream is ctrld's address: a loop, not a fallback.
//
// A nil or portless listener is treated as viable: the port is resolved elsewhere and
// defaults to 53, so there is nothing to refuse yet.
//
// A non-53 listener is still viable where a local resolver owns :53 and forwards to
// ctrld's port. That is the arrangement on the router platforms with a dnsmasq of their
// own: ctrld writes "server=<listener ip>#<listener port>", so the forward follows
// whatever port ctrld actually bound. setDNS then points the interface at that resolver
// rather than at the listener - see the lc.Port != 53 case there, which this mirrors.
// Refusing on port alone would turn a working configuration into a startup failure on
// those routers.
func interfaceDNSFallbackViable(lc *ctrld.ListenerConfig, localResolverIP string) bool {
return lc == nil || lc.Port == 0 || lc.Port == 53 || localResolverIP != ""
}
func (p *prog) setDNS() {
setDnsOK := false
defer func() {
@@ -871,7 +926,30 @@ func (p *prog) setDNS() {
// modifying interface DNS settings. This eliminates race conditions with VPN
// software that also manages DNS. See issue #489.
if dnsIntercept {
if err := p.startDNSIntercept(); err != nil {
if err := startDNSInterceptFn(p); err != nil {
// Interface DNS cannot express a port: macOS interface settings and Windows
// NRPT rules both name a resolver by IP alone. So it is only a usable
// fallback when the listener actually bound :53. When something else owns
// :53 - mDNSResponder on macOS, which is the whole reason the :5354 fallback
// exists - pointing the system at 127.0.0.1 hands queries to that other
// resolver, whose own upstream is now ctrld's address. That is a resolution
// loop, not degraded operation: a healthy ctrld listener nothing on the host
// can reach, no working DNS, and no recovery short of stopping the service.
//
// Refuse instead, after putting the host's own DNS back. A visible startup
// failure beats DNS that is broken by design, and it stops a fallback that
// cannot work from quietly undoing the fail-closed verification above.
if lc := cfg.FirstListener(); !interfaceDNSFallbackViable(lc, localResolverIPFn()) {
mainLog.Load().Error().Err(err).Msgf("DNS intercept mode failed with the listener on port %d", lc.Port)
// Leave the host resolvable: restore static settings or DHCP rather than
// exiting with an interface still pointed at a ctrld that is not serving.
resetDNSFn(p, false, true)
refuseFallbackFatal("Refusing to fall back to interface DNS: it cannot direct queries to %s:%d, which would leave this host with no working resolver. Free port 53 for ctrld, or resolve the intercept failure, then start again.", lc.IP, lc.Port)
// Unreachable in production - the line above exits - but returning
// explicitly keeps the refusal from depending on that, so nothing can
// fall through to installing the fallback this just rejected.
return
}
mainLog.Load().Error().Err(err).Msg("DNS intercept mode failed — falling back to interface DNS settings")
// Fall through to traditional setDNS behavior.
} else {
@@ -907,7 +985,7 @@ func (p *prog) setDNS() {
ns = "127.0.0.1"
case lc.Port != 53:
ns = "127.0.0.1"
if resolver := router.LocalResolverIP(); resolver != "" {
if resolver := localResolverIPFn(); resolver != "" {
ns = resolver
}
default:
@@ -926,7 +1004,7 @@ func (p *prog) setDNS() {
slices.Sort(nameservers)
netIfaceName := ""
netIface := p.setDnsForRunningIface(nameservers)
netIface := setDnsForRunningIfaceFn(p, nameservers)
if netIface != nil {
netIfaceName = netIface.Name
}
+221
View File
@@ -0,0 +1,221 @@
package cli
import (
"errors"
"fmt"
"net"
"slices"
"strings"
"testing"
"github.com/Control-D-Inc/ctrld"
)
// TestInterfaceDNSFallbackViable covers when the interface-DNS fallback may be used
// after DNS intercept fails to start.
//
// The fallback names a resolver by IP with no port, so it can only reach a listener on
// :53. Taking it with the listener on a redirect-dependent port produced a total DNS
// outage on macOS: the interface points at 127.0.0.1, mDNSResponder answers there, and
// its upstream is ctrld's own address - a resolution loop with a healthy ctrld listener
// nothing can reach. Intercept startup refuses the fallback in that case rather than
// creating it.
func TestInterfaceDNSFallbackViable(t *testing.T) {
tests := []struct {
name string
lc *ctrld.ListenerConfig
localResolver string
want bool
}{
{
name: "listener on 53 can be reached by interface DNS",
lc: &ctrld.ListenerConfig{IP: "127.0.0.1", Port: 53},
want: true,
},
{
// The reported outage: no local resolver, so the :5354 fallback port
// cannot be expressed by interface DNS.
name: "listener on the fallback port cannot",
lc: &ctrld.ListenerConfig{IP: "127.0.0.1", Port: 5354},
want: false,
},
{
name: "any other non-53 port cannot",
lc: &ctrld.ListenerConfig{IP: "127.0.0.1", Port: 5300},
want: false,
},
{
// Router platforms with their own dnsmasq: it owns :53 and forwards to
// ctrld's port, so interface DNS reaches the listener through it.
// Refusing here would break a working EdgeOS/Firewalla setup.
name: "non-53 listener behind a forwarding local resolver",
lc: &ctrld.ListenerConfig{IP: "127.0.0.1", Port: 5354},
localResolver: "192.168.1.1",
want: true,
},
{
// Port is resolved elsewhere and defaults to 53; nothing to refuse yet.
name: "unset port is not refused",
lc: &ctrld.ListenerConfig{IP: "127.0.0.1"},
want: true,
},
{
name: "no listener is not refused",
lc: nil,
want: true,
},
{
// A non-loopback listener on 53 is still reachable by IP.
name: "non-loopback listener on 53",
lc: &ctrld.ListenerConfig{IP: "192.168.1.10", Port: 53},
want: true,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
if got := interfaceDNSFallbackViable(tc.lc, tc.localResolver); got != tc.want {
t.Errorf("interfaceDNSFallbackViable() = %v, want %v", got, tc.want)
}
})
}
}
// interceptFallbackHarness drives setDNS() through the intercept-start failure path and
// records the side effects that decide whether the host ends up with a working
// resolver.
//
// Every host-touching step is stubbed, including the intercept start itself: this test
// runs untagged on Linux, macOS and Windows runners, where the real startDNSIntercept
// would set up pf or install an NRPT rule on the machine running the tests. Stubbing it
// also makes the precondition deterministic - the failure under test is injected rather
// than depending on the runner denying a privileged operation.
type interceptFallbackHarness struct {
interceptCalls int
installedNameservers []string
installCalls int
resetCalls int
refusals []string
}
func newInterceptFallbackHarness(t *testing.T, lc *ctrld.ListenerConfig) *interceptFallbackHarness {
t.Helper()
h := &interceptFallbackHarness{}
origStart, origInstall := startDNSInterceptFn, setDnsForRunningIfaceFn
origReset, origFatal := resetDNSFn, refuseFallbackFatal
origResolver := localResolverIPFn
origCfg, origMode, origIntercept, origHard := cfg, interceptMode, dnsIntercept, hardIntercept
t.Cleanup(func() {
startDNSInterceptFn, setDnsForRunningIfaceFn = origStart, origInstall
resetDNSFn, refuseFallbackFatal = origReset, origFatal
localResolverIPFn = origResolver
cfg, interceptMode, dnsIntercept, hardIntercept = origCfg, origMode, origIntercept, origHard
})
// Default to no local resolver: the desktop case. Router cases set it per test.
localResolverIPFn = func() string { return "" }
// Never reach the real interceptor: it would configure pf on macOS and NRPT on
// Windows, on the machine running the tests.
startDNSInterceptFn = func(_ *prog) error {
h.interceptCalls++
return errors.New("dns intercept: injected start failure")
}
setDnsForRunningIfaceFn = func(_ *prog, nameservers []string) *net.Interface {
h.installCalls++
h.installedNameservers = nameservers
return nil
}
resetDNSFn = func(_ *prog, _ bool, _ bool) { h.resetCalls++ }
refuseFallbackFatal = func(format string, v ...any) {
h.refusals = append(h.refusals, fmt.Sprintf(format, v...))
}
cfg = ctrld.Config{}
cfg.Service.InterceptMode = "dns"
cfg.Listener = map[string]*ctrld.ListenerConfig{"0": lc}
watchdogOff := false
cfg.Service.DnsWatchdogEnabled = &watchdogOff
interceptMode, dnsIntercept, hardIntercept = "dns", false, false
return h
}
func (h *interceptFallbackHarness) run(t *testing.T) {
t.Helper()
p := &prog{cfg: &cfg}
p.setDNS()
}
// TestSetDNSRefusesUnreachableFallback is the behaviour test for the reported outage: it
// drives the real setDNS() lifecycle rather than the classification helper alone.
//
// Deleting or bypassing the guard in setDNS makes the first case fail, because interface
// DNS then gets installed pointing at a listener that cannot answer on :53 - which is
// the resolution loop this refuses to create.
func TestSetDNSRefusesUnreachableFallback(t *testing.T) {
t.Run("non-53 listener refuses the fallback and restores DNS", func(t *testing.T) {
h := newInterceptFallbackHarness(t, &ctrld.ListenerConfig{IP: "127.0.0.1", Port: 5354})
h.run(t)
if h.interceptCalls != 1 {
t.Fatalf("intercept start called %d time(s) through the seam, want 1 — the real platform interceptor must never run here", h.interceptCalls)
}
if h.installCalls != 0 {
t.Errorf("interface DNS was installed %d time(s) for a listener on :5354 — that is the resolver loop", h.installCalls)
}
if h.resetCalls == 0 {
t.Error("host DNS was not restored before refusing, leaving the interface pointed at a ctrld that is not serving")
}
if len(h.refusals) == 0 {
t.Fatal("refusal was not surfaced: startup must fail loudly rather than silently skip the fallback")
}
if !strings.Contains(h.refusals[0], "5354") {
t.Errorf("refusal does not name the unreachable port: %q", h.refusals[0])
}
})
t.Run("non-53 listener behind a local resolver still falls back", func(t *testing.T) {
// EdgeOS/Firewalla: dnsmasq owns :53 and forwards to ctrld's port, so the
// fallback works and must not be refused. setDNS points the interface at the
// resolver rather than at the listener.
h := newInterceptFallbackHarness(t, &ctrld.ListenerConfig{IP: "127.0.0.1", Port: 5354})
localResolverIPFn = func() string { return "192.168.1.1" }
h.run(t)
if h.installCalls != 1 {
t.Errorf("interface DNS installed %d time(s), want 1: a forwarding local resolver makes the fallback usable", h.installCalls)
}
if len(h.refusals) != 0 {
t.Errorf("refused a fallback that a local resolver can serve: %v", h.refusals)
}
// Assert on membership, not on the exact set: setDNS appends platform-dependent
// entries beside the chosen nameserver - "::1" on Windows for the local IPv6
// listener, the RFC1918 addresses where those listeners are needed. What matters
// is that the interface points at the resolver and not at the listener IP, whose
// port the interface cannot express.
if !slices.Contains(h.installedNameservers, "192.168.1.1") {
t.Errorf("nameservers = %v, want the local resolver among them so queries reach ctrld through it", h.installedNameservers)
}
if slices.Contains(h.installedNameservers, "127.0.0.1") {
t.Errorf("nameservers = %v, must not name the listener IP: interface DNS cannot reach it on :5354", h.installedNameservers)
}
})
t.Run("listener on 53 still reaches the interface-DNS fallback", func(t *testing.T) {
h := newInterceptFallbackHarness(t, &ctrld.ListenerConfig{IP: "127.0.0.1", Port: 53})
h.run(t)
if h.interceptCalls != 1 {
t.Fatalf("intercept start called %d time(s) through the seam, want 1", h.interceptCalls)
}
if h.installCalls != 1 {
t.Errorf("interface DNS installed %d time(s), want 1: a listener on :53 is reachable, so the fallback must still apply", h.installCalls)
}
if len(h.refusals) != 0 {
t.Errorf("unexpected refusal for a reachable listener: %v", h.refusals)
}
if len(h.installedNameservers) == 0 {
t.Error("fallback installed no nameservers")
}
})
}
+76 -27
View File
@@ -6,7 +6,6 @@ import (
"runtime"
"strings"
"sync"
"sync/atomic"
"github.com/rs/zerolog"
"tailscale.com/net/netmon"
@@ -43,6 +42,9 @@ type vpnDNSManager struct {
// as additional nameservers for queries that match split-DNS rules
// (from ctrld config, AD domain, or VPN suffix config).
domainlessServers []string
// appliedExemptions advances only after the platform PF/WFP callback succeeds.
// Keeping it separate from discovered configs makes failed rule updates retryable.
appliedExemptions []vpnDNSExemption
// retainedAfterEmptyDiscovery means Windows reported an empty VPN DNS
// snapshot once while previous VPN DNS state existed. We keep that last-known
// state for one guarded refresh cycle because Windows can briefly report an
@@ -51,9 +53,13 @@ type vpnDNSManager struct {
// discoverVPNDNS is injected for tests so Refresh does not depend on the
// runner host's real VPN/virtual adapter state.
discoverVPNDNS func(context.Context) []ctrld.VPNDNSConfig
// refreshRunning keeps noisy network-change storms from running overlapping
// scutil/networksetup VPN DNS discovery work.
refreshRunning atomic.Bool
// refreshStateMu keeps noisy network-change storms from running overlapping
// full VPN DNS refreshes and retains one trailing refresh when an event arrives
// during discovery so the newest OS state is not lost.
refreshStateMu sync.Mutex
refreshRunning bool
refreshPending bool
discoveryMu sync.Mutex
// Called when VPN DNS server list changes, to update intercept exemptions.
onServersChanged vpnDNSExemptFunc
}
@@ -70,14 +76,39 @@ func newVPNDNSManager(exemptFunc vpnDNSExemptFunc) *vpnDNSManager {
}
// Refresh re-discovers VPN DNS configs from the OS.
// Called on network change events.
// Called on network change events. Overlapping calls are coalesced into one
// trailing refresh so a newer OS snapshot is never silently discarded.
func (m *vpnDNSManager) Refresh(guardAgainstNoNameservers bool) {
logger := mainLog.Load()
if !m.refreshRunning.CompareAndSwap(false, true) {
logger.Debug().Msg("VPN DNS refresh already running, skipping duplicate")
m.refreshStateMu.Lock()
if m.refreshRunning {
m.refreshPending = true
m.refreshStateMu.Unlock()
mainLog.Load().Debug().Msg("VPN DNS refresh already running, coalescing trailing refresh")
return
}
defer m.refreshRunning.Store(false)
m.refreshRunning = true
m.refreshStateMu.Unlock()
for {
m.refreshOnce(guardAgainstNoNameservers)
m.refreshStateMu.Lock()
if m.refreshPending {
m.refreshPending = false
m.refreshStateMu.Unlock()
guardAgainstNoNameservers = true
continue
}
m.refreshRunning = false
m.refreshStateMu.Unlock()
return
}
}
func (m *vpnDNSManager) refreshOnce(guardAgainstNoNameservers bool) {
logger := mainLog.Load()
m.discoveryMu.Lock()
defer m.discoveryMu.Unlock()
logger.Debug().Msg("Refreshing VPN DNS configurations")
discoverVPNDNS := m.discoverVPNDNS
@@ -104,8 +135,6 @@ func (m *vpnDNSManager) Refresh(guardAgainstNoNameservers bool) {
m.mu.Lock()
defer m.mu.Unlock()
previousExemptions := m.currentExemptionsLocked()
if vpnDNSSettlingEnabled && len(configs) == 0 && guardAgainstNoNameservers && m.hasVPNDNSStateLocked() {
if !m.retainedAfterEmptyDiscovery {
exemptions := m.currentExemptionsLocked()
@@ -116,6 +145,8 @@ func (m *vpnDNSManager) Refresh(guardAgainstNoNameservers bool) {
if m.onServersChanged != nil {
if err := m.onServersChanged(exemptions); err != nil {
logger.Error().Err(err).Msg("Failed to re-apply retained VPN DNS exemptions")
} else {
m.appliedExemptions = append([]vpnDNSExemption(nil), exemptions...)
}
}
return
@@ -192,35 +223,37 @@ func (m *vpnDNSManager) Refresh(guardAgainstNoNameservers bool) {
logger.Debug().Msgf("VPN DNS refresh completed: %d configs, %d routes, %d domainless servers, %d unique exemptions",
len(m.configs), len(m.routes), len(m.domainlessServers), len(exemptions))
// Update intercept rules to permit VPN DNS traffic only when the exemption set
// actually changes. Network-change events can fire repeatedly while macOS/VPN
// state is otherwise identical; rewriting pf for identical exemptions can feed
// a self-triggering network-change loop. Empty exemptions are still applied
// when they differ from the previous set, so stale VPN exemptions are cleared
// on disconnect.
m.updateInterceptExemptionsIfChanged(logger, previousExemptions, exemptions, "VPN DNS")
// Update intercept rules only when desired exemptions differ from the last
// successfully applied set. Failed PF/WFP callbacks remain retryable on the
// next refresh even when discovery returns the same VPN DNS state.
m.updateInterceptExemptionsIfChanged(logger, exemptions, "VPN DNS")
}
func (m *vpnDNSManager) updateInterceptExemptionsIfChanged(logger *zerolog.Logger, before, after []vpnDNSExemption, reason string) {
func (m *vpnDNSManager) updateInterceptExemptionsIfChanged(logger *zerolog.Logger, desired []vpnDNSExemption, reason string) {
if m.onServersChanged == nil {
return
}
if vpnDNSExemptionsEqual(before, after) {
if vpnDNSExemptionsEqual(m.appliedExemptions, desired) {
logger.Debug().Msgf("VPN DNS exemptions unchanged after %s refresh; skipping intercept rule update", reason)
return
}
if err := m.onServersChanged(after); err != nil {
if err := m.onServersChanged(desired); err != nil {
logger.Error().Err(err).Msg("Failed to update intercept exemptions for VPN DNS servers")
return
}
m.appliedExemptions = append([]vpnDNSExemption(nil), desired...)
}
// RefreshRoutesOnly re-discovers VPN DNS configs and updates only ctrld's
// in-memory split-DNS routes. It intentionally does not call onServersChanged,
// so it does not rewrite/reload pf/WFP rules. Use this for post-settle discovery
// checks where we only need to learn late-published VPN search domains.
// RefreshRoutesOnly re-discovers VPN DNS configs and updates ctrld's
// in-memory split-DNS routes. It applies intercept exemptions only when that set
// changes, while holding the shared discovery lane so a concurrent full refresh
// cannot commit a newer snapshot and then be overwritten by this one.
func (m *vpnDNSManager) RefreshRoutesOnly() (routes, domainlessServers, exemptions int) {
logger := mainLog.Load()
m.discoveryMu.Lock()
defer m.discoveryMu.Unlock()
logger.Debug().Msg("Refreshing VPN DNS route state only")
discoverVPNDNS := m.discoverVPNDNS
if discoverVPNDNS == nil {
@@ -267,10 +300,26 @@ func (m *vpnDNSManager) RefreshRoutesOnly() (routes, domainlessServers, exemptio
}
}
m.domainlessServers = domainless
currentExemptions := m.currentExemptionsLocked()
logger.Debug().Msgf("VPN DNS route-only refresh completed: %d configs, %d routes, %d domainless servers, %d exemptions",
len(m.configs), len(m.routes), len(m.domainlessServers), len(m.currentExemptionsLocked()))
return len(m.routes), len(m.domainlessServers), len(m.currentExemptionsLocked())
len(m.configs), len(m.routes), len(m.domainlessServers), len(currentExemptions))
m.updateInterceptExemptionsIfChanged(logger, currentExemptions, "route-only VPN DNS")
return len(m.routes), len(m.domainlessServers), len(currentExemptions)
}
func (m *vpnDNSManager) markInterceptExemptionsApplied(applied []vpnDNSExemption) {
m.mu.Lock()
defer m.mu.Unlock()
if vpnDNSExemptionsEqual(m.currentExemptionsLocked(), applied) {
m.appliedExemptions = append([]vpnDNSExemption(nil), applied...)
}
}
func (m *vpnDNSManager) interceptExemptionsPending() bool {
m.mu.RLock()
defer m.mu.RUnlock()
return !vpnDNSExemptionsEqual(m.appliedExemptions, m.currentExemptionsLocked())
}
func (m *vpnDNSManager) hasVPNDNSStateLocked() bool {
+157 -6
View File
@@ -2,9 +2,11 @@ package cli
import (
"context"
"errors"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/Control-D-Inc/ctrld"
)
@@ -16,7 +18,7 @@ func withVPNDNSSettlingEnabled(t *testing.T) {
t.Cleanup(func() { vpnDNSSettlingEnabled = old })
}
func TestVPNDNSRefreshSkipsConcurrentDuplicate(t *testing.T) {
func TestVPNDNSRefreshCoalescesConcurrentTrailingRefresh(t *testing.T) {
m := newVPNDNSManager(nil)
started := make(chan struct{})
release := make(chan struct{})
@@ -25,9 +27,16 @@ func TestVPNDNSRefreshSkipsConcurrentDuplicate(t *testing.T) {
var calls atomic.Int32
m.discoverVPNDNS = func(context.Context) []ctrld.VPNDNSConfig {
calls.Add(1)
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
}
@@ -41,8 +50,11 @@ func TestVPNDNSRefreshSkipsConcurrentDuplicate(t *testing.T) {
close(release)
<-done
if calls.Load() != 1 {
t.Fatalf("expected overlapping refresh to be skipped, got %d discovery calls", calls.Load())
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)
}
}
@@ -76,7 +88,9 @@ func TestVPNDNSRefreshRetainsStateForOneGuardedEmptyDiscovery(t *testing.T) {
func TestVPNDNSRefreshClearsOnSecondGuardedEmptyDiscovery(t *testing.T) {
withVPNDNSSettlingEnabled(t)
var gotExemptions []vpnDNSExemption
updates := 0
m := newVPNDNSManager(func(exemptions []vpnDNSExemption) error {
updates++
gotExemptions = exemptions
return nil
})
@@ -86,6 +100,7 @@ func TestVPNDNSRefreshClearsOnSecondGuardedEmptyDiscovery(t *testing.T) {
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(true)
@@ -93,8 +108,8 @@ func TestVPNDNSRefreshClearsOnSecondGuardedEmptyDiscovery(t *testing.T) {
if got := m.DomainlessServers(); len(got) != 0 {
t.Fatalf("expected domainless servers to be cleared on second empty discovery, got %v", got)
}
if len(gotExemptions) != 0 {
t.Fatalf("expected empty exemptions after clearing stale state, got %v", gotExemptions)
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")
@@ -126,6 +141,56 @@ func TestVPNDNSRefreshSkipsUnchangedInterceptExemptions(t *testing.T) {
}
}
func TestVPNDNSRefreshRetriesFailedInterceptExemptionUpdate(t *testing.T) {
attempts := 0
m := newVPNDNSManager(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(true)
if !m.interceptExemptionsPending() {
t.Fatal("failed intercept exemption update was not retained for retry")
}
m.Refresh(true)
if m.interceptExemptionsPending() {
t.Fatal("successful intercept exemption retry did not advance applied state")
}
m.Refresh(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(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(nil)
@@ -145,3 +210,89 @@ func TestVPNDNSTransportFailureSuppressesFallbackOnlyWhileRetainingState(t *test
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(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(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)
}
}
+16 -5
View File
@@ -298,11 +298,22 @@ The full pf reload is VPN-safe: it reassembles from `pfctl -sr` + `pfctl -sn`
### What about `set skip on lo0`?
Some pf.conf files include `set skip on lo0` which tells pf to skip ALL processing on loopback. **This would break our approach** since both the `rdr on lo0` and `pass in on lo0` rules would be skipped.
**Mitigation:** When injecting anchor references via `ensurePFAnchorReference()`,
we strip `lo0` from any `set skip on` directives before reloading. The watchdog
also checks for `set skip on lo0` and triggers a restore if detected. The
interception probe provides an additional safety net — if `set skip on lo0` gets
re-applied by another program, the probe will fail and trigger a full reload.
**Mitigation:** the interception probe. `probePFIntercept()` sends a real query from
outside the `_ctrld` group and confirms the listener received the redirect, which cannot
succeed while pf is bypassing loopback — so a skip on `lo0` shows up as a probe failure
and triggers a full reload.
**Not implemented, contrary to earlier versions of this document:** ctrld does *not*
strip `lo0` from `set skip on` directives, and the watchdog does *not* inspect skip
state. Apple's `pfctl` offers no way to read it — `pfctl(8)` accepts `-s` nat, queue,
rules, Anchors, states, Sources, info, References, labels, timeouts, memory, Tables,
osfp, Interfaces, all, with no options or skip modifier — so text-based detection is not
available on macOS.
Adding an explicit check is tracked as follow-up: `pfctl(8)` documents
`-s Interfaces -v` as additionally listing which interfaces have skip rules activated,
which is the query to build on once its output shape is confirmed on a host that has a
skip configured.
## Cleanup