mirror of
https://github.com/Control-D-Inc/ctrld.git
synced 2026-09-04 13:36:35 +02:00
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.
This commit is contained in:
@@ -91,9 +91,12 @@ func TestInterfaceDNSFallbackViable(t *testing.T) {
|
||||
// than depending on the runner denying a privileged operation.
|
||||
type interceptFallbackHarness struct {
|
||||
interceptCalls int
|
||||
ensureTargetCalls int
|
||||
ensuredNameservers []string
|
||||
installedNameservers []string
|
||||
installCalls int
|
||||
resetCalls int
|
||||
removeTargetCalls int
|
||||
refusals []string
|
||||
}
|
||||
|
||||
@@ -101,12 +104,12 @@ func newInterceptFallbackHarness(t *testing.T, lc *ctrld.ListenerConfig) *interc
|
||||
t.Helper()
|
||||
h := &interceptFallbackHarness{}
|
||||
|
||||
origStart, origInstall := startDNSInterceptFn, setDnsForRunningIfaceFn
|
||||
origStart, origEnsure, origRemove, origInstall := startDNSInterceptFn, ensureInterceptDNSTargetFn, removeInterceptDNSTargetFn, setDnsForRunningIfaceFn
|
||||
origReset, origFatal := resetDNSFn, refuseFallbackFatal
|
||||
origResolver := localResolverIPFn
|
||||
origCfg, origMode, origIntercept, origHard := cfg, interceptMode, dnsIntercept, hardIntercept
|
||||
t.Cleanup(func() {
|
||||
startDNSInterceptFn, setDnsForRunningIfaceFn = origStart, origInstall
|
||||
startDNSInterceptFn, ensureInterceptDNSTargetFn, removeInterceptDNSTargetFn, setDnsForRunningIfaceFn = origStart, origEnsure, origRemove, origInstall
|
||||
resetDNSFn, refuseFallbackFatal = origReset, origFatal
|
||||
localResolverIPFn = origResolver
|
||||
cfg, interceptMode, dnsIntercept, hardIntercept = origCfg, origMode, origIntercept, origHard
|
||||
@@ -121,6 +124,11 @@ func newInterceptFallbackHarness(t *testing.T, lc *ctrld.ListenerConfig) *interc
|
||||
h.interceptCalls++
|
||||
return errors.New("dns intercept: injected start failure")
|
||||
}
|
||||
ensureInterceptDNSTargetFn = func(_ *prog, nameservers []string) {
|
||||
h.ensureTargetCalls++
|
||||
h.ensuredNameservers = slices.Clone(nameservers)
|
||||
}
|
||||
removeInterceptDNSTargetFn = func(_ *prog, _ string) { h.removeTargetCalls++ }
|
||||
setDnsForRunningIfaceFn = func(_ *prog, nameservers []string) *net.Interface {
|
||||
h.installCalls++
|
||||
h.installedNameservers = nameservers
|
||||
@@ -143,7 +151,31 @@ func newInterceptFallbackHarness(t *testing.T, lc *ctrld.ListenerConfig) *interc
|
||||
func (h *interceptFallbackHarness) run(t *testing.T) {
|
||||
t.Helper()
|
||||
p := &prog{cfg: &cfg}
|
||||
p.setDNS()
|
||||
p.setDNS(nil)
|
||||
}
|
||||
|
||||
func TestSetDNSEnsuresInterceptTargetAfterSuccessfulStart(t *testing.T) {
|
||||
h := newInterceptFallbackHarness(t, &ctrld.ListenerConfig{IP: "127.0.0.1", Port: 5354})
|
||||
startDNSInterceptFn = func(_ *prog) error {
|
||||
h.interceptCalls++
|
||||
return nil
|
||||
}
|
||||
want := []string{"fe80::1"}
|
||||
p := &prog{cfg: &cfg}
|
||||
p.setDNS(want)
|
||||
|
||||
if h.interceptCalls != 1 {
|
||||
t.Fatalf("intercept start called %d time(s), want 1", h.interceptCalls)
|
||||
}
|
||||
if h.ensureTargetCalls != 1 {
|
||||
t.Fatalf("intercept DNS target ensured %d time(s), want 1 after successful start", h.ensureTargetCalls)
|
||||
}
|
||||
if !slices.Equal(h.ensuredNameservers, want) {
|
||||
t.Fatalf("system nameservers = %v, want %v", h.ensuredNameservers, want)
|
||||
}
|
||||
if h.installCalls != 0 {
|
||||
t.Fatalf("interface-DNS fallback installed %d time(s) after successful intercept start", h.installCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetDNSExplicitOffOverridesConfig(t *testing.T) {
|
||||
@@ -160,6 +192,9 @@ func TestSetDNSExplicitOffOverridesConfig(t *testing.T) {
|
||||
if h.installCalls != 1 {
|
||||
t.Fatalf("interface DNS installed %d time(s), want 1", h.installCalls)
|
||||
}
|
||||
if h.removeTargetCalls != 1 {
|
||||
t.Fatalf("stale intercept DNS target cleanup called %d time(s), want 1", h.removeTargetCalls)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSetDNSRefusesUnreachableFallback is the behaviour test for the reported outage: it
|
||||
@@ -182,6 +217,9 @@ func TestSetDNSRefusesUnreachableFallback(t *testing.T) {
|
||||
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 h.removeTargetCalls != 1 {
|
||||
t.Errorf("stale intercept DNS target cleanup called %d time(s), want 1 after intercept failure", h.removeTargetCalls)
|
||||
}
|
||||
if len(h.refusals) == 0 {
|
||||
t.Fatal("refusal was not surfaced: startup must fail loudly rather than silently skip the fallback")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user