fancy-dns-console is a second binary holding the web UI, authentication and every edit. It writes ClientGroup, BlockList and DNSZone objects through the new kubeedit package and forwards all other /api routes to a running resolver with its own key, dropping the browser's credentials, so it can run in the cluster or on a workstation with a kubeconfig and nothing about the resolver's placement matters. The resolver never imports kubeedit and the console is not on the DNS path. kubeedit validates every spec with the runtime's own converters (kubesource.CheckGroup/CheckList/CheckZone) before writing, so the console cannot store an object the resolver would reject. Zone edits keep the file-based writer's behaviour: duplicate and below-a-delegation records are refused, a delegation in a new zone also gives the zone its servers, and a zone with no apex NS is never stored. Each object records the editing user in an annotation; updates retry on conflict. The resolver reports groups from ClientGroup objects as source "kubernetes" and counter-only rows (the "(default)" bucket, or a group removed since) as "history", and lists DNSZone zones as the editable ones. The console uses that to leave the YAML's groups alone and to drop history rows, which the UI would otherwise send back on save. Its GET /api/clients overlays the cluster's current objects on the resolver's list, because the resolver only sees an edit after its watch fires and a save made just after another would delete the first. An end-to-end test runs both halves against one fake apiserver: a group saved through the console refuses queries, a zone built record by record answers authoritatively, and a request with the resolver's own key is rejected by the console. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
481 lines
14 KiB
Go
481 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
|
|
}
|
|
// 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]
|
|
}
|