Files
fancy-dns/internal/acl/acl.go
T
2026-09-27 11:20:34 +00:00

423 lines
11 KiB
Go

// Package acl implements per-client policy: which blocklist rules apply,
// whether the cache may be used, rate limits, and which upstreams to use.
//
// Groups are evaluated in order and the first match wins, so an operator can
// put specific groups above catch-alls. The compiled form is immutable and
// swapped atomically on reload, so lookups need no locks.
package acl
import (
"net/netip"
"sort"
"strings"
"sync/atomic"
"git.k8s-git-k6n.net/mats/fancy-dns/internal/config"
)
// Policy is what a matched group (or the default) allows.
type Policy string
const (
// PolicyAllow resolves everything not blocked by the blocklist.
PolicyAllow Policy = "allow"
// PolicyBlockOnly applies the blocklist but refuses nothing.
PolicyBlockOnly Policy = "block_only"
// PolicyDeny answers REFUSED to everything.
PolicyDeny Policy = "deny"
)
// Decision is the outcome of matching a client.
type Decision struct {
// Group is the matched group name, or "" for the default.
Group string
// Policy is the effective policy.
Policy Policy
// SkipBlocklist lets the client bypass blocklist filtering entirely.
SkipBlocklist bool
// SkipCache forces a fresh resolution for this client.
SkipCache bool
// RateLimit is queries per second; 0 means unlimited.
RateLimit int
// MaxConcurrent caps in-flight resolutions for this client; 0 defers to
// the global per-client cap.
MaxConcurrent int
// LogQueries forces query logging regardless of the sampling rate.
LogQueries bool
// Upstreams restricts this client to specific upstream addresses.
Upstreams []string
// AllowDomains and DenyDomains are per-group name overrides, using the
// same wildcard syntax as the blocklist ("*.example.com").
AllowDomains []string
DenyDomains []string
// Default is true when no group matched.
Default bool
}
// Allows reports whether the client may resolve at all.
func (d Decision) Allows() bool { return d.Policy != PolicyDeny }
// DomainSet is a compiled allow/deny name matcher: a set of exact names plus a
// set of parent domains whose subdomains are covered.
type DomainSet struct {
exact map[string]struct{}
subs map[string]struct{} // parent domain, covers it and its subdomains
// any is set when the set contains a rule matching everything.
any bool
}
// NewDomainSet compiles patterns like "*.example.com" (subdomains only),
// "**.example.com" (subdomains plus the apex) and "example.com" (exact).
func NewDomainSet(patterns []string) *DomainSet {
ds := &DomainSet{
exact: make(map[string]struct{}, len(patterns)),
subs: make(map[string]struct{}, len(patterns)),
}
for _, p := range patterns {
p = strings.ToLower(strings.TrimSpace(p))
if p == "" {
continue
}
switch {
case p == "*" || p == "**" || p == "*.*":
ds.any = true
case strings.HasPrefix(p, "**."):
ds.subs[strings.TrimSuffix(p[3:], ".")] = struct{}{}
case strings.HasPrefix(p, "*."):
// A strict-subdomain pattern still needs the parent for the
// suffix walk; store the parent and accept the apex too, since
// an operator writing "*.example.com" almost always means the
// whole domain. Use "example.com" for an exact-only rule.
ds.subs[strings.TrimSuffix(p[2:], ".")] = struct{}{}
default:
ds.exact[strings.TrimSuffix(p, ".")] = struct{}{}
}
}
return ds
}
// Match reports whether name is covered.
func (d *DomainSet) Match(name string) bool {
if d == nil {
return false
}
if d.any {
return true
}
name = strings.ToLower(strings.TrimSuffix(name, "."))
if name == "" {
return false
}
if _, ok := d.exact[name]; ok {
return true
}
if _, ok := d.subs[name]; ok {
return true
}
for off := labelEnd(name); off < len(name); off = labelEnd(name[off:]) + off {
if _, ok := d.subs[name[off:]]; ok {
return true
}
}
return false
}
func labelEnd(s string) int {
if i := strings.IndexByte(s, '.'); i >= 0 {
return i + 1
}
return len(s)
}
// Len reports how many rules the set holds.
func (d *DomainSet) Len() int {
if d == nil {
return 0
}
return len(d.exact) + len(d.subs)
}
// group is one compiled ACL group.
type group struct {
name string
comment string
policy Policy
any bool
prefixes []netip.Prefix
ptr []string // reverse-name suffixes, lowercase with trailing dot
tags []string
allow *DomainSet
deny *DomainSet
skipBlocklist bool
skipCache bool
rateLimit int
maxConcurrent int
logQueries bool
upstreams []string
}
// Engine is the compiled, hot-swappable ACL table.
type Engine struct {
groups []*group
def Decision
cur atomic.Pointer[Engine]
}
// Compile builds an Engine from configuration. An empty group list produces an
// engine that allows everything, which is the safe default for a fresh install.
func Compile(cfg config.ACL) *Engine {
e := &Engine{
def: Decision{
Policy: Policy(cfg.DefaultPolicy),
RateLimit: cfg.DefaultRateLimit,
Default: true,
},
}
if e.def.Policy == "" {
e.def.Policy = PolicyAllow
}
for i := range cfg.Groups {
g := &cfg.Groups[i]
cg := &group{
name: g.Name,
comment: g.Comment,
policy: Policy(g.Policy),
any: g.Match.Any,
ptr: lowerAll(g.Match.PTRSuffixes),
tags: lowerAll(g.Match.Tags),
allow: NewDomainSet(g.AllowDomains),
deny: NewDomainSet(g.DenyDomains),
skipBlocklist: g.SkipBlocklist,
skipCache: g.SkipCache,
rateLimit: g.RateLimit,
maxConcurrent: g.MaxConcurrent,
logQueries: g.LogQueries,
upstreams: append([]string(nil), g.Upstreams...),
}
if cg.policy == "" {
cg.policy = PolicyAllow
}
for _, c := range g.Match.CIDRs {
if p, err := netip.ParsePrefix(c); err == nil {
cg.prefixes = append(cg.prefixes, p)
}
}
// Sort by prefix length descending so the most specific network wins
// when one group lists overlapping ranges (though first-match-wins
// already handles ordering, this keeps a single group honest).
sort.Slice(cg.prefixes, func(i, j int) bool {
return cg.prefixes[i].Bits() > cg.prefixes[j].Bits()
})
e.groups = append(e.groups, cg)
}
return e
}
// Holder keeps the current Engine for the DNS hot path.
type Holder struct{ cur atomic.Pointer[Engine] }
// NewHolder wraps an engine.
func NewHolder(e *Engine) *Holder {
h := &Holder{}
h.cur.Store(e)
return h
}
// Get returns the current engine.
func (h *Holder) Get() *Engine { return h.cur.Load() }
// Set installs a new engine.
func (h *Holder) Set(e *Engine) { h.cur.Store(e) }
// Client is the identity of a DNS client as far as the ACL is concerned.
type Client struct {
// IP is the client address.
IP netip.Addr
// Names are the reverse-DNS names for the client, lowercased. They come
// from a reverse cache the server maintains, so matching is lock-free.
Names []string
// Tags are extra labels derived from the client identity, e.g. "laptop"
// for laptop.lan.internal.
Tags []string
}
// Match resolves a client to a Decision.
func (e *Engine) Match(c Client) Decision {
if e == nil {
return Decision{Policy: PolicyAllow, Default: true}
}
for _, g := range e.groups {
if !g.matches(c) {
continue
}
return Decision{
Group: g.name,
Policy: g.policy,
SkipBlocklist: g.skipBlocklist,
SkipCache: g.skipCache,
RateLimit: g.rateLimit,
MaxConcurrent: g.maxConcurrent,
LogQueries: g.logQueries,
Upstreams: g.upstreams,
AllowDomains: domainPatterns(g.allow),
DenyDomains: domainPatterns(g.deny),
}
}
return e.defaultDecision()
}
// Default returns the decision for a client that matched nothing.
func (e *Engine) Default() Decision { return e.defaultDecision() }
func (e *Engine) defaultDecision() Decision {
d := e.def
d.Default = true
return d
}
func (g *group) matches(c Client) bool {
if g.any {
return true
}
if len(g.prefixes) > 0 && c.IP.IsValid() {
addr := c.IP.Unmap()
for _, p := range g.prefixes {
if p.Contains(addr) {
return true
}
}
}
if len(g.ptr) > 0 {
for _, n := range c.Names {
for _, suffix := range g.ptr {
if strings.HasSuffix(n, suffix) {
return true
}
}
}
}
if len(g.tags) > 0 {
for _, t := range c.Tags {
for _, want := range g.tags {
if t == want {
return true
}
}
}
}
return false
}
// Groups returns the compiled group summaries for the API.
type GroupSummary struct {
Name string `json:"name"`
Comment string `json:"comment,omitempty"`
Policy string `json:"policy"`
CIDRs []string `json:"cidrs"`
PTRSuffixes []string `json:"ptr_suffixes"`
Tags []string `json:"tags"`
Any bool `json:"any"`
AllowDomains []string `json:"allow_domains"`
DenyDomains []string `json:"deny_domains"`
SkipBlocklist bool `json:"skip_blocklist"`
SkipCache bool `json:"skip_cache"`
RateLimit int `json:"rate_limit"`
MaxConcurrent int `json:"max_concurrent"`
LogQueries bool `json:"log_queries"`
Upstreams []string `json:"upstreams"`
ClientCount int64 `json:"client_count"`
QueryCount uint64 `json:"query_count"`
BlockedCount uint64 `json:"blocked_count"`
// Matched is populated by a dry-run lookup from the API.
Matched bool `json:"matched"`
}
// Summaries renders the engine's groups for the API.
func (e *Engine) Summaries() []GroupSummary {
if e == nil {
return nil
}
out := make([]GroupSummary, 0, len(e.groups))
for _, g := range e.groups {
cidrs := make([]string, 0, len(g.prefixes))
for _, p := range g.prefixes {
cidrs = append(cidrs, p.String())
}
out = append(out, GroupSummary{
Name: g.name,
Comment: g.comment,
Policy: string(g.policy),
CIDRs: cidrs,
PTRSuffixes: g.ptr,
Tags: g.tags,
Any: g.any,
AllowDomains: domainPatterns(g.allow),
DenyDomains: domainPatterns(g.deny),
SkipBlocklist: g.skipBlocklist,
SkipCache: g.skipCache,
RateLimit: g.rateLimit,
MaxConcurrent: g.maxConcurrent,
LogQueries: g.logQueries,
Upstreams: g.upstreams,
})
}
return out
}
// Count reports how many groups are configured.
func (e *Engine) Count() int {
if e == nil {
return 0
}
return len(e.groups)
}
func domainPatterns(d *DomainSet) []string {
if d == nil {
return nil
}
out := make([]string, 0, len(d.exact)+len(d.subs))
for k := range d.exact {
out = append(out, k)
}
for k := range d.subs {
out = append(out, "*."+k)
}
sort.Strings(out)
return out
}
func lowerAll(in []string) []string {
if len(in) == 0 {
return nil
}
out := make([]string, 0, len(in))
for _, s := range in {
s = strings.ToLower(strings.TrimSpace(s))
if s == "" {
continue
}
out = append(out, s)
}
return out
}
// TagsFromName derives candidate tags from a reverse-DNS name, using the
// labels as-is: "laptop.lan.example.net" yields laptop, lan, example, net.
// Matching a group on tag "laptop" then works without any extra
// configuration.
func TagsFromName(name string) []string {
name = strings.ToLower(strings.TrimSuffix(name, "."))
if name == "" {
return nil
}
labels := strings.Split(name, ".")
if len(labels) < 2 {
return labels
}
// Skip the last two labels (the name plus its parent domain).
return labels[:len(labels)-2]
}