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())
}
}
+17
View File
@@ -0,0 +1,17 @@
# Target ownership safety (relates to #619)
The target cleanup record is not proof of actual service DNS. Reconciliation now reports a tracked/actual mismatch instead of returning an implicit already-installed decision. An external clear or IPv6-only edit does not authorize reinstalling a target; the record is retained to prevent repeated watchdog admission. Usable external IPv4 keeps the existing guarded cleanup path.
Ownership is written by atomic replacement before target installation. Failure refuses installation, rather than depending on a future restart to recover an untracked target. A failed DNS command may have partly mutated OS state, so its cleanup record is retained. Failed ownership-file removal also retains memory state and does not emit the target-removed success event.
On external edits, cleanup discards the stale saved-static backup before releasing the record. The full saved-static sweep skips a service with retained target cleanup state; unreadable/malformed ownership state conservatively skips the sweep. No unbounded retry, fatal exit, or machine-stop policy is introduced.
## Evidence and limits
- Linux-adapted Darwin tests execute the real target lifecycle and saved-static sweep with only OS boundaries replaced. New regression tests cover persistence failure before mutation, external edit through reconciliation/cleanup/full sweep, ownership clear failure, actual-vs-tracked discrepancy, and failed-cleanup sweep guards. Custom listener values are configured and assertions derive the target from the production selector.
- This is not native macOS PF/CLAT proof. Existing native CLAT admission/removal behavior still needs native validation on the integrated artifact; no PF rules or target selector were changed here.
- A missing target is not automatically repaired: external clearing and accidental loss cannot safely be distinguished from these observations. The diagnostic reports the uncertainty; it does not claim resolution.
- Once explicit cleanup releases a record, a later new admission uses the existing policy for IPv6-only static DNS. This is not a persistent per-service operator opt-out mechanism.
- Retaining cleanup state does not guarantee another cleanup attempt after process exit. The void shutdown API still cannot report aggregate stop failure to its caller; no guaranteed retry or issue closure is claimed.
- Startup/running-interface reset and callback shutdown ordering live in prog.go and are outside this patch. Persistence failure was not a universal restart orphan on the baseline: startup can rescue the current interface, while old/noncurrent-service cases are conditional.
- Network configuration commands are not compare-and-swap transactions. A concurrent external edit after the last read remains a native race; these tests do not prove otherwise. Atomic file replacement does not claim power-loss durability.
+5 -31
View File
@@ -20,6 +20,8 @@ func dnsFns() []dnsFn {
return []dnsFn{dnsFromResolvConf, getDNSFromScutil, getAllDHCPNameservers}
}
var scutilLocalAddresses = netmon.LocalAddresses
func getDNSFromScutil(ctx context.Context) []string {
logger := LoggerFromCtx(ctx)
@@ -28,7 +30,7 @@ func getDNSFromScutil(ctx context.Context) []string {
retryInterval = 100 * time.Millisecond
)
regularIPs, loopbackIPs, _ := netmon.LocalAddresses()
regularIPs, loopbackIPs, _ := scutilLocalAddresses()
var nameservers []string
for attempt := 0; attempt < maxRetries; attempt++ {
@@ -43,36 +45,8 @@ func getDNSFromScutil(ctx context.Context) []string {
continue
}
var localDNS []string
seen := make(map[string]bool)
scanner := bufio.NewScanner(bytes.NewReader(output))
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if strings.HasPrefix(line, "nameserver[") {
parts := strings.Split(line, ":")
if len(parts) == 2 {
ns := strings.TrimSpace(parts[1])
if ip := net.ParseIP(ns); ip != nil {
// skip loopback IPs
isLocal := false
for _, v := range slices.Concat(regularIPs, loopbackIPs) {
ipStr := v.String()
if ip.String() == ipStr {
isLocal = true
break
}
}
if !isLocal && !seen[ip.String()] {
seen[ip.String()] = true
localDNS = append(localDNS, ip.String())
}
}
}
}
}
if err := scanner.Err(); err != nil {
localDNS, err := parseScutilNameservers(output, slices.Concat(regularIPs, loopbackIPs))
if err != nil {
Log(context.Background(), logger.Error(), "Error scanning scutil output (attempt %d/%d): %v", attempt+1, maxRetries, err)
continue
}
+45
View File
@@ -0,0 +1,45 @@
package ctrld
import (
"bufio"
"bytes"
"net/netip"
"strings"
)
// Link-local addresses identify a host only within their interface's zone.
// In particular, an unzoned local fe80::1 must not exclude fe80::1%en0.
func scutilLocalAddressKey(addr netip.Addr) netip.Addr {
addr = addr.Unmap()
if addr.IsLinkLocalUnicast() {
return addr
}
return addr.WithZone("")
}
// parseScutilNameservers retains IPv6 zones for the eventual socket dial.
func parseScutilNameservers(output []byte, local []netip.Addr) ([]string, error) {
excluded := make(map[netip.Addr]bool, len(local))
for _, addr := range local {
excluded[scutilLocalAddressKey(addr)] = true
}
var servers []string
seen := make(map[netip.Addr]bool)
scanner := bufio.NewScanner(bytes.NewReader(output))
for scanner.Scan() {
key, value, ok := strings.Cut(strings.TrimSpace(scanner.Text()), ":")
if !ok || !strings.HasPrefix(key, "nameserver[") {
continue
}
addr, err := netip.ParseAddr(strings.TrimSpace(value))
if err != nil || excluded[scutilLocalAddressKey(addr)] || seen[addr] {
continue
}
seen[addr] = true
servers = append(servers, addr.String())
}
if err := scanner.Err(); err != nil {
return nil, err
}
return servers, nil
}
+55
View File
@@ -0,0 +1,55 @@
//go:build darwin
package ctrld
import (
"context"
"net"
"net/netip"
"os"
"path/filepath"
"slices"
"testing"
)
func TestScopedOSResolverDoesNotBindDefaultSource(t *testing.T) {
old4, old6 := GetDefaultLocalIPv4(), GetDefaultLocalIPv6()
SetDefaultLocalIPv4(context.Background(), net.ParseIP("192.0.2.10"))
SetDefaultLocalIPv6(context.Background(), net.ParseIP("2001:db8::10"))
t.Cleanup(func() {
SetDefaultLocalIPv4(context.Background(), old4)
SetDefaultLocalIPv6(context.Background(), old6)
})
if got := defaultLocalIPForServer("[fe80::1%en0]:53"); got != nil {
t.Fatalf("scoped resolver bound unrelated source %v", got)
}
if got := defaultLocalIPForServer("[2001:db8::53]:53"); !got.Equal(net.ParseIP("2001:db8::10")) {
t.Fatalf("global IPv6 source changed: %v", got)
}
if got := defaultLocalIPForServer("192.0.2.53:53"); !got.Equal(net.ParseIP("192.0.2.10")) {
t.Fatalf("IPv4 source changed: %v", got)
}
}
func TestScutilIPv6ProductionReader(t *testing.T) {
old := scutilLocalAddresses
t.Cleanup(func() { scutilLocalAddresses = old })
localReads := 0
scutilLocalAddresses = func() ([]netip.Addr, []netip.Addr, error) {
localReads++
return []netip.Addr{netip.MustParseAddr("2001:db8::10"), netip.MustParseAddr("fe80::2%en0")},
[]netip.Addr{netip.MustParseAddr("::1"), netip.MustParseAddr("fe80::1")}, nil
}
dir := t.TempDir()
script := "#!/bin/sh\nprintf '%s\\n' 'nameserver[0] : 2001:db8::53' 'nameserver[1] : fe80::1%en0' 'nameserver[2] : fe80::2%en0' 'nameserver[3] : ::1' 'nameserver[4] : 2001:db8::10'\n"
if err := os.WriteFile(filepath.Join(dir, "scutil"), []byte(script), 0700); err != nil {
t.Fatal(err)
}
t.Setenv("PATH", dir)
if got := getDNSFromScutil(context.Background()); !slices.Equal(got, []string{"2001:db8::53", "fe80::1%en0"}) {
t.Fatalf("production reader lost IPv6: %v", got)
}
if localReads != 1 {
t.Fatalf("local-address reads=%d, want 1", localReads)
}
}
+80
View File
@@ -0,0 +1,80 @@
package ctrld
import (
"net/netip"
"slices"
"strings"
"testing"
)
func TestScutilNameserversIPv6AndZones(t *testing.T) {
output := []byte(`DNS configuration
resolver #1
nameserver[0] : 192.0.2.53
nameserver[1] : 2001:db8::53
nameserver[2] : fe80::1%en0
nameserver[3] : 2001:db8::53
nameserver[4] : fe80::1%en1
nameserver[5] : 127.0.0.1
nameserver[6] : ::1
nameserver[7] : fe80::2%en0
nameserver[8] : not-an-address
search domain[0] : internal.example
`)
local := []netip.Addr{netip.MustParseAddr("127.0.0.1"), netip.MustParseAddr("::1"), netip.MustParseAddr("fe80::2%en0")}
got, err := parseScutilNameservers(output, local)
want := []string{"192.0.2.53", "2001:db8::53", "fe80::1%en0", "fe80::1%en1"}
if err != nil || !slices.Equal(got, want) {
t.Fatalf("nameservers=%v err=%v", got, err)
}
effective := initializeOsResolver(got)
r := newResolverWithNameserver(effective)
if !slices.Contains(*r.lanServers.Load(), "[fe80::1%en0]:53") || !slices.Contains(*r.lanServers.Load(), "[fe80::1%en1]:53") {
t.Fatalf("zones lost in effective resolver: %v", effective)
}
if !slices.Contains(*r.publicServers.Load(), "[2001:db8::53]:53") {
t.Fatalf("global IPv6 lost: %v", effective)
}
}
func TestScutilNameserversLocalExclusion(t *testing.T) {
for _, tt := range []struct {
name, local, server string
excluded bool
}{
{"unzoned link-local does not exclude scoped router", "fe80::1", "fe80::1%en0", false},
{"loopback link-local does not exclude router", "fe80::1%lo0", "fe80::1%en0", false},
{"other link does not exclude router", "fe80::1%en1", "fe80::1%en0", false},
{"same link excludes local address", "fe80::1%en0", "fe80::1%en0", true},
{"unzoned link-local matches unzoned local", "fe80::1", "fe80::1", true},
{"scoped local does not exclude unzoned candidate", "fe80::1%en0", "fe80::1", false},
{"global ignores zones", "2001:db8::1%en0", "2001:db8::1%en1", true},
{"loopback ignores zones", "::1%lo0", "::1", true},
{"IPv4 mapped local", "::ffff:192.0.2.1", "192.0.2.1", true},
{"IPv4 mapped candidate", "192.0.2.1", "::ffff:192.0.2.1", true},
{"IPv4 link-local mapped candidate", "169.254.1.1", "::ffff:169.254.1.1", true},
{"other global is retained", "2001:db8::1", "2001:db8::53", false},
} {
t.Run(tt.name, func(t *testing.T) {
got, err := parseScutilNameservers([]byte("nameserver[0] : "+tt.server+"\n"), []netip.Addr{netip.MustParseAddr(tt.local)})
if err != nil {
t.Fatal(err)
}
var want []string
if !tt.excluded {
want = []string{tt.server}
}
if !slices.Equal(got, want) {
t.Fatalf("nameservers=%v, want %v", got, want)
}
})
}
}
func TestScutilNameserversRejectsPartialScan(t *testing.T) {
output := []byte("nameserver[0] : 192.0.2.53\n" + strings.Repeat("x", 128*1024))
got, err := parseScutilNameservers(output, nil)
if err == nil || got != nil {
t.Fatalf("partial scan accepted: %v, %v", got, err)
}
}
+7 -2
View File
@@ -512,8 +512,13 @@ func defaultLocalIPForServer(server string) net.IP {
if err != nil {
return nil
}
ip := net.ParseIP(host)
if ip != nil && ip.To4() == nil {
addr, err := netip.ParseAddr(host)
if err == nil && addr.Zone() != "" {
// Scoped resolvers select their interface via the destination zone.
// A cached default-interface source can have the wrong family or scope.
return nil
}
if err == nil && addr.Is6() && !addr.Is4In6() {
return GetDefaultLocalIPv6()
}
return GetDefaultLocalIPv4()