golib/net/ippolicy/policy.go
AJ ONeal 07d4a2652c
feat!(ippolicy): share refreshed IP policy behavior
Rename net/ipgate to net/ippolicy and compose independently refreshed
whitelist and blacklist sources into one IP decision policy.

- rename slog group and error prefixes from ipgate to ippolicy
- delete unused ParseDomainSet and RecordReader from domainsetcsv.go
- rewrite README to match the actual Config API
- document nil-whitelist behavior on New()
- bump iplist dependency to v0.8.5
- go mod tidy: remove stale ipgate and iplist entries
2026-09-09 02:55:09 -06:00

157 lines
3.5 KiB
Go

// Package ippolicy composes independently refreshed whitelist and blacklist
// sources into one IP decision policy.
package ippolicy
import (
"context"
"errors"
"fmt"
"net/netip"
"github.com/therootcompany/golib/net/iplist"
)
type Config struct {
Whitelist *iplist.Source
Blacklist *PrefixSet
BlacklistExtra *iplist.Source
OnRefresh func(RefreshEvent)
}
// 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 RefreshKind uint8
const (
RefreshSuccess RefreshKind = iota
RefreshFailure
RefreshFallback
)
func (k RefreshKind) String() string {
switch k {
case RefreshSuccess:
return "success"
case RefreshFailure:
return "failure"
case RefreshFallback:
return "fallback"
default:
return fmt.Sprintf("refresh(%d)", k)
}
}
type RefreshEvent struct {
Kind RefreshKind
Err error
}
type Policy struct {
onRefresh func(RefreshEvent)
whitelist *DomainSet
extra *DomainSet
git *PrefixSet
}
// New creates a Policy from the given sources. The caller is responsible for
// constructing and later closing the *iplist.Source 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{onRefresh: config.OnRefresh}
if config.Whitelist == nil {
p.whitelist = EmptyDomainSet()
p.emit(RefreshFallback, errors.New("whitelist source is required"))
return p
}
p.whitelist = NewDomainSetFromEntries(ctx, config.Whitelist.Entries(), DefaultDomainSetRefreshInterval)
config.Whitelist.Subscribe(func(event iplist.SourceEvent) {
if event.Err != nil {
p.emit(RefreshFailure, event.Err)
return
}
p.whitelist.ReplaceEntries(config.Whitelist.Entries())
p.emit(RefreshSuccess, nil)
})
if config.BlacklistExtra != nil {
p.extra = NewDomainSetFromEntries(ctx, config.BlacklistExtra.Entries(), DefaultDomainSetRefreshInterval)
config.BlacklistExtra.Subscribe(func(event iplist.SourceEvent) {
if event.Err != nil {
p.emit(RefreshFailure, event.Err)
return
}
p.extra.ReplaceEntries(config.BlacklistExtra.Entries())
p.emit(RefreshSuccess, nil)
})
} else {
p.extra = EmptyDomainSet()
}
p.git = config.Blacklist
p.emit(RefreshSuccess, nil)
return p
}
func (p *Policy) Evaluate(addr netip.Addr) Decision {
if p.whitelist == nil {
return Unlisted
}
if p.whitelist.Contains(addr) {
return Whitelisted
}
if (p.extra != nil && p.extra.Contains(addr)) ||
(p.git != nil && p.git.Contains(addr)) {
return Blacklisted
}
return Unlisted
}
func (p *Policy) Close() error {
if p == nil {
return nil
}
if p.whitelist != nil {
_ = p.whitelist.Close()
}
if p.extra != nil {
_ = p.extra.Close()
}
if p.git != nil {
_ = p.git.Close()
}
return nil
}
func (p *Policy) emit(kind RefreshKind, err error) {
if p.onRefresh == nil {
return
}
p.onRefresh(RefreshEvent{Kind: kind, Err: err})
}