feat(net/ipgate): IP allowlist/blocklist via PrefixSet and DomainSet

PrefixSet: git-backed CIDR files via gitshallow, refreshed every 47 min.
DomainSet: takes pre-parsed (staticPrefixes, domains []string); resolves
hostnames every 5 min, retaining stale IPs on failure. Both use
atomic-swapped ipcohort.Cohort for lock-free reads.

ParseDomainSet(RecordReader) parses rows into prefixes and hostnames;
caller constructs the record reader (e.g. csv.Reader with delimiter,
comment char). RecordReader interface is satisfied by *csv.Reader.

commaify uses plain string concat (integers max ~7 groups).
This commit is contained in:
AJ ONeal 2026-05-22 12:23:35 -04:00
parent d472c00ef1
commit dddfdd392c
No known key found for this signature in database
6 changed files with 344 additions and 0 deletions

122
net/ipgate/domains.go Normal file
View File

@ -0,0 +1,122 @@
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,
}
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 {
return ds.cohort.Load().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)
}
}

View File

@ -0,0 +1,61 @@
package ipgate
import (
"fmt"
"io"
"net/netip"
"strings"
)
// RecordReader is satisfied by *csv.Reader.
type RecordReader interface {
Read() (record []string, err error)
}
// ParseDomainSet reads rows from r and returns static CIDR prefixes and
// hostnames to resolve. The first row is skipped when it contains no IP,
// prefix, or dotted hostname (header detection).
func ParseDomainSet(r RecordReader) (staticPrefixes []string, domains []string, err error) {
firstRow := true
for {
record, readErr := r.Read()
if readErr == io.EOF {
break
}
if readErr != nil {
return nil, nil, fmt.Errorf("csv read: %w", readErr)
}
if len(record) == 0 {
continue
}
raw := strings.TrimSpace(record[0])
if firstRow {
firstRow = false
if _, err := netip.ParseAddr(raw); err != nil {
if _, err := netip.ParsePrefix(raw); err != nil {
if !strings.Contains(raw, ".") {
continue
}
}
}
}
if raw == "" {
continue
}
if _, err := netip.ParseAddr(raw); err == nil {
staticPrefixes = append(staticPrefixes, raw+"/32")
continue
}
if _, err := netip.ParsePrefix(raw); err == nil {
staticPrefixes = append(staticPrefixes, raw)
continue
}
domains = append(domains, raw)
}
return staticPrefixes, domains, nil
}

29
net/ipgate/format.go Normal file
View File

@ -0,0 +1,29 @@
package ipgate
import (
"log/slog"
"strconv"
)
func log() *slog.Logger { return slog.Default().WithGroup("ipgate") }
func commaify(n int) string {
s := strconv.Itoa(n)
if n < 0 {
return "-" + commaify(-n)
}
if len(s) <= 3 {
return s
}
rem := len(s) % 3
if rem == 0 {
rem = 3
}
out := s[:rem]
for i := rem; i < len(s); i += 3 {
out += "," + s[i:i+3]
}
return out
}

18
net/ipgate/go.mod Normal file
View File

@ -0,0 +1,18 @@
module github.com/therootcompany/golib/net/ipgate
go 1.26.0
require (
github.com/therootcompany/golib/net/dnsresolver v0.5.0
github.com/therootcompany/golib/net/gitshallow v0.9.1
github.com/therootcompany/golib/net/ipcohort v0.9.0
)
require (
github.com/miekg/dns v1.1.69 // indirect
golang.org/x/mod v0.30.0 // indirect
golang.org/x/net v0.47.0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.38.0 // indirect
golang.org/x/tools v0.39.0 // indirect
)

20
net/ipgate/go.sum Normal file
View File

