423 lines
11 KiB
Go
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]
|
|
}
|