Files
ctrld/cmd/cli/recovery_state_test.go
T
Dev Scribe 8013bb72d2 dns intercept: handle DNS-less networks and canceled-recovery state leak
macOS intercept mode fails totally on networks that provide no usable
IPv4 DNS (e.g. IPv6-only iPhone tethering with 464XLAT): the pf ruleset
blocks all outbound IPv6 port 53, and with no IPv4 DNS configured
mDNSResponder emits no DNS packets at all, so pf has nothing to
intercept while the Control D upstream stays provably healthy.

When network-change recovery discovers no usable IPv4 DNS on the
default-route service, set 127.0.0.1 as that service DNS so macOS can
emit queries that land directly on the listener. The entry is removed
when the network regains IPv4 DNS and on intercept shutdown; networks
that provide IPv4 DNS are never modified. Runs on the already-debounced
recovery path so interface flaps do not churn networksetup.

Also fix the canceled-recovery state leak: the cancellation early
return never reset recoveryBypass/recoveryRunning, so a flap burst
ending in a canceled recovery left the daemon in bypass forever with
the DNS watchdog disabled. Cleanup is generation-gated so a superseded
recovery never clears state owned by its successor.
2026-09-01 11:08:13 +07:00

162 lines
5.3 KiB
Go

package cli
import (
"testing"
"time"
)
// interceptStateStub stands in for the platform pfState/wfpState; the
// recovery cleanup path only checks dnsInterceptState != nil.
type interceptStateStub struct{}
// setupInterceptRecovery puts p into "intercept-mode recovery in flight"
// state and restores the package-level dnsIntercept flag on cleanup.
func setupInterceptRecovery(t *testing.T, p *prog) {
t.Helper()
oldIntercept := dnsIntercept
dnsIntercept = true
t.Cleanup(func() { dnsIntercept = oldIntercept })
p.dnsInterceptState = &interceptStateStub{}
p.recoveryBypass.Store(true)
p.recoveryRunning.Store(true)
p.recoveryCancel = func() {}
}
// TestRecoveryCanceledCleanup_LastRecoveryResetsState pins issue #597: a
// canceled recovery with no successor must clear recoveryBypass and
// recoveryRunning, or the daemon stays in bypass forever (every query
// detours to the OS resolver) and the DNS watchdog stays disabled.
func TestRecoveryCanceledCleanup_LastRecoveryResetsState(t *testing.T) {
p := &prog{}
setupInterceptRecovery(t, p)
gen := p.recoveryGen.Add(1)
p.recoveryCanceledCleanup(gen)
if p.recoveryBypass.Load() {
t.Error("recoveryBypass still set after canceled recovery with no successor")
}
if p.recoveryRunning.Load() {
t.Error("recoveryRunning still set after canceled recovery with no successor")
}
p.recoveryCancelMu.Lock()
cancelCleared := p.recoveryCancel == nil
p.recoveryCancelMu.Unlock()
if !cancelCleared {
t.Error("recoveryCancel not cleared after canceled recovery with no successor")
}
}
// TestRecoveryCanceledCleanup_SupersededKeepsSuccessorState pins the
// captive-portal/network-flap contract: when a newer recovery superseded the
// canceled one, the canceled recovery must NOT clear shared state — the
// successor owns bypass for its own duration.
func TestRecoveryCanceledCleanup_SupersededKeepsSuccessorState(t *testing.T) {
p := &prog{}
setupInterceptRecovery(t, p)
gen := p.recoveryGen.Add(1)
// A successor recovery started.
p.recoveryGen.Add(1)
p.recoveryCanceledCleanup(gen)
if !p.recoveryBypass.Load() {
t.Error("superseded canceled recovery cleared recoveryBypass owned by its successor")
}
if !p.recoveryRunning.Load() {
t.Error("superseded canceled recovery cleared recoveryRunning owned by its successor")
}
p.recoveryCancelMu.Lock()
cancelKept := p.recoveryCancel != nil
p.recoveryCancelMu.Unlock()
if !cancelKept {
t.Error("superseded canceled recovery cleared the successor's recoveryCancel")
}
}
// TestRecoveryCanceledCleanup_NonInterceptResetsRunning covers traditional
// (non-intercept) mode: recoveryRunning must still be reset so watchdogs
// resume, while bypass is untouched (it is never set in that mode).
func TestRecoveryCanceledCleanup_NonInterceptResetsRunning(t *testing.T) {
oldIntercept := dnsIntercept
dnsIntercept = false
t.Cleanup(func() { dnsIntercept = oldIntercept })
p := &prog{}
p.recoveryRunning.Store(true)
p.recoveryCancel = func() {}
gen := p.recoveryGen.Add(1)
p.recoveryCanceledCleanup(gen)
if p.recoveryRunning.Load() {
t.Error("recoveryRunning still set after canceled non-intercept recovery")
}
}
func TestBeginRecoveryTransfersOwnershipAtomically(t *testing.T) {
oldIntercept := dnsIntercept
dnsIntercept = true
t.Cleanup(func() { dnsIntercept = oldIntercept })
p := &prog{dnsInterceptState: &interceptStateStub{}}
firstCtx, firstGen, _, ok := p.beginRecovery(RecoveryReasonRegularFailure)
if !ok {
t.Fatal("first recovery did not acquire ownership")
}
if _, _, _, ok := p.beginRecovery(RecoveryReasonRegularFailure); ok {
t.Fatal("duplicate upstream recovery acquired ownership")
}
_, successorGen, intercept, ok := p.beginRecovery(RecoveryReasonNetworkChange)
if !ok || !intercept || successorGen <= firstGen {
t.Fatalf("network recovery did not replace owner: first=%d successor=%d intercept=%v ok=%v", firstGen, successorGen, intercept, ok)
}
select {
case <-firstCtx.Done():
case <-time.After(time.Second):
t.Fatal("successor did not cancel the previous recovery")
}
p.recoveryCanceledCleanup(firstGen)
if !p.recoveryRunning.Load() || !p.recoveryBypass.Load() || !p.recoveryOwnsState(successorGen) {
t.Fatal("stale cleanup changed successor-owned recovery state")
}
if !p.completeRecovery(successorGen) {
t.Fatal("successor could not complete its own recovery state")
}
}
func TestRecoveryCleanupClearsBypassAfterInterceptStateDisappears(t *testing.T) {
p := &prog{}
p.recoveryBypass.Store(true)
p.recoveryRunning.Store(true)
p.recoveryCancel = func() {}
gen := p.recoveryGen.Add(1)
p.recoveryCanceledCleanup(gen)
if p.recoveryBypass.Load() || p.recoveryRunning.Load() {
t.Fatal("cleanup retained recovery flags after intercept state disappeared")
}
}
func TestSystemNameserversForInterceptRetryNormalizesEmptyDiscovery(t *testing.T) {
original := initializeOsResolverWithSystemNameserversFn
called := false
initializeOsResolverWithSystemNameserversFn = func(guard bool) ([]string, []string) {
called = true
if !guard {
t.Error("intercept retry discovery did not guard the existing resolver")
}
return nil, nil
}
t.Cleanup(func() { initializeOsResolverWithSystemNameserversFn = original })
if got := systemNameserversForInterceptRetry(); got == nil || len(got) != 0 {
t.Fatalf("system discovery = %#v, want non-nil empty slice", got)
}
if !called {
t.Fatal("system discovery was not called")
}
}