initial commit

This commit is contained in:
2026-08-16 21:18:45 -05:00
commit 1e05a01bcf
122 changed files with 29178 additions and 0 deletions
+298
View File
@@ -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
}