From 8330049b66924ccc0d006c198b9ae3024e4c5056 Mon Sep 17 00:00:00 2001 From: Codescribe Date: Wed, 24 Jun 2026 03:38:19 -0400 Subject: [PATCH] feat: add firewall mode DNS-resolved IP allowlist --- cmd/cli/cli.go | 33 +- cmd/cli/cli_intercept_listener_test.go | 28 -- cmd/cli/commands_run.go | 1 + cmd/cli/commands_service.go | 9 + cmd/cli/commands_service_start.go | 5 + cmd/cli/dns_intercept_darwin.go | 29 +- cmd/cli/dns_intercept_settle.go | 55 --- cmd/cli/dns_intercept_settle_test.go | 49 -- cmd/cli/dns_intercept_windows.go | 7 + cmd/cli/dns_proxy.go | 24 + cmd/cli/firewall.go | 311 +++++++++++++ cmd/cli/firewall_darwin.go | 314 +++++++++++++ cmd/cli/firewall_others.go | 15 + cmd/cli/firewall_test.go | 26 ++ cmd/cli/firewall_windows.go | 614 +++++++++++++++++++++++++ cmd/cli/main.go | 60 +-- cmd/cli/main_test.go | 16 - cmd/cli/prog.go | 47 +- cmd/cli/prog_test.go | 2 - cmd/cli/vpn_dns.go | 85 +--- cmd/cli/vpn_dns_test.go | 25 - config.go | 10 +- docs/firewall-mode.md | 160 +++++++ internal/firewall/allowlist.go | 567 +++++++++++++++++++++++ internal/firewall/allowlist_test.go | 461 +++++++++++++++++++ 25 files changed, 2618 insertions(+), 335 deletions(-) delete mode 100644 cmd/cli/cli_intercept_listener_test.go delete mode 100644 cmd/cli/dns_intercept_settle.go delete mode 100644 cmd/cli/dns_intercept_settle_test.go create mode 100644 cmd/cli/firewall.go create mode 100644 cmd/cli/firewall_darwin.go create mode 100644 cmd/cli/firewall_others.go create mode 100644 cmd/cli/firewall_test.go create mode 100644 cmd/cli/firewall_windows.go create mode 100644 docs/firewall-mode.md create mode 100644 internal/firewall/allowlist.go create mode 100644 internal/firewall/allowlist_test.go diff --git a/cmd/cli/cli.go b/cmd/cli/cli.go index 0ca20df..0ee8036 100644 --- a/cmd/cli/cli.go +++ b/cmd/cli/cli.go @@ -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. diff --git a/cmd/cli/cli_intercept_listener_test.go b/cmd/cli/cli_intercept_listener_test.go deleted file mode 100644 index 489b7ac..0000000 --- a/cmd/cli/cli_intercept_listener_test.go +++ /dev/null @@ -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) - } - }) - } -} diff --git a/cmd/cli/commands_run.go b/cmd/cli/commands_run.go index aa2b6b4..12b1143 100644 --- a/cmd/cli/commands_run.go +++ b/cmd/cli/commands_run.go @@ -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) diff --git a/cmd/cli/commands_service.go b/cmd/cli/commands_service.go index aac5a7d..9153119 100644 --- a/cmd/cli/commands_service.go +++ b/cmd/cli/commands_service.go @@ -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 ) and flags that are auto-added by the // start command alias (--iface). This is used to detect "ctrld start --intercept-mode dns" diff --git a/cmd/cli/commands_service_start.go b/cmd/cli/commands_service_start.go index 2c1798b..12f25b7 100644 --- a/cmd/cli/commands_service_start.go +++ b/cmd/cli/commands_service_start.go @@ -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{ diff --git a/cmd/cli/dns_intercept_darwin.go b/cmd/cli/dns_intercept_darwin.go index 8f96f38..9f86410 100644 --- a/cmd/cli/dns_intercept_darwin.go +++ b/cmd/cli/dns_intercept_darwin.go @@ -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: -> 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") diff --git a/cmd/cli/dns_intercept_settle.go b/cmd/cli/dns_intercept_settle.go deleted file mode 100644 index 6d6ffaf..0000000 --- a/cmd/cli/dns_intercept_settle.go +++ /dev/null @@ -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 -} diff --git a/cmd/cli/dns_intercept_settle_test.go b/cmd/cli/dns_intercept_settle_test.go deleted file mode 100644 index b6c80be..0000000 --- a/cmd/cli/dns_intercept_settle_test.go +++ /dev/null @@ -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) - } -} diff --git a/cmd/cli/dns_intercept_windows.go b/cmd/cli/dns_intercept_windows.go index 6e0e7d4..c33c42b 100644 --- a/cmd/cli/dns_intercept_windows.go +++ b/cmd/cli/dns_intercept_windows.go @@ -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 { diff --git a/cmd/cli/dns_proxy.go b/cmd/cli/dns_proxy.go index 2a1a24e..697e19f 100644 --- a/cmd/cli/dns_proxy.go +++ b/cmd/cli/dns_proxy.go @@ -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 diff --git a/cmd/cli/firewall.go b/cmd/cli/firewall.go new file mode 100644 index 0000000..4b4cfea --- /dev/null +++ b/cmd/cli/firewall.go @@ -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") +} diff --git a/cmd/cli/firewall_darwin.go b/cmd/cli/firewall_darwin.go new file mode 100644 index 0000000..26bc84a --- /dev/null +++ b/cmd/cli/firewall_darwin.go @@ -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 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 -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 (dynamically populated via pfctl -T add/delete) +// - Block all outbound traffic by default (after DNS intercept rules) +// - Pass outbound to IPs in +// - 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() +} diff --git a/cmd/cli/firewall_others.go b/cmd/cli/firewall_others.go new file mode 100644 index 0000000..112a74f --- /dev/null +++ b/cmd/cli/firewall_others.go @@ -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() {} diff --git a/cmd/cli/firewall_test.go b/cmd/cli/firewall_test.go new file mode 100644 index 0000000..29dba0d --- /dev/null +++ b/cmd/cli/firewall_test.go @@ -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) + } + }) + } +} diff --git a/cmd/cli/firewall_windows.go b/cmd/cli/firewall_windows.go new file mode 100644 index 0000000..a4c6580 --- /dev/null +++ b/cmd/cli/firewall_windows.go @@ -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 +} diff --git a/cmd/cli/main.go b/cmd/cli/main.go index 0c10f7f..01c2c05 100644 --- a/cmd/cli/main.go +++ b/cmd/cli/main.go @@ -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 diff --git a/cmd/cli/main_test.go b/cmd/cli/main_test.go index 55de3b0..d0a1149 100644 --- a/cmd/cli/main_test.go +++ b/cmd/cli/main_test.go @@ -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()) } diff --git a/cmd/cli/prog.go b/cmd/cli/prog.go index 66c2f15..329de8e 100644 --- a/cmd/cli/prog.go +++ b/cmd/cli/prog.go @@ -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 diff --git a/cmd/cli/prog_test.go b/cmd/cli/prog_test.go index f30dcb8..eccc30b 100644 --- a/cmd/cli/prog_test.go +++ b/cmd/cli/prog_test.go @@ -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) { diff --git a/cmd/cli/vpn_dns.go b/cmd/cli/vpn_dns.go index 93569e4..50d81bd 100644 --- a/cmd/cli/vpn_dns.go +++ b/cmd/cli/vpn_dns.go @@ -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 { diff --git a/cmd/cli/vpn_dns_test.go b/cmd/cli/vpn_dns_test.go index 86d4602..32f6c24 100644 --- a/cmd/cli/vpn_dns_test.go +++ b/cmd/cli/vpn_dns_test.go @@ -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) diff --git a/config.go b/config.go index 75f17e5..7357e31 100644 --- a/config.go +++ b/config.go @@ -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. diff --git a/docs/firewall-mode.md b/docs/firewall-mode.md new file mode 100644 index 0000000..e608243 --- /dev/null +++ b/docs/firewall-mode.md @@ -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 `` table: + +- Default: block all outbound traffic +- Pass: traffic to IPs in the `` 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 diff --git a/internal/firewall/allowlist.go b/internal/firewall/allowlist.go new file mode 100644 index 0000000..c205daf --- /dev/null +++ b/internal/firewall/allowlist.go @@ -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 +} diff --git a/internal/firewall/allowlist_test.go b/internal/firewall/allowlist_test.go new file mode 100644 index 0000000..61bdfd9 --- /dev/null +++ b/internal/firewall/allowlist_test.go @@ -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) +}