mirror of
https://github.com/Control-D-Inc/ctrld.git
synced 2026-07-16 13:17:19 +02:00
feat: add firewall mode DNS-resolved IP allowlist
This commit is contained in:
committed by
Cuong Manh Le
parent
9eb7067fbe
commit
8330049b66
+22
-11
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -268,6 +268,15 @@ func validInterceptMode(mode string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// validFirewallMode reports whether the given value is a recognized --firewall-mode.
|
||||
func validFirewallMode(mode string) bool {
|
||||
switch mode {
|
||||
case "off", "on":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// onlyInterceptFlags reports whether args contain only intercept mode
|
||||
// flags (--intercept-mode <value>) and flags that are auto-added by the
|
||||
// start command alias (--iface). This is used to detect "ctrld start --intercept-mode dns"
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -960,6 +960,13 @@ func (p *prog) buildPFAnchorRules(vpnExemptions []vpnDNSExemption) string {
|
||||
rules.WriteString("# Accept redirected DNS — reply-to lo0 forces response through loopback.\n")
|
||||
rules.WriteString(fmt.Sprintf("pass in quick on lo0 reply-to lo0 inet proto { udp, tcp } from any to %s\n", listenerAddr))
|
||||
|
||||
// Firewall mode: append IP allowlist enforcement rules AFTER DNS intercept rules.
|
||||
// DNS intercept rules must evaluate first so that DNS queries work (they're how
|
||||
// IPs get into the allowlist in the first place).
|
||||
if p.firewallModeEnabled() {
|
||||
rules.WriteString(buildPFFirewallRules())
|
||||
}
|
||||
|
||||
return rules.String()
|
||||
}
|
||||
|
||||
@@ -1221,10 +1228,6 @@ func (p *prog) pfStabilizationLoop(ctx context.Context, stableRequired time.Dura
|
||||
p.pfStabilizing.Store(false)
|
||||
mainLog.Load().Info().Msgf("DNS intercept: pf stable for %s — restoring anchor rules", stableRequired)
|
||||
p.ensurePFAnchorActive()
|
||||
routes, domainlessServers, exemptions := p.refreshDNSAfterVPNSettle("pf_stabilized")
|
||||
if routes == 0 && domainlessServers == 0 && exemptions == 0 {
|
||||
p.scheduleDNSAfterVPNSettleRefresh("pf_stabilized_followup", pfAnchorRecheckDelayLong)
|
||||
}
|
||||
p.pfLastRestoreTime.Store(time.Now().UnixMilli())
|
||||
return
|
||||
}
|
||||
@@ -1386,15 +1389,6 @@ func (p *prog) ensurePFAnchorActive() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func (p *prog) scheduleDNSAfterVPNSettleRefresh(reason string, delay time.Duration) {
|
||||
time.AfterFunc(delay, func() {
|
||||
if p.dnsInterceptState == nil {
|
||||
return
|
||||
}
|
||||
p.refreshDNSAfterVPNSettle(reason)
|
||||
})
|
||||
}
|
||||
|
||||
// pfWatchdog periodically checks that our pf anchor is still active.
|
||||
// Other programs (e.g., Windscribe desktop app, macOS configd) can replace
|
||||
// scheduleDelayedRechecks schedules delayed re-checks after a network change event.
|
||||
@@ -1750,14 +1744,7 @@ func (p *prog) forceReloadPFMainRuleset() {
|
||||
mainLog.Load().Error().Err(err).Msgf("DNS intercept: force reload — failed to load anchor (output: %s)", strings.TrimSpace(string(out)))
|
||||
}
|
||||
|
||||
// Flush stale rdr/reply states after the forced ruleset + anchor reload.
|
||||
// Without this, macOS can keep using pre-reload state and try to send
|
||||
// redirected DNS replies directly from loopback to tunnel client addresses
|
||||
// (for example, 127.0.0.1:<listener> -> 100.64.0.0/10), which fails with
|
||||
// "sendmsg: can't assign requested address".
|
||||
flushPFStates()
|
||||
|
||||
// Reset upstream transports — pf reload/state flush kills existing DoH connections.
|
||||
// Reset upstream transports — pf reload flushes state table, killing DoH connections.
|
||||
p.resetUpstreamTransports()
|
||||
|
||||
mainLog.Load().Info().Msg("DNS intercept: force reload — pf ruleset and anchor reloaded successfully")
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
@@ -0,0 +1,314 @@
|
||||
//go:build darwin
|
||||
|
||||
package cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/netip"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// pfFirewallState holds the state for pf-based firewall mode enforcement on macOS.
|
||||
// When firewall mode is active, we maintain a pf table of allowed IPs and add
|
||||
// block/pass rules to the ctrld anchor that enforce the allowlist.
|
||||
type pfFirewallState struct {
|
||||
// mu protects batch accumulation.
|
||||
mu sync.Mutex
|
||||
|
||||
// pendingAdds and pendingRemoves accumulate changes for batched pf updates.
|
||||
pendingAdds []netip.Addr
|
||||
pendingRemoves []netip.Addr
|
||||
|
||||
// batchTimer fires after the accumulation window to flush pending changes.
|
||||
batchTimer *time.Timer
|
||||
}
|
||||
|
||||
const (
|
||||
// pfFirewallTable is the pf table name for dynamically-allowed IPs.
|
||||
pfFirewallTable = "ctrld_allowed"
|
||||
|
||||
// pfFirewallBatchInterval is the accumulation window for batching pf table updates.
|
||||
// Short enough for responsiveness, long enough to avoid per-DNS-response pfctl calls.
|
||||
pfFirewallBatchInterval = 200 * time.Millisecond
|
||||
)
|
||||
|
||||
// firewallFlushPlatform flushes the pf table on macOS.
|
||||
func (p *prog) firewallFlushPlatform() {
|
||||
p.pfFirewallFlushTable()
|
||||
}
|
||||
|
||||
// shutdownPlatformFirewall removes macOS firewall-mode dynamic state. The pf
|
||||
// anchor itself is rebuilt by DNS intercept without firewall rules once
|
||||
// p.allowList is nil.
|
||||
func (p *prog) shutdownPlatformFirewall() {
|
||||
p.pfFirewallFlushTable()
|
||||
|
||||
if p.dnsInterceptState == nil {
|
||||
return
|
||||
}
|
||||
|
||||
var vpnExemptions []vpnDNSExemption
|
||||
if p.vpnDNS != nil {
|
||||
vpnExemptions = p.vpnDNS.CurrentExemptions()
|
||||
}
|
||||
rulesStr := p.buildPFAnchorRules(vpnExemptions)
|
||||
if err := os.WriteFile(pfAnchorFile, []byte(rulesStr), 0644); err != nil {
|
||||
p.Warn().Err(err).Msg("Firewall: failed to write pf anchor during shutdown")
|
||||
return
|
||||
}
|
||||
if out, err := exec.Command("pfctl", "-a", pfAnchorName, "-f", pfAnchorFile).CombinedOutput(); err != nil {
|
||||
p.Warn().Err(err).Str("output", strings.TrimSpace(string(out))).Msg("Firewall: failed to reload pf anchor during shutdown")
|
||||
}
|
||||
}
|
||||
|
||||
// initPlatformFirewall initializes macOS-specific firewall enforcement (pf tables).
|
||||
func (p *prog) initPlatformFirewall() {
|
||||
if _, ok := p.platformFirewallState.(*pfFirewallState); ok {
|
||||
return
|
||||
}
|
||||
|
||||
// pf enforcement is only meaningful when intercept mode is active —
|
||||
// without it, we have no pf anchor to add rules to.
|
||||
if dnsIntercept && p.dnsInterceptState != nil {
|
||||
p.initPFFirewall()
|
||||
} else {
|
||||
p.Info().Msg("Firewall: pf enforcement deferred until intercept mode starts")
|
||||
}
|
||||
}
|
||||
|
||||
// initPFFirewall sets up pf-based firewall mode enforcement. Called from
|
||||
// initFirewallAllowList() when running on macOS with intercept mode active.
|
||||
//
|
||||
// Architecture:
|
||||
// - Creates a pf table <ctrld_allowed> for dynamic IP allowlisting
|
||||
// - Registers batch change callbacks on the AllowList
|
||||
// - The actual pf anchor rules are injected via buildPFFirewallRules() which
|
||||
// is called from buildPFAnchorRules() when firewall mode is active
|
||||
//
|
||||
// The table approach (vs. per-IP pass rules) is critical for performance:
|
||||
// pfctl table operations are O(log n) and don't require a full anchor reload.
|
||||
func (p *prog) initPFFirewall() {
|
||||
state := &pfFirewallState{}
|
||||
p.platformFirewallState = state
|
||||
|
||||
// Register batch callback — AllowList reaper and FlushDomain use this.
|
||||
p.allowList.SetOnBatchChange(func(added []netip.Addr, removed []netip.Addr) {
|
||||
state.mu.Lock()
|
||||
defer state.mu.Unlock()
|
||||
|
||||
if len(added) > 0 {
|
||||
state.pendingAdds = append(state.pendingAdds, added...)
|
||||
}
|
||||
if len(removed) > 0 {
|
||||
state.pendingRemoves = append(state.pendingRemoves, removed...)
|
||||
}
|
||||
state.scheduleBatchFlush(p)
|
||||
})
|
||||
|
||||
// Register individual change callback — Add() and Remove() use this.
|
||||
p.allowList.SetOnChange(func(ip netip.Addr, added bool) {
|
||||
state.mu.Lock()
|
||||
defer state.mu.Unlock()
|
||||
|
||||
if added {
|
||||
state.pendingAdds = append(state.pendingAdds, ip)
|
||||
} else {
|
||||
state.pendingRemoves = append(state.pendingRemoves, ip)
|
||||
}
|
||||
state.scheduleBatchFlush(p)
|
||||
})
|
||||
|
||||
// DNS responses may have populated the allowlist before platform callbacks
|
||||
// were registered. Bulk-load that snapshot so pf starts with the same view
|
||||
// as the in-memory allowlist.
|
||||
p.pfFirewallPopulateTable()
|
||||
|
||||
p.Info().Msg("Firewall: pf table enforcement initialized")
|
||||
}
|
||||
|
||||
// scheduleBatchFlush starts or resets the batch timer. Must be called with state.mu held.
|
||||
func (s *pfFirewallState) scheduleBatchFlush(p *prog) {
|
||||
if s.batchTimer != nil {
|
||||
return
|
||||
}
|
||||
s.batchTimer = time.AfterFunc(pfFirewallBatchInterval, func() {
|
||||
s.flushBatch(p)
|
||||
})
|
||||
}
|
||||
|
||||
// flushBatch applies accumulated pf table changes in a single pfctl call per direction.
|
||||
func (s *pfFirewallState) flushBatch(p *prog) {
|
||||
s.mu.Lock()
|
||||
adds := s.pendingAdds
|
||||
removes := s.pendingRemoves
|
||||
s.pendingAdds = nil
|
||||
s.pendingRemoves = nil
|
||||
s.batchTimer = nil
|
||||
s.mu.Unlock()
|
||||
|
||||
if len(adds) == 0 && len(removes) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
// Collapse add/remove deltas into the current primary allowlist state. This
|
||||
// avoids leaving pf opposite the allowlist when an Add and Remove for the
|
||||
// same IP land in one batch window.
|
||||
ipsToSync := make(map[netip.Addr]struct{}, len(adds)+len(removes))
|
||||
for _, ip := range adds {
|
||||
ipsToSync[ip] = struct{}{}
|
||||
}
|
||||
for _, ip := range removes {
|
||||
ipsToSync[ip] = struct{}{}
|
||||
}
|
||||
|
||||
var tableAdds, tableRemoves []string
|
||||
for ip := range ipsToSync {
|
||||
if p.allowList != nil && p.allowList.Contains(ip) {
|
||||
tableAdds = append(tableAdds, ip.String())
|
||||
} else {
|
||||
tableRemoves = append(tableRemoves, ip.String())
|
||||
}
|
||||
}
|
||||
|
||||
// Apply additions.
|
||||
if len(tableAdds) > 0 {
|
||||
// pfctl -t <table> -T add accepts multiple IPs space-separated.
|
||||
args := append([]string{"-a", pfAnchorName, "-t", pfFirewallTable, "-T", "add"}, tableAdds...)
|
||||
if out, err := exec.Command("pfctl", args...).CombinedOutput(); err != nil {
|
||||
p.Warn().Err(err).Str("output", string(out)).
|
||||
Msgf("Firewall: failed to add %d IPs to pf table", len(tableAdds))
|
||||
} else {
|
||||
p.Debug().Msgf("Firewall: added %d IPs to pf table %s", len(tableAdds), pfFirewallTable)
|
||||
}
|
||||
}
|
||||
|
||||
// Apply removals.
|
||||
if len(tableRemoves) > 0 {
|
||||
args := append([]string{"-a", pfAnchorName, "-t", pfFirewallTable, "-T", "delete"}, tableRemoves...)
|
||||
if out, err := exec.Command("pfctl", args...).CombinedOutput(); err != nil {
|
||||
// Not a hard error — the IP may have already been removed (e.g., by a Flush).
|
||||
p.Debug().Err(err).Str("output", string(out)).
|
||||
Msgf("Firewall: failed to remove %d IPs from pf table (may already be gone)", len(tableRemoves))
|
||||
} else {
|
||||
p.Debug().Msgf("Firewall: removed %d IPs from pf table %s", len(tableRemoves), pfFirewallTable)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// pfFirewallFlushTable removes all entries from the pf firewall table.
|
||||
// Called on network changes and config reloads before the AllowList is flushed.
|
||||
func (p *prog) pfFirewallFlushTable() {
|
||||
if state, ok := p.platformFirewallState.(*pfFirewallState); ok && state != nil {
|
||||
state.mu.Lock()
|
||||
if state.batchTimer != nil {
|
||||
state.batchTimer.Stop()
|
||||
state.batchTimer = nil
|
||||
}
|
||||
state.pendingAdds = nil
|
||||
state.pendingRemoves = nil
|
||||
state.mu.Unlock()
|
||||
}
|
||||
|
||||
out, err := exec.Command("pfctl", "-a", pfAnchorName, "-t", pfFirewallTable, "-T", "flush").CombinedOutput()
|
||||
if err != nil {
|
||||
p.Debug().Err(err).Str("output", string(out)).Msg("Firewall: failed to flush pf table (may not exist yet)")
|
||||
} else {
|
||||
p.Info().Msg("Firewall: flushed pf table " + pfFirewallTable)
|
||||
}
|
||||
}
|
||||
|
||||
// pfFirewallPopulateTable bulk-loads all currently allowed IPs into the pf table.
|
||||
// Called after anchor rule installation to ensure the table is populated.
|
||||
func (p *prog) pfFirewallPopulateTable() {
|
||||
if p.allowList == nil {
|
||||
return
|
||||
}
|
||||
ips := p.allowList.AllowedIPs()
|
||||
if len(ips) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
ipStrs := make([]string, 0, len(ips))
|
||||
for _, ip := range ips {
|
||||
ipStrs = append(ipStrs, ip.String())
|
||||
}
|
||||
|
||||
args := append([]string{"-a", pfAnchorName, "-t", pfFirewallTable, "-T", "add"}, ipStrs...)
|
||||
if out, err := exec.Command("pfctl", args...).CombinedOutput(); err != nil {
|
||||
p.Warn().Err(err).Str("output", string(out)).
|
||||
Msgf("Firewall: failed to populate pf table with %d IPs", len(ips))
|
||||
} else {
|
||||
p.Info().Msgf("Firewall: populated pf table with %d allowed IPs", len(ips))
|
||||
}
|
||||
}
|
||||
|
||||
// buildPFFirewallRules generates the pf rules for firewall mode enforcement.
|
||||
// These rules are appended to the anchor by buildPFAnchorRules() when firewall
|
||||
// mode is active.
|
||||
//
|
||||
// The strategy is:
|
||||
// - Define table <ctrld_allowed> (dynamically populated via pfctl -T add/delete)
|
||||
// - Block all outbound traffic by default (after DNS intercept rules)
|
||||
// - Pass outbound to IPs in <ctrld_allowed>
|
||||
// - Pass outbound from ctrld's group (already handled by blanket exemption)
|
||||
// - Pass loopback, link-local, multicast (already handled by permanent allowlist,
|
||||
// but explicit pf rules prevent kernel-level blocking before our check)
|
||||
//
|
||||
// IMPORTANT: These rules must come AFTER the DNS intercept rules in the anchor
|
||||
// so that DNS itself still works (DNS is how IPs get into the allowlist).
|
||||
func buildPFFirewallRules() string {
|
||||
var rules strings.Builder
|
||||
|
||||
rules.WriteString("\n# --- Firewall Mode: DNS-resolved IP allowlist enforcement ---\n")
|
||||
rules.WriteString("# Only IPs resolved by ctrld are allowed for outbound connections.\n")
|
||||
rules.WriteString("# Table is dynamically populated from DNS responses.\n\n")
|
||||
|
||||
// Declare the table. pfctl -T add/delete operates on this table dynamically.
|
||||
fmt.Fprintf(&rules, "table <%s> persist\n\n", pfFirewallTable)
|
||||
|
||||
// Pass traffic to allowed IPs (both IPv4 and IPv6).
|
||||
rules.WriteString("# Allow outbound to DNS-resolved IPs.\n")
|
||||
fmt.Fprintf(&rules, "pass out quick inet proto { tcp, udp } from any to <%s>\n", pfFirewallTable)
|
||||
fmt.Fprintf(&rules, "pass out quick inet6 proto { tcp, udp } from any to <%s>\n\n", pfFirewallTable)
|
||||
|
||||
// Allow ICMP/ICMPv6 — needed for path MTU discovery, ping, etc.
|
||||
rules.WriteString("# Allow ICMP (path MTU discovery, ping, etc.)\n")
|
||||
rules.WriteString("pass out quick inet proto icmp\n")
|
||||
rules.WriteString("pass out quick inet6 proto icmp6\n\n")
|
||||
|
||||
// Allow all loopback traffic (safety net — permanent allowlist covers this too).
|
||||
rules.WriteString("# Allow all loopback traffic.\n")
|
||||
rules.WriteString("pass out quick on lo0\n")
|
||||
rules.WriteString("pass in quick on lo0\n\n")
|
||||
|
||||
// Allow RFC1918 and link-local — these are in the permanent allowlist but
|
||||
// explicit pf rules prevent the block rule below from catching them.
|
||||
rules.WriteString("# Allow private/link-local ranges (LAN, printers, NAS, mDNS, DHCP).\n")
|
||||
rules.WriteString("pass out quick inet proto { tcp, udp } from any to 10.0.0.0/8\n")
|
||||
rules.WriteString("pass out quick inet proto { tcp, udp } from any to 172.16.0.0/12\n")
|
||||
rules.WriteString("pass out quick inet proto { tcp, udp } from any to 192.168.0.0/16\n")
|
||||
rules.WriteString("pass out quick inet proto { tcp, udp } from any to 169.254.0.0/16\n")
|
||||
rules.WriteString("pass out quick inet proto { tcp, udp } from any to 100.64.0.0/10\n")
|
||||
rules.WriteString("pass out quick inet6 proto { tcp, udp } from any to fe80::/10\n\n")
|
||||
|
||||
// Allow multicast (mDNS, SSDP, etc.).
|
||||
rules.WriteString("# Allow multicast (mDNS, SSDP, etc.).\n")
|
||||
rules.WriteString("pass out quick inet proto { tcp, udp } from any to 224.0.0.0/4\n")
|
||||
rules.WriteString("pass out quick inet6 proto { tcp, udp } from any to ff00::/8\n\n")
|
||||
|
||||
// Allow DHCP (UDP 67/68) — needed for network configuration.
|
||||
rules.WriteString("# Allow DHCP.\n")
|
||||
rules.WriteString("pass out quick inet proto udp from any port 68 to any port 67\n\n")
|
||||
|
||||
// Block everything else. This is the enforcement rule.
|
||||
// "block return" sends TCP RST / ICMP unreachable so apps fail fast instead of timing out.
|
||||
rules.WriteString("# Block all other outbound traffic (IPs not resolved by ctrld).\n")
|
||||
rules.WriteString("block return out quick inet proto { tcp, udp } from any to any\n")
|
||||
rules.WriteString("block return out quick inet6 proto { tcp, udp } from any to any\n")
|
||||
|
||||
return rules.String()
|
||||
}
|
||||
@@ -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() {}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,614 @@
|
||||
//go:build windows
|
||||
|
||||
package cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/netip"
|
||||
"runtime"
|
||||
"sync"
|
||||
"time"
|
||||
"unsafe"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
// wfpFirewallState holds the state for WFP-based firewall mode enforcement on Windows.
|
||||
// When firewall mode is active, we add dynamic WFP permit filters for each allowed IP
|
||||
// on top of a block-all base filter.
|
||||
type wfpFirewallState struct {
|
||||
mu sync.Mutex
|
||||
|
||||
// pendingAdds and pendingRemoves accumulate changes for batched WFP updates.
|
||||
pendingAdds []netip.Addr
|
||||
pendingRemoves []netip.Addr
|
||||
|
||||
// batchTimer fires after the accumulation window to flush pending changes.
|
||||
batchTimer *time.Timer
|
||||
|
||||
// filterMap tracks WFP filter IDs for each dynamically allowed IP so we can remove them.
|
||||
// Maps IP string → WFP filter ID.
|
||||
filterMap map[string]uint64
|
||||
|
||||
// permanentFilterMap tracks permit filters for permanent allowlist entries
|
||||
// (loopback/private/link-local/listener/upstream IPs). These stay installed
|
||||
// across dynamic allowlist flushes.
|
||||
permanentFilterMap map[string]uint64
|
||||
|
||||
// blockFilterIDv4 and blockFilterIDv6 are the base block-all filters.
|
||||
blockFilterIDv4 uint64
|
||||
blockFilterIDv6 uint64
|
||||
|
||||
// engineHandle is the WFP engine handle from the intercept state.
|
||||
engineHandle uintptr
|
||||
}
|
||||
|
||||
const (
|
||||
// wfpFirewallBatchInterval is the accumulation window for batching WFP filter updates.
|
||||
wfpFirewallBatchInterval = 200 * time.Millisecond
|
||||
)
|
||||
|
||||
// firewallFlushPlatform removes all dynamic WFP permit filters on Windows.
|
||||
// Called on network changes and config reloads before the in-memory allowlist is flushed.
|
||||
func (p *prog) firewallFlushPlatform() {
|
||||
fwState, ok := p.platformFirewallState.(*wfpFirewallState)
|
||||
if !ok || fwState == nil {
|
||||
return
|
||||
}
|
||||
fwState.flushAll(p)
|
||||
}
|
||||
|
||||
// shutdownPlatformFirewall removes Windows firewall-mode WFP filters, including
|
||||
// dynamic permits, permanent permits, and the base block-all filters.
|
||||
func (p *prog) shutdownPlatformFirewall() {
|
||||
fwState, ok := p.platformFirewallState.(*wfpFirewallState)
|
||||
if !ok || fwState == nil {
|
||||
return
|
||||
}
|
||||
fwState.shutdown(p)
|
||||
}
|
||||
|
||||
// initPlatformFirewall initializes Windows-specific firewall enforcement (WFP filters).
|
||||
func (p *prog) initPlatformFirewall() {
|
||||
if fwState, ok := p.platformFirewallState.(*wfpFirewallState); ok && fwState != nil {
|
||||
fwState.populatePermanentFilters(p)
|
||||
fwState.populateFilters(p)
|
||||
return
|
||||
}
|
||||
|
||||
if !hardIntercept || p.dnsInterceptState == nil {
|
||||
p.Info().Msg("Firewall: WFP enforcement requires hard intercept mode")
|
||||
return
|
||||
}
|
||||
|
||||
state, ok := p.dnsInterceptState.(*wfpState)
|
||||
if !ok || state == nil {
|
||||
p.Warn().Msg("Firewall: could not access WFP state for firewall enforcement")
|
||||
return
|
||||
}
|
||||
|
||||
fwState := &wfpFirewallState{
|
||||
filterMap: make(map[string]uint64),
|
||||
permanentFilterMap: make(map[string]uint64),
|
||||
engineHandle: state.engineHandle,
|
||||
}
|
||||
p.platformFirewallState = fwState
|
||||
|
||||
// Install base block-all outbound filters. We add our firewall filters to the
|
||||
// SAME sublayer as DNS intercept with carefully chosen weights:
|
||||
// - Existing DNS permits (localhost): weight 10 (highest, evaluated first)
|
||||
// - Firewall IP permits: weight 5 (middle)
|
||||
// - Existing DNS block: weight 1 (blocks non-localhost DNS)
|
||||
// - Firewall block-all: weight 1 (catch-all for non-DNS)
|
||||
//
|
||||
// WFP evaluates higher weights first within a sublayer. DNS permits at 10
|
||||
// always win, ensuring DNS resolution works. Firewall IP permits at 5
|
||||
// override the block-all for resolved IPs. The existing DNS block at 1
|
||||
// and our block-all at 1 are both catch-alls (DNS block has port 53
|
||||
// conditions so it only catches DNS; our block-all has no conditions
|
||||
// so it catches everything else).
|
||||
if err := p.addWFPFirewallBlockFilters(fwState); err != nil {
|
||||
p.Error().Err(err).Msg("Firewall: failed to install WFP block-all filters")
|
||||
return
|
||||
}
|
||||
|
||||
// Install permits for the permanent allowlist before enabling dynamic updates.
|
||||
// Without these, the block-all filters would also block ctrld upstreams,
|
||||
// listener/loopback traffic, LAN ranges, and other permanent exceptions.
|
||||
fwState.populatePermanentFilters(p)
|
||||
|
||||
// Register batch callback.
|
||||
p.allowList.SetOnBatchChange(func(added []netip.Addr, removed []netip.Addr) {
|
||||
fwState.mu.Lock()
|
||||
defer fwState.mu.Unlock()
|
||||
|
||||
if len(added) > 0 {
|
||||
fwState.pendingAdds = append(fwState.pendingAdds, added...)
|
||||
}
|
||||
if len(removed) > 0 {
|
||||
fwState.pendingRemoves = append(fwState.pendingRemoves, removed...)
|
||||
}
|
||||
fwState.scheduleBatchFlush(p)
|
||||
})
|
||||
|
||||
// Register individual change callback.
|
||||
p.allowList.SetOnChange(func(ip netip.Addr, isAdded bool) {
|
||||
fwState.mu.Lock()
|
||||
defer fwState.mu.Unlock()
|
||||
|
||||
if isAdded {
|
||||
fwState.pendingAdds = append(fwState.pendingAdds, ip)
|
||||
} else {
|
||||
fwState.pendingRemoves = append(fwState.pendingRemoves, ip)
|
||||
}
|
||||
fwState.scheduleBatchFlush(p)
|
||||
})
|
||||
|
||||
// DNS responses may have populated the allowlist before platform callbacks
|
||||
// were registered. Add permit filters for that snapshot so WFP starts with
|
||||
// the same view as the in-memory allowlist.
|
||||
fwState.populateFilters(p)
|
||||
|
||||
p.Info().Msg("Firewall: WFP enforcement initialized with block-all base filters")
|
||||
}
|
||||
|
||||
// scheduleBatchFlush starts or resets the batch timer. Must be called with fwState.mu held.
|
||||
func (s *wfpFirewallState) scheduleBatchFlush(p *prog) {
|
||||
if s.batchTimer != nil {
|
||||
return
|
||||
}
|
||||
s.batchTimer = time.AfterFunc(wfpFirewallBatchInterval, func() {
|
||||
s.flushBatch(p)
|
||||
})
|
||||
}
|
||||
|
||||
// flushBatch applies accumulated WFP filter changes.
|
||||
func (s *wfpFirewallState) flushBatch(p *prog) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
adds := s.pendingAdds
|
||||
removes := s.pendingRemoves
|
||||
s.pendingAdds = nil
|
||||
s.pendingRemoves = nil
|
||||
s.batchTimer = nil
|
||||
|
||||
// Collapse add/remove deltas into the current primary allowlist state. This
|
||||
// avoids leaving WFP opposite the allowlist when an Add and Remove for the
|
||||
// same IP land in one batch window.
|
||||
ipsToSync := make(map[netip.Addr]struct{}, len(adds)+len(removes))
|
||||
for _, ip := range adds {
|
||||
ipsToSync[ip] = struct{}{}
|
||||
}
|
||||
for _, ip := range removes {
|
||||
ipsToSync[ip] = struct{}{}
|
||||
}
|
||||
|
||||
for ip := range ipsToSync {
|
||||
key := ip.String()
|
||||
allowed := p.allowList != nil && p.allowList.Contains(ip)
|
||||
if !allowed {
|
||||
s.removePermitFilterLocked(p, key)
|
||||
continue
|
||||
}
|
||||
|
||||
if _, exists := s.filterMap[key]; exists {
|
||||
continue // Already has a permit filter.
|
||||
}
|
||||
|
||||
filterID, err := p.addWFPFirewallPermitFilter(s, ip)
|
||||
if err != nil {
|
||||
p.Warn().Err(err).Msgf("Firewall: failed to add WFP permit filter for %s", key)
|
||||
continue
|
||||
}
|
||||
s.filterMap[key] = filterID
|
||||
p.Debug().Msgf("Firewall: added WFP permit filter for %s (ID: %d)", key, filterID)
|
||||
}
|
||||
}
|
||||
|
||||
// removePermitFilterLocked removes one dynamic WFP permit filter. s.mu must be held.
|
||||
func (s *wfpFirewallState) removePermitFilterLocked(p *prog, key string) {
|
||||
filterID, ok := s.filterMap[key]
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
r1, _, _ := procFwpmFilterDeleteById0.Call(s.engineHandle, uintptr(filterID))
|
||||
if r1 != 0 {
|
||||
p.Debug().Msgf("Firewall: failed to remove WFP filter for %s (HRESULT 0x%x, may already be gone)", key, r1)
|
||||
} else {
|
||||
p.Debug().Msgf("Firewall: removed WFP permit filter for %s", key)
|
||||
}
|
||||
delete(s.filterMap, key)
|
||||
}
|
||||
|
||||
// flushAll synchronously removes every dynamic WFP permit filter and clears any
|
||||
// queued batch work. The block-all filters stay installed.
|
||||
func (s *wfpFirewallState) flushAll(p *prog) {
|
||||
s.mu.Lock()
|
||||
if s.batchTimer != nil {
|
||||
s.batchTimer.Stop()
|
||||
s.batchTimer = nil
|
||||
}
|
||||
s.pendingAdds = nil
|
||||
s.pendingRemoves = nil
|
||||
|
||||
filters := make(map[string]uint64, len(s.filterMap))
|
||||
for key, filterID := range s.filterMap {
|
||||
filters[key] = filterID
|
||||
}
|
||||
s.filterMap = make(map[string]uint64)
|
||||
s.mu.Unlock()
|
||||
|
||||
for key, filterID := range filters {
|
||||
r1, _, _ := procFwpmFilterDeleteById0.Call(s.engineHandle, uintptr(filterID))
|
||||
if r1 != 0 {
|
||||
p.Debug().Msgf("Firewall: failed to remove WFP filter for %s during flush (HRESULT 0x%x, may already be gone)", key, r1)
|
||||
} else {
|
||||
p.Debug().Msgf("Firewall: removed WFP permit filter for %s during flush", key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// shutdown removes every WFP filter owned by firewall mode.
|
||||
func (s *wfpFirewallState) shutdown(p *prog) {
|
||||
s.flushAll(p)
|
||||
|
||||
s.mu.Lock()
|
||||
permanentFilters := make(map[string]uint64, len(s.permanentFilterMap))
|
||||
for key, filterID := range s.permanentFilterMap {
|
||||
permanentFilters[key] = filterID
|
||||
}
|
||||
s.permanentFilterMap = make(map[string]uint64)
|
||||
|
||||
blockIDs := []uint64{s.blockFilterIDv4, s.blockFilterIDv6}
|
||||
s.blockFilterIDv4 = 0
|
||||
s.blockFilterIDv6 = 0
|
||||
s.mu.Unlock()
|
||||
|
||||
for key, filterID := range permanentFilters {
|
||||
if r1, _, _ := procFwpmFilterDeleteById0.Call(s.engineHandle, uintptr(filterID)); r1 != 0 {
|
||||
p.Debug().Msgf("Firewall: failed to remove permanent WFP filter for %s during shutdown (HRESULT 0x%x, may already be gone)", key, r1)
|
||||
}
|
||||
}
|
||||
for _, filterID := range blockIDs {
|
||||
if filterID == 0 {
|
||||
continue
|
||||
}
|
||||
if r1, _, _ := procFwpmFilterDeleteById0.Call(s.engineHandle, uintptr(filterID)); r1 != 0 {
|
||||
p.Debug().Msgf("Firewall: failed to remove WFP block filter %d during shutdown (HRESULT 0x%x, may already be gone)", filterID, r1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// populatePermanentFilters mirrors the in-memory permanent allowlist into WFP
|
||||
// permit filters so the base block-all rule does not block ctrld itself, local
|
||||
// network traffic, or upstream resolver endpoints.
|
||||
func (s *wfpFirewallState) populatePermanentFilters(p *prog) {
|
||||
if p.allowList == nil {
|
||||
return
|
||||
}
|
||||
addrs, prefixes := p.allowList.PermanentEntries()
|
||||
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
for _, ip := range addrs {
|
||||
key := "addr:" + ip.String()
|
||||
if _, exists := s.permanentFilterMap[key]; exists {
|
||||
continue
|
||||
}
|
||||
filterID, err := p.addWFPFirewallPermitFilter(s, ip)
|
||||
if err != nil {
|
||||
p.Warn().Err(err).Msgf("Firewall: failed to add permanent WFP permit for %s", ip)
|
||||
continue
|
||||
}
|
||||
s.permanentFilterMap[key] = filterID
|
||||
p.Debug().Msgf("Firewall: added permanent WFP permit for %s (ID: %d)", ip, filterID)
|
||||
}
|
||||
|
||||
for _, prefix := range prefixes {
|
||||
key := "prefix:" + prefix.String()
|
||||
if _, exists := s.permanentFilterMap[key]; exists {
|
||||
continue
|
||||
}
|
||||
filterID, err := p.addWFPFirewallPermitPrefix(s, prefix)
|
||||
if err != nil {
|
||||
p.Warn().Err(err).Msgf("Firewall: failed to add permanent WFP permit for %s", prefix)
|
||||
continue
|
||||
}
|
||||
s.permanentFilterMap[key] = filterID
|
||||
p.Debug().Msgf("Firewall: added permanent WFP permit for %s (ID: %d)", prefix, filterID)
|
||||
}
|
||||
}
|
||||
|
||||
// populateFilters installs permit filters for IPs already present in the allowlist
|
||||
// before WFP callbacks were registered.
|
||||
func (s *wfpFirewallState) populateFilters(p *prog) {
|
||||
if p.allowList == nil {
|
||||
return
|
||||
}
|
||||
ips := p.allowList.AllowedIPs()
|
||||
if len(ips) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
for _, ip := range ips {
|
||||
key := ip.String()
|
||||
if _, exists := s.filterMap[key]; exists {
|
||||
continue
|
||||
}
|
||||
|
||||
filterID, err := p.addWFPFirewallPermitFilter(s, ip)
|
||||
if err != nil {
|
||||
p.Warn().Err(err).Msgf("Firewall: failed to add initial WFP permit filter for %s", key)
|
||||
continue
|
||||
}
|
||||
s.filterMap[key] = filterID
|
||||
p.Debug().Msgf("Firewall: added initial WFP permit filter for %s (ID: %d)", key, filterID)
|
||||
}
|
||||
}
|
||||
|
||||
// addWFPFirewallBlockFilters installs the base block-all outbound filters.
|
||||
// These block ALL non-loopback outbound TCP/UDP traffic. Per-IP permit filters
|
||||
// (added dynamically from the allowlist) override these for resolved IPs.
|
||||
func (p *prog) addWFPFirewallBlockFilters(fwState *wfpFirewallState) error {
|
||||
// Block all outbound IPv4 TCP/UDP.
|
||||
filterName, _ := windows.UTF16PtrFromString("ctrld Firewall Block All IPv4")
|
||||
filter := fwpmFilter0{
|
||||
subLayerKey: ctrldSubLayerGUID,
|
||||
weight: fwpValue0{
|
||||
valueType: fwpUint8, // FWP_UINT8
|
||||
value: 1, // Must be lower than DNS permits (10). Firewall IP permits (5) override this.
|
||||
},
|
||||
action: fwpmAction0{
|
||||
actionType: fwpActionBlock, // FWP_ACTION_BLOCK
|
||||
},
|
||||
layerKey: fwpmLayerALEAuthConnectV4,
|
||||
}
|
||||
filter.displayData.name = filterName
|
||||
|
||||
var filterID uint64
|
||||
r1, _, _ := procFwpmFilterAdd0.Call(
|
||||
fwState.engineHandle,
|
||||
uintptr(unsafe.Pointer(&filter)),
|
||||
0,
|
||||
uintptr(unsafe.Pointer(&filterID)),
|
||||
)
|
||||
if r1 != 0 {
|
||||
return fmt.Errorf("FwpmFilterAdd0 (block IPv4) failed: HRESULT 0x%x", r1)
|
||||
}
|
||||
fwState.blockFilterIDv4 = filterID
|
||||
|
||||
// Block all outbound IPv6 TCP/UDP.
|
||||
filterNameV6, _ := windows.UTF16PtrFromString("ctrld Firewall Block All IPv6")
|
||||
filterV6 := fwpmFilter0{
|
||||
subLayerKey: ctrldSubLayerGUID,
|
||||
weight: fwpValue0{
|
||||
valueType: fwpUint8,
|
||||
value: 1,
|
||||
},
|
||||
action: fwpmAction0{
|
||||
actionType: fwpActionBlock,
|
||||
},
|
||||
layerKey: fwpmLayerALEAuthConnectV6,
|
||||
}
|
||||
filterV6.displayData.name = filterNameV6
|
||||
|
||||
var filterIDv6 uint64
|
||||
r1, _, _ = procFwpmFilterAdd0.Call(
|
||||
fwState.engineHandle,
|
||||
uintptr(unsafe.Pointer(&filterV6)),
|
||||
0,
|
||||
uintptr(unsafe.Pointer(&filterIDv6)),
|
||||
)
|
||||
if r1 != 0 {
|
||||
if fwState.blockFilterIDv4 != 0 {
|
||||
deleteResult, _, _ := procFwpmFilterDeleteById0.Call(fwState.engineHandle, uintptr(fwState.blockFilterIDv4))
|
||||
if deleteResult != 0 {
|
||||
p.Debug().Msgf("Firewall: failed to roll back IPv4 block filter %d after IPv6 setup failure (HRESULT 0x%x)", fwState.blockFilterIDv4, deleteResult)
|
||||
}
|
||||
fwState.blockFilterIDv4 = 0
|
||||
}
|
||||
return fmt.Errorf("FwpmFilterAdd0 (block IPv6) failed: HRESULT 0x%x", r1)
|
||||
}
|
||||
fwState.blockFilterIDv6 = filterIDv6
|
||||
|
||||
p.Info().Msgf("Firewall: WFP block-all filters installed (v4 ID: %d, v6 ID: %d)", filterID, filterIDv6)
|
||||
return nil
|
||||
}
|
||||
|
||||
// addWFPFirewallPermitPrefix adds a WFP permit filter for a permanent CIDR prefix.
|
||||
func (p *prog) addWFPFirewallPermitPrefix(fwState *wfpFirewallState, prefix netip.Prefix) (uint64, error) {
|
||||
prefix = prefix.Masked()
|
||||
if prefix.Addr().Is4() {
|
||||
return p.addWFPFirewallPermitIPv4Prefix(fwState, prefix)
|
||||
}
|
||||
return p.addWFPFirewallPermitIPv6Prefix(fwState, prefix)
|
||||
}
|
||||
|
||||
func (p *prog) addWFPFirewallPermitIPv4Prefix(fwState *wfpFirewallState, prefix netip.Prefix) (uint64, error) {
|
||||
addr4 := prefix.Addr().As4()
|
||||
addr := uint32(addr4[0])<<24 | uint32(addr4[1])<<16 | uint32(addr4[2])<<8 | uint32(addr4[3])
|
||||
bits := prefix.Bits()
|
||||
var mask uint32
|
||||
if bits == 0 {
|
||||
mask = 0
|
||||
} else {
|
||||
mask = ^uint32(0) << uint(32-bits)
|
||||
}
|
||||
addrMask := fwpV4AddrAndMask{addr: addr & mask, mask: mask}
|
||||
|
||||
filterName, _ := windows.UTF16PtrFromString("ctrld Firewall Permit " + prefix.String())
|
||||
condition := fwpmFilterCondition0{
|
||||
fieldKey: fwpmConditionIPRemoteAddress,
|
||||
matchType: fwpMatchEqual,
|
||||
}
|
||||
condition.condValue.valueType = fwpV4AddrMask
|
||||
condition.condValue.value = uint64(uintptr(unsafe.Pointer(&addrMask)))
|
||||
|
||||
filter := fwpmFilter0{
|
||||
subLayerKey: ctrldSubLayerGUID,
|
||||
numFilterConds: 1,
|
||||
filterCondition: (*fwpmFilterCondition0)(unsafe.Pointer(&condition)),
|
||||
weight: fwpValue0{
|
||||
valueType: fwpUint8,
|
||||
value: 5,
|
||||
},
|
||||
action: fwpmAction0{
|
||||
actionType: fwpActionPermit,
|
||||
},
|
||||
layerKey: fwpmLayerALEAuthConnectV4,
|
||||
}
|
||||
filter.displayData.name = filterName
|
||||
|
||||
var filterID uint64
|
||||
r1, _, _ := procFwpmFilterAdd0.Call(
|
||||
fwState.engineHandle,
|
||||
uintptr(unsafe.Pointer(&filter)),
|
||||
0,
|
||||
uintptr(unsafe.Pointer(&filterID)),
|
||||
)
|
||||
runtime.KeepAlive(&addrMask)
|
||||
if r1 != 0 {
|
||||
return 0, fmt.Errorf("FwpmFilterAdd0 (permit IPv4 prefix %s) failed: HRESULT 0x%x", prefix, r1)
|
||||
}
|
||||
return filterID, nil
|
||||
}
|
||||
|
||||
func (p *prog) addWFPFirewallPermitIPv6Prefix(fwState *wfpFirewallState, prefix netip.Prefix) (uint64, error) {
|
||||
addrMask := fwpV6AddrAndMask{addr: prefix.Addr().As16(), prefixLength: uint8(prefix.Bits())}
|
||||
|
||||
filterName, _ := windows.UTF16PtrFromString("ctrld Firewall Permit " + prefix.String())
|
||||
condition := fwpmFilterCondition0{
|
||||
fieldKey: fwpmConditionIPRemoteAddress,
|
||||
matchType: fwpMatchEqual,
|
||||
}
|
||||
condition.condValue.valueType = fwpV6AddrMask
|
||||
condition.condValue.value = uint64(uintptr(unsafe.Pointer(&addrMask)))
|
||||
|
||||
filter := fwpmFilter0{
|
||||
subLayerKey: ctrldSubLayerGUID,
|
||||
numFilterConds: 1,
|
||||
filterCondition: (*fwpmFilterCondition0)(unsafe.Pointer(&condition)),
|
||||
weight: fwpValue0{
|
||||
valueType: fwpUint8,
|
||||
value: 5,
|
||||
},
|
||||
action: fwpmAction0{
|
||||
actionType: fwpActionPermit,
|
||||
},
|
||||
layerKey: fwpmLayerALEAuthConnectV6,
|
||||
}
|
||||
filter.displayData.name = filterName
|
||||
|
||||
var filterID uint64
|
||||
r1, _, _ := procFwpmFilterAdd0.Call(
|
||||
fwState.engineHandle,
|
||||
uintptr(unsafe.Pointer(&filter)),
|
||||
0,
|
||||
uintptr(unsafe.Pointer(&filterID)),
|
||||
)
|
||||
runtime.KeepAlive(&addrMask)
|
||||
if r1 != 0 {
|
||||
return 0, fmt.Errorf("FwpmFilterAdd0 (permit IPv6 prefix %s) failed: HRESULT 0x%x", prefix, r1)
|
||||
}
|
||||
return filterID, nil
|
||||
}
|
||||
|
||||
// addWFPFirewallPermitFilter adds a WFP permit filter for a single IP address.
|
||||
// Returns the filter ID for later removal.
|
||||
func (p *prog) addWFPFirewallPermitFilter(fwState *wfpFirewallState, ip netip.Addr) (uint64, error) {
|
||||
ip = ip.Unmap()
|
||||
|
||||
if ip.Is4() {
|
||||
return p.addWFPFirewallPermitIPv4(fwState, ip)
|
||||
}
|
||||
return p.addWFPFirewallPermitIPv6(fwState, ip)
|
||||
}
|
||||
|
||||
// addWFPFirewallPermitIPv4 adds a WFP permit filter for an IPv4 address.
|
||||
func (p *prog) addWFPFirewallPermitIPv4(fwState *wfpFirewallState, ip netip.Addr) (uint64, error) {
|
||||
addr4 := ip.As4()
|
||||
ipUint32 := uint32(addr4[0])<<24 | uint32(addr4[1])<<16 | uint32(addr4[2])<<8 | uint32(addr4[3])
|
||||
|
||||
filterName, _ := windows.UTF16PtrFromString("ctrld Firewall Permit " + ip.String())
|
||||
|
||||
condition := fwpmFilterCondition0{
|
||||
fieldKey: fwpmConditionIPRemoteAddress,
|
||||
matchType: fwpMatchEqual,
|
||||
}
|
||||
condition.condValue.valueType = fwpUint32
|
||||
condition.condValue.value = uint64(ipUint32)
|
||||
|
||||
filter := fwpmFilter0{
|
||||
subLayerKey: ctrldSubLayerGUID,
|
||||
numFilterConds: 1,
|
||||
filterCondition: (*fwpmFilterCondition0)(unsafe.Pointer(&condition)),
|
||||
weight: fwpValue0{
|
||||
valueType: fwpUint8,
|
||||
value: 5, // Higher than block-all (1), lower than DNS permits (10).
|
||||
},
|
||||
action: fwpmAction0{
|
||||
actionType: fwpActionPermit, // FWP_ACTION_PERMIT
|
||||
},
|
||||
layerKey: fwpmLayerALEAuthConnectV4,
|
||||
}
|
||||
filter.displayData.name = filterName
|
||||
|
||||
var filterID uint64
|
||||
r1, _, _ := procFwpmFilterAdd0.Call(
|
||||
fwState.engineHandle,
|
||||
uintptr(unsafe.Pointer(&filter)),
|
||||
0,
|
||||
uintptr(unsafe.Pointer(&filterID)),
|
||||
)
|
||||
if r1 != 0 {
|
||||
return 0, fmt.Errorf("FwpmFilterAdd0 (permit IPv4 %s) failed: HRESULT 0x%x", ip, r1)
|
||||
}
|
||||
return filterID, nil
|
||||
}
|
||||
|
||||
// addWFPFirewallPermitIPv6 adds a WFP permit filter for an IPv6 address.
|
||||
func (p *prog) addWFPFirewallPermitIPv6(fwState *wfpFirewallState, ip netip.Addr) (uint64, error) {
|
||||
addr16 := ip.As16()
|
||||
|
||||
filterName, _ := windows.UTF16PtrFromString("ctrld Firewall Permit " + ip.String())
|
||||
|
||||
condition := fwpmFilterCondition0{
|
||||
fieldKey: fwpmConditionIPRemoteAddress,
|
||||
matchType: fwpMatchEqual,
|
||||
}
|
||||
condition.condValue.valueType = fwpByteArray16Type
|
||||
condition.condValue.value = uint64(uintptr(unsafe.Pointer(&addr16)))
|
||||
|
||||
filter := fwpmFilter0{
|
||||
subLayerKey: ctrldSubLayerGUID,
|
||||
numFilterConds: 1,
|
||||
filterCondition: (*fwpmFilterCondition0)(unsafe.Pointer(&condition)),
|
||||
weight: fwpValue0{
|
||||
valueType: fwpUint8,
|
||||
value: 5, // Higher than block-all (1), lower than DNS permits (10).
|
||||
},
|
||||
action: fwpmAction0{
|
||||
actionType: fwpActionPermit,
|
||||
},
|
||||
layerKey: fwpmLayerALEAuthConnectV6,
|
||||
}
|
||||
filter.displayData.name = filterName
|
||||
|
||||
var filterID uint64
|
||||
r1, _, _ := procFwpmFilterAdd0.Call(
|
||||
fwState.engineHandle,
|
||||
uintptr(unsafe.Pointer(&filter)),
|
||||
0,
|
||||
uintptr(unsafe.Pointer(&filterID)),
|
||||
)
|
||||
runtime.KeepAlive(addr16)
|
||||
if r1 != 0 {
|
||||
return 0, fmt.Errorf("FwpmFilterAdd0 (permit IPv6 %s) failed: HRESULT 0x%x", ip, r1)
|
||||
}
|
||||
return filterID, nil
|
||||
}
|
||||
+31
-29
@@ -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
|
||||
|
||||
@@ -2,7 +2,6 @@ package cli
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -29,20 +28,5 @@ func TestMain(m *testing.M) {
|
||||
l := zap.New(core)
|
||||
|
||||
mainLog.Store(&ctrld.Logger{Logger: l})
|
||||
|
||||
// Stub the self-upgrade command builder for the whole test binary. The real
|
||||
// builder execs os.Executable() — which under `go test` IS this test binary
|
||||
// — with positional args ("upgrade", ...). `go test` stops flag parsing at
|
||||
// the first positional arg and ignores the rest, so the child just re-runs
|
||||
// the entire suite, hits the upgrade tests again, and spawns more children:
|
||||
// a fork bomb of detached processes that stalls the host and (on Windows)
|
||||
// holds the test binary's image locked, breaking CI artifact cleanup.
|
||||
// Point it at the test binary with a no-match -test.run so any test that
|
||||
// reaches performUpgrade still exercises the cmd.Start() success path while
|
||||
// the child exits immediately without recursing.
|
||||
newUpgradeCmd = func(exe string) *exec.Cmd {
|
||||
return exec.Command(exe, "-test.run=^$")
|
||||
}
|
||||
|
||||
os.Exit(m.Run())
|
||||
}
|
||||
|
||||
+29
-18
@@ -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
|
||||
|
||||
@@ -253,8 +253,6 @@ func Test_performUpgrade(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
// newUpgradeCmd is stubbed in TestMain so performUpgrade does not re-exec
|
||||
// (and fork-bomb) the test binary; see the comment there.
|
||||
for _, tc := range tests {
|
||||
tc := tc
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
|
||||
+6
-79
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
# Firewall Mode
|
||||
|
||||
Firewall mode makes DNS policy unbypassable by blocking outbound connections to any
|
||||
IP that wasn't resolved by ctrld. This closes the "DNS gap" — where apps use hardcoded
|
||||
IPs, direct-IP fallbacks, or alternative DNS resolvers to bypass DNS-based filtering.
|
||||
|
||||
## How It Works
|
||||
|
||||
1. **DNS responses feed the allowlist**: Every successful A/AAAA record resolved by ctrld
|
||||
is added to an in-memory allowlist with TTL-based expiry.
|
||||
|
||||
2. **Outbound connections are checked**: Before any outbound TCP/UDP connection, the
|
||||
destination IP is checked against the allowlist. If it wasn't resolved by ctrld, the
|
||||
connection is blocked.
|
||||
|
||||
3. **Permanent entries are always allowed**: Loopback, RFC1918 private ranges, link-local,
|
||||
CGNAT, multicast, ctrld's own listener, and upstream resolver IPs are always allowed.
|
||||
|
||||
## Configuration
|
||||
|
||||
### TOML Config
|
||||
|
||||
```toml
|
||||
[service]
|
||||
firewall_mode = "on" # "off" (default) or "on"
|
||||
intercept_mode = "hard" # Required on desktop for enforcement
|
||||
```
|
||||
|
||||
### CLI Flag
|
||||
|
||||
```bash
|
||||
ctrld start --firewall-mode on --intercept-mode hard
|
||||
```
|
||||
|
||||
### Remote API
|
||||
|
||||
Firewall mode can be toggled remotely via the ControlD API's `custom_config` field,
|
||||
which is polled by `apiConfigReload()`.
|
||||
|
||||
## Platform-Specific Enforcement
|
||||
|
||||
### macOS (pf)
|
||||
|
||||
When both firewall mode and intercept mode are active, ctrld extends the pf anchor
|
||||
with a `<ctrld_allowed>` table:
|
||||
|
||||
- Default: block all outbound traffic
|
||||
- Pass: traffic to IPs in the `<ctrld_allowed>` table
|
||||
- Pass: traffic to loopback and link-local
|
||||
- Pass: existing DNS intercept rules
|
||||
|
||||
The table is dynamically updated as DNS responses arrive. Updates are batched (200ms
|
||||
accumulation window) to avoid excessive `pfctl` calls.
|
||||
|
||||
### Windows (WFP)
|
||||
|
||||
When both firewall mode and hard intercept mode are active, ctrld extends the WFP
|
||||
sublayer with dynamic permit filters:
|
||||
|
||||
- Base: block all outbound traffic (low-weight filter)
|
||||
- Dynamic: permit filters for each IP in the allowlist
|
||||
- Static: permits for loopback, RFC1918, ctrld listener
|
||||
|
||||
Permit filters are added/removed dynamically as the allowlist changes.
|
||||
|
||||
### Linux and Unsupported Platforms
|
||||
|
||||
Kernel enforcement is not implemented yet. On unsupported platforms, `firewall_mode = "on"` currently fails open: ctrld still records allowlist stats, but it does not block outbound traffic. A warning is logged at startup so this is visible. Future work: iptables/nftables rules or eBPF, and possibly a strict mode that fails closed when platform enforcement is unavailable.
|
||||
|
||||
## Permanently Allowed IPs
|
||||
|
||||
These IPs are always allowed regardless of DNS resolution:
|
||||
|
||||
| Range | Reason |
|
||||
|-------|--------|
|
||||
| `127.0.0.0/8`, `::1` | Loopback — local services |
|
||||
| `10.0.0.0/8` | RFC1918 — LAN, printers, NAS |
|
||||
| `172.16.0.0/12` | RFC1918 — LAN |
|
||||
| `192.168.0.0/16` | RFC1918 — LAN |
|
||||
| `169.254.0.0/16`, `fe80::/10` | Link-local — DHCP, mDNS |
|
||||
| `100.64.0.0/10` | CGNAT — Tailscale, carrier NAT |
|
||||
| `224.0.0.0/4`, `ff00::/8` | Multicast — mDNS, SSDP |
|
||||
| ctrld listener IPs | Self — DNS proxy must be reachable |
|
||||
| Upstream resolver IPs | DoH/DoT/DoQ endpoints |
|
||||
|
||||
## Live Profile Updates
|
||||
|
||||
When a ControlD profile changes (domain goes from allowed → blocked or vice versa):
|
||||
|
||||
1. ctrld's `apiConfigReload()` detects the change
|
||||
2. The entire allowlist is flushed
|
||||
3. Subsequent DNS queries repopulate the allowlist under the new policy
|
||||
4. Brief connectivity interruption (~seconds) while DNS cache repopulates
|
||||
|
||||
This is the "flush and repopulate" strategy — simple and correct, with a small
|
||||
tradeoff of a brief connectivity blip on config changes.
|
||||
|
||||
## Network State Changes
|
||||
|
||||
When the device changes networks (WiFi → cellular, between WiFi networks, etc.):
|
||||
|
||||
1. `monitorNetworkChanges()` detects the transition
|
||||
2. The allowlist is flushed (old IPs may not be routable on new network)
|
||||
3. DNS cache is also flushed (existing behavior)
|
||||
4. Both repopulate naturally from new DNS queries
|
||||
|
||||
## Edge Cases
|
||||
|
||||
### CDN IP Rotation
|
||||
A domain may resolve to different IPs over time. Each resolved IP is added independently
|
||||
with its own TTL. Multiple IPs can coexist for the same domain.
|
||||
|
||||
### CNAME Chains
|
||||
For `foo.com` → CNAME → `bar.cdn.com` → A record, the final A/AAAA IPs are allowlisted
|
||||
and associated with the original query domain (`foo.com`).
|
||||
|
||||
### Short TTLs
|
||||
Some CDNs use 30-second TTLs. The allowlist enforces a minimum TTL of 30 seconds to
|
||||
prevent excessive churn. The background reaper runs every 30 seconds.
|
||||
|
||||
### App Startup Race
|
||||
Apps may attempt connections before their first DNS query reaches ctrld. This is a known
|
||||
limitation. A "learning mode" grace period at startup is a future enhancement.
|
||||
|
||||
### Cached Responses
|
||||
When ctrld serves a response from its DNS cache, the allowlist entries are refreshed.
|
||||
This prevents the case where the DNS cache outlives the allowlist TTL.
|
||||
|
||||
### Long-Lived Connections and Direct-IP Retries
|
||||
Firewall mode learns allowed destinations from DNS responses. If an app keeps a
|
||||
long-lived connection open across a firewall/profile refresh, or retries directly
|
||||
to a previously resolved IP without issuing another DNS query, the reconnect can
|
||||
remain blocked until the app performs DNS resolution again. This is an accepted
|
||||
v1 tradeoff and should be called out in release notes and compatibility testing
|
||||
for common apps.
|
||||
|
||||
## Metrics
|
||||
|
||||
Allowlist stats are logged every 5 minutes:
|
||||
|
||||
```
|
||||
Firewall allowlist stats allowed_ips=142 permanent_ips=18 tracked_domains=89 total_hits=4521 total_misses=23
|
||||
```
|
||||
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Everything is blocked
|
||||
- Check that the upstream resolver IPs are in the permanent allowlist (logged at startup)
|
||||
- Verify DNS is working: `nslookup example.com 127.0.0.1`
|
||||
- Check allowlist stats for hit/miss ratio
|
||||
|
||||
### Certain apps don't work
|
||||
- The app may be using hardcoded IPs (this is the intended behavior — those IPs aren't DNS-resolved)
|
||||
- Check if the app uses a custom DNS resolver that bypasses ctrld
|
||||
- RFC1918 traffic is always allowed, so LAN-only apps should work
|
||||
|
||||
### High miss count
|
||||
- Normal for the first few seconds after startup or network change
|
||||
- Persistent high misses may indicate apps using hardcoded IPs extensively
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user