Port DNS64, VPN routing and DNS recovery ownership to master

Retain master's context-aware VPN and resolver APIs, per-program logger,
shutdown fencing, native CLAT service identity and firewall behavior.
Keep saved-DNS paths owned by the root ctrld package; isolate the added
ownership/sweep tests with a shared path seam and temporary backups.

Preserve the source DNS64 DNSSEC/cache gates, longest-suffix VPN routing,
DNS-journal convergence, scoped IPv6 handling, target ownership checks,
custom-listener regressions and documented native QA limits.
This commit is contained in:
Dev Scribe authored and Cuong Manh Le committed 2026-09-28 14:44:47 +07:00
1 parent a9382c3a50
commit c7b3881e13
23 files changed
+1046 -92

No files matched your search

+11 -3
View File
@@ -1558,12 +1558,20 @@ func uninstall(p *prog, s service.Service) {
}
}
var restoreSavedStaticDNSInterfacesFn = withEachPhysicalInterfaces
var restoreSavedStaticDNSRestoreFn = restoreDNS
var savedStaticDNSPathFn = ctrld.SavedStaticDnsSettingsFilePath
// restoreSavedStaticDNS restores DNS from saved static config files on physical interfaces.
func restoreSavedStaticDNS(excludeIfaceName string, removeSaved bool) {
withEachPhysicalInterfaces(excludeIfaceName, "restore static DNS", func(i *net.Interface) error {
file := ctrld.SavedStaticDnsSettingsFilePath(i)
restoreSavedStaticDNSInterfacesFn(excludeIfaceName, "restore static DNS", func(i *net.Interface) error {
if !interceptTargetAllowsStaticRestore(i.Name) {
mainLog.Load().Debug().Msgf("Saved static DNS restore skipped on interface %s: intercept target cleanup is pending or ownership is unreadable", i.Name)
return nil
}
file := savedStaticDNSPathFn(i)
if _, err := os.Stat(file); err == nil {
if err := restoreDNS(i); err != nil {
if err := restoreSavedStaticDNSRestoreFn(i); err != nil {
mainLog.Load().Error().Err(err).Msgf("Could not restore static DNS on interface %s", i.Name)
} else {
mainLog.Load().Debug().Msgf("Restored static DNS on interface %s successfully", i.Name)
+24 -5
View File
@@ -175,6 +175,9 @@ func synthesizeAAAAFromA(req *dns.Msg, aAnswer *dns.Msg, prefix netip.Prefix) *d
out.SetReply(req)
out.Rcode = aAnswer.Rcode
out.Compress = true
// An authenticated A RRset alone cannot authenticate synthesized AAAA.
// maybeDNS64 also checks the original AAAA denial before setting AD.
out.AuthenticatedData = false
answers := make([]dns.RR, 0, len(aAnswer.Answer))
pb := prefix.Addr().As16()
for _, rr := range aAnswer.Answer {
@@ -228,10 +231,7 @@ func answerHasAAAA(answer *dns.Msg) bool {
// rcodes are never synthesized (RFC 6147 §5.1.2: the name genuinely does
// not exist or the query failed).
func dns64Eligible(req, answer *dns.Msg) bool {
if req == nil || answer == nil || len(req.Question) == 0 || req.CheckingDisabled {
return false
}
if req.Question[0].Qtype != dns.TypeAAAA {
if !dns64RequestAllowed(req) || answer == nil {
return false
}
if answer.Rcode != dns.RcodeSuccess {
@@ -240,6 +240,13 @@ func dns64Eligible(req, answer *dns.Msg) bool {
return !answerHasAAAA(answer)
}
// Apply the same client gate to cold synthesis and fresh/stale variants.
// In particular, RFC 6147 section 5.5 forbids synthesis for CD+DO clients.
// Retain the existing conservative bypass for all CD requests.
func dns64RequestAllowed(req *dns.Msg) bool {
return req != nil && len(req.Question) > 0 && req.Question[0].Qtype == dns.TypeAAAA && !req.CheckingDisabled
}
// dns64Active reports whether synthesis should currently run, re-evaluating
// network class and (if needed) kicking off background prefix discovery at
// most every dns64RecheckInterval.
@@ -307,7 +314,13 @@ func dns64CacheVariant(prefix netip.Prefix) string {
}
func dns64CacheKey(msg *dns.Msg, upstream string, prefix netip.Prefix) dnscache.Key {
return dnscache.NewVariantKey(msg, upstream, dns64CacheVariant(prefix))
variant := dns64CacheVariant(prefix)
// DO changes the authentication and DNSSEC data in a synthesized reply.
// Do not reuse a non-DO result for a DO client (or the reverse).
if opt := msg.IsEdns0(); opt != nil && opt.Do() {
variant += ":do"
}
return dnscache.NewVariantKey(msg, upstream, variant)
}
func (p *prog) resetDNS64State() {
@@ -477,6 +490,12 @@ func (p *prog) maybeDNS64(ctx context.Context, req *dns.Msg, answer *dns.Msg, re
// DNS64 variant to avoid repeating both upstream lookups.
return answer, prefix
}
// RFC 6147 section 5.5: AD may describe synthesis only when both the
// AAAA denial and the A response were authenticated by our upstream.
// Without DO, synthesized responses must not assert AD.
if opt := req.IsEdns0(); opt != nil && opt.Do() {
synth.AuthenticatedData = answer.AuthenticatedData && aAnswer.AuthenticatedData
}
ctrld.Log(ctx, mainLog.Load().Debug(), "dns64: synthesized AAAA from A records via NAT64 prefix %s", prefix)
return synth, prefix
}
+143
View File
@@ -0,0 +1,143 @@
package cli
import (
"context"
"fmt"
"net"
"testing"
"time"
"github.com/miekg/dns"
ctrld "github.com/Control-D-Inc/ctrld"
"github.com/Control-D-Inc/ctrld/internal/dnscache"
)
func TestDNS64DNSSECCacheAndSynthesis(t *testing.T) {
captureDebugMainLog(t)
for _, do := range []bool{false, true} {
for _, denialAD := range []bool{false, true} {
for _, aAD := range []bool{false, true} {
t.Run(fmt.Sprintf("DO=%v/denialAD=%v/A_AD=%v", do, denialAD, aAD), func(t *testing.T) {
req := mkAAAAReq("dnssec.example.")
req.SetEdns0(1232, do)
denial := new(dns.Msg)
denial.SetReply(req)
denial.AuthenticatedData = denialAD
cache, err := dnscache.NewLRUCache(10)
if err != nil {
t.Fatal(err)
}
p := &prog{cache: cache}
p.logger.Store(mainLog.Load())
p.dns64.active = true
p.dns64.prefix = dns64WellKnownPrefix
p.dns64.checkedAt = time.Now()
calls := 0
resolveA := func(q *dns.Msg) *dns.Msg {
calls++
if q.Question[0].Qtype != dns.TypeA {
t.Fatal("not an A lookup")
}
a := new(dns.Msg)
a.SetReply(q)
a.AuthenticatedData = aAD
a.Answer = []dns.RR{&dns.A{Hdr: dns.RR_Header{Name: q.Question[0].Name, Rrtype: dns.TypeA, Class: dns.ClassINET, Ttl: 60}, A: net.ParseIP("198.51.100.7")}}
return a
}
out, prefix := p.maybeDNS64(context.Background(), req, denial, resolveA)
wantAD := do && denialAD && aAD
if calls != 1 || !answerHasAAAA(out) || !prefix.IsValid() || out.AuthenticatedData != wantAD {
t.Fatalf("cold: calls=%d AAAA=%v prefix=%v AD=%v wantAD=%v", calls, answerHasAAAA(out), prefix, out.AuthenticatedData, wantAD)
}
now := time.Now()
for _, stale := range []bool{false, true} {
expiry := now.Add(time.Minute)
if stale {
expiry = now.Add(-time.Minute)
}
cache.Add(dns64CacheKey(req, "upstream.0", prefix), dnscache.NewValue(out, expiry))
answer, old, hit, variant, _ := p.cachedResponse(req, "upstream.0", prefix, true, now)
got := answer
if stale {
got = old
}
if got == nil || !answerHasAAAA(got) || got.AuthenticatedData != wantAD || hit == stale || variant == stale {
t.Fatalf("normal cache stale=%v: answer=%v old=%v hit=%v variant=%v", stale, answer, old, hit, variant)
}
cd := req.Copy()
cd.CheckingDisabled = true
answer, old, hit, variant, _ = p.cachedResponse(cd, "upstream.0", prefix, true, now)
if answerHasAAAA(answer) || answerHasAAAA(old) || hit || variant {
t.Errorf("CD cache stale=%v leaked synthesis: answer=%v old=%v hit=%v variant=%v", stale, answer, old, hit, variant)
}
cold, coldPrefix := p.maybeDNS64(context.Background(), cd, denial, resolveA)
if cold != denial || coldPrefix.IsValid() || calls != 1 {
t.Fatal("CD cold path performed synthesis")
}
}
if denial.AuthenticatedData != denialAD {
t.Fatal("mutated denial")
}
})
}
}
}
}
func TestDNS64DNSSECOrdinaryCacheFallback(t *testing.T) {
req := mkAAAAReq("dnssec.example.")
req.CheckingDisabled = true
req.SetEdns0(1232, true)
ordinary := new(dns.Msg)
ordinary.SetReply(req)
cache, err := dnscache.NewLRUCache(10)
if err != nil {
t.Fatal(err)
}
p := &prog{cache: cache}
p.logger.Store(mainLog.Load())
now := time.Now()
cache.Add(dnscache.NewKey(req, "upstream.0"), dnscache.NewValue(ordinary, now.Add(time.Minute)))
answer, _, hit, variant, bypass := p.cachedResponse(req, "upstream.0", dns64WellKnownPrefix, true, now)
if !hit || variant || bypass || answerHasAAAA(answer) {
t.Fatalf("ordinary cache: hit=%v variant=%v bypass=%v answer=%v", hit, variant, bypass, answer)
}
}
func TestRecoveryOSOnlyCandidatePool(t *testing.T) {
for _, reason := range []RecoveryReason{RecoveryReasonNetworkChange, RecoveryReasonRegularFailure} {
p := &prog{cfg: &ctrld.Config{Upstream: map[string]*ctrld.UpstreamConfig{"0": {Type: ctrld.ResolverTypeOS}, "nil": nil}}}
p.logger.Store(mainLog.Load())
pool := p.buildRecoveryUpstreams(reason)
if len(pool) != 1 || pool[upstreamOS] != osUpstreamConfig {
t.Errorf("reason=%v: OS-only recovery candidates=%v", reason, pool)
}
}
}
func TestDNS64DNSSECCacheDOIsolation(t *testing.T) {
for _, do := range []bool{false, true} {
req := mkAAAAReq("dnssec.example.")
req.SetEdns0(1232, do)
response := new(dns.Msg)
response.SetReply(req)
response.AuthenticatedData = do
response.Answer = []dns.RR{&dns.AAAA{Hdr: dns.RR_Header{Name: req.Question[0].Name, Rrtype: dns.TypeAAAA, Class: dns.ClassINET, Ttl: 60}, AAAA: net.ParseIP("64:ff9b::c633:6407")}}
cache, err := dnscache.NewLRUCache(10)
if err != nil {
t.Fatal(err)
}
p := &prog{cache: cache}
p.logger.Store(mainLog.Load())
now := time.Now()
other := req.Copy()
other.IsEdns0().SetDo(!do)
for _, expiry := range []time.Time{now.Add(time.Minute), now.Add(-time.Minute)} {
cache.Add(dns64CacheKey(req, "upstream.0", dns64WellKnownPrefix), dnscache.NewValue(response, expiry))
answer, stale, hit, variant, _ := p.cachedResponse(other, "upstream.0", dns64WellKnownPrefix, true, now)
if answer != nil || stale != nil || hit || variant {
t.Fatalf("DO=%v result reused for DO=%v: answer=%v stale=%v", do, !do, answer, stale)
}
}
}
}
+22 -8
View File
@@ -735,6 +735,21 @@ func flushPFStates() {
}
}
// pfNameserverAddress keeps socket zones out of group-scoped PF addresses.
// ctrld already has a blanket group exemption in the same anchor.
func pfNameserverAddress(server string) (string, string, bool) {
host, _, err := net.SplitHostPort(server)
if err != nil {
host = server
}
addr, err := netip.ParseAddr(host)
if err != nil {
return "", "", false
}
host = addr.WithZone("").Unmap().String()
return host, pfAddressFamily(host), true
}
func pfAddressFamily(ip string) string {
if addr := net.ParseIP(ip); addr != nil && addr.To4() == nil {
return "inet6"
@@ -937,11 +952,10 @@ func (p *prog) buildPFAnchorRulesForState(vpnExemptions []vpnDNSExemption, tunne
rules.WriteString("# Scoped to group " + pfGroupName + " so only ctrld's own queries are exempted,\n")
rules.WriteString("# preventing other processes from bypassing the redirect by querying these IPs.\n")
for _, ns := range osNS {
host, _, _ := net.SplitHostPort(ns)
if host == "" {
host = ns
host, af, ok := pfNameserverAddress(ns)
if !ok {
continue
}
af := pfAddressFamily(host)
rules.WriteString(fmt.Sprintf("pass out quick on ! lo0 %s proto { udp, tcp } from any to %s port 53 group %s\n", af, host, pfGroupName))
}
rules.WriteString("\n")
@@ -973,10 +987,10 @@ func (p *prog) buildPFAnchorRulesForState(vpnExemptions []vpnDNSExemption, tunne
rules.WriteString("# Exempt VPN DNS servers: ctrld's own queries (group-scoped).\n")
seen := make(map[string]bool)
for _, ex := range vpnExemptions {
if !seen[ex.Server] {
seen[ex.Server] = true
af := pfAddressFamily(ex.Server)
rules.WriteString(fmt.Sprintf("pass out quick on ! lo0 %s proto { udp, tcp } from any to %s port 53 group %s\n", af, ex.Server, pfGroupName))
host, af, ok := pfNameserverAddress(ex.Server)
if ok && !seen[host] {
seen[host] = true
rules.WriteString(fmt.Sprintf("pass out quick on ! lo0 %s proto { udp, tcp } from any to %s port 53 group %s\n", af, host, pfGroupName))
}
}
rules.WriteString("\n")
+6 -1
View File
@@ -136,7 +136,7 @@ func (p *prog) addCachedResponse(key dnscache.Key, answer *dns.Msg) {
}
func (p *prog) cachedResponse(req *dns.Msg, upstream string, dns64Prefix netip.Prefix, dns64Active bool, now time.Time) (answer, stale *dns.Msg, hit, dns64Hit, dns64Bypass bool) {
if dns64Active {
if dns64Active && dns64RequestAllowed(req) {
if cachedValue := p.cache.Get(dns64CacheKey(req, upstream, dns64Prefix)); cachedValue != nil {
answer = cachedValue.Msg.Copy()
ctrld.SetCacheReply(answer, req, answer.Rcode)
@@ -2942,6 +2942,11 @@ func (p *prog) buildRecoveryUpstreams(reason RecoveryReason) map[string]*ctrld.U
upstreams[upstreamPrefix+k] = uc
}
}
// An OS-only configuration still needs a recovery worker. Do not add
// OS to mixed pools: their existing recovery boundaries stay intact.
if len(upstreams) == 0 {
upstreams[upstreamOS] = osUpstreamConfig
}
}
return upstreams
}
+84 -15
View File
@@ -33,7 +33,14 @@ var (
interceptSaveStaticDNSSnapshotFn = func(iface *net.Interface, dns []string, owned string) error {
return saveTargetStaticDNSSnapshot(ctrld.SavedStaticDnsSettingsFilePath(iface), dns, owned)
}
interceptSaveCurrentStaticDNSFn = saveCurrentStaticDNS
interceptSaveCurrentStaticDNSFn = saveCurrentStaticDNS
interceptRemoveSavedDNSFn = func(iface *net.Interface) error {
err := os.Remove(savedStaticDNSPathFn(iface))
if errors.Is(err, os.ErrNotExist) {
return nil
}
return err
}
interceptSetDNSFn = setDNS
interceptSavedStaticNameserversFn = ctrld.SavedStaticNameservers
interceptResetDNSIgnoreUnusableIfaceFn = resetDnsIgnoreUnusableInterface
@@ -48,6 +55,24 @@ func interceptDNSTargetStatePath() string {
return filepath.Join(dir, interceptDNSTargetStateFile)
}
// A retained target record means cleanup was not completed. The broad saved
// static sweep must not bypass the target's ownership check or retry failures
// through an unguarded restore. Unknown state is conservatively left alone.
func interceptTargetAllowsStaticRestore(service string) bool {
data, err := os.ReadFile(interceptDNSTargetStatePathFn())
if errors.Is(err, os.ErrNotExist) {
return true
}
if err != nil {
return false
}
var st interceptDNSTargetState
if json.Unmarshal(data, &st) != nil || st.Service == "" {
return false
}
return st.Service != service
}
type interceptDNSTargetState struct {
Service string `json:"service"`
Value string `json:"value"`
@@ -75,23 +100,34 @@ func (p *prog) loadInterceptDNSTargetStateLocked() {
// persistInterceptDNSTargetStateLocked writes (or clears) the state file to
// match in-memory tracking. Callers must hold interceptDNSTargetMu.
func (p *prog) persistInterceptDNSTargetStateLocked() {
func (p *prog) persistInterceptDNSTargetStateLocked() error {
file := interceptDNSTargetStatePathFn()
if p.interceptDNSTargetService == "" {
_ = os.Remove(file)
return
if err := os.Remove(file); err != nil && !errors.Is(err, os.ErrNotExist) {
return err
}
return nil
}
data, err := json.Marshal(interceptDNSTargetState{Service: p.interceptDNSTargetService, Value: p.interceptDNSTargetSetValue})
if err != nil {
return
return err
}
if err := os.WriteFile(file, data, 0600); err != nil {
mainLog.Load().Debug().Err(err).Msg("intercept DNS target: could not persist state file")
// Replace atomically; a failed write must not truncate prior ownership.
tmp, err := os.CreateTemp(filepath.Dir(file), ".intercept-target-*")
if err != nil {
return err
}
defer os.Remove(tmp.Name())
_, writeErr := tmp.Write(data)
err = errors.Join(writeErr, tmp.Close())
if err != nil {
return err
}
return os.Rename(tmp.Name(), file)
}
// ensureInterceptDNSTarget guarantees macOS always has an emittable DNS
// target while DNS intercept mode is active.
// ensureInterceptDNSTarget provides an emittable DNS target when ownership
// and discovery permit it; externally changed DNS is not permission to reinstall.
//
// Intercept mode deliberately never manages interface DNS: pf redirects DNS
// packets in flight. But pf can only redirect packets macOS actually sends,
@@ -203,6 +239,14 @@ func (p *prog) ensureInterceptDNSTarget(systemDiscovery []string) {
return
}
}
// Tracking is not proof that the target is still installed. An external
// edit (including clearing DNS) is not permission to reinstall it. Retain
// the cleanup record until shutdown, preventing periodic reconciliation
// from reinterpreting the same edit as a new DNS-less admission.
if p.interceptDNSTargetService == iface.Name && !isInterceptDNSTargetOnly(snapshot, p.interceptDNSTargetSetValue) && !hasIPv4DNS(filterOwnTarget(snapshot, p.interceptDNSTargetSetValue)) {
diagnostic.failed(decision, "owned_target_changed", nil)
return
}
// Never count ctrld's own previously-set entry as network-provided DNS,
// or the next recovery on the same DNS-less network would remove it and
// the one after re-add it.
@@ -267,12 +311,21 @@ func (p *prog) ensureInterceptDNSTarget(systemDiscovery []string) {
mainLog.Load().Debug().Err(err).Msgf("intercept DNS target: could not save static DNS for %q", iface.Name)
}
}
p.setInterceptDNSTargetLocked(iface.Name, target)
if err := p.persistInterceptDNSTargetStateLocked(); err != nil {
p.setInterceptDNSTargetLocked("", "")
resolvedReason = ""
diagnostic.failed(decision, "ownership_persistence", err)
mainLog.Load().Warn().Err(err).Msg("intercept DNS target: not changing DNS without persisted ownership")
return
}
if err := interceptSetDNSFn(iface, []string{target}); err != nil {
// A command error can follow a partial OS mutation. Keep the durable
// cleanup record; the next observation decides what still belongs to us.
resolvedReason = ""
mainLog.Load().Warn().Err(err).Msgf("intercept DNS target: could not set %s on %q", target, iface.Name)
return
}
p.setInterceptDNSTargetLocked(iface.Name, target)
p.persistInterceptDNSTargetStateLocked()
journal(mainLog.Load().Warn()).Str("service", iface.Name).Str("target", target).
Bool("native_clat_fallback", nativeFallback).
Str("reason", "dns_less_network").
@@ -306,7 +359,15 @@ func (p *prog) removeInterceptDNSTargetLocked(reason string) {
return
}
if !isInterceptDNSTargetOnly(cur, val) {
p.clearInterceptDNSTargetStateLocked()
// The generic stop/uninstall sweep must not replay a stale snapshot
// over the external edit. On failure keep ownership so its guard skips.
if err := interceptRemoveSavedDNSFn(iface); err != nil {
mainLog.Load().Warn().Err(err).Msg("intercept DNS target: could not discard stale backup; retaining cleanup state")
return
}
if !p.clearInterceptDNSTargetStateLocked() {
return
}
// ctrld owns the DNS of the service no longer, and a later outage
// report needs the moment that ownership ended.
journal(mainLog.Load().Info()).Str("service", svc).Str("target", val).
@@ -323,7 +384,9 @@ func (p *prog) removeInterceptDNSTargetLocked(reason string) {
mainLog.Load().Warn().Err(err).Msgf("intercept DNS target: could not reset DNS on %q; retaining cleanup state", svc)
return
}
p.clearInterceptDNSTargetStateLocked()
if !p.clearInterceptDNSTargetStateLocked() {
return
}
journal(mainLog.Load().Info()).Str("service", svc).Str("target", val).Str("reason", reason).
Msgf("intercept DNS target: removed %s from %q (%s)", val, svc, reason)
}
@@ -332,9 +395,15 @@ func (p *prog) interceptDNSTargetOwnershipLocked() dnsTargetOwnership {
return dnsTargetOwnership{p.interceptDNSTargetService, p.interceptDNSTargetSetValue}
}
func (p *prog) clearInterceptDNSTargetStateLocked() {
func (p *prog) clearInterceptDNSTargetStateLocked() bool {
service, value := p.interceptDNSTargetService, p.interceptDNSTargetSetValue
p.setInterceptDNSTargetLocked("", "")
p.persistInterceptDNSTargetStateLocked()
if err := p.persistInterceptDNSTargetStateLocked(); err != nil {
p.setInterceptDNSTargetLocked(service, value)
mainLog.Load().Warn().Err(err).Msg("intercept DNS target: could not clear ownership; retaining cleanup state")
return false
}
return true
}
// setInterceptDNSTargetLocked stores the service that ctrld owns and the value
+14 -2
View File
@@ -41,6 +41,14 @@ func newInterceptTargetHarness(t *testing.T) *interceptTargetHarness {
statePath: filepath.Join(t.TempDir(), interceptDNSTargetStateFile),
}
// Master's saved DNS path belongs to ctrld, not the CLI homedir.
// Isolate the production remove/sweep paths without touching host backups.
savedDir := t.TempDir()
origSavedPath := savedStaticDNSPathFn
savedStaticDNSPathFn = func(iface *net.Interface) string {
return filepath.Join(savedDir, ".dns_"+iface.Name)
}
t.Cleanup(func() { savedStaticDNSPathFn = origSavedPath })
origPath := interceptDNSTargetStatePathFn
origRoute := interceptDefaultRouteInterfaceFn
origIface := interceptInterfaceByNameFn
@@ -144,12 +152,14 @@ func newInterceptTargetHarness(t *testing.T) *interceptTargetHarness {
}
func newInterceptTargetProg() *prog {
return &prog{
p := &prog{
cfg: &ctrld.Config{Listener: map[string]*ctrld.ListenerConfig{
"0": {IP: "127.0.0.1", Port: 5354},
}},
dnsInterceptState: &pfState{},
}
p.logger.Store(mainLog.Load())
return p
}
func persistInterceptTargetForTest(t *testing.T, p *prog, service, value string) {
@@ -158,7 +168,9 @@ func persistInterceptTargetForTest(t *testing.T, p *prog, service, value string)
defer p.interceptDNSTargetMu.Unlock()
p.interceptDNSTargetLoaded = true
p.setInterceptDNSTargetLocked(service, value)
p.persistInterceptDNSTargetStateLocked()
if err := p.persistInterceptDNSTargetStateLocked(); err != nil {
t.Fatal(err)
}
}
func TestEnsureInterceptDNSTargetRequiresCompletedDiscovery(t *testing.T) {
+5 -8
View File
@@ -240,14 +240,11 @@ func TestEnsureInterceptDNSTargetResolutionDoesNotClaimWriteSuccess(t *testing.T
h.dhcpErr = nil
h.setErr = errors.New("synthetic set failure")
p.ensureInterceptDNSTarget([]string{})
event := oneRecoveryEvent(t, logs, dnsTargetResolvedMessage)
wantField(t, event, "outcome", "decision_available")
wantField(t, event, "reason", "dns_less_network")
wantField(t, event, "action", "unchanged")
wantField(t, event, "ownership_before", false)
wantField(t, event, "ownership_after", false)
if len(h.setCalls) != 1 || p.interceptDNSTargetService != "" {
t.Fatal("failed target write was not exercised or acquired ownership")
if events := jsonLogEvents(t, logs, dnsTargetResolvedMessage); len(events) != 0 {
t.Fatal("failed target write reported a resolved decision")
}
if len(h.setCalls) != 1 || p.interceptDNSTargetService != "Wi-Fi" {
t.Fatal("failed target write did not retain cleanup state for possible partial mutation")
}
}
+168
View File
@@ -0,0 +1,168 @@
//go:build darwin
package cli
import (
"errors"
"fmt"
"net"
"os"
"path/filepath"
"slices"
"testing"
)
func TestInterceptDNSTargetOwnershipPersistenceBeforeMutation(t *testing.T) {
h := newInterceptTargetHarness(t)
h.nativeCLAT = true
h.dhcpErr = errors.New("no DHCPv4")
h.dns["Wi-Fi"] = []string{"2001:db8::53"}
h.statePath = filepath.Join(t.TempDir(), "absent", "ownership")
p := newInterceptTargetProg()
p.cfg.Listener["0"].IP = "127.0.0.9"
p.cfg.Listener["0"].Port = 5353
p.ensureInterceptDNSTarget([]string{})
if len(h.setCalls) != 0 || p.interceptDNSTargetService != "" || !slices.Equal(h.dns["Wi-Fi"], []string{"2001:db8::53"}) {
t.Fatalf("persistence failure mutated DNS: calls=%v dns=%v owner=%s", h.setCalls, h.dns["Wi-Fi"], p.interceptDNSTargetService)
}
}
func TestInterceptDNSTargetExternalEditInvalidatesBackup(t *testing.T) {
h := newInterceptTargetHarness(t)
p := newInterceptTargetProg()
p.cfg.Listener["0"].IP = "127.0.0.9"
p.cfg.Listener["0"].Port = 5353
p.ensureInterceptDNSTarget([]string{})
target := p.interceptDNSTargetValue()
if !slices.Equal(h.dns["Wi-Fi"], []string{target}) {
t.Fatal("listener-aware target not installed")
}
backup := savedStaticDNSPathFn(&net.Interface{Name: "Wi-Fi"})
if err := os.WriteFile(backup, []byte("2001:db8::old"), 0600); err != nil {
t.Fatal(err)
}
external := []string{"2001:db8::54"}
h.dns["Wi-Fi"] = slices.Clone(external)
for range 2 {
p.ensureInterceptDNSTarget([]string{})
}
if !slices.Equal(h.dns["Wi-Fi"], external) || len(h.setCalls) != 1 {
t.Fatal("reconciliation overwrote external DNS")
}
p.removeInterceptDNSTarget("stop")
oldEach, oldRestore := restoreSavedStaticDNSInterfacesFn, restoreSavedStaticDNSRestoreFn
t.Cleanup(func() { restoreSavedStaticDNSInterfacesFn, restoreSavedStaticDNSRestoreFn = oldEach, oldRestore })
restoreSavedStaticDNSInterfacesFn = func(_, _ string, f func(*net.Interface) error) { _ = f(&net.Interface{Name: "Wi-Fi"}) }
restoreSavedStaticDNSRestoreFn = func(*net.Interface) error { t.Fatal("full stop sweep replayed stale DNS"); return nil }
restoreSavedStaticDNS("", false)
if _, err := os.Stat(backup); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("stale backup remains available to stop sweep: %v", err)
}
if !slices.Equal(h.dns["Wi-Fi"], external) {
t.Fatal("stop overwrote external DNS")
}
}
func TestInterceptDNSTargetFailedOwnershipClearRetainsState(t *testing.T) {
h := newInterceptTargetHarness(t)
p := newInterceptTargetProg()
p.ensureInterceptDNSTarget([]string{})
if err := os.Remove(h.statePath); err != nil {
t.Fatal(err)
}
if err := os.Mkdir(h.statePath, 0700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(h.statePath, "busy"), nil, 0600); err != nil {
t.Fatal(err)
}
p.removeInterceptDNSTarget("stop")
if p.interceptDNSTargetService != "Wi-Fi" {
t.Fatal("failed ownership clear reported completed cleanup")
}
}
func TestInterceptDNSTargetTrackedValueIsNotActualDNS(t *testing.T) {
for _, port := range []int{53, 5353} {
t.Run(fmt.Sprint(port), func(t *testing.T) {
logs := captureDebugMainLog(t)
h := newInterceptTargetHarness(t)
p := newInterceptTargetProg()
p.cfg.Listener["0"].IP = "127.0.0.9"
p.cfg.Listener["0"].Port = port
p.ensureInterceptDNSTarget([]string{})
h.dns["Wi-Fi"] = nil
p.ensureInterceptDNSTarget([]string{})
event := oneRecoveryEvent(t, logs, dnsTargetFailedMessage)
wantField(t, event, "stage", "owned_target_changed")
if len(h.setCalls) != 1 || len(h.dns["Wi-Fi"]) != 0 {
t.Fatal("reinstalled after external clear")
}
})
}
}
func TestInterceptDNSTargetFailedCleanupGuardsFullSweep(t *testing.T) {
h := newInterceptTargetHarness(t)
p := newInterceptTargetProg()
p.ensureInterceptDNSTarget([]string{})
backup := savedStaticDNSPathFn(&net.Interface{Name: "Wi-Fi"})
if err := os.WriteFile(backup, []byte("2001:db8::53"), 0600); err != nil {
t.Fatal(err)
}
h.resetErr = errors.New("reset failed")
p.removeInterceptDNSTarget("stop")
oldEach, oldRestore := restoreSavedStaticDNSInterfacesFn, restoreSavedStaticDNSRestoreFn
t.Cleanup(func() { restoreSavedStaticDNSInterfacesFn, restoreSavedStaticDNSRestoreFn = oldEach, oldRestore })
restoreSavedStaticDNSInterfacesFn = func(_, _ string, f func(*net.Interface) error) { _ = f(&net.Interface{Name: "Wi-Fi"}) }
restoreSavedStaticDNSRestoreFn = func(*net.Interface) error { t.Fatal("sweep bypassed failed guarded cleanup"); return nil }
restoreSavedStaticDNS("", true)
if _, err := os.Stat(backup); err != nil {
t.Fatal("failed cleanup lost backup:", err)
}
if p.interceptDNSTargetService != "Wi-Fi" {
t.Fatal("failed cleanup lost state")
}
}
func TestInterceptDNSTargetStaticRestoreSkipIsNotFailure(t *testing.T) {
for _, state := range []string{`{"service":"Wi-Fi","value":"127.0.0.53"}`, `invalid ownership`} {
t.Run(state, func(t *testing.T) {
h := newInterceptTargetHarness(t)
logs := captureDebugMainLog(t)
if err := os.WriteFile(h.statePath, []byte(state), 0600); err != nil {
t.Fatal(err)
}
backup := savedStaticDNSPathFn(&net.Interface{Name: "Wi-Fi"})
const savedDNS = "2001:db8::53"
if err := os.WriteFile(backup, []byte(savedDNS), 0600); err != nil {
t.Fatal(err)
}
oldEach, oldRestore := restoreSavedStaticDNSInterfacesFn, restoreSavedStaticDNSRestoreFn
t.Cleanup(func() { restoreSavedStaticDNSInterfacesFn, restoreSavedStaticDNSRestoreFn = oldEach, oldRestore })
calls := 0
restoreSavedStaticDNSInterfacesFn = func(_, _ string, f func(*net.Interface) error) {
calls++
if err := f(&net.Interface{Name: "Wi-Fi"}); err != nil {
t.Fatalf("ownership skip reported as restore failure: %v", err)
}
}
restoreSavedStaticDNSRestoreFn = func(*net.Interface) error {
t.Fatal("ownership skip restored DNS")
return nil
}
restoreSavedStaticDNS("", true)
if calls != 1 {
t.Fatalf("interface sweeps=%d, want 1", calls)
}
event := oneRecoveryEvent(t, logs, "Saved static DNS restore skipped on interface Wi-Fi: intercept target cleanup is pending or ownership is unreadable")
wantField(t, event, "level", "debug")
if data, err := os.ReadFile(backup); err != nil || string(data) != savedDNS {
t.Fatalf("ownership skip changed backup: %q, %v", data, err)
}
if data, err := os.ReadFile(h.statePath); err != nil || string(data) != state {
t.Fatalf("ownership skip changed state: %q, %v", data, err)
}
})
}
}
+2
View File
@@ -2,6 +2,8 @@
package cli
func interceptTargetAllowsStaticRestore(_ string) bool { return true }
// ensureInterceptDNSTarget is a no-op on non-Darwin platforms: the DNS-less
// network problem it solves is specific to macOS pf interception blocking
// IPv6 port 53 with no IPv4 fallback (issue #533). Windows intercept mode
+69
View File
@@ -0,0 +1,69 @@
//go:build darwin
package cli
import (
"fmt"
ctrld "github.com/Control-D-Inc/ctrld"
"strings"
"testing"
)
func TestPFScopedNameserverAddress(t *testing.T) {
for _, tc := range []struct{ server, host, family string }{
{"[fe80::1%en0]:53", "fe80::1", "inet6"},
{"2001:db8::53", "2001:db8::53", "inet6"},
{"192.0.2.53:53", "192.0.2.53", "inet"},
} {
host, family, ok := pfNameserverAddress(tc.server)
if !ok || host != tc.host || family != tc.family {
t.Fatalf("PF nameserver %q: %q %q %v", tc.server, host, family, ok)
}
}
if _, _, ok := pfNameserverAddress("not an address"); ok {
t.Fatal("invalid PF address accepted")
}
}
func TestPFRecoveryScopedNameserverRules(t *testing.T) {
captureDebugMainLog(t)
p := &prog{cfg: &ctrld.Config{Listener: map[string]*ctrld.ListenerConfig{"0": {IP: "127.0.0.2", Port: 5353}}}}
p.logger.Store(mainLog.Load())
// Recovery converts OS-discovered addresses into this exemption list.
rules := p.buildPFAnchorRulesForTunnels([]vpnDNSExemption{
{Server: "fe80::1%en0"}, {Server: "fe80::1%en1"}, {Server: "192.0.2.53"},
}, nil)
want := "pass out quick on ! lo0 inet6 proto { udp, tcp } from any to fe80::1 port 53 group " + pfGroupName
if strings.Count(rules, want) != 1 {
t.Fatalf("scoped recovery exemption missing or duplicated: %s", rules)
}
if strings.Contains(rules, "%en0") || strings.Contains(rules, "%en1") || strings.Contains(rules, "inet proto { udp, tcp } from any to fe80:") {
t.Fatal("socket zone or IPv4 family leaked into recovery PF rules")
}
if !strings.Contains(rules, "inet proto { udp, tcp } from any to 192.0.2.53 port 53 group "+pfGroupName) {
t.Fatal("IPv4 recovery exemption changed")
}
}
func TestCustomListener5353PFRules(t *testing.T) {
captureDebugMainLog(t)
for _, ip := range []string{"127.0.0.1", "127.0.0.2", "127.0.0.53", "192.0.2.10"} {
t.Run(ip, func(t *testing.T) {
p := &prog{cfg: &ctrld.Config{Listener: map[string]*ctrld.ListenerConfig{"0": {IP: ip, Port: 5353}}}}
p.logger.Store(mainLog.Load())
rules := p.buildPFAnchorRulesForTunnels(nil, []string{"utun9"})
for _, proto := range []string{"udp", "tcp"} {
want := fmt.Sprintf("rdr on lo0 inet proto %s from any to ! %s port 53 -> %s port 5353", proto, ip, ip)
if !strings.Contains(rules, want) {
t.Errorf("missing redirect %s", want)
}
}
if strings.Contains(rules, "port 5354") {
t.Fatal("explicit port replaced with automatic fallback")
}
if p.interceptDNSTargetValue() == ip {
t.Fatal("target equals rdr exclusion: no port translation")
}
})
}
}
+32
View File
@@ -0,0 +1,32 @@
//go:build linux
package cli
import (
ctrld "github.com/Control-D-Inc/ctrld"
"net"
"testing"
)
// Socket-only Linux control. Native macOS PF delivery is a separate QA gate.
func TestCustomListener5353Binding(t *testing.T) {
captureDebugMainLog(t)
for _, ip := range []string{"127.0.0.1", "127.0.0.2"} {
t.Run(ip, func(t *testing.T) {
cfg := &ctrld.Config{Listener: map[string]*ctrld.ListenerConfig{"0": {IP: ip, Port: 5353}}}
changed, ok := tryUpdateListenerConfigIntercept(cfg, func() {}, false)
if !ok || changed {
t.Fatalf("custom listener did not bind unchanged: changed=%v ok=%v", changed, ok)
}
occupied, err := net.Listen("tcp4", net.JoinHostPort(ip, "5353"))
if err != nil {
t.Fatal(err)
}
defer occupied.Close()
changed, ok = tryUpdateListenerConfigIntercept(cfg, func() {}, false)
if ok || changed || cfg.Listener["0"].IP != ip || cfg.Listener["0"].Port != 5353 {
t.Fatal("occupied explicit listener silently fell back")
}
})
}
}
@@ -0,0 +1,41 @@
//go:build darwin
package cli
import (
"errors"
"slices"
"testing"
)
func TestNativeCLATCustomListener5353Lifecycle(t *testing.T) {
for _, ip := range []string{"127.0.0.1", "127.0.0.2", "127.0.0.53", "192.0.2.10"} {
t.Run(ip, func(t *testing.T) {
h := newInterceptTargetHarness(t)
h.nativeCLAT = true
h.dhcpErr = errors.New("DHCPv4 intentionally absent")
p := newInterceptTargetProg()
p.cfg.Listener["0"].IP = ip
p.cfg.Listener["0"].Port = 5353
target := p.interceptDNSTargetValue()
p.ensureInterceptDNSTarget([]string{})
p.ensureInterceptDNSTarget([]string{})
if !slices.Equal(h.dns["Wi-Fi"], []string{target}) || len(h.setCalls) != 1 {
t.Fatalf("target was not installed idempotently: %v writes %v", h.dns, h.setCalls)
}
if target == ip {
t.Fatal("OS DNS would hit port53 at listener instead of PF port5353 redirect")
}
h.dhcpErr = nil
h.dhcp = []string{"192.0.2.53"}
h.nativeCLAT = false
p.ensureInterceptDNSTarget([]string{"192.0.2.53"})
if len(h.dns["Wi-Fi"]) != 0 || p.interceptDNSTargetService != "" {
t.Fatalf("DHCP return failed to remove target: %+v", h.dns)
}
if p.cfg.Listener["0"].IP != ip || p.cfg.Listener["0"].Port != 5353 {
t.Fatal("target lifecycle changed configured listener")
}
})
}
}
+34
View File
@@ -0,0 +1,34 @@
package cli
import (
ctrld "github.com/Control-D-Inc/ctrld"
"testing"
)
func TestCustomListener5353Contract(t *testing.T) {
for _, tc := range []struct{ ip, target string }{
{"127.0.0.1", "127.0.0.53"}, {"127.0.0.2", "127.0.0.53"},
{"127.0.0.53", "127.0.0.54"}, {"192.0.2.10", "127.0.0.53"},
} {
t.Run(tc.ip, func(t *testing.T) {
if !isExplicitInterceptListener(tc.ip, 5353) {
t.Fatal("5353 was not explicit")
}
p := &prog{cfg: &ctrld.Config{Listener: map[string]*ctrld.ListenerConfig{"0": {IP: tc.ip, Port: 5353}}}}
if got := p.interceptDNSTargetValue(); got != tc.target {
t.Fatalf("target=%s want %s", got, tc.target)
}
current := p.cfg.Listener
generated := map[string]*ctrld.ListenerConfig{"0": {IP: "127.0.0.1", Port: 53}}
preserveBoundListeners(generated, current)
if generated["0"].IP != tc.ip || generated["0"].Port != 5353 {
t.Fatalf("generated reload lost bound listener: %+v", generated["0"])
}
explicit := map[string]*ctrld.ListenerConfig{"0": {IP: tc.ip, Port: 5353}}
preserveBoundListeners(explicit, map[string]*ctrld.ListenerConfig{"0": {IP: "127.0.0.1", Port: 5354}})
if explicit["0"].IP != tc.ip || explicit["0"].Port != 5353 {
t.Fatalf("explicit reload normalized: %+v", explicit["0"])
}
})
}
}
+27
View File
@@ -320,6 +320,8 @@ type prog struct {
// VPN DNS manager for split DNS routing when intercept mode is active.
vpnDNS *vpnDNSManager
// Serializes journal reads with publication after an intercept startup retry.
vpnDNSJournalMu sync.Mutex
// rejectedDestinationsKey is the signature of the organization allowed
// destination entries that were last reported as unusable, so re-parsing an
@@ -1224,6 +1226,27 @@ func (p *prog) startNetworkJournal() func() {
// drop. The search suffixes name the organization of the endpoint, so the
// journal holds their number only.
func (p *prog) logDNSConfigChanges(entries []dnsResolverEntry) {
// The poller emits one batch per changed snapshot (at most once per poll),
// not one discovery per resolver. Late DNS publication on an unchanged
// tunnel otherwise has no network event to refresh split routing.
// Join the existing shutdown fence before touching the manager or PF/WFP:
// closeNetMonitor drains this callback before restoring the host's DNS.
if len(entries) > 0 && p.beginNetworkActivity() {
defer p.netMonitorWG.Done()
// setDNS publishes the manager before signalling completion. The journal
// starts earlier; never race initial manager publication or initialize a
// manager in traditional/hard intercept mode.
select {
case <-p.csSetDnsDone:
p.vpnDNSJournalMu.Lock()
manager := p.vpnDNS
p.vpnDNSJournalMu.Unlock()
if manager != nil {
manager.Refresh(ctrld.LoggerCtx(context.Background(), p.logger.Load()), true)
}
default:
}
}
for _, entry := range entries {
journal(mainLog.Load().Info()).
Strs("nameservers", entry.Nameservers).
@@ -1564,8 +1587,10 @@ func (p *prog) setDNS(systemNameservers []string) {
// Discovers search domains from virtual/VPN interfaces and forwards
// matching queries to the DNS server on that interface.
// Skipped in --intercept-mode hard where all DNS goes through ctrld.
p.vpnDNSJournalMu.Lock()
p.vpnDNS = newVPNDNSManager(&p.logger, p.exemptVPNDNSServers)
p.vpnDNS.Refresh(ctrld.LoggerCtx(context.Background(), p.logger.Load()))
p.vpnDNSJournalMu.Unlock()
}
setDnsOK = true
@@ -1814,7 +1839,9 @@ func (p *prog) resetDNS(isStart bool, restoreStatic bool) {
}
// Clean up VPN DNS manager
p.vpnDNSJournalMu.Lock()
p.vpnDNS = nil
p.vpnDNSJournalMu.Unlock()
return
}
+20 -17
View File
@@ -184,11 +184,7 @@ func (m *vpnDNSManager) refreshOnce(ctx context.Context, guardAgainstNoNameserve
config.InterfaceName, len(config.Domains), len(config.Servers))
for _, domain := range config.Domains {
// Normalize domain: remove leading dot, Linux routing domain prefix (~),
// and convert to lowercase.
domain = strings.TrimPrefix(domain, "~") // Linux resolvectl routing domain prefix
domain = strings.TrimPrefix(domain, ".")
domain = strings.ToLower(domain)
domain = normalizeVPNDomain(domain)
if domain != "" {
m.routes[domain] = append([]string{}, config.Servers...)
@@ -302,9 +298,7 @@ func (m *vpnDNSManager) RefreshRoutesOnly() (routes, domainlessServers, exemptio
for _, config := range configs {
for _, domain := range config.Domains {
domain = strings.TrimPrefix(domain, "~")
domain = strings.TrimPrefix(domain, ".")
domain = strings.ToLower(domain)
domain = normalizeVPNDomain(domain)
if domain != "" {
m.routes[domain] = append([]string{}, config.Servers...)
}
@@ -409,6 +403,14 @@ func (m *vpnDNSManager) VPNDNSReachable() {
m.retainedAfterEmptyDiscovery = false
}
// normalizeVPNDomain accepts OS suffix and Linux routing-domain spellings.
// The root alone is not a split-DNS route (including the Linux ~. catch-all).
func normalizeVPNDomain(domain string) string {
domain = strings.TrimPrefix(domain, "~")
domain = strings.TrimPrefix(domain, ".")
return strings.ToLower(strings.TrimSuffix(domain, "."))
}
// UpstreamForDomain checks if the domain matches any VPN search domain.
// Returns VPN DNS servers if matched, nil otherwise.
// Uses suffix matching: "foo.provisur.local" matches "provisur.local"
@@ -424,16 +426,17 @@ func (m *vpnDNSManager) UpstreamForDomain(domain string) []string {
domain = strings.TrimSuffix(domain, ".")
domain = strings.ToLower(domain)
// First try exact match
if servers, ok := m.routes[domain]; ok {
return append([]string{}, servers...) // Return copy to avoid race conditions
}
// Try suffix matching - check if domain ends with any of our VPN domains
for vpnDomain, servers := range m.routes {
if strings.HasSuffix(domain, "."+vpnDomain) {
return append([]string{}, servers...) // Return copy
// Walk label boundaries from most to least specific. Map iteration order
// must never choose a parent VPN's resolver over a matching child zone.
for domain != "" {
if servers, ok := m.routes[domain]; ok {
return append([]string{}, servers...)
}
i := strings.IndexByte(domain, '.')
if i < 0 {
break
}
domain = domain[i+1:]
}
return nil
+135
View File
@@ -0,0 +1,135 @@
package cli
import (
"context"
"reflect"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/Control-D-Inc/ctrld"
)
func TestVPNDNSLongestNormalizedSuffixAcrossRefreshes(t *testing.T) {
for _, routeOnly := range []bool{false, true} {
t.Run(map[bool]string{false: "full", true: "route-only"}[routeOnly], func(t *testing.T) {
m := newVPNDNSManager(&mainLog, nil)
m.discoverVPNDNS = func(context.Context) []ctrld.VPNDNSConfig {
return []ctrld.VPNDNSConfig{
{Domains: []string{".EXAMPLE.com."}, Servers: []string{"10.0.0.1"}},
{Domains: []string{"~Child.Example.COM."}, Servers: []string{"10.0.0.2", "10.0.0.3"}},
{Domains: []string{".", "~."}, Servers: []string{"10.0.0.4"}},
}
}
if routeOnly {
m.RefreshRoutesOnly()
} else {
m.Refresh(context.Background(), true)
}
for query, want := range map[string][]string{
"example.com.": {"10.0.0.1"}, "other.example.com": {"10.0.0.1"},
"child.example.com.": {"10.0.0.2", "10.0.0.3"},
"HOST.Deep.Child.Example.COM.": {"10.0.0.2", "10.0.0.3"},
"notexample.com.": nil, "example.com.other.": nil, "public.test.": nil,
} {
for i := 0; i < 1000; i++ {
if got := m.UpstreamForDomain(query); !reflect.DeepEqual(got, want) {
t.Fatalf("%s: got %v want %v", query, got, want)
}
}
}
got := m.UpstreamForDomain("child.example.com")
got[0] = "mutated"
if m.UpstreamForDomain("child.example.com")[0] != "10.0.0.2" {
t.Fatal("lookup leaked mutable slice")
}
})
}
}
// Drive the real poller diff -> journal callback -> manager discovery path.
// Both OS boundaries are fixtures, so this test cannot touch host DNS or PF.
func TestVPNDNSJournalLatePublicationConverges(t *testing.T) {
m := newVPNDNSManager(&mainLog, nil)
var discoveryCalls, updates int
var snapshot []ctrld.VPNDNSConfig
m.discoverVPNDNS = func(context.Context) []ctrld.VPNDNSConfig { discoveryCalls++; return snapshot }
m.onServersChanged = func([]vpnDNSExemption) error { updates++; return nil }
p := &prog{vpnDNS: m, csSetDnsDone: make(chan struct{})}
p.logger.Store(mainLog.Load())
close(p.csSetDnsDone)
var table []dnsResolverEntry
poller := newDNSConfigPoller(func() ([]dnsResolverEntry, error) { return table, nil })
emit := func() { p.logDNSConfigChanges(poller.pollOnce(time.Now())) }
emit() // empty startup baseline
for _, server := range []string{"10.0.0.1", "10.0.0.2"} {
snapshot = []ctrld.VPNDNSConfig{{InterfaceName: "utun-fixture", Domains: []string{"corp.example."}, Servers: []string{server}}}
table = []dnsResolverEntry{{Interface: "utun-fixture", Nameservers: []string{server}}}
emit()
if got := m.UpstreamForDomain("host.corp.example."); len(got) != 1 || got[0] != server {
t.Fatalf("late DNS-only change not published: %v", got)
}
for i := 0; i < 100; i++ {
emit()
}
}
if discoveryCalls != 2 || updates != 2 {
t.Fatalf("unchanged tables caused refresh storm: discoveries=%d updates=%d", discoveryCalls, updates)
}
// Removal is a DNS-only delta too; keep Windows' one-empty-snapshot guard.
snapshot = nil
table = nil
emit()
if vpnDNSSettlingEnabled { // Preserve Windows' one-empty-snapshot guard.
m.Refresh(context.Background(), true)
}
if got := m.UpstreamForDomain("host.corp.example"); len(got) != 0 {
t.Fatalf("removed DNS retained: %v", got)
}
p.closeNetMonitor()
}
func TestVPNDNSJournalShutdownDrainsAndRejectsRefresh(t *testing.T) {
m := newVPNDNSManager(&mainLog, nil)
entered, release := make(chan struct{}), make(chan struct{})
var calls, updates atomic.Int32
m.discoverVPNDNS = func(context.Context) []ctrld.VPNDNSConfig {
calls.Add(1)
close(entered)
<-release
return []ctrld.VPNDNSConfig{{Domains: []string{"corp.example"}, Servers: []string{"10.0.0.1"}}}
}
m.onServersChanged = func([]vpnDNSExemption) error { updates.Add(1); return nil }
p := &prog{vpnDNS: m, csSetDnsDone: make(chan struct{})}
p.logger.Store(mainLog.Load())
entries := []dnsResolverEntry{{Action: resolverActionChanged}}
p.logDNSConfigChanges(entries)
if calls.Load() != 0 {
t.Fatal("discovery ran before DNS initialization")
}
close(p.csSetDnsDone)
callbackDone := make(chan struct{})
go func() { defer close(callbackDone); p.logDNSConfigChanges(entries) }()
<-entered
stopDone := make(chan struct{})
go func() { p.closeNetMonitor(); close(stopDone) }()
<-p.networkActivityDone()
select {
case <-stopDone:
t.Fatal("shutdown did not drain in-flight refresh")
default:
}
close(release)
<-callbackDone
<-stopDone
var wg sync.WaitGroup
for i := 0; i < 100; i++ {
wg.Add(1)
go func() { defer wg.Done(); p.logDNSConfigChanges(entries) }()
}
wg.Wait()
if calls.Load() != 1 || updates.Load() != 1 {
t.Fatalf("post-stop mutation: discovery=%d updates=%d", calls.Load(), updates.Load())
}
}