initial commit
This commit is contained in:
1 parent
5b935301e5
commit
85453852d5
105 files changed
+24846
No files matched your search
@@ -0,0 +1,422 @@
|
||||
// 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]
|
||||
}
|
||||
Reference in new issue
Block a user