Files
fancy-dns/internal/acl/acl.go
T
mats 66f36cfac6
build / build-push (push) Successful in 1m11s
more stuff
2026-10-16 14:49:50 +02:00

487 lines
14 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. The
// slice is owned by the engine and must be treated as read-only.
Upstreams []string
// AllowDomains and DenyDomains are per-group name overrides, using the
// blocklist's wildcard spellings ("*.example.com", "**.example.com"),
// except that both forms also cover the apex: see NewDomainSet.
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 the blocklist's wildcard spellings: "example.com"
// (exact), "*.example.com" and "**.example.com" (the domain plus its
// subdomains), and "*", "**" or "*.*" (everything). Unlike in the blocklist,
// "*.x" and "**.x" are the same rule here: the leading "*" label is dropped,
// so either form covers the apex too. An exact-only rule carries no wildcard.
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: ptrSuffixes(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 {
if e == nil {
return Decision{Policy: PolicyAllow, Default: true}
}
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 matchPTR(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
}
// matchPTR reports whether the reverse name n ends with suffix at a DNS label
// boundary. Both sides are in FQDN form (trailing dot) by the time they get
// here, so "example.com." must not match the client "host.notexample.com.".
func matchPTR(n, suffix string) bool {
if suffix == "" || len(n) < len(suffix) || !strings.HasSuffix(n, suffix) {
return false
}
// A suffix written with its own leading dot (".lan.") is the natural way to
// say "anything under lan" and the console's own hint suggests it. Its dot
// is the label boundary, so there is nothing more to check.
if suffix[0] == '.' {
return true
}
// The suffix has to start a label: it is either the whole name, or the
// character in front of it is a dot.
rest := n[:len(n)-len(suffix)]
return rest == "" || rest[len(rest)-1] == '.'
}
// Match selects clients by network identity. It is the API's spelling of
// config.Match: the same four fields, nested under `match`, so the summary
// the console reads and the group the console writes are the same shape.
type Match struct {
CIDRs []string `json:"cidrs"`
PTRSuffixes []string `json:"ptr_suffixes"`
Tags []string `json:"tags"`
Any bool `json:"any"`
}
// Groups returns the compiled group summaries for the API.
//
// The four match fields are nested under `match` because that is how the
// config file spells a group (config.Group.Match) and therefore how the
// console spells it too: a flat summary made every group card read "matches
// nothing" and the editor write back a shape the server refused.
type GroupSummary struct {
Name string `json:"name"`
Comment string `json:"comment,omitempty"`
Policy string `json:"policy"`
Match Match `json:"match"`
// AllowDomains and DenyDomains stay flat, as in config.Group.
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"`
// Source names where the group is defined when it is not the config
// file: "kubernetes" for a ClientGroup object, "history" for a name that
// only has traffic counters (the "(default)" bucket, or a group removed
// since). Empty means the file.
Source string `json:"source,omitempty"`
}
// 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),
Match: Match{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)+1)
if d.any {
// Keep the catch-all visible alongside the specific rules — dropping
// it would mean the engine's per-query recompile of the rendered
// patterns lost the "everything" case. It sorts first, like any other
// pattern.
out = append(out, "*")
}
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
}
// ptrSuffixes lowercases reverse-name suffixes and normalises them to the
// FQDN form (trailing dot) that Client.Names carries, so matching can rely on
// a label boundary at the end of the suffix.
func ptrSuffixes(in []string) []string {
out := lowerAll(in)
for i, s := range out {
if !strings.HasSuffix(s, ".") {
out[i] = s + "."
}
}
return out
}
// TagsFromName derives candidate tags from a reverse-DNS name: every label
// above the parent domain, so "laptop.lan.example.net" yields laptop and lan.
// 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]
}