initial commit
This commit is contained in:
@@ -0,0 +1,298 @@
|
||||
// Package policy decides what happens to a query based on where it came from.
|
||||
//
|
||||
// A client address is matched to the most specific configured network, the
|
||||
// policies attached to that network are consulted, and the query name is
|
||||
// checked against their allowlists and then their blacklists. Allowlists always
|
||||
// win, so an operator can carve an exception out of a large imported blocklist
|
||||
// without editing it.
|
||||
package policy
|
||||
|
||||
import (
|
||||
"net/netip"
|
||||
"sort"
|
||||
|
||||
"github.com/owen/vibedns/internal/blacklist"
|
||||
"github.com/owen/vibedns/internal/models"
|
||||
)
|
||||
|
||||
// Policy is a compiled policy: an action plus the lists it consults.
|
||||
type Policy struct {
|
||||
ID int64
|
||||
Name string
|
||||
Action models.BlockAction
|
||||
SinkholeV4 netip.Addr
|
||||
SinkholeV6 netip.Addr
|
||||
TTL uint32
|
||||
Blacklists []*blacklist.Set
|
||||
Allowlists []*blacklist.Set
|
||||
}
|
||||
|
||||
// Network is a compiled client network with its policies attached.
|
||||
type Network struct {
|
||||
ID int64
|
||||
Name string
|
||||
Prefix netip.Prefix
|
||||
Policies []*Policy
|
||||
}
|
||||
|
||||
// Index is the immutable policy lookup structure.
|
||||
type Index struct {
|
||||
// networks is sorted most-specific first so the first containing prefix
|
||||
// found is the right one.
|
||||
networks []*Network
|
||||
policies map[int64]*Policy
|
||||
lists map[int64]*blacklist.Set
|
||||
}
|
||||
|
||||
// Decision is the outcome of evaluating a query against the policy set.
|
||||
type Decision struct {
|
||||
Network *Network
|
||||
Policy *Policy
|
||||
|
||||
Blocked bool
|
||||
Allowed bool // an allowlist explicitly permitted the name
|
||||
ListID int64
|
||||
ListName string
|
||||
MatchedDomain string
|
||||
}
|
||||
|
||||
// Action returns the block action to apply, defaulting to NXDOMAIN.
|
||||
func (d Decision) Action() models.BlockAction {
|
||||
if d.Policy == nil || !d.Policy.Action.Valid() {
|
||||
return models.BlockNXDOMAIN
|
||||
}
|
||||
return d.Policy.Action
|
||||
}
|
||||
|
||||
// NetworkID returns the matched network ID, or nil when no network matched.
|
||||
func (d Decision) NetworkID() *int64 {
|
||||
if d.Network == nil {
|
||||
return nil
|
||||
}
|
||||
id := d.Network.ID
|
||||
return &id
|
||||
}
|
||||
|
||||
// NetworkName returns the matched network name, or "".
|
||||
func (d Decision) NetworkName() string {
|
||||
if d.Network == nil {
|
||||
return ""
|
||||
}
|
||||
return d.Network.Name
|
||||
}
|
||||
|
||||
// PolicyID returns the matched policy ID, or nil.
|
||||
func (d Decision) PolicyID() *int64 {
|
||||
if d.Policy == nil {
|
||||
return nil
|
||||
}
|
||||
id := d.Policy.ID
|
||||
return &id
|
||||
}
|
||||
|
||||
// PolicyName returns the matched policy name, or "".
|
||||
func (d Decision) PolicyName() string {
|
||||
if d.Policy == nil {
|
||||
return ""
|
||||
}
|
||||
return d.Policy.Name
|
||||
}
|
||||
|
||||
// ListRef returns the matched list ID, or nil.
|
||||
func (d Decision) ListRef() *int64 {
|
||||
if d.ListID == 0 {
|
||||
return nil
|
||||
}
|
||||
id := d.ListID
|
||||
return &id
|
||||
}
|
||||
|
||||
// Build compiles the policy index from stored configuration.
|
||||
//
|
||||
// lists maps a domain-list ID to its compiled matcher. Sets are shared by
|
||||
// pointer between policies, so a 200,000 domain blocklist used by five
|
||||
// policies is held in memory exactly once.
|
||||
func Build(networks []models.Network, assignments map[int64][]int64,
|
||||
policies []models.Policy, lists map[int64]*blacklist.Set) *Index {
|
||||
|
||||
idx := &Index{
|
||||
policies: make(map[int64]*Policy, len(policies)),
|
||||
lists: lists,
|
||||
}
|
||||
|
||||
for _, mp := range policies {
|
||||
if !mp.Enabled {
|
||||
continue
|
||||
}
|
||||
p := &Policy{
|
||||
ID: mp.ID,
|
||||
Name: mp.Name,
|
||||
Action: mp.BlockAction,
|
||||
TTL: mp.BlockTTL,
|
||||
}
|
||||
if p.TTL == 0 {
|
||||
p.TTL = 60
|
||||
}
|
||||
if a, err := netip.ParseAddr(mp.SinkholeIPv4); err == nil && a.Is4() {
|
||||
p.SinkholeV4 = a
|
||||
}
|
||||
if a, err := netip.ParseAddr(mp.SinkholeIPv6); err == nil && !a.Is4() {
|
||||
p.SinkholeV6 = a
|
||||
}
|
||||
for _, id := range mp.BlacklistIDs {
|
||||
if s, ok := lists[id]; ok && s.Len() > 0 {
|
||||
p.Blacklists = append(p.Blacklists, s)
|
||||
}
|
||||
}
|
||||
for _, id := range mp.AllowlistIDs {
|
||||
if s, ok := lists[id]; ok && s.Len() > 0 {
|
||||
p.Allowlists = append(p.Allowlists, s)
|
||||
}
|
||||
}
|
||||
idx.policies[p.ID] = p
|
||||
}
|
||||
|
||||
for _, mn := range networks {
|
||||
if !mn.Enabled {
|
||||
continue
|
||||
}
|
||||
prefix, err := parsePrefix(mn.CIDR)
|
||||
if err != nil {
|
||||
continue // validation happens on save; skip unusable rows here
|
||||
}
|
||||
n := &Network{ID: mn.ID, Name: mn.Name, Prefix: prefix}
|
||||
for _, pid := range assignments[mn.ID] {
|
||||
if p, ok := idx.policies[pid]; ok {
|
||||
n.Policies = append(n.Policies, p)
|
||||
}
|
||||
}
|
||||
idx.networks = append(idx.networks, n)
|
||||
}
|
||||
|
||||
// Most specific prefix first; ties broken by name for deterministic output.
|
||||
sort.SliceStable(idx.networks, func(i, j int) bool {
|
||||
a, b := idx.networks[i], idx.networks[j]
|
||||
if a.Prefix.Bits() != b.Prefix.Bits() {
|
||||
return a.Prefix.Bits() > b.Prefix.Bits()
|
||||
}
|
||||
return a.Name < b.Name
|
||||
})
|
||||
return idx
|
||||
}
|
||||
|
||||
func parsePrefix(s string) (netip.Prefix, error) {
|
||||
p, err := netip.ParsePrefix(s)
|
||||
if err != nil {
|
||||
addr, aerr := netip.ParseAddr(s)
|
||||
if aerr != nil {
|
||||
return netip.Prefix{}, err
|
||||
}
|
||||
return netip.PrefixFrom(addr.Unmap(), addr.Unmap().BitLen()), nil
|
||||
}
|
||||
return p.Masked(), nil
|
||||
}
|
||||
|
||||
// MatchNetwork returns the most specific network containing addr, or nil.
|
||||
func (idx *Index) MatchNetwork(addr netip.Addr) *Network {
|
||||
if idx == nil {
|
||||
return nil
|
||||
}
|
||||
a := addr.Unmap()
|
||||
for _, n := range idx.networks {
|
||||
if n.Prefix.Addr().Is4() != a.Is4() {
|
||||
continue
|
||||
}
|
||||
if n.Prefix.Contains(a) {
|
||||
return n
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Evaluate decides whether a query from addr for qname should be blocked.
|
||||
//
|
||||
// qname may carry a trailing dot and any casing.
|
||||
func (idx *Index) Evaluate(addr netip.Addr, qname string) Decision {
|
||||
if idx == nil {
|
||||
return Decision{}
|
||||
}
|
||||
n := idx.MatchNetwork(addr)
|
||||
if n == nil || len(n.Policies) == 0 {
|
||||
return Decision{Network: n}
|
||||
}
|
||||
d := Decision{Network: n}
|
||||
|
||||
// Allowlists are consulted across every policy on the network first, so an
|
||||
// exception in one policy cannot be defeated by a blocklist in another.
|
||||
for _, p := range n.Policies {
|
||||
for _, set := range p.Allowlists {
|
||||
if matched, ok := set.Match(qname); ok {
|
||||
d.Allowed = true
|
||||
d.Policy = p
|
||||
d.ListID = set.ID
|
||||
d.ListName = set.Name
|
||||
d.MatchedDomain = matched
|
||||
return d
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, p := range n.Policies {
|
||||
for _, set := range p.Blacklists {
|
||||
if matched, ok := set.Match(qname); ok {
|
||||
d.Blocked = true
|
||||
d.Policy = p
|
||||
d.ListID = set.ID
|
||||
d.ListName = set.Name
|
||||
d.MatchedDomain = matched
|
||||
return d
|
||||
}
|
||||
}
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
// Networks returns the compiled networks, most specific first.
|
||||
func (idx *Index) Networks() []*Network {
|
||||
if idx == nil {
|
||||
return nil
|
||||
}
|
||||
return idx.networks
|
||||
}
|
||||
|
||||
// Sets returns the compiled domain lists keyed by list ID. It backs the
|
||||
// "which lists cover this name?" diagnostic in the UI.
|
||||
func (idx *Index) Sets() map[int64]*blacklist.Set {
|
||||
if idx == nil {
|
||||
return nil
|
||||
}
|
||||
return idx.lists
|
||||
}
|
||||
|
||||
// Stats summarises the compiled index for the dashboard.
|
||||
type Stats struct {
|
||||
Networks int `json:"networks"`
|
||||
Policies int `json:"policies"`
|
||||
Lists int `json:"lists"`
|
||||
BlockedDomains int64 `json:"blocked_domains"`
|
||||
AllowedDomains int64 `json:"allowed_domains"`
|
||||
}
|
||||
|
||||
// Stats computes counts over the compiled index.
|
||||
func (idx *Index) Stats() Stats {
|
||||
s := Stats{}
|
||||
if idx == nil {
|
||||
return s
|
||||
}
|
||||
s.Networks = len(idx.networks)
|
||||
s.Policies = len(idx.policies)
|
||||
s.Lists = len(idx.lists)
|
||||
for _, set := range idx.lists {
|
||||
if set.Kind == models.KindAllowlist {
|
||||
s.AllowedDomains += int64(set.Len())
|
||||
} else {
|
||||
s.BlockedDomains += int64(set.Len())
|
||||
}
|
||||
}
|
||||
return s
|
||||
}
|
||||
Reference in New Issue
Block a user