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:
+507
-361
File diff suppressed because it is too large
Load Diff
@@ -3,9 +3,16 @@
|
|||||||
package cli
|
package cli
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"tailscale.com/net/netmon"
|
||||||
|
|
||||||
"github.com/Control-D-Inc/ctrld"
|
"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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ package cli
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// startDNSIntercept is not supported on this platform.
|
// 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.
|
// ensurePFAnchorActive is a no-op on unsupported platforms.
|
||||||
func (p *prog) ensurePFAnchorActive() bool {
|
func (p *prog) ensurePFAnchorActive() pfAnchorCheckResult {
|
||||||
return false
|
return pfAnchorCheckSkipped
|
||||||
}
|
}
|
||||||
|
|
||||||
// checkTunnelInterfaceChanges is a no-op on unsupported platforms.
|
// checkTunnelInterfaceChanges is a no-op on unsupported platforms.
|
||||||
@@ -32,6 +33,10 @@ func (p *prog) checkTunnelInterfaceChanges() bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (p *prog) dnsInterceptIgnoredChangeReconcileDue(time.Time) bool {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
// scheduleDelayedRechecks is a no-op on unsupported platforms.
|
// scheduleDelayedRechecks is a no-op on unsupported platforms.
|
||||||
func (p *prog) scheduleDelayedRechecks() {}
|
func (p *prog) scheduleDelayedRechecks() {}
|
||||||
|
|
||||||
|
|||||||
@@ -14,21 +14,9 @@ func (p *prog) refreshDNSAfterVPNSettle(reason string) (routes, domainlessServer
|
|||||||
return 0, 0, 0
|
return 0, 0, 0
|
||||||
}
|
}
|
||||||
|
|
||||||
beforeExemptions := p.vpnDNS.CurrentExemptions()
|
|
||||||
routes, domainlessServers, exemptions = p.vpnDNS.RefreshRoutesOnly()
|
routes, domainlessServers, exemptions = p.vpnDNS.RefreshRoutesOnly()
|
||||||
afterExemptions := p.vpnDNS.CurrentExemptions()
|
mainLog.Load().Info().Msgf("DNS intercept: post-settle VPN DNS route refresh completed — %d routes, %d domainless servers, %d exemptions",
|
||||||
|
routes, domainlessServers, exemptions)
|
||||||
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))
|
|
||||||
}
|
|
||||||
return routes, domainlessServers, exemptions
|
return routes, domainlessServers, exemptions
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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" {
|
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)
|
t.Fatalf("expected refreshed VPN DNS route, got %v", got)
|
||||||
}
|
}
|
||||||
if len(exemptionUpdates) != 0 {
|
if len(exemptionUpdates) != 1 || len(exemptionUpdates[0]) != 1 || exemptionUpdates[0][0].Server != "10.102.26.10" {
|
||||||
t.Fatalf("expected route-only refresh to avoid pf exemption updates, got %+v", exemptionUpdates)
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1526,8 +1526,8 @@ func parseIPv4AsUint32(ipStr string) uint32 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ensurePFAnchorActive is a no-op on Windows (WFP handles intercept differently).
|
// ensurePFAnchorActive is a no-op on Windows (WFP handles intercept differently).
|
||||||
func (p *prog) ensurePFAnchorActive() bool {
|
func (p *prog) ensurePFAnchorActive() pfAnchorCheckResult {
|
||||||
return false
|
return pfAnchorCheckSkipped
|
||||||
}
|
}
|
||||||
|
|
||||||
// checkTunnelInterfaceChanges is a no-op on Windows (WFP handles intercept differently).
|
// checkTunnelInterfaceChanges is a no-op on Windows (WFP handles intercept differently).
|
||||||
@@ -1535,6 +1535,12 @@ func (p *prog) checkTunnelInterfaceChanges() bool {
|
|||||||
return false
|
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.
|
// pfAnchorRecheckDelay is the delay for deferred pf anchor re-checks.
|
||||||
// Defined here as a stub for Windows (referenced from dns_proxy.go).
|
// Defined here as a stub for Windows (referenced from dns_proxy.go).
|
||||||
const pfAnchorRecheckDelay = 2 * time.Second
|
const pfAnchorRecheckDelay = 2 * time.Second
|
||||||
|
|||||||
+76
-57
@@ -1541,64 +1541,13 @@ func (p *prog) monitorNetworkChanges() error {
|
|||||||
mainLog.Load().Debug().Msg("Ignoring interface change - no valid interfaces affected")
|
mainLog.Load().Debug().Msg("Ignoring interface change - no valid interfaces affected")
|
||||||
// check if the default IPs are still on an interface that is up
|
// check if the default IPs are still on an interface that is up
|
||||||
ValidateDefaultLocalIPsFromDelta(delta.New)
|
ValidateDefaultLocalIPsFromDelta(delta.New)
|
||||||
// Even minor interface changes can trigger macOS pf reloads — verify anchor.
|
// Minor interface changes can still accompany pf/WFP or VPN DNS changes.
|
||||||
// We check immediately AND schedule delayed re-checks (2s + 4s) to catch
|
// On macOS, bound the immediate full reconciliation so link-local-only
|
||||||
// programs like Windscribe that modify pf rules and DNS settings
|
// notification storms do not run pfctl/scutil work for every event.
|
||||||
// asynchronously after the network change event fires.
|
// 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 dnsIntercept && p.dnsInterceptState != nil {
|
||||||
if !p.pfStabilizing.Load() {
|
p.handleDNSInterceptIgnoredNetworkChange(delta, time.Now())
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -1700,6 +1649,76 @@ func (p *prog) monitorNetworkChanges() error {
|
|||||||
return nil
|
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
|
// interfaceStatesEqual compares two interface states
|
||||||
func interfaceStatesEqual(a, b *netmon.Interface) bool {
|
func interfaceStatesEqual(a, b *netmon.Interface) bool {
|
||||||
if a == nil || b == nil {
|
if a == nil || b == nil {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
@@ -92,6 +92,16 @@ var svcConfig = &service.Config{
|
|||||||
|
|
||||||
var useSystemdResolved = false
|
var useSystemdResolved = false
|
||||||
|
|
||||||
|
type pfAnchorCheckResult uint8
|
||||||
|
|
||||||
|
const (
|
||||||
|
pfAnchorCheckSkipped pfAnchorCheckResult = iota
|
||||||
|
pfAnchorCheckIntact
|
||||||
|
pfAnchorCheckRestored
|
||||||
|
pfAnchorCheckDeferred
|
||||||
|
pfAnchorCheckFailed
|
||||||
|
)
|
||||||
|
|
||||||
type prog struct {
|
type prog struct {
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
waitCh chan struct{}
|
waitCh chan struct{}
|
||||||
@@ -162,11 +172,12 @@ type prog struct {
|
|||||||
// On Windows: *wfpState, on macOS: *pfState, nil on other platforms.
|
// On Windows: *wfpState, on macOS: *pfState, nil on other platforms.
|
||||||
dnsInterceptState any
|
dnsInterceptState any
|
||||||
|
|
||||||
// lastTunnelIfaces tracks the set of active VPN/tunnel interfaces (utun*, ipsec*, etc.)
|
// lastTunnelIfaces tracks the tunnel set included in the last successfully loaded
|
||||||
// discovered during the last pf anchor rule build. When the set changes (e.g., a VPN
|
// pf anchor. Pending tunnel state is kept separately so failed PF work is retried
|
||||||
// connects and creates utun420), we rebuild the pf anchor to add interface-specific
|
// instead of being mistaken for an applied update. Protected by mu.
|
||||||
// intercept rules for the new interface. Protected by mu.
|
lastTunnelIfaces []string //lint:ignore U1000 used on darwin
|
||||||
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.
|
// 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.
|
// 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.
|
// interception with exponential backoff and auto-heals if broken.
|
||||||
pfMonitorRunning atomic.Bool //lint:ignore U1000 used on darwin
|
pfMonitorRunning atomic.Bool //lint:ignore U1000 used on darwin
|
||||||
|
|
||||||
// pfEnsureRunning ensures only one pf anchor validation/restoration runs at a time.
|
// pfEnsureRunning ensures only one pf validation or mutation runs at a time.
|
||||||
// Network-change callbacks, delayed rechecks, and the periodic watchdog can all
|
// Network callbacks, VPN exemption updates, delayed rechecks, probes, and the
|
||||||
// converge during macOS interface churn; concurrent pfctl/scutil exec storms can
|
// watchdog can converge during macOS churn; concurrent pfctl/scutil work can
|
||||||
// exhaust process/file limits and make the outage worse.
|
// exhaust process/file limits or interleave anchor snapshots.
|
||||||
pfEnsureRunning atomic.Bool //lint:ignore U1000 used on darwin
|
pfEnsureRunning atomic.Bool //lint:ignore U1000 used on darwin
|
||||||
|
|
||||||
// pfExecBackoffUntil suppresses pf anchor validation after pfctl/scutil execs
|
// 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
|
pfDelayedRecheckMu sync.Mutex //lint:ignore U1000 used on darwin
|
||||||
pfDelayedRecheckTimers []*time.Timer //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.
|
// pfProbeExpected holds the domain name of a pending pf interception probe.
|
||||||
// When non-empty, the DNS handler checks incoming queries against this value
|
// 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
|
// and signals pfProbeCh if matched. The probe verifies that pf's rdr rules
|
||||||
@@ -839,6 +855,45 @@ func (p *prog) deAllocateIP() error {
|
|||||||
return nil
|
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() {
|
func (p *prog) setDNS() {
|
||||||
setDnsOK := false
|
setDnsOK := false
|
||||||
defer func() {
|
defer func() {
|
||||||
@@ -871,7 +926,30 @@ func (p *prog) setDNS() {
|
|||||||
// modifying interface DNS settings. This eliminates race conditions with VPN
|
// modifying interface DNS settings. This eliminates race conditions with VPN
|
||||||
// software that also manages DNS. See issue #489.
|
// software that also manages DNS. See issue #489.
|
||||||
if dnsIntercept {
|
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")
|
mainLog.Load().Error().Err(err).Msg("DNS intercept mode failed — falling back to interface DNS settings")
|
||||||
// Fall through to traditional setDNS behavior.
|
// Fall through to traditional setDNS behavior.
|
||||||
} else {
|
} else {
|
||||||
@@ -907,7 +985,7 @@ func (p *prog) setDNS() {
|
|||||||
ns = "127.0.0.1"
|
ns = "127.0.0.1"
|
||||||
case lc.Port != 53:
|
case lc.Port != 53:
|
||||||
ns = "127.0.0.1"
|
ns = "127.0.0.1"
|
||||||
if resolver := router.LocalResolverIP(); resolver != "" {
|
if resolver := localResolverIPFn(); resolver != "" {
|
||||||
ns = resolver
|
ns = resolver
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
@@ -926,7 +1004,7 @@ func (p *prog) setDNS() {
|
|||||||
slices.Sort(nameservers)
|
slices.Sort(nameservers)
|
||||||
|
|
||||||
netIfaceName := ""
|
netIfaceName := ""
|
||||||
netIface := p.setDnsForRunningIface(nameservers)
|
netIface := setDnsForRunningIfaceFn(p, nameservers)
|
||||||
if netIface != nil {
|
if netIface != nil {
|
||||||
netIfaceName = netIface.Name
|
netIfaceName = netIface.Name
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -6,7 +6,6 @@ import (
|
|||||||
"runtime"
|
"runtime"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"sync/atomic"
|
|
||||||
|
|
||||||
"github.com/rs/zerolog"
|
"github.com/rs/zerolog"
|
||||||
"tailscale.com/net/netmon"
|
"tailscale.com/net/netmon"
|
||||||
@@ -43,6 +42,9 @@ type vpnDNSManager struct {
|
|||||||
// as additional nameservers for queries that match split-DNS rules
|
// as additional nameservers for queries that match split-DNS rules
|
||||||
// (from ctrld config, AD domain, or VPN suffix config).
|
// (from ctrld config, AD domain, or VPN suffix config).
|
||||||
domainlessServers []string
|
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
|
// retainedAfterEmptyDiscovery means Windows reported an empty VPN DNS
|
||||||
// snapshot once while previous VPN DNS state existed. We keep that last-known
|
// 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
|
// 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
|
// discoverVPNDNS is injected for tests so Refresh does not depend on the
|
||||||
// runner host's real VPN/virtual adapter state.
|
// runner host's real VPN/virtual adapter state.
|
||||||
discoverVPNDNS func(context.Context) []ctrld.VPNDNSConfig
|
discoverVPNDNS func(context.Context) []ctrld.VPNDNSConfig
|
||||||
// refreshRunning keeps noisy network-change storms from running overlapping
|
// refreshStateMu keeps noisy network-change storms from running overlapping
|
||||||
// scutil/networksetup VPN DNS discovery work.
|
// full VPN DNS refreshes and retains one trailing refresh when an event arrives
|
||||||
refreshRunning atomic.Bool
|
// 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.
|
// Called when VPN DNS server list changes, to update intercept exemptions.
|
||||||
onServersChanged vpnDNSExemptFunc
|
onServersChanged vpnDNSExemptFunc
|
||||||
}
|
}
|
||||||
@@ -70,14 +76,39 @@ func newVPNDNSManager(exemptFunc vpnDNSExemptFunc) *vpnDNSManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Refresh re-discovers VPN DNS configs from the OS.
|
// 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) {
|
func (m *vpnDNSManager) Refresh(guardAgainstNoNameservers bool) {
|
||||||
logger := mainLog.Load()
|
m.refreshStateMu.Lock()
|
||||||
if !m.refreshRunning.CompareAndSwap(false, true) {
|
if m.refreshRunning {
|
||||||
logger.Debug().Msg("VPN DNS refresh already running, skipping duplicate")
|
m.refreshPending = true
|
||||||
|
m.refreshStateMu.Unlock()
|
||||||
|
mainLog.Load().Debug().Msg("VPN DNS refresh already running, coalescing trailing refresh")
|
||||||
return
|
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")
|
logger.Debug().Msg("Refreshing VPN DNS configurations")
|
||||||
discoverVPNDNS := m.discoverVPNDNS
|
discoverVPNDNS := m.discoverVPNDNS
|
||||||
@@ -104,8 +135,6 @@ func (m *vpnDNSManager) Refresh(guardAgainstNoNameservers bool) {
|
|||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
defer m.mu.Unlock()
|
defer m.mu.Unlock()
|
||||||
|
|
||||||
previousExemptions := m.currentExemptionsLocked()
|
|
||||||
|
|
||||||
if vpnDNSSettlingEnabled && len(configs) == 0 && guardAgainstNoNameservers && m.hasVPNDNSStateLocked() {
|
if vpnDNSSettlingEnabled && len(configs) == 0 && guardAgainstNoNameservers && m.hasVPNDNSStateLocked() {
|
||||||
if !m.retainedAfterEmptyDiscovery {
|
if !m.retainedAfterEmptyDiscovery {
|
||||||
exemptions := m.currentExemptionsLocked()
|
exemptions := m.currentExemptionsLocked()
|
||||||
@@ -116,6 +145,8 @@ func (m *vpnDNSManager) Refresh(guardAgainstNoNameservers bool) {
|
|||||||
if m.onServersChanged != nil {
|
if m.onServersChanged != nil {
|
||||||
if err := m.onServersChanged(exemptions); err != nil {
|
if err := m.onServersChanged(exemptions); err != nil {
|
||||||
logger.Error().Err(err).Msg("Failed to re-apply retained VPN DNS exemptions")
|
logger.Error().Err(err).Msg("Failed to re-apply retained VPN DNS exemptions")
|
||||||
|
} else {
|
||||||
|
m.appliedExemptions = append([]vpnDNSExemption(nil), exemptions...)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return
|
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",
|
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))
|
len(m.configs), len(m.routes), len(m.domainlessServers), len(exemptions))
|
||||||
|
|
||||||
// Update intercept rules to permit VPN DNS traffic only when the exemption set
|
// Update intercept rules only when desired exemptions differ from the last
|
||||||
// actually changes. Network-change events can fire repeatedly while macOS/VPN
|
// successfully applied set. Failed PF/WFP callbacks remain retryable on the
|
||||||
// state is otherwise identical; rewriting pf for identical exemptions can feed
|
// next refresh even when discovery returns the same VPN DNS state.
|
||||||
// a self-triggering network-change loop. Empty exemptions are still applied
|
m.updateInterceptExemptionsIfChanged(logger, exemptions, "VPN DNS")
|
||||||
// when they differ from the previous set, so stale VPN exemptions are cleared
|
|
||||||
// on disconnect.
|
|
||||||
m.updateInterceptExemptionsIfChanged(logger, previousExemptions, 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 {
|
if m.onServersChanged == nil {
|
||||||
return
|
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)
|
logger.Debug().Msgf("VPN DNS exemptions unchanged after %s refresh; skipping intercept rule update", reason)
|
||||||
return
|
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")
|
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
|
// RefreshRoutesOnly re-discovers VPN DNS configs and updates ctrld's
|
||||||
// in-memory split-DNS routes. It intentionally does not call onServersChanged,
|
// in-memory split-DNS routes. It applies intercept exemptions only when that set
|
||||||
// so it does not rewrite/reload pf/WFP rules. Use this for post-settle discovery
|
// changes, while holding the shared discovery lane so a concurrent full refresh
|
||||||
// checks where we only need to learn late-published VPN search domains.
|
// cannot commit a newer snapshot and then be overwritten by this one.
|
||||||
func (m *vpnDNSManager) RefreshRoutesOnly() (routes, domainlessServers, exemptions int) {
|
func (m *vpnDNSManager) RefreshRoutesOnly() (routes, domainlessServers, exemptions int) {
|
||||||
logger := mainLog.Load()
|
logger := mainLog.Load()
|
||||||
|
|
||||||
|
m.discoveryMu.Lock()
|
||||||
|
defer m.discoveryMu.Unlock()
|
||||||
|
|
||||||
logger.Debug().Msg("Refreshing VPN DNS route state only")
|
logger.Debug().Msg("Refreshing VPN DNS route state only")
|
||||||
discoverVPNDNS := m.discoverVPNDNS
|
discoverVPNDNS := m.discoverVPNDNS
|
||||||
if discoverVPNDNS == nil {
|
if discoverVPNDNS == nil {
|
||||||
@@ -267,10 +300,26 @@ func (m *vpnDNSManager) RefreshRoutesOnly() (routes, domainlessServers, exemptio
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
m.domainlessServers = domainless
|
m.domainlessServers = domainless
|
||||||
|
currentExemptions := m.currentExemptionsLocked()
|
||||||
|
|
||||||
logger.Debug().Msgf("VPN DNS route-only refresh completed: %d configs, %d routes, %d domainless servers, %d exemptions",
|
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()))
|
len(m.configs), len(m.routes), len(m.domainlessServers), len(currentExemptions))
|
||||||
return len(m.routes), len(m.domainlessServers), len(m.currentExemptionsLocked())
|
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 {
|
func (m *vpnDNSManager) hasVPNDNSStateLocked() bool {
|
||||||
|
|||||||
+157
-6
@@ -2,9 +2,11 @@ package cli
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"sync"
|
"sync"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/Control-D-Inc/ctrld"
|
"github.com/Control-D-Inc/ctrld"
|
||||||
)
|
)
|
||||||
@@ -16,7 +18,7 @@ func withVPNDNSSettlingEnabled(t *testing.T) {
|
|||||||
t.Cleanup(func() { vpnDNSSettlingEnabled = old })
|
t.Cleanup(func() { vpnDNSSettlingEnabled = old })
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestVPNDNSRefreshSkipsConcurrentDuplicate(t *testing.T) {
|
func TestVPNDNSRefreshCoalescesConcurrentTrailingRefresh(t *testing.T) {
|
||||||
m := newVPNDNSManager(nil)
|
m := newVPNDNSManager(nil)
|
||||||
started := make(chan struct{})
|
started := make(chan struct{})
|
||||||
release := make(chan struct{})
|
release := make(chan struct{})
|
||||||
@@ -25,9 +27,16 @@ func TestVPNDNSRefreshSkipsConcurrentDuplicate(t *testing.T) {
|
|||||||
var calls atomic.Int32
|
var calls atomic.Int32
|
||||||
|
|
||||||
m.discoverVPNDNS = func(context.Context) []ctrld.VPNDNSConfig {
|
m.discoverVPNDNS = func(context.Context) []ctrld.VPNDNSConfig {
|
||||||
calls.Add(1)
|
call := calls.Add(1)
|
||||||
once.Do(func() { close(started) })
|
once.Do(func() { close(started) })
|
||||||
<-release
|
<-release
|
||||||
|
if call == 2 {
|
||||||
|
return []ctrld.VPNDNSConfig{{
|
||||||
|
InterfaceName: "utun-latest",
|
||||||
|
Servers: []string{"10.0.0.2"},
|
||||||
|
Domains: []string{"latest.internal"},
|
||||||
|
}}
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -41,8 +50,11 @@ func TestVPNDNSRefreshSkipsConcurrentDuplicate(t *testing.T) {
|
|||||||
close(release)
|
close(release)
|
||||||
<-done
|
<-done
|
||||||
|
|
||||||
if calls.Load() != 1 {
|
if calls.Load() != 2 {
|
||||||
t.Fatalf("expected overlapping refresh to be skipped, got %d discovery calls", calls.Load())
|
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) {
|
func TestVPNDNSRefreshClearsOnSecondGuardedEmptyDiscovery(t *testing.T) {
|
||||||
withVPNDNSSettlingEnabled(t)
|
withVPNDNSSettlingEnabled(t)
|
||||||
var gotExemptions []vpnDNSExemption
|
var gotExemptions []vpnDNSExemption
|
||||||
|
updates := 0
|
||||||
m := newVPNDNSManager(func(exemptions []vpnDNSExemption) error {
|
m := newVPNDNSManager(func(exemptions []vpnDNSExemption) error {
|
||||||
|
updates++
|
||||||
gotExemptions = exemptions
|
gotExemptions = exemptions
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
@@ -86,6 +100,7 @@ func TestVPNDNSRefreshClearsOnSecondGuardedEmptyDiscovery(t *testing.T) {
|
|||||||
Servers: []string{"10.25.37.21"},
|
Servers: []string{"10.25.37.21"},
|
||||||
}}
|
}}
|
||||||
m.domainlessServers = []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.retainedAfterEmptyDiscovery = true
|
||||||
|
|
||||||
m.Refresh(true)
|
m.Refresh(true)
|
||||||
@@ -93,8 +108,8 @@ func TestVPNDNSRefreshClearsOnSecondGuardedEmptyDiscovery(t *testing.T) {
|
|||||||
if got := m.DomainlessServers(); len(got) != 0 {
|
if got := m.DomainlessServers(); len(got) != 0 {
|
||||||
t.Fatalf("expected domainless servers to be cleared on second empty discovery, got %v", got)
|
t.Fatalf("expected domainless servers to be cleared on second empty discovery, got %v", got)
|
||||||
}
|
}
|
||||||
if len(gotExemptions) != 0 {
|
if updates != 1 || len(gotExemptions) != 0 {
|
||||||
t.Fatalf("expected empty exemptions after clearing stale state, got %v", gotExemptions)
|
t.Fatalf("expected one empty exemption update after clearing stale state, calls=%d exemptions=%v", updates, gotExemptions)
|
||||||
}
|
}
|
||||||
if m.retainedAfterEmptyDiscovery {
|
if m.retainedAfterEmptyDiscovery {
|
||||||
t.Fatal("expected retained empty-discovery marker to be cleared with stale state")
|
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) {
|
func TestVPNDNSTransportFailureSuppressesFallbackOnlyWhileRetainingState(t *testing.T) {
|
||||||
withVPNDNSSettlingEnabled(t)
|
withVPNDNSSettlingEnabled(t)
|
||||||
m := newVPNDNSManager(nil)
|
m := newVPNDNSManager(nil)
|
||||||
@@ -145,3 +210,89 @@ func TestVPNDNSTransportFailureSuppressesFallbackOnlyWhileRetainingState(t *test
|
|||||||
t.Fatal("expected reachable DNS response to clear retained empty-discovery state")
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -298,11 +298,22 @@ The full pf reload is VPN-safe: it reassembles from `pfctl -sr` + `pfctl -sn`
|
|||||||
### What about `set skip on lo0`?
|
### 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.
|
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()`,
|
**Mitigation:** the interception probe. `probePFIntercept()` sends a real query from
|
||||||
we strip `lo0` from any `set skip on` directives before reloading. The watchdog
|
outside the `_ctrld` group and confirms the listener received the redirect, which cannot
|
||||||
also checks for `set skip on lo0` and triggers a restore if detected. The
|
succeed while pf is bypassing loopback — so a skip on `lo0` shows up as a probe failure
|
||||||
interception probe provides an additional safety net — if `set skip on lo0` gets
|
and triggers a full reload.
|
||||||
re-applied by another program, the probe will fail and trigger 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
|
## Cleanup
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user