// 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 }