// 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 } // 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] }