golib/net/ipcohort/ipcohort.go
AJ ONeal bea4c3de0a
feat(net/ipcohort): IP block/allow cohort filter
IPv4 host + CIDR membership testing backed by sorted /32 binary search
and a linear scan of CIDR ranges. CSV/text loader with bounded reads
(maxBytes is required so callers pick a limit appropriate to their
input source — 1 KB allowlists through 100+ MB threat feeds). Returns
errors for unparseable IPs so callers can decide fail-open vs
fail-closed; partial cohorts are returned alongside parse errors.
2026-05-02 15:41:38 -06:00

265 lines
6.5 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package ipcohort
import (
"cmp"
"encoding/binary"
"encoding/csv"
"errors"
"fmt"
"io"
"net/netip"
"os"
"slices"
"strings"
)
// IPv4Net represents a subnet or single address (/32).
type IPv4Net struct {
networkBE uint32
prefix uint8
}
func NewIPv4Net(ip4be uint32, prefix uint8) IPv4Net {
return IPv4Net{
networkBE: ip4be,
prefix: prefix,
}
}
func (r IPv4Net) Contains(ip uint32) bool {
mask := uint32(0xFFFFFFFF) << (32 - r.prefix)
return (ip & mask) == r.networkBE
}
// Cohort is an immutable, read-only set of IPv4 addresses and subnets.
// Contains is safe for concurrent use without locks.
//
// hosts holds sorted /32 addresses for O(log n) binary search.
// nets holds CIDR ranges (prefix < 32), sorted by networkBE.
//
// Invariant: nets is an anti-chain — no net contains another. Cohort
// sources are expected to ship pre-normalized (a /8 supersedes any /24
// inside it; the /24 is omitted). Under this invariant at most one net
// can match any IP, so Contains does a single binary search + one
// Contains check rather than walking candidates.
type Cohort struct {
hosts []uint32
nets []IPv4Net
}
func sortNets(nets []IPv4Net) {
slices.SortFunc(nets, func(a, b IPv4Net) int {
return cmp.Compare(a.networkBE, b.networkBE)
})
}
// Size returns the total number of entries (hosts + nets).
func (c *Cohort) Size() int {
return len(c.hosts) + len(c.nets)
}
// Contains reports whether ipStr falls within any host or subnet in the cohort.
// Returns an error if ipStr is unparseable; callers decide fail-open vs
// fail-closed for invalid input. IPv6 addresses parse but always return false
// (cohort is IPv4-only).
func (c *Cohort) Contains(ipStr string) (bool, error) {
ip, err := netip.ParseAddr(ipStr)
if err != nil {
return false, fmt.Errorf("parse %q: %w", ipStr, err)
}
return c.ContainsAddr(ip), nil
}
// ContainsAddr reports whether ip falls within any host or subnet in the cohort.
// IPv6 addresses always return false (cohort is IPv4-only).
func (c *Cohort) ContainsAddr(ip netip.Addr) bool {
if !ip.Is4() {
return false
}
ip4 := ip.As4()
ipU32 := binary.BigEndian.Uint32(ip4[:])
if _, found := slices.BinarySearch(c.hosts, ipU32); found {
return true
}
// Under the anti-chain invariant, at most one net can contain ipU32,
// and (when one does) it's the net with the largest networkBE <= ipU32.
// Binary-search for the upper bound, then check the immediate predecessor.
hi, _ := slices.BinarySearchFunc(c.nets, ipU32, func(n IPv4Net, target uint32) int {
if n.networkBE > target {
return 1
}
return -1
})
if hi == 0 {
return false
}
return c.nets[hi-1].Contains(ipU32)
}
// Parse builds a Cohort from a list of IP/CIDR strings. Returns an error
// listing every unparseable entry; the caller decides whether to proceed
// with a partial cohort by inspecting the returned (non-nil) Cohort.
func Parse(prefixList []string) (*Cohort, error) {
var hosts []uint32
var nets []IPv4Net
var errs []error
for i, raw := range prefixList {
ipv4net, err := ParseIPv4(raw)
if err != nil {
errs = append(errs, fmt.Errorf("entry %d %q: %w", i, raw, err))
continue
}
if ipv4net.prefix == 32 {
hosts = append(hosts, ipv4net.networkBE)
} else {
nets = append(nets, ipv4net)
}
}
slices.Sort(hosts)
sortNets(nets)
c := &Cohort{hosts: hosts, nets: nets}
if len(errs) > 0 {
return c, fmt.Errorf("ipcohort.Parse: %d invalid entries: %w",
len(errs), errors.Join(errs...))
}
return c, nil
}
func ParseIPv4(raw string) (ipv4net IPv4Net, err error) {
var ippre netip.Prefix
var ip netip.Addr
if strings.Contains(raw, "/") {
ippre, err = netip.ParsePrefix(raw)
if err != nil {
return ipv4net, err
}
} else {
ip, err = netip.ParseAddr(raw)
if err != nil {
return ipv4net, err
}
ippre = netip.PrefixFrom(ip, 32)
}
addr := ippre.Addr()
if !addr.Is4() {
return ipv4net, fmt.Errorf("not an IPv4 address: %s", raw)
}
ip4 := addr.As4()
prefix := uint8(ippre.Bits()) // 0–32
return NewIPv4Net(
binary.BigEndian.Uint32(ip4[:]),
prefix,
), nil
}
// LoadFile reads path and parses it into a Cohort. On open or read
// errors, returns nil + error. On parse errors only, returns the partial
// cohort + a joined error.
func LoadFile(path string) (*Cohort, error) {
f, err := os.Open(path)
if err != nil {
return nil, fmt.Errorf("could not load %q: %w", path, err)
}
defer func() { _ = f.Close() }()
return ParseCSV(f)
}
// LoadFiles loads and merges multiple files into one Cohort. Useful when
// hosts and networks are stored in separate files.
//
// On open or read errors, returns nil + error. On parse errors only,
// returns the partial cohort + a joined error so callers can choose to
// proceed with what loaded.
func LoadFiles(paths ...string) (*Cohort, error) {
var hosts []uint32
var nets []IPv4Net
var parseErrs []error
for _, path := range paths {
c, err := LoadFile(path)
if c == nil {
return nil, err
}
if err != nil {
parseErrs = append(parseErrs, fmt.Errorf("%s: %w", path, err))
}
hosts = append(hosts, c.hosts...)
nets = append(nets, c.nets...)
}
slices.Sort(hosts)
sortNets(nets)
c := &Cohort{hosts: hosts, nets: nets}
if len(parseErrs) > 0 {
return c, errors.Join(parseErrs...)
}
return c, nil
}
// ParseCSV parses CSV cohort data from r.
func ParseCSV(r io.Reader) (*Cohort, error) {
csvReader := csv.NewReader(r)
csvReader.FieldsPerRecord = -1
return ReadAll(csvReader)
}
// ReadAll reads CSV records from r and builds a Cohort. Returns an error
// listing every unparseable entry (with line number); the returned Cohort
// is non-nil and contains every entry that did parse, so callers can choose
// to proceed with a partial cohort.
func ReadAll(r *csv.Reader) (*Cohort, error) {
var hosts []uint32
var nets []IPv4Net
var errs []error
for {
record, err := r.Read()
if err == io.EOF {
break
}
if err != nil {
return nil, fmt.Errorf("csv read error: %w", err)
}
if len(record) == 0 {
continue
}
raw := strings.TrimSpace(record[0])
if raw == "" || strings.HasPrefix(raw, "#") {
continue
}
ipv4net, err := ParseIPv4(raw)
if err != nil {
line, _ := r.FieldPos(0)
errs = append(errs, fmt.Errorf("line %d %q: %w", line, raw, err))
continue
}
if ipv4net.prefix == 32 {
hosts = append(hosts, ipv4net.networkBE)
} else {
nets = append(nets, ipv4net)
}
}
slices.Sort(hosts)
sortNets(nets)
c := &Cohort{hosts: hosts, nets: nets}
if len(errs) > 0 {
return c, fmt.Errorf("ipcohort.ReadAll: %d invalid entries: %w",
len(errs), errors.Join(errs...))
}
return c, nil
}