golib/net/ipgate/domains.go
AJ ONeal bb488fe230 fix(net/ipgate): prevent nil cohort by construction, extract cachedCohortValid
Ensure cohort is never nil:
- NewDomainSet stores an empty Cohort before rebuildCohort, so a parse
  failure leaves an empty (non-nil) cohort instead of nil
- Contains uses CompareAndSwap to lazily self-initialise a zero-value
  struct, making nil impossible after first access — no explicit nil
  check needed

Extract the cache-validity check into a testable cachedCohortValid()
helper and add filesPresent() to detect when data files exist on disk.

Use strings.Builder in commaify() for efficiency.

Tests:
- TestPrefixSetContainsZeroValue: zero-value self-heals via CAS,
  cohort is non-nil after first Contains call
- TestCachedCohortValidRequiresFiles: cache invalidation logic
2026-09-08 20:36:43 -06:00

129 lines
2.8 KiB
Go

package ipgate
import (
"context"
"net/netip"
"sync/atomic"
"time"
"github.com/therootcompany/golib/net/dnsresolver"
"github.com/therootcompany/golib/net/ipcohort"
)
const domainSetRefreshInterval = 5 * time.Minute
type DomainSet struct {
staticPrefixes []string
domains []string
resolved atomic.Pointer[map[string][]string]
cohort atomic.Pointer[ipcohort.Cohort]
}
func EmptyDomainSet() *DomainSet {
ds := &DomainSet{}
ds.cohort.Store(&ipcohort.Cohort{})
return ds
}
// NewDomainSet creates a DomainSet from pre-parsed inputs.
// staticPrefixes is a list of CIDRs or bare IPs.
// domains is a list of hostnames to resolve periodically.
func NewDomainSet(ctx context.Context, staticPrefixes []string, domains []string) *DomainSet {
ds := &DomainSet{
staticPrefixes: staticPrefixes,
domains: domains,
}
ds.cohort.Store(&ipcohort.Cohort{})
emptyResolved := make(map[string][]string)
ds.resolved.Store(&emptyResolved)
ds.rebuildCohort()
log().Info("domain set loaded", "static", commaify(len(staticPrefixes)), "domains", commaify(len(domains)))
go ds.refreshLoop(ctx)
return ds
}
func (ds *DomainSet) Contains(addr netip.Addr) bool {
cohort := ds.cohort.Load()
if cohort == nil {
cohort = &ipcohort.Cohort{}
ds.cohort.CompareAndSwap(nil, cohort)
}
return cohort.ContainsAddr(addr)
}
func (ds *DomainSet) refreshLoop(ctx context.Context) {
ds.resolveDomains(ctx)
ds.rebuildCohort()
ticker := time.NewTicker(domainSetRefreshInterval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
ds.resolveDomains(ctx)
ds.rebuildCohort()
}
}
}
func (ds *DomainSet) resolveDomains(ctx context.Context) {
if len(ds.domains) == 0 {
return
}
prev := *ds.resolved.Load()
next := make(map[string][]string, len(ds.domains))
resolver := dnsresolver.New()
for _, domain := range ds.domains {
ips, _, err := resolver.LookupIP(ctx, domain)
if err != nil || len(ips) == 0 {
if old, ok := prev[domain]; ok {
next[domain] = old
log().Warn("resolve failed, keeping prior IPs", "domain", domain, "count", len(old), "err", err)
} else {
log().Warn("resolve failed, no prior data", "domain", domain, "err", err)
}
continue
}
addrs := make([]string, 0, len(ips))
for _, ip := range ips {
addrs = append(addrs, ip.String())
}
next[domain] = addrs
}
ds.resolved.Store(&next)
}
func (ds *DomainSet) rebuildCohort() {
var all []string
all = append(all, ds.staticPrefixes...)
resolved := *ds.resolved.Load()
for _, addrs := range resolved {
for _, addr := range addrs {
all = append(all, addr+"/32")
}
}
cohort, err := ipcohort.Parse(all)
if err != nil {
log().Warn("domain set rebuild failed", "err", err)
}
if cohort != nil {
ds.cohort.Store(cohort)
}
}