mirror of
https://github.com/Control-D-Inc/ctrld.git
synced 2026-09-04 13:36:35 +02:00
Firewall Mode only permits what ctrld resolved, so an approved service addressed by literal IP - with no DNS lookup to observe - is unreachable, and the only workaround was turning the mode off. The API now sends the effective per-org list in destination_ips of every resolver-config response. Apply it as a set rather than as additions: each refresh replaces the previous snapshot, so an entry added upstream takes effect and one removed upstream stops bypassing enforcement. This happens inside the refresh handler before its early returns, so scheduled and forced refreshes both carry it, and without a ctrld reload. Entries carry no TTL and survive the allowlist flushes that follow a profile or network change. Track what the API asked for separately from what pf/WFP accepted, because mirroring can fail and the next refresh - carrying an identical list - would compute no delta to retry. The applied snapshot advances only on success, and the difference is retried by the next refresh and by a reconcile every 5 minutes, reported meanwhile as allowed_destinations_pending. Enforcement coming up replaces the whole set rather than adding to it: the macOS table is a persist table that can still hold what a previous run put there. Enforcement is versioned by a generation advanced under the same lock the mirror is called with, so a maintenance worker outliving its run cannot reinstall permits into enforcement that is gone. macOS keeps the set in a second pf table, <ctrld_allowed_dst>; Windows in per-entry WFP permit filters in their own map - apart from the DNS-resolved entries so a flush of those leaves them installed. Linux is unchanged, the mode already fails open there, and devices with Firewall Mode off are unaffected. Lookups binary search sorted per-family address ranges, so the per-connection hot path stays flat at ~40ns rather than growing with the list. Addresses are logged at debug level only: the list is organization network topology, and Info-level logs are persisted and travel in support bundles. Indirect the refresh's fetch and split its handler out of the fetch loop so both refresh paths are driven end to end in tests without an API server.
595 lines
17 KiB
Go
595 lines
17 KiB
Go
// 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. Alongside it are two sets that no DNS response feeds:
|
|
// permanent entries (loopback, RFC1918, upstreams — added once, never removed) and
|
|
// exceptions, the organization's Allowed Destination IP list, which is replaced as
|
|
// a whole set on every configuration refresh (see exceptions.go).
|
|
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{}
|
|
|
|
// exceptions holds the administratively allowed destinations — the
|
|
// organization's Allowed Destination IP list, delivered by the API on every
|
|
// configuration refresh. Unlike permanent entries they are replaced as a whole
|
|
// set (see SetExceptions), so an entry removed upstream stops being allowed.
|
|
// Held as an immutable index behind an atomic pointer because Contains()
|
|
// reads it on the hot path.
|
|
exceptions atomic.Pointer[exceptionIndex]
|
|
|
|
// exceptionsMu serializes SetExceptions so two concurrent refreshes cannot
|
|
// interleave their compare and store steps. It is separate from mu so
|
|
// replacing the set never blocks DNS-driven allowlist updates.
|
|
exceptionsMu sync.Mutex
|
|
|
|
// 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"`
|
|
// ExceptionPrefixes is the number of administratively allowed destination
|
|
// prefixes (the organization's Allowed Destination IP list).
|
|
ExceptionPrefixes int `json:"exception_prefixes"`
|
|
// 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
|
|
}
|
|
|
|
// Administratively allowed destinations are reachable without ctrld having
|
|
// resolved them, so they are checked before the DNS-driven map.
|
|
if a.containsException(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.ExceptionPrefixes = len(a.exceptionsSnapshot())
|
|
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
|
|
}
|