@ -0,0 +1,20 @@
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/miekg/dns v1.1.69 h1:Kb7Y/1Jo+SG+a2GtfoFUfDkG//csdRPwRLkCsxDG9Sc=
github.com/miekg/dns v1.1.69/go.mod h1:7OyjD9nEba5OkqQ/hB4fy3PIoxafSZJtducccIelz3g=
github.com/therootcompany/golib/net/dnsresolver v0.5.0 h1:JTHvRVjc/cCL6SpWFo6btvv0lllW2wTYjRXLE8cOdMw=
github.com/therootcompany/golib/net/dnsresolver v0.5.0/go.mod h1:tOV99lX6ghpWPksxSFAt4CDe5XLefFzIGf2qXsZJap4=
github.com/therootcompany/golib/net/gitshallow v0.9.1 h1:VI/Q4jViKvVuJ+lzZrD7suhSXLfNNhUcd4ZJrkpTP/Q=
github.com/therootcompany/golib/net/gitshallow v0.9.1/go.mod h1:S/3OK7wMOqJJjP79U9Qw/l3jjEyrb/zsjJ8HAPAX/Fc=
github.com/therootcompany/golib/net/ipcohort v0.9.0 h1:j3AaoW2u4XQJz1ya6fixv3pb3KgxIpZV8t0082/kS3E=
github.com/therootcompany/golib/net/ipcohort v0.9.0/go.mod h1:Ljcnt2xOryVDGIVV5Kzt5hj2LhW4lBAWo5OeeVYU9ys=
golang.org/x/mod v0.30.0 h1:fDEXFVZ/fmCKProc/yAXXUijritrDzahmwwefnjoPFk=
golang.org/x/mod v0.30.0/go.mod h1:lAsf5O2EvJeSFMiBxXDki7sCgAxEUcZHXoXMKT4GJKc=
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/tools v0.39.0 h1:ik4ho21kwuQln40uelmciQPp9SipgNDdrafrYA4TmQQ=
golang.org/x/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ=

94
net/ipgate/ipprefix.go Normal file
View File

@ -0,0 +1,94 @@
package ipgate
import (
"context"
"fmt"
"net/netip"
"os"
"sync/atomic"
"time"
"github.com/therootcompany/golib/net/gitshallow"
"github.com/therootcompany/golib/net/ipcohort"
)
const prefixSetRefreshInterval = 47 * time.Minute
type PrefixSet struct {
repo *gitshallow.Repo
files []string
cohort atomic.Pointer[ipcohort.Cohort]
}
func EmptyPrefixSet() *PrefixSet {
ps := &PrefixSet{}
ps.cohort.Store(&ipcohort.Cohort{})
return ps
}
func NewPrefixSet(ctx context.Context, repoURL, dataPath string, files []string) (*PrefixSet, error) {
if err := os.MkdirAll(dataPath, 0o755); err != nil {
return nil, fmt.Errorf("ipgate: create data dir: %w", err)
}
repo := gitshallow.New(repoURL, dataPath, 0, "")
ps := &PrefixSet{
repo: repo,
files: files,
}
ps.cohort.Store(&ipcohort.Cohort{})
go ps.refreshLoop(ctx)
return ps, nil
}
func (ps *PrefixSet) Contains(addr netip.Addr) bool {
return ps.cohort.Load().ContainsAddr(addr)
}
func (ps *PrefixSet) reload(ctx context.Context) error {
updated, err := ps.repo.Fetch(ctx)
if err != nil {
return err
}
if !updated && ps.cohort.Load().Size() > 0 {
return nil
}
paths := make([]string, len(ps.files))
for i, f := range ps.files {
paths[i] = ps.repo.FilePath(f)
}
cohort, err := ipcohort.LoadFiles(paths...)
if err != nil {
return fmt.Errorf("load files: %w", err)
}
ps.cohort.Store(cohort)
log().Info("prefix set loaded", "entries", commaify(cohort.Size()))
return nil
}
func (ps *PrefixSet) refreshLoop(ctx context.Context) {
if err := ps.reload(ctx); err != nil {
log().Warn("prefix set initial load (will retry)", "err", err)
}
ticker := time.NewTicker(prefixSetRefreshInterval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
if err := ps.reload(ctx); err != nil {
log().Warn("prefix set reload failed", "err", err)
}
}
}
}