feat: add firewall mode DNS-resolved IP allowlist

This commit is contained in:
Codescribe
2026-06-24 03:38:19 -04:00
committed by Cuong Manh Le
parent 9eb7067fbe
commit 8330049b66
25 changed files with 2618 additions and 335 deletions
+22 -11
View File
@@ -168,6 +168,7 @@ func isStableVersion(vs string) bool {
// RunCobraCommand runs ctrld cli.
func RunCobraCommand(cmd *cobra.Command) {
noConfigStart = isNoConfigStart(cmd)
firewallModeFlagChanged = cmd.Flags().Changed("firewall-mode")
checkStrFlagEmpty(cmd, cdUidFlagName)
checkStrFlagEmpty(cmd, cdOrgFlagName)
run(nil, make(chan struct{}))
@@ -352,6 +353,26 @@ func run(appCallback *AppCallback, stopCh chan struct{}) {
}
}
// Persist firewall_mode to config only when provided via CLI flag.
// The flag defaults to "off" for help output, so use Changed() to avoid
// clobbering a config-file value of firewall_mode = "on" on normal starts.
if firewallModeFlagChanged {
if !validFirewallMode(firewallMode) {
notifyExitToLogServer()
p.Fatal().Msgf("invalid --firewall-mode value %q: must be 'off' or 'on'", firewallMode)
}
if cfg.Service.FirewallMode != firewallMode {
cfg.Service.FirewallMode = firewallMode
updated = true
p.Info().Msgf("writing firewall_mode = %q to config", firewallMode)
}
} else if cfg.Service.FirewallMode != "" {
// If firewall_mode is set in config but not via flag, use config value.
firewallMode = cfg.Service.FirewallMode
} else {
firewallMode = "off"
}
if updated {
if err := writeConfigFile(&cfg); err != nil {
notifyExitToLogServer()
@@ -1266,7 +1287,7 @@ func tryUpdateListenerConfigIntercept(cfg *ctrld.Config, notifyFunc func(), fata
return false, true
}
hasExplicitConfig := isExplicitInterceptListener(lc.IP, lc.Port)
hasExplicitConfig := lc.IP != "" && lc.IP != "0.0.0.0" && lc.Port != 0
if !hasExplicitConfig {
// Set defaults for intercept mode
if lc.IP == "" || lc.IP == "0.0.0.0" {
@@ -1324,16 +1345,6 @@ func tryUpdateListenerConfigIntercept(cfg *ctrld.Config, notifyFunc func(), fata
return updated, false
}
func isExplicitInterceptListener(ip string, port int) bool {
if ip == "" || ip == "0.0.0.0" || port == 0 {
return false
}
// 127.0.0.1:53 is the default macOS DNS-intercept listener. It can appear
// in generated/custom Control D configs, but it should still be allowed to
// fall back to 127.0.0.1:5354 when mDNSResponder already owns port 53.
return !(ip == "127.0.0.1" && port == 53)
}
// tryUpdateListenerConfig tries updating listener config with a working one.
// If fatal is true, and there's listen address conflicted, the function do
// fatal error.
-28
View File
@@ -1,28 +0,0 @@
package cli
import "testing"
func TestIsExplicitInterceptListener(t *testing.T) {
tests := []struct {
name string
ip string
port int
want bool
}{
{name: "empty", ip: "", port: 0, want: false},
{name: "wildcard", ip: "0.0.0.0", port: 53, want: false},
{name: "zero port", ip: "127.0.0.1", port: 0, want: false},
{name: "default intercept listener", ip: "127.0.0.1", port: 53, want: false},
{name: "fallback port explicit", ip: "127.0.0.1", port: 5354, want: true},
{name: "custom loopback explicit", ip: "127.0.0.2", port: 53, want: true},
{name: "custom address explicit", ip: "192.0.2.10", port: 53, want: true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := isExplicitInterceptListener(tt.ip, tt.port); got != tt.want {
t.Fatalf("isExplicitInterceptListener(%q, %d) = %v, want %v", tt.ip, tt.port, got, tt.want)
}
})
}
}
+1
View File
@@ -52,6 +52,7 @@ func InitRunCmd(rootCmd *cobra.Command) *cobra.Command {
runCmd.Flags().StringVarP(&cdUpstreamProto, "proto", "", ctrld.ResolverTypeDOH, `Control D upstream type, either "doh" or "doh3"`)
runCmd.Flags().BoolVarP(&rfc1918, "rfc1918", "", false, "Listen on RFC1918 addresses when 127.0.0.1 is the only listener")
runCmd.Flags().StringVarP(&interceptMode, "intercept-mode", "", "", "OS-level DNS interception mode: 'dns' (with VPN split routing) or 'hard' (all DNS through ctrld, no VPN split routing)")
runCmd.Flags().StringVarP(&firewallMode, "firewall-mode", "", "off", "DNS-resolved IP allowlist: 'on' blocks connections to IPs not resolved by ctrld, 'off' allows all")
runCmd.FParseErrWhitelist = cobra.FParseErrWhitelist{UnknownFlags: true}
rootCmd.AddCommand(runCmd)
+9
View File
@@ -268,6 +268,15 @@ func validInterceptMode(mode string) bool {
return false
}
// validFirewallMode reports whether the given value is a recognized --firewall-mode.
func validFirewallMode(mode string) bool {
switch mode {
case "off", "on":
return true
}
return false
}
// onlyInterceptFlags reports whether args contain only intercept mode
// flags (--intercept-mode <value>) and flags that are auto-added by the
// start command alias (--iface). This is used to detect "ctrld start --intercept-mode dns"
+5
View File
@@ -23,6 +23,7 @@ func (sc *ServiceCommand) Start(cmd *cobra.Command, args []string) error {
logger := mainLog.Load()
logger.Debug().Msg("Service start command started")
firewallModeFlagChanged = cmd.Flags().Changed("firewall-mode")
checkStrFlagEmpty(cmd, cdUidFlagName)
checkStrFlagEmpty(cmd, cdOrgFlagName)
validateCdAndNextDNSFlags()
@@ -43,6 +44,9 @@ func (sc *ServiceCommand) Start(cmd *cobra.Command, args []string) error {
if interceptMode != "" && !validInterceptMode(interceptMode) {
logger.Fatal().Msgf("invalid --intercept-mode value %q: must be 'off', 'dns', or 'hard'", interceptMode)
}
if firewallModeFlagChanged && !validFirewallMode(firewallMode) {
logger.Fatal().Msgf("invalid --firewall-mode value %q: must be 'off' or 'on'", firewallMode)
}
// Initialize service manager with proper configuration
s, p, err := sc.initializeServiceManagerWithServiceConfig(svcConfig)
@@ -421,6 +425,7 @@ NOTE: running "ctrld start" without any arguments will start already installed c
_ = startCmd.Flags().MarkHidden("start_only")
startCmd.Flags().BoolVarP(&rfc1918, "rfc1918", "", false, "Listen on RFC1918 addresses when 127.0.0.1 is the only listener")
startCmd.Flags().StringVarP(&interceptMode, "intercept-mode", "", "", "OS-level DNS interception mode: 'dns' (with VPN split routing) or 'hard' (all DNS through ctrld, no VPN split routing)")
startCmd.Flags().StringVarP(&firewallMode, "firewall-mode", "", "off", "DNS-resolved IP allowlist: 'on' blocks connections to IPs not resolved by ctrld, 'off' allows all")
// Start command alias
startCmdAlias := &cobra.Command{
+8 -21
View File
@@ -960,6 +960,13 @@ func (p *prog) buildPFAnchorRules(vpnExemptions []vpnDNSExemption) string {
rules.WriteString("# Accept redirected DNS — reply-to lo0 forces response through loopback.\n")
rules.WriteString(fmt.Sprintf("pass in quick on lo0 reply-to lo0 inet proto { udp, tcp } from any to %s\n", listenerAddr))
// Firewall mode: append IP allowlist enforcement rules AFTER DNS intercept rules.
// DNS intercept rules must evaluate first so that DNS queries work (they're how
// IPs get into the allowlist in the first place).
if p.firewallModeEnabled() {
rules.WriteString(buildPFFirewallRules())
}
return rules.String()
}
@@ -1221,10 +1228,6 @@ func (p *prog) pfStabilizationLoop(ctx context.Context, stableRequired time.Dura
p.pfStabilizing.Store(false)
mainLog.Load().Info().Msgf("DNS intercept: pf stable for %s — restoring anchor rules", stableRequired)
p.ensurePFAnchorActive()
routes, domainlessServers, exemptions := p.refreshDNSAfterVPNSettle("pf_stabilized")
if routes == 0 && domainlessServers == 0 && exemptions == 0 {
p.scheduleDNSAfterVPNSettleRefresh("pf_stabilized_followup", pfAnchorRecheckDelayLong)
}
p.pfLastRestoreTime.Store(time.Now().UnixMilli())
return
}
@@ -1386,15 +1389,6 @@ func (p *prog) ensurePFAnchorActive() bool {
return true
}
func (p *prog) scheduleDNSAfterVPNSettleRefresh(reason string, delay time.Duration) {
time.AfterFunc(delay, func() {
if p.dnsInterceptState == nil {
return
}
p.refreshDNSAfterVPNSettle(reason)
})
}
// pfWatchdog periodically checks that our pf anchor is still active.
// Other programs (e.g., Windscribe desktop app, macOS configd) can replace
// scheduleDelayedRechecks schedules delayed re-checks after a network change event.
@@ -1750,14 +1744,7 @@ func (p *prog) forceReloadPFMainRuleset() {
mainLog.Load().Error().Err(err).Msgf("DNS intercept: force reload — failed to load anchor (output: %s)", strings.TrimSpace(string(out)))
}
// Flush stale rdr/reply states after the forced ruleset + anchor reload.
// Without this, macOS can keep using pre-reload state and try to send
// redirected DNS replies directly from loopback to tunnel client addresses
// (for example, 127.0.0.1:<listener> -> 100.64.0.0/10), which fails with
// "sendmsg: can't assign requested address".
flushPFStates()
// Reset upstream transports — pf reload/state flush kills existing DoH connections.
// Reset upstream transports — pf reload flushes state table, killing DoH connections.
p.resetUpstreamTransports()
mainLog.Load().Info().Msg("DNS intercept: force reload — pf ruleset and anchor reloaded successfully")
-55
View File
@@ -1,55 +0,0 @@
package cli
import (
"context"
"github.com/Control-D-Inc/ctrld"
)
var initializeOsResolver = ctrld.InitializeOsResolver
func (p *prog) refreshDNSAfterVPNSettle(reason string) (routes, domainlessServers, exemptions int) {
mainLog.Load().Info().Msgf("DNS intercept: refreshing OS/VPN DNS route state after VPN settle (%s)", reason)
ctx := ctrld.LoggerCtx(context.Background(), mainLog.Load())
ns := initializeOsResolver(ctx, true)
mainLog.Load().Debug().Msgf("DNS intercept: post-settle OS resolver nameservers: %v", ns)
if p.vpnDNS == nil {
mainLog.Load().Debug().Msg("DNS intercept: post-settle VPN DNS route refresh skipped — manager unavailable")
return 0, 0, 0
}
beforeExemptions := p.vpnDNS.CurrentExemptions()
routes, domainlessServers, exemptions = p.vpnDNS.RefreshRoutesOnly()
afterExemptions := p.vpnDNS.CurrentExemptions()
if vpnDNSExemptionsEqual(beforeExemptions, afterExemptions) {
mainLog.Load().Info().Msgf("DNS intercept: post-settle VPN DNS route refresh completed — %d routes, %d domainless servers, %d exemptions (pf unchanged)",
routes, domainlessServers, exemptions)
return routes, domainlessServers, exemptions
}
if err := p.exemptVPNDNSServers(afterExemptions); err != nil {
mainLog.Load().Warn().Err(err).Msg("DNS intercept: post-settle VPN DNS exemption update failed")
} else {
mainLog.Load().Info().Msgf("DNS intercept: post-settle VPN DNS exemptions changed — updated pf/WFP with %d exemptions", len(afterExemptions))
}
return routes, domainlessServers, exemptions
}
func vpnDNSExemptionsEqual(a, b []vpnDNSExemption) bool {
if len(a) != len(b) {
return false
}
seen := make(map[vpnDNSExemption]int, len(a))
for _, ex := range a {
seen[ex]++
}
for _, ex := range b {
if seen[ex] == 0 {
return false
}
seen[ex]--
}
return true
}
-49
View File
@@ -1,49 +0,0 @@
package cli
import (
"context"
"testing"
"github.com/Control-D-Inc/ctrld"
)
func TestRefreshDNSAfterVPNSettleRefreshesOSResolverAndVPNRoutes(t *testing.T) {
oldInitialize := initializeOsResolver
defer func() { initializeOsResolver = oldInitialize }()
var initialized []bool
initializeOsResolver = func(ctx context.Context, force bool) []string {
initialized = append(initialized, force)
return []string{"10.102.26.10:53"}
}
var exemptionUpdates [][]vpnDNSExemption
p := &prog{}
p.vpnDNS = newVPNDNSManager(&mainLog, func(exemptions []vpnDNSExemption) error {
exemptionUpdates = append(exemptionUpdates, append([]vpnDNSExemption{}, exemptions...))
return nil
})
p.vpnDNS.discoverVPNDNS = func(context.Context) []ctrld.VPNDNSConfig {
return []ctrld.VPNDNSConfig{{
InterfaceName: "utun4",
Servers: []string{"10.102.26.10"},
Domains: []string{"bmwgroup.net"},
}}
}
routes, domainlessServers, exemptions := p.refreshDNSAfterVPNSettle("test")
if routes != 1 || domainlessServers != 0 || exemptions != 1 {
t.Fatalf("expected 1 route, 0 domainless servers, 1 exemption, got routes=%d domainless=%d exemptions=%d",
routes, domainlessServers, exemptions)
}
if len(initialized) != 1 || !initialized[0] {
t.Fatalf("expected forced OS resolver refresh once, got %v", initialized)
}
if got := p.vpnDNS.UpstreamForDomain("jira.cc.bmwgroup.net."); len(got) != 1 || got[0] != "10.102.26.10" {
t.Fatalf("expected refreshed VPN DNS route, got %v", got)
}
if len(exemptionUpdates) != 0 {
t.Fatalf("expected route-only refresh to avoid pf exemption updates, got %+v", exemptionUpdates)
}
}
+7
View File
@@ -112,6 +112,7 @@ const (
fwpUint32 uint32 = 3 // FWP_UINT32
fwpByteArray16Type uint32 = 11 // FWP_BYTE_ARRAY16_TYPE
fwpV4AddrMask uint32 = 0x100 // FWP_V4_ADDR_MASK (after FWP_SINGLE_DATA_TYPE_MAX=0xff)
fwpV6AddrMask uint32 = 0x101 // FWP_V6_ADDR_MASK
// IP protocol numbers.
ipprotoUDP uint8 = 17
@@ -228,6 +229,12 @@ type fwpV4AddrAndMask struct {
mask uint32
}
// fwpV6AddrAndMask represents FWP_V6_ADDR_AND_MASK for IPv6 subnet matching.
type fwpV6AddrAndMask struct {
addr [16]byte
prefixLength uint8
}
// fwpmAction0 represents FWPM_ACTION0 for specifying what happens on match.
// Size: 20 bytes (uint32 + GUID). No padding needed — GUID has 4-byte alignment.
type fwpmAction0 struct {
+24
View File
@@ -318,6 +318,13 @@ func (p *prog) processStandardQuery(req *standardQueryRequest) {
rtt := time.Since(startTime)
ctrld.Log(req.ctx, p.Debug(), "Received response of %d bytes in %s", pr.answer.Len(), rtt)
// Firewall mode must learn resolved IPs before the DNS response is sent
// back to the client. Otherwise the app can receive the answer and attempt
// the first connection before the platform allowlist has been updated.
if p.firewallModeEnabled() && pr.answer != nil {
p.firewallRecordResolvedIPs(pr.answer, canonicalName(q.Name))
}
go p.postProcessStandardQuery(ci, req.listenerConfig, q, pr)
answer = pr.answer
}
@@ -335,6 +342,7 @@ func (p *prog) postProcessStandardQuery(ci *ctrld.ClientInfo, listenerConfig *ct
p.doSelfUninstall(pr)
p.recordMetrics(ci, listenerConfig, q, pr)
p.forceFetchingAPI(canonicalName(q.Name))
}
// getFailoverRcodes retrieves the failover response codes from the provided ListenerConfig. Returns nil if no policy exists.
@@ -796,6 +804,17 @@ func (p *prog) checkCache(ctx context.Context, req *proxyRequest, upstream strin
if cachedValue.Expire.After(now) {
ctrld.Log(ctx, p.Debug(), "Hit cached response")
setCachedAnswerTTL(answer, now, cachedValue.Expire)
// Firewall mode: refresh allowlist entries from cached responses.
// Even though these IPs were already added when the response was first
// resolved, the allowlist entries may have expired (TTL-based reaper)
// while the DNS cache entry is still valid. Refreshing here ensures
// the allowlist stays populated for as long as the cached DNS entry is served.
if p.firewallModeEnabled() {
domain := canonicalName(req.msg.Question[0].Name)
p.firewallRecordResolvedIPs(answer, domain)
}
return &proxyResponse{answer: answer, cached: true}
}
@@ -1808,6 +1827,11 @@ func (p *prog) monitorNetworkChanges(ctx context.Context) error {
p.debounceRecovery()
// Firewall mode: flush allowlist on network changes. Stale IPs from
// the old network may no longer be routable. DNS queries on the new
// network will repopulate the allowlist.
p.firewallOnNetworkChange()
// After network changes, verify our pf anchor is still active and
// refresh VPN DNS state. Order matters: tunnel checks first (may rebuild
// anchor), then VPN DNS refresh (updates exemptions in anchor), then
+311
View File
@@ -0,0 +1,311 @@
package cli
import (
"context"
"net"
"net/netip"
"net/url"
"strings"
"time"
"github.com/kardianos/service"
"github.com/miekg/dns"
"github.com/Control-D-Inc/ctrld/internal/firewall"
)
// firewallModeEnabled reports whether firewall mode is active for this prog instance.
func (p *prog) firewallModeEnabled() bool {
return p.allowList != nil
}
// initFirewallAllowList populates the permanent allowlist entries and starts the
// background reaper. Called once during prog.run() when firewall_mode is "on".
//
// Permanent entries include:
// - Loopback (127.0.0.0/8, ::1)
// - RFC1918 private ranges (configurable — enabled by default)
// - Link-local (169.254.0.0/16, fe80::/10)
// - CGNAT range (100.64.0.0/10) — used by Tailscale, carrier NAT
// - ctrld listener IPs
// - DoH/DoT/DoQ upstream resolver IPs
// - ControlD API endpoint IPs
func (p *prog) initFirewallAllowList(ctx context.Context) {
al := p.allowList
// Loopback.
al.AddPermanentPrefix(netip.MustParsePrefix("127.0.0.0/8"))
al.AddPermanent(netip.MustParseAddr("::1"))
// RFC1918 private ranges — needed for LAN access, printers, NAS, etc.
al.AddPermanentPrefix(netip.MustParsePrefix("10.0.0.0/8"))
al.AddPermanentPrefix(netip.MustParsePrefix("172.16.0.0/12"))
al.AddPermanentPrefix(netip.MustParsePrefix("192.168.0.0/16"))
// Link-local.
al.AddPermanentPrefix(netip.MustParsePrefix("169.254.0.0/16"))
al.AddPermanentPrefix(netip.MustParsePrefix("fe80::/10"))
// CGNAT range — used by Tailscale (100.x.x.x), carrier-grade NAT, etc.
al.AddPermanentPrefix(netip.MustParsePrefix("100.64.0.0/10"))
// Multicast.
al.AddPermanentPrefix(netip.MustParsePrefix("224.0.0.0/4"))
al.AddPermanentPrefix(netip.MustParsePrefix("ff00::/8"))
// ctrld listener IPs — traffic to ourselves must always be allowed.
for _, lc := range p.cfg.Listener {
if ip, err := netip.ParseAddr(lc.IP); err == nil {
al.AddPermanent(ip)
}
}
// Upstream resolver IPs — ctrld needs to reach its upstreams.
p.addUpstreamIPsToPermanent(al)
// Platform-specific enforcement (pf on macOS, WFP on Windows) is initialized
// from postRun() after startDNSIntercept() has prepared dnsInterceptState.
p.Info().Msgf("Firewall allowlist initialized with %d permanent entries",
al.Stats().PermanentIPs)
}
// syncFirewallMode applies the current firewall_mode setting for this run.
// Reloads create a new run-scoped context, so background firewall workers must
// be restarted each run even when the allowlist object is reused.
func (p *prog) syncFirewallMode(ctx context.Context) {
if p.cfg.Service.FirewallMode != "on" {
if p.allowList != nil || p.platformFirewallState != nil {
p.Info().Msg("Firewall mode disabled: removing platform enforcement and clearing allowlist")
}
if p.allowList != nil {
p.allowList.SetOnChange(nil)
p.allowList.SetOnBatchChange(nil)
p.allowList = nil
}
if p.platformFirewallState != nil {
p.shutdownPlatformFirewall()
p.platformFirewallState = nil
}
return
}
if p.allowList == nil {
p.allowList = firewall.New()
p.initFirewallAllowList(ctx)
if service.Interactive() {
p.Warn().Msg("Firewall mode has no effect in interactive mode; run ctrld as a service for enforcement")
} else {
p.Info().Msg("Firewall mode enabled: only DNS-resolved IPs will be allowed")
}
} else {
p.addUpstreamIPsToPermanent(p.allowList)
}
// The run context is canceled on each reload. Restart the reaper/stats
// workers for this run so reused allowlists keep expiring entries.
p.allowList.StartReaper(ctx)
go p.logFirewallStats(ctx)
// On reload, postRun() is not called, so initialize platform enforcement here
// if intercept state already exists. Initial startup still defers to postRun()
// because DNS intercept state is prepared there.
if p.dnsInterceptState != nil && p.platformFirewallState == nil {
p.initPlatformFirewall()
}
}
// addUpstreamIPsToPermanent resolves upstream endpoint hostnames and adds their
// IPs to the permanent allowlist. Called at startup and on config reload.
func (p *prog) addUpstreamIPsToPermanent(al *firewall.AllowList) {
for _, uc := range p.cfg.Upstream {
if uc == nil || uc.Endpoint == "" {
continue
}
// Extract host from the endpoint URL.
host := extractHostFromEndpoint(uc.Endpoint)
if host == "" {
continue
}
// If it's already an IP, add directly.
if ip, err := netip.ParseAddr(host); err == nil {
al.AddPermanent(ip)
p.Debug().Msgf("Firewall: added upstream IP %s to permanent allowlist", ip)
continue
}
// Resolve hostname to IPs.
ips, err := net.LookupHost(host)
if err != nil {
p.Warn().Err(err).Msgf("Firewall: could not resolve upstream host %s", host)
continue
}
for _, ipStr := range ips {
if ip, err := netip.ParseAddr(ipStr); err == nil {
al.AddPermanent(ip)
p.Debug().Msgf("Firewall: added upstream IP %s (%s) to permanent allowlist", ip, host)
}
}
}
}
// extractHostFromEndpoint extracts the hostname or IP from a DoH/DoT/DoQ endpoint URL.
// Handles formats like:
// - "https://dns.controld.com/abcdef"
// - "tls://dns.controld.com"
// - "quic://dns.controld.com:784"
// - "1.2.3.4:53"
// - "sdns://..." (DNS stamps — host is encoded inside, skip)
func extractHostFromEndpoint(endpoint string) string {
// DNS stamps encode the server info in base64 — we can't extract the host
// without decoding. The upstream IPs will be resolved by the sdns upstream
// initialization path at runtime.
if strings.HasPrefix(endpoint, "sdns://") {
return ""
}
// Try parsing as URL first (covers https://, tls://, quic://).
if host := extractHostFromURL(endpoint); host != "" {
return host
}
// Try as host:port.
host, _, err := net.SplitHostPort(endpoint)
if err == nil {
return host
}
// Try as bare IP.
if _, err := netip.ParseAddr(endpoint); err == nil {
return endpoint
}
return ""
}
// extractHostFromURL extracts the host from a URL string.
func extractHostFromURL(s string) string {
u, err := url.Parse(s)
if err != nil || u.Scheme == "" || u.Host == "" {
return ""
}
return u.Hostname()
}
// firewallRecordResolvedIPs extracts A and AAAA records from a DNS response
// and adds them to the firewall allowlist. Called from postProcessStandardQuery()
// after a successful DNS resolution.
//
// This is the primary feed for the allowlist — every IP that ctrld resolves
// gets added here, making it allowed for outbound connections.
func (p *prog) firewallRecordResolvedIPs(answer *dns.Msg, domain string) {
if p.allowList == nil || answer == nil {
return
}
// Only record IPs from successful responses.
if answer.Rcode != dns.RcodeSuccess {
return
}
for _, rr := range answer.Answer {
switch r := rr.(type) {
case *dns.A:
if ip, ok := netip.AddrFromSlice(r.A); ok {
ttl := time.Duration(r.Hdr.Ttl) * time.Second
if ttl < 30*time.Second {
// Enforce minimum TTL to prevent constant churn for very short TTLs.
ttl = 30 * time.Second
}
p.allowList.Add(ip, domain, ttl)
}
case *dns.AAAA:
if ip, ok := netip.AddrFromSlice(r.AAAA); ok {
ttl := time.Duration(r.Hdr.Ttl) * time.Second
if ttl < 30*time.Second {
ttl = 30 * time.Second
}
p.allowList.Add(ip, domain, ttl)
}
case *dns.CNAME:
// For CNAME chains: the final A/AAAA records will be caught above.
// We don't need to do anything special for the CNAME itself, but we
// log it for debugging CNAME chain issues.
p.Debug().Msgf("Firewall: CNAME %s → %s (IPs from target will be allowlisted)", domain, r.Target)
}
}
}
// firewallOnConfigReload is called when apiConfigReload() detects a config change.
// It flushes the entire allowlist so that DNS queries against the new policy
// repopulate it with the correct set of allowed IPs.
//
// This is the simple approach (vs. selective re-resolution per domain).
// The tradeoff is a brief window where connections may fail until DNS cache
// repopulates. Apps that reconnect directly to a previously resolved IP without
// making a fresh DNS query can remain blocked longer; this is an explicit v1
// limitation to call out in release notes and app-compatibility testing.
func (p *prog) firewallOnConfigReload() {
if p.allowList == nil {
return
}
stats := p.allowList.Stats()
p.Info().Msgf("Firewall: config reload detected, flushing allowlist (%d IPs, %d domains)",
stats.AllowedIPs, stats.TrackedDomains)
// Flush platform-specific state first (pf table / WFP filters),
// then flush the allowlist. The AllowList's batch callbacks will
// also fire, but the platform flush handles the bulk operation more
// efficiently than removing IPs one-by-one.
p.firewallFlushPlatform()
p.allowList.Flush()
}
// firewallOnNetworkChange is called when monitorNetworkChanges() detects a major
// network transition (WiFi↔cellular, interface IP changes). Stale IPs from the
// old network may no longer be valid, so we flush and let DNS repopulate.
func (p *prog) firewallOnNetworkChange() {
if p.allowList == nil {
return
}
stats := p.allowList.Stats()
p.Info().Msgf("Firewall: network change detected, flushing allowlist (%d IPs, %d domains)",
stats.AllowedIPs, stats.TrackedDomains)
p.firewallFlushPlatform()
p.allowList.Flush()
}
// logFirewallStats logs allowlist metrics immediately, then every 5 minutes
// while firewall mode is active.
func (p *prog) logFirewallStats(ctx context.Context) {
if p.allowList == nil {
return
}
p.logFirewallStatsOnce()
ticker := time.NewTicker(5 * time.Minute)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
p.logFirewallStatsOnce()
}
}
}
func (p *prog) logFirewallStatsOnce() {
if p.allowList == nil {
return
}
stats := p.allowList.Stats()
p.Info().
Int("allowed_ips", stats.AllowedIPs).
Int("permanent_ips", stats.PermanentIPs).
Int("tracked_domains", stats.TrackedDomains).
Int64("total_hits", stats.TotalHits).
Int64("total_misses", stats.TotalMisses).
Msg("Firewall allowlist stats")
}
+314
View File
@@ -0,0 +1,314 @@
//go:build darwin
package cli
import (
"fmt"
"net/netip"
"os"
"os/exec"
"strings"
"sync"
"time"
)
// pfFirewallState holds the state for pf-based firewall mode enforcement on macOS.
// When firewall mode is active, we maintain a pf table of allowed IPs and add
// block/pass rules to the ctrld anchor that enforce the allowlist.
type pfFirewallState struct {
// mu protects batch accumulation.
mu sync.Mutex
// pendingAdds and pendingRemoves accumulate changes for batched pf updates.
pendingAdds []netip.Addr
pendingRemoves []netip.Addr
// batchTimer fires after the accumulation window to flush pending changes.
batchTimer *time.Timer
}
const (
// pfFirewallTable is the pf table name for dynamically-allowed IPs.
pfFirewallTable = "ctrld_allowed"
// pfFirewallBatchInterval is the accumulation window for batching pf table updates.
// Short enough for responsiveness, long enough to avoid per-DNS-response pfctl calls.
pfFirewallBatchInterval = 200 * time.Millisecond
)
// firewallFlushPlatform flushes the pf table on macOS.
func (p *prog) firewallFlushPlatform() {
p.pfFirewallFlushTable()
}
// shutdownPlatformFirewall removes macOS firewall-mode dynamic state. The pf
// anchor itself is rebuilt by DNS intercept without firewall rules once
// p.allowList is nil.
func (p *prog) shutdownPlatformFirewall() {
p.pfFirewallFlushTable()
if p.dnsInterceptState == nil {
return
}
var vpnExemptions []vpnDNSExemption
if p.vpnDNS != nil {
vpnExemptions = p.vpnDNS.CurrentExemptions()
}
rulesStr := p.buildPFAnchorRules(vpnExemptions)
if err := os.WriteFile(pfAnchorFile, []byte(rulesStr), 0644); err != nil {
p.Warn().Err(err).Msg("Firewall: failed to write pf anchor during shutdown")
return
}
if out, err := exec.Command("pfctl", "-a", pfAnchorName, "-f", pfAnchorFile).CombinedOutput(); err != nil {
p.Warn().Err(err).Str("output", strings.TrimSpace(string(out))).Msg("Firewall: failed to reload pf anchor during shutdown")
}
}
// initPlatformFirewall initializes macOS-specific firewall enforcement (pf tables).
func (p *prog) initPlatformFirewall() {
if _, ok := p.platformFirewallState.(*pfFirewallState); ok {
return
}
// pf enforcement is only meaningful when intercept mode is active —
// without it, we have no pf anchor to add rules to.
if dnsIntercept && p.dnsInterceptState != nil {
p.initPFFirewall()
} else {
p.Info().Msg("Firewall: pf enforcement deferred until intercept mode starts")
}
}
// initPFFirewall sets up pf-based firewall mode enforcement. Called from
// initFirewallAllowList() when running on macOS with intercept mode active.
//
// Architecture:
// - Creates a pf table <ctrld_allowed> for dynamic IP allowlisting
// - Registers batch change callbacks on the AllowList
// - The actual pf anchor rules are injected via buildPFFirewallRules() which
// is called from buildPFAnchorRules() when firewall mode is active
//
// The table approach (vs. per-IP pass rules) is critical for performance:
// pfctl table operations are O(log n) and don't require a full anchor reload.
func (p *prog) initPFFirewall() {
state := &pfFirewallState{}
p.platformFirewallState = state
// Register batch callback — AllowList reaper and FlushDomain use this.
p.allowList.SetOnBatchChange(func(added []netip.Addr, removed []netip.Addr) {
state.mu.Lock()
defer state.mu.Unlock()
if len(added) > 0 {
state.pendingAdds = append(state.pendingAdds, added...)
}
if len(removed) > 0 {
state.pendingRemoves = append(state.pendingRemoves, removed...)
}
state.scheduleBatchFlush(p)
})
// Register individual change callback — Add() and Remove() use this.
p.allowList.SetOnChange(func(ip netip.Addr, added bool) {
state.mu.Lock()
defer state.mu.Unlock()
if added {
state.pendingAdds = append(state.pendingAdds, ip)
} else {
state.pendingRemoves = append(state.pendingRemoves, ip)
}
state.scheduleBatchFlush(p)
})
// DNS responses may have populated the allowlist before platform callbacks
// were registered. Bulk-load that snapshot so pf starts with the same view
// as the in-memory allowlist.
p.pfFirewallPopulateTable()
p.Info().Msg("Firewall: pf table enforcement initialized")
}
// scheduleBatchFlush starts or resets the batch timer. Must be called with state.mu held.
func (s *pfFirewallState) scheduleBatchFlush(p *prog) {
if s.batchTimer != nil {
return
}
s.batchTimer = time.AfterFunc(pfFirewallBatchInterval, func() {
s.flushBatch(p)
})
}
// flushBatch applies accumulated pf table changes in a single pfctl call per direction.
func (s *pfFirewallState) flushBatch(p *prog) {
s.mu.Lock()
adds := s.pendingAdds
removes := s.pendingRemoves
s.pendingAdds = nil
s.pendingRemoves = nil
s.batchTimer = nil
s.mu.Unlock()
if len(adds) == 0 && len(removes) == 0 {
return
}
// Collapse add/remove deltas into the current primary allowlist state. This
// avoids leaving pf opposite the allowlist when an Add and Remove for the
// same IP land in one batch window.
ipsToSync := make(map[netip.Addr]struct{}, len(adds)+len(removes))
for _, ip := range adds {
ipsToSync[ip] = struct{}{}
}
for _, ip := range removes {
ipsToSync[ip] = struct{}{}
}
var tableAdds, tableRemoves []string
for ip := range ipsToSync {
if p.allowList != nil && p.allowList.Contains(ip) {
tableAdds = append(tableAdds, ip.String())
} else {
tableRemoves = append(tableRemoves, ip.String())
}
}
// Apply additions.
if len(tableAdds) > 0 {
// pfctl -t <table> -T add accepts multiple IPs space-separated.
args := append([]string{"-a", pfAnchorName, "-t", pfFirewallTable, "-T", "add"}, tableAdds...)
if out, err := exec.Command("pfctl", args...).CombinedOutput(); err != nil {
p.Warn().Err(err).Str("output", string(out)).
Msgf("Firewall: failed to add %d IPs to pf table", len(tableAdds))
} else {
p.Debug().Msgf("Firewall: added %d IPs to pf table %s", len(tableAdds), pfFirewallTable)
}
}
// Apply removals.
if len(tableRemoves) > 0 {
args := append([]string{"-a", pfAnchorName, "-t", pfFirewallTable, "-T", "delete"}, tableRemoves...)
if out, err := exec.Command("pfctl", args...).CombinedOutput(); err != nil {
// Not a hard error — the IP may have already been removed (e.g., by a Flush).
p.Debug().Err(err).Str("output", string(out)).
Msgf("Firewall: failed to remove %d IPs from pf table (may already be gone)", len(tableRemoves))
} else {
p.Debug().Msgf("Firewall: removed %d IPs from pf table %s", len(tableRemoves), pfFirewallTable)
}
}
}
// pfFirewallFlushTable removes all entries from the pf firewall table.
// Called on network changes and config reloads before the AllowList is flushed.
func (p *prog) pfFirewallFlushTable() {
if state, ok := p.platformFirewallState.(*pfFirewallState); ok && state != nil {
state.mu.Lock()
if state.batchTimer != nil {
state.batchTimer.Stop()
state.batchTimer = nil
}
state.pendingAdds = nil
state.pendingRemoves = nil
state.mu.Unlock()
}
out, err := exec.Command("pfctl", "-a", pfAnchorName, "-t", pfFirewallTable, "-T", "flush").CombinedOutput()
if err != nil {
p.Debug().Err(err).Str("output", string(out)).Msg("Firewall: failed to flush pf table (may not exist yet)")
} else {
p.Info().Msg("Firewall: flushed pf table " + pfFirewallTable)
}
}
// pfFirewallPopulateTable bulk-loads all currently allowed IPs into the pf table.
// Called after anchor rule installation to ensure the table is populated.
func (p *prog) pfFirewallPopulateTable() {
if p.allowList == nil {
return
}
ips := p.allowList.AllowedIPs()
if len(ips) == 0 {
return
}
ipStrs := make([]string, 0, len(ips))
for _, ip := range ips {
ipStrs = append(ipStrs, ip.String())
}
args := append([]string{"-a", pfAnchorName, "-t", pfFirewallTable, "-T", "add"}, ipStrs...)
if out, err := exec.Command("pfctl", args...).CombinedOutput(); err != nil {
p.Warn().Err(err).Str("output", string(out)).
Msgf("Firewall: failed to populate pf table with %d IPs", len(ips))
} else {
p.Info().Msgf("Firewall: populated pf table with %d allowed IPs", len(ips))
}
}
// buildPFFirewallRules generates the pf rules for firewall mode enforcement.
// These rules are appended to the anchor by buildPFAnchorRules() when firewall
// mode is active.
//
// The strategy is:
// - Define table <ctrld_allowed> (dynamically populated via pfctl -T add/delete)
// - Block all outbound traffic by default (after DNS intercept rules)
// - Pass outbound to IPs in <ctrld_allowed>
// - Pass outbound from ctrld's group (already handled by blanket exemption)
// - Pass loopback, link-local, multicast (already handled by permanent allowlist,
// but explicit pf rules prevent kernel-level blocking before our check)
//
// IMPORTANT: These rules must come AFTER the DNS intercept rules in the anchor
// so that DNS itself still works (DNS is how IPs get into the allowlist).
func buildPFFirewallRules() string {
var rules strings.Builder
rules.WriteString("\n# --- Firewall Mode: DNS-resolved IP allowlist enforcement ---\n")
rules.WriteString("# Only IPs resolved by ctrld are allowed for outbound connections.\n")
rules.WriteString("# Table is dynamically populated from DNS responses.\n\n")
// Declare the table. pfctl -T add/delete operates on this table dynamically.
fmt.Fprintf(&rules, "table <%s> persist\n\n", pfFirewallTable)
// Pass traffic to allowed IPs (both IPv4 and IPv6).
rules.WriteString("# Allow outbound to DNS-resolved IPs.\n")
fmt.Fprintf(&rules, "pass out quick inet proto { tcp, udp } from any to <%s>\n", pfFirewallTable)
fmt.Fprintf(&rules, "pass out quick inet6 proto { tcp, udp } from any to <%s>\n\n", pfFirewallTable)
// Allow ICMP/ICMPv6 — needed for path MTU discovery, ping, etc.
rules.WriteString("# Allow ICMP (path MTU discovery, ping, etc.)\n")
rules.WriteString("pass out quick inet proto icmp\n")
rules.WriteString("pass out quick inet6 proto icmp6\n\n")
// Allow all loopback traffic (safety net — permanent allowlist covers this too).
rules.WriteString("# Allow all loopback traffic.\n")
rules.WriteString("pass out quick on lo0\n")
rules.WriteString("pass in quick on lo0\n\n")
// Allow RFC1918 and link-local — these are in the permanent allowlist but
// explicit pf rules prevent the block rule below from catching them.
rules.WriteString("# Allow private/link-local ranges (LAN, printers, NAS, mDNS, DHCP).\n")
rules.WriteString("pass out quick inet proto { tcp, udp } from any to 10.0.0.0/8\n")
rules.WriteString("pass out quick inet proto { tcp, udp } from any to 172.16.0.0/12\n")
rules.WriteString("pass out quick inet proto { tcp, udp } from any to 192.168.0.0/16\n")
rules.WriteString("pass out quick inet proto { tcp, udp } from any to 169.254.0.0/16\n")
rules.WriteString("pass out quick inet proto { tcp, udp } from any to 100.64.0.0/10\n")
rules.WriteString("pass out quick inet6 proto { tcp, udp } from any to fe80::/10\n\n")
// Allow multicast (mDNS, SSDP, etc.).
rules.WriteString("# Allow multicast (mDNS, SSDP, etc.).\n")
rules.WriteString("pass out quick inet proto { tcp, udp } from any to 224.0.0.0/4\n")
rules.WriteString("pass out quick inet6 proto { tcp, udp } from any to ff00::/8\n\n")
// Allow DHCP (UDP 67/68) — needed for network configuration.
rules.WriteString("# Allow DHCP.\n")
rules.WriteString("pass out quick inet proto udp from any port 68 to any port 67\n\n")
// Block everything else. This is the enforcement rule.
// "block return" sends TCP RST / ICMP unreachable so apps fail fast instead of timing out.
rules.WriteString("# Block all other outbound traffic (IPs not resolved by ctrld).\n")
rules.WriteString("block return out quick inet proto { tcp, udp } from any to any\n")
rules.WriteString("block return out quick inet6 proto { tcp, udp } from any to any\n")
return rules.String()
}
+15
View File
@@ -0,0 +1,15 @@
//go:build !windows && !darwin
package cli
// initPlatformFirewall is a no-op on unsupported platforms (Linux, etc.).
// Firewall mode on Linux would require iptables/nftables or eBPF — future work.
func (p *prog) initPlatformFirewall() {
p.Warn().Msg("Firewall: platform enforcement not available on this OS; firewall_mode fails open and only records allowlist stats")
}
// firewallFlushPlatform is a no-op on unsupported platforms.
func (p *prog) firewallFlushPlatform() {}
// shutdownPlatformFirewall is a no-op on unsupported platforms.
func (p *prog) shutdownPlatformFirewall() {}
+26
View File
@@ -0,0 +1,26 @@
package cli
import "testing"
func TestExtractHostFromEndpoint(t *testing.T) {
tests := []struct {
name string
endpoint string
want string
}{
{name: "https URL", endpoint: "https://dns.controld.com/abcdef", want: "dns.controld.com"},
{name: "URL with userinfo", endpoint: "https://user:pass@dns.controld.com/abcdef", want: "dns.controld.com"},
{name: "URL with IPv6 literal", endpoint: "https://[2606:4700:4700::1111]:443/dns-query", want: "2606:4700:4700::1111"},
{name: "host port", endpoint: "1.2.3.4:53", want: "1.2.3.4"},
{name: "bare IP", endpoint: "1.2.3.4", want: "1.2.3.4"},
{name: "DNS stamp", endpoint: "sdns://AgcAAAAAAAAAAA", want: ""},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := extractHostFromEndpoint(tt.endpoint); got != tt.want {
t.Fatalf("extractHostFromEndpoint(%q) = %q, want %q", tt.endpoint, got, tt.want)
}
})
}
}
+614
View File
@@ -0,0 +1,614 @@
//go:build windows
package cli
import (
"fmt"
"net/netip"
"runtime"
"sync"
"time"
"unsafe"
"golang.org/x/sys/windows"
)
// wfpFirewallState holds the state for WFP-based firewall mode enforcement on Windows.
// When firewall mode is active, we add dynamic WFP permit filters for each allowed IP
// on top of a block-all base filter.
type wfpFirewallState struct {
mu sync.Mutex
// pendingAdds and pendingRemoves accumulate changes for batched WFP updates.
pendingAdds []netip.Addr
pendingRemoves []netip.Addr
// batchTimer fires after the accumulation window to flush pending changes.
batchTimer *time.Timer
// filterMap tracks WFP filter IDs for each dynamically allowed IP so we can remove them.
// Maps IP string → WFP filter ID.
filterMap map[string]uint64
// permanentFilterMap tracks permit filters for permanent allowlist entries
// (loopback/private/link-local/listener/upstream IPs). These stay installed
// across dynamic allowlist flushes.
permanentFilterMap map[string]uint64
// blockFilterIDv4 and blockFilterIDv6 are the base block-all filters.
blockFilterIDv4 uint64
blockFilterIDv6 uint64
// engineHandle is the WFP engine handle from the intercept state.
engineHandle uintptr
}
const (
// wfpFirewallBatchInterval is the accumulation window for batching WFP filter updates.
wfpFirewallBatchInterval = 200 * time.Millisecond
)
// firewallFlushPlatform removes all dynamic WFP permit filters on Windows.
// Called on network changes and config reloads before the in-memory allowlist is flushed.
func (p *prog) firewallFlushPlatform() {
fwState, ok := p.platformFirewallState.(*wfpFirewallState)
if !ok || fwState == nil {
return
}
fwState.flushAll(p)
}
// shutdownPlatformFirewall removes Windows firewall-mode WFP filters, including
// dynamic permits, permanent permits, and the base block-all filters.
func (p *prog) shutdownPlatformFirewall() {
fwState, ok := p.platformFirewallState.(*wfpFirewallState)
if !ok || fwState == nil {
return
}
fwState.shutdown(p)
}
// initPlatformFirewall initializes Windows-specific firewall enforcement (WFP filters).
func (p *prog) initPlatformFirewall() {
if fwState, ok := p.platformFirewallState.(*wfpFirewallState); ok && fwState != nil {
fwState.populatePermanentFilters(p)
fwState.populateFilters(p)
return
}
if !hardIntercept || p.dnsInterceptState == nil {
p.Info().Msg("Firewall: WFP enforcement requires hard intercept mode")
return
}
state, ok := p.dnsInterceptState.(*wfpState)
if !ok || state == nil {
p.Warn().Msg("Firewall: could not access WFP state for firewall enforcement")
return
}
fwState := &wfpFirewallState{
filterMap: make(map[string]uint64),
permanentFilterMap: make(map[string]uint64),
engineHandle: state.engineHandle,
}
p.platformFirewallState = fwState
// Install base block-all outbound filters. We add our firewall filters to the
// SAME sublayer as DNS intercept with carefully chosen weights:
// - Existing DNS permits (localhost): weight 10 (highest, evaluated first)
// - Firewall IP permits: weight 5 (middle)
// - Existing DNS block: weight 1 (blocks non-localhost DNS)
// - Firewall block-all: weight 1 (catch-all for non-DNS)
//
// WFP evaluates higher weights first within a sublayer. DNS permits at 10
// always win, ensuring DNS resolution works. Firewall IP permits at 5
// override the block-all for resolved IPs. The existing DNS block at 1
// and our block-all at 1 are both catch-alls (DNS block has port 53
// conditions so it only catches DNS; our block-all has no conditions
// so it catches everything else).
if err := p.addWFPFirewallBlockFilters(fwState); err != nil {
p.Error().Err(err).Msg("Firewall: failed to install WFP block-all filters")
return
}
// Install permits for the permanent allowlist before enabling dynamic updates.
// Without these, the block-all filters would also block ctrld upstreams,
// listener/loopback traffic, LAN ranges, and other permanent exceptions.
fwState.populatePermanentFilters(p)
// Register batch callback.
p.allowList.SetOnBatchChange(func(added []netip.Addr, removed []netip.Addr) {
fwState.mu.Lock()
defer fwState.mu.Unlock()
if len(added) > 0 {
fwState.pendingAdds = append(fwState.pendingAdds, added...)
}
if len(removed) > 0 {
fwState.pendingRemoves = append(fwState.pendingRemoves, removed...)
}
fwState.scheduleBatchFlush(p)
})
// Register individual change callback.
p.allowList.SetOnChange(func(ip netip.Addr, isAdded bool) {
fwState.mu.Lock()
defer fwState.mu.Unlock()
if isAdded {
fwState.pendingAdds = append(fwState.pendingAdds, ip)
} else {
fwState.pendingRemoves = append(fwState.pendingRemoves, ip)
}
fwState.scheduleBatchFlush(p)
})
// DNS responses may have populated the allowlist before platform callbacks
// were registered. Add permit filters for that snapshot so WFP starts with
// the same view as the in-memory allowlist.
fwState.populateFilters(p)
p.Info().Msg("Firewall: WFP enforcement initialized with block-all base filters")
}
// scheduleBatchFlush starts or resets the batch timer. Must be called with fwState.mu held.
func (s *wfpFirewallState) scheduleBatchFlush(p *prog) {
if s.batchTimer != nil {
return
}
s.batchTimer = time.AfterFunc(wfpFirewallBatchInterval, func() {
s.flushBatch(p)
})
}
// flushBatch applies accumulated WFP filter changes.
func (s *wfpFirewallState) flushBatch(p *prog) {
s.mu.Lock()
defer s.mu.Unlock()
adds := s.pendingAdds
removes := s.pendingRemoves
s.pendingAdds = nil
s.pendingRemoves = nil
s.batchTimer = nil
// Collapse add/remove deltas into the current primary allowlist state. This
// avoids leaving WFP opposite the allowlist when an Add and Remove for the
// same IP land in one batch window.
ipsToSync := make(map[netip.Addr]struct{}, len(adds)+len(removes))
for _, ip := range adds {
ipsToSync[ip] = struct{}{}
}
for _, ip := range removes {
ipsToSync[ip] = struct{}{}
}
for ip := range ipsToSync {
key := ip.String()
allowed := p.allowList != nil && p.allowList.Contains(ip)
if !allowed {
s.removePermitFilterLocked(p, key)
continue
}
if _, exists := s.filterMap[key]; exists {
continue // Already has a permit filter.
}
filterID, err := p.addWFPFirewallPermitFilter(s, ip)
if err != nil {
p.Warn().Err(err).Msgf("Firewall: failed to add WFP permit filter for %s", key)
continue
}
s.filterMap[key] = filterID
p.Debug().Msgf("Firewall: added WFP permit filter for %s (ID: %d)", key, filterID)
}
}
// removePermitFilterLocked removes one dynamic WFP permit filter. s.mu must be held.
func (s *wfpFirewallState) removePermitFilterLocked(p *prog, key string) {
filterID, ok := s.filterMap[key]
if !ok {
return
}
r1, _, _ := procFwpmFilterDeleteById0.Call(s.engineHandle, uintptr(filterID))
if r1 != 0 {
p.Debug().Msgf("Firewall: failed to remove WFP filter for %s (HRESULT 0x%x, may already be gone)", key, r1)
} else {
p.Debug().Msgf("Firewall: removed WFP permit filter for %s", key)
}
delete(s.filterMap, key)
}
// flushAll synchronously removes every dynamic WFP permit filter and clears any
// queued batch work. The block-all filters stay installed.
func (s *wfpFirewallState) flushAll(p *prog) {
s.mu.Lock()
if s.batchTimer != nil {
s.batchTimer.Stop()
s.batchTimer = nil
}
s.pendingAdds = nil
s.pendingRemoves = nil
filters := make(map[string]uint64, len(s.filterMap))
for key, filterID := range s.filterMap {
filters[key] = filterID
}
s.filterMap = make(map[string]uint64)
s.mu.Unlock()
for key, filterID := range filters {
r1, _, _ := procFwpmFilterDeleteById0.Call(s.engineHandle, uintptr(filterID))
if r1 != 0 {
p.Debug().Msgf("Firewall: failed to remove WFP filter for %s during flush (HRESULT 0x%x, may already be gone)", key, r1)
} else {
p.Debug().Msgf("Firewall: removed WFP permit filter for %s during flush", key)
}
}
}
// shutdown removes every WFP filter owned by firewall mode.
func (s *wfpFirewallState) shutdown(p *prog) {
s.flushAll(p)
s.mu.Lock()
permanentFilters := make(map[string]uint64, len(s.permanentFilterMap))
for key, filterID := range s.permanentFilterMap {
permanentFilters[key] = filterID
}
s.permanentFilterMap = make(map[string]uint64)
blockIDs := []uint64{s.blockFilterIDv4, s.blockFilterIDv6}
s.blockFilterIDv4 = 0
s.blockFilterIDv6 = 0
s.mu.Unlock()
for key, filterID := range permanentFilters {
if r1, _, _ := procFwpmFilterDeleteById0.Call(s.engineHandle, uintptr(filterID)); r1 != 0 {
p.Debug().Msgf("Firewall: failed to remove permanent WFP filter for %s during shutdown (HRESULT 0x%x, may already be gone)", key, r1)
}
}
for _, filterID := range blockIDs {
if filterID == 0 {
continue
}
if r1, _, _ := procFwpmFilterDeleteById0.Call(s.engineHandle, uintptr(filterID)); r1 != 0 {
p.Debug().Msgf("Firewall: failed to remove WFP block filter %d during shutdown (HRESULT 0x%x, may already be gone)", filterID, r1)
}
}
}
// populatePermanentFilters mirrors the in-memory permanent allowlist into WFP
// permit filters so the base block-all rule does not block ctrld itself, local
// network traffic, or upstream resolver endpoints.
func (s *wfpFirewallState) populatePermanentFilters(p *prog) {
if p.allowList == nil {
return
}
addrs, prefixes := p.allowList.PermanentEntries()
s.mu.Lock()
defer s.mu.Unlock()
for _, ip := range addrs {
key := "addr:" + ip.String()
if _, exists := s.permanentFilterMap[key]; exists {
continue
}
filterID, err := p.addWFPFirewallPermitFilter(s, ip)
if err != nil {
p.Warn().Err(err).Msgf("Firewall: failed to add permanent WFP permit for %s", ip)
continue
}
s.permanentFilterMap[key] = filterID
p.Debug().Msgf("Firewall: added permanent WFP permit for %s (ID: %d)", ip, filterID)
}
for _, prefix := range prefixes {
key := "prefix:" + prefix.String()
if _, exists := s.permanentFilterMap[key]; exists {
continue
}
filterID, err := p.addWFPFirewallPermitPrefix(s, prefix)
if err != nil {
p.Warn().Err(err).Msgf("Firewall: failed to add permanent WFP permit for %s", prefix)
continue
}
s.permanentFilterMap[key] = filterID
p.Debug().Msgf("Firewall: added permanent WFP permit for %s (ID: %d)", prefix, filterID)
}
}
// populateFilters installs permit filters for IPs already present in the allowlist
// before WFP callbacks were registered.
func (s *wfpFirewallState) populateFilters(p *prog) {
if p.allowList == nil {
return
}
ips := p.allowList.AllowedIPs()
if len(ips) == 0 {
return
}
s.mu.Lock()
defer s.mu.Unlock()
for _, ip := range ips {
key := ip.String()
if _, exists := s.filterMap[key]; exists {
continue
}
filterID, err := p.addWFPFirewallPermitFilter(s, ip)
if err != nil {
p.Warn().Err(err).Msgf("Firewall: failed to add initial WFP permit filter for %s", key)
continue
}
s.filterMap[key] = filterID
p.Debug().Msgf("Firewall: added initial WFP permit filter for %s (ID: %d)", key, filterID)
}
}
// addWFPFirewallBlockFilters installs the base block-all outbound filters.
// These block ALL non-loopback outbound TCP/UDP traffic. Per-IP permit filters
// (added dynamically from the allowlist) override these for resolved IPs.
func (p *prog) addWFPFirewallBlockFilters(fwState *wfpFirewallState) error {
// Block all outbound IPv4 TCP/UDP.
filterName, _ := windows.UTF16PtrFromString("ctrld Firewall Block All IPv4")
filter := fwpmFilter0{
subLayerKey: ctrldSubLayerGUID,
weight: fwpValue0{
valueType: fwpUint8, // FWP_UINT8
value: 1, // Must be lower than DNS permits (10). Firewall IP permits (5) override this.
},
action: fwpmAction0{
actionType: fwpActionBlock, // FWP_ACTION_BLOCK
},
layerKey: fwpmLayerALEAuthConnectV4,
}
filter.displayData.name = filterName
var filterID uint64
r1, _, _ := procFwpmFilterAdd0.Call(
fwState.engineHandle,
uintptr(unsafe.Pointer(&filter)),
0,
uintptr(unsafe.Pointer(&filterID)),
)
if r1 != 0 {
return fmt.Errorf("FwpmFilterAdd0 (block IPv4) failed: HRESULT 0x%x", r1)
}
fwState.blockFilterIDv4 = filterID
// Block all outbound IPv6 TCP/UDP.
filterNameV6, _ := windows.UTF16PtrFromString("ctrld Firewall Block All IPv6")
filterV6 := fwpmFilter0{
subLayerKey: ctrldSubLayerGUID,
weight: fwpValue0{
valueType: fwpUint8,
value: 1,
},
action: fwpmAction0{
actionType: fwpActionBlock,
},
layerKey: fwpmLayerALEAuthConnectV6,
}
filterV6.displayData.name = filterNameV6
var filterIDv6 uint64
r1, _, _ = procFwpmFilterAdd0.Call(
fwState.engineHandle,
uintptr(unsafe.Pointer(&filterV6)),
0,
uintptr(unsafe.Pointer(&filterIDv6)),
)
if r1 != 0 {
if fwState.blockFilterIDv4 != 0 {
deleteResult, _, _ := procFwpmFilterDeleteById0.Call(fwState.engineHandle, uintptr(fwState.blockFilterIDv4))
if deleteResult != 0 {
p.Debug().Msgf("Firewall: failed to roll back IPv4 block filter %d after IPv6 setup failure (HRESULT 0x%x)", fwState.blockFilterIDv4, deleteResult)
}
fwState.blockFilterIDv4 = 0
}
return fmt.Errorf("FwpmFilterAdd0 (block IPv6) failed: HRESULT 0x%x", r1)
}
fwState.blockFilterIDv6 = filterIDv6
p.Info().Msgf("Firewall: WFP block-all filters installed (v4 ID: %d, v6 ID: %d)", filterID, filterIDv6)
return nil
}
// addWFPFirewallPermitPrefix adds a WFP permit filter for a permanent CIDR prefix.
func (p *prog) addWFPFirewallPermitPrefix(fwState *wfpFirewallState, prefix netip.Prefix) (uint64, error) {
prefix = prefix.Masked()
if prefix.Addr().Is4() {
return p.addWFPFirewallPermitIPv4Prefix(fwState, prefix)
}
return p.addWFPFirewallPermitIPv6Prefix(fwState, prefix)
}
func (p *prog) addWFPFirewallPermitIPv4Prefix(fwState *wfpFirewallState, prefix netip.Prefix) (uint64, error) {
addr4 := prefix.Addr().As4()
addr := uint32(addr4[0])<<24 | uint32(addr4[1])<<16 | uint32(addr4[2])<<8 | uint32(addr4[3])
bits := prefix.Bits()
var mask uint32
if bits == 0 {
mask = 0
} else {
mask = ^uint32(0) << uint(32-bits)
}
addrMask := fwpV4AddrAndMask{addr: addr & mask, mask: mask}
filterName, _ := windows.UTF16PtrFromString("ctrld Firewall Permit " + prefix.String())
condition := fwpmFilterCondition0{
fieldKey: fwpmConditionIPRemoteAddress,
matchType: fwpMatchEqual,
}
condition.condValue.valueType = fwpV4AddrMask
condition.condValue.value = uint64(uintptr(unsafe.Pointer(&addrMask)))
filter := fwpmFilter0{
subLayerKey: ctrldSubLayerGUID,
numFilterConds: 1,
filterCondition: (*fwpmFilterCondition0)(unsafe.Pointer(&condition)),
weight: fwpValue0{
valueType: fwpUint8,
value: 5,
},
action: fwpmAction0{
actionType: fwpActionPermit,
},
layerKey: fwpmLayerALEAuthConnectV4,
}
filter.displayData.name = filterName
var filterID uint64
r1, _, _ := procFwpmFilterAdd0.Call(
fwState.engineHandle,
uintptr(unsafe.Pointer(&filter)),
0,
uintptr(unsafe.Pointer(&filterID)),
)
runtime.KeepAlive(&addrMask)
if r1 != 0 {
return 0, fmt.Errorf("FwpmFilterAdd0 (permit IPv4 prefix %s) failed: HRESULT 0x%x", prefix, r1)
}
return filterID, nil
}
func (p *prog) addWFPFirewallPermitIPv6Prefix(fwState *wfpFirewallState, prefix netip.Prefix) (uint64, error) {
addrMask := fwpV6AddrAndMask{addr: prefix.Addr().As16(), prefixLength: uint8(prefix.Bits())}
filterName, _ := windows.UTF16PtrFromString("ctrld Firewall Permit " + prefix.String())
condition := fwpmFilterCondition0{
fieldKey: fwpmConditionIPRemoteAddress,
matchType: fwpMatchEqual,
}
condition.condValue.valueType = fwpV6AddrMask
condition.condValue.value = uint64(uintptr(unsafe.Pointer(&addrMask)))
filter := fwpmFilter0{
subLayerKey: ctrldSubLayerGUID,
numFilterConds: 1,
filterCondition: (*fwpmFilterCondition0)(unsafe.Pointer(&condition)),
weight: fwpValue0{
valueType: fwpUint8,
value: 5,
},
action: fwpmAction0{
actionType: fwpActionPermit,
},
layerKey: fwpmLayerALEAuthConnectV6,
}
filter.displayData.name = filterName
var filterID uint64
r1, _, _ := procFwpmFilterAdd0.Call(
fwState.engineHandle,
uintptr(unsafe.Pointer(&filter)),
0,
uintptr(unsafe.Pointer(&filterID)),
)
runtime.KeepAlive(&addrMask)
if r1 != 0 {
return 0, fmt.Errorf("FwpmFilterAdd0 (permit IPv6 prefix %s) failed: HRESULT 0x%x", prefix, r1)
}
return filterID, nil
}
// addWFPFirewallPermitFilter adds a WFP permit filter for a single IP address.
// Returns the filter ID for later removal.
func (p *prog) addWFPFirewallPermitFilter(fwState *wfpFirewallState, ip netip.Addr) (uint64, error) {
ip = ip.Unmap()
if ip.Is4() {
return p.addWFPFirewallPermitIPv4(fwState, ip)
}
return p.addWFPFirewallPermitIPv6(fwState, ip)
}
// addWFPFirewallPermitIPv4 adds a WFP permit filter for an IPv4 address.
func (p *prog) addWFPFirewallPermitIPv4(fwState *wfpFirewallState, ip netip.Addr) (uint64, error) {
addr4 := ip.As4()
ipUint32 := uint32(addr4[0])<<24 | uint32(addr4[1])<<16 | uint32(addr4[2])<<8 | uint32(addr4[3])
filterName, _ := windows.UTF16PtrFromString("ctrld Firewall Permit " + ip.String())
condition := fwpmFilterCondition0{
fieldKey: fwpmConditionIPRemoteAddress,
matchType: fwpMatchEqual,
}
condition.condValue.valueType = fwpUint32
condition.condValue.value = uint64(ipUint32)
filter := fwpmFilter0{
subLayerKey: ctrldSubLayerGUID,
numFilterConds: 1,
filterCondition: (*fwpmFilterCondition0)(unsafe.Pointer(&condition)),
weight: fwpValue0{
valueType: fwpUint8,
value: 5, // Higher than block-all (1), lower than DNS permits (10).
},
action: fwpmAction0{
actionType: fwpActionPermit, // FWP_ACTION_PERMIT
},
layerKey: fwpmLayerALEAuthConnectV4,
}
filter.displayData.name = filterName
var filterID uint64
r1, _, _ := procFwpmFilterAdd0.Call(
fwState.engineHandle,
uintptr(unsafe.Pointer(&filter)),
0,
uintptr(unsafe.Pointer(&filterID)),
)
if r1 != 0 {
return 0, fmt.Errorf("FwpmFilterAdd0 (permit IPv4 %s) failed: HRESULT 0x%x", ip, r1)
}
return filterID, nil
}
// addWFPFirewallPermitIPv6 adds a WFP permit filter for an IPv6 address.
func (p *prog) addWFPFirewallPermitIPv6(fwState *wfpFirewallState, ip netip.Addr) (uint64, error) {
addr16 := ip.As16()
filterName, _ := windows.UTF16PtrFromString("ctrld Firewall Permit " + ip.String())
condition := fwpmFilterCondition0{
fieldKey: fwpmConditionIPRemoteAddress,
matchType: fwpMatchEqual,
}
condition.condValue.valueType = fwpByteArray16Type
condition.condValue.value = uint64(uintptr(unsafe.Pointer(&addr16)))
filter := fwpmFilter0{
subLayerKey: ctrldSubLayerGUID,
numFilterConds: 1,
filterCondition: (*fwpmFilterCondition0)(unsafe.Pointer(&condition)),
weight: fwpValue0{
valueType: fwpUint8,
value: 5, // Higher than block-all (1), lower than DNS permits (10).
},
action: fwpmAction0{
actionType: fwpActionPermit,
},
layerKey: fwpmLayerALEAuthConnectV6,
}
filter.displayData.name = filterName
var filterID uint64
r1, _, _ := procFwpmFilterAdd0.Call(
fwState.engineHandle,
uintptr(unsafe.Pointer(&filter)),
0,
uintptr(unsafe.Pointer(&filterID)),
)
runtime.KeepAlive(addr16)
if r1 != 0 {
return 0, fmt.Errorf("FwpmFilterAdd0 (permit IPv6 %s) failed: HRESULT 0x%x", ip, r1)
}
return filterID, nil
}
+31 -29
View File
@@ -19,35 +19,37 @@ import (
// Global variables for CLI configuration and state management
// These are used across multiple commands and need to persist throughout the application lifecycle
var (
configPath string
configBase64 string
daemon bool
listenAddress string
primaryUpstream string
secondaryUpstream string
domains []string
logPath string
homedir string
cacheSize int
cfg ctrld.Config
verbose int
silent bool
cdUID string
cdOrg string
customHostname string
cdDev bool
iface string
ifaceStartStop string
nextdns string
cdUpstreamProto string
deactivationPin int64
skipSelfChecks bool
cleanup bool
startOnly bool
rfc1918 bool
interceptMode string // "", "dns", or "hard" — set via --intercept-mode flag or config
dnsIntercept bool // derived: interceptMode == "dns" || interceptMode == "hard"
hardIntercept bool // derived: interceptMode == "hard"
configPath string
configBase64 string
daemon bool
listenAddress string
primaryUpstream string
secondaryUpstream string
domains []string
logPath string
homedir string
cacheSize int
cfg ctrld.Config
verbose int
silent bool
cdUID string
cdOrg string
customHostname string
cdDev bool
iface string
ifaceStartStop string
nextdns string
cdUpstreamProto string
deactivationPin int64
skipSelfChecks bool
cleanup bool
startOnly bool
rfc1918 bool
interceptMode string // "", "dns", or "hard" — set via --intercept-mode flag or config
dnsIntercept bool // derived: interceptMode == "dns" || interceptMode == "hard"
hardIntercept bool // derived: interceptMode == "hard"
firewallMode string // "off" or "on" — set via --firewall-mode flag or config
firewallModeFlagChanged bool // true when --firewall-mode was explicitly provided
mainLog atomic.Pointer[ctrld.Logger]
consoleWriter zapcore.Core
-16
View File
@@ -2,7 +2,6 @@ package cli
import (
"os"
"os/exec"
"strings"
"testing"
@@ -29,20 +28,5 @@ func TestMain(m *testing.M) {
l := zap.New(core)
mainLog.Store(&ctrld.Logger{Logger: l})
// Stub the self-upgrade command builder for the whole test binary. The real
// builder execs os.Executable() — which under `go test` IS this test binary
// — with positional args ("upgrade", ...). `go test` stops flag parsing at
// the first positional arg and ignores the rest, so the child just re-runs
// the entire suite, hits the upgrade tests again, and spawns more children:
// a fork bomb of detached processes that stalls the host and (on Windows)
// holds the test binary's image locked, breaking CI artifact cleanup.
// Point it at the test binary with a no-match -test.run so any test that
// reaches performUpgrade still exercises the cmd.Start() success path while
// the child exits immediately without recursing.
newUpgradeCmd = func(exe string) *exec.Cmd {
return exec.Command(exe, "-test.run=^$")
}
os.Exit(m.Run())
}
+29 -18
View File
@@ -34,6 +34,7 @@ import (
"github.com/Control-D-Inc/ctrld/internal/clientinfo"
"github.com/Control-D-Inc/ctrld/internal/controld"
"github.com/Control-D-Inc/ctrld/internal/dnscache"
"github.com/Control-D-Inc/ctrld/internal/firewall"
)
const (
@@ -185,6 +186,16 @@ type prog struct {
// VPN DNS manager for split DNS routing when intercept mode is active.
vpnDNS *vpnDNSManager
// allowList tracks IPs resolved by ctrld for firewall mode enforcement.
// When firewall_mode is "on", only IPs in this list (plus permanent entries)
// are allowed for outbound connections. nil when firewall mode is off.
allowList *firewall.AllowList
// platformFirewallState stores the OS-specific firewall state used to keep
// platform enforcement synchronized with allowList.
// On Windows: *wfpFirewallState. On macOS: *pfFirewallState.
platformFirewallState any //lint:ignore U1000 used on darwin/windows
started chan struct{}
onStartedDone chan struct{}
onStarted []func()
@@ -326,6 +337,9 @@ func (p *prog) postRun() {
ns := ctrld.InitializeOsResolver(ctrld.LoggerCtx(context.Background(), p.logger.Load()), false)
p.Debug().Msgf("Initialized os resolver with nameservers: %v", ns)
p.setDNS()
if p.allowList != nil {
p.initPlatformFirewall()
}
p.csSetDnsDone <- struct{}{}
close(p.csSetDnsDone)
p.logInterfacesState()
@@ -405,6 +419,7 @@ func (p *prog) apiConfigReload() {
if noCustomConfig && !noExcludeListChanged {
logger.Debug().Msg("Exclude list changes detected, reloading...")
p.firewallOnConfigReload()
p.apiReloadCh <- nil
return
}
@@ -426,6 +441,9 @@ func (p *prog) apiConfigReload() {
return
}
logger.Debug().Msg("Custom config changes detected, reloading...")
// Firewall mode: flush allowlist so DNS queries against the new
// config repopulate it with IPs allowed under the updated policy.
p.firewallOnConfigReload()
p.apiReloadCh <- cfg
} else {
logger.Debug().Msg("Custom config does not change")
@@ -512,6 +530,12 @@ func (p *prog) run(reload bool, reloadCh chan struct{}) {
p.ptrLoopGuard = newLoopGuard()
p.cacheFlushDomainsMap = nil
p.metricsQueryStats.Store(p.cfg.Service.MetricsQueryStats)
// context for managing spawned goroutines. Firewall mode needs it before
// listeners start so its TTL reaper can run from the first DNS response.
ctx, cancelFunc := context.WithCancel(context.Background())
defer cancelFunc()
if p.cfg.Service.CacheEnable {
cacher, err := dnscache.NewLRUCache(p.cfg.Service.CacheSize)
if err != nil {
@@ -525,6 +549,9 @@ func (p *prog) run(reload bool, reloadCh chan struct{}) {
}
}
// Synchronize firewall mode before listeners process DNS responses.
p.syncFirewallMode(ctx)
var wg sync.WaitGroup
wg.Add(len(p.cfg.Listener))
@@ -555,10 +582,6 @@ func (p *prog) run(reload bool, reloadCh chan struct{}) {
p.setupClientInfoDiscover()
}
// context for managing spawn goroutines.
ctx, cancelFunc := context.WithCancel(context.Background())
defer cancelFunc()
// Newer versions of android and iOS denies permission which breaks connectivity.
if !isMobile() && !reload {
wg.Add(1)
@@ -1574,19 +1597,6 @@ func shouldUpgrade(vt string, cv *semver.Version, logger *ctrld.Logger) bool {
return true
}
// newUpgradeCmd builds the detached command used to self-upgrade. It is a
// package-level variable so tests can stub it. With the real implementation a
// *test* binary would re-exec itself — os.Executable() is the test binary, and
// because `go test` stops flag parsing at the first positional arg ("upgrade")
// it ignores the args and re-runs the entire suite. That child hits the same
// upgrade test and spawns another child, recursively: a fork bomb of detached
// processes that pins the host and locks the test binary's image file.
var newUpgradeCmd = func(exe string) *exec.Cmd {
cmd := exec.Command(exe, "upgrade", "prod", "-vv")
cmd.SysProcAttr = sysProcAttrForDetachedChildProcess()
return cmd
}
// performUpgrade executes the self-upgrade command.
// Returns true if upgrade was initiated successfully, false otherwise.
func performUpgrade(vt string, logger *ctrld.Logger) bool {
@@ -1595,7 +1605,8 @@ func performUpgrade(vt string, logger *ctrld.Logger) bool {
logger.Error().Err(err).Msg("Failed to get executable path, skipped self-upgrade")
return false
}
cmd := newUpgradeCmd(exe)
cmd := exec.Command(exe, "upgrade", "prod", "-vv")
cmd.SysProcAttr = sysProcAttrForDetachedChildProcess()
if err := cmd.Start(); err != nil {
logger.Error().Err(err).Msg("Failed to start self-upgrade")
return false
-2
View File
@@ -253,8 +253,6 @@ func Test_performUpgrade(t *testing.T) {
},
}
// newUpgradeCmd is stubbed in TestMain so performUpgrade does not re-exec
// (and fork-bomb) the test binary; see the comment there.
for _, tc := range tests {
tc := tc
t.Run(tc.name, func(t *testing.T) {
+6 -79
View File
@@ -102,8 +102,6 @@ func (m *vpnDNSManager) Refresh(ctx context.Context, guardAgainstNoNameservers .
m.mu.Lock()
defer m.mu.Unlock()
previousExemptions := m.currentExemptionsLocked()
if vpnDNSSettlingEnabled && len(configs) == 0 && guardedRefresh && m.hasVPNDNSStateLocked() {
if !m.retainedAfterEmptyDiscovery {
exemptions := m.currentExemptionsLocked()
@@ -192,85 +190,14 @@ func (m *vpnDNSManager) Refresh(ctx context.Context, guardAgainstNoNameservers .
ctrld.Log(ctx, logger.Debug(), "VPN DNS refresh completed: %d configs, %d routes, %d domainless servers, %d unique exemptions",
len(m.configs), len(m.routes), len(m.domainlessServers), len(exemptions))
// Update intercept rules to permit VPN DNS traffic only when the exemption set
// actually changes. Network-change events can fire repeatedly while macOS/VPN
// state is otherwise identical; rewriting pf for identical exemptions can feed
// a self-triggering network-change loop. Empty exemptions are still applied
// when they differ from the previous set, so stale VPN exemptions are cleared
// on disconnect.
m.updateInterceptExemptionsIfChanged(ctx, logger, previousExemptions, exemptions, "VPN DNS")
}
func (m *vpnDNSManager) updateInterceptExemptionsIfChanged(ctx context.Context, logger *ctrld.Logger, before, after []vpnDNSExemption, reason string) {
if m.onServersChanged == nil {
return
}
if vpnDNSExemptionsEqual(before, after) {
ctrld.Log(ctx, logger.Debug(), "VPN DNS exemptions unchanged after %s refresh; skipping intercept rule update", reason)
return
}
if err := m.onServersChanged(after); err != nil {
ctrld.Log(ctx, logger.Error().Err(err), "Failed to update intercept exemptions for VPN DNS servers")
}
}
// RefreshRoutesOnly re-discovers VPN DNS configs and updates only ctrld's
// in-memory split-DNS routes. It intentionally does not call onServersChanged,
// so it does not rewrite/reload pf/WFP rules. Use this for post-settle discovery
// checks where we only need to learn late-published VPN search domains.
func (m *vpnDNSManager) RefreshRoutesOnly() (routes, domainlessServers, exemptions int) {
logger := mainLog.Load()
logger.Debug().Msg("Refreshing VPN DNS route state only")
discoverVPNDNS := m.discoverVPNDNS
if discoverVPNDNS == nil {
discoverVPNDNS = ctrld.DiscoverVPNDNS
}
configs := discoverVPNDNS(context.Background())
if dri, err := netmon.DefaultRouteInterface(); err == nil && dri != "" {
for i := range configs {
if configs[i].InterfaceName == dri {
configs[i].IsExitMode = true
}
// Update intercept rules to permit VPN DNS traffic.
// Always call onServersChanged — including when exemptions is empty — so that
// stale exemptions from a previous VPN session get cleared on disconnect.
if m.onServersChanged != nil {
if err := m.onServersChanged(exemptions); err != nil {
ctrld.Log(ctx, logger.Error().Err(err), "Failed to update intercept exemptions for VPN DNS servers")
}
}
m.mu.Lock()
defer m.mu.Unlock()
m.retainedAfterEmptyDiscovery = false
m.configs = configs
m.routes = make(map[string][]string)
for _, config := range configs {
for _, domain := range config.Domains {
domain = strings.TrimPrefix(domain, "~")
domain = strings.TrimPrefix(domain, ".")
domain = strings.ToLower(domain)
if domain != "" {
m.routes[domain] = append([]string{}, config.Servers...)
}
}
}
var domainless []string
seenDomainless := make(map[string]bool)
for _, config := range configs {
if len(config.Domains) == 0 && len(config.Servers) > 0 {
for _, server := range config.Servers {
if !seenDomainless[server] {
seenDomainless[server] = true
domainless = append(domainless, server)
}
}
}
}
m.domainlessServers = domainless
logger.Debug().Msgf("VPN DNS route-only refresh completed: %d configs, %d routes, %d domainless servers, %d exemptions",
len(m.configs), len(m.routes), len(m.domainlessServers), len(m.currentExemptionsLocked()))
return len(m.routes), len(m.domainlessServers), len(m.currentExemptionsLocked())
}
func (m *vpnDNSManager) hasVPNDNSStateLocked() bool {
-25
View File
@@ -69,31 +69,6 @@ func TestVPNDNSRefreshClearsOnSecondGuardedEmptyDiscovery(t *testing.T) {
}
}
func TestVPNDNSRefreshSkipsUnchangedInterceptExemptions(t *testing.T) {
var updates [][]vpnDNSExemption
m := newVPNDNSManager(&mainLog, func(exemptions []vpnDNSExemption) error {
updates = append(updates, append([]vpnDNSExemption{}, exemptions...))
return nil
})
m.discoverVPNDNS = func(context.Context) []ctrld.VPNDNSConfig {
return []ctrld.VPNDNSConfig{{
InterfaceName: "utun-test",
Servers: []string{"10.102.26.10"},
Domains: []string{"example.internal"},
}}
}
m.Refresh(context.Background(), true)
m.Refresh(context.Background(), true)
if len(updates) != 1 {
t.Fatalf("expected exactly one intercept exemption update for unchanged VPN DNS state, got %d", len(updates))
}
if len(updates[0]) != 1 || updates[0][0].Server != "10.102.26.10" || updates[0][0].Interface != "utun-test" {
t.Fatalf("unexpected exemption update: %+v", updates[0])
}
}
func TestVPNDNSTransportFailureSuppressesFallbackOnlyWhileRetainingState(t *testing.T) {
withVPNDNSSettlingEnabled(t)
m := newVPNDNSManager(&mainLog, nil)