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
}
+229
View File
@@ -0,0 +1,229 @@
package policy
import (
"net/netip"
"testing"
"github.com/owen/vibedns/internal/blacklist"
"github.com/owen/vibedns/internal/models"
)
func buildSet(id int64, name, kind string, domains map[string]bool) *blacklist.Set {
b := blacklist.NewBuilder(id, name, kind, len(domains))
for d, sub := range domains {
b.Add(d, sub)
}
return b.Build()
}
// testIndex mirrors the example in the brief: a guest network with three
// blacklists, a secure LAN with malware only, and a global allowlist.
func testIndex(t *testing.T) *Index {
t.Helper()
malware := buildSet(1, "Malware", models.KindBlacklist, map[string]bool{
"evil.example": true,
"c2.example.net": true,
})
adult := buildSet(2, "Adult Content", models.KindBlacklist, map[string]bool{
"adult.example": true,
})
gambling := buildSet(3, "Gambling", models.KindBlacklist, map[string]bool{
"bet.example": true,
})
allow := buildSet(4, "Global Allowlist", models.KindAllowlist, map[string]bool{
"safe.adult.example": false, // exact match only
})
lists := map[int64]*blacklist.Set{1: malware, 2: adult, 3: gambling, 4: allow}
policies := []models.Policy{
{
ID: 10, Name: "Guest Filtering", Enabled: true,
BlockAction: models.BlockNXDOMAIN, BlockTTL: 60,
BlacklistIDs: []int64{1, 2, 3}, AllowlistIDs: []int64{4},
SinkholeIPv4: "0.0.0.0", SinkholeIPv6: "::",
},
{
ID: 11, Name: "Malware Only", Enabled: true,
BlockAction: models.BlockSinkhole, BlockTTL: 30,
BlacklistIDs: []int64{1},
SinkholeIPv4: "192.0.2.1", SinkholeIPv6: "2001:db8::1",
},
{
ID: 12, Name: "Disabled Policy", Enabled: false,
BlockAction: models.BlockRefused, BlacklistIDs: []int64{1, 2, 3},
},
}
networks := []models.Network{
{ID: 100, Name: "Guest Wi-Fi", CIDR: "100.64.30.0/24", Enabled: true},
{ID: 101, Name: "SecureLAN", CIDR: "100.64.10.0/24", Enabled: true},
{ID: 102, Name: "Broad", CIDR: "100.64.0.0/16", Enabled: true},
{ID: 103, Name: "Disabled Net", CIDR: "10.9.0.0/16", Enabled: false},
{ID: 104, Name: "IPv6 LAN", CIDR: "2001:db8:1::/48", Enabled: true},
}
assignments := map[int64][]int64{
100: {10},
101: {11},
102: {12}, // only a disabled policy
103: {10},
104: {11},
}
return Build(networks, assignments, policies, lists)
}
func addr(t *testing.T, s string) netip.Addr {
t.Helper()
a, err := netip.ParseAddr(s)
if err != nil {
t.Fatalf("bad test address %q: %v", s, err)
}
return a
}
func TestMatchNetworkPrefersMostSpecific(t *testing.T) {
idx := testIndex(t)
tests := []struct {
ip string
want string
}{
{"100.64.30.5", "Guest Wi-Fi"}, // /24 beats the enclosing /16
{"100.64.10.5", "SecureLAN"},
{"100.64.99.5", "Broad"}, // only the /16 covers it
{"10.9.0.1", ""}, // network is disabled
{"203.0.113.1", ""}, // no network covers it
{"2001:db8:1::5", "IPv6 LAN"},
}
for _, tc := range tests {
t.Run(tc.ip, func(t *testing.T) {
n := idx.MatchNetwork(addr(t, tc.ip))
got := ""
if n != nil {
got = n.Name
}
if got != tc.want {
t.Errorf("network for %s = %q, want %q", tc.ip, got, tc.want)
}
})
}
}
func TestEvaluateBlocking(t *testing.T) {
idx := testIndex(t)
tests := []struct {
name string
client string
qname string
wantBlocked bool
wantList string
wantAction models.BlockAction
}{
{"guest blocked by malware", "100.64.30.5", "evil.example", true, "Malware", models.BlockNXDOMAIN},
{"guest blocked by adult", "100.64.30.5", "adult.example", true, "Adult Content", models.BlockNXDOMAIN},
{"guest blocked by gambling", "100.64.30.5", "bet.example", true, "Gambling", models.BlockNXDOMAIN},
{"guest subdomain blocked", "100.64.30.5", "www.adult.example", true, "Adult Content", models.BlockNXDOMAIN},
{"guest deep subdomain blocked", "100.64.30.5", "a.b.c.adult.example", true, "Adult Content", models.BlockNXDOMAIN},
{"guest clean name allowed", "100.64.30.5", "example.org", false, "", ""},
{"lan blocked by malware", "100.64.10.5", "evil.example", true, "Malware", models.BlockSinkhole},
{"lan not filtered for adult", "100.64.10.5", "adult.example", false, "", ""},
{"lan not filtered for gambling", "100.64.10.5", "bet.example", false, "", ""},
{"disabled policy filters nothing", "100.64.99.5", "evil.example", false, "", ""},
{"unknown client is unfiltered", "203.0.113.1", "evil.example", false, "", ""},
{"ipv6 client uses its policy", "2001:db8:1::5", "c2.example.net", true, "Malware", models.BlockSinkhole},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
d := idx.Evaluate(addr(t, tc.client), tc.qname)
if d.Blocked != tc.wantBlocked {
t.Fatalf("blocked = %v, want %v", d.Blocked, tc.wantBlocked)
}
if !tc.wantBlocked {
return
}
if d.ListName != tc.wantList {
t.Errorf("list = %q, want %q", d.ListName, tc.wantList)
}
if d.Action() != tc.wantAction {
t.Errorf("action = %q, want %q", d.Action(), tc.wantAction)
}
})
}
}
// TestAllowlistOverridesBlacklist is the rule that lets an operator carve an
// exception out of a large imported blocklist without editing it.
func TestAllowlistOverridesBlacklist(t *testing.T) {
idx := testIndex(t)
client := addr(t, "100.64.30.5")
// safe.adult.example is on the allowlist even though adult.example (and
// therefore all of its subdomains) is blacklisted.
d := idx.Evaluate(client, "safe.adult.example")
if d.Blocked {
t.Errorf("allowlisted name was blocked by %q", d.ListName)
}
if !d.Allowed {
t.Error("expected the decision to record an explicit allow")
}
// The allowlist entry is exact-only, so a sibling stays blocked.
if d := idx.Evaluate(client, "other.adult.example"); !d.Blocked {
t.Error("an exact-only allowlist entry must not cover sibling names")
}
}
func TestQueryNameNormalisation(t *testing.T) {
idx := testIndex(t)
client := addr(t, "100.64.30.5")
for _, name := range []string{"evil.example", "evil.example.", "EVIL.EXAMPLE", "Evil.Example."} {
if d := idx.Evaluate(client, name); !d.Blocked {
t.Errorf("%q was not blocked; names must match regardless of case or trailing dot", name)
}
}
}
func TestIPv4MappedClientAddress(t *testing.T) {
idx := testIndex(t)
// A UDP socket on a dual-stack listener reports IPv4 clients in the
// ::ffff:a.b.c.d form; it must still match an IPv4 network.
mapped := netip.MustParseAddr("::ffff:100.64.30.5")
n := idx.MatchNetwork(mapped)
if n == nil || n.Name != "Guest Wi-Fi" {
t.Errorf("IPv4-mapped address matched %v, want Guest Wi-Fi", n)
}
}
func TestStats(t *testing.T) {
idx := testIndex(t)
s := idx.Stats()
if s.Networks != 4 {
t.Errorf("networks = %d, want 4 enabled", s.Networks)
}
if s.Policies != 2 {
t.Errorf("policies = %d, want 2 enabled", s.Policies)
}
if s.BlockedDomains != 4 {
t.Errorf("blocked domains = %d, want 4", s.BlockedDomains)
}
if s.AllowedDomains != 1 {
t.Errorf("allowed domains = %d, want 1", s.AllowedDomains)
}
}
func TestNilIndexIsSafe(t *testing.T) {
var idx *Index
if d := idx.Evaluate(netip.MustParseAddr("192.0.2.1"), "example.com"); d.Blocked {
t.Error("a nil index must not block anything")
}
if idx.MatchNetwork(netip.MustParseAddr("192.0.2.1")) != nil {
t.Error("a nil index must match no network")
}
}