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)
+8 -2
View File
@@ -247,8 +247,14 @@ type ServiceConfig struct {
ForceRefetchWaitTime *int `mapstructure:"force_refetch_wait_time" toml:"force_refetch_wait_time,omitempty"`
LeakOnUpstreamFailure *bool `mapstructure:"leak_on_upstream_failure" toml:"leak_on_upstream_failure,omitempty"`
InterceptMode string `mapstructure:"intercept_mode" toml:"intercept_mode,omitempty" validate:"omitempty,oneof=off dns hard"`
Daemon bool `mapstructure:"-" toml:"-"`
AllocateIP bool `mapstructure:"-" toml:"-"`
// FirewallMode controls the DNS-resolved IP allowlist. When "on", only IPs
// that were successfully resolved by ctrld are allowed for outbound connections.
// This closes the "DNS gap" where apps bypass DNS policy using hardcoded IPs.
// Requires intercept mode to be active for enforcement on desktop platforms.
// On mobile, the netstack layer uses the allowlist directly.
FirewallMode string `mapstructure:"firewall_mode" toml:"firewall_mode,omitempty" validate:"omitempty,oneof=off on"`
Daemon bool `mapstructure:"-" toml:"-"`
AllocateIP bool `mapstructure:"-" toml:"-"`
}
// NetworkConfig specifies configuration for networks where ctrld will handle requests.
+160
View File
@@ -0,0 +1,160 @@
# Firewall Mode
Firewall mode makes DNS policy unbypassable by blocking outbound connections to any
IP that wasn't resolved by ctrld. This closes the "DNS gap" — where apps use hardcoded
IPs, direct-IP fallbacks, or alternative DNS resolvers to bypass DNS-based filtering.
## How It Works
1. **DNS responses feed the allowlist**: Every successful A/AAAA record resolved by ctrld
is added to an in-memory allowlist with TTL-based expiry.
2. **Outbound connections are checked**: Before any outbound TCP/UDP connection, the
destination IP is checked against the allowlist. If it wasn't resolved by ctrld, the
connection is blocked.
3. **Permanent entries are always allowed**: Loopback, RFC1918 private ranges, link-local,
CGNAT, multicast, ctrld's own listener, and upstream resolver IPs are always allowed.
## Configuration
### TOML Config
```toml
[service]
firewall_mode = "on" # "off" (default) or "on"
intercept_mode = "hard" # Required on desktop for enforcement
```
### CLI Flag
```bash
ctrld start --firewall-mode on --intercept-mode hard
```
### Remote API
Firewall mode can be toggled remotely via the ControlD API's `custom_config` field,
which is polled by `apiConfigReload()`.
## Platform-Specific Enforcement
### macOS (pf)
When both firewall mode and intercept mode are active, ctrld extends the pf anchor
with a `<ctrld_allowed>` table:
- Default: block all outbound traffic
- Pass: traffic to IPs in the `<ctrld_allowed>` table
- Pass: traffic to loopback and link-local
- Pass: existing DNS intercept rules
The table is dynamically updated as DNS responses arrive. Updates are batched (200ms
accumulation window) to avoid excessive `pfctl` calls.
### Windows (WFP)
When both firewall mode and hard intercept mode are active, ctrld extends the WFP
sublayer with dynamic permit filters:
- Base: block all outbound traffic (low-weight filter)
- Dynamic: permit filters for each IP in the allowlist
- Static: permits for loopback, RFC1918, ctrld listener
Permit filters are added/removed dynamically as the allowlist changes.
### Linux and Unsupported Platforms
Kernel enforcement is not implemented yet. On unsupported platforms, `firewall_mode = "on"` currently fails open: ctrld still records allowlist stats, but it does not block outbound traffic. A warning is logged at startup so this is visible. Future work: iptables/nftables rules or eBPF, and possibly a strict mode that fails closed when platform enforcement is unavailable.
## Permanently Allowed IPs
These IPs are always allowed regardless of DNS resolution:
| Range | Reason |
|-------|--------|
| `127.0.0.0/8`, `::1` | Loopback — local services |
| `10.0.0.0/8` | RFC1918 — LAN, printers, NAS |
| `172.16.0.0/12` | RFC1918 — LAN |
| `192.168.0.0/16` | RFC1918 — LAN |
| `169.254.0.0/16`, `fe80::/10` | Link-local — DHCP, mDNS |
| `100.64.0.0/10` | CGNAT — Tailscale, carrier NAT |
| `224.0.0.0/4`, `ff00::/8` | Multicast — mDNS, SSDP |
| ctrld listener IPs | Self — DNS proxy must be reachable |
| Upstream resolver IPs | DoH/DoT/DoQ endpoints |
## Live Profile Updates
When a ControlD profile changes (domain goes from allowed → blocked or vice versa):
1. ctrld's `apiConfigReload()` detects the change
2. The entire allowlist is flushed
3. Subsequent DNS queries repopulate the allowlist under the new policy
4. Brief connectivity interruption (~seconds) while DNS cache repopulates
This is the "flush and repopulate" strategy — simple and correct, with a small
tradeoff of a brief connectivity blip on config changes.
## Network State Changes
When the device changes networks (WiFi → cellular, between WiFi networks, etc.):
1. `monitorNetworkChanges()` detects the transition
2. The allowlist is flushed (old IPs may not be routable on new network)
3. DNS cache is also flushed (existing behavior)
4. Both repopulate naturally from new DNS queries
## Edge Cases
### CDN IP Rotation
A domain may resolve to different IPs over time. Each resolved IP is added independently
with its own TTL. Multiple IPs can coexist for the same domain.
### CNAME Chains
For `foo.com` → CNAME → `bar.cdn.com` → A record, the final A/AAAA IPs are allowlisted
and associated with the original query domain (`foo.com`).
### Short TTLs
Some CDNs use 30-second TTLs. The allowlist enforces a minimum TTL of 30 seconds to
prevent excessive churn. The background reaper runs every 30 seconds.
### App Startup Race
Apps may attempt connections before their first DNS query reaches ctrld. This is a known
limitation. A "learning mode" grace period at startup is a future enhancement.
### Cached Responses
When ctrld serves a response from its DNS cache, the allowlist entries are refreshed.
This prevents the case where the DNS cache outlives the allowlist TTL.
### Long-Lived Connections and Direct-IP Retries
Firewall mode learns allowed destinations from DNS responses. If an app keeps a
long-lived connection open across a firewall/profile refresh, or retries directly
to a previously resolved IP without issuing another DNS query, the reconnect can
remain blocked until the app performs DNS resolution again. This is an accepted
v1 tradeoff and should be called out in release notes and compatibility testing
for common apps.
## Metrics
Allowlist stats are logged every 5 minutes:
```
Firewall allowlist stats allowed_ips=142 permanent_ips=18 tracked_domains=89 total_hits=4521 total_misses=23
```
## Troubleshooting
### Everything is blocked
- Check that the upstream resolver IPs are in the permanent allowlist (logged at startup)
- Verify DNS is working: `nslookup example.com 127.0.0.1`
- Check allowlist stats for hit/miss ratio
### Certain apps don't work
- The app may be using hardcoded IPs (this is the intended behavior — those IPs aren't DNS-resolved)
- Check if the app uses a custom DNS resolver that bypasses ctrld
- RFC1918 traffic is always allowed, so LAN-only apps should work
### High miss count
- Normal for the first few seconds after startup or network change
- Persistent high misses may indicate apps using hardcoded IPs extensively
+567
View File
@@ -0,0 +1,567 @@
// Package firewall provides DNS-resolved IP allowlist enforcement for ctrld's
// firewall mode. When enabled, only IPs that were successfully resolved by ctrld
// are allowed outbound connections — blocking hardcoded IPs, direct-IP fallbacks,
// and DNS bypass attempts.
//
// The AllowList is the core data structure: a concurrent map of allowed IPs
// populated by DNS responses, with TTL-based expiry and domain-level invalidation
// for live profile updates.
package firewall
import (
"context"
"net/netip"
"sync"
"sync/atomic"
"time"
)
// AllowList tracks IPs that were resolved by ctrld and are allowed for outbound
// connections. It is safe for concurrent use from multiple goroutines.
//
// Architecture:
// - ips: primary map, netip.Addr → *entry (expiry + originating domains)
// - domains: reverse map, domain → []netip.Addr (for bulk invalidation on policy changes)
// - permanent: IPs that are always allowed (loopback, RFC1918, upstream endpoints)
//
// The hot path is Contains(), which must be O(1) with zero allocations.
type AllowList struct {
// ips is the primary allowlist. Keyed by IP address, value is the entry
// containing expiry time and which domains resolved to this IP.
ips sync.Map // netip.Addr → *entry
// domains is the reverse map for bulk invalidation. When a domain's policy
// changes (e.g., previously-allowed domain gets blocked), we can look up all
// IPs associated with that domain and remove them.
domains sync.Map // string → *domainEntry
// permanent contains IPs that are always allowed regardless of DNS resolution.
// These include loopback, RFC1918, link-local, ctrld listener IPs, and
// upstream resolver IPs.
permanent sync.Map // netip.Addr → struct{}
// onChange is called (if non-nil) whenever the allowlist changes.
// The callback receives the IP and whether it was added (true) or removed (false).
// Platform-specific enforcement (pf/WFP) registers a callback here.
onChange func(ip netip.Addr, added bool)
// onBatchChange is called (if non-nil) with batches of changes.
// When possible, AllowList prefers onBatchChange to reduce platform
// enforcement calls. Single Add() calls still use onChange.
onBatchChange func(added []netip.Addr, removed []netip.Addr)
// mu protects mutations that need consistency across the ips/domains maps
// and platform callbacks.
mu sync.Mutex
// stats tracks allowlist metrics without per-operation locks.
totalAdds atomic.Int64
totalRemoves atomic.Int64
totalHits atomic.Int64
totalMisses atomic.Int64
}
// entry represents an allowed IP with expiry and provenance tracking.
// Fields are protected by mu since Add() and reap() may access concurrently.
type entry struct {
mu sync.Mutex
expiry time.Time
domains []string // which domains resolved to this IP (for reverse lookup)
}
// domainEntry tracks all IPs associated with a domain for bulk invalidation.
type domainEntry struct {
ips []netip.Addr
}
// Stats contains a snapshot of allowlist metrics.
type Stats struct {
// AllowedIPs is the current number of IPs in the allowlist (non-permanent).
AllowedIPs int `json:"allowed_ips"`
// PermanentIPs is the number of permanently allowed IPs.
PermanentIPs int `json:"permanent_ips"`
// TrackedDomains is the number of domains with IP associations.
TrackedDomains int `json:"tracked_domains"`
// TotalAdds is the cumulative number of Add() calls.
TotalAdds int64 `json:"total_adds"`
// TotalRemoves is the cumulative number of Remove()/Flush() operations.
TotalRemoves int64 `json:"total_removes"`
// TotalHits is the number of Contains() calls that returned true.
TotalHits int64 `json:"total_hits"`
// TotalMisses is the number of Contains() calls that returned false.
TotalMisses int64 `json:"total_misses"`
}
// New creates a new empty AllowList. Call AddPermanent() to seed it with
// IPs that should always be allowed (loopback, upstreams, etc.), then
// StartReaper() to begin background TTL expiry.
func New() *AllowList {
return &AllowList{}
}
// SetOnChange registers a callback that fires on each individual IP change.
// Use SetOnBatchChange instead for platform enforcement (reduces churn).
func (a *AllowList) SetOnChange(fn func(ip netip.Addr, added bool)) {
a.mu.Lock()
defer a.mu.Unlock()
a.onChange = fn
}
// SetOnBatchChange registers a callback that fires with batched changes.
// The reaper and FlushDomain operations use this to deliver bulk updates.
func (a *AllowList) SetOnBatchChange(fn func(added []netip.Addr, removed []netip.Addr)) {
a.mu.Lock()
defer a.mu.Unlock()
a.onBatchChange = fn
}
// Add inserts an IP into the allowlist with a TTL and domain association.
// If the IP already exists, its expiry is extended (max of old and new) and the
// domain is added to its provenance list. This is called from the DNS proxy
// response path for every A/AAAA record in a successful resolution.
//
// The domain parameter tracks which DNS query produced this IP, enabling
// FlushDomain() to invalidate IPs when a domain's policy changes.
func (a *AllowList) Add(ip netip.Addr, domain string, ttl time.Duration) {
if !ip.IsValid() {
return
}
// Normalize IPv4-in-IPv6 to plain IPv4 for consistent lookups.
ip = ip.Unmap()
expiry := time.Now().Add(ttl)
a.mu.Lock()
defer a.mu.Unlock()
// Update or create the IP entry. LoadOrStore avoids overwriting an entry if
// another goroutine won the initialization race. The outer mutex keeps the
// IP and domain reverse maps in step with Flush/FlushDomain callbacks.
entryValue, loaded := a.ips.LoadOrStore(ip, &entry{
expiry: expiry,
domains: []string{domain},
})
if loaded {
e := entryValue.(*entry)
e.mu.Lock()
// Extend expiry if the new one is later.
if expiry.After(e.expiry) {
e.expiry = expiry
}
// Add domain to provenance if not already tracked.
if !containsString(e.domains, domain) {
e.domains = append(e.domains, domain)
}
e.mu.Unlock()
} else {
a.totalAdds.Add(1)
// Notify platform enforcement of the new IP.
if a.onChange != nil {
a.onChange(ip, true)
}
}
// Update the domain → IP reverse map.
de, _ := a.domains.LoadOrStore(domain, &domainEntry{})
d := de.(*domainEntry)
if !containsAddr(d.ips, ip) {
d.ips = append(d.ips, ip)
}
}
// Contains checks whether an IP is allowed. This is the hot path — called
// per-packet on mobile (via netstack) and per-connection on desktop (via
// pf/WFP). It must be O(1) with zero allocations.
//
// Returns true if the IP is in the allowlist (not expired) or in the
// permanent list.
func (a *AllowList) Contains(ip netip.Addr) bool {
if !ip.IsValid() {
return false
}
ip = ip.Unmap()
// Check permanent list first (most common for loopback/private).
if a.containsPermanent(ip) {
a.totalHits.Add(1)
return true
}
// Check dynamic allowlist.
if existing, ok := a.ips.Load(ip); ok {
e := existing.(*entry)
e.mu.Lock()
expired := time.Now().After(e.expiry)
e.mu.Unlock()
if !expired {
a.totalHits.Add(1)
return true
}
// Expired — will be cleaned up by reaper. Don't remove here to avoid
// per-lookup overhead and lock contention.
}
a.totalMisses.Add(1)
return false
}
// Remove deletes a single IP from the allowlist. Does not affect permanent entries.
func (a *AllowList) Remove(ip netip.Addr) {
ip = ip.Unmap()
a.mu.Lock()
defer a.mu.Unlock()
if existing, ok := a.ips.LoadAndDelete(ip); ok {
e := existing.(*entry)
a.totalRemoves.Add(1)
e.mu.Lock()
domains := append([]string(nil), e.domains...)
e.mu.Unlock()
// Clean up domain reverse map entries.
for _, domain := range domains {
a.removeDomainIP(domain, ip)
}
if a.onChange != nil {
a.onChange(ip, false)
}
}
}
// FlushDomain removes all IPs associated with a specific domain. This is used
// when a profile/policy change makes a previously-allowed domain blocked.
// IPs that are also associated with OTHER still-allowed domains are kept.
func (a *AllowList) FlushDomain(domain string) {
a.mu.Lock()
defer a.mu.Unlock()
de, ok := a.domains.LoadAndDelete(domain)
if !ok {
return
}
d := de.(*domainEntry)
ipsToCheck := make([]netip.Addr, len(d.ips))
copy(ipsToCheck, d.ips)
var removed []netip.Addr
for _, ip := range ipsToCheck {
existing, ok := a.ips.Load(ip)
if !ok {
continue
}
e := existing.(*entry)
e.mu.Lock()
// Remove this domain from the entry's domain list.
e.domains = removeString(e.domains, domain)
empty := len(e.domains) == 0
e.mu.Unlock()
// If no other domains reference this IP, remove it entirely.
if empty {
a.ips.Delete(ip)
a.totalRemoves.Add(1)
removed = append(removed, ip)
}
}
// Batch notify platform enforcement.
if len(removed) > 0 && a.onBatchChange != nil {
a.onBatchChange(nil, removed)
} else if a.onChange != nil {
for _, ip := range removed {
a.onChange(ip, false)
}
}
}
// Flush removes all non-permanent entries. Used on network state changes
// (WiFi→cellular, interface IP changes) where stale IPs from the old network
// may no longer be valid. DNS cache should also be flushed so that subsequent
// queries repopulate both caches.
func (a *AllowList) Flush() {
a.mu.Lock()
defer a.mu.Unlock()
var removed []netip.Addr
a.ips.Range(func(key, value any) bool {
ip := key.(netip.Addr)
a.ips.Delete(ip)
a.totalRemoves.Add(1)
removed = append(removed, ip)
return true
})
// Clear all domain reverse mappings.
a.domains.Range(func(key, _ any) bool {
a.domains.Delete(key)
return true
})
// Batch notify platform enforcement.
if len(removed) > 0 && a.onBatchChange != nil {
a.onBatchChange(nil, removed)
} else if a.onChange != nil {
for _, ip := range removed {
a.onChange(ip, false)
}
}
}
// AddPermanent adds an IP to the permanent allowlist. Permanent entries never
// expire and survive Flush(). Use for:
// - Loopback (127.0.0.0/8, ::1)
// - RFC1918 private ranges (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16)
// - Link-local (169.254.0.0/16, fe80::/10)
// - ctrld listener IPs
// - DoH/DoT/DoQ upstream IPs
// - ControlD API endpoint IPs
func (a *AllowList) AddPermanent(ip netip.Addr) {
if !ip.IsValid() {
return
}
ip = ip.Unmap()
a.permanent.Store(ip, struct{}{})
}
// AddPermanentPrefix adds an entire CIDR prefix to the permanent allowlist.
// Used for ranges like 127.0.0.0/8, 10.0.0.0/8, etc.
// For large prefixes (e.g., /8), this stores the prefix for range-based lookup
// rather than enumerating all IPs.
func (a *AllowList) AddPermanentPrefix(prefix netip.Prefix) {
// For small prefixes, enumerate. For large ones, we'd need a different approach.
// Since we're dealing with a known set of well-defined ranges, and Contains()
// needs to be fast, we check prefixes in a separate path.
// Store the prefix in a separate list for range-based checks.
a.permanent.Store(prefix, struct{}{})
}
// containsPermanent checks both individual IPs and prefix ranges in the permanent list.
func (a *AllowList) containsPermanent(ip netip.Addr) bool {
// Direct IP match.
if _, ok := a.permanent.Load(ip); ok {
return true
}
// Check prefix ranges. We iterate the permanent map looking for Prefix entries.
// This is acceptable because the permanent map is small and rarely changes.
found := false
a.permanent.Range(func(key, _ any) bool {
if prefix, ok := key.(netip.Prefix); ok {
if prefix.Contains(ip) {
found = true
return false // stop iteration
}
}
return true
})
return found
}
// PermanentEntries returns snapshots of permanently allowed individual IPs and
// prefixes. Platform enforcers use this to mirror the in-memory permanent
// allowlist into kernel-level permit rules.
func (a *AllowList) PermanentEntries() ([]netip.Addr, []netip.Prefix) {
a.mu.Lock()
defer a.mu.Unlock()
var addrs []netip.Addr
var prefixes []netip.Prefix
a.permanent.Range(func(key, _ any) bool {
switch v := key.(type) {
case netip.Addr:
addrs = append(addrs, v)
case netip.Prefix:
prefixes = append(prefixes, v)
}
return true
})
return addrs, prefixes
}
// RemovePermanent removes an IP from the permanent allowlist.
func (a *AllowList) RemovePermanent(ip netip.Addr) {
ip = ip.Unmap()
a.permanent.Delete(ip)
}
// Stats returns a snapshot of current allowlist metrics.
func (a *AllowList) Stats() Stats {
a.mu.Lock()
defer a.mu.Unlock()
var s Stats
now := time.Now()
a.ips.Range(func(_, value any) bool {
e := value.(*entry)
e.mu.Lock()
expired := now.After(e.expiry)
e.mu.Unlock()
if !expired {
s.AllowedIPs++
}
return true
})
a.permanent.Range(func(_, _ any) bool {
s.PermanentIPs++
return true
})
a.domains.Range(func(_, _ any) bool {
s.TrackedDomains++
return true
})
s.TotalAdds = a.totalAdds.Load()
s.TotalRemoves = a.totalRemoves.Load()
s.TotalHits = a.totalHits.Load()
s.TotalMisses = a.totalMisses.Load()
return s
}
// StartReaper begins a background goroutine that periodically removes expired
// entries from the allowlist. It runs every 30 seconds and batches removals
// for efficient platform enforcement notification.
//
// The reaper is intentionally separate from Contains() to avoid per-lookup
// overhead. Expired entries may linger for up to 30 seconds, which is acceptable
// since DNS TTLs are typically 30s-300s and the reaper interval is a fraction of that.
func (a *AllowList) StartReaper(ctx context.Context) {
go func() {
ticker := time.NewTicker(30 * time.Second)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
a.reap()
}
}
}()
}
// reap removes expired entries and notifies platform enforcement.
func (a *AllowList) reap() {
a.mu.Lock()
defer a.mu.Unlock()
now := time.Now()
var removed []netip.Addr
a.ips.Range(func(key, value any) bool {
ip := key.(netip.Addr)
e := value.(*entry)
e.mu.Lock()
expired := now.After(e.expiry)
var domains []string
if expired {
domains = make([]string, len(e.domains))
copy(domains, e.domains)
}
e.mu.Unlock()
if expired {
a.ips.Delete(ip)
a.totalRemoves.Add(1)
removed = append(removed, ip)
// Clean up domain reverse map.
for _, domain := range domains {
a.removeDomainIP(domain, ip)
}
}
return true
})
if len(removed) > 0 && a.onBatchChange != nil {
a.onBatchChange(nil, removed)
} else if a.onChange != nil {
for _, ip := range removed {
a.onChange(ip, false)
}
}
}
// removeDomainIP removes an IP from a domain's reverse map entry.
func (a *AllowList) removeDomainIP(domain string, ip netip.Addr) {
de, ok := a.domains.Load(domain)
if !ok {
return
}
d := de.(*domainEntry)
d.ips = removeAddr(d.ips, ip)
empty := len(d.ips) == 0
// Clean up empty domain entries.
if empty {
a.domains.Delete(domain)
}
}
// AllowedIPs returns a snapshot of all currently allowed (non-expired, non-permanent) IPs.
// Used by platform enforcement for initial table population.
func (a *AllowList) AllowedIPs() []netip.Addr {
a.mu.Lock()
defer a.mu.Unlock()
now := time.Now()
var ips []netip.Addr
a.ips.Range(func(key, value any) bool {
ip := key.(netip.Addr)
e := value.(*entry)
e.mu.Lock()
expiry := e.expiry
e.mu.Unlock()
if now.Before(expiry) {
ips = append(ips, ip)
}
return true
})
return ips
}
// --- Helpers ---
func containsString(ss []string, s string) bool {
for _, v := range ss {
if v == s {
return true
}
}
return false
}
func removeString(ss []string, s string) []string {
for i, v := range ss {
if v == s {
return append(ss[:i], ss[i+1:]...)
}
}
return ss
}
func containsAddr(addrs []netip.Addr, addr netip.Addr) bool {
for _, a := range addrs {
if a == addr {
return true
}
}
return false
}
func removeAddr(addrs []netip.Addr, addr netip.Addr) []netip.Addr {
for i, a := range addrs {
if a == addr {
return append(addrs[:i], addrs[i+1:]...)
}
}
return addrs
}
+461
View File
@@ -0,0 +1,461 @@
package firewall
import (
"context"
"net/netip"
"sync"
"testing"
"time"
)
func TestAddAndContains(t *testing.T) {
al := New()
ip := netip.MustParseAddr("93.184.216.34")
// IP should not be allowed before adding.
if al.Contains(ip) {
t.Fatal("expected IP to not be in allowlist before Add")
}
al.Add(ip, "example.com", 5*time.Minute)
if !al.Contains(ip) {
t.Fatal("expected IP to be in allowlist after Add")
}
}
func TestContainsExpired(t *testing.T) {
al := New()
ip := netip.MustParseAddr("93.184.216.34")
// Add with a TTL that's already past.
al.Add(ip, "example.com", -1*time.Second)
if al.Contains(ip) {
t.Fatal("expected expired IP to not be allowed")
}
}
func TestAddExtendsExpiry(t *testing.T) {
al := New()
ip := netip.MustParseAddr("93.184.216.34")
// Add with short TTL.
al.Add(ip, "example.com", 1*time.Second)
// Extend with longer TTL.
al.Add(ip, "cdn.example.com", 1*time.Hour)
// Should still be valid.
if !al.Contains(ip) {
t.Fatal("expected IP to be allowed after expiry extension")
}
// Check domain provenance was tracked.
existing, ok := al.ips.Load(ip)
if !ok {
t.Fatal("expected IP entry to exist")
}
e := existing.(*entry)
e.mu.Lock()
domainCount := len(e.domains)
e.mu.Unlock()
if domainCount != 2 {
t.Fatalf("expected 2 domains, got %d", domainCount)
}
}
func TestRemove(t *testing.T) {
al := New()
ip := netip.MustParseAddr("93.184.216.34")
al.Add(ip, "example.com", 5*time.Minute)
al.Remove(ip)
if al.Contains(ip) {
t.Fatal("expected IP to not be in allowlist after Remove")
}
}
func TestPermanent(t *testing.T) {
al := New()
ip := netip.MustParseAddr("127.0.0.1")
al.AddPermanent(ip)
if !al.Contains(ip) {
t.Fatal("expected permanent IP to be allowed")
}
// Permanent entries survive Flush.
al.Flush()
if !al.Contains(ip) {
t.Fatal("expected permanent IP to survive Flush")
}
}
func TestPermanentPrefix(t *testing.T) {
al := New()
prefix := netip.MustParsePrefix("10.0.0.0/8")
al.AddPermanentPrefix(prefix)
if !al.Contains(netip.MustParseAddr("10.1.2.3")) {
t.Fatal("expected IP in permanent prefix to be allowed")
}
if !al.Contains(netip.MustParseAddr("10.255.255.255")) {
t.Fatal("expected IP at end of permanent prefix to be allowed")
}
if al.Contains(netip.MustParseAddr("11.0.0.1")) {
t.Fatal("expected IP outside permanent prefix to not be allowed")
}
}
func TestFlush(t *testing.T) {
al := New()
al.AddPermanent(netip.MustParseAddr("127.0.0.1"))
al.Add(netip.MustParseAddr("93.184.216.34"), "example.com", 5*time.Minute)
al.Add(netip.MustParseAddr("151.101.1.69"), "reddit.com", 5*time.Minute)
al.Flush()
// Dynamic entries should be gone.
if al.Contains(netip.MustParseAddr("93.184.216.34")) {
t.Fatal("expected dynamic IP to be removed after Flush")
}
if al.Contains(netip.MustParseAddr("151.101.1.69")) {
t.Fatal("expected dynamic IP to be removed after Flush")
}
// Permanent should survive.
if !al.Contains(netip.MustParseAddr("127.0.0.1")) {
t.Fatal("expected permanent IP to survive Flush")
}
// Domain reverse map should be cleared.
stats := al.Stats()
if stats.TrackedDomains != 0 {
t.Fatalf("expected 0 tracked domains after Flush, got %d", stats.TrackedDomains)
}
}
func TestFlushDomain(t *testing.T) {
al := New()
// IP shared between two domains.
sharedIP := netip.MustParseAddr("93.184.216.34")
exclusiveIP := netip.MustParseAddr("93.184.216.35")
al.Add(sharedIP, "example.com", 5*time.Minute)
al.Add(sharedIP, "cdn.example.com", 5*time.Minute)
al.Add(exclusiveIP, "example.com", 5*time.Minute)
// Flush only example.com.
al.FlushDomain("example.com")
// Shared IP should remain because cdn.example.com still references it.
if !al.Contains(sharedIP) {
t.Fatal("expected shared IP to remain (still referenced by cdn.example.com)")
}
// Exclusive IP should be removed (only referenced by example.com).
if al.Contains(exclusiveIP) {
t.Fatal("expected exclusive IP to be removed after FlushDomain")
}
}
func TestFlushFallsBackToOnChange(t *testing.T) {
al := New()
ip1 := netip.MustParseAddr("93.184.216.34")
ip2 := netip.MustParseAddr("151.101.1.69")
var removed []netip.Addr
al.SetOnChange(func(ip netip.Addr, added bool) {
if !added {
removed = append(removed, ip)
}
})
al.Add(ip1, "example.com", 5*time.Minute)
al.Add(ip2, "reddit.com", 5*time.Minute)
al.Flush()
if len(removed) != 2 {
t.Fatalf("expected 2 removed IP callbacks, got %d", len(removed))
}
}
func TestFlushDomainThenAddKeepsPlatformInSync(t *testing.T) {
al := New()
ip := netip.MustParseAddr("93.184.216.34")
platform := make(map[netip.Addr]bool)
al.SetOnChange(func(ip netip.Addr, added bool) {
platform[ip] = added
})
al.SetOnBatchChange(func(added, removed []netip.Addr) {
for _, ip := range added {
platform[ip] = true
}
for _, ip := range removed {
platform[ip] = false
}
})
al.Add(ip, "example.com", 5*time.Minute)
al.FlushDomain("example.com")
al.Add(ip, "example.com", 5*time.Minute)
if !al.Contains(ip) {
t.Fatal("expected IP to be allowed after re-add")
}
if !platform[ip] {
t.Fatal("expected platform callback state to match allowlist after flush/re-add")
}
}
func TestConcurrentAddFlushDomainAndReap(t *testing.T) {
al := New()
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
al.StartReaper(ctx)
var wg sync.WaitGroup
for i := 0; i < 8; i++ {
wg.Add(1)
go func(n int) {
defer wg.Done()
for j := 0; j < 200; j++ {
ip := netip.AddrFrom4([4]byte{10, byte(n), byte(j / 255), byte(j % 255)})
al.Add(ip, "example.com", time.Minute)
al.Contains(ip)
}
}(i)
}
for i := 0; i < 4; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for j := 0; j < 50; j++ {
al.FlushDomain("example.com")
al.reap()
}
}()
}
wg.Wait()
}
func TestIPv4MappedIPv6(t *testing.T) {
al := New()
// Add as IPv4.
al.Add(netip.MustParseAddr("93.184.216.34"), "example.com", 5*time.Minute)
// Look up as IPv4-mapped IPv6 — should still match due to Unmap().
mapped := netip.AddrFrom16(netip.MustParseAddr("93.184.216.34").As16())
if !al.Contains(mapped) {
t.Fatal("expected IPv4-mapped IPv6 lookup to match IPv4 entry")
}
}
func TestIPv6(t *testing.T) {
al := New()
ip := netip.MustParseAddr("2606:2800:220:1:248:1893:25c8:1946")
al.Add(ip, "example.com", 5*time.Minute)
if !al.Contains(ip) {
t.Fatal("expected IPv6 address to be in allowlist")
}
}
func TestReaper(t *testing.T) {
al := New()
ip := netip.MustParseAddr("93.184.216.34")
// Add with very short TTL that's already expired.
al.Add(ip, "example.com", -1*time.Second)
// Manually trigger reaper.
al.reap()
// Should have been cleaned up.
_, ok := al.ips.Load(ip)
if ok {
t.Fatal("expected reaper to remove expired entry from ips map")
}
}
func TestReaperFallsBackToOnChange(t *testing.T) {
al := New()
var removed []netip.Addr
al.SetOnChange(func(ip netip.Addr, added bool) {
if !added {
removed = append(removed, ip)
}
})
al.Add(netip.MustParseAddr("1.1.1.1"), "one.com", -1*time.Second)
al.Add(netip.MustParseAddr("2.2.2.2"), "two.com", -1*time.Second)
al.reap()
if len(removed) != 2 {
t.Fatalf("expected 2 removed IP callbacks, got %d", len(removed))
}
}
func TestReaperBatchCallback(t *testing.T) {
al := New()
var removedIPs []netip.Addr
al.SetOnBatchChange(func(added, removed []netip.Addr) {
removedIPs = append(removedIPs, removed...)
})
al.Add(netip.MustParseAddr("1.1.1.1"), "one.com", -1*time.Second)
al.Add(netip.MustParseAddr("2.2.2.2"), "two.com", -1*time.Second)
al.Add(netip.MustParseAddr("3.3.3.3"), "three.com", 1*time.Hour) // not expired
al.reap()
if len(removedIPs) != 2 {
t.Fatalf("expected 2 removed IPs in batch callback, got %d", len(removedIPs))
}
}
func TestStatsExcludesExpiredAllowedIPs(t *testing.T) {
al := New()
al.Add(netip.MustParseAddr("93.184.216.34"), "example.com", -1*time.Second)
al.Add(netip.MustParseAddr("151.101.1.69"), "reddit.com", 5*time.Minute)
stats := al.Stats()
if stats.AllowedIPs != 1 {
t.Fatalf("expected 1 non-expired allowed IP, got %d", stats.AllowedIPs)
}
}
func TestStats(t *testing.T) {
al := New()
al.AddPermanent(netip.MustParseAddr("127.0.0.1"))
al.AddPermanentPrefix(netip.MustParsePrefix("10.0.0.0/8"))
al.Add(netip.MustParseAddr("93.184.216.34"), "example.com", 5*time.Minute)
al.Add(netip.MustParseAddr("151.101.1.69"), "reddit.com", 5*time.Minute)
// Generate some hits/misses.
al.Contains(netip.MustParseAddr("93.184.216.34")) // hit
al.Contains(netip.MustParseAddr("127.0.0.1")) // hit (permanent)
al.Contains(netip.MustParseAddr("8.8.8.8")) // miss
stats := al.Stats()
if stats.AllowedIPs != 2 {
t.Fatalf("expected 2 allowed IPs, got %d", stats.AllowedIPs)
}
// Permanent count includes both the individual IP and the prefix.
if stats.PermanentIPs != 2 {
t.Fatalf("expected 2 permanent entries, got %d", stats.PermanentIPs)
}
if stats.TrackedDomains != 2 {
t.Fatalf("expected 2 tracked domains, got %d", stats.TrackedDomains)
}
if stats.TotalHits != 2 {
t.Fatalf("expected 2 total hits, got %d", stats.TotalHits)
}
if stats.TotalMisses != 1 {
t.Fatalf("expected 1 total miss, got %d", stats.TotalMisses)
}
}
func TestConcurrentAccess(t *testing.T) {
al := New()
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
al.StartReaper(ctx)
al.AddPermanent(netip.MustParseAddr("127.0.0.1"))
var wg sync.WaitGroup
// Concurrent writers.
for i := 0; i < 10; i++ {
wg.Add(1)
go func(n int) {
defer wg.Done()
for j := 0; j < 100; j++ {
ip := netip.AddrFrom4([4]byte{10, 0, byte(n), byte(j)})
al.Add(ip, "test.com", 1*time.Minute)
}
}(i)
}
// Concurrent readers.
for i := 0; i < 10; i++ {
wg.Add(1)
go func(n int) {
defer wg.Done()
for j := 0; j < 100; j++ {
ip := netip.AddrFrom4([4]byte{10, 0, byte(n), byte(j)})
al.Contains(ip)
}
}(i)
}
// Concurrent flush.
wg.Add(1)
go func() {
defer wg.Done()
time.Sleep(10 * time.Millisecond)
al.Flush()
}()
wg.Wait()
}
func TestStartReaperWithContext(t *testing.T) {
// This test verifies the reaper goroutine starts and stops cleanly.
// We test actual reaping behavior via TestReaper (calls reap() directly).
al := New()
ctx, cancel := context.WithCancel(context.Background())
al.StartReaper(ctx)
cancel() // Should not panic or leak.
}
func TestOnChangeCallback(t *testing.T) {
al := New()
var added []netip.Addr
var removed []netip.Addr
al.SetOnChange(func(ip netip.Addr, isAdded bool) {
if isAdded {
added = append(added, ip)
} else {
removed = append(removed, ip)
}
})
ip := netip.MustParseAddr("93.184.216.34")
al.Add(ip, "example.com", 5*time.Minute)
al.Remove(ip)
if len(added) != 1 {
t.Fatalf("expected 1 added callback, got %d", len(added))
}
if len(removed) != 1 {
t.Fatalf("expected 1 removed callback, got %d", len(removed))
}
}
func TestInvalidIP(t *testing.T) {
al := New()
// Should not panic on invalid IPs.
var invalid netip.Addr
al.Add(invalid, "test.com", time.Minute)
if al.Contains(invalid) {
t.Fatal("expected invalid IP to not be in allowlist")
}
al.AddPermanent(invalid)
}