mirror of
https://github.com/therootcompany/golib.git
synced 2026-08-09 22:10:24 +00:00
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:
parent
d472c00ef1
commit
dddfdd392c
122
net/ipgate/domains.go
Normal file
122
net/ipgate/domains.go
Normal 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)
|
||||
}
|
||||
}
|
||||
|
||||
61
net/ipgate/domainsetcsv.go
Normal file
61
net/ipgate/domainsetcsv.go
Normal 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
29
net/ipgate/format.go
Normal 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
18
net/ipgate/go.mod
Normal 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
20
net/ipgate/go.sum
Normal 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
94
net/ipgate/ipprefix.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user