// 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 }