mirror of
https://github.com/Control-D-Inc/ctrld.git
synced 2026-10-12 01:58:46 +02:00
Harden DNS64, VPN routing and DNS recovery ownership
This commit is contained in:
1 parent
44a638d158
commit
462f912495
23 files changed
+1021
-86
No files matched your search
+9
-2
@@ -1482,12 +1482,19 @@ func uninstall(p *prog, s service.Service) {
|
||||
}
|
||||
}
|
||||
|
||||
var restoreSavedStaticDNSInterfacesFn = withEachPhysicalInterfaces
|
||||
var restoreSavedStaticDNSRestoreFn = restoreDNS
|
||||
|
||||
// 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 {
|
||||
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 := savedStaticDnsSettingsFilePath(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
@@ -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() {
|
||||
@@ -476,6 +489,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
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
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.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}
|
||||
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}}}
|
||||
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}
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -696,6 +696,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"
|
||||
@@ -886,11 +901,10 @@ func (p *prog) buildPFAnchorRulesForTunnels(vpnExemptions []vpnDNSExemption, tun
|
||||
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")
|
||||
@@ -932,10 +946,10 @@ func (p *prog) buildPFAnchorRulesForTunnels(vpnExemptions []vpnDNSExemption, tun
|
||||
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")
|
||||
|
||||
@@ -124,7 +124,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)
|
||||
@@ -2719,6 +2719,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
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"errors"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
|
||||
"tailscale.com/net/netmon"
|
||||
@@ -30,13 +31,38 @@ var (
|
||||
interceptSaveStaticDNSSnapshotFn = func(iface *net.Interface, dns []string, owned string) error {
|
||||
return saveTargetStaticDNSSnapshot(savedStaticDnsSettingsFilePath(iface), dns, owned)
|
||||
}
|
||||
interceptSaveCurrentStaticDNSFn = saveCurrentStaticDNS
|
||||
interceptSaveCurrentStaticDNSFn = saveCurrentStaticDNS
|
||||
interceptRemoveSavedDNSFn = func(iface *net.Interface) error {
|
||||
err := os.Remove(savedStaticDnsSettingsFilePath(iface))
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
interceptSetDNSFn = setDNS
|
||||
interceptSavedStaticNameserversFn = savedStaticNameservers
|
||||
interceptResetDNSIgnoreUnusableIfaceFn = resetDnsIgnoreUnusableInterface
|
||||
interceptDHCPNameserversForInterfaceFn = readTargetDHCPNameservers
|
||||
)
|
||||
|
||||
// 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"`
|
||||
@@ -64,23 +90,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,
|
||||
@@ -192,6 +229,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.
|
||||
@@ -256,12 +301,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").
|
||||
@@ -295,7 +349,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).
|
||||
@@ -312,7 +374,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)
|
||||
}
|
||||
@@ -321,9 +385,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
|
||||
|
||||
@@ -41,6 +41,9 @@ func newInterceptTargetHarness(t *testing.T) *interceptTargetHarness {
|
||||
statePath: filepath.Join(t.TempDir(), interceptDNSTargetStateFile),
|
||||
}
|
||||
|
||||
origHome := homedir
|
||||
homedir = t.TempDir()
|
||||
t.Cleanup(func() { homedir = origHome })
|
||||
origPath := interceptDNSTargetStatePathFn
|
||||
origRoute := interceptDefaultRouteInterfaceFn
|
||||
origIface := interceptInterfaceByNameFn
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 := savedStaticDnsSettingsFilePath(&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 := savedStaticDnsSettingsFilePath(&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 := savedStaticDnsSettingsFilePath(&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,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
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
//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}}}}
|
||||
// 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}}}}
|
||||
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")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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"])
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -323,6 +323,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
|
||||
|
||||
started chan struct{}
|
||||
onStartedDone chan struct{}
|
||||
@@ -1178,6 +1180,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(true)
|
||||
}
|
||||
default:
|
||||
}
|
||||
}
|
||||
for _, entry := range entries {
|
||||
journal(mainLog.Load().Info()).
|
||||
Strs("nameservers", entry.Nameservers).
|
||||
@@ -1546,8 +1569,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.exemptVPNDNSServers)
|
||||
p.vpnDNS.Refresh(true)
|
||||
p.vpnDNSJournalMu.Unlock()
|
||||
}
|
||||
|
||||
setDnsOK = true
|
||||
@@ -1802,7 +1827,9 @@ func (p *prog) resetDNS(isStart bool, restoreStatic bool) {
|
||||
}
|
||||
|
||||
// Clean up VPN DNS manager
|
||||
p.vpnDNSJournalMu.Lock()
|
||||
p.vpnDNS = nil
|
||||
p.vpnDNSJournalMu.Unlock()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
+19
-14
@@ -176,11 +176,7 @@ func (m *vpnDNSManager) refreshOnce(guardAgainstNoNameservers bool) {
|
||||
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, "~")
|
||||
domain = strings.TrimPrefix(domain, ".")
|
||||
domain = strings.ToLower(domain)
|
||||
domain = normalizeVPNDomain(domain)
|
||||
|
||||
if domain != "" {
|
||||
m.routes[domain] = append([]string{}, config.Servers...)
|
||||
@@ -288,9 +284,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...)
|
||||
}
|
||||
@@ -389,6 +383,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.
|
||||
func (m *vpnDNSManager) UpstreamForDomain(domain string) []string {
|
||||
@@ -402,14 +404,17 @@ func (m *vpnDNSManager) UpstreamForDomain(domain string) []string {
|
||||
domain = strings.TrimSuffix(domain, ".")
|
||||
domain = strings.ToLower(domain)
|
||||
|
||||
if servers, ok := m.routes[domain]; ok {
|
||||
return append([]string{}, servers...)
|
||||
}
|
||||
|
||||
for vpnDomain, servers := range m.routes {
|
||||
if strings.HasSuffix(domain, "."+vpnDomain) {
|
||||
// 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
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
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(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(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(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{})}
|
||||
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(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(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{})}
|
||||
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())
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user