golib/net/ippolicy/policy.go

266 lines
6.0 KiB
Go

// Package ippolicy composes whitelist and blacklist IP sources into one
// atomically refreshed policy.
package ippolicy
import (
"context"
"fmt"
"net"
"net/http"
"net/netip"
"net/url"
"os"
"sync/atomic"
"time"
"github.com/therootcompany/golib/net/ipgate"
"github.com/therootcompany/golib/net/iplist"
)
const DefaultRefreshInterval = time.Hour + 57*time.Minute + 13*time.Second
type Config struct {
HTTPClient *http.Client
Whitelist string
BlacklistExtra string
BlacklistRepo string
BlacklistRepoDir string
BlacklistRepoFiles []string
CacheDir string
RefreshInterval time.Duration
Resolver *net.Resolver
OnRefresh func(RefreshEvent)
}
type Decision struct {
Whitelisted bool
Blacklisted bool
Blocked bool
GitMatched bool
ExtraMatched bool
}
type RefreshKind string
const (
RefreshSuccess RefreshKind = "success"
RefreshFailure RefreshKind = "failure"
RefreshFallback RefreshKind = "fallback"
)
type RefreshEvent struct {
Kind RefreshKind
Generation uint64
Err error
}
type snapshot struct {
whitelist *ipgate.DomainSet
extra *ipgate.DomainSet
git *ipgate.PrefixSet
generation uint64
}
type Policy struct {
ctx context.Context
cancel context.CancelFunc
config Config
current atomic.Pointer[snapshot]
sequence atomic.Uint64
}
func New(ctx context.Context, config Config) (*Policy, error) {
if config.RefreshInterval <= 0 {
config.RefreshInterval = DefaultRefreshInterval
}
if config.Resolver == nil {
config.Resolver = net.DefaultResolver
}
policyCtx, cancel := context.WithCancel(ctx)
p := &Policy{ctx: policyCtx, cancel: cancel, config: config}
if err := p.refresh(true); err != nil {
cancel()
return nil, err
}
go p.refreshLoop()
return p, nil
}
func (p *Policy) Evaluate(addr netip.Addr) Decision {
current := p.current.Load()
if current == nil {
return Decision{}
}
whitelisted := current.whitelist.Contains(addr)
extraMatched := current.extra != nil && current.extra.Contains(addr)
gitMatched := current.git != nil && current.git.Contains(addr)
blacklisted := extraMatched || gitMatched
return Decision{
Whitelisted: whitelisted,
Blacklisted: blacklisted,
Blocked: blacklisted && !whitelisted,
GitMatched: gitMatched,
ExtraMatched: extraMatched,
}
}
func (p *Policy) Close() error {
if p != nil && p.cancel != nil {
p.cancel()
}
return nil
}
func (p *Policy) refresh(initial bool) error {
whitelist, err := p.load(p.config.Whitelist, false)
if err != nil {
if initial {
p.publish(&snapshot{whitelist: ipgate.EmptyDomainSet()})
p.emit(RefreshFallback, err)
return nil
}
p.emit(RefreshFailure, err)
return err
}
whitelistSet, err := p.build(whitelist)
if err != nil {
return p.failed(initial, err)
}
extra := []string(nil)
var fallbackErr error
if p.config.BlacklistExtra != "" {
extra, err = p.load(p.config.BlacklistExtra, true)
if err != nil {
if !initial {
return p.failed(false, err)
}
fallbackErr = err
}
}
extraSet := ipgate.EmptyDomainSet()
if fallbackErr == nil {
extraSet, err = p.build(extra)
if err != nil {
if !initial {
return p.failed(false, err)
}
fallbackErr = err
}
}
var git *ipgate.PrefixSet
if p.config.BlacklistRepo != "" && p.config.BlacklistRepo != "none" {
files := p.config.BlacklistRepoFiles
if len(files) == 0 {
files = []string{"tables/inbound/single_ips.txt", "tables/inbound/networks.txt"}
}
git, err = ipgate.NewPrefixSet(p.ctx, p.config.BlacklistRepo, p.config.BlacklistRepoDir, files)
if err != nil {
if !initial {
return p.failed(false, err)
}
fallbackErr = err
}
}
p.publish(&snapshot{whitelist: whitelistSet, extra: extraSet, git: git})
if fallbackErr != nil {
p.emit(RefreshFallback, fallbackErr)
} else {
p.emit(RefreshSuccess, nil)
}
return nil
}
func (p *Policy) failed(initial bool, err error) error {
if initial {
p.publish(&snapshot{whitelist: ipgate.EmptyDomainSet()})
p.emit(RefreshFallback, err)
return nil
}
p.emit(RefreshFailure, err)
return err
}
func (p *Policy) load(source string, optional bool) ([]string, error) {
if source == "" {
if optional {
return nil, nil
}
return nil, fmt.Errorf("whitelist source is required")
}
if optional {
parsed, parseErr := url.Parse(source)
isURL := parseErr == nil && (parsed.Scheme == "http" || parsed.Scheme == "https")
if !isURL {
if _, err := os.Stat(source); os.IsNotExist(err) {
return nil, nil
} else if err != nil {
return nil, err
}
}
}
entries, err := iplist.Load(p.ctx, source, p.config.CacheDir, p.config.HTTPClient)
if err != nil {
return nil, err
}
return entries, nil
}
func (p *Policy) build(entries []string) (*ipgate.DomainSet, error) {
prefixes := make([]string, 0, len(entries))
domains := make([]string, 0, len(entries))
for _, entry := range entries {
if net.ParseIP(entry) != nil {
prefixes = append(prefixes, entry)
} else if _, _, err := net.ParseCIDR(entry); err == nil {
prefixes = append(prefixes, entry)
} else {
domains = append(domains, entry)
}
}
for _, domain := range domains {
ips, err := p.config.Resolver.LookupNetIP(p.ctx, "ip", domain)
if err != nil {
return nil, fmt.Errorf("resolve domain %q: %w", domain, err)
}
if len(ips) == 0 {
return nil, fmt.Errorf("resolve domain %q: no addresses", domain)
}
for _, ip := range ips {
prefixes = append(prefixes, ip.String())
}
}
return ipgate.NewDomainSet(p.ctx, prefixes, nil), nil
}
func (p *Policy) publish(next *snapshot) {
next.generation = p.sequence.Add(1)
p.current.Store(next)
}
func (p *Policy) emit(kind RefreshKind, err error) {
if p.config.OnRefresh == nil {
return
}
current := p.current.Load()
var generation uint64
if current != nil {
generation = current.generation
}
p.config.OnRefresh(RefreshEvent{Kind: kind, Generation: generation, Err: err})
}
func (p *Policy) refreshLoop() {
ticker := time.NewTicker(p.config.RefreshInterval)
defer ticker.Stop()
for {
select {
case <-p.ctx.Done():
return
case <-ticker.C:
_ = p.refresh(false)
}
}
}