golib/net/ippolicy/policy.go

200 lines
5.4 KiB
Go

// Package ippolicy composes independently refreshed whitelist and blacklist
// sources into one IP decision policy.
package ippolicy
import (
"context"
"errors"
"fmt"
"net/netip"
"sync"
"time"
"github.com/therootcompany/golib/net/iplist"
)
type Config struct {
Whitelist *iplist.IPList
Blacklist *IPPrefixSet
BlacklistExtra *iplist.IPList
}
// Decision is the mutually exclusive result of evaluating an address.
type Decision uint8
const (
Blacklisted Decision = iota
Unlisted
Whitelisted
)
func (d Decision) String() string {
switch d {
case Blacklisted:
return "blacklisted"
case Unlisted:
return "unlisted"
case Whitelisted:
return "whitelisted"
default:
return fmt.Sprintf("decision(%d)", d)
}
}
type Policy struct {
whitelist *DomainSet
blacklistExtra *DomainSet
blacklist *IPPrefixSet
whitelistSource *iplist.IPList
blacklistExtraSource *iplist.IPList
start sync.Once
cancel context.CancelFunc
loadMu sync.Mutex
}
// New creates a Policy from the given sources. The caller is responsible for
// constructing and later closing the *iplist.IPList values; New does not take
// ownership of them.
//
// A nil Whitelist is treated as an empty whitelist: every address evaluates to
// Unlisted. This also intentionally drops the blacklists, because a policy
// without a whitelist provides no meaningful allow-list semantics — blocking
// would reject everything not on the blacklist rather than only what is on it.
// The caller receives a RefreshFallback event so it can log or alert.
func New(ctx context.Context, config Config) *Policy {
p := &Policy{}
if config.Whitelist == nil {
p.whitelist = EmptyDomainSet()
return p
}
p.whitelistSource = config.Whitelist
p.whitelist = NewDomainSetFromEntries(ctx, sourceEntries(config.Whitelist), DefaultDomainSetRefreshInterval)
if config.BlacklistExtra != nil {
p.blacklistExtraSource = config.BlacklistExtra
p.blacklistExtra = NewDomainSetFromEntries(ctx, sourceEntries(config.BlacklistExtra), DefaultDomainSetRefreshInterval)
} else {
p.blacklistExtra = EmptyDomainSet()
}
p.blacklist = config.Blacklist
return p
}
// Load refreshes policy inputs and updates the derived domain sets. It reads
// the current snapshot of each source IP list and pushes the entries into the
// derived DomainSets (re-resolving DNS for any hostname entries). With
// wait=false it returns after issuing the refreshes; with wait=true it waits
// for each source refresh to complete first.
//
// Sources that refresh in the background (via their own Start ticker) do not
// automatically update the derived sets; call Start on the Policy to run a
// periodic Load(false), or call Load explicitly after a source refresh.
func (p *Policy) Load(ctx context.Context, wait bool) error {
p.loadMu.Lock()
defer p.loadMu.Unlock()
if p.whitelistSource == nil {
return nil
}
if _, err := p.whitelistSource.Load(ctx, wait); err != nil {
return err
}
p.whitelist.ReplaceEntries(sourceEntries(p.whitelistSource))
if p.blacklistExtraSource != nil {
if _, err := p.blacklistExtraSource.Load(ctx, wait); err != nil {
return err
}
p.blacklistExtra.ReplaceEntries(sourceEntries(p.blacklistExtraSource))
}
if p.blacklist != nil {
if _, err := p.blacklist.Load(ctx, wait); err != nil {
return err
}
}
return nil
}
func sourceEntries(source *iplist.IPList) []string {
entries := source.Current()
if entries == nil {
return nil
}
return *entries
}
func (p *Policy) Evaluate(addr netip.Addr) Decision {
if p.whitelist == nil {
return Unlisted
}
if p.whitelist.Contains(addr) {
return Whitelisted
}
if (p.blacklistExtra != nil && p.blacklistExtra.Contains(addr)) ||
(p.blacklist != nil && p.blacklist.Contains(addr)) {
return Blacklisted
}
return Unlisted
}
// Start starts at most one background loop that periodically re-reads the
// source IP lists and pushes their current entries into the derived domain
// sets (re-resolving DNS each time). This closes the gap where a source
// refreshes via its own ticker but the derived DomainSet is never updated
// because nobody calls Load. The interval defaults to
// DefaultDomainSetRefreshInterval when zero.
//
// Start does not start the DomainSets' own DNS refresh loops; the Policy loop
// drives both source re-reads and DNS re-resolution via Load+ReplaceEntries.
func (p *Policy) Start(ctx context.Context, interval time.Duration) {
p.start.Do(func() {
if interval <= 0 {
interval = DefaultDomainSetRefreshInterval
}
loopCtx, cancel := context.WithCancel(ctx)
p.cancel = cancel
go func() {
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-loopCtx.Done():
return
case <-ticker.C:
// Re-read sources and re-resolve derived sets without blocking
// the hot path. Errors are non-fatal: last-good snapshots are
// retained by the sources and DomainSets.
_ = p.Load(loopCtx, false)
}
}
}()
})
}
func (p *Policy) Stop() error {
if p == nil {
return nil
}
if p.cancel != nil {
p.cancel()
}
var errs []error
if p.whitelist != nil {
if err := p.whitelist.Stop(); err != nil {
errs = append(errs, err)
}
}
if p.blacklistExtra != nil {
if err := p.blacklistExtra.Stop(); err != nil {
errs = append(errs, err)
}
}
if p.blacklist != nil {
if err := p.blacklist.Stop(); err != nil {
errs = append(errs, err)
}
}
return errors.Join(errs...)
}