The cache snapshot never loaded, so every restart was a cold cache. Snapshot
packed the whole key while Load decoded the record header out of the key's
remainder instead of the value, so every entry was skipped. The two sides are
now an explicit encodeRecord/decodeRecord pair with a version byte and a CRC-32C
trailer, so a future format change is detected instead of silently misparsed.
Load also drops entries that are already past ExpiresAt and rewrites the TTL to
the time actually left, so a served TTL never jumps back up across a restart.
Blocklist, all six gaps the failing tests were pointing at: a leading UTF-8 BOM
is stripped, "!allow example.com" works, SourceMeta exists for a source that
only ever contributed a rule, Pattern() reports the bare name again (the new
WireName() produces the wildcard form for the API), an RPZ record with no
explicit TTL inherits the zone default instead of whatever the reader left in
the RR, a hosts line with no address in the first field says so, and the
adblock parser handles CRLF and adblock-flavoured lines pasted into a plain
list. TestHolderSwapIsAtomic was asserting the wrong invariant — the swap was
always atomic, a name blocked by the old set is legitimately unblocked by the
new one.
Tests added for acl and resolver, both of which turned up real bugs:
- a PTR suffix matched across label boundaries, so a group scoped to
example.com. also swallowed ample.com. and notexample.com. — a client could
land in a group it does not belong to;
- the "*" catch-all was dropped from the rendered domain patterns, so the
engine's per-query recompile lost the "everything" case;
- Upstream.Success never closed an open breaker, so a recovered upstream stayed
excluded for the full cooldown;
- Exchange fanned out in parallel even under policy: failover.
Deploy set: go.mod requires Go 1.27.1 but CI pinned 1.23 and the Dockerfile
pinned golang:1.23-alpine, so neither could have built. The Dockerfile also
copied the source tree after the console bundle, which clobbered it.
The k8s set asked for hostNetwork with two replicas, but the server never sets
SO_REUSEPORT, so the second pod could not bind :53 and would crash-loop. It now
runs without hostNetwork behind a LoadBalancer Service, with a separate
ClusterIP Service for the Ingress (a headless Service cannot back one), a
ServiceAccount, an API-key Secret, per-pod PVCs, and a kustomization. The HPA
is gone: it cannot drive a StatefulSet. To let the API key come from that
Secret, config.Load now expands ${VAR} / ${VAR:-default} / ${NAME-default} and
fails to start on an unset variable with no default, so a missing secret is a
crash loop rather than an unauthenticated console. Expansion skips YAML
comments, so documenting ${VAR} in the ConfigMap does not break the file.
Verified: gofmt, go build, go vet clean; go test -race ./internal/... green
(acl, app, blocklist, cache, config, resolver, zonefile).
466 lines
13 KiB
Go
466 lines
13 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
|
|
}
|
|
// 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] == '.'
|
|
}
|
|
|
|
// 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)+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]
|
|
}
|