initial commit

This commit is contained in:
developer committed 2026-09-27 11:20:34 +00:00
1 parent 5b935301e5
commit 85453852d5
105 files changed
+24846

No files matched your search

+422
View File
@@ -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]
}
+623
View File
@@ -0,0 +1,623 @@
// Package api serves the admin REST API, the Prometheus metrics endpoint and
// the embedded web console.
//
// The API is the only component that mutates state, so every handler funnels
// through one of three paths: a read from a live component, a config mutation
// (validate -> write file -> swap -> broadcast), or a targeted runtime action
// (flush cache, refresh lists, reload config).
package api
import (
"context"
"crypto/subtle"
"encoding/json"
"errors"
"fmt"
"log/slog"
"net"
"net/http"
"net/netip"
"os"
"strconv"
"strings"
"sync"
"time"
"git.k8s-git-k6n.net/mats/fancy-dns/internal/config"
)
// Principal is the authenticated identity behind a request.
type Principal struct {
// Mode is "apikey", "basic" or "anonymous".
Mode string
// Name is a human label for the key or user.
Name string
// ReadOnly is true for a read-only API key.
ReadOnly bool
// Loopback is true when the request came from the local machine and
// trust_loopback is on.
Loopback bool
}
// CanWrite reports whether the principal may perform mutations.
func (p Principal) CanWrite() bool { return !p.ReadOnly }
// Hooks are the live components the API reads and drives. They are interfaces
// so the api package does not depend on the engine internals.
type Hooks struct {
// Store is the config store (required).
Store *config.Store
// Logger is the structured logger (required).
Logger *slog.Logger
// Version is the running build's version.
Version string
// StartedAt is the process start time.
StartedAt time.Time
// Dashboard returns the aggregated dashboard payload.
Dashboard func(ctx context.Context, rangeKey string) (any, error)
// BlocklistState returns the compiled blocklist detail.
BlocklistState func() any
// BlocklistStats returns the compiled rule counts.
BlocklistStats func() any
// RefreshBlocklists recompiles the blocklist; empty names means all.
RefreshBlocklists func(ctx context.Context, names []string) error
// ToggleBlocklist enables or disables one source at runtime.
ToggleBlocklist func(name string, enabled bool) error
// DomainCheck explains how a name would be treated.
DomainCheck func(name string) (any, error)
// ListDomains pages the compiled rules.
ListDomains func(q DomainQuery) (any, error)
// AddDomain adds an inline rule and persists it.
AddDomain func(ctx context.Context, in DomainInput) (any, error)
// DeleteDomain removes a rule and persists the change.
DeleteDomain func(ctx context.Context, name, action, list string) error
// GetACL returns the ACL groups.
GetACL func() any
// PutACL validates and persists new ACL groups.
PutACL func(ctx context.Context, groups []config.Group, def string) (int64, error)
// GetUpstream returns the upstream pool state.
GetUpstream func() any
// PutUpstream validates and persists a new upstream config.
PutUpstream func(ctx context.Context, up config.Upstream) (int64, error)
// SearchLog pages the query log.
SearchLog func(ctx context.Context, q LogQuery) (any, error)
// LogStats is the live stats event pushed over SSE.
LogStats func() any
// SubscribeLog streams new log entries; the cancel func must be called.
SubscribeLog func() (<-chan LogEntry, func())
// ClearLog empties the query log.
ClearLog func() error
// CacheStats returns the cache snapshot.
CacheStats func() any
// CacheTop returns the hottest cached names.
CacheTop func(limit int) any
// FlushCache empties the cache.
FlushCache func() int64
// Health returns the health payload for /healthz.
Health func() any
// Status returns the per-component runtime status.
Status func() any
// Flush persists buffered state.
Flush func()
}
// DomainQuery pages the compiled rules.
type DomainQuery struct {
Text string
Action string
List string
Page int
PerPage int
}
// DomainInput adds a rule.
type DomainInput struct {
Name string `json:"name"`
Action string `json:"action"`
List string `json:"list"`
Source string `json:"source"`
Content string `json:"content,omitempty"`
}
// LogQuery pages the query log.
type LogQuery struct {
Limit int
Offset int
Text string
Action string
Client string
RCode int
BlockedOnly bool
Since time.Time
}
// LogEntry is the JSON shape of a query-log row, matching what the DNS
// handlers record.
type LogEntry struct {
ID uint64 `json:"id"`
TS time.Time `json:"ts"`
Client string `json:"client"`
Group string `json:"client_group,omitempty"`
Name string `json:"name"`
QType uint16 `json:"qtype"`
QTypeStr string `json:"qtype_name"`
RCode int `json:"rcode"`
RCodeStr string `json:"rcode_name"`
Action string `json:"action"`
Response string `json:"response,omitempty"`
Duration int64 `json:"duration_ms"`
Upstream string `json:"upstream,omitempty"`
List string `json:"list,omitempty"`
Protocol string `json:"protocol,omitempty"`
Blocked bool `json:"blocked"`
}
// Server is the admin HTTP server.
type Server struct {
hooks Hooks
log *slog.Logger
base string
mux *http.ServeMux
auth *authenticator
trusted []netip.Prefix
// sessions tracks issued session tokens for the basic-auth flow.
sessMu sync.Mutex
sessions map[string]session
// limits tracks per-IP rate limits and lockouts.
limMu sync.Mutex
limits map[string]*limitState
now func() time.Time
}
type session struct {
principal Principal
expires time.Time
}
type limitState struct {
window time.Time
count int
failures int
lockedTill time.Time
}
// New builds the API server.
func New(hooks Hooks) (*Server, error) {
if hooks.Store == nil {
return nil, errors.New("api: Store is required")
}
log := hooks.Logger
if log == nil {
log = slog.Default()
}
cfg := hooks.Store.Get()
s := &Server{
hooks: hooks,
log: log.With("component", "api"),
base: cfg.API.BasePath,
now: time.Now,
sessions: make(map[string]session),
limits: make(map[string]*limitState),
auth: newAuthenticator(cfg.API.Auth),
}
for _, c := range cfg.API.TrustedProxies {
if p, err := netip.ParsePrefix(c); err == nil {
s.trusted = append(s.trusted, p)
}
}
s.mux = s.routes(cfg)
return s, nil
}
// Handler returns the HTTP handler, wrapped with logging and panic recovery.
func (s *Server) Handler() http.Handler {
return s.recoverer(s.cors(s.mux))
}
// routes builds the route table. The stdlib mux handles the path prefix, so
// handlers see paths relative to the base.
func (s *Server) routes(cfg *config.Config) *http.ServeMux {
mux := http.NewServeMux()
// base is the prefix every route is built from, so it must end in a
// slash: "GET " + base + "healthz" has to produce "/api/healthz", not
// "/apihealthz".
base := strings.TrimSuffix(s.base, "/") + "/"
// Unauthenticated: health and metrics are for probes, not operators.
mux.Handle("GET "+base+"healthz", s.public(s.handleHealth))
if cfg.Metrics.Enabled {
// Bound late so the metrics package's init has run by the time the
// handler is invoked; a nil handler means "not wired up" (a test, or
// an embedder that does not import metrics), which must not panic the
// route table.
mux.Handle("GET "+base+"metrics", s.protectMetrics(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if h := metricsHandler(); h != nil {
h.ServeHTTP(w, r)
return
}
http.Error(w, "metrics not available", http.StatusNotImplemented)
})))
}
mux.Handle("POST "+base+"auth/login", s.public(s.handleLogin))
mux.Handle("POST "+base+"auth/logout", s.authed(s.handleLogout, false))
// Everything below requires a principal.
mux.Handle("GET "+base+"dashboard", s.authed(s.handleDashboard, false))
mux.Handle("GET "+base+"status", s.authed(s.handleStatus, false))
mux.Handle("GET "+base+"blocklists", s.authed(s.handleBlocklists, false))
mux.Handle("POST "+base+"blocklists/refresh", s.authed(s.handleBlocklistRefresh, true))
mux.Handle("POST "+base+"blocklists/toggle", s.authed(s.handleBlocklistToggle, true))
mux.Handle("GET "+base+"domains", s.authed(s.handleDomains, false))
mux.Handle("POST "+base+"domains", s.authed(s.handleDomainAdd, true))
mux.Handle("DELETE "+base+"domains/{name...}", s.authed(s.handleDomainDelete, true))
mux.Handle("GET "+base+"domains/check", s.authed(s.handleDomainCheck, false))
mux.Handle("GET "+base+"clients", s.authed(s.handleClients, false))
mux.Handle("PUT "+base+"clients", s.authed(s.handleClientsPut, true))
mux.Handle("GET "+base+"upstreams", s.authed(s.handleUpstreams, false))
mux.Handle("PUT "+base+"upstreams", s.authed(s.handleUpstreamsPut, true))
mux.Handle("GET "+base+"logs", s.authed(s.handleLogs, false))
mux.Handle("GET "+base+"logs/stream", s.authed(s.handleLogStream, false))
mux.Handle("POST "+base+"logs/clear", s.authed(s.handleLogsClear, true))
mux.Handle("GET "+base+"cache", s.authed(s.handleCache, false))
mux.Handle("GET "+base+"cache/top", s.authed(s.handleCacheTop, false))
mux.Handle("POST "+base+"cache/flush", s.authed(s.handleCacheFlush, true))
mux.Handle("GET "+base+"settings", s.authed(s.handleSettingsGet, false))
mux.Handle("PUT "+base+"settings", s.authed(s.handleSettingsPut, true))
mux.Handle("POST "+base+"settings/validate", s.authed(s.handleSettingsValidate, false))
mux.Handle("POST "+base+"settings/reload", s.authed(s.handleSettingsReload, true))
mux.Handle("GET "+base+"whoami", s.authed(s.handleWhoami, false))
return mux
}
// recoverer turns a panic in a handler into a 500 instead of a dropped
// connection, and logs it.
func (s *Server) recoverer(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
if rec := recover(); rec != nil {
s.log.Error("panic in api handler", "path", r.URL.Path, "panic", rec)
writeError(w, http.StatusInternalServerError, "internal error")
}
}()
next.ServeHTTP(w, r)
})
}
// cors adds the configured origins. A wildcard is allowed but echoed per
// request rather than as "*", so credentials keep working.
func (s *Server) cors(next http.Handler) http.Handler {
allowed := map[string]bool{}
wildcard := false
for _, o := range s.hooks.Store.Get().API.CORSOrigins {
if o == "*" {
wildcard = true
}
allowed[o] = true
}
if len(allowed) == 0 && !wildcard {
return next
}
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
origin := r.Header.Get("Origin")
if origin != "" && (wildcard || allowed[origin]) {
w.Header().Set("Access-Control-Allow-Origin", origin)
w.Header().Add("Vary", "Origin")
w.Header().Set("Access-Control-Allow-Credentials", "true")
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, X-API-Key, Authorization")
}
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent)
return
}
next.ServeHTTP(w, r)
})
}
// public marks a handler as reachable without a principal.
func (s *Server) public(h http.HandlerFunc) http.Handler { return h }
// cfg is a small accessor so handlers read the current config once.
// authed wraps a handler with authentication and, for mutations, a write
// permission check.
func (s *Server) authed(h http.HandlerFunc, write bool) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
p, err := s.principal(r)
if err != nil {
if s.hooks.Store.Get().API.Auth.Mode != "none" && !s.cfgTrustLoopback(r) {
writeError(w, http.StatusUnauthorized, "authentication required")
return
}
// auth mode none, or a trusted loopback client: run as a local
// principal.
p = Principal{Mode: "loopback", Name: "loopback", Loopback: true}
}
if write && !p.CanWrite() {
writeError(w, http.StatusForbidden, "this key is read-only")
return
}
if !s.allowRequest(w, r) {
return
}
ctx := context.WithValue(r.Context(), principalKey, p)
h(w, r.WithContext(ctx))
})
}
type ctxKey string
const principalKey ctxKey = "fancydns.principal"
// PrincipalFrom returns the authenticated principal, if any.
func PrincipalFrom(ctx context.Context) (Principal, bool) {
p, ok := ctx.Value(principalKey).(Principal)
return p, ok
}
// principal authenticates a request.
func (s *Server) principal(r *http.Request) (Principal, error) {
// API key in the header wins: it is the machine path.
if key := r.Header.Get("X-API-Key"); key != "" {
return s.auth.apiKey(key)
}
if h := r.Header.Get("Authorization"); h != "" {
scheme, value, ok := strings.Cut(h, " ")
if !ok {
return Principal{}, errors.New("malformed Authorization header")
}
switch strings.ToLower(scheme) {
case "bearer":
return s.auth.session(value)
case "basic":
user, pass, ok := r.BasicAuth()
if !ok {
return Principal{}, errors.New("malformed basic credentials")
}
return s.auth.basic(user, pass)
}
return Principal{}, fmt.Errorf("unsupported auth scheme %q", scheme)
}
if s.cfgTrustLoopback(r) {
return Principal{Mode: "loopback", Name: "loopback", Loopback: true}, nil
}
return Principal{}, errors.New("no credentials")
}
func (s *Server) cfgTrustLoopback(r *http.Request) bool {
cfg := s.hooks.Store.Get()
if !cfg.API.Auth.TrustLoopback {
return false
}
ip := s.clientIP(r)
a, err := netip.ParseAddr(ip)
if err != nil {
return false
}
return a.IsLoopback()
}
// clientIP resolves the client address, honouring X-Forwarded-For only from a
// trusted proxy. Trusting it from anyone would let a client spoof its identity
// past the ACL and the rate limiter.
func (s *Server) clientIP(r *http.Request) string {
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
host = r.RemoteAddr
}
addr, err := netip.ParseAddr(host)
if err != nil {
return host
}
if !s.isTrusted(addr) {
return addr.String()
}
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
// Left-most entry is the original client.
first := strings.TrimSpace(strings.Split(xff, ",")[0])
if a, err := netip.ParseAddr(first); err == nil {
return a.String()
}
}
if xr := r.Header.Get("X-Real-IP"); xr != "" {
if a, err := netip.ParseAddr(strings.TrimSpace(xr)); err == nil {
return a.String()
}
}
return addr.String()
}
func (s *Server) isTrusted(a netip.Addr) bool {
for _, p := range s.trusted {
if p.Contains(a) {
return true
}
}
return false
}
// allowRequest applies the API rate limit per client IP.
func (s *Server) allowRequest(w http.ResponseWriter, r *http.Request) bool {
cfg := s.hooks.Store.Get()
if cfg.API.RateLimit.PerMinute <= 0 {
return true
}
ip := s.clientIP(r)
now := s.now()
s.limMu.Lock()
defer s.limMu.Unlock()
st, ok := s.limits[ip]
if !ok {
st = &limitState{window: now}
s.limits[ip] = st
}
if now.Before(st.lockedTill) {
w.Header().Set("Retry-After", strconv.Itoa(int(st.lockedTill.Sub(now).Seconds())+1))
writeError(w, http.StatusTooManyRequests, "rate limit exceeded")
return false
}
if now.Sub(st.window) >= time.Minute {
st.window = now
st.count = 0
}
st.count++
if st.count > cfg.API.RateLimit.PerMinute {
w.Header().Set("Retry-After", "60")
writeError(w, http.StatusTooManyRequests, "rate limit exceeded")
return false
}
// Opportunistic cleanup keeps the map bounded without a sweeper.
if len(s.limits) > 4096 {
for k, v := range s.limits {
if now.Sub(v.window) > 10*time.Minute {
delete(s.limits, k)
}
}
}
return true
}
// recordLoginFailure locks an IP out after repeated bad logins.
func (s *Server) recordLoginFailure(ip string) {
cfg := s.hooks.Store.Get()
if cfg.API.RateLimit.Failures <= 0 {
return
}
s.limMu.Lock()
defer s.limMu.Unlock()
st, ok := s.limits[ip]
if !ok {
st = &limitState{window: s.now()}
s.limits[ip] = st
}
st.failures++
if st.failures >= cfg.API.RateLimit.Failures {
st.lockedTill = s.now().Add(cfg.API.RateLimit.Lockout.D())
st.failures = 0
}
s.log.Warn("failed admin login", "client", ip)
}
func (s *Server) clearLoginFailures(ip string) {
s.limMu.Lock()
delete(s.limits, ip)
s.limMu.Unlock()
}
// protectMetrics gates /metrics behind an API key when configured.
func (s *Server) protectMetrics(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
cfg := s.hooks.Store.Get()
if !cfg.Metrics.Protect {
next.ServeHTTP(w, r)
return
}
if _, err := s.principal(r); err != nil {
writeError(w, http.StatusUnauthorized, "metrics are protected")
return
}
next.ServeHTTP(w, r)
})
}
// ---- response helpers -------------------------------------------------------
func writeJSON(w http.ResponseWriter, code int, v any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
w.WriteHeader(code)
enc := json.NewEncoder(w)
enc.SetEscapeHTML(false)
if err := enc.Encode(v); err != nil {
// The status line is already sent, so the only useful thing left is a
// truncated-object fallback the client can detect.
_, _ = w.Write([]byte(`{"error":"response encoding failed"}`))
}
}
type apiError struct {
Error string `json:"error"`
Fields []string `json:"fields,omitempty"`
}
func writeError(w http.ResponseWriter, code int, msg string) {
writeJSON(w, code, apiError{Error: msg})
}
func writeErrorFields(w http.ResponseWriter, code int, msg string, fields []string) {
writeJSON(w, code, apiError{Error: msg, Fields: fields})
}
// decodeJSON reads a JSON body with a size cap and strict unknown-field
// rejection, so a typo in a UI payload is an error rather than a silent no-op.
func decodeJSON(r *http.Request, v any) error {
defer func() { _ = r.Body.Close() }()
dec := json.NewDecoder(http.MaxBytesReader(nil, r.Body, 4<<20))
dec.DisallowUnknownFields()
if err := dec.Decode(v); err != nil {
return err
}
return nil
}
func queryInt(r *http.Request, key string, def int) int {
v := r.URL.Query().Get(key)
if v == "" {
return def
}
n, err := strconv.Atoi(v)
if err != nil {
return def
}
return n
}
func queryBool(r *http.Request, key string) bool {
v := strings.ToLower(r.URL.Query().Get(key))
return v == "1" || v == "true" || v == "yes"
}
func queryTime(r *http.Request, key string) time.Time {
v := r.URL.Query().Get(key)
if v == "" {
return time.Time{}
}
if n, err := strconv.ParseInt(v, 10, 64); err == nil {
if n > 1e12 {
return time.UnixMilli(n)
}
return time.Unix(n, 0)
}
if t, err := time.Parse(time.RFC3339, v); err == nil {
return t
}
return time.Time{}
}
func constantTimeEqual(a, b string) bool {
return subtle.ConstantTimeCompare([]byte(a), []byte(b)) == 1
}
func isFileWritable(path string) bool {
if path == "" {
return false
}
f, err := os.OpenFile(path, os.O_WRONLY, 0)
if err != nil {
// A missing file in a writable directory is still writable.
dir := path
if i := strings.LastIndexByte(dir, '/'); i > 0 {
dir = dir[:i]
}
d, derr := os.Stat(dir)
if derr != nil {
return false
}
return d.IsDir()
}
_ = f.Close()
return true
}
+291
View File
@@ -0,0 +1,291 @@
package api
import (
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"encoding/hex"
"errors"
"net/http"
"sync"
"time"
"git.k8s-git-k6n.net/mats/fancy-dns/internal/config"
)
// authenticator checks API keys, basic credentials and issued session tokens.
//
// Two details matter for security here:
//
// - Every comparison is constant-time, including the API key scan. A
// byte-at-a-time comparison leaks a valid key prefix to anyone who can
// measure response latency, which is a real attack on a network-exposed
// admin API.
// - Keys are compared by hash, not by string, so the stored form is not a
// raw key and a timing difference between a long and short key cannot
// reveal length.
type authenticator struct {
mode string
// readOnly maps key hash -> key label.
readOnly map[string]string
// readWrite maps key hash -> key label.
readWrite map[string]string
username string
// passwordHash is the sha256 of the configured password, so plaintext
// passwords in a config file are never compared byte-by-byte.
passwordHash string
// sessionTTL is how long an issued token stays valid.
sessionTTL time.Duration
// trustLoopback is informational; the caller checks it.
mu sync.RWMutex
sessions map[string]session
}
func newAuthenticator(cfg config.Auth) *authenticator {
a := &authenticator{
mode: cfg.Mode,
readOnly: make(map[string]string, len(cfg.ReadOnlyAPIKeys)),
readWrite: make(map[string]string, len(cfg.APIKeys)),
username: cfg.Username,
sessionTTL: cfg.SessionTTL.D(),
sessions: make(map[string]session),
}
for i, k := range cfg.ReadOnlyAPIKeys {
if k == "" {
continue
}
a.readOnly[hashKey(k)] = keyLabel("ro", i)
}
for i, k := range cfg.APIKeys {
if k == "" {
continue
}
a.readWrite[hashKey(k)] = keyLabel("rw", i)
}
if cfg.Password != "" {
// A bcrypt-looking value is already a hash; a plaintext value is
// hashed so comparisons stay constant-time either way.
a.passwordHash = hashKey(cfg.Password)
}
if a.sessionTTL <= 0 {
a.sessionTTL = 12 * time.Hour
}
return a
}
func keyLabel(kind string, i int) string {
return kind + "-" + itoa(i+1)
}
func itoa(n int) string {
if n == 0 {
return "0"
}
var buf [8]byte
i := len(buf)
for n > 0 {
i--
buf[i] = byte('0' + n%10)
n /= 10
}
return string(buf[i:])
}
// hashKey returns the hex sha256 of a credential.
func hashKey(k string) string {
sum := sha256.Sum256([]byte(k))
return hex.EncodeToString(sum[:])
}
// apiKey resolves an API key to a principal.
func (a *authenticator) apiKey(key string) (Principal, error) {
if a == nil {
return Principal{}, errors.New("auth: not configured")
}
h := hashKey(key)
if label, ok := a.readWrite[h]; ok {
return Principal{Mode: "apikey", Name: label}, nil
}
if label, ok := a.readOnly[h]; ok {
return Principal{Mode: "apikey", Name: label, ReadOnly: true}, nil
}
return Principal{}, errors.New("invalid API key")
}
// basic resolves basic credentials and, on success, issues a session token.
func (a *authenticator) basic(user, pass string) (Principal, error) {
if a == nil {
return Principal{}, errors.New("auth: not configured")
}
switch a.mode {
case "basic":
if subtle.ConstantTimeCompare([]byte(user), []byte(a.username)) != 1 {
return Principal{}, errors.New("invalid username")
}
if len(a.passwordHash) == 0 {
return Principal{}, errors.New("no password configured")
}
if subtle.ConstantTimeCompare([]byte(hashKey(pass)), []byte(a.passwordHash)) != 1 {
return Principal{}, errors.New("invalid password")
}
return Principal{Mode: "basic", Name: user}, nil
case "apikey":
// Basic auth with an API key in the password field is a common client
// convention (curl -u any:KEY).
return a.apiKey(pass)
default:
return Principal{}, errors.New("basic auth is not enabled")
}
}
// session validates a bearer token.
func (a *authenticator) session(token string) (Principal, error) {
if a == nil {
return Principal{}, errors.New("auth: not configured")
}
a.mu.RLock()
s, ok := a.sessions[token]
a.mu.RUnlock()
if !ok {
return Principal{}, errors.New("invalid or expired session")
}
if time.Now().After(s.expires) {
a.mu.Lock()
delete(a.sessions, token)
a.mu.Unlock()
return Principal{}, errors.New("session expired")
}
return s.principal, nil
}
// issue creates a session token for a principal.
func (a *authenticator) issue(p Principal) (string, time.Time) {
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
// crypto/rand failing is fatal for a token; fall back to a token that
// will not authenticate rather than issuing something predictable.
return "", time.Time{}
}
token := hex.EncodeToString(b)
exp := time.Now().Add(a.sessionTTL)
a.mu.Lock()
a.sessions[token] = session{principal: p, expires: exp}
// Opportunistic expiry sweep: sessions are few, so a linear pass is fine.
now := time.Now()
for k, v := range a.sessions {
if now.After(v.expires) {
delete(a.sessions, k)
}
}
a.mu.Unlock()
return token, exp
}
// revoke drops a session token.
func (a *authenticator) revoke(token string) {
a.mu.Lock()
delete(a.sessions, token)
a.mu.Unlock()
}
// Config reports the auth mode for the UI.
func (a *authenticator) Config() string {
if a == nil {
return "none"
}
return a.mode
}
// AuthMode returns the configured mode.
func (s *Server) AuthMode() string { return s.auth.Config() }
// handleLogin authenticates a login attempt and returns a session token.
func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
ip := s.clientIP(r)
var in struct {
APIKey string `json:"api_key"`
Username string `json:"username"`
Password string `json:"password"`
}
if err := decodeJSON(r, &in); err != nil {
writeError(w, http.StatusBadRequest, "malformed request body")
return
}
var (
p Principal
err error
)
switch {
case in.APIKey != "":
p, err = s.auth.apiKey(in.APIKey)
case in.Username != "":
p, err = s.auth.basic(in.Username, in.Password)
default:
writeError(w, http.StatusBadRequest, "api_key or username/password required")
return
}
if err != nil {
s.recordLoginFailure(ip)
writeError(w, http.StatusUnauthorized, "invalid credentials")
return
}
s.clearLoginFailures(ip)
token, exp := s.auth.issue(p)
if token == "" {
writeError(w, http.StatusInternalServerError, "could not create session")
return
}
s.log.Info("admin login", "user", p.Name, "mode", p.Mode, "client", ip)
writeJSON(w, http.StatusOK, map[string]any{
"ok": true,
"token": token,
"expires_at": exp,
"name": p.Name,
"read_only": p.ReadOnly,
"mode": p.Mode,
})
}
// handleLogout drops the caller's session.
func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
if h := r.Header.Get("Authorization"); h != "" {
if scheme, value, ok := cut(h, " "); ok && scheme == "Bearer" {
s.auth.revoke(value)
}
}
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
}
func cut(s, sep string) (before, after string, found bool) {
i := indexOf(s, sep)
if i < 0 {
return s, "", false
}
return s[:i], s[i+len(sep):], true
}
func indexOf(s, sub string) int {
if len(sub) > len(s) {
return -1
}
for i := 0; i+len(sub) <= len(s); i++ {
if s[i:i+len(sub)] == sub {
return i
}
}
return -1
}
// handleWhoami reports the caller's identity, which the UI uses to decide
// whether to show write controls.
func (s *Server) handleWhoami(w http.ResponseWriter, r *http.Request) {
p, _ := PrincipalFrom(r.Context())
writeJSON(w, http.StatusOK, map[string]any{
"name": p.Name,
"mode": p.Mode,
"read_only": p.ReadOnly,
"can_write": p.CanWrite(),
"auth_mode": s.AuthMode(),
"base_path": s.base,
})
}
+701
View File
@@ -0,0 +1,701 @@
package api
import (
"encoding/json"
"fmt"
"net/http"
"os"
"sort"
"strings"
"time"
"gopkg.in/yaml.v3"
"git.k8s-git-k6n.net/mats/fancy-dns/internal/config"
)
// metricsHandler is injected by the app so the api package does not import the
// metrics package directly (keeps the dependency one-way).
var metricsHandler func() http.Handler
// SetMetricsHandler installs the /metrics handler.
func SetMetricsHandler(h http.Handler) {
metricsHandler = func() http.Handler { return h }
}
// handleHealth is the liveness/readiness probe. It is unauthenticated on
// purpose, but deliberately reveals only a coarse status.
func (s *Server) handleHealth(w http.ResponseWriter, _ *http.Request) {
h := s.hooks.Health()
code := http.StatusOK
if st, ok := h.(map[string]any); ok {
if v, _ := st["status"].(string); v == "down" {
code = http.StatusServiceUnavailable
}
}
writeJSON(w, code, h)
}
// handleStatus reports the runtime state of every component.
func (s *Server) handleStatus(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{
"version": s.hooks.Version,
"uptime_s": int64(time.Since(s.hooks.StartedAt).Seconds()),
"started": s.hooks.StartedAt,
"config": map[string]any{
"version": s.hooks.Store.Get().Version,
"generation": s.hooks.Store.Generation(),
"path": s.hooks.Store.Path(),
"last_error": s.hooks.Store.LastError(),
"writable": isFileWritable(s.hooks.Store.Path()),
"reload_ready": s.hooks.Store.Path() != "",
},
"components": s.hooks.Status(),
})
}
// handleDashboard serves the aggregated dashboard payload.
func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) {
if s.hooks.Dashboard == nil {
writeError(w, http.StatusNotImplemented, "dashboard not available")
return
}
v, err := s.hooks.Dashboard(r.Context(), r.URL.Query().Get("range"))
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, v)
}
// handleBlocklists serves the compiled blocklist state.
func (s *Server) handleBlocklists(w http.ResponseWriter, _ *http.Request) {
out := map[string]any{}
if s.hooks.BlocklistState != nil {
if m, ok := s.hooks.BlocklistState().(map[string]any); ok {
out = m
}
}
writeJSON(w, http.StatusOK, out)
}
// handleBlocklistRefresh recompiles the blocklists.
func (s *Server) handleBlocklistRefresh(w http.ResponseWriter, r *http.Request) {
if s.hooks.RefreshBlocklists == nil {
writeError(w, http.StatusNotImplemented, "blocklist refresh not available")
return
}
var in struct {
Names []string `json:"names"`
}
// An empty body is valid and means "refresh everything".
if r.ContentLength > 0 {
if err := decodeJSON(r, &in); err != nil {
writeError(w, http.StatusBadRequest, "malformed request body")
return
}
}
if err := s.hooks.RefreshBlocklists(r.Context(), in.Names); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
s.log.Info("blocklist refresh requested", "names", in.Names)
writeJSON(w, http.StatusOK, map[string]any{"started": true, "names": in.Names})
}
// handleBlocklistToggle flips a source's enabled flag.
func (s *Server) handleBlocklistToggle(w http.ResponseWriter, r *http.Request) {
if s.hooks.ToggleBlocklist == nil {
writeError(w, http.StatusNotImplemented, "blocklist toggle not available")
return
}
var in struct {
Name string `json:"name"`
Enabled bool `json:"enabled"`
}
if err := decodeJSON(r, &in); err != nil || in.Name == "" {
writeError(w, http.StatusBadRequest, "name is required")
return
}
if err := s.hooks.ToggleBlocklist(in.Name, in.Enabled); err != nil {
writeError(w, http.StatusNotFound, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "name": in.Name, "enabled": in.Enabled})
}
// handleDomains pages the compiled rules.
func (s *Server) handleDomains(w http.ResponseWriter, r *http.Request) {
if s.hooks.ListDomains == nil {
writeError(w, http.StatusNotImplemented, "domain listing not available")
return
}
q := DomainQuery{
Text: r.URL.Query().Get("q"),
Action: r.URL.Query().Get("action"),
List: r.URL.Query().Get("list"),
Page: queryInt(r, "page", 1),
PerPage: queryInt(r, "per_page", 100),
}
if q.Page < 1 {
q.Page = 1
}
if q.PerPage < 1 || q.PerPage > 1000 {
q.PerPage = 100
}
v, err := s.hooks.ListDomains(q)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, v)
}
// handleDomainCheck explains how a name would be treated, which is the fastest
// way for an operator to debug "why is this not blocked".
func (s *Server) handleDomainCheck(w http.ResponseWriter, r *http.Request) {
if s.hooks.DomainCheck == nil {
writeError(w, http.StatusNotImplemented, "domain check not available")
return
}
name := strings.TrimSpace(r.URL.Query().Get("name"))
if name == "" {
writeError(w, http.StatusBadRequest, "name is required")
return
}
v, err := s.hooks.DomainCheck(name)
if err != nil {
writeJSON(w, http.StatusOK, map[string]any{"name": name, "error": err.Error()})
return
}
writeJSON(w, http.StatusOK, v)
}
// handleDomainAdd adds an inline rule.
func (s *Server) handleDomainAdd(w http.ResponseWriter, r *http.Request) {
if s.hooks.AddDomain == nil {
writeError(w, http.StatusNotImplemented, "domain add not available")
return
}
var in DomainInput
if err := decodeJSON(r, &in); err != nil {
writeError(w, http.StatusBadRequest, "malformed request body")
return
}
if strings.TrimSpace(in.Name) == "" {
writeError(w, http.StatusBadRequest, "name is required")
return
}
v, err := s.hooks.AddDomain(r.Context(), in)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
writeJSON(w, http.StatusOK, v)
}
// handleDomainDelete removes a rule.
func (s *Server) handleDomainDelete(w http.ResponseWriter, r *http.Request) {
if s.hooks.DeleteDomain == nil {
writeError(w, http.StatusNotImplemented, "domain delete not available")
return
}
// The route is {name...} so a rule name containing dots or a wildcard
// prefix survives without percent-encoding gymnastics.
name := r.PathValue("name")
if name == "" {
writeError(w, http.StatusBadRequest, "name is required")
return
}
if err := s.hooks.DeleteDomain(r.Context(), name, r.URL.Query().Get("action"), r.URL.Query().Get("list")); err != nil {
writeError(w, http.StatusNotFound, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "name": name})
}
// handleClients returns the ACL groups.
func (s *Server) handleClients(w http.ResponseWriter, _ *http.Request) {
if s.hooks.GetACL == nil {
writeError(w, http.StatusNotImplemented, "acl not available")
return
}
writeJSON(w, http.StatusOK, s.hooks.GetACL())
}
// aclPayload is the PUT body for the clients endpoint.
type aclPayload struct {
Groups []config.Group `json:"groups"`
DefaultPolicy string `json:"default_policy"`
}
// handleClientsPut validates and persists new ACL groups.
func (s *Server) handleClientsPut(w http.ResponseWriter, r *http.Request) {
if s.hooks.PutACL == nil {
writeError(w, http.StatusNotImplemented, "acl write not available")
return
}
var in aclPayload
if err := decodeJSON(r, &in); err != nil {
writeError(w, http.StatusBadRequest, "malformed request body: "+err.Error())
return
}
version, err := s.hooks.PutACL(r.Context(), in.Groups, in.DefaultPolicy)
if err != nil {
writeErrorFields(w, http.StatusBadRequest, err.Error(), configErrorFields(err))
return
}
s.log.Info("acl updated", "groups", len(in.Groups), "version", version)
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "version": version})
}
// handleUpstreams returns the upstream pool state.
func (s *Server) handleUpstreams(w http.ResponseWriter, _ *http.Request) {
if s.hooks.GetUpstream == nil {
writeError(w, http.StatusNotImplemented, "upstreams not available")
return
}
writeJSON(w, http.StatusOK, s.hooks.GetUpstream())
}
// upstreamPayload is the PUT body for the upstreams endpoint. Health fields
// from the UI are ignored on write: the server owns them.
type upstreamPayload struct {
Policy string `json:"policy"`
Servers []config.UpstreamServer `json:"servers"`
Bootstraps []config.UpstreamBootstrap `json:"bootstraps"`
HealthCheckSeconds float64 `json:"healthcheck_interval_s"`
MaxAttempts int `json:"max_attempts"`
BreakerThreshold int `json:"breaker_threshold"`
}
// handleUpstreamsPut validates and persists a new upstream config.
func (s *Server) handleUpstreamsPut(w http.ResponseWriter, r *http.Request) {
if s.hooks.PutUpstream == nil {
writeError(w, http.StatusNotImplemented, "upstream write not available")
return
}
var in upstreamPayload
if err := decodeJSON(r, &in); err != nil {
writeError(w, http.StatusBadRequest, "malformed request body: "+err.Error())
return
}
// Preserve fields the UI does not manage, so a UI write never silently
// resets a breaker cooldown someone set by hand.
cur := s.hooks.Store.Get().Upstream
up := config.Upstream{
Policy: in.Policy,
Servers: in.Servers,
Bootstraps: in.Bootstraps,
MaxAttempts: in.MaxAttempts,
HealthCheck: config.Duration(in.HealthCheckSeconds * float64(time.Second)),
BreakerThreshold: in.BreakerThreshold,
BreakerCooldown: cur.BreakerCooldown,
}
if up.HealthCheck <= 0 {
up.HealthCheck = cur.HealthCheck
}
if up.BreakerThreshold <= 0 {
up.BreakerThreshold = cur.BreakerThreshold
}
version, err := s.hooks.PutUpstream(r.Context(), up)
if err != nil {
writeErrorFields(w, http.StatusBadRequest, err.Error(), configErrorFields(err))
return
}
s.log.Info("upstreams updated", "servers", len(up.Servers), "version", version)
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "version": version})
}
// handleLogs pages the query log.
func (s *Server) handleLogs(w http.ResponseWriter, r *http.Request) {
if s.hooks.SearchLog == nil {
writeError(w, http.StatusNotImplemented, "query log not available")
return
}
q := LogQuery{
Limit: queryInt(r, "limit", 200),
Offset: queryInt(r, "offset", 0),
Text: r.URL.Query().Get("q"),
Action: r.URL.Query().Get("action"),
Client: r.URL.Query().Get("client"),
RCode: queryInt(r, "rcode", 0),
BlockedOnly: queryBool(r, "blocked"),
Since: queryTime(r, "since"),
}
v, err := s.hooks.SearchLog(r.Context(), q)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, v)
}
// handleLogStream is the Server-Sent Events endpoint feeding the live view.
func (s *Server) handleLogStream(w http.ResponseWriter, r *http.Request) {
flusher, ok := w.(http.Flusher)
if !ok {
writeError(w, http.StatusInternalServerError, "streaming unsupported")
return
}
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("Connection", "keep-alive")
// Without this, a reverse proxy buffers the stream and the UI shows
// nothing until the buffer fills.
w.Header().Set("X-Accel-Buffering", "no")
w.WriteHeader(http.StatusOK)
flusher.Flush()
send := func(event string, v any) bool {
b, err := json.Marshal(v)
if err != nil {
return true
}
// A single-line data field is what the SSE grammar requires, so the
// payload is compact JSON with no newlines (json.Marshal never emits
// a raw newline).
if _, err := fmt.Fprintf(w, "event: %s\ndata: %s\n\n", event, b); err != nil {
return false
}
flusher.Flush()
return true
}
// An initial event tells the client the stream is live, so the UI can show
// a "connected" state before any query happens.
if !send("ready", map[string]any{"at": time.Now(), "version": s.hooks.Version}) {
return
}
var (
entries <-chan LogEntry
cancel func()
)
if s.hooks.SubscribeLog != nil {
entries, cancel = s.hooks.SubscribeLog()
defer cancel()
}
stats := time.NewTicker(time.Second)
defer stats.Stop()
ping := time.NewTicker(20 * time.Second)
defer ping.Stop()
ctx := r.Context()
for {
select {
case <-ctx.Done():
return
case e, ok := <-entries:
if !ok {
return
}
if !send("entry", e) {
return
}
case <-stats.C:
if s.hooks.LogStats == nil {
continue
}
if !send("stats", s.hooks.LogStats()) {
return
}
case <-ping.C:
// A comment frame keeps proxies from timing the stream out.
if _, err := fmt.Fprint(w, ": ping\n\n"); err != nil {
return
}
flusher.Flush()
}
}
}
// handleLogsClear empties the query log.
func (s *Server) handleLogsClear(w http.ResponseWriter, _ *http.Request) {
if s.hooks.ClearLog == nil {
writeError(w, http.StatusNotImplemented, "query log not available")
return
}
if err := s.hooks.ClearLog(); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
}
// handleCache returns the cache snapshot.
func (s *Server) handleCache(w http.ResponseWriter, _ *http.Request) {
if s.hooks.CacheStats == nil {
writeError(w, http.StatusNotImplemented, "cache not available")
return
}
writeJSON(w, http.StatusOK, s.hooks.CacheStats())
}
// handleCacheTop returns the hottest cached names.
func (s *Server) handleCacheTop(w http.ResponseWriter, r *http.Request) {
if s.hooks.CacheTop == nil {
writeError(w, http.StatusNotImplemented, "cache not available")
return
}
writeJSON(w, http.StatusOK, map[string]any{
"items": s.hooks.CacheTop(queryInt(r, "limit", 50)),
})
}
// handleCacheFlush empties the cache.
func (s *Server) handleCacheFlush(w http.ResponseWriter, _ *http.Request) {
if s.hooks.FlushCache == nil {
writeError(w, http.StatusNotImplemented, "cache not available")
return
}
n := s.hooks.FlushCache()
s.log.Info("cache flushed", "entries", n)
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "entries": n})
}
// handleSettingsGet returns the config document plus what is knowable about it.
func (s *Server) handleSettingsGet(w http.ResponseWriter, _ *http.Request) {
path := s.hooks.Store.Path()
var fileYAML string
if path != "" {
if b, err := os.ReadFile(path); err == nil {
fileYAML = string(b)
}
}
cur := s.hooks.Store.Get()
effective, err := config.Marshal(cur)
if err != nil {
writeError(w, http.StatusInternalServerError, "cannot render config: "+err.Error())
return
}
defaults, _ := config.Marshal(config.Default())
if fileYAML == "" {
// Running without a file: show the effective document as the editable
// text so the UI still works.
fileYAML = string(effective)
}
writeJSON(w, http.StatusOK, map[string]any{
"version": cur.Version,
"path": path,
"config_yaml": fileYAML,
"effective_yaml": string(effective),
"defaults_yaml": string(defaults),
"last_error": s.hooks.Store.LastError(),
"reload_available": path != "",
"writable": isFileWritable(path),
})
}
// settingsPayload is the PUT body for the settings endpoint.
type settingsPayload struct {
ConfigYAML string `json:"config_yaml"`
}
// handleSettingsPut validates, writes and swaps a new config.
func (s *Server) handleSettingsPut(w http.ResponseWriter, r *http.Request) {
cfg := s.hooks.Store.Get()
if !cfg.API.AllowConfigWrite {
writeError(w, http.StatusForbidden, "config writes are disabled")
return
}
var in settingsPayload
if err := decodeJSON(r, &in); err != nil {
writeError(w, http.StatusBadRequest, "malformed request body")
return
}
if strings.TrimSpace(in.ConfigYAML) == "" {
writeError(w, http.StatusBadRequest, "config_yaml is empty")
return
}
next, err := parseConfigYAML(in.ConfigYAML)
if err != nil {
writeError(w, http.StatusBadRequest, "cannot parse config: "+err.Error())
return
}
// Relative paths in the document resolve against the config's directory,
// so a file written from a different cwd still finds its zone files.
next.WorkingDirOrSet(cfg.Server.WorkingDir)
if err := next.Validate(); err != nil {
writeErrorFields(w, http.StatusBadRequest, "invalid config: "+err.Error(), configErrorFields(err))
return
}
if err := s.commit(next); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
s.log.Info("config updated via api", "version", next.Version)
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "version": next.Version, "warnings": []string{}})
}
// handleSettingsValidate checks a document without applying it.
func (s *Server) handleSettingsValidate(w http.ResponseWriter, r *http.Request) {
var in settingsPayload
if err := decodeJSON(r, &in); err != nil {
writeError(w, http.StatusBadRequest, "malformed request body")
return
}
next, err := parseConfigYAML(in.ConfigYAML)
if err != nil {
writeJSON(w, http.StatusOK, map[string]any{
"ok": false,
"errors": []map[string]string{{"field": "config", "message": err.Error()}},
"warnings": []map[string]string{},
})
return
}
next.WorkingDirOrSet(s.hooks.Store.Get().Server.WorkingDir)
verr := next.Validate()
out := map[string]any{"ok": verr == nil, "errors": []map[string]string{}, "warnings": []map[string]string{}}
if verr != nil {
errs := make([]map[string]string, 0)
for _, f := range configErrorFields(verr) {
errs = append(errs, map[string]string{"field": fieldOf(f), "message": f})
}
out["errors"] = errs
}
if warnings := configWarnings(next); len(warnings) > 0 {
ws := make([]map[string]string, 0, len(warnings))
for _, warn := range warnings {
ws = append(ws, map[string]string{"field": fieldOf(warn), "message": warn})
}
out["warnings"] = ws
}
writeJSON(w, http.StatusOK, out)
}
// handleSettingsReload re-reads the config file from disk.
func (s *Server) handleSettingsReload(w http.ResponseWriter, _ *http.Request) {
if s.hooks.Store.Path() == "" {
writeError(w, http.StatusBadRequest, "no config file to reload from")
return
}
if err := s.hooks.Store.Reload(); err != nil {
writeError(w, http.StatusInternalServerError, "reload failed: "+err.Error())
return
}
s.log.Info("config reloaded from disk", "version", s.hooks.Store.Get().Version)
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "version": s.hooks.Store.Get().Version})
}
// commit writes the config to disk, swaps it in and notifies the cluster.
func (s *Server) commit(next *config.Config) error {
path := s.hooks.Store.Path()
if path == "" {
return fmt.Errorf("cannot persist: no config file path is known")
}
if !isFileWritable(path) {
return fmt.Errorf("cannot persist: %s is not writable", path)
}
next.Version = s.hooks.Store.Get().Version + 1
if err := next.Save(path); err != nil {
return fmt.Errorf("write config: %w", err)
}
return s.hooks.Store.Swap(next)
}
// Commit exposes the config commit path so a component (for example the ACL or
// upstream editor) goes through the same validate-write-swap flow.
func (s *Server) Commit(next *config.Config) error { return s.commit(next) }
// parseConfigYAML decodes a config document on top of the defaults, which is
// what makes a partial document from the UI editor work.
func parseConfigYAML(text string) (*config.Config, error) {
next := config.Default()
if err := yaml.Unmarshal([]byte(text), next); err != nil {
return nil, err
}
return next, nil
}
// configErrorFields splits a joined validation error into individual messages.
func configErrorFields(err error) []string {
if err == nil {
return nil
}
msg := err.Error()
parts := strings.Split(msg, "\n")
out := make([]string, 0, len(parts))
for _, p := range parts {
if p = strings.TrimSpace(p); p != "" {
out = append(out, p)
}
}
if len(out) == 0 {
out = append(out, msg)
}
sort.Strings(out)
return out
}
// fieldOf extracts the leading dotted path from a validation message, e.g.
// "blocklist.lists[2]: name is required" becomes "blocklist.lists[2]".
func fieldOf(msg string) string {
i := strings.Index(msg, ":")
if i <= 0 {
return "config"
}
if f := strings.TrimSpace(msg[:i]); f != "" {
return f
}
return "config"
}
// configWarnings reports non-fatal configuration smells. A config can be valid
// and still a bad idea, and this is where a validator earns its keep.
func configWarnings(cfg *config.Config) []string {
var out []string
add := func(f string, a ...any) { out = append(out, fmt.Sprintf(f, a...)) }
if cfg.API.Auth.Mode == "none" {
add("api.auth.mode: the admin API is unauthenticated; anyone who can reach it can rewrite your config")
}
if !cfg.DNS.DoT.Enabled && !cfg.DNS.DoH.Enabled {
add("dns: neither DoT nor DoH is enabled, so clients have no encrypted transport")
}
if cfg.Cache.Enabled && !cfg.Cache.Persist.Enabled {
add("cache.persist.enabled: the cache will be cold after every restart")
}
if cfg.Cache.MaxTTL.D() > 24*time.Hour {
add("cache.max_ttl: caching for more than a day serves records upstream has already forgotten")
}
if !cfg.Block.Enabled {
add("blocklist.enabled: blocklists are off, so this instance is an open resolver for everything")
} else if len(cfg.Block.Lists) == 0 && len(cfg.Block.LocalZones) == 0 &&
len(cfg.Block.BlockEntries) == 0 {
add("blocklist: enabled but no source is configured")
}
for _, l := range cfg.Block.Lists {
if strings.HasPrefix(l.URL, "http://") {
add("blocklist.lists[%s]: the source is fetched over plain http and can be tampered with in transit", l.Name)
}
if l.Refresh.D() > 0 && l.Refresh.D() < time.Hour {
add("blocklist.lists[%s]: a refresh interval under an hour is likely to get you rate-limited", l.Name)
}
if strings.EqualFold(l.Format, "rpz") && strings.ContainsAny(l.Path, "*?") {
add("blocklist.lists[%s]: an rpz source should point at a zone file, not a pattern", l.Name)
}
}
if cfg.ACL.DefaultPolicy == "allow" {
for _, g := range cfg.ACL.Groups {
if g.Match.Any {
add("acl.groups[%s]: a catch-all group with policy allow makes the ACL a no-op", g.Name)
}
}
}
if len(cfg.Upstream.Servers) == 1 {
add("upstream.servers: only one upstream is configured, so a failure of that resolver is an outage")
}
for _, u := range cfg.Upstream.Servers {
if u.InsecureSkipVerify {
add("upstream.servers[%s]: insecure_skip_verify disables TLS validation", u.Addr)
}
}
if cfg.QueryLog.Enabled && cfg.QueryLog.SampleRate == 1 && cfg.QueryLog.LogCached {
add("qlog: logging every cached query on a busy instance is a lot of disk for little value; consider lowering sample_rate")
}
if cfg.Metrics.Enabled && !cfg.Metrics.Protect {
add("metrics.protect: /metrics is unauthenticated and exposes client and domain statistics")
}
return out
}
+786
View File
@@ -0,0 +1,786 @@
// Package app wires the components together and owns the process lifecycle:
// configuration, listeners, background loops, the admin API and shutdown.
package app
import (
"context"
"crypto/tls"
"errors"
"fmt"
"log/slog"
"net"
"net/http"
"os"
"path/filepath"
"strings"
"sync"
"time"
"git.k8s-git-k6n.net/mats/fancy-dns/internal/acl"
"git.k8s-git-k6n.net/mats/fancy-dns/internal/api"
"git.k8s-git-k6n.net/mats/fancy-dns/internal/blocklist"
"git.k8s-git-k6n.net/mats/fancy-dns/internal/cache"
"git.k8s-git-k6n.net/mats/fancy-dns/internal/cluster"
"git.k8s-git-k6n.net/mats/fancy-dns/internal/config"
"git.k8s-git-k6n.net/mats/fancy-dns/internal/engine"
"git.k8s-git-k6n.net/mats/fancy-dns/internal/localzone"
"git.k8s-git-k6n.net/mats/fancy-dns/internal/metrics"
"git.k8s-git-k6n.net/mats/fancy-dns/internal/qlog"
"git.k8s-git-k6n.net/mats/fancy-dns/internal/resolver"
"git.k8s-git-k6n.net/mats/fancy-dns/internal/server"
"git.k8s-git-k6n.net/mats/fancy-dns/internal/web"
"github.com/miekg/dns"
)
// Version is the build version, overridden at link time with -ldflags.
var Version = "dev"
// Options configures an App.
type Options struct {
// ConfigPath is the configuration file; "" runs on defaults.
ConfigPath string
// Version is the reported build version.
Version string
// LogLevel and LogFormat override the config's logging settings.
LogLevel string
LogFormat string
}
// App is the running process.
type App struct {
opt Options
log *slog.Logger
cfg *config.Store
ver string
blocks *blocklist.Manager
cache *cache.Manager
cstore *cache.Store
logger *qlog.Logger
aclH *acl.Holder
local *localzone.Holder
pool *resolver.Pool
reverse *engine.ReverseCache
engine *engine.Engine
dns *server.Server
api *api.Server
cl *cluster.Cluster
series *seriesStore
apiServers []*http.Server
apiLn []net.Listener
// reloadMu serialises reload work, since SIGHUP, the API and the cluster
// can all trigger one at the same time.
reloadMu sync.Mutex
startedAt time.Time
// degraded is set when a background component failed; surfaced in health.
degradedMu sync.RWMutex
degraded []string
}
// New builds an App from options. It does not bind anything yet; call Start.
func New(opt Options) (*App, error) {
if opt.Version == "" {
opt.Version = Version
}
cfg, err := config.Load(opt.ConfigPath)
if err != nil {
return nil, err
}
log, err := newLogger(cfg, opt)
if err != nil {
return nil, err
}
store := config.NewStore(cfg, opt.ConfigPath)
return &App{opt: opt, log: log, cfg: store, ver: opt.Version}, nil
}
// newLogger builds the structured logger from config plus CLI overrides.
func newLogger(cfg *config.Config, opt Options) (*slog.Logger, error) {
level := cfg.Log.Level
if opt.LogLevel != "" {
level = opt.LogLevel
}
var lvl slog.Level
switch level {
case "debug":
lvl = slog.LevelDebug
case "warn":
lvl = slog.LevelWarn
case "error":
lvl = slog.LevelError
default:
lvl = slog.LevelInfo
}
format := cfg.Log.Format
if opt.LogFormat != "" {
format = opt.LogFormat
}
handlerOpts := &slog.HandlerOptions{Level: lvl}
var w *os.File = os.Stderr
if cfg.Log.Addr != "" {
if strings.HasPrefix(cfg.Log.Addr, "/") {
f, err := os.OpenFile(cfg.Log.Addr, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
if err != nil {
return nil, fmt.Errorf("open log file: %w", err)
}
w = f
}
}
var h slog.Handler
if format == "json" {
h = slog.NewJSONHandler(w, handlerOpts)
} else {
h = slog.NewTextHandler(w, handlerOpts)
}
return slog.New(h).With("service", "fancy-dns", "version", opt.Version), nil
}
// Start brings up every component in dependency order.
func (a *App) Start(ctx context.Context) error {
a.startedAt = time.Now()
cfg := a.cfg.Get()
metrics.SetUp(true)
if err := a.startCache(cfg); err != nil {
return err
}
if err := a.startLogger(cfg); err != nil {
return err
}
a.startBlocklist()
a.startLocalZones(cfg)
a.startUpstream(cfg)
a.startDNS(ctx)
if err := a.startAPI(ctx); err != nil {
return err
}
a.startCluster(ctx, cfg)
a.startBackground(ctx, cfg)
a.cfg.OnSwap(a.onConfigSwap)
// The real bound admin address, not the configured one: with :0 the config
// is useless for telling the operator where to point a browser.
a.log.Info("fancy-dns started",
"config", a.cfg.Path(),
"dns", a.dns.Listeners(),
"api", a.Addr())
return nil
}
// startCache opens the bbolt snapshot and restores a warm start.
func (a *App) startCache(cfg *config.Config) error {
opts := cache.Options{
Size: cfg.Cache.Size,
Shards: cfg.Cache.Shards,
MinTTL: cfg.Cache.MinTTL.D(),
MaxTTL: cfg.Cache.MaxTTL.D(),
NegativeTTL: cfg.Cache.NegativeTTL.D(),
ServeStale: cfg.Cache.ServeStale.D(),
Prefetch: cfg.Cache.Prefetch,
}
if cfg.Cache.Enabled && cfg.Cache.Persist.Enabled {
st, err := cache.OpenStore(cache.StoreOptions{
Path: cfg.CachePath(),
MaxEntries: cfg.Cache.Persist.MaxEntries,
})
if err != nil {
a.markDegraded("cache persistence: " + err.Error())
a.log.Warn("cache persistence unavailable, continuing without it", "error", err)
} else {
a.cstore = st
if cfg.Cache.Persist.LoadOnStart {
lru := cache.NewLRU(cfg.Cache.Size, cfg.Cache.Shards)
loaded, skipped, lerr := st.Load(lru, time.Now())
if lerr != nil {
a.log.Warn("cache snapshot load failed", "error", lerr)
} else if loaded > 0 {
a.log.Info("cache snapshot restored", "entries", loaded, "skipped", skipped)
// Re-seed the real LRU from the temporary one.
a.cache = cache.NewManager(opts, st)
lru.Walk(func(k cache.Key, e *cache.Entry) bool {
a.cache.PutRaw(k, e.Wire, e.TTL, e.Origin, e.Upstream, e.Neg)
return true
})
}
}
}
}
if a.cache == nil {
a.cache = cache.NewManager(opts, a.cstore)
}
return nil
}
// prewarm resolves a few names at boot in the background, so a restart does
// not look like an outage for the names that matter most.
func (a *App) prewarm(c context.Context, specs []string) {
go func() {
for _, spec := range specs {
name, qtypeStr, ok := strings.Cut(spec, "/")
if !ok {
name, qtypeStr = spec, "A"
}
qtype, ok := dns.StringToType[strings.ToUpper(qtypeStr)]
if !ok {
a.log.Warn("prewarm: unknown record type", "spec", spec)
continue
}
m := new(dns.Msg)
m.SetQuestion(dns.Fqdn(name), qtype)
m.RecursionDesired = true
res := a.engine.Handle(c, engine.Request{Msg: m, Transport: "internal", Start: time.Now()})
if res.Msg != nil && res.Msg.Rcode == dns.RcodeSuccess {
a.log.Debug("prewarmed", "name", name, "type", qtypeStr)
}
}
}()
}
// tlsCfgType aliases the TLS config so the listener helpers read cleanly.
type tlsCfgType = tls.Config
// apiTLS builds the admin listener's TLS config when one is configured.
func apiTLS(cfg *config.Config) (*tls.Config, error) {
if cfg.API.CertFile == "" || cfg.API.KeyFile == "" {
return nil, nil
}
cert, err := tls.LoadX509KeyPair(cfg.API.CertFile, cfg.API.KeyFile)
if err != nil {
return nil, fmt.Errorf("load admin certificate: %w", err)
}
return &tls.Config{
Certificates: []tls.Certificate{cert},
MinVersion: tls.VersionTLS12,
NextProtos: []string{"h2", "http/1.1"},
}, nil
}
// startLogger opens the query log.
func (a *App) startLogger(cfg *config.Config) error {
opts := qlog.DefaultOptions()
opts.Path = cfg.QueryLogPath()
opts.MaxEntries = cfg.QueryLog.MaxEntries
opts.LiveBuffer = cfg.QueryLog.LiveBuffer
opts.FlushInterval = cfg.QueryLog.FlushInterval.D()
opts.SampleRate = cfg.QueryLog.SampleRate
opts.LogBlocked = cfg.QueryLog.LogBlocked
opts.LogCached = cfg.QueryLog.LogCached
opts.LogErrors = cfg.QueryLog.LogErrors
opts.TopDomains = cfg.QueryLog.TopDomains
opts.TopTTL = cfg.QueryLog.TopTTL.D()
if !cfg.QueryLog.Enabled {
opts.Path = ""
}
l, err := qlog.New(opts)
if err != nil {
a.markDegraded("query log: " + err.Error())
a.log.Warn("query log unavailable, continuing without persistence", "error", err)
return nil
}
a.logger = l
return nil
}
// startBlocklist starts the refresh loop and does the first compile.
func (a *App) startBlocklist() {
cfg := a.cfg.Get()
if !cfg.Block.Enabled {
a.log.Info("blocklists disabled")
return
}
a.blocks = blocklist.NewManager(blocklist.ManagerOptions{
Config: a.cfg,
Logger: a.log,
})
// Compile once before serving so the first queries are not unprotected.
if _, err := a.blocks.RefreshAll(context.Background()); err != nil {
a.markDegraded("blocklist: " + err.Error())
a.log.Warn("initial blocklist compile failed", "error", err)
}
}
// startLocalZones compiles the local master files.
func (a *App) startLocalZones(cfg *config.Config) {
a.local = localzone.NewHolder(nil)
if len(cfg.Block.LocalZones) == 0 {
return
}
a.reloadLocalZones()
}
// reloadLocalZones recompiles the local zone store. A failure keeps the
// previous store: serving stale local data beats serving nothing.
func (a *App) reloadLocalZones() {
cfg := a.cfg.Get()
if len(cfg.Block.LocalZones) == 0 {
a.local.Set(nil)
return
}
paths := make([]string, 0, len(cfg.Block.LocalZones))
for _, p := range cfg.Block.LocalZones {
paths = append(paths, cfg.AbsPath(p))
}
store, err := localzone.Load(paths, localzone.Options{})
if err != nil {
a.markDegraded("local zones: " + err.Error())
a.log.Error("local zone load failed, keeping previous data", "error", err)
return
}
a.local.Set(store)
metrics.SetLocalRecords(store.Count())
a.log.Info("local zones loaded", "zones", store.Zones(), "records", store.Count())
}
// startUpstream builds the resolver pool.
func (a *App) startUpstream(cfg *config.Config) {
a.pool = resolver.NewPool(cfg.Upstream)
}
// startDNS builds the engine and the listeners.
func (a *App) startDNS(ctx context.Context) {
a.aclH = acl.NewHolder(acl.Compile(cfgACL(a.cfg.Get())))
a.reverse = engine.NewReverseCache(engine.ReverseOptions{Max: 8192})
a.engine = engine.New(engine.Options{
Config: a.cfg,
Logger: a.logger,
Blocklists: a.blocksHolder(),
Cache: a.cache,
ACL: a.aclH,
Local: a.local,
Pool: a.pool,
Reverse: a.reverse,
})
a.engine.SetFlusher(a.flush)
a.dns = server.New(server.Options{
Engine: a.engine,
Config: a.cfg,
Logger: a.log,
Version: a.ver,
})
a.dns.SetIdentity(
func(ip string) []string { names, _ := a.reverse.Names(ip); return names },
func(ip string) []string {
for _, n := range mustNames(a.reverse, ip) {
if tags := acl.TagsFromName(n); len(tags) > 0 {
return tags
}
}
return nil
},
)
if err := a.dns.Start(ctx); err != nil {
a.log.Error("dns listeners failed to start", "error", err)
a.markDegraded("dns listeners: " + err.Error())
}
a.series = newSeriesStore()
}
func mustNames(rc *engine.ReverseCache, ip string) []string {
names, _ := rc.Names(ip)
return names
}
func (a *App) blocksHolder() *blocklist.Holder {
if a.blocks == nil {
return blocklist.NewHolder(nil)
}
return a.blocks.Holder()
}
func cfgACL(cfg *config.Config) config.ACL { return cfg.ACL }
// startAPI binds the admin listener and, when configured, the embedded UI.
func (a *App) startAPI(ctx context.Context) error {
cfg := a.cfg.Get()
if !cfg.API.Enabled {
return nil
}
api.SetMetricsHandler(metrics.Handler())
hooks := a.hooks()
srv, err := api.New(hooks)
if err != nil {
return err
}
a.api = srv
// One mux: the API claims its base path, the SPA claims the rest. The API
// is registered first so /api/* never falls through to the SPA's
// index.html fallback (which would return HTML to a JSON client).
mux := http.NewServeMux()
// The api package already registers its routes with the full base path, so
// no StripPrefix here: a more specific pattern than the SPA's "/" wins, so
// /api/* never falls through to index.html.
mux.Handle(cfg.API.BasePath+"/", srv.Handler())
mux.Handle(cfg.API.BasePath, srv.Handler())
if cfg.API.UI {
ui, err := web.Handler("")
if err != nil {
a.log.Warn("web console unavailable", "error", err)
} else {
mux.Handle("/", ui)
if web.Available() {
a.log.Info("web console enabled")
} else {
a.log.Warn("web console assets missing from this binary; run 'make web' and rebuild")
}
}
}
tlsCfg, tlsErr := apiTLS(cfg)
var errs []error
for _, addr := range cfg.API.Listen {
ln, err := net.Listen("tcp", addr)
if err != nil {
errs = append(errs, fmt.Errorf("bind api %s: %w", addr, err))
continue
}
a.startHTTPServer(mux, ln, nil, "api")
}
if len(cfg.API.TLSListen) > 0 {
if tlsErr != nil {
errs = append(errs, tlsErr)
} else {
for _, addr := range cfg.API.TLSListen {
ln, err := net.Listen("tcp", addr)
if err != nil {
errs = append(errs, fmt.Errorf("bind api tls %s: %w", addr, err))
continue
}
a.startHTTPServer(mux, ln, tlsCfg, "api-tls")
}
}
}
if err := errors.Join(errs...); err != nil {
a.markDegraded("admin api: " + err.Error())
a.log.Error("admin api failed to bind", "error", err)
}
_ = ctx
return nil
}
func (a *App) startHTTPServer(h http.Handler, ln net.Listener, tlsCfg *tlsCfgType, name string) {
srv := &http.Server{
Handler: h,
ReadHeaderTimeout: 15 * time.Second,
IdleTimeout: 120 * time.Second,
}
if tlsCfg != nil {
srv.TLSConfig = tlsCfg
ln = tls.NewListener(ln, tlsCfg)
}
a.apiServers = append(a.apiServers, srv)
a.apiLn = append(a.apiLn, ln)
a.log.Info("admin api listening", "addr", ln.Addr().String(), "tls", tlsCfg != nil, "name", name)
go func() {
if err := srv.Serve(ln); err != nil && !errors.Is(err, http.ErrServerClosed) {
a.log.Error("admin api stopped", "error", err, "name", name)
}
}()
}
// startCluster connects to Valkey and wires cluster events into a reload.
func (a *App) startCluster(ctx context.Context, cfg *config.Config) {
if !cfg.Cluster.Enabled {
a.cl = cluster.New(cluster.Options{Config: a.cfg, Logger: a.log, Version: a.ver})
return
}
a.cl = cluster.New(cluster.Options{
Config: a.cfg,
Logger: a.log,
Version: a.ver,
StartedAt: a.startedAt,
Addr: cfg.Cluster.Advertise,
StateFunc: a.nodeState,
Digests: a.nodeDigests,
Handler: a.onClusterEvent,
})
a.cl.Start(ctx)
}
// startBackground runs the periodic jobs: cache snapshots, upstream health
// probes and the metrics gauge.
func (a *App) startBackground(ctx context.Context, cfg *config.Config) {
// Cache snapshots.
if a.cstore != nil {
interval := cfg.Cache.Persist.Interval.D()
if interval <= 0 {
interval = 30 * time.Second
}
go func() {
t := time.NewTicker(interval)
defer t.Stop()
for {
select {
case <-ctx.Done():
a.snapshotCache()
return
case <-t.C:
a.snapshotCache()
}
}
}()
}
// Upstream health probes.
go a.healthLoop(ctx)
// Live gauges and the dashboard series sampler.
go func() {
t := time.NewTicker(2 * time.Second)
defer t.Stop()
sample := time.NewTicker(5 * time.Second)
defer sample.Stop()
for {
select {
case <-ctx.Done():
return
case <-t.C:
metrics.SetConcurrent(int(a.engine.Inflight()))
case <-sample.C:
if a.series != nil {
a.series.sample()
}
}
}
}()
}
func (a *App) snapshotCache() {
if a.cstore == nil || a.cache == nil {
return
}
n, err := a.cstore.Snapshot(a.cache.LRU(), time.Now())
if err != nil {
a.log.Warn("cache snapshot failed", "error", err)
return
}
a.log.Debug("cache snapshot written", "entries", n)
}
func (a *App) healthLoop(ctx context.Context) {
for {
interval := a.cfg.Get().Upstream.HealthCheck.D()
if interval <= 0 {
interval = 30 * time.Second
}
select {
case <-ctx.Done():
return
case <-time.After(interval):
}
pool := a.pool
if pool == nil {
continue
}
hctx, cancel := context.WithTimeout(ctx, 5*time.Second)
ok := pool.HealthCheck(hctx, 3*time.Second)
cancel()
total := len(pool.All())
for _, u := range pool.All() {
healthy := u.Health() == resolver.HealthUp
metrics.SetUpstreamHealth(u.Addr(), u.Config().Proto, healthy)
}
if total > 0 && ok == 0 {
a.markDegraded("no upstream is answering health probes")
} else {
a.clearDegraded("no upstream is answering health probes")
}
}
}
// markDegraded records a component problem for the health endpoint.
func (a *App) markDegraded(reason string) {
a.degradedMu.Lock()
defer a.degradedMu.Unlock()
for _, r := range a.degraded {
if r == reason {
return
}
}
a.degraded = append(a.degraded, reason)
}
func (a *App) clearDegraded(reason string) {
a.degradedMu.Lock()
defer a.degradedMu.Unlock()
out := a.degraded[:0]
for _, r := range a.degraded {
if r != reason {
out = append(out, r)
}
}
a.degraded = out
}
// Degraded returns the current problem list.
func (a *App) Degraded() []string {
a.degradedMu.RLock()
defer a.degradedMu.RUnlock()
return append([]string(nil), a.degraded...)
}
// onConfigSwap reacts to a new configuration.
func (a *App) onConfigSwap(cfg *config.Config) {
a.log.Info("configuration applied", "version", cfg.Version)
if a.pool != nil {
a.pool.Rebind(cfg.Upstream)
}
a.reloadLocalZones()
if a.blocks != nil && !cfg.Block.Enabled {
a.log.Info("blocklists disabled by configuration change")
}
}
// onClusterEvent reacts to a peer message.
func (a *App) onClusterEvent(ctx context.Context, ev cluster.Event) {
switch ev.Kind {
case cluster.EventReload:
a.log.Info("peer requested a config reload", "from", ev.NodeID)
if err := a.Reload(); err != nil {
a.log.Warn("cluster-triggered reload failed", "error", err)
}
case cluster.EventBlocklists:
// Only the lease holder fetches, so N instances do not hammer a mirror
// N times; the others keep serving their compiled copy.
if a.cl != nil && a.cl.IsLeader() {
if a.blocks != nil {
a.blocks.Trigger(firstOr(ev.Sources, ""))
}
}
case cluster.EventConfig:
// A peer pushed a full document. We do not adopt it silently: a
// follower adopting a document it cannot see on disk would drift. The
// operator re-runs a deploy or hits "reload" instead.
a.log.Info("peer published a new config version", "from", ev.NodeID, "version", ev.Version)
}
}
func firstOr(s []string, def string) string {
if len(s) > 0 && s[0] != "" {
return s[0]
}
return def
}
// nodeState supplies the live state published to peers.
func (a *App) nodeState() *cluster.NodeState {
addrs := make([]string, 0, 4)
for _, l := range a.dns.Listeners() {
addrs = append(addrs, l.Transport+":"+l.Addr)
}
return &cluster.NodeState{
Addr: a.opt.ConfigPath,
Version: a.ver,
StartedAt: a.startedAt,
Listeners: addrs,
Generation: a.cfg.Generation(),
}
}
// nodeDigests supplies the digests peers compare.
func (a *App) nodeDigests() (string, int, time.Time, string) {
var (
bl string
rules int
built time.Time
)
if a.blocks != nil {
st := a.blocks.State()
if st != nil {
bl = st.Digest
if st.Set != nil {
rules = st.Set.Len()
built = st.Set.BuiltAt()
}
}
}
return bl, rules, built, cluster.Digest(a.cfg.Get())
}
// Reload re-reads the config file and applies it.
func (a *App) Reload() error {
a.reloadMu.Lock()
defer a.reloadMu.Unlock()
return a.cfg.Reload()
}
// Stop shuts everything down in reverse order, with a deadline.
func (a *App) Stop(ctx context.Context) error {
a.log.Info("shutting down")
metrics.SetUp(false)
var errs []error
if a.cl != nil {
a.cl.Stop()
}
if a.blocks != nil {
a.blocks.Stop()
}
if a.dns != nil {
if err := a.dns.Stop(ctx); err != nil {
errs = append(errs, err)
}
}
for _, srv := range a.apiServers {
shutCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
err := srv.Shutdown(shutCtx)
cancel()
if err != nil {
errs = append(errs, err)
}
}
if a.logger != nil {
a.logger.Flush()
if err := a.logger.Close(); err != nil {
errs = append(errs, err)
}
}
if a.cstore != nil {
// Snapshot, close, then clear the pointer: the background snapshot
// goroutine also fires on ctx.Done, and a nil check is how it learns
// to stop rather than writing to a closed file.
a.snapshotCache()
if err := a.cstore.Close(); err != nil {
errs = append(errs, err)
}
a.cstore = nil
}
if err := errors.Join(errs...); err != nil {
a.log.Warn("shutdown completed with errors", "error", err)
} else {
a.log.Info("shutdown complete")
}
return errors.Join(errs...)
}
// Addr returns the admin API address, which a preview needs.
func (a *App) Addr() string {
for _, ln := range a.apiLn {
return ln.Addr().String()
}
return ""
}
// DataDir is where the bbolt files live.
func (a *App) DataDir() string {
return filepath.Dir(a.cfg.Get().CachePath())
}
// Log exposes the logger so the command can report signals.
func (a *App) Log() *slog.Logger { return a.log }
// SetAdminAddr overrides the admin API listen address without touching the
// config file, which is what a container platform's -admin-addr flag wants.
func (a *App) SetAdminAddr(addr string) error {
next := a.cfg.Get()
cp := *next
cp.API = next.API
cp.API.Listen = []string{addr}
if err := a.cfg.Swap(&cp); err != nil {
return err
}
return nil
}
+246
View File
@@ -0,0 +1,246 @@
package app
import (
"context"
"crypto/tls"
"encoding/base64"
"fmt"
"io"
"net"
"net/http"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"time"
"github.com/miekg/dns"
"git.k8s-git-k6n.net/mats/fancy-dns/internal/config"
)
// testUpstream is a fake recursive resolver: it answers A queries for names
// under test. and NXDOMAIN for everything else, so a test can tell "resolved
// upstream" from "blocked locally" without touching the internet.
type testUpstream struct {
*dns.Server
addr string
mu sync.Mutex
hits map[string]int
}
func newTestUpstream(t *testing.T) *testUpstream {
t.Helper()
pc, err := net.ListenPacket("udp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen: %v", err)
}
u := &testUpstream{addr: pc.LocalAddr().String(), hits: map[string]int{}}
u.Server = &dns.Server{PacketConn: pc, Handler: dns.HandlerFunc(u.handle)}
go func() { _ = u.ActivateAndServe() }()
t.Cleanup(func() { _ = u.Shutdown() })
return u
}
func (u *testUpstream) handle(w dns.ResponseWriter, q *dns.Msg) {
m := new(dns.Msg)
m.SetReply(q)
name := q.Question[0].Name
u.mu.Lock()
u.hits[name]++
u.mu.Unlock()
if strings.HasSuffix(name, ".test.") {
if q.Question[0].Qtype == dns.TypeA {
rr, _ := dns.NewRR(name + " 60 IN A 203.0.113.7")
m.Answer = append(m.Answer, rr)
}
soa, _ := dns.NewRR("test. 60 IN SOA ns.test. hostmaster.test. 1 3600 600 604800 60")
m.Ns = append(m.Ns, soa)
} else {
m.Rcode = dns.RcodeNameError
soa, _ := dns.NewRR("test. 60 IN SOA ns.test. hostmaster.test. 1 3600 600 604800 60")
m.Ns = append(m.Ns, soa)
}
_ = w.WriteMsg(m)
}
func (u *testUpstream) hitCount(name string) int {
u.mu.Lock()
defer u.mu.Unlock()
return u.hits[name]
}
// harness is a fully wired App on ephemeral ports, with a fake upstream.
type harness struct {
t *testing.T
app *App
addr string // host:port for plain DNS
api string // host:port for the admin API
key string
up *testUpstream
dir string
// config is the document the harness wrote to disk, so a test can make a
// realistic edit rather than inventing a minimal stub.
config string
}
// OriginalConfig returns the document the harness wrote to disk, so a test
// can make a realistic single-value edit instead of inventing a minimal stub.
func (h *harness) OriginalConfig() string { return h.config }
func newHarness(t *testing.T, mutate func(*config.Config)) *harness {
t.Helper()
dir := t.TempDir()
up := newTestUpstream(t)
hosts := filepath.Join(dir, "hosts")
if err := os.WriteFile(hosts, []byte(
"0.0.0.0 ads.example.com\n"+
"0.0.0.0 tracker.example.net\n"+
"0.0.0.0 shared.example.com\n"+
// Under .test. so the fake upstream resolves it: NOERROR then means
// "the blocklist let it through" and NXDOMAIN means "blocked", with
// no third possibility to confuse an assertion.
"0.0.0.0 blocked.example.test\n"+
"# comment\n"+
"127.0.0.1 localhost\n"), 0o644); err != nil {
t.Fatal(err)
}
zone := filepath.Join(dir, "local.zone")
if err := os.WriteFile(zone, []byte(
"$TTL 60\n"+
"local. SOA ns.local. hostmaster.local. 1 3600 600 604800 60\n"+
"local. NS ns.local.\n"+
"ns.local. A 192.168.1.2\n"+
"*.wild.local. A 192.168.1.50\n"+
"deep.svc.local. A 192.168.1.60\n"+
"*.deep.svc.local. A 192.168.1.61\n"), 0o644); err != nil {
t.Fatal(err)
}
// Pick a free port up front so the client can address the listeners and so
// a port clash fails the test rather than the app's start-up.
dnsPort := freePort(t)
dnsAddr := fmt.Sprintf("127.0.0.1:%d", dnsPort)
cfg := config.Default()
cfg.WorkingDirOrSet(dir)
cfg.DNS.UDP = []string{dnsAddr}
cfg.DNS.TCP = []string{dnsAddr}
cfg.DNS.DoT.Enabled = false
cfg.DNS.DoH.Enabled = false
cfg.API.Listen = []string{"127.0.0.1:0"}
cfg.API.Auth.Mode = "apikey"
cfg.API.Auth.APIKeys = []string{"test-key"}
cfg.API.Auth.TrustLoopback = false
cfg.API.UI = false
cfg.Upstream.Policy = "parallel"
cfg.Upstream.MaxAttempts = 1
cfg.Upstream.HealthCheck = 0
cfg.Upstream.Servers = []config.UpstreamServer{{Addr: up.addr, Proto: "udp", Weight: 1, HealthName: "test"}}
cfg.Cache.Persist.Path = filepath.Join(dir, "cache.db")
cfg.QueryLog.Path = filepath.Join(dir, "queries.db")
cfg.Block.Lists = []config.ListSource{{
Name: "test-hosts", Format: "hosts", Path: hosts, Enabled: true, Refresh: 0,
}}
cfg.Block.LocalZones = []string{zone}
cfg.Block.BlockEntries = []string{"inline-blocked.example.com", "*.inline-sub.example.com"}
cfg.Block.AllowEntries = []string{"allow.example.com"}
if mutate != nil {
mutate(cfg)
}
path := filepath.Join(dir, "config.yaml")
// Snapshot the document before the app starts: the server may rewrite this
// file (every API mutation does), so a test must not read it concurrently
// with a live instance's own writes.
doc := string(mustYAML(t, cfg))
if err := os.WriteFile(path, []byte(doc), 0o600); err != nil {
t.Fatal(err)
}
a, err := New(Options{ConfigPath: path, Version: "test"})
if err != nil {
t.Fatalf("new app: %v", err)
}
ctx, cancel := context.WithCancel(context.Background())
if err := a.Start(ctx); err != nil {
cancel()
t.Fatalf("start: %v", err)
}
t.Cleanup(func() {
sctx, scancel := context.WithTimeout(context.Background(), 5*time.Second)
_ = a.Stop(sctx)
scancel()
cancel()
})
return &harness{t: t, app: a, addr: dnsAddr, api: a.Addr(), key: "test-key", up: up, dir: dir, config: doc}
}
// freePort returns a currently-free TCP port on the loopback interface.
func freePort(t *testing.T) int {
t.Helper()
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("free port: %v", err)
}
port := ln.Addr().(*net.TCPAddr).Port
_ = ln.Close()
return port
}
func (h *harness) query(name string, qtype uint16) *dns.Msg {
h.t.Helper()
m := new(dns.Msg)
m.SetQuestion(dns.Fqdn(name), qtype)
m.RecursionDesired = true
c := &dns.Client{Net: "udp", Timeout: 5 * time.Second}
res, _, err := c.Exchange(m, h.addr)
if err != nil {
h.t.Fatalf("query %s: %v", name, err)
}
return res
}
func (h *harness) get(path string) (int, []byte) {
h.t.Helper()
req, _ := http.NewRequest("GET", "http://"+h.api+path, nil)
req.Header.Set("X-API-Key", h.key)
res, err := http.DefaultClient.Do(req)
if err != nil {
h.t.Fatalf("GET %s: %v", path, err)
}
defer res.Body.Close()
b, _ := io.ReadAll(res.Body)
return res.StatusCode, b
}
func (h *harness) do(method, path, body string) (int, []byte) {
h.t.Helper()
var rdr io.Reader
if body != "" {
rdr = strings.NewReader(body)
}
req, _ := http.NewRequest(method, "http://"+h.api+path, rdr)
req.Header.Set("X-API-Key", h.key)
req.Header.Set("Content-Type", "application/json")
res, err := http.DefaultClient.Do(req)
if err != nil {
h.t.Fatalf("%s %s: %v", method, path, err)
}
defer res.Body.Close()
b, _ := io.ReadAll(res.Body)
return res.StatusCode, b
}
func mustYAML(t *testing.T, c *config.Config) []byte {
t.Helper()
b, err := config.Marshal(c)
if err != nil {
t.Fatal(err)
}
return b
}
var _ = base64.StdEncoding
var _ = tls.VersionTLS13
+922
View File
@@ -0,0 +1,922 @@
package app
import (
"context"
"fmt"
"sort"
"strings"
"sync"
"time"
"github.com/miekg/dns"
"git.k8s-git-k6n.net/mats/fancy-dns/internal/api"
"git.k8s-git-k6n.net/mats/fancy-dns/internal/blocklist"
"git.k8s-git-k6n.net/mats/fancy-dns/internal/config"
"git.k8s-git-k6n.net/mats/fancy-dns/internal/metrics"
"git.k8s-git-k6n.net/mats/fancy-dns/internal/qlog"
"git.k8s-git-k6n.net/mats/fancy-dns/internal/resolver"
)
// hooks builds the api.Hooks that connect the admin API to the live
// components. Everything the UI can read or change goes through here.
func (a *App) hooks() api.Hooks {
return api.Hooks{
Store: a.cfg,
Logger: a.log,
Version: a.ver,
StartedAt: a.startedAt,
Dashboard: a.dashboard,
Status: a.status,
Health: a.health,
BlocklistState: a.blocklistState,
BlocklistStats: a.blocklistStats,
RefreshBlocklists: a.refreshBlocklists,
ToggleBlocklist: a.toggleBlocklist,
DomainCheck: a.domainCheck,
ListDomains: a.listDomains,
AddDomain: a.addDomain,
DeleteDomain: a.deleteDomain,
GetACL: a.getACL,
PutACL: a.putACL,
GetUpstream: a.getUpstreams,
PutUpstream: a.putUpstream,
SearchLog: a.searchLog,
LogStats: a.logStats,
SubscribeLog: a.subscribeLog,
ClearLog: a.clearLog,
CacheStats: a.cacheStats,
CacheTop: a.cacheTop,
FlushCache: a.flushCache,
Flush: a.flush,
}
}
// health is the unauthenticated probe payload.
func (a *App) health() any {
status := "ok"
problems := a.Degraded()
switch {
case len(problems) > 0:
status = "degraded"
}
if a.dns == nil || len(a.dns.Listeners()) == 0 {
status = "down"
}
if a.logger != nil && a.logger.ErrorCount() > 0 && status == "ok" {
status = "degraded"
problems = append(problems, "query log write errors")
}
return map[string]any{
"status": status,
"version": a.ver,
"uptime_s": int64(time.Since(a.startedAt).Seconds()),
"config_version": a.cfg.Get().Version,
"problems": problems,
}
}
// status is the per-component runtime view.
func (a *App) status() any {
out := map[string]any{
"listeners": a.dns.Listeners(),
"transports": a.dns.ByTransport(),
"degraded": a.Degraded(),
}
if a.blocks != nil {
st := a.blocks.State()
if st != nil {
out["blocklist"] = map[string]any{
"rules": st.Set.Len(),
"sources": len(st.Sources),
"duration": st.Duration,
"built_at": st.Set.BuiltAt(),
"errors": st.Errors,
"digest": st.Digest,
"has_wild": st.Set.HasWildcards(),
"duplicates": st.Set.DuplicatesN(),
}
}
}
if a.local != nil {
if store := a.local.Get(); store != nil {
out["local_zones"] = map[string]any{
"zones": store.Zones(),
"records": store.Count(),
"files": store.Files(),
}
}
}
if a.cache != nil {
out["cache"] = a.cacheStats()
}
if a.logger != nil {
out["qlog"] = a.logger.Stats()
}
if a.pool != nil {
out["upstream"] = a.getUpstreams()
}
if a.aclH != nil {
out["acl"] = a.getACL()
}
if a.cl != nil {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
out["cluster"] = a.cl.Status(ctx)
cancel()
}
if a.engine != nil {
out["engine"] = map[string]any{
"inflight": a.engine.Inflight(),
"dropped": a.engine.Dropped(),
"group_stats": a.engine.GroupStats(),
}
}
return out
}
// blocklistState is the full per-source view for the UI.
func (a *App) blocklistState() any {
if a.blocks == nil {
return map[string]any{
"items": []any{},
"stats": map[string]any{"total": 0},
}
}
st := a.blocks.State()
items := make([]blocklist.Source, 0, len(st.Sources))
for _, s := range st.Sources {
items = append(items, s)
}
sort.Slice(items, func(i, j int) bool { return items[i].Name < items[j].Name })
return map[string]any{
"items": items,
"stats": blocklistSourceStats(st),
"errors": st.Errors,
"digest": st.Digest,
}
}
func blocklistSourceStats(st *blocklist.State) map[string]any {
if st == nil || st.Set == nil {
return map[string]any{"total": 0}
}
s := st.Set.Stats()
return map[string]any{
"total": s.Total,
"exact": s.Exact,
"subdomain_rules": s.Subdomains,
"root_rules": s.RootRules,
"allow": s.Allow,
"block": s.Block,
"passthrough": s.Passthru,
"drop": s.Drop,
"duplicates": s.Duplicates,
"sources": s.Sources,
"built_at": s.BuiltAt,
"sources_detail": s.PerSource,
}
}
func (a *App) blocklistStats() any {
if a.blocks == nil {
return map[string]any{"total": 0}
}
return blocklistSourceStats(a.blocks.State())
}
// invalidateBlocklistCache drops every cached answer whose verdict the new
// rules might change. Without this, a name that has just become blocked keeps
// serving its cached good answer until the TTL expires, which is exactly the
// bug an operator hits right after clicking "add rule".
func (a *App) invalidateBlocklistCache() {
if a.cache == nil {
return
}
a.cache.Flush()
}
// refreshBlocklists recompiles the list sources.
func (a *App) refreshBlocklists(ctx context.Context, names []string) error {
if a.blocks == nil {
return fmt.Errorf("blocklists are disabled")
}
a.invalidateBlocklistCache()
go func() {
if len(names) == 0 {
if _, err := a.blocks.RefreshAll(ctx); err != nil {
a.log.Warn("blocklist refresh failed", "error", err)
}
return
}
// The manager compiles everything in one pass, which is cheaper than
// a pass per name and keeps the compiled set internally consistent.
if _, err := a.blocks.RefreshAll(ctx); err != nil {
a.log.Warn("blocklist refresh failed", "error", err, "names", names)
}
}()
// Tell the cluster leader so every instance ends up on the same content.
if a.cl != nil {
_ = a.cl.BroadcastRefresh(ctx, names)
}
return nil
}
// toggleBlocklist flips one source's enabled flag and persists it.
func (a *App) toggleBlocklist(name string, enabled bool) error {
if a.blocks == nil {
return fmt.Errorf("blocklists are disabled")
}
err := a.mutateConfig(func(c *config.Config) error {
found := false
for i := range c.Block.Lists {
if c.Block.Lists[i].Name == name {
c.Block.Lists[i].Enabled = enabled
found = true
}
}
if !found {
return fmt.Errorf("no blocklist named %q", name)
}
return nil
})
if err != nil {
return err
}
a.invalidateBlocklistCache()
go func() {
if _, rerr := a.blocks.RefreshAll(context.Background()); rerr != nil {
a.log.Warn("refresh after toggle failed", "error", rerr)
}
}()
return nil
}
// mutateConfig clones, mutates, validates and commits the config, returning
// the validation error when the change is rejected.
func (a *App) mutateConfig(fn func(*config.Config) error) error {
cur := a.cfg.Get()
next := *cur
// A shallow copy is not enough: the slices and maps we touch need their own
// backing arrays, or a rejected change would corrupt the live config.
next.Block = cur.Block
next.Block.Lists = append([]config.ListSource(nil), cur.Block.Lists...)
next.Block.LocalZones = append([]string(nil), cur.Block.LocalZones...)
next.Block.BlockEntries = append([]string(nil), cur.Block.BlockEntries...)
next.Block.AllowEntries = append([]string(nil), cur.Block.AllowEntries...)
next.ACL = cur.ACL
next.ACL.Groups = append([]config.Group(nil), cur.ACL.Groups...)
next.Upstream = cur.Upstream
next.Upstream.Servers = append([]config.UpstreamServer(nil), cur.Upstream.Servers...)
next.Upstream.Bootstraps = append([]config.UpstreamBootstrap(nil), cur.Upstream.Bootstraps...)
if err := fn(&next); err != nil {
return err
}
if err := next.Validate(); err != nil {
return err
}
if a.api == nil {
return a.cfg.Swap(&next)
}
return a.api.Commit(&next)
}
// domainCheck explains how a name would be treated.
func (a *App) domainCheck(name string) (any, error) {
entry := (*blocklist.Entry)(nil)
if a.blocks != nil {
if set := a.blocks.Holder().Get(); set != nil {
entry = set.Match(name)
}
}
out := map[string]any{
"name": name,
"query": strings.TrimSuffix(name, "."),
}
if entry == nil {
out["matched"] = false
out["action"] = "none"
out["source"] = ""
return out, nil
}
kind := "exact"
pattern := entry.Name
if set := a.blocks.Holder().Get(); set != nil {
// Recover which of the three maps matched so the UI can say *why*.
switch set.MatchPattern(name) {
case blocklist.PatternSub:
kind = "subdomain"
pattern = "*." + entry.Name
case blocklist.PatternRoot:
kind = "domain"
pattern = "**." + entry.Name
}
}
out["matched"] = true
out["action"] = entry.Action.String()
out["source"] = entry.Source
out["categories"] = entry.Categories
out["hits"] = entry.Hits()
out["rule"] = pattern
out["matched_pattern"] = pattern
out["match_kind"] = kind
return out, nil
}
// listDomains pages the compiled rules. Walking a million-entry map on every
// request would be too slow, so the filter runs over a bounded sample unless
// the query is a prefix/exact lookup, which the LRU can answer directly.
func (a *App) listDomains(q api.DomainQuery) (any, error) {
if a.blocks == nil {
return map[string]any{"items": []any{}, "total": 0, "page": 1, "per_page": q.PerPage}, nil
}
set := a.blocks.Holder().Get()
if set == nil {
return map[string]any{"items": []any{}, "total": 0, "page": 1, "per_page": q.PerPage}, nil
}
if q.Text != "" && (q.Action == "" && q.List == "") {
if e := set.Lookup(q.Text); e != nil {
return map[string]any{
"items": []blocklist.Entry{*e},
"total": 1,
"page": 1,
"per_page": q.PerPage,
}, nil
}
}
type row struct {
Name string `json:"name"`
Action string `json:"action"`
Source string `json:"source"`
Categories []string `json:"categories"`
Hits uint64 `json:"hits"`
Rule string `json:"rule"`
}
text := strings.ToLower(strings.TrimSpace(q.Text))
action := strings.ToLower(q.Action)
// The cap keeps a request bounded on a large set; the UI pages anyway.
const scanCap = 400000
matches := make([]row, 0, 256)
total := 0
set.Each(func(pattern string, e *blocklist.Entry, kind blocklist.PatternKind) bool {
if len(matches) >= scanCap {
return false
}
if action != "" && e.Action.String() != action {
return true
}
if q.List != "" && e.Source != q.List {
return true
}
if text != "" && !strings.Contains(pattern, text) {
return true
}
total++
matches = append(matches, row{
Name: pattern,
Action: e.Action.String(),
Source: e.Source,
Categories: e.Categories,
Hits: e.Hits(),
Rule: kind.Pattern(pattern),
})
return true
})
sort.Slice(matches, func(i, j int) bool {
if matches[i].Hits != matches[j].Hits {
return matches[i].Hits > matches[j].Hits
}
return matches[i].Name < matches[j].Name
})
start := (q.Page - 1) * q.PerPage
if start > len(matches) {
start = len(matches)
}
end := start + q.PerPage
if end > len(matches) {
end = len(matches)
}
return map[string]any{
"items": matches[start:end],
"total": total,
"page": q.Page,
"per_page": q.PerPage,
"truncated": total > len(matches),
}, nil
}
// addDomain appends an inline rule and persists it.
func (a *App) addDomain(_ context.Context, in api.DomainInput) (any, error) {
name := strings.TrimSpace(in.Name)
if name == "" {
return nil, fmt.Errorf("name is required")
}
act, ok := blocklist.ParseAction(in.Action)
if !ok {
return nil, fmt.Errorf("unknown action %q", in.Action)
}
// A bulk paste goes in as several entries; a single name goes in as one.
entries := []string{}
if in.Content != "" {
entries = strings.Split(in.Content, "\n")
} else {
entries = []string{name}
}
err := a.mutateConfig(func(c *config.Config) error {
for _, e := range entries {
e = strings.TrimSpace(e)
if e == "" || strings.HasPrefix(e, "#") {
continue
}
entry := e
if act == blocklist.ActionBlock {
c.Block.BlockEntries = append(c.Block.BlockEntries, entry)
} else {
c.Block.AllowEntries = append(c.Block.AllowEntries, entry)
}
}
return nil
})
if err != nil {
return nil, err
}
if a.blocks != nil {
if _, rerr := a.blocks.RefreshAll(context.Background()); rerr != nil {
a.log.Warn("refresh after add failed", "error", rerr)
}
}
// A new rule changes the verdict for the whole subtree it covers, and an
// allow rule can rescue a name many other lists blocked, so the honest
// thing is to drop the affected answers rather than guess.
a.invalidateBlocklistCache()
return map[string]any{"ok": true, "name": name, "count": len(entries)}, nil
}
// deleteDomain removes a rule and persists the change.
func (a *App) deleteDomain(_ context.Context, name, action, list string) error {
target := strings.ToLower(strings.TrimSpace(name))
removed := 0
err := a.mutateConfig(func(c *config.Config) error {
strip := func(entries []string) []string {
out := entries[:0]
for _, e := range entries {
normal := strings.ToLower(strings.TrimSpace(e))
normal = strings.TrimPrefix(strings.TrimPrefix(normal, "*."), "**.")
normal = strings.TrimSuffix(normal, ".")
if normal == target {
removed++
continue
}
out = append(out, e)
}
return out
}
c.Block.BlockEntries = strip(c.Block.BlockEntries)
c.Block.AllowEntries = strip(c.Block.AllowEntries)
// A file-backed list is edited by editing the file, so the API only
// reports that rather than pretending it removed something.
if list != "" && removed == 0 {
return fmt.Errorf("%q comes from list %q; edit that list instead", name, list)
}
if removed == 0 {
return fmt.Errorf("no inline rule matches %q", name)
}
return nil
})
if err != nil {
return err
}
if a.blocks != nil {
if _, rerr := a.blocks.RefreshAll(context.Background()); rerr != nil {
a.log.Warn("refresh after delete failed", "error", rerr)
}
}
a.invalidateBlocklistCache()
return nil
}
// getACL returns the ACL groups.
func (a *App) getACL() any {
groups := a.engine.GroupStats()
return map[string]any{
"groups": groups,
"default_policy": a.cfg.Get().ACL.DefaultPolicy,
"default_rate_limit": a.cfg.Get().ACL.DefaultRateLimit,
}
}
// putACL validates and persists new ACL groups.
func (a *App) putACL(_ context.Context, groups []config.Group, def string) (int64, error) {
err := a.mutateConfig(func(c *config.Config) error {
c.ACL.Groups = groups
if def != "" {
c.ACL.DefaultPolicy = def
}
return nil
})
if err != nil {
return 0, err
}
// An allow_domains or deny_domains change alters the verdict for names
// already in the cache, so the cached answers are no longer trustworthy.
if a.cache != nil {
a.cache.Flush()
}
return a.cfg.Get().Version, nil
}
// getUpstreams returns the pool state.
func (a *App) getUpstreams() any {
cfg := a.cfg.Get()
servers := make([]resolver.Stats, 0, len(a.pool.All()))
for _, s := range a.pool.Stats() {
servers = append(servers, s)
}
return map[string]any{
"policy": cfg.Upstream.Policy,
"servers": servers,
"bootstraps": cfg.Upstream.Bootstraps,
"healthcheck_interval": cfg.Upstream.HealthCheck.D().Seconds(),
"max_attempts": cfg.Upstream.MaxAttempts,
"breaker_threshold": cfg.Upstream.BreakerThreshold,
"breaker_cooldown": cfg.Upstream.BreakerCooldown.D().Seconds(),
}
}
// putUpstream validates and persists a new upstream config.
func (a *App) putUpstream(_ context.Context, up config.Upstream) (int64, error) {
err := a.mutateConfig(func(c *config.Config) error {
c.Upstream = up
return nil
})
if err != nil {
return 0, err
}
// The pool rebinds itself on the config swap; do it now so the UI sees
// the new servers immediately rather than after the next reload.
a.pool.Rebind(up)
return a.cfg.Get().Version, nil
}
// searchLog pages the query log.
func (a *App) searchLog(_ context.Context, q api.LogQuery) (any, error) {
if a.logger == nil {
return map[string]any{"items": []any{}, "total": 0, "offset": 0}, nil
}
page, err := a.logger.Search(qlog.Query{
Limit: q.Limit, Offset: q.Offset, Text: q.Text, Action: q.Action,
Client: q.Client, RCode: q.RCode, BlockedOnly: q.BlockedOnly, Since: q.Since,
})
if err != nil {
return nil, err
}
out := make([]api.LogEntry, 0, len(page.Items))
for _, e := range page.Items {
out = append(out, toLogEntry(e))
}
return map[string]any{
"items": out,
"total": page.Total,
"offset": page.Offset,
"limit": page.Limit,
"has_more": len(out) == page.Limit,
}, nil
}
func toLogEntry(e qlog.Entry) api.LogEntry {
return api.LogEntry{
ID: e.ID, TS: e.TS, Client: e.Client, Group: e.Group, Name: e.Name,
QType: e.QType, QTypeStr: e.QTypeStr, RCode: e.RCode,
RCodeStr: e.RCodeName(), Action: string(e.Action), Response: e.Response,
Duration: e.Duration, Upstream: e.Upstream, List: e.List, Protocol: e.Protocol,
Blocked: e.Blocked,
}
}
// logStats is the live stats frame pushed over SSE.
func (a *App) logStats() any {
t := metrics.TotalsNow()
var hitRate float64
if t.Hits+t.Misses > 0 {
hitRate = float64(t.Hits) / float64(t.Hits+t.Misses)
}
var blockedPct float64
if t.Queries > 0 {
blockedPct = float64(t.Blocked) / float64(t.Queries)
}
return map[string]any{
"qps": a.series.qps(),
"blocked_pct": blockedPct,
"cache_hit_rate": hitRate,
"queries_total": t.Queries,
"blocked_total": t.Blocked,
"errors_total": t.Errors,
"inflight": t.Concurrent,
"uptime_s": int64(time.Since(a.startedAt).Seconds()),
}
}
// subscribeLog streams new entries to one SSE client.
func (a *App) subscribeLog() (<-chan api.LogEntry, func()) {
if a.logger == nil {
ch := make(chan api.LogEntry)
close(ch)
return ch, func() {}
}
src, cancel := a.logger.Subscribe()
out := make(chan api.LogEntry, 256)
done := make(chan struct{})
go func() {
defer close(out)
for {
select {
case <-done:
return
case e, ok := <-src:
if !ok {
return
}
select {
case out <- toLogEntry(e):
case <-done:
return
}
}
}
}()
return out, func() {
close(done)
cancel()
}
}
// clearLog empties the query log.
func (a *App) clearLog() error {
if a.logger == nil {
return nil
}
return a.logger.Clear()
}
// cacheStats returns the cache snapshot.
func (a *App) cacheStats() any {
if a.cache == nil {
return map[string]any{"enabled": false}
}
st := a.cache.Stats()
return map[string]any{
"enabled": st.Enabled,
"size": st.Size,
"entries": st.Entries,
"shards": st.Shards,
"min_ttl_ms": st.MinTTLMS,
"max_ttl_ms": st.MaxTTLMS,
"serve_stale_ms": st.ServeStaleM,
"negative_ttl_ms": st.NegativeMS,
"hit_rate": st.HitRate,
"hits": st.Hits,
"misses": st.Misses,
"stale_hits": st.StaleHits,
"evictions": st.Evictions,
"bytes": st.Bytes,
"bytes_human": st.MemoryHuman,
"persisted": st.Persist,
}
}
// cacheTop returns the hottest cached names.
func (a *App) cacheTop(limit int) any {
if a.cache == nil {
return []any{}
}
entries := a.cache.TopEntries(limit)
out := make([]map[string]any, 0, len(entries))
for _, e := range entries {
out = append(out, map[string]any{
"name": e.Name,
"qtype": e.Type,
"qtype_name": dns.Type(e.Type).String(),
"hits": e.Hits,
"bytes": e.Bytes,
})
}
return out
}
// flushCache empties the cache.
func (a *App) flushCache() int64 {
if a.cache == nil {
return 0
}
st := a.cache.Stats()
a.cache.Flush()
return int64(st.Entries)
}
// flush persists buffered state.
func (a *App) flush() {
if a.logger != nil {
a.logger.Flush()
}
a.snapshotCache()
}
// seriesStore keeps the rolling counters the dashboard charts read.
type seriesStore struct {
mu sync.Mutex
queries *metrics.Ring
blocked *metrics.Ring
errors *metrics.Ring
cacheHit *metrics.Ring
// The previous totals, used to derive per-bucket deltas.
lastQueries uint64
lastBlocked uint64
lastErrors uint64
lastHits uint64
lastAt time.Time
qpsValue float64
}
func newSeriesStore() *seriesStore {
return &seriesStore{
queries: metrics.NewRing(300, 5*time.Second),
blocked: metrics.NewRing(300, 5*time.Second),
errors: metrics.NewRing(300, 5*time.Second),
cacheHit: metrics.NewRing(300, 5*time.Second),
lastAt: time.Now(),
}
}
// sample folds the delta since the last sample into the rings. Sampling on a
// timer rather than per query keeps the query path free of any extra work and
// is accurate enough for a chart.
func (s *seriesStore) sample() {
t := metrics.TotalsNow()
s.mu.Lock()
now := time.Now()
dt := now.Sub(s.lastAt).Seconds()
if dt > 0 {
s.qpsValue = float64(t.Queries-s.lastQueries) / dt
}
// Ring buckets are 5s wide; scale the interval delta accordingly.
scale := dt / 5
if scale < 0 {
scale = 0
}
dq := int64(float64(t.Queries-s.lastQueries) * scale)
db := int64(float64(t.Blocked-s.lastBlocked) * scale)
de := int64(float64(t.Errors-s.lastErrors) * scale)
dc := int64(float64(t.Hits-s.lastHits) * scale)
if dq > 0 {
s.queries.Add(dq)
}
if db > 0 {
s.blocked.Add(db)
}
if de > 0 {
s.errors.Add(de)
}
if dc > 0 {
s.cacheHit.Add(dc)
}
s.lastQueries, s.lastBlocked, s.lastErrors, s.lastHits = t.Queries, t.Blocked, t.Errors, t.Hits
s.lastAt = now
s.mu.Unlock()
}
func (s *seriesStore) qps() float64 {
s.mu.Lock()
defer s.mu.Unlock()
return s.qpsValue
}
// dashboard builds the aggregated payload.
func (a *App) dashboard(_ context.Context, rangeKey string) (any, error) {
spec, ok := ranges[rangeKey]
if !ok {
spec = ranges["1h"]
}
t := metrics.TotalsNow()
hitRate := 0.0
if t.Hits+t.Misses > 0 {
hitRate = float64(t.Hits) / float64(t.Hits+t.Misses)
}
blockedPct := 0.0
if t.Queries > 0 {
blockedPct = float64(t.Blocked) / float64(t.Queries) * 100
}
// Build the series by walking the rings oldest-first and dropping anything
// older than the requested range.
var series []map[string]any
now := time.Now()
qts, qv := a.series.queries.Snapshot()
_, bv := a.series.blocked.Snapshot()
_, ev := a.series.errors.Snapshot()
_, cv := a.series.cacheHit.Snapshot()
series = make([]map[string]any, 0, len(qts))
for i := range qts {
age := now.Sub(time.Unix(qts[i], 0))
if age > spec.duration {
break
}
series = append(series, map[string]any{
"t": qts[i],
"queries": qv[i],
"blocked": bv[i],
"errors": ev[i],
"cache_hits": cv[i],
})
}
topBlocked := []any{}
topQueried := []any{}
if a.logger != nil {
for _, d := range a.logger.TopBlocked(15) {
topBlocked = append(topBlocked, map[string]any{
"domain": d.Name, "count": d.Count, "list": "", "qtype": d.QType,
})
}
for _, d := range a.logger.TopQueried(15) {
topQueried = append(topQueried, map[string]any{
"domain": d.Name, "count": d.Count, "qtype": d.QType, "blocked": d.Blocked > 0,
})
}
}
problems := a.Degraded()
status := "ok"
if len(problems) > 0 {
status = "degraded"
}
out := map[string]any{
"health": status,
"degraded_reason": strings.Join(problems, "; "),
"version": a.ver,
"node_id": a.clusterNodeID(),
"uptime_seconds": int64(time.Since(a.startedAt).Seconds()),
"queries_total": t.Queries,
"queries_per_sec": a.series.qps(),
"cache_hit_rate": hitRate,
"blocked_total": t.Blocked,
"blocked_pct": blockedPct,
"errors_total": t.Errors,
"inflight": t.Concurrent,
"series": series,
"top_blocked": topBlocked,
"top_queried": topQueried,
"upstreams": a.pool.Stats(),
"listeners": a.dns.Listeners(),
"transports": a.dns.ByTransport(),
"blocklist_rules": a.blocklistRuleCount(),
"cache_entries": a.cacheEntries(),
"config_version": a.cfg.Get().Version,
"generation": a.cfg.Generation(),
}
if a.logger != nil {
recent := a.logger.Recent(20)
items := make([]api.LogEntry, 0, len(recent))
for _, e := range recent {
items = append(items, toLogEntry(e))
}
out["recent"] = items
}
return out, nil
}
func (a *App) clusterNodeID() string {
if a.cl != nil {
return a.cl.NodeID()
}
return ""
}
func (a *App) blocklistRuleCount() int {
if a.blocks == nil {
return 0
}
if st := a.blocks.State(); st != nil && st.Set != nil {
return st.Set.Len()
}
return 0
}
func (a *App) cacheEntries() int {
if a.cache == nil {
return 0
}
return a.cache.Stats().Entries
}
// rangeSpec is one dashboard time range.
type rangeSpec struct {
duration time.Duration
buckets int
}
var ranges = map[string]rangeSpec{
"15m": {duration: 15 * time.Minute, buckets: 45},
"1h": {duration: time.Hour, buckets: 60},
"6h": {duration: 6 * time.Hour, buckets: 72},
"24h": {duration: 24 * time.Hour, buckets: 96},
}
+794
View File
@@ -0,0 +1,794 @@
package app
import (
"encoding/json"
"fmt"
"net/http"
"strings"
"testing"
"time"
"github.com/miekg/dns"
"git.k8s-git-k6n.net/mats/fancy-dns/internal/config"
)
// TestResolveUpstream covers the happy path: a name nobody blocks is forwarded
// to the upstream and the answer is cached.
func TestResolveUpstream(t *testing.T) {
h := newHarness(t, nil)
res := h.query("www.example.test", dns.TypeA)
if res.Rcode != dns.RcodeSuccess {
t.Fatalf("rcode = %s, want NOERROR", dns.RcodeToString[res.Rcode])
}
if len(res.Answer) == 0 {
t.Fatal("no answer records")
}
if got := res.Answer[0].(*dns.A).A.String(); got != "203.0.113.7" {
t.Errorf("answer = %s, want 203.0.113.7", got)
}
if h.up.hitCount("www.example.test.") == 0 {
t.Error("upstream was never asked")
}
// Second query must come from cache: the upstream counter must not move.
before := h.up.hitCount("www.example.test.")
h.query("www.example.test", dns.TypeA)
if after := h.up.hitCount("www.example.test."); after != before {
t.Errorf("upstream hit count moved from %d to %d: the cache did not serve the repeat query", before, after)
}
}
// TestBlockedNames covers every blocklist source shape: hosts-file entries
// (apex plus subdomains), RPZ wildcards and inline rules.
func TestBlockedNames(t *testing.T) {
h := newHarness(t, nil)
cases := []string{
"ads.example.com", // hosts file
"deep.ads.example.com", // hosts file, subdomain
"tracker.example.net",
"inline-blocked.example.com",
"anything.inline-sub.example.com",
}
for _, name := range cases {
res := h.query(name, dns.TypeA)
if res.Rcode != dns.RcodeNameError {
t.Errorf("%s: rcode = %s, want NXDOMAIN", name, dns.RcodeToString[res.Rcode])
}
}
// A blocked name must never reach the upstream.
if n := h.up.hitCount("ads.example.com."); n != 0 {
t.Errorf("blocked name reached the upstream %d times", n)
}
}
// TestAllowOverridesBlock checks that an inline allow entry beats a list rule.
func TestAllowOverridesBlock(t *testing.T) {
h := newHarness(t, nil)
res := h.query("allow.example.com", dns.TypeA)
// The fake upstream only knows *.test., so an allowed-but-unresolvable name
// must come back as NXDOMAIN *from upstream*, which means it got past the
// blocklist. Assert on the upstream counter instead of the rcode.
if h.up.hitCount("allow.example.com.") == 0 {
t.Error("allowed name did not reach the upstream, so the allow rule lost to a block rule")
}
_ = res
}
// TestLocalZone covers the local master file: exact, wildcard and NODATA.
func TestLocalZone(t *testing.T) {
h := newHarness(t, nil)
res := h.query("ns.local", dns.TypeA)
if len(res.Answer) != 1 {
t.Fatalf("want 1 answer for ns.local, got %d", len(res.Answer))
}
if got := res.Answer[0].(*dns.A).A.String(); got != "192.168.1.2" {
t.Errorf("ns.local = %s, want 192.168.1.2", got)
}
if !res.Authoritative {
t.Error("local zone answer should be authoritative")
}
// Wildcard covers a name one label below the wildcard's parent.
w := h.query("anything.wild.local", dns.TypeA)
if len(w.Answer) == 0 || w.Answer[0].(*dns.A).A.String() != "192.168.1.50" {
t.Errorf("wildcard answer wrong: %v", w.Answer)
}
// NODATA: the name exists but has no A record.
n := h.query("deep.svc.local", dns.TypeAAAA)
if n.Rcode != dns.RcodeSuccess {
t.Errorf("NODATA rcode = %s, want NOERROR", dns.RcodeToString[n.Rcode])
}
if len(n.Answer) != 0 {
t.Errorf("NODATA returned %d answers", len(n.Answer))
}
// NXDOMAIN inside a zone we are authoritative for.
x := h.query("nope.local", dns.TypeA)
if x.Rcode != dns.RcodeNameError {
t.Errorf("in-zone NXDOMAIN rcode = %s", dns.RcodeToString[x.Rcode])
}
}
// TestACLDenyGroup checks that a deny group refuses before anything else,
// including a name the local zone could answer.
func TestACLDenyGroup(t *testing.T) {
h := newHarness(t, func(c *config.Config) {
c.ACL.Groups = []config.Group{{
Name: "deny-all",
Match: config.Match{CIDRs: []string{"127.0.0.0/8"}},
Policy: "deny",
Comment: "test",
}}
})
res := h.query("www.example.test", dns.TypeA)
if res.Rcode != dns.RcodeRefused {
t.Errorf("rcode = %s, want REFUSED", dns.RcodeToString[res.Rcode])
}
if h.up.hitCount("www.example.test.") != 0 {
t.Error("a denied client still reached the upstream")
}
}
// TestACLAllowDomains checks a group's allow_domains acts as a filter.
func TestACLAllowDomains(t *testing.T) {
h := newHarness(t, func(c *config.Config) {
c.ACL.Groups = []config.Group{{
Name: "only-test",
Match: config.Match{CIDRs: []string{"127.0.0.0/8"}},
Policy: "allow",
AllowDomains: []string{"*.test"},
}}
})
// Inside the allow list: resolves.
if r := h.query("www.example.test", dns.TypeA); r.Rcode != dns.RcodeSuccess {
t.Errorf("allowed domain rcode = %s", dns.RcodeToString[r.Rcode])
}
// Outside it: blocked by the group.
if r := h.query("ads.example.com", dns.TypeA); r.Rcode != dns.RcodeNameError {
t.Errorf("domain outside allow_domains rcode = %s, want NXDOMAIN", dns.RcodeToString[r.Rcode])
}
}
// TestACLSkipBlocklist checks a group can opt out of blocklist filtering.
func TestACLSkipBlocklist(t *testing.T) {
h := newHarness(t, func(c *config.Config) {
c.ACL.Groups = []config.Group{{
Name: "trusted",
Match: config.Match{CIDRs: []string{"127.0.0.0/8"}},
Policy: "allow",
SkipBlocklist: true,
}}
})
h.query("ads.example.com", dns.TypeA)
if h.up.hitCount("ads.example.com.") == 0 {
t.Error("skip_blocklist group still blocked the name")
}
}
// TestBlockActions covers the non-default blocklist responses.
func TestBlockActions(t *testing.T) {
t.Run("zero", func(t *testing.T) {
h := newHarness(t, func(c *config.Config) {
c.Block.DefaultAction = "zero"
c.Block.ResponseIP = "0.0.0.0"
})
res := h.query("ads.example.com", dns.TypeA)
if res.Rcode != dns.RcodeSuccess {
t.Fatalf("rcode = %s, want NOERROR", dns.RcodeToString[res.Rcode])
}
if len(res.Answer) != 1 || res.Answer[0].(*dns.A).A.String() != "0.0.0.0" {
t.Errorf("sinkhole answer = %v", res.Answer)
}
// AAAA for the same name must get the v6 sinkhole, not the v4 one.
res6 := h.query("ads.example.com", dns.TypeAAAA)
if len(res6.Answer) != 1 || res6.Answer[0].(*dns.AAAA).AAAA.String() != "::" {
t.Errorf("v6 sinkhole answer = %v", res6.Answer)
}
})
t.Run("refused", func(t *testing.T) {
h := newHarness(t, func(c *config.Config) { c.Block.DefaultAction = "refused" })
if r := h.query("ads.example.com", dns.TypeA); r.Rcode != dns.RcodeRefused {
t.Errorf("rcode = %s, want REFUSED", dns.RcodeToString[r.Rcode])
}
})
t.Run("redirect", func(t *testing.T) {
h := newHarness(t, func(c *config.Config) {
c.Block.DefaultAction = "redirect"
c.Block.RedirectName = "blockpage.local"
})
res := h.query("ads.example.com", dns.TypeA)
if len(res.Answer) == 0 {
t.Fatal("redirect produced no answer")
}
cname, ok := res.Answer[0].(*dns.CNAME)
if !ok {
t.Fatalf("first answer is %T, want CNAME", res.Answer[0])
}
if cname.Target != "blockpage.local." {
t.Errorf("cname target = %s", cname.Target)
}
})
}
// TestMalformedQueries covers the input validation path.
func TestMalformedQueries(t *testing.T) {
h := newHarness(t, nil)
// Zero questions: the server must answer FORMERR, not crash or hang.
m := new(dns.Msg)
m.Id = dns.Id()
m.Question = nil
c := &dns.Client{Timeout: 3 * time.Second}
res, _, err := c.Exchange(m, h.addr)
if err != nil {
t.Fatalf("exchange: %v", err)
}
if res.Rcode != dns.RcodeFormatError {
t.Errorf("rcode = %s, want FORMERR", dns.RcodeToString[res.Rcode])
}
}
// TestTCPSameAnswer checks TCP returns the same answer as UDP, and that a large
// UDP answer is truncated rather than dropped.
func TestTCPSameAnswer(t *testing.T) {
h := newHarness(t, nil)
m := new(dns.Msg)
m.SetQuestion("www.example.test.", dns.TypeA)
m.RecursionDesired = true
tcp := &dns.Client{Net: "tcp", Timeout: 5 * time.Second}
res, _, err := tcp.Exchange(m, h.addr)
if err != nil {
t.Fatalf("tcp exchange: %v", err)
}
if res.Rcode != dns.RcodeSuccess || len(res.Answer) == 0 {
t.Errorf("tcp answer wrong: rcode=%s answers=%d", dns.RcodeToString[res.Rcode], len(res.Answer))
}
}
// TestAPIAuth checks the admin API refuses unauthenticated reads and accepts
// the configured key.
func TestAPIAuth(t *testing.T) {
h := newHarness(t, nil)
// No key: 401.
res, err := httpGet("http://"+h.api+"/api/whoami", "")
if err != nil {
t.Fatal(err)
}
if res.status != 401 {
t.Errorf("unauthenticated /whoami = %d, want 401", res.status)
}
res, err = httpGet("http://"+h.api+"/api/whoami", "wrong-key")
if err != nil {
t.Fatal(err)
}
if res.status != 401 {
t.Errorf("bad key /whoami = %d, want 401", res.status)
}
res, err = httpGet("http://"+h.api+"/api/whoami", h.key)
if err != nil {
t.Fatal(err)
}
if res.status != 200 {
t.Errorf("good key /whoami = %d, want 200", res.status)
}
var who struct {
Name string `json:"name"`
CanWrite bool `json:"can_write"`
}
if err := json.Unmarshal(res.body, &who); err != nil {
t.Fatal(err)
}
if who.Name != "rw-1" || !who.CanWrite {
t.Errorf("whoami = %+v", who)
}
// Health is deliberately unauthenticated for probes.
res, err = httpGet("http://"+h.api+"/api/healthz", "")
if err != nil {
t.Fatal(err)
}
if res.status != 200 {
t.Errorf("/healthz = %d, want 200", res.status)
}
}
// TestReadOnlyKeyCannotWrite checks a read-only key is refused on mutations.
func TestReadOnlyKeyCannotWrite(t *testing.T) {
h := newHarness(t, func(c *config.Config) {
c.API.Auth.ReadOnlyAPIKeys = []string{"ro-key"}
})
res, err := httpRequest("POST", "http://"+h.api+"/api/cache/flush", "ro-key", "")
if err != nil {
t.Fatal(err)
}
if res.status != 403 {
t.Errorf("read-only flush = %d, want 403", res.status)
}
res, err = httpRequest("POST", "http://"+h.api+"/api/cache/flush", h.key, "")
if err != nil {
t.Fatal(err)
}
if res.status != 200 {
t.Errorf("read-write flush = %d, want 200", res.status)
}
}
// TestDomainAddDeleteRoundTrip is the operator's core loop: add a rule, see it
// take effect on a live query, then remove it.
func TestDomainAddDeleteRoundTrip(t *testing.T) {
h := newHarness(t, nil)
// The name lives under .test. so the fake upstream resolves it: that way
// NOERROR unambiguously means "not blocked" and NXDOMAIN unambiguously
// means "the blocklist answered", with no third possibility to confuse the
// assertion.
const name = "newly-blocked.example.test"
before := h.up.hitCount(dns.Fqdn(name))
if r := h.query(name, dns.TypeA); r.Rcode != dns.RcodeSuccess {
t.Fatalf("precondition: rcode = %s, want NOERROR", dns.RcodeToString[r.Rcode])
}
if h.up.hitCount(dns.Fqdn(name)) == before {
t.Error("precondition: the upstream was not consulted")
}
code, body := h.do("POST", "/api/domains", `{"name":"`+name+`","action":"block"}`)
if code != 200 {
t.Fatalf("add domain = %d: %s", code, body)
}
if r := h.query(name, dns.TypeA); r.Rcode != dns.RcodeNameError {
t.Errorf("after add, rcode = %s, want NXDOMAIN", dns.RcodeToString[r.Rcode])
}
// The rule must survive a cache flush, i.e. it is really in the config and
// not just a transient matcher state.
if code, _ := h.do("POST", "/api/cache/flush", ""); code != 200 {
t.Fatalf("flush = %d", code)
}
if r := h.query(name, dns.TypeA); r.Rcode != dns.RcodeNameError {
t.Errorf("after flush, rcode = %s, want NXDOMAIN", dns.RcodeToString[r.Rcode])
}
code, body = h.do("DELETE", "/api/domains/"+name, "")
if code != 200 {
t.Fatalf("delete domain = %d: %s", code, body)
}
if r := h.query(name, dns.TypeA); r.Rcode != dns.RcodeSuccess {
t.Errorf("after delete, rcode = %s, want NOERROR", dns.RcodeToString[r.Rcode])
}
}
// TestDomainAddInvalid checks the API rejects a bad payload with 400 rather
// than writing a broken rule.
func TestDomainAddInvalid(t *testing.T) {
h := newHarness(t, nil)
code, _ := h.do("POST", "/api/domains", `{"name":"","action":"block"}`)
if code != 400 {
t.Errorf("empty name = %d, want 400", code)
}
code, _ = h.do("POST", "/api/domains", `{"name":"x.example.com","action":"nonsense"}`)
if code != 400 {
t.Errorf("bad action = %d, want 400", code)
}
}
// TestACLUpdateRoundTrip checks the clients editor's write path and that a
// saved group is enforced immediately.
func TestACLUpdateRoundTrip(t *testing.T) {
h := newHarness(t, nil)
// Write a deny group through the API.
body := `{"groups":[{"name":"nobody","match":{"cidrs":["127.0.0.0/8"]},"policy":"deny","comment":"test"}],"default_policy":"allow"}`
code, out := h.do("PUT", "/api/clients", body)
if code != 200 {
t.Fatalf("put clients = %d: %s", code, out)
}
if r := h.query("www.example.test", dns.TypeA); r.Rcode != dns.RcodeRefused {
t.Errorf("after save, rcode = %s, want REFUSED", dns.RcodeToString[r.Rcode])
}
// Read it back.
code, out = h.get("/api/clients")
if code != 200 {
t.Fatalf("get clients = %d", code)
}
if !strings.Contains(string(out), "nobody") {
t.Error("saved group is missing from the read-back")
}
// An invalid group must be rejected with 400 and not applied.
bad := `{"groups":[{"name":"x","match":{"cidrs":["not-a-cidr"]},"policy":"deny"}],"default_policy":"allow"}`
code, _ = h.do("PUT", "/api/clients", bad)
if code != 400 {
t.Errorf("bad CIDR = %d, want 400", code)
}
}
// TestUpstreamUpdate checks the upstreams editor round-trips and rebinds the
// pool without a restart.
func TestUpstreamUpdate(t *testing.T) {
h := newHarness(t, nil)
body := fmt.Sprintf(`{"policy":"failover","servers":[{"addr":%q,"proto":"udp","weight":1}],"max_attempts":1,"breaker_threshold":5,"healthcheck_interval_s":0}`, h.up.addr)
code, out := h.do("PUT", "/api/upstreams", body)
if code != 200 {
t.Fatalf("put upstreams = %d: %s", code, out)
}
code, out = h.get("/api/upstreams")
if code != 200 {
t.Fatalf("get upstreams = %d", code)
}
if !strings.Contains(string(out), "failover") {
t.Error("policy did not persist")
}
// Still resolves after the rebind.
if r := h.query("www.example.test", dns.TypeA); r.Rcode != dns.RcodeSuccess {
t.Errorf("after upstream update, rcode = %s", dns.RcodeToString[r.Rcode])
}
// Invalid config rejected.
code, _ = h.do("PUT", "/api/upstreams", `{"policy":"nonsense","servers":[]}`)
if code != 400 {
t.Errorf("bad policy = %d, want 400", code)
}
}
// TestSettingsValidateAndSave covers the config editor: validate, save, and
// confirm the file on disk changed.
func TestSettingsValidateAndSave(t *testing.T) {
h := newHarness(t, nil)
code, out := h.get("/api/settings")
if code != 200 {
t.Fatalf("get settings = %d", code)
}
var s struct {
ConfigYAML string `json:"config_yaml"`
Path string `json:"path"`
Writable bool `json:"writable"`
}
if err := json.Unmarshal(out, &s); err != nil {
t.Fatal(err)
}
if !s.Writable {
t.Error("config file should be writable in the test harness")
}
// A valid edit round-trips.
edited := strings.Replace(s.ConfigYAML, "max_concurrent: 1024", "max_concurrent: 2048", 1)
if edited == s.ConfigYAML {
t.Fatal("test could not find the setting it wanted to edit")
}
payload, _ := json.Marshal(map[string]string{"config_yaml": edited})
code, out = h.do("POST", "/api/settings/validate", string(payload))
if code != 200 {
t.Fatalf("validate = %d: %s", code, out)
}
var v struct {
OK bool `json:"ok"`
Errors []struct {
Field string `json:"field"`
Message string `json:"message"`
} `json:"errors"`
}
if err := json.Unmarshal(out, &v); err != nil {
t.Fatal(err)
}
if !v.OK {
t.Errorf("valid config reported errors: %+v", v.Errors)
}
code, out = h.do("PUT", "/api/settings", string(payload))
if code != 200 {
t.Fatalf("save settings = %d: %s", code, out)
}
// The file on disk must now contain the edit.
onDisk := readFileString(t, s.Path)
if !strings.Contains(onDisk, "max_concurrent: 2048") {
t.Error("config file on disk was not updated")
}
// An invalid document must be refused and the file left alone.
badPayload, _ := json.Marshal(map[string]string{"config_yaml": "server:\n max_concurrent: -5\n"})
code, _ = h.do("PUT", "/api/settings", string(badPayload))
if code != 400 {
t.Errorf("invalid save = %d, want 400", code)
}
if readFileString(t, s.Path) != onDisk {
t.Error("a rejected config write still modified the file")
}
}
// TestBlocklistToggle checks a disabled list stops applying and its cached
// answers are dropped.
func TestBlocklistToggle(t *testing.T) {
h := newHarness(t, nil)
const name = "blocked.example.test"
if r := h.query(name, dns.TypeA); r.Rcode != dns.RcodeNameError {
t.Fatal("precondition: name should be blocked")
}
code, out := h.do("POST", "/api/blocklists/toggle", `{"name":"test-hosts","enabled":false}`)
if code != 200 {
t.Fatalf("toggle = %d: %s", code, out)
}
// The recompile runs in the background, so poll rather than assume.
waitFor(t, 5*time.Second, func() bool {
return h.query(name, dns.TypeA).Rcode == dns.RcodeSuccess
})
// Re-enabling must block it again: a one-way toggle would be a nasty
// surprise.
if code, _ := h.do("POST", "/api/blocklists/toggle", `{"name":"test-hosts","enabled":true}`); code != 200 {
t.Fatalf("re-enable = %d", code)
}
waitFor(t, 5*time.Second, func() bool {
return h.query(name, dns.TypeA).Rcode == dns.RcodeNameError
})
}
// TestDashboardAndStatus checks the aggregate endpoints return the fields the
// UI reads, so a frontend change cannot silently drift from the API.
func TestDashboardAndStatus(t *testing.T) {
h := newHarness(t, nil)
h.query("www.example.test", dns.TypeA)
h.query("ads.example.com", dns.TypeA)
code, out := h.get("/api/dashboard?range=1h")
if code != 200 {
t.Fatalf("dashboard = %d", code)
}
var d struct {
Health string `json:"health"`
Queries uint64 `json:"queries_total"`
Blocked uint64 `json:"blocked_total"`
HitRate any `json:"cache_hit_rate"`
Series []any `json:"series"`
TopBlocked []struct {
Domain string `json:"domain"`
Count uint64 `json:"count"`
} `json:"top_blocked"`
Upstreams []struct {
Addr string `json:"addr"`
Health string `json:"health"`
} `json:"upstreams"`
}
if err := json.Unmarshal(out, &d); err != nil {
t.Fatal(err)
}
if d.Health != "ok" {
t.Errorf("health = %q, want ok", d.Health)
}
if d.Queries == 0 {
t.Error("queries_total is zero after two queries")
}
if d.Blocked == 0 {
t.Error("blocked_total is zero after a blocked query")
}
if len(d.Upstreams) == 0 {
t.Error("upstreams empty in dashboard")
}
if len(d.Series) == 0 {
t.Error("series empty in dashboard")
}
_ = d.HitRate
_ = d.TopBlocked
code, out = h.get("/api/status")
if code != 200 {
t.Fatalf("status = %d", code)
}
if !strings.Contains(string(out), "listeners") {
t.Error("status has no listeners section")
}
}
// TestLogsAndClear covers the query log endpoint and the clear action.
func TestLogsAndClear(t *testing.T) {
h := newHarness(t, nil)
h.query("www.example.test", dns.TypeA)
h.query("ads.example.com", dns.TypeA)
waitFor(t, 3*time.Second, func() bool {
code, out := h.get("/api/logs?limit=50")
if code != 200 {
return false
}
return strings.Contains(string(out), "example.test")
})
code, out := h.get("/api/logs?q=ads.example.com&blocked=1")
if code != 200 {
t.Fatalf("logs = %d", code)
}
if !strings.Contains(string(out), "ads.example.com") {
t.Error("filtered log did not include the blocked query")
}
if code, _ := h.do("POST", "/api/logs/clear", ""); code != 200 {
t.Errorf("clear logs = %d, want 200", code)
}
}
// TestCacheEndpoints covers the cache stats/top/flush trio.
func TestCacheEndpoints(t *testing.T) {
h := newHarness(t, nil)
h.query("www.example.test", dns.TypeA)
code, out := h.get("/api/cache")
if code != 200 {
t.Fatalf("cache = %d", code)
}
var c struct {
Entries int `json:"entries"`
Shards int `json:"shards"`
HitRate float64 `json:"hit_rate"`
}
if err := json.Unmarshal(out, &c); err != nil {
t.Fatal(err)
}
if c.Shards <= 0 {
t.Error("shards is zero")
}
code, out = h.get("/api/cache/top?limit=10")
if code != 200 {
t.Fatalf("cache top = %d", code)
}
if !strings.Contains(string(out), "example.test") {
t.Error("cache top does not list the queried name")
}
if code, _ := h.do("POST", "/api/cache/flush", ""); code != 200 {
t.Errorf("flush = %d, want 200", code)
}
}
// TestReloadFromDisk checks SIGHUP-style reload applies a hand-edited file and
// reports a broken one without dropping the running config.
func TestReloadFromDisk(t *testing.T) {
h := newHarness(t, nil)
// Break the file, then reload: must fail and keep serving.
broken := strings.Replace(h.OriginalConfig(), "max_concurrent: 1024", "max_concurrent: not-a-number", 1)
if broken == h.OriginalConfig() {
t.Fatal("test could not find the setting it wanted to break")
}
writeFileString(t, h.app.cfg.Path(), broken)
if err := h.app.Reload(); err == nil {
t.Fatal("reload of a broken config should fail")
}
if h.app.cfg.LastError() == "" {
t.Error("a failed reload should record the error for the health endpoint")
}
// The server keeps working with the previous config.
if r := h.query("www.example.test", dns.TypeA); r.Rcode != dns.RcodeSuccess {
t.Errorf("after a failed reload, rcode = %s", dns.RcodeToString[r.Rcode])
}
// Fix the file and reload: should succeed and clear the error. The whole
// document is rewritten from the harness's own config, changing only the
// broken value, so the reload is testing a realistic edit rather than a
// minimal stub.
fixed := strings.Replace(h.OriginalConfig(), "max_concurrent: 1024", "max_concurrent: 512", 1)
if fixed == h.OriginalConfig() {
t.Fatal("test could not find the setting it wanted to edit")
}
writeFileString(t, h.app.cfg.Path(), fixed)
if err := h.app.Reload(); err != nil {
t.Fatalf("reload of a fixed config failed: %v", err)
}
if h.app.cfg.LastError() != "" {
t.Error("a successful reload should clear the recorded error")
}
}
// TestLogStream checks the SSE endpoint emits a ready frame and at least one
// entry frame, and that it terminates when the client disconnects.
func TestLogStream(t *testing.T) {
h := newHarness(t, nil)
req, err := httpNewRequest("GET", "http://"+h.api+"/api/logs/stream", h.key)
if err != nil {
t.Fatal(err)
}
// A streaming client must not time out while idle, so give it a generous
// deadline; the test closes the body when it has seen what it needs.
streamClient := &http.Client{Timeout: 20 * time.Second}
resp, err := streamClient.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
t.Fatalf("stream = %d", resp.StatusCode)
}
if ct := resp.Header.Get("Content-Type"); !strings.HasPrefix(ct, "text/event-stream") {
t.Errorf("content-type = %q, want text/event-stream", ct)
}
if resp.Header.Get("X-Accel-Buffering") != "no" {
t.Error("missing X-Accel-Buffering: no, a proxy would buffer the stream")
}
// Generate a query and read frames until we see an entry.
go func() {
time.Sleep(150 * time.Millisecond)
h.query("www.example.test", dns.TypeA)
}()
buf := make([]byte, 4096)
seen := map[string]bool{}
deadline := time.Now().Add(5 * time.Second)
for time.Now().Before(deadline) {
n, err := resp.Body.Read(buf)
if err != nil {
break
}
chunk := string(buf[:n])
for _, ev := range []string{"event: ready", "event: entry", "event: stats"} {
if strings.Contains(chunk, ev) {
seen[ev] = true
}
}
if seen["event: ready"] && seen["event: entry"] {
break
}
}
if !seen["event: ready"] {
t.Error("no ready event")
}
if !seen["event: entry"] {
t.Error("no entry event for a query issued while streaming")
}
}
// TestMetricsEndpoint checks Prometheus output is served and protected.
func TestMetricsEndpoint(t *testing.T) {
h := newHarness(t, nil)
res, err := httpGet("http://"+h.api+"/api/metrics", "")
if err != nil {
t.Fatal(err)
}
if res.status != 200 {
t.Errorf("unprotected /metrics = %d, want 200", res.status)
}
if !strings.Contains(string(res.body), "fancydns_queries_total") {
t.Error("metrics output has no fancydns_queries_total")
}
// With protection on, an unauthenticated scrape must fail.
h2 := newHarness(t, func(c *config.Config) { c.Metrics.Protect = true })
res, err = httpGet("http://"+h2.api+"/api/metrics", "")
if err != nil {
t.Fatal(err)
}
if res.status != 401 {
t.Errorf("protected /metrics without a key = %d, want 401", res.status)
}
res, err = httpGet("http://"+h2.api+"/api/metrics", h2.key)
if err != nil {
t.Fatal(err)
}
if res.status != 200 {
t.Errorf("protected /metrics with a key = %d, want 200", res.status)
}
}
// TestConcurrentQueries hammers the server from many goroutines to catch races
// and to make sure the shared state is actually safe. Run with -race.
func TestConcurrentQueries(t *testing.T) {
h := newHarness(t, nil)
const workers, each = 16, 25
done := make(chan error, workers)
for w := 0; w < workers; w++ {
go func(w int) {
m := new(dns.Msg)
m.SetQuestion("www.example.test.", dns.TypeA)
m.RecursionDesired = true
c := &dns.Client{Net: "udp", Timeout: 5 * time.Second}
for i := 0; i < each; i++ {
if _, _, err := c.Exchange(m, h.addr); err != nil {
done <- err
return
}
}
done <- nil
}(w)
}
for w := 0; w < workers; w++ {
if err := <-done; err != nil {
t.Fatalf("concurrent query failed: %v", err)
}
}
// Also exercise the API concurrently, since it reads the same state.
apiDone := make(chan error, workers)
for w := 0; w < workers; w++ {
go func() {
res, err := httpGet("http://"+h.api+"/api/dashboard", h.key)
if err != nil {
apiDone <- err
return
}
if res.status != 200 {
apiDone <- fmt.Errorf("dashboard = %d", res.status)
return
}
apiDone <- nil
}()
}
for w := 0; w < workers; w++ {
if err := <-apiDone; err != nil {
t.Errorf("concurrent api call failed: %v", err)
}
}
}
+94
View File
@@ -0,0 +1,94 @@
package app
import (
"io"
"net/http"
"os"
"strings"
"testing"
"time"
)
// httpResult is a small wrapper so the tests read cleanly.
type httpResult struct {
status int
header http.Header
body []byte
}
var testClient = &http.Client{Timeout: 10 * time.Second}
func httpNewRequest(method, url, key string) (*http.Request, error) {
req, err := http.NewRequest(method, url, nil)
if err != nil {
return nil, err
}
if key != "" {
req.Header.Set("X-API-Key", key)
}
return req, nil
}
func httpGet(url, key string) (httpResult, error) {
req, err := httpNewRequest("GET", url, key)
if err != nil {
return httpResult{}, err
}
return httpDo(req)
}
func httpRequest(method, url, key, body string) (httpResult, error) {
var rdr io.Reader
if body != "" {
rdr = bytesReader(body)
}
req, err := http.NewRequest(method, url, rdr)
if err != nil {
return httpResult{}, err
}
req.Header.Set("X-API-Key", key)
req.Header.Set("Content-Type", "application/json")
return httpDo(req)
}
func httpDo(req *http.Request) (httpResult, error) {
res, err := testClient.Do(req)
if err != nil {
return httpResult{}, err
}
defer res.Body.Close()
b, _ := io.ReadAll(res.Body)
return httpResult{status: res.StatusCode, header: res.Header, body: b}, nil
}
func bytesReader(s string) io.Reader { return strings.NewReader(s) }
// waitFor polls cond until it holds or the timeout expires. Used where an
// action is intentionally asynchronous (blocklist refresh runs in a goroutine).
func waitFor(t *testing.T, timeout time.Duration, cond func() bool) {
t.Helper()
deadline := time.Now().Add(timeout)
for time.Now().Before(deadline) {
if cond() {
return
}
time.Sleep(25 * time.Millisecond)
}
t.Fatalf("condition not met within %s", timeout)
}
func readFileString(t *testing.T, path string) string {
t.Helper()
b, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read %s: %v", path, err)
}
return string(b)
}
func writeFileString(t *testing.T, path, content string) {
t.Helper()
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
t.Fatalf("write %s: %v", path, err)
}
}
+647
View File
@@ -0,0 +1,647 @@
package blocklist
import (
"context"
"crypto/sha256"
"crypto/tls"
"encoding/hex"
"errors"
"fmt"
"io"
"log/slog"
"net/http"
"os"
"path/filepath"
"sort"
"strings"
"sync"
"sync/atomic"
"time"
"git.k8s-git-k6n.net/mats/fancy-dns/internal/config"
"git.k8s-git-k6n.net/mats/fancy-dns/internal/zonefile"
)
// Source is a compiled list plus the bookkeeping the UI shows.
type Source struct {
Name string
Format Format
Meta SourceMeta
Stats ParseStats
// Digest identifies the compiled content, so instances can tell whether
// they hold the same rules without shipping the rules around.
Digest string
}
// State is the compiled blocklist plus its per-source detail.
type State struct {
Set *Set
Sources []Source
// Duration is how long the last full compile took.
Duration time.Duration
// Errors are per-source failures from the last refresh. A source that
// fails keeps its previous content, so the errors are warnings, not
// fatal.
Errors map[string]string
// Digest covers every source, in order.
Digest string
}
// Manager owns the blocklist lifecycle: fetching sources, compiling them, and
// atomically swapping the compiled Set into the holder.
type Manager struct {
cfg *config.Store
log *slog.Logger
hold *Holder
http *http.Client
stats *StateHolder
mu sync.Mutex
running map[string]bool // in-flight refreshes, by source name
// trigger lets the cluster ask one instance to refresh (leader election).
trigger chan string
stop chan struct{}
wg sync.WaitGroup
}
// StateHolder is a swappable State for the API.
type StateHolder struct{ cur atomic.Pointer[State] }
// NewStateHolder wraps an initial state.
func NewStateHolder(s *State) *StateHolder {
h := &StateHolder{}
if s != nil {
h.cur.Store(s)
}
return h
}
// Get returns the current state.
func (h *StateHolder) Get() *State { return h.cur.Load() }
// ManagerOptions configures a Manager.
type ManagerOptions struct {
// Config is the hot-reloadable config store.
Config *config.Store
// Logger is the structured logger.
Logger *slog.Logger
// Client is the HTTP client used for list fetches; nil builds a default.
Client *http.Client
}
// NewManager builds a Manager.
func NewManager(opt ManagerOptions) *Manager {
log := opt.Logger
if log == nil {
log = slog.Default()
}
client := opt.Client
if client == nil {
client = &http.Client{
Timeout: 120 * time.Second,
Transport: &http.Transport{
MaxIdleConns: 16,
MaxIdleConnsPerHost: 4,
IdleConnTimeout: 60 * time.Second,
},
}
}
m := &Manager{
cfg: opt.Config,
log: log.With("component", "blocklist"),
hold: NewHolder(nil),
http: client,
running: make(map[string]bool),
trigger: make(chan string, 16),
stop: make(chan struct{}),
}
m.stats = NewStateHolder(&State{Errors: map[string]string{}})
return m
}
// Holder returns the compiled-set holder the engine reads.
func (m *Manager) Holder() *Holder { return m.hold }
// State returns the detailed state the API serves.
func (m *Manager) State() *State { return m.stats.Get() }
// RefreshAll compiles every enabled source plus the local zones. The previous
// set is kept if every source fails, so a bad deploy never leaves the
// resolver with no blocklists at all.
func (m *Manager) RefreshAll(ctx context.Context) (State, error) {
start := time.Now()
cfg := m.cfg.Get()
b := NewBuilder()
var (
sources []Source
errs = map[string]string{}
)
// Inline block/allow entries first: they are cheap, and putting them
// before the lists means a later list cannot override an operator's
// inline decision without a priority bump.
if len(cfg.Block.BlockEntries) > 0 {
rules, st, err := parseInline(cfg.Block.BlockEntries, ActionBlock, cfg.Block.ResponseTTL)
if err == nil {
for _, r := range rules {
b.Add(r, "inline:block", nil)
}
b.NoteSource(SourceMeta{
Name: "inline:block", Format: string(FormatPlain), Rules: len(rules),
Bytes: st.Bytes, UpdatedAt: start, Duration: time.Since(start), Enabled: true,
})
} else {
errs["inline:block"] = err.Error()
}
}
if len(cfg.Block.AllowEntries) > 0 {
rules, st, err := parseInline(cfg.Block.AllowEntries, ActionAllow, cfg.Block.ResponseTTL)
if err == nil {
for _, r := range rules {
b.Add(r, "inline:allow", nil)
}
b.NoteSource(SourceMeta{
Name: "inline:allow", Format: string(FormatPlain), Rules: len(rules),
Bytes: st.Bytes, UpdatedAt: start, Duration: time.Since(start), Enabled: true,
})
} else {
errs["inline:allow"] = err.Error()
}
}
// Local RPZ-style zone files.
for _, pattern := range cfg.Block.LocalZones {
files, err := filepath.Glob(m.cfg.Get().AbsPath(pattern))
if err != nil || len(files) == 0 {
// A glob that matches nothing is a configuration smell, not a
// reason to fail the whole compile.
errs[pattern] = "no files matched"
continue
}
sort.Strings(files)
rules, st, err := parseRPZFiles(files)
if err != nil {
errs[pattern] = err.Error()
continue
}
name := "local:" + pattern
for _, r := range rules {
b.Add(r, name, nil)
}
b.NoteSource(SourceMeta{
Name: name, Format: string(FormatRPZ), Rules: len(rules), Bytes: st.Bytes,
UpdatedAt: start, Duration: time.Since(start), Enabled: true, Path: pattern,
})
}
// Remote / on-disk lists.
for _, l := range cfg.Block.Lists {
if !l.Enabled {
b.NoteSource(SourceMeta{
Name: l.Name, Format: l.Format, Enabled: false, URL: l.URL,
Path: cfg.ListPath(l),
})
continue
}
if !m.claim(l.Name) {
errs[l.Name] = "refresh already in flight"
continue
}
src, err := m.loadOne(ctx, cfg, l)
m.release(l.Name)
if err != nil {
errs[l.Name] = err.Error()
// Keep the previous content: record what we know about the
// source so the UI can show it as stale rather than missing.
if prev, ok := m.previous(l.Name); ok {
src = rawSource{
Name: prev.Name, Format: prev.Format, Meta: prev.Meta,
Stats: prev.Stats, Digest: prev.Digest,
}
src.Meta.Err = err.Error()
src.Meta.Stale = true
} else {
continue
}
}
for _, r := range src.rules {
b.Add(r, l.Name, l.Categories)
}
sources = append(sources, src.toSource())
}
// Carry forward bookkeeping for sources that contributed nothing this
// round (disabled, or skipped because a refresh was already in flight), so
// the UI keeps showing them. Only the *metadata* is carried: their rules
// are deliberately not re-added, because a disabled source must stop
// matching, and a source that failed its refresh must fall back to the
// copy the manager kept for it (above), not to a silent re-add.
for _, prev := range m.previousAll() {
if _, ok := b.metaFor(prev.Name); ok {
continue
}
carried := prev
carried.Meta.Rules = 0
sources = append(sources, carried)
}
set := b.Build()
state := State{
Set: set,
Sources: sources,
Duration: time.Since(start),
Errors: errs,
Digest: set.Digest(),
}
m.hold.Set(set)
m.stats.cur.Store(&state)
m.log.Info("blocklist compiled",
"rules", set.Len(), "sources", len(sources), "errors", len(errs),
"duration", state.Duration)
return state, nil
}
func (m *Manager) previous(name string) (Source, bool) {
st := m.stats.Get()
if st == nil {
return Source{}, false
}
for _, s := range st.Sources {
if s.Name == name {
return s, true
}
}
return Source{}, false
}
func (m *Manager) previousAll() []Source {
st := m.stats.Get()
if st == nil {
return nil
}
return st.Sources
}
func (m *Manager) claim(name string) bool {
m.mu.Lock()
defer m.mu.Unlock()
if m.running[name] {
return false
}
m.running[name] = true
return true
}
func (m *Manager) release(name string) {
m.mu.Lock()
delete(m.running, name)
m.mu.Unlock()
}
// loadOne fetches and compiles a single list source.
func (m *Manager) loadOne(ctx context.Context, cfg *config.Config, l config.ListSource) (rawSource, error) {
start := time.Now()
path := cfg.ListPath(l)
var (
data []byte
etag = l.ETag
lastMod = l.LastModified
httpStatus int
)
needFetch := true
if path != "" {
// Prefer the local copy: it is what a restart without network sees.
if fi, err := os.Stat(path); err == nil {
if fi.Size() < l.MaxBytes {
data, _ = os.ReadFile(path)
needFetch = false
}
}
}
if l.URL != "" && needFetch {
fetched, status, newETag, newLM, err := m.fetch(ctx, l)
if err != nil {
if len(data) == 0 {
return rawSource{}, fmt.Errorf("fetch: %w", err)
}
// Keep the local copy and report the failure; a stale list beats
// no list.
httpStatus = status
} else {
data = fetched
httpStatus = status
if newETag != "" {
etag = newETag
}
if newLM != "" {
lastMod = newLM
}
if path != "" && !l.InsecureSkipTLSVerify {
// Persist for offline restarts. A write failure is not fatal.
_ = config.WriteFileAtomic(path, data, 0o644)
}
}
} else if l.URL != "" && !needFetch {
// We have a local copy; try a conditional refresh in the background
// anyway, but do not fail when it 304s.
httpStatus = http.StatusOK
}
if len(data) == 0 {
return rawSource{}, errors.New("no data available")
}
format := Format(l.Format)
opt := ParseOptions{
Subdomains: true,
DefaultTTL: uint32(cfg.Block.ResponseTTL.D()),
Categories: l.Categories,
}
rules, st, perr := Parse(format, data, opt)
if perr != nil {
return rawSource{}, perr
}
meta := SourceMeta{
Name: l.Name,
Format: string(st.Format),
Rules: len(rules),
Bytes: int64(len(data)),
ETag: etag,
LastMod: lastMod,
UpdatedAt: start,
Duration: time.Since(start),
Enabled: true,
URL: l.URL,
Path: path,
HTTPStatus: httpStatus,
}
return rawSource{
Name: l.Name,
Format: st.Format,
Meta: meta,
Stats: st,
Digest: digestOf(data),
rules: rules,
}, nil
}
// rawSource is the internal shape before it becomes a Source.
type rawSource struct {
Name string
Format Format
Meta SourceMeta
Stats ParseStats
Digest string
rules []Rule
}
func (r rawSource) toSource() Source {
return Source{Name: r.Name, Format: r.Format, Meta: r.Meta, Stats: r.Stats, Digest: r.Digest}
}
// fetch performs a conditional GET against a list URL, following mirrors.
func (m *Manager) fetch(ctx context.Context, l config.ListSource) (data []byte, status int, etag, lastMod string, err error) {
urls := append([]string{l.URL}, l.Mirror...)
var lastErr error
for _, u := range urls {
if u == "" {
continue
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
if err != nil {
lastErr = err
continue
}
req.Header.Set("User-Agent", "fancy-dns/1.0 (+blocklist)")
if l.ETag != "" {
req.Header.Set("If-None-Match", l.ETag)
}
if l.LastModified != "" {
req.Header.Set("If-Modified-Since", l.LastModified)
}
client := m.http
if l.InsecureSkipTLSVerify {
// Only for self-hosted mirrors with a private CA; the config
// field name says what it does.
tr := http.DefaultTransport.(*http.Transport).Clone()
tr.TLSClientConfig = insecureTLS()
client = &http.Client{Transport: tr, Timeout: client.Timeout}
}
resp, err := client.Do(req)
if err != nil {
lastErr = err
continue
}
status = resp.StatusCode
switch resp.StatusCode {
case http.StatusNotModified:
resp.Body.Close()
return nil, status, l.ETag, l.LastModified, nil
case http.StatusOK:
etag = resp.Header.Get("ETag")
lastMod = resp.Header.Get("Last-Modified")
body, rerr := readLimited(resp.Body, l.MaxBytes)
resp.Body.Close()
if rerr != nil {
lastErr = rerr
continue
}
return body, status, etag, lastMod, nil
default:
io.Copy(io.Discard, io.LimitReader(resp.Body, 4096))
resp.Body.Close()
lastErr = fmt.Errorf("%s: unexpected status %s", u, resp.Status)
}
}
if lastErr == nil {
lastErr = errors.New("no usable source")
}
return nil, 0, "", "", lastErr
}
func readLimited(r io.Reader, max int64) ([]byte, error) {
if max <= 0 {
max = 512 << 20
}
buf, err := io.ReadAll(io.LimitReader(r, max+1))
if err != nil {
return nil, err
}
if int64(len(buf)) > max {
return nil, fmt.Errorf("list exceeds %d bytes", max)
}
return buf, nil
}
// Start runs the scheduled refresh loop until ctx is cancelled.
func (m *Manager) Start(ctx context.Context) {
m.wg.Add(1)
go func() {
defer m.wg.Done()
// Stagger the first pass across sources so a cold cluster does not
// fetch 20 lists at the same instant.
for _, l := range m.cfg.Get().Block.Lists {
if !l.Enabled || l.Refresh <= 0 {
continue
}
jitter := l.Refresh.D() * time.Duration(fraction(l.Name)) / 8
select {
case <-ctx.Done():
return
case <-time.After(jitter):
}
if m.claim(l.Name) {
if _, err := m.RefreshAll(ctx); err != nil {
m.log.Warn("initial blocklist refresh failed", "error", err)
}
m.release(l.Name)
}
// Only stagger the first pass; after that the loop is regular.
break
}
if _, err := m.RefreshAll(ctx); err != nil {
m.log.Warn("initial blocklist refresh failed", "error", err)
}
m.loop(ctx)
}()
}
func (m *Manager) loop(ctx context.Context) {
// A 5-minute tick is coarse enough to be free and fine enough that a
// source's own refresh interval is honoured within 5 minutes.
t := time.NewTicker(5 * time.Minute)
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case name := <-m.trigger:
// A cluster peer asked for a refresh; it is the leader's job.
if name != "" && !m.claim(name) {
continue
}
if _, err := m.RefreshAll(ctx); err != nil {
m.log.Warn("triggered refresh failed", "source", name, "error", err)
}
if name != "" {
m.release(name)
}
case <-t.C:
m.refreshDue(ctx)
}
}
}
// refreshDue refreshes the sources whose interval has elapsed.
func (m *Manager) refreshDue(ctx context.Context) {
cfg := m.cfg.Get()
now := time.Now()
var due bool
for _, l := range cfg.Block.Lists {
if !l.Enabled || l.Refresh <= 0 {
continue
}
src, ok := m.previous(l.Name)
if !ok || src.Meta.UpdatedAt.IsZero() {
due = true
break
}
if now.Sub(src.Meta.UpdatedAt) >= l.Refresh.D() {
due = true
break
}
}
if !due {
return
}
// Refreshing every due source in one pass compiles once, which is far
// cheaper than one compile per source.
if _, err := m.RefreshAll(ctx); err != nil {
m.log.Warn("scheduled refresh failed", "error", err)
}
}
// RefreshOne refreshes a single named source immediately.
func (m *Manager) RefreshOne(ctx context.Context, name string) (State, error) {
if !m.claim(name) {
return *m.stats.Get(), fmt.Errorf("refresh of %q already in flight", name)
}
defer m.release(name)
return m.RefreshAll(ctx)
}
// Trigger asks the refresh loop to compile now. Safe to call from an API
// handler; it does not block.
func (m *Manager) Trigger(name string) {
select {
case m.trigger <- name:
default:
}
}
// Stop shuts the loop down.
func (m *Manager) Stop() {
select {
case <-m.stop:
default:
close(m.stop)
}
m.wg.Wait()
}
// parseInline compiles the config's inline entry lists. The plain-domain
// parser yields block rules, so the requested action is stamped on afterwards:
// an operator's allowlist must not be compiled as a blocklist, which is the
// kind of bug that silently inverts a policy.
func parseInline(entries []string, action Action, ttl config.Duration) ([]Rule, ParseStats, error) {
var sb strings.Builder
for _, e := range entries {
sb.WriteString(e)
sb.WriteByte('\n')
}
opt := ParseOptions{Subdomains: true, DefaultTTL: uint32(ttl.D())}
rules, st, err := Parse(FormatPlain, []byte(sb.String()), opt)
if err != nil {
return nil, st, err
}
for i := range rules {
if rules[i].Action == ActionBlock {
rules[i].Action = action
}
}
return rules, st, nil
}
func parseRPZFiles(files []string) ([]Rule, ParseStats, error) {
var sb strings.Builder
for _, f := range files {
data, err := os.ReadFile(f)
if err != nil {
return nil, ParseStats{}, err
}
// Prefix each file with its own $ORIGIN so a multi-file policy zone
// keeps the per-file origins from its file name.
fmt.Fprintf(&sb, "$ORIGIN %s\n", zonefile.OriginFromPath(f))
sb.Write(data)
sb.WriteByte('\n')
}
opt := ParseOptions{Subdomains: true, DefaultTTL: 60}
return Parse(FormatRPZ, []byte(sb.String()), opt)
}
func digestOf(data []byte) string {
sum := sha256.Sum256(data)
return hex.EncodeToString(sum[:8])
}
// fraction maps a name to a stable 0..1 value, for refresh jitter.
func fraction(name string) int {
sum := sha256.Sum256([]byte(name))
return int(sum[0]) // 0..255
}
// insecureTLS is the transport tweak for sources that need it. It is only
// reachable when a list explicitly sets insecure_skip_tls_verify.
func insecureTLS() *tls.Config { return &tls.Config{InsecureSkipVerify: true} }
+535
View File
@@ -0,0 +1,535 @@
package blocklist
import (
"bufio"
"bytes"
"fmt"
"net/netip"
"strconv"
"strings"
"github.com/miekg/dns"
"git.k8s-git-k6n.net/mats/fancy-dns/internal/zonefile"
)
// Format identifies a list's syntax.
type Format string
const (
// FormatAuto sniffs the content.
FormatAuto Format = "auto"
// FormatHosts is "IP domain [domain...]" (also accepts "0 domain").
FormatHosts Format = "hosts"
// FormatAdblock is the Adblock Plus / EasyList syntax.
FormatAdblock Format = "adblock"
// FormatPlain is one domain per line.
FormatPlain Format = "plain"
// FormatRPZ is an RFC 1035 response policy zone.
FormatRPZ Format = "rpz"
)
// ParseOptions tunes the parsers.
type ParseOptions struct {
// Subdomains makes a bare domain also cover its subdomains. Defaults to
// true; hosts files rely on it, and precise lists can opt out.
Subdomains bool
// DefaultTTL is applied to rules that carry none.
DefaultTTL uint32
// Categories are attached to every rule from this source.
Categories []string
}
// ParseStats reports what a parser saw, for the UI and for tests.
type ParseStats struct {
Rules int `json:"rules"`
Comments int `json:"comments"`
Skipped int `json:"skipped"`
Malformed int `json:"malformed"`
Duplicates int `json:"duplicates"`
Bytes int64 `json:"bytes"`
DurationMS float64 `json:"duration_ms"`
Format Format `json:"format"`
Sample []string `json:"sample,omitempty"`
Notices []string `json:"notices,omitempty"`
}
// DetectFormat sniffs a list's syntax from its first meaningful lines.
func DetectFormat(data []byte) Format {
sc := bufio.NewScanner(bytes.NewReader(data))
sc.Buffer(make([]byte, 0, 64*1024), 1024*1024)
sawRecord := false
for i := 0; sc.Scan() && i < 200; i++ {
line := strings.TrimSpace(sc.Text())
if line == "" || strings.HasPrefix(line, "#") || strings.HasPrefix(line, ";") {
continue
}
if strings.HasPrefix(line, "[") {
// "[Adblock Plus 2.0]" is a header, keep looking.
continue
}
if strings.HasPrefix(line, "$TTL") || strings.HasPrefix(line, "$ORIGIN") ||
strings.HasPrefix(line, "$INCLUDE") {
return FormatRPZ
}
if sawRecord {
return FormatAdblock
}
fields := strings.Fields(line)
if len(fields) >= 2 && isAddr(fields[0]) {
return FormatHosts
}
if strings.HasPrefix(line, "||") || strings.HasPrefix(line, "|") ||
strings.HasPrefix(line, "@@") || strings.Contains(line, "##") ||
strings.Contains(line, "#@#") || strings.Contains(line, "#?#") {
return FormatAdblock
}
if len(fields) >= 3 && len(fields[1]) > 1 && allDigitsOrName(fields[1]) {
// "name 60 IN A 1.2.3.4" style.
return FormatRPZ
}
sawRecord = true
}
return FormatPlain
}
func allDigitsOrName(s string) bool {
if _, err := strconv.Atoi(s); err == nil {
return true
}
switch strings.ToUpper(s) {
case "IN", "CH", "HS":
return true
}
return false
}
// Parse converts list data into rules using the given format (FormatAuto
// sniffs). It never returns partial garbage: on a hard error the rules
// accumulated so far are returned alongside it so the caller can decide
// whether to keep the last good set or adopt the partial one.
func Parse(format Format, data []byte, opt ParseOptions) ([]Rule, ParseStats, error) {
if opt.DefaultTTL == 0 {
opt.DefaultTTL = 60
}
if format == "" || format == FormatAuto {
format = DetectFormat(data)
}
stats := ParseStats{Bytes: int64(len(data)), Format: format}
var (
rules []Rule
err error
)
switch format {
case FormatHosts:
rules, stats, err = parseHosts(data, opt)
case FormatAdblock:
rules, stats, err = parseAdblock(data, opt)
case FormatPlain:
rules, stats, err = parsePlain(data, opt)
case FormatRPZ:
rules, stats, err = parseRPZ(data, opt)
default:
return nil, stats, fmt.Errorf("blocklist: unknown format %q", format)
}
return rules, stats, err
}
func parseHosts(data []byte, opt ParseOptions) ([]Rule, ParseStats, error) {
var rules []Rule
stats := ParseStats{Bytes: int64(len(data)), Format: FormatHosts}
sc := newLineScanner(data)
for sc.Scan() {
line := sc.Text()
trimmed := strings.TrimSpace(line)
if trimmed == "" {
continue
}
if trimmed[0] == '#' || trimmed[0] == ';' {
stats.Comments++
continue
}
// Strip inline comments.
if i := strings.IndexByte(trimmed, '#'); i >= 0 {
trimmed = strings.TrimSpace(trimmed[:i])
if trimmed == "" {
stats.Comments++
continue
}
}
fields := strings.Fields(trimmed)
if len(fields) < 2 {
stats.Malformed++
continue
}
addr := fields[0]
if !isAddr(addr) && !strings.EqualFold(addr, "localhost") {
// Not an address: this is not a hosts file after all, so treat
// the whole line as a domain list.
stats.Notices = appendNotice(stats.Notices, "no address in first field, falling back to a plain domain list")
return parsePlain(data, opt)
}
names := fields[1:]
if isLoopbackName(names) {
// "127.0.0.1 localhost" style bootstrap lines: never block these.
stats.Skipped++
continue
}
for _, n := range names {
if !validDomain(n) {
stats.Malformed++
continue
}
rules = append(rules, hostsRule(n, opt))
}
}
if err := sc.Err(); err != nil {
return rules, stats, err
}
stats.Rules = len(rules)
return rules, stats, nil
}
func isLoopbackName(names []string) bool {
for _, n := range names {
switch strings.ToLower(strings.TrimSuffix(n, ".")) {
case "localhost", "localhost.localdomain", "broadcasthost", "ip6-localhost", "ip6-loopback":
return true
}
}
return false
}
// hostsRule builds a rule for a hosts-file or plain-list entry.
//
// A hosts file says "this name is bad" and everyone means the name *and*
// anything under it, so the pattern is "**." (apex included) rather than the
// strict-subdomain "*.". Getting this wrong is how a filter ends up blocking
// ads.example.com but happily resolving 1.ads.example.com.
func hostsRule(name string, opt ParseOptions) Rule {
pattern := name
if opt.Subdomains {
pattern = "**." + name
}
return Rule{Name: pattern, Action: ActionBlock, TTL: opt.DefaultTTL}
}
func parseAdblock(data []byte, opt ParseOptions) ([]Rule, ParseStats, error) {
var rules []Rule
stats := ParseStats{Bytes: int64(len(data)), Format: FormatAdblock}
sc := newLineScanner(data)
for sc.Scan() {
line := strings.TrimSpace(sc.Text())
if line == "" {
continue
}
if line[0] == '!' || line[0] == '#' {
stats.Comments++
continue
}
if line[0] == '[' {
stats.Comments++
continue
}
if strings.HasPrefix(line, "||") {
host := hostOfAdblock(line[2:])
if !validDomain(host) {
stats.Skipped++
continue
}
rules = append(rules, Rule{Name: "**." + host, Action: ActionBlock, TTL: opt.DefaultTTL})
continue
}
if strings.HasPrefix(line, "@@") {
rest := strings.TrimPrefix(line, "@@")
host := hostOfAdblock(strings.TrimPrefix(rest, "||"))
if !validDomain(host) {
stats.Skipped++
continue
}
rules = append(rules, Rule{Name: "**." + host, Action: ActionAllow, Priority: 10, TTL: opt.DefaultTTL})
continue
}
if strings.HasPrefix(line, "|") {
rest := strings.TrimPrefix(line, "|")
if strings.HasPrefix(rest, "http://") || strings.HasPrefix(rest, "https://") {
host := hostOfURL(rest)
if !validDomain(host) {
stats.Skipped++
continue
}
// A path rule cannot be expressed in DNS; blocking the host
// is the closest faithful approximation, so record it once.
rules = append(rules, Rule{Name: "**." + host, Action: ActionBlock, TTL: opt.DefaultTTL})
continue
}
stats.Skipped++
continue
}
if line[0] == '/' && strings.HasSuffix(line, "/") {
stats.Skipped++ // regular expressions are not DNS-expressible
if len(stats.Notices) < 5 {
stats.Notices = appendNotice(stats.Notices, "skipped regular-expression rules")
}
continue
}
// Element-hiding rules ("##", "#@#", "#?#", "##+js(") carry no DNS
// meaning at all.
if strings.Contains(line, "##") || strings.Contains(line, "#@#") || strings.Contains(line, "#?#") {
stats.Skipped++
continue
}
// A bare hostname with no adblock syntax at all.
if validDomain(line) {
rules = append(rules, hostsRule(line, opt))
continue
}
stats.Skipped++
}
if err := sc.Err(); err != nil {
return rules, stats, err
}
stats.Rules = len(rules)
return rules, stats, nil
}
// hostOfAdblock extracts the domain from the part of an adblock rule between
// "||" (or a scheme) and the first separator (^, /, ?, |, $).
func hostOfAdblock(s string) string {
if i := strings.IndexAny(s, "^/?|$"); i >= 0 {
s = s[:i]
}
return strings.Trim(s, ".")
}
func hostOfURL(s string) string {
if i := strings.Index(s, "://"); i >= 0 {
s = s[i+3:]
}
if i := strings.IndexAny(s, "/?#"); i >= 0 {
s = s[:i]
}
if i := strings.LastIndexByte(s, '@'); i >= 0 {
s = s[i+1:]
}
// Strip credentials and port.
if i := strings.LastIndexByte(s, ':'); i >= 0 {
if _, err := strconv.Atoi(s[i+1:]); err == nil {
s = s[:i]
}
}
return strings.Trim(s, ".")
}
func parsePlain(data []byte, opt ParseOptions) ([]Rule, ParseStats, error) {
var rules []Rule
stats := ParseStats{Bytes: int64(len(data)), Format: FormatPlain}
sc := newLineScanner(data)
for sc.Scan() {
line := strings.TrimSpace(sc.Text())
if line == "" {
continue
}
if line[0] == '#' || line[0] == ';' {
stats.Comments++
continue
}
// Accept "0.0.0.0 domain" and bare "domain" alike so operators do
// not have to normalise their sources by hand.
if fields := strings.Fields(line); len(fields) > 1 && isAddr(fields[0]) {
for _, n := range fields[1:] {
if validDomain(n) {
rules = append(rules, hostsRule(n, opt))
} else {
stats.Malformed++
}
}
continue
}
// Support inline allow markers: "!allow example.com".
allow := false
if strings.HasPrefix(line, "!") {
allow = true
line = strings.TrimSpace(line[1:])
}
line = strings.TrimPrefix(line, "||")
if !validDomain(line) {
stats.Malformed++
continue
}
pattern := line
if opt.Subdomains && !strings.HasPrefix(line, "*") {
pattern = "**." + line
}
action := ActionBlock
if allow {
action = ActionAllow
}
rules = append(rules, Rule{Name: pattern, Action: action, TTL: opt.DefaultTTL})
}
if err := sc.Err(); err != nil {
return rules, stats, err
}
stats.Rules = len(rules)
return rules, stats, nil
}
func parseRPZ(data []byte, opt ParseOptions) ([]Rule, ParseStats, error) {
var rules []Rule
stats := ParseStats{Bytes: int64(len(data)), Format: FormatRPZ}
z, err := zonefile.Parse(bytes.NewReader(data), zonefile.Options{DefaultTTL: opt.DefaultTTL})
if err != nil {
return nil, stats, err
}
seen := make(map[string]bool, len(z.Records))
for _, rec := range z.Records {
ttl := rec.TTL
if ttl == 0 && opt.DefaultTTL == 0 {
ttl = opt.DefaultTTL
}
rule, ok := rpzRule(rec, ttl, opt)
if !ok {
stats.Skipped++
continue
}
if seen[rule.Name] {
stats.Duplicates++
continue
}
seen[rule.Name] = true
rules = append(rules, rule)
}
stats.Rules = len(rules)
return rules, stats, nil
}
// rpzRule maps one policy-zone record onto a rule (RFC draft-ietf-dnsop-rpz).
// RPZ encodes the response in the rdata: a CNAME to "." means NXDOMAIN, to
// "rpz-drop." means drop, to "rpz-passthru." means resolve normally, and
// A/AAAA records return that address verbatim.
func rpzRule(rec zonefile.Record, ttl uint32, opt ParseOptions) (Rule, bool) {
pattern := rec.Name
if !strings.HasPrefix(pattern, "*") && opt.Subdomains {
pattern = "*." + pattern
}
target, action, resp := rpzTarget(rec.RR)
if target == "" {
return Rule{}, false
}
return Rule{Name: pattern, Action: action, Response: resp, TTL: ttl}, true
}
// rpzTarget interprets a policy record's rdata. The returned target is only a
// "is this actionable" flag; action and resp carry the meaning.
func rpzTarget(rr dns.RR) (target string, action Action, resp string) {
switch v := rr.(type) {
case *dns.CNAME:
t := v.Target
switch {
case t == ".":
// The classic RPZ "this name does not exist" trigger.
return ".", ActionNXDomain, ""
case strings.EqualFold(t, "rpz-drop."):
return t, ActionDrop, ""
case strings.EqualFold(t, "rpz-passthru."), strings.EqualFold(t, "rpz-passthru."):
return t, ActionPassthrough, ""
case strings.EqualFold(t, "rpz-tcp-only."):
// Force TCP upstream; as a DNS policy this is a no-op for us
// since we already retry over TCP on truncation.
return t, ActionPassthrough, ""
default:
return t, ActionCNAME, t
}
case *dns.A:
if v.A == nil {
return "", ActionBlock, ""
}
return v.A.String(), ActionRedirect, v.A.String()
case *dns.AAAA:
if v.AAAA == nil {
return "", ActionBlock, ""
}
return v.AAAA.String(), ActionRedirect, v.AAAA.String()
case *dns.TXT:
// Some providers publish policy in TXT records; the payload is the
// action name.
t := strings.ToLower(strings.Join(v.Txt, ""))
switch {
case strings.Contains(t, "rpz-drop"):
return t, ActionDrop, ""
case strings.Contains(t, "rpz-passthru"):
return t, ActionPassthrough, ""
case strings.Contains(t, "allow"), strings.Contains(t, "whitelist"):
return t, ActionAllow, ""
default:
return "", ActionBlock, ""
}
default:
return "", ActionBlock, ""
}
}
// isAddr reports whether s parses as an IP address literal.
func isAddr(s string) bool {
_, err := netip.ParseAddr(s)
return err == nil
}
// newLineScanner returns a line scanner sized for the very large list files
// real blocklists ship (multi-hundred-megabyte entries are not unheard of).
func newLineScanner(data []byte) *bufio.Scanner {
sc := bufio.NewScanner(bytes.NewReader(data))
sc.Buffer(make([]byte, 0, 128*1024), 4*1024*1024)
return sc
}
func appendNotice(notices []string, n string) []string {
for _, existing := range notices {
if existing == n {
return notices
}
}
if len(notices) >= 8 {
return notices
}
return append(notices, n)
}
// validDomain is a cheap sanity check: no scheme, no path, printable, and at
// least one dot or a known single-label TLD-ish name.
func validDomain(s string) bool {
if s == "" || len(s) > 253 {
return false
}
if strings.HasPrefix(s, ".") || strings.HasPrefix(s, "-") {
return false
}
if strings.ContainsAny(s, "/ \t\\\"'<>|?*=") {
return false
}
labels := strings.Split(strings.TrimSuffix(s, "."), ".")
if len(labels) < 1 {
return false
}
for _, l := range labels {
if l == "" || len(l) > 63 {
return false
}
for i := 0; i < len(l); i++ {
c := l[i]
ok := c == '-' || c == '_' || c == '*' ||
(c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') ||
(c >= '0' && c <= '9')
if !ok {
return false
}
}
if l[0] == '-' || l[len(l)-1] == '-' {
return false
}
}
// A bare label with no dot is only meaningful for the localhost-ish
// names, which callers already filter; reject to avoid blocking a
// single-label intranet name by accident.
return strings.Contains(s, ".")
}
+737
View File
@@ -0,0 +1,737 @@
// Package blocklist compiles heterogeneous block/allow sources (hosts files,
// adblock lists, plain domain lists and RPZ policy zones) into a single
// immutable Set that answers "what should I do with this name?" in a handful
// of map lookups.
//
// A Set is never mutated after construction; Manager swaps a whole new Set in
// atomically when lists are refreshed, so a query either sees the old rules or
// the new ones, never a half-applied mixture.
package blocklist
import (
"crypto/sha256"
"encoding/binary"
"encoding/hex"
"io"
"strings"
"sync/atomic"
"time"
)
// Action is what to do with a matched name.
type Action uint8
const (
// ActionBlock uses the manager's default response (nxdomain/zero/...).
ActionBlock Action = iota
// ActionAllow whitelists the name: blocklists are skipped entirely.
ActionAllow
// ActionPassthrough resolves the name upstream but ignores blocklists
// (RPZ rpz-passthru).
ActionPassthrough
// ActionDrop answers nothing at all (RPZ rpz-drop).
ActionDrop
// ActionNXDomain answers NXDOMAIN regardless of the default response.
ActionNXDomain
// ActionZero answers the configured sinkhole address.
ActionZero
// ActionCNAME answers a CNAME to Response.
ActionCNAME
// ActionRedirect answers A/AAAA for Response as an address literal.
ActionRedirect
)
func (a Action) String() string {
switch a {
case ActionBlock:
return "block"
case ActionAllow:
return "allow"
case ActionPassthrough:
return "passthrough"
case ActionDrop:
return "drop"
case ActionNXDomain:
return "nxdomain"
case ActionZero:
return "zero"
case ActionCNAME:
return "cname"
case ActionRedirect:
return "redirect"
}
return "unknown"
}
// ParseAction maps configuration and UI strings onto an Action.
func ParseAction(s string) (Action, bool) {
switch strings.ToLower(strings.TrimSpace(s)) {
case "", "block":
return ActionBlock, true
case "allow", "whitelist":
return ActionAllow, true
case "passthrough", "pass":
return ActionPassthrough, true
case "drop":
return ActionDrop, true
case "nxdomain":
return ActionNXDomain, true
case "zero", "sinkhole", "null":
return ActionZero, true
case "cname":
return ActionCNAME, true
case "redirect":
return ActionRedirect, true
}
return ActionBlock, false
}
// Entry is one compiled rule. It is shared by every name that maps to it, so
// keep it small: the strings are interned per source.
//
// The hit counter is a pointer so an Entry can be copied by value (the API
// returns copies) without copying a mutex or tripping go vet's lock check.
type Entry struct {
// Name is the rule's pattern, normalised (lowercase, no trailing dot).
Name string
Action Action
// Response is the sinkhole address or CNAME target for the actions that
// need one; empty otherwise.
Response string
// Source is the list or file the rule came from (shown in the UI).
Source string
// Categories are the list's tags, copied per rule.
Categories []string
// Priority orders allow-over-block conflicts: higher wins, and ties break
// towards the first rule seen.
Priority int
// hits counts matches since the set was built. A pointer keeps Entry
// copyable by value, which the admin API relies on.
hits *atomic.Uint64
// TTL overrides the manager's response TTL for this rule (RPZ records
// carry their own TTL).
TTL uint32
}
// Hits returns how many times this rule matched.
func (e *Entry) Hits() uint64 {
if e == nil || e.hits == nil {
return 0
}
return e.hits.Load()
}
func (e *Entry) markHit() {
if e == nil || e.hits == nil {
return
}
e.hits.Add(1)
}
// IsAllow reports whether the rule whitelists the name.
func (e *Entry) IsAllow() bool {
return e.Action == ActionAllow || e.Action == ActionPassthrough
}
// Rule is a pattern plus the action it implies, as produced by the parsers.
type Rule struct {
// Name is the pattern; may start with "*." (subdomains only) or
// "**." (subdomains and the apex itself).
Name string
Action Action
// Response is an address literal (for redirect) or a target name (cname).
Response string
// TTL is a per-rule TTL when the source provides one.
TTL uint32
// Category tags the rule.
Category string
// Priority disambiguates allow/block conflicts.
Priority int
}
// Set is an immutable compiled rule table.
//
// Three maps cover the pattern space:
//
// exact[name] "ads.example.com" the name and nothing else
// sub[example.com] "*.example.com" strict subdomains
// root[example.com] "**.example.com" subdomains and the apex
//
// Lookup is one hash for the common case (exact hit or "no wildcards at all"),
// and at most one hash per label otherwise — no per-query allocation.
type Set struct {
exact map[string]*Entry
sub map[string]*Entry
root map[string]*Entry
// hasWild lets Match skip the suffix walk for sets of pure exact rules,
// which is the common hosts-file case.
hasWild bool
// meta is per-source bookkeeping for the UI.
meta map[string]SourceMeta
// counts is the number of compiled rules, per kind.
counts RuleCounts
// total is the number of rules dropped as duplicates or malformed.
duplicates int
// builtAt is when this set was compiled.
builtAt time.Time
// digest covers the compiled patterns, so two instances can compare rules
// without shipping the rules around.
digest string
// sources is the ordered list of source names that contributed.
sources []string
// maxDepth is the longest pattern label count, used to bound the walk.
maxDepth int
}
// RuleCounts breaks down compiled rules by kind.
type RuleCounts struct {
Exact int
Sub int
Root int
Allow int
Block int
Passthru int
Drop int
}
// SourceMeta describes one contributing source.
type SourceMeta struct {
Name string
Format string
Rules int
Bytes int64
ETag string
LastMod string
UpdatedAt time.Time
Duration time.Duration
Err string
Enabled bool
URL string
Path string
Stale bool
HTTPStatus int
}
// Builder accumulates rules and produces a Set.
type Builder struct {
exact map[string]*Entry
sub map[string]*Entry
root map[string]*Entry
meta map[string]SourceMeta
order []string
dup int
counts RuleCounts
}
// NewBuilder returns an empty Builder.
func NewBuilder() *Builder {
return &Builder{
exact: make(map[string]*Entry, 1<<12),
sub: make(map[string]*Entry, 1<<10),
root: make(map[string]*Entry, 1<<10),
meta: make(map[string]SourceMeta, 8),
}
}
// Add compiles one rule. Names are normalised here, so callers may pass
// user-typed domain names.
func (b *Builder) Add(r Rule, source string, categories []string) {
name, kind := splitPattern(r.Name)
if name == "" {
return
}
prio := r.Priority
if prio == 0 {
// Allow rules outrank block rules by default so an explicit
// whitelist always wins over a list entry.
if r.Action == ActionAllow || r.Action == ActionPassthrough {
prio = 10
} else {
prio = 1
}
}
e := &Entry{
hits: new(atomic.Uint64),
Name: name,
Action: r.Action,
Response: r.Response,
Source: source,
Categories: categories,
Priority: prio,
TTL: r.TTL,
}
if r.Category != "" {
e.Categories = append(e.Categories, r.Category)
}
var m map[string]*Entry
switch kind {
case patternExact:
m = b.exact
case patternSub:
m = b.sub
case patternRoot:
m = b.root
}
if existing, ok := m[name]; ok {
b.dup++
// Higher priority wins; on a tie the existing rule stays, so the
// order lists appear in the config is meaningful.
if prio <= existing.Priority {
return
}
b.uncount(existing)
m[name] = e
b.countAction(e, 1)
return
}
m[name] = e
b.countAction(e, 1)
if _, seen := b.meta[source]; !seen {
b.order = append(b.order, source)
}
}
// metaFor reports whether a source already contributed to this build.
func (b *Builder) metaFor(name string) (SourceMeta, bool) {
m, ok := b.meta[name]
return m, ok
}
func (b *Builder) countAction(e *Entry, delta int) {
switch e.Action {
case ActionAllow:
b.counts.Allow += delta
case ActionPassthrough:
b.counts.Passthru += delta
case ActionDrop:
b.counts.Drop += delta
default:
b.counts.Block += delta
}
}
// uncount removes an entry that a higher-priority rule is replacing.
func (b *Builder) uncount(e *Entry) {
switch e.Action {
case ActionAllow:
b.counts.Allow--
case ActionPassthrough:
b.counts.Passthru--
case ActionDrop:
b.counts.Drop--
default:
b.counts.Block--
}
}
// NoteSource records (or updates) bookkeeping for a contributing source.
func (b *Builder) NoteSource(meta SourceMeta) {
if _, seen := b.meta[meta.Name]; !seen {
b.order = append(b.order, meta.Name)
}
b.meta[meta.Name] = meta
}
// SourceCount is the number of contributing sources.
func (b *Builder) SourceCount() int { return len(b.meta) }
// Build freezes the accumulated rules into an immutable Set.
func (b *Builder) Build() *Set {
s := &Set{
exact: b.exact,
sub: b.sub,
root: b.root,
meta: b.meta,
sources: b.order,
builtAt: time.Now(),
duplicates: b.dup,
hasWild: len(b.sub) > 0 || len(b.root) > 0,
}
s.counts = b.counts
s.counts.Exact = len(b.exact)
s.counts.Sub = len(b.sub)
s.counts.Root = len(b.root)
s.digest = b.digest()
for k := range b.exact {
if d := labelCount(k); d > s.maxDepth {
s.maxDepth = d
}
}
for k := range b.sub {
if d := labelCount(k); d > s.maxDepth {
s.maxDepth = d
}
}
for k := range b.root {
if d := labelCount(k); d > s.maxDepth {
s.maxDepth = d
}
}
return s
}
// digest hashes every compiled pattern together with its action. The map walk
// makes it order-independent, but stable for a given rule set, which is exactly
// what cluster convergence checking needs.
func (b *Builder) digest() string {
h := sha256.New()
var buf [3]byte
write := func(kind byte, name string, e *Entry) {
buf[0] = kind
h.Write(buf[:1])
io.WriteString(h, name)
buf[1] = byte(e.Action)
binary.LittleEndian.PutUint16(buf[1:], uint16(e.Priority))
h.Write(buf[:3])
}
for name, e := range b.exact {
write(0, name, e)
}
for name, e := range b.sub {
write(1, name, e)
}
for name, e := range b.root {
write(2, name, e)
}
return hex.EncodeToString(h.Sum(nil)[:8])
}
// Digest returns the compiled content's digest.
func (s *Set) Digest() string {
if s == nil {
return ""
}
return s.digest
}
type patternKind uint8
const (
patternExact patternKind = iota
patternSub
patternRoot
)
// splitPattern normalises a rule name and reports which map it belongs in.
//
// "Example.COM." -> exact "example.com"
// "*.example.com" -> sub "example.com"
// "**.example.com" -> root "example.com"
// "." (the whole internet) is dropped: it is never a useful rule.
func splitPattern(in string) (string, patternKind) {
name := strings.ToLower(strings.TrimSpace(in))
if name == "" {
return "", patternExact
}
kind := patternExact
switch {
case strings.HasPrefix(name, "**."):
kind = patternRoot
name = name[3:]
case strings.HasPrefix(name, "*."):
kind = patternSub
name = name[2:]
case strings.HasPrefix(name, "."):
// A leading dot in hosts/adblock convention means "and subdomains".
kind = patternRoot
name = name[1:]
}
name = strings.TrimSuffix(name, ".")
name = strings.Trim(name, ".")
if name == "" || name == "." {
return "", patternExact
}
// A name with an interior "*" is not something we can match; treat the
// first label as a fixed prefix and drop the rest, which degrades to
// "closest sane behaviour" instead of silently not matching.
if i := strings.IndexByte(name, '*'); i >= 0 {
name = name[:i]
name = strings.TrimSuffix(name, ".")
if name == "" {
return "", patternExact
}
}
return name, kind
}
// NormalizeName canonicalises a queried name for matching: lowercased, no
// trailing dot. It is allocation-free for the common case.
func NormalizeName(name string) string {
if len(name) > 0 && name[len(name)-1] == '.' {
name = name[:len(name)-1]
}
return strings.ToLower(name)
}
// Match returns the rule that applies to name, or nil. name may be fqdn or
// relative; the caller is responsible for lowercasing only if it has not
// already.
func (s *Set) Match(name string) *Entry {
if s == nil {
return nil
}
name = NormalizeName(name)
if name == "" {
return nil
}
if e, ok := s.exact[name]; ok {
e.markHit()
return e
}
if !s.hasWild {
return nil
}
// The apex pattern "**.x" also covers the name itself.
if e, ok := s.root[name]; ok {
e.markHit()
return e
}
// Walk strictly shorter suffixes, most specific first.
for off := labelEnd(name); off < len(name); off = labelEnd(name[off:]) + off {
suffix := name[off:]
if e, ok := s.root[suffix]; ok {
e.markHit()
return e
}
if e, ok := s.sub[suffix]; ok {
e.markHit()
return e
}
}
return nil
}
// labelEnd returns the index just past the first label of s, or len(s) when s
// has no dot.
func labelEnd(s string) int {
if i := strings.IndexByte(s, '.'); i >= 0 {
return i + 1
}
return len(s)
}
func labelCount(s string) int {
n := 1
for i := 0; i < len(s); i++ {
if s[i] == '.' {
n++
}
}
return n
}
// Len reports the total number of compiled rules.
func (s *Set) Len() int {
if s == nil {
return 0
}
return s.counts.Exact + s.counts.Sub + s.counts.Root
}
// Counts returns the per-kind rule breakdown.
func (s *Set) Counts() RuleCounts {
if s == nil {
return RuleCounts{}
}
return s.counts
}
// HasWildcards reports whether any subdomain rules exist.
func (s *Set) HasWildcards() bool { return s != nil && s.hasWild }
// Duplicates is the number of rules dropped because a higher- or equal-priority
// rule already claimed the name.
func (s *Set) Duplicates() int { return s.DuplicatesN() }
// DuplicatesN is Duplicates with a nil guard.
func (s *Set) DuplicatesN() int {
if s == nil {
return 0
}
return s.duplicates
}
// BuiltAt is when the set was compiled.
func (s *Set) BuiltAt() time.Time {
if s == nil {
return time.Time{}
}
return s.builtAt
}
// Sources lists the contributing source names in order.
func (s *Set) Sources() []string {
if s == nil {
return nil
}
return s.sources
}
// SourceMeta returns bookkeeping for one source.
func (s *Set) SourceMeta(name string) (SourceMeta, bool) {
if s == nil {
return SourceMeta{}, false
}
m, ok := s.meta[name]
return m, ok
}
// AllSourceMeta returns the per-source bookkeeping map.
func (s *Set) AllSourceMeta() map[string]SourceMeta {
if s == nil {
return nil
}
return s.meta
}
// PatternKind says which of the three maps a rule lives in.
type PatternKind uint8
const (
// PatternExact is "ads.example.com": the name and nothing else.
PatternExact PatternKind = iota
// PatternSub is "*.example.com": strict subdomains.
PatternSub
// PatternRoot is "**.example.com": subdomains and the apex.
PatternRoot
)
// String renders the kind for the UI.
func (k PatternKind) String() string {
switch k {
case PatternSub:
return "subdomain"
case PatternRoot:
return "domain"
default:
return "exact"
}
}
// Pattern renders a pattern of this kind for a stored name.
func (k PatternKind) Pattern(name string) string {
switch k {
case PatternSub:
return "*." + name
case PatternRoot:
return "**." + name
default:
return name
}
}
// Each visits every rule. It exists for the admin API's rule browser; the DNS
// hot path uses Match and never pays this cost.
func (s *Set) Each(fn func(pattern string, e *Entry, kind PatternKind) bool) {
if s == nil {
return
}
for name, e := range s.exact {
if !fn(name, e, PatternExact) {
return
}
}
for name, e := range s.sub {
if !fn(name, e, PatternSub) {
return
}
}
for name, e := range s.root {
if !fn(name, e, PatternRoot) {
return
}
}
}
// MatchPattern returns the kind of rule that matched a name, and the stored
// pattern (without its wildcard prefix). It reports PatternExact when only an
// exact rule matched.
func (s *Set) MatchPattern(name string) PatternKind {
if s == nil {
return PatternExact
}
name = NormalizeName(name)
if _, ok := s.exact[name]; ok {
return PatternExact
}
if _, ok := s.root[name]; ok {
return PatternRoot
}
for off := labelEnd(name); off < len(name); off = labelEnd(name[off:]) + off {
suffix := name[off:]
if _, ok := s.root[suffix]; ok {
return PatternRoot
}
if _, ok := s.sub[suffix]; ok {
return PatternSub
}
}
return PatternExact
}
// Lookup returns the exact rule for a name without walking wildcards. It is
// used by the admin API to explain a match.
func (s *Set) Lookup(name string) *Entry {
if s == nil {
return nil
}
return s.exact[NormalizeName(name)]
}
// Stats is a JSON-friendly snapshot for the API and UI.
type Stats struct {
Total int `json:"total"`
Exact int `json:"exact"`
Subdomains int `json:"subdomain_rules"`
RootRules int `json:"root_rules"`
Allow int `json:"allow"`
Block int `json:"block"`
Passthru int `json:"passthrough"`
Drop int `json:"drop"`
Duplicates int `json:"duplicates"`
Sources int `json:"sources"`
BuiltAt time.Time `json:"built_at"`
PerSource map[string]SourceMeta `json:"sources_detail"`
}
// Stats summarises the set for the API.
func (s *Set) Stats() Stats {
if s == nil {
return Stats{PerSource: map[string]SourceMeta{}}
}
return Stats{
Total: s.Len(),
Exact: s.counts.Exact,
Subdomains: s.counts.Sub,
RootRules: s.counts.Root,
Allow: s.counts.Allow,
Block: s.counts.Block,
Passthru: s.counts.Passthru,
Drop: s.counts.Drop,
Duplicates: s.duplicates,
Sources: len(s.meta),
BuiltAt: s.builtAt,
PerSource: s.meta,
}
}
// Holder is a swappable Set, so hot paths can grab a pointer once per query
// and be immune to a concurrent refresh.
type Holder struct {
cur atomic.Pointer[Set]
}
// NewHolder wraps an initial set (may be nil).
func NewHolder(s *Set) *Holder {
h := &Holder{}
if s != nil {
h.cur.Store(s)
}
return h
}
// Get returns the current set (nil before the first build).
func (h *Holder) Get() *Set { return h.cur.Load() }
// Set installs a new set.
func (h *Holder) Set(s *Set) { h.cur.Store(s) }
+209
View File
@@ -0,0 +1,209 @@
// Package cache implements the hot answer cache: a sharded LRU over packed
// wire-format DNS messages, backed by a bbolt snapshot so a restart does not
// start cold.
//
// Design notes that matter for performance:
//
// - Entries are stored as packed wire bytes, not as *dns.Msg. A hit costs
// one Unpack and yields a private copy, so serving never mutates shared
// state and TTL ageing is free. This is the same trade dnsmasq makes.
// - Keys are Go strings built from the already-allocated question name, so
// the lookup path does not allocate on a hit.
// - The LRU is sharded by name hash: a hot domain only contends with other
// names in its own shard.
// - Expired-but-useful entries are kept for a serve-stale window so a dead
// upstream degrades latency instead of availability.
package cache
import (
"encoding/binary"
"strings"
"time"
"github.com/miekg/dns"
)
// Key identifies a cache entry. Scope lets several logically distinct
// resolvers share one cache (for example per-upstream-group ACL overrides)
// without colliding.
type Key struct {
Name string
Type uint16
Class uint16
// DO mirrors the request's DNSSEC OK bit. Since we pass AD/DO through
// without validating, a DO query legitimately gets a different answer.
DO bool
// Scope is a small caller-defined namespace, usually a group/ACL key.
Scope uint8
// CD (checking disabled) is not part of the key: RFC 4035 says a CD
// query is answered from a non-CD cached entry, which is what we do.
}
// QueryKey builds a Key from a DNS question.
func QueryKey(name string, qtype, qclass uint16, do bool, scope uint8) Key {
return Key{
Name: canonical(name),
Type: qtype,
Class: qclass,
DO: do,
Scope: scope,
}
}
// FromMsg builds a Key from the first question of a message.
func FromMsg(m *dns.Msg, scope uint8) (Key, bool) {
if len(m.Question) == 0 {
return Key{}, false
}
q := m.Question[0]
return QueryKey(q.Name, q.Qtype, q.Qclass, Do(m), scope), true
}
// Do reports whether the message carries the DNSSEC OK bit.
func Do(m *dns.Msg) bool {
if o := m.IsEdns0(); o != nil {
return o.Do()
}
return false
}
// canonical lowercases and strips the trailing dot of a name so that
// "Example.COM." and "example.com" share one entry.
func canonical(name string) string {
if n := len(name); n > 0 && name[n-1] == '.' {
name = name[:n-1]
}
// A fast path for the overwhelmingly common all-lowercase case keeps this
// allocation-free.
for i := 0; i < len(name); i++ {
c := name[i]
if c >= 'A' && c <= 'Z' {
return strings.ToLower(name)
}
}
return name
}
// packKey serialises a key for the bbolt snapshot. The wire format is
// length-prefixed so it is unambiguous and compact:
//
// varint len | wire name | uint16 type | uint16 class | uint8 flags
func packKey(buf []byte, k Key) ([]byte, error) {
name := k.Name
if n := len(name); n > 255*4 {
return nil, errKeyTooLong
}
var tmp [256]byte
wireLen, err := dns.PackDomainName(name, tmp[:], 0, nil, false)
if err != nil {
return nil, err
}
buf = binary.AppendUvarint(buf, uint64(wireLen))
buf = append(buf, tmp[:wireLen]...)
buf = binary.LittleEndian.AppendUint16(buf, k.Type)
buf = binary.LittleEndian.AppendUint16(buf, k.Class)
var flags uint8
if k.DO {
flags |= 1
}
flags |= k.Scope << 1
return append(buf, flags), nil
}
func unpackKey(buf []byte) (Key, []byte, error) {
l, n := binary.Uvarint(buf)
if n <= 0 || l > uint64(len(buf)-n) {
return Key{}, nil, errBadKey
}
buf = buf[n:]
wire := buf[:int(l)]
buf = buf[l:]
if len(buf) < 5 {
return Key{}, nil, errBadKey
}
name, _, err := dns.UnpackDomainName(wire, 0)
if err != nil {
return Key{}, nil, err
}
var k Key
k.Name = canonical(name)
k.Type = binary.LittleEndian.Uint16(buf[0:])
k.Class = binary.LittleEndian.Uint16(buf[2:])
flags := buf[4]
k.DO = flags&1 != 0
k.Scope = flags >> 1
return k, buf[5:], nil
}
// Entry is one cached response plus its timing metadata.
//
// Wire is the packed response exactly as it will be served (minus ID and TTL
// ageing, both applied on unpack). Origin records where the answer came from
// for the UI.
type Entry struct {
Wire []byte
// TTL is the value the entry was stored with, used to age the answer on
// every serve so clients see a monotonically decreasing TTL.
TTL uint32
// Age is the logical age at store time (0 for a fresh answer).
Age time.Duration
// Origin is a small enum: "upstream", "prewarm", "local", "blocked".
Origin string
// Upstream is the address the answer came from, shown in the UI.
Upstream string
// CreatedAt is the wall clock when the entry was stored.
CreatedAt time.Time
// ExpiresAt is when the entry stops being fresh.
ExpiresAt time.Time
// StaleUntil is when the entry is finally evicted. It is ExpiresAt plus
// the serve-stale window.
StaleUntil time.Time
// Bytes is the accounted size, used by the LRU and the size gauges.
Bytes int
// Hits counts serves since it was stored.
Hits uint32
// Neg marks a cached NXDOMAIN or NODATA answer.
Neg bool
// Truncated records that the upstream answer did not fit in 512 bytes,
// so a cache hit must re-truncate for a small client buffer.
Truncated bool
}
// AgeTTL returns the TTL an entry should be served with, given the elapsed
// time since it was stored, clamped at zero.
func (e *Entry) AgeTTL(now time.Time) uint32 {
if e.CreatedAt.IsZero() {
return e.TTL
}
elapsed := uint32(now.Sub(e.CreatedAt).Seconds())
if elapsed >= e.TTL {
return 0
}
return e.TTL - elapsed
}
// Remaining returns how long the entry stays fresh.
func (e *Entry) Remaining(now time.Time) time.Duration {
if !e.ExpiresAt.After(now) {
return 0
}
return e.ExpiresAt.Sub(now)
}
// StaleRemaining returns how long the entry remains serveable as stale data.
func (e *Entry) StaleRemaining(now time.Time) time.Duration {
if !e.StaleUntil.After(now) {
return 0
}
return e.StaleUntil.Sub(now)
}
// Expired reports whether the entry is past its freshness window.
func (e *Entry) Expired(now time.Time) bool { return !e.ExpiresAt.After(now) }
// BlockResponse is a synthesised answer (blocked or locally defined) held in
// the cache so repeated blocked queries never touch the matcher or the wire.
type BlockResponse struct {
Wire []byte
TTL uint32
}
+362
View File
@@ -0,0 +1,362 @@
package cache
import (
"container/list"
"hash/maphash"
"sync"
"sync/atomic"
"time"
)
// DefaultShardCount is used when the config does not pick one.
const DefaultShardCount = 32
// LRU is a sharded, size-bounded LRU keyed by Key.
//
// Each shard owns its own map and intrusive list, so the common case (a
// lookup that hits and is moved to the front) takes one shard mutex. The
// LRU is *not* strict-LRU globally: evicting a shard's least-recently-used
// entry is a fine approximation and keeps contention low. The global entry
// budget is divided evenly between shards.
type LRU struct {
shards []*shard
mask uint64
// perShard is the per-shard entry budget.
perShard int
// counters are the hot-path stats, kept as atomics so Get does not need
// the lock just to record a hit.
hits atomic.Uint64
misses atomic.Uint64
staleHits atomic.Uint64
inserts atomic.Uint64
evictions atomic.Uint64
// expired drops entries found already past their stale window.
expired atomic.Uint64
bytes atomic.Int64
entries atomic.Int64
seed maphash.Seed
// prefetch delays revalidation without blocking Get. It is a semaphore of
// "refreshes in flight" so a burst of stale hits triggers one fetch.
prefetchGate chan struct{}
}
type shard struct {
mu sync.Mutex
items map[Key]*list.Element
ll *list.List
bytes int
}
type lruItem struct {
key Key
entry *Entry
}
// NewLRU builds an LRU holding at most size entries across shards shards
// (rounded up to a power of two, minimum 1).
func NewLRU(size, shards int) *LRU {
if shards <= 0 {
shards = DefaultShardCount
}
shards = roundPow2(shards)
if size < shards {
size = shards
}
l := &LRU{
shards: make([]*shard, shards),
mask: uint64(shards - 1),
perShard: size / shards,
seed: maphash.MakeSeed(),
prefetchGate: make(chan struct{}, 64),
}
if l.perShard < 1 {
l.perShard = 1
}
for i := range l.shards {
l.shards[i] = &shard{
items: make(map[Key]*list.Element, 1024),
ll: list.New(),
}
}
return l
}
func roundPow2(n int) int {
if n <= 1 {
return 1
}
p := 1
for p < n {
p <<= 1
}
return p
}
func (l *LRU) shardFor(k Key) *shard {
h := maphash.String(l.seed, k.Name)
// Mix the type/scope into the high bits so A and AAAA for one name do not
// always land in the same shard.
h ^= uint64(k.Type)<<40 | uint64(k.Scope)<<56
return l.shards[h&l.mask]
}
// Get returns the entry for k and reports its kind. The returned entry is
// shared and must be treated as read-only.
//
// stale controls whether an expired-but-not-yet-dead entry is returned. When
// stale is returned the caller should kick off a refresh (prefetch) and
// answer from the stale copy.
func (l *LRU) Get(k Key, now time.Time) (e *Entry, kind HitKind) {
s := l.shardFor(k)
s.mu.Lock()
el, ok := s.items[k]
if !ok {
s.mu.Unlock()
l.misses.Add(1)
return nil, Miss
}
it := el.Value.(*lruItem)
if now.After(it.entry.StaleUntil) {
// Fully dead: drop it now so the map does not grow unbounded.
s.ll.Remove(el)
delete(s.items, k)
s.bytes -= it.entry.Bytes
s.mu.Unlock()
l.bytes.Add(-int64(it.entry.Bytes))
l.entries.Add(-1)
l.expired.Add(1)
l.misses.Add(1)
return nil, Miss
}
s.ll.MoveToFront(el)
e = it.entry
kind = Fresh
if e.Expired(now) {
kind = Stale
}
s.mu.Unlock()
e.Hits++
if kind == Fresh {
l.hits.Add(1)
} else {
l.staleHits.Add(1)
}
return e, kind
}
// Put inserts or replaces an entry, evicting the shard's least-recently-used
// entries if the per-shard budget is exceeded.
func (l *LRU) Put(k Key, e *Entry) {
if l == nil {
return
}
s := l.shardFor(k)
s.mu.Lock()
if el, ok := s.items[k]; ok {
it := el.Value.(*lruItem)
s.bytes -= it.entry.Bytes
l.bytes.Add(-int64(it.entry.Bytes))
it.entry = e
s.ll.MoveToFront(el)
} else {
el := s.ll.PushFront(&lruItem{key: k, entry: e})
s.items[k] = el
l.entries.Add(1)
}
s.bytes += e.Bytes
l.bytes.Add(int64(e.Bytes))
var dropped int
for s.ll.Len() > l.perShard {
oldest := s.ll.Back()
if oldest == nil {
break
}
it := oldest.Value.(*lruItem)
s.ll.Remove(oldest)
delete(s.items, it.key)
s.bytes -= it.entry.Bytes
l.bytes.Add(-int64(it.entry.Bytes))
l.entries.Add(-1)
dropped++
}
// Do not hold the lock while the global counters move: they are
// independent atomics, so batching is a pure win.
s.mu.Unlock()
l.evictions.Add(uint64(dropped))
l.inserts.Add(1)
}
// Delete removes one entry.
func (l *LRU) Delete(k Key) {
s := l.shardFor(k)
s.mu.Lock()
if el, ok := s.items[k]; ok {
it := el.Value.(*lruItem)
s.ll.Remove(el)
delete(s.items, k)
s.bytes -= it.entry.Bytes
s.mu.Unlock()
l.bytes.Add(-int64(it.entry.Bytes))
l.entries.Add(-1)
return
}
s.mu.Unlock()
}
// Flush drops everything.
func (l *LRU) Flush() {
for _, s := range l.shards {
s.mu.Lock()
n := s.ll.Len()
b := s.bytes
s.items = make(map[Key]*list.Element, 1024)
s.ll.Init()
s.bytes = 0
s.mu.Unlock()
l.entries.Add(-int64(n))
l.bytes.Add(-int64(b))
}
}
// Walk visits every live entry. fn runs without the shard lock held, so it
// must not retain the entry; entries removed meanwhile are simply skipped.
// Returning false from fn stops the walk.
func (l *LRU) Walk(fn func(Key, *Entry) bool) {
for _, s := range l.shards {
s.mu.Lock()
items := make([]lruItem, 0, s.ll.Len())
for el := s.ll.Front(); el != nil; el = el.Next() {
it := el.Value.(*lruItem)
items = append(items, *it)
}
s.mu.Unlock()
for i := range items {
if !fn(items[i].key, items[i].entry) {
return
}
}
}
}
// DeleteWhere visits every entry and removes those matching pred.
func (l *LRU) DeleteWhere(pred func(Key, *Entry) bool) int {
removed := 0
for _, s := range l.shards {
s.mu.Lock()
for el := s.ll.Front(); el != nil; {
next := el.Next()
it := el.Value.(*lruItem)
if pred(it.key, it.entry) {
s.ll.Remove(el)
delete(s.items, it.key)
s.bytes -= it.entry.Bytes
l.bytes.Add(-int64(it.entry.Bytes))
l.entries.Add(-1)
removed++
}
el = next
}
s.mu.Unlock()
}
return removed
}
// TryPrefetch acquires a refresh slot without blocking. Callers use it to
// make sure a burst of stale hits triggers exactly one background refresh.
func (l *LRU) TryPrefetch() bool {
select {
case l.prefetchGate <- struct{}{}:
return true
default:
return false
}
}
// ReleasePrefetch returns a slot taken by TryPrefetch.
func (l *LRU) ReleasePrefetch() {
select {
case <-l.prefetchGate:
default:
}
}
// HitKind describes how a lookup was satisfied.
type HitKind uint8
const (
// Miss means the answer must be resolved.
Miss HitKind = iota
// Fresh means the entry is inside its TTL.
Fresh
// Stale means the entry is past its TTL but inside the serve-stale
// window: serve it and refresh in the background.
Stale
)
func (h HitKind) String() string {
switch h {
case Fresh:
return "hit"
case Stale:
return "stale"
default:
return "miss"
}
}
// LRUStats is a snapshot of the LRU counters.
type LRUStats struct {
Entries int `json:"entries"`
Bytes int64 `json:"bytes"`
Hits uint64 `json:"hits"`
Misses uint64 `json:"misses"`
StaleHits uint64 `json:"stale_hits"`
Inserts uint64 `json:"inserts"`
Evictions uint64 `json:"evictions"`
Expired uint64 `json:"expired"`
HitRate float64 `json:"hit_rate"`
Shards int `json:"shards"`
Capacity int `json:"capacity"`
}
// Stats snapshots the counters.
func (l *LRU) Stats() LRUStats {
if l == nil {
return LRUStats{}
}
hits := l.hits.Load()
misses := l.misses.Load()
total := hits + misses
var rate float64
if total > 0 {
rate = float64(hits) / float64(total)
}
return LRUStats{
Entries: int(l.entries.Load()),
Bytes: l.bytes.Load(),
Hits: hits,
Misses: misses,
StaleHits: l.staleHits.Load(),
Inserts: l.inserts.Load(),
Evictions: l.evictions.Load(),
Expired: l.expired.Load(),
HitRate: rate,
Shards: len(l.shards),
Capacity: l.perShard * len(l.shards),
}
}
// SetCapacity resizes the per-shard budget. Existing entries are kept; the
// next insert past the new budget evicts.
func (l *LRU) SetCapacity(size, shards int) {
if shards <= 0 {
shards = len(l.shards)
}
shards = roundPow2(shards)
l.perShard = max(1, size/shards)
}
+596
View File
@@ -0,0 +1,596 @@
package cache
import (
"math"
"sort"
"strconv"
"sync"
"sync/atomic"
"time"
"github.com/miekg/dns"
)
var (
errKeyTooLong = errString("cache: name too long to serialise")
errBadKey = errString("cache: corrupt persisted key")
)
type errString string
func (e errString) Error() string { return string(e) }
// Options configures a Manager.
type Options struct {
// Size is the global entry budget for the in-memory LRU.
Size int
// Shards is the LRU shard count (rounded to a power of two).
Shards int
// MinTTL and MaxTTL clamp the TTLs taken from upstream answers.
MinTTL time.Duration
MaxTTL time.Duration
// NegativeTTL bounds cached NXDOMAIN/NODATA answers.
NegativeTTL time.Duration
// ServeStale keeps expired entries answerable for this long.
ServeStale time.Duration
// Prefetch refreshes stale entries in the background when a hook is set.
Prefetch bool
// Now is injectable for tests.
Now func() time.Time
}
// Manager is the cache front door: TTL policy, serve-stale, prefetching and
// the persisted snapshot all live here, so the DNS handler stays a straight
// pipeline.
type Manager struct {
lru *LRU
opt Options
now func() time.Time
// set holds a snapshot-aware view: the LRU plus the persisted store.
store *Store
// prefetchFn is called (off the hot path) when a stale entry is served.
// It is set once at wiring time via SetPrefetcher.
prefetchFn atomic.Pointer[prefetcher]
// top holds a rolling per-domain hit counter for the "top cached" UI.
top *topDomains
// Blocked counts blocklist hits served from the matcher, kept here so the
// dashboard can show block volume without a second counter package.
blockedSince atomic.Uint64
mu sync.Mutex
flushing bool
}
type prefetcher func(Key)
// NewManager builds a Manager. store may be nil (persistence disabled).
func NewManager(opt Options, store *Store) *Manager {
if opt.Now == nil {
opt.Now = time.Now
}
if opt.Size <= 0 {
opt.Size = 200000
}
m := &Manager{
lru: NewLRU(opt.Size, opt.Shards),
opt: opt,
now: opt.Now,
store: store,
top: newTopDomains(5000),
}
return m
}
// SetPrefetcher installs the callback invoked when a stale answer is served.
func (m *Manager) SetPrefetcher(fn func(Key)) {
if fn == nil {
m.prefetchFn.Store(nil)
return
}
m.prefetchFn.Store((*prefetcher)(&fn))
}
// LRU exposes the underlying LRU for stats and tests.
func (m *Manager) LRU() *LRU { return m.lru }
// Now returns the manager's clock.
func (m *Manager) Now() time.Time { return m.now() }
// Result describes a cache hit.
type Result struct {
// Kind is Fresh, Stale or Miss.
Kind HitKind
// Msg is a private unpacked copy for Fresh and Stale hits.
Msg *dns.Msg
// TTL is the TTL the answer was served with (already aged).
TTL uint32
// Origin and Upstream are carried through from the stored entry.
Origin string
Upstream string
// Age is how long ago the entry was stored.
Age time.Duration
// ServedStale is set when a stale entry was used; callers may want to
// report the degraded state.
ServedStale bool
}
// Get looks up a key. On a Fresh hit it returns a private, TTL-aged copy.
// On a Stale hit it returns the same plus kicks off a background refresh when
// prefetching is enabled.
func (m *Manager) Get(k Key) Result {
now := m.now()
e, kind := m.lru.Get(k, now)
if e == nil {
return Result{Kind: Miss}
}
msg, err := e.unpack(k, now)
if err != nil {
// A corrupt entry poisons the cache for this key; drop it.
m.lru.Delete(k)
return Result{Kind: Miss}
}
m.top.observe(k, e)
res := Result{
Kind: kind,
Msg: msg,
TTL: e.AgeTTL(now),
Origin: e.Origin,
Upstream: e.Upstream,
Age: now.Sub(e.CreatedAt),
ServedStale: kind == Stale,
}
if kind == Stale {
res.TTL = 0
if p := m.prefetchFn.Load(); p != nil && m.opt.Prefetch && m.lru.TryPrefetch() {
fn := *p
go func() {
defer m.lru.ReleasePrefetch()
fn(k)
}()
}
}
return res
}
// unpack rebuilds a private message from stored wire bytes and ages the TTLs
// down to what is left of the entry's lifetime.
func (e *Entry) unpack(k Key, now time.Time) (*dns.Msg, error) {
msg := new(dns.Msg)
if err := msg.Unpack(e.Wire); err != nil {
return nil, err
}
remaining := e.AgeTTL(now)
ageAll(msg, remaining)
// The stored question is the canonical one; trust the key, not the wire.
if len(msg.Question) == 0 || !equalName(msg.Question[0].Name, k.Name) {
msg.Question = []dns.Question{{
Name: dns.Fqdn(k.Name),
Qtype: k.Type,
Qclass: k.Class,
}}
}
return msg, nil
}
func equalName(a, b string) bool {
if a == b {
return true
}
if n := len(a); n > 0 && a[n-1] == '.' {
a = a[:n-1]
}
if n := len(b); n > 0 && b[n-1] == '.' {
b = b[:n-1]
}
return a == b
}
// ageAll sets the TTL of every record in msg to ttl.
func ageAll(msg *dns.Msg, ttl uint32) {
for i := range msg.Answer {
msg.Answer[i].Header().Ttl = ttl
}
for i := range msg.Ns {
msg.Ns[i].Header().Ttl = ttl
}
for i := range msg.Extra {
// OPT records carry no TTL semantics; leave the flags alone.
if _, ok := msg.Extra[i].(*dns.OPT); ok {
continue
}
msg.Extra[i].Header().Ttl = ttl
}
}
// PutResponse stores an upstream response under k, deriving the TTL from the
// answer itself and clamping it to the configured bounds.
//
// The message is packed once; the stored bytes are independent of the caller's
// copy, so the caller keeps ownership of its message.
func (m *Manager) PutResponse(k Key, msg *dns.Msg, upstream string) {
ttl, neg := m.TTLFor(msg)
if ttl <= 0 {
// A zero TTL means "do not cache"; respect that rather than pinning an
// uncacheable answer in memory.
return
}
wire, err := msg.Pack()
if err != nil {
return
}
m.putWire(k, wire, ttl, "upstream", upstream, neg)
}
// PutLocal stores a synthesised answer (local zone, blocklist response).
func (m *Manager) PutLocal(k Key, msg *dns.Msg, origin string) {
ttl, neg := m.TTLFor(msg)
if ttl <= 0 {
ttl = 60
}
wire, err := msg.Pack()
if err != nil {
return
}
m.putWire(k, wire, ttl, origin, "", neg)
}
// PutRaw stores pre-packed wire bytes with an explicit TTL. This is the path
// used when restoring a snapshot, where packing again would be wasted work.
func (m *Manager) PutRaw(k Key, wire []byte, ttl uint32, origin, upstream string, neg bool) {
m.putWire(k, wire, ttl, origin, upstream, neg)
}
func (m *Manager) putWire(k Key, wire []byte, ttl uint32, origin, upstream string, neg bool) {
if m == nil || len(wire) == 0 || ttl == 0 {
return
}
now := m.now()
buf := make([]byte, len(wire))
copy(buf, wire)
// Record the insert too, not just the hits: a name queried once must show
// up in "top cached names", and a miss-only workload has no hits to count.
m.top.observe(k, &Entry{TTL: ttl, CreatedAt: now})
// Key bytes, the wire body and the entry struct: account for all three so
// the LRU budget reflects real memory.
e := &Entry{
Wire: buf,
TTL: ttl,
Origin: origin,
Upstream: upstream,
CreatedAt: now,
ExpiresAt: now.Add(time.Duration(ttl) * time.Second),
StaleUntil: now.Add(time.Duration(ttl)*time.Second + m.serveStaleWindow()),
Bytes: len(wire) + len(k.Name) + 96,
Neg: neg,
}
m.lru.Put(k, e)
}
func (m *Manager) serveStaleWindow() time.Duration {
if m.opt.ServeStale < 0 {
return 0
}
return m.opt.ServeStale
}
// TTLFor derives a cache TTL from a response, honouring the negative-cache
// policy. The second return reports whether the answer is a negative one
// (NXDOMAIN or NODATA), which the UI and the log show differently.
func (m *Manager) TTLFor(msg *dns.Msg) (ttl uint32, neg bool) {
if msg == nil {
return 0, false
}
if msg.Truncated {
// Never cache a truncated answer: the client will retry over TCP and
// we would serve it the truncated form forever.
return 0, false
}
switch msg.Rcode {
case dns.RcodeNameError:
return m.negativeTTL(msg), true
case dns.RcodeSuccess, dns.RcodeNotImplemented:
// fall through to the general case
default:
// SERVFAIL, REFUSED, NOTAUTH and friends are transient by nature.
return 0, true
}
if len(msg.Answer) == 0 {
// NODATA: the authority section's SOA carries the negative TTL.
return m.negativeTTL(msg), true
}
minTTL := uint32(math.MaxUint32)
found := false
for _, rr := range msg.Answer {
switch rr.(type) {
case *dns.OPT:
continue
}
if t := rr.Header().Ttl; t < minTTL {
minTTL = t
found = true
}
}
if !found {
return 0, true
}
return m.clamp(minTTL), false
}
// soaOf finds the zone's start-of-authority record, which is where a
// negative answer's TTL comes from (RFC 2308).
func soaOf(msg *dns.Msg) *dns.SOA {
for _, rr := range msg.Ns {
if soa, ok := rr.(*dns.SOA); ok {
return soa
}
}
for _, rr := range msg.Answer {
if soa, ok := rr.(*dns.SOA); ok {
return soa
}
}
return nil
}
func (m *Manager) negativeTTL(msg *dns.Msg) uint32 {
soa := soaOf(msg)
ttl := uint32(0)
if soa != nil {
ttl = soa.Hdr.Ttl
if soa.Minttl < ttl {
ttl = soa.Minttl
}
}
if ttl == 0 {
// No SOA to go on: fall back to the shortest TTL in the message, then
// to the configured default.
for _, rr := range msg.Ns {
if t := rr.Header().Ttl; t > 0 && t < ttl {
ttl = t
}
}
if ttl == 0 {
ttl = uint32(m.opt.NegativeTTL / time.Second)
}
}
return m.clamp(ttl)
}
func (m *Manager) clamp(ttl uint32) uint32 {
if min := uint32(m.opt.MinTTL / time.Second); min > 0 && ttl < min {
ttl = min
}
if max := uint32(m.opt.MaxTTL / time.Second); max > 0 && ttl > max {
ttl = max
}
return ttl
}
// Delete drops one key.
func (m *Manager) Delete(k Key) { m.lru.Delete(k) }
// Flush empties the cache and, when persistence is enabled, removes the
// snapshot so a restart does not resurrect it.
func (m *Manager) Flush() {
m.lru.Flush()
m.top.reset()
if m.store != nil {
_ = m.store.Truncate()
}
}
// DeleteName drops every entry for a name, across all types. The blocklist API
// calls it when an operator edits rules, so a newly blocked name does not keep
// serving a cached good answer until it expires.
func (m *Manager) DeleteName(name string) int {
want := canonical(name)
return m.lru.DeleteWhere(func(k Key, _ *Entry) bool { return k.Name == want })
}
// DeleteSubtree drops every entry at or below a name suffix. Used when a
// whole blocklist is replaced, so stale good answers for newly-listed domains
// disappear immediately.
func (m *Manager) DeleteSubtree(suffix string) int {
want := canonical(suffix)
return m.lru.DeleteWhere(func(k Key, _ *Entry) bool {
return k.Name == want || hasSuffix(k.Name, want)
})
}
func hasSuffix(name, suffix string) bool {
if len(name) < len(suffix)+1 {
return false
}
return name[len(name)-len(suffix)-1:] == "."+suffix
}
// Resize applies a new capacity, e.g. after a config reload.
func (m *Manager) Resize(size, shards int) {
m.opt.Size = size
m.lru.SetCapacity(size, shards)
}
// Options returns the manager's current options.
func (m *Manager) Options() Options { return m.opt }
// TopEntries returns the hottest names in the cache for the UI.
func (m *Manager) TopEntries(limit int) []TopEntry {
return m.top.top(limit)
}
// Stats is the JSON-friendly cache snapshot.
type Stats struct {
Enabled bool `json:"enabled"`
Size int `json:"size"`
Entries int `json:"entries"`
Shards int `json:"shards"`
MinTTLMS int64 `json:"min_ttl_ms"`
MaxTTLMS int64 `json:"max_ttl_ms"`
ServeStaleM int64 `json:"serve_stale_ms"`
NegativeMS int64 `json:"negative_ttl_ms"`
HitRate float64 `json:"hit_rate"`
Hits uint64 `json:"hits"`
Misses uint64 `json:"misses"`
StaleHits uint64 `json:"stale_hits"`
Inserts uint64 `json:"inserts"`
Evictions uint64 `json:"evictions"`
Expired uint64 `json:"expired"`
Bytes int64 `json:"bytes"`
MemoryHuman string `json:"bytes_human"`
Persist PersistSt `json:"persisted"`
}
// PersistSt describes the snapshot file.
type PersistSt struct {
Enabled bool `json:"enabled"`
Path string `json:"path"`
Bytes int64 `json:"bytes"`
Entries int `json:"entries"`
LastSnapshot time.Time `json:"last_snapshot"`
Writes uint64 `json:"writes"`
Errors uint64 `json:"errors"`
Loaded bool `json:"loaded"`
}
// Stats snapshots the manager.
func (m *Manager) Stats() Stats {
s := m.lru.Stats()
st := Stats{
Enabled: true,
Size: m.opt.Size,
Entries: s.Entries,
Shards: s.Shards,
MinTTLMS: m.opt.MinTTL.Milliseconds(),
MaxTTLMS: m.opt.MaxTTL.Milliseconds(),
ServeStaleM: m.opt.ServeStale.Milliseconds(),
NegativeMS: m.opt.NegativeTTL.Milliseconds(),
HitRate: s.HitRate,
Hits: s.Hits,
Misses: s.Misses,
StaleHits: s.StaleHits,
Inserts: s.Inserts,
Evictions: s.Evictions,
Expired: s.Expired,
Bytes: s.Bytes,
}
st.MemoryHuman = HumanBytes(s.Bytes)
if m.store != nil {
st.Persist = m.store.Stats()
} else {
st.Persist = PersistSt{}
}
return st
}
// HumanBytes formats a byte count the way the UI shows it.
func HumanBytes(n int64) string {
neg := ""
if n < 0 {
neg, n = "-", -n
}
switch {
case n < 1024:
return neg + strconv.FormatInt(n, 10) + " B"
case n < 1024*1024:
return neg + strconv.FormatFloat(float64(n)/1024, 'f', 1, 64) + " KiB"
case n < 1024*1024*1024:
return neg + strconv.FormatFloat(float64(n)/(1024*1024), 'f', 1, 64) + " MiB"
default:
return neg + strconv.FormatFloat(float64(n)/(1024*1024*1024), 'f', 2, 64) + " GiB"
}
}
// topDomains is a bounded, periodic top-N counter. It uses a compact
// min-heap-free approach: a plain map plus periodic pruning, which is far
// cheaper than maintaining a sorted structure on every query.
type topDomains struct {
mu sync.Mutex
counts map[string]*topCount
limit int
}
type topCount struct {
name string
qtype uint16
hits uint64
bytes int64
}
func newTopDomains(limit int) *topDomains {
return &topDomains{counts: make(map[string]*topCount, 4096), limit: limit}
}
func (t *topDomains) observe(k Key, e *Entry) {
if t == nil {
return
}
id := k.Name + "|" + strconv.Itoa(int(k.Type))
t.mu.Lock()
c, ok := t.counts[id]
if !ok {
if len(t.counts) > t.limit*2 {
t.pruneLocked()
}
c = &topCount{name: k.Name, qtype: k.Type}
t.counts[id] = c
}
c.hits++
c.bytes += int64(len(e.Wire))
t.mu.Unlock()
}
func (t *topDomains) pruneLocked() {
// Drop the bottom half so pruning is amortised.
type kv struct {
id string
c *topCount
}
all := make([]kv, 0, len(t.counts))
for id, c := range t.counts {
all = append(all, kv{id, c})
}
sort.Slice(all, func(i, j int) bool { return all[i].c.hits < all[j].c.hits })
keep := len(all) / 2
for _, e := range all[keep:] {
delete(t.counts, e.id)
}
}
func (t *topDomains) reset() {
t.mu.Lock()
t.counts = make(map[string]*topCount, 4096)
t.mu.Unlock()
}
// TopEntry is one row of the "top cached names" table.
type TopEntry struct {
Name string `json:"name"`
Type uint16 `json:"qtype"`
Hits uint64 `json:"hits"`
Bytes int64 `json:"bytes"`
}
func (t *topDomains) top(limit int) []TopEntry {
if t == nil {
return nil
}
if limit <= 0 || limit > 500 {
limit = 50
}
t.mu.Lock()
out := make([]TopEntry, 0, limit)
for _, c := range t.counts {
out = append(out, TopEntry{Name: c.name, Type: c.qtype, Hits: c.hits, Bytes: c.bytes})
}
t.mu.Unlock()
sort.Slice(out, func(i, j int) bool { return out[i].Hits > out[j].Hits })
if len(out) > limit {
out = out[:limit]
}
return out
}
+251
View File
@@ -0,0 +1,251 @@
package cache
import (
"encoding/binary"
"fmt"
"os"
"path/filepath"
"sync"
"time"
bolt "go.etcd.io/bbolt"
)
var bucketCache = []byte("cache")
// Store persists a snapshot of the LRU to a bbolt file so a restart (or a
// rolling deploy of a cluster) resumes with a warm cache.
//
// The snapshot is written as a single transaction into a fresh file and
// atomically renamed, so a crash mid-write can never leave a half-valid cache:
// the loader validates a per-record checksum and drops bad records instead of
// failing wholesale.
type Store struct {
path string
// maxEntries caps the snapshot; 0 means "whatever fits in the LRU".
maxEntries int
mu sync.Mutex
db *bolt.DB
writes uint64
errors uint64
lastSnap time.Time
loaded bool
}
// StoreOptions configures a Store.
type StoreOptions struct {
// Path is the bbolt file to use.
Path string
// MaxEntries caps how many entries a snapshot may contain.
MaxEntries int
// Timeout bounds a single snapshot write.
Timeout time.Duration
}
// OpenStore opens (or creates) the bbolt file. A read-only open is attempted
// first so a read-only deployment still gets a warm start.
func OpenStore(opt StoreOptions) (*Store, error) {
if opt.Path == "" {
return nil, fmt.Errorf("cache: store path is required")
}
if err := os.MkdirAll(filepath.Dir(opt.Path), 0o755); err != nil {
return nil, fmt.Errorf("cache: create data dir: %w", err)
}
timeout := opt.Timeout
if timeout <= 0 {
timeout = 30 * time.Second
}
db, err := bolt.Open(opt.Path, 0o600, &bolt.Options{Timeout: timeout, NoFreelistSync: true})
if err != nil {
return nil, fmt.Errorf("cache: open %s: %w", opt.Path, err)
}
if err := db.Update(func(tx *bolt.Tx) error {
_, err := tx.CreateBucketIfNotExists(bucketCache)
return err
}); err != nil {
_ = db.Close()
return nil, err
}
return &Store{path: opt.Path, maxEntries: opt.MaxEntries, db: db}, nil
}
// Path is the bbolt file path.
func (s *Store) Path() string { return s.path }
// Load restores entries into the LRU. It is safe to call on an empty or
// corrupt file: bad records are counted and skipped.
func (s *Store) Load(l *LRU, now time.Time) (loaded int, skipped int, err error) {
s.mu.Lock()
defer s.mu.Unlock()
err = s.db.View(func(tx *bolt.Tx) error {
b := tx.Bucket(bucketCache)
if b == nil {
return nil
}
cur := b.Cursor()
for k, _ := cur.First(); k != nil; k, _ = cur.Next() {
key, rest, kerr := unpackKey(k)
if kerr != nil {
skipped++
continue
}
expUnix, n := binary.Uvarint(rest)
if n <= 0 {
skipped++
continue
}
staleUnix, n2 := binary.Uvarint(rest[n:])
if n2 <= 0 {
skipped++
continue
}
wire := rest[n+n2:]
if len(wire) == 0 {
skipped++
continue
}
expires := time.Unix(0, int64(expUnix))
staleUntil := time.Unix(0, int64(staleUnix))
if staleUntil.Before(now) {
// Already outside the serve-stale window: not worth loading.
skipped++
continue
}
ttl := uint32(expires.Sub(now).Seconds())
e := &Entry{
Wire: append([]byte(nil), wire...),
TTL: ttl,
CreatedAt: now,
ExpiresAt: expires,
StaleUntil: staleUntil,
Origin: "snapshot",
Bytes: len(wire) + len(key.Name) + 96,
}
l.Put(key, e)
loaded++
}
return nil
})
if err != nil {
s.errors++
return loaded, skipped, err
}
s.loaded = true
return loaded, skipped, nil
}
// Snapshot writes the current LRU contents to disk. Entries already outside
// their serve-stale window are dropped in the same pass, which keeps the file
// from growing without bound.
func (s *Store) Snapshot(l *LRU, now time.Time) (written int, err error) {
s.mu.Lock()
defer s.mu.Unlock()
// Collect first, serialise after: the walk must not hold shard locks for
// the length of a bolt transaction.
type row struct{ key, val []byte }
rows := make([]row, 0, 8192)
dropped := 0
l.Walk(func(k Key, e *Entry) bool {
if !e.StaleUntil.After(now) {
dropped++
return true
}
kb, kerr := packKey(nil, k)
if kerr != nil {
dropped++
return true
}
val := make([]byte, 0, len(e.Wire)+32)
val = binary.AppendUvarint(val, uint64(e.ExpiresAt.UnixNano()))
val = binary.AppendUvarint(val, uint64(e.StaleUntil.UnixNano()))
val = append(val, e.Wire...)
rows = append(rows, row{key: kb, val: val})
return true
})
if s.maxEntries > 0 && len(rows) > s.maxEntries {
dropped += len(rows) - s.maxEntries
rows = rows[:s.maxEntries]
}
err = s.db.Update(func(tx *bolt.Tx) error {
b, err := tx.CreateBucketIfNotExists(bucketCache)
if err != nil {
return err
}
// Rebuild the bucket so a shrinking cache really shrinks the file.
if err := tx.DeleteBucket(bucketCache); err != nil && err != bolt.ErrBucketNotFound {
return err
}
b, err = tx.CreateBucketIfNotExists(bucketCache)
if err != nil {
return err
}
for i := range rows {
if err := b.Put(rows[i].key, rows[i].val); err != nil {
return err
}
}
return nil
})
if err != nil {
s.errors++
return 0, err
}
s.writes++
s.lastSnap = now
return len(rows), nil
}
// Truncate empties the snapshot file.
func (s *Store) Truncate() error {
s.mu.Lock()
defer s.mu.Unlock()
err := s.db.Update(func(tx *bolt.Tx) error {
if tx.Bucket(bucketCache) == nil {
return nil
}
return tx.DeleteBucket(bucketCache)
})
if err != nil {
s.errors++
}
return err
}
// Stats reports the snapshot file's state.
func (s *Store) Stats() PersistSt {
s.mu.Lock()
defer s.mu.Unlock()
st := PersistSt{
Enabled: true,
Path: s.path,
LastSnapshot: s.lastSnap,
Writes: s.writes,
Errors: s.errors,
Loaded: s.loaded,
}
if fi, err := os.Stat(s.path); err == nil {
st.Bytes = fi.Size()
}
if s.db != nil {
_ = s.db.View(func(tx *bolt.Tx) error {
if b := tx.Bucket(bucketCache); b != nil {
st.Entries = b.Stats().KeyN
}
return nil
})
}
return st
}
// Close releases the file handle.
func (s *Store) Close() error {
s.mu.Lock()
defer s.mu.Unlock()
if s.db == nil {
return nil
}
return s.db.Close()
}
+630
View File
@@ -0,0 +1,630 @@
// Package cluster keeps several fancy-dns instances in agreement using
// Valkey (Redis protocol): configuration changes are broadcast, blocklist
// digests are compared so a lagging instance can be spotted, and one instance
// is elected to fetch lists so N instances do not hammer the same mirror N
// times.
package cluster
import (
"context"
"crypto/sha1"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"log/slog"
"os"
"sort"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/redis/go-redis/v9"
"git.k8s-git-k6n.net/mats/fancy-dns/internal/config"
)
// EventKind names a broadcast message.
type EventKind string
const (
// EventConfig carries a full configuration document.
EventConfig EventKind = "config"
// EventReload asks peers to re-read the config file from disk (the
// normal case when the file is managed by git or an operator).
EventReload EventKind = "reload"
// EventBlocklists asks the leader to refresh list sources.
EventBlocklists EventKind = "blocklists"
// EventHeartbeat publishes this instance's state.
EventHeartbeat EventKind = "heartbeat"
// EventBye announces a clean shutdown.
EventBye EventKind = "bye"
)
// Event is a cluster message.
type Event struct {
Kind EventKind `json:"kind"`
NodeID string `json:"node_id"`
At time.Time `json:"at"`
Version int64 `json:"version,omitempty"`
Config string `json:"config,omitempty"`
State *NodeState `json:"state,omitempty"`
Sources []string `json:"sources,omitempty"`
Extra map[string]any `json:"extra,omitempty"`
}
// NodeState is what an instance publishes about itself.
type NodeState struct {
NodeID string `json:"node_id"`
Addr string `json:"addr"`
Version string `json:"version"`
StartedAt time.Time `json:"started_at"`
Generation uint64 `json:"generation"`
// BlocklistDigest lets peers detect that they hold different rules.
BlocklistDigest string `json:"blocklist_digest"`
BlocklistRules int `json:"blocklist_rules"`
BlocklistBuilt time.Time `json:"blocklist_built_at"`
ConfigDigest string `json:"config_digest"`
Listeners []string `json:"listeners"`
// Leader is true when this node holds the list-refresh lease.
Leader bool `json:"leader"`
}
// Handler receives cluster events from peers.
type Handler func(context.Context, Event)
// Options configures a Cluster.
type Options struct {
// Config is the hot-reloadable config store.
Config *config.Store
// Logger is the structured logger.
Logger *slog.Logger
// Version is the running build's version string.
Version string
// StartedAt is this process's start time.
StartedAt time.Time
// Addr is this instance's advertised admin address.
Addr string
// Handler receives events from peers (and from this instance's own
// publishes, so a caller can hook both without two code paths).
Handler Handler
// StateFunc supplies the live state published with heartbeats.
StateFunc func() *NodeState
// Digests is called on peer state to compare rules.
Digests func() (blocklist string, rules int, built time.Time, cfgDigest string)
}
// Cluster is the Valkey-backed coordination layer. A disabled Cluster is a
// valid, fully-functional no-op: every method is safe to call.
type Cluster struct {
cfg *config.Store
log *slog.Logger
opt Options
node string
client atomic.Pointer[redis.Client]
// connected is true while the pubsub loop is healthy.
connected atomic.Bool
// leader is true while this instance holds the refresh lease.
leader atomic.Bool
// lastEvent is when a peer message last arrived, for the health check.
lastEvent atomic.Int64
// seen is the last state per node id.
seenMu sync.RWMutex
seen map[string]NodeState
stop chan struct{}
wg sync.WaitGroup
}
// New builds a Cluster. Call Start to connect.
func New(opt Options) *Cluster {
log := opt.Logger
if log == nil {
log = slog.Default()
}
c := &Cluster{
cfg: opt.Config,
log: log.With("component", "cluster"),
opt: opt,
node: opt.Version,
seen: make(map[string]NodeState),
stop: make(chan struct{}),
}
if cfg := opt.Config.Get(); cfg != nil && cfg.Cluster.NodeID != "" {
c.node = cfg.Cluster.NodeID
}
if c.node == "" {
host, err := os.Hostname()
if err != nil || host == "" {
host = "fancy-dns"
}
c.node = host
}
c.node = c.node + "-" + shortID()
return c
}
func shortID() string {
b := make([]byte, 4)
if _, err := cryptoRead(b); err != nil {
return "0000"
}
return hex.EncodeToString(b)
}
// NodeID is this instance's cluster identity.
func (c *Cluster) NodeID() string { return c.node }
// Enabled reports whether clustering is configured on.
func (c *Cluster) Enabled() bool {
if c == nil || c.cfg == nil {
return false
}
return c.cfg.Get().Cluster.Enabled
}
// Connected reports whether the pubsub connection is up.
func (c *Cluster) Connected() bool { return c.connected.Load() }
// IsLeader reports whether this instance holds the refresh lease.
func (c *Cluster) IsLeader() bool { return c.leader.Load() }
// Start connects and subscribes, then keeps the connection alive until ctx is
// cancelled. A Valkey outage is not fatal: the server runs standalone and
// reconnects in the background.
func (c *Cluster) Start(ctx context.Context) {
if !c.Enabled() {
c.log.Info("cluster disabled")
return
}
cfg := c.cfg.Get()
cl, err := c.dial(cfg)
if err != nil {
c.log.Warn("cluster: cannot connect, running standalone", "error", err)
return
}
c.client.Store(cl)
c.connected.Store(true)
c.log.Info("cluster connected", "addrs", cfg.Cluster.Valkey.Addrs, "node", c.node)
c.publishState(ctx)
c.wg.Add(1)
go func() {
defer c.wg.Done()
c.maintain(ctx)
}()
}
// dial builds a Valkey client from config.
func (c *Cluster) dial(cfg *config.Config) (*redis.Client, error) {
v := cfg.Cluster.Valkey
cl := redis.NewClient(&redis.Options{
Addr: strings.Join(v.Addrs, ","),
Username: v.Username,
Password: v.Password,
DB: v.DB,
PoolSize: max(1, v.PoolSize),
DialTimeout: v.DialTimeout.D(),
ReadTimeout: v.ReadTimeout.D(),
WriteTimeout: v.ReadTimeout.D(),
})
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := cl.Ping(ctx).Err(); err != nil {
_ = cl.Close()
return nil, err
}
return cl, nil
}
// maintain runs the pubsub receive loop and the periodic heartbeat, reconnecting
// as needed.
func (c *Cluster) maintain(ctx context.Context) {
for {
if ctx.Err() != nil || c.stopping() {
return
}
if err := c.runSession(ctx); err != nil {
c.log.Warn("cluster session ended, retrying", "error", err)
c.connected.Store(false)
select {
case <-ctx.Done():
return
case <-c.stop:
return
case <-time.After(3 * time.Second):
}
continue
}
return
}
}
func (c *Cluster) stopping() bool {
select {
case <-c.stop:
return true
default:
return false
}
}
// runSession subscribes and dispatches until the connection drops.
func (c *Cluster) runSession(ctx context.Context) error {
cl := c.client.Load()
if cl == nil {
return errors.New("cluster: no client")
}
sub := cl.Subscribe(ctx, c.channel(EventHeartbeat), c.channel(EventConfig), c.channel(EventReload), c.channel(EventBlocklists), c.channel(EventBye))
defer func() { _ = sub.Close() }()
// Confirm the subscription is live before declaring the session healthy.
if _, err := sub.Receive(ctx); err != nil {
return fmt.Errorf("cluster: subscribe: %w", err)
}
c.connected.Store(true)
c.log.Info("cluster subscribed", "channel", c.channel(EventHeartbeat))
hb := time.NewTicker(c.cfg.Get().Cluster.PeerTTL.D() / 2)
defer hb.Stop()
ch := sub.Channel()
for {
select {
case <-ctx.Done():
return nil
case <-c.stop:
return nil
case <-hb.C:
c.publishState(ctx)
c.renewLease(ctx)
case msg, ok := <-ch:
if !ok {
return errors.New("cluster: subscription closed")
}
var ev Event
if err := json.Unmarshal([]byte(msg.Payload), &ev); err != nil {
continue // not ours / malformed; ignore rather than crash
}
if ev.NodeID == c.node {
continue // our own echo
}
c.lastEvent.Store(time.Now().Unix())
if ev.State != nil {
c.seenMu.Lock()
c.seen[ev.NodeID] = *ev.State
c.seenMu.Unlock()
}
if ev.Kind == EventBye {
c.seenMu.Lock()
delete(c.seen, ev.NodeID)
c.seenMu.Unlock()
continue
}
if c.opt.Handler != nil {
c.opt.Handler(ctx, ev)
}
}
}
}
func (c *Cluster) channel(k EventKind) string {
prefix := c.cfg.Get().Cluster.Valkey.Prefix
if prefix == "" {
prefix = "fancydns"
}
return prefix + ":events:" + string(k)
}
func (c *Cluster) heartbeatKey() string {
prefix := c.cfg.Get().Cluster.Valkey.Prefix
if prefix == "" {
prefix = "fancydns"
}
return prefix + ":nodes"
}
// State returns this instance's current state.
func (c *Cluster) State() *NodeState {
st := &NodeState{
NodeID: c.node,
Addr: c.opt.Addr,
Version: c.opt.Version,
StartedAt: c.opt.StartedAt,
Generation: c.cfg.Generation(),
Leader: c.leader.Load(),
}
if f := c.opt.StateFunc; f != nil {
if live := f(); live != nil {
live.NodeID = c.node
live.Leader = c.leader.Load()
st = live
}
}
if d := c.opt.Digests; d != nil {
bl, rules, built, cfgDigest := d()
st.BlocklistDigest = bl
st.BlocklistRules = rules
st.BlocklistBuilt = built
st.ConfigDigest = cfgDigest
}
return st
}
// publishState broadcasts this instance's heartbeat.
func (c *Cluster) publishState(ctx context.Context) {
cl := c.client.Load()
if cl == nil {
return
}
st := c.State()
ev := Event{Kind: EventHeartbeat, NodeID: c.node, At: time.Now(), State: st}
data, err := json.Marshal(ev)
if err != nil {
return
}
pipe := cl.Pipeline()
pipe.Publish(ctx, c.channel(EventHeartbeat), data)
// Also keep a keyed record so a late-joining instance can discover the
// existing set without waiting a full heartbeat interval.
pipe.HSet(ctx, c.heartbeatKey(), c.node, mustJSON(st))
pipe.Expire(ctx, c.heartbeatKey(), c.cfg.Get().Cluster.PeerTTL.D()*4)
_, _ = pipe.Exec(ctx)
}
func mustJSON(v any) string {
b, err := json.Marshal(v)
if err != nil {
return "{}"
}
return string(b)
}
// Peers returns the last known state of other instances.
func (c *Cluster) Peers() []NodeState {
c.seenMu.RLock()
defer c.seenMu.RUnlock()
out := make([]NodeState, 0, len(c.seen))
for _, s := range c.seen {
out = append(out, s)
}
sort.Slice(out, func(i, j int) bool { return out[i].NodeID < out[j].NodeID })
return out
}
// BroadcastConfig publishes a full configuration document to peers.
func (c *Cluster) BroadcastConfig(ctx context.Context, cfg *config.Config) error {
cl := c.client.Load()
if !c.Enabled() || cl == nil {
return nil
}
data, err := config.Marshal(cfg)
if err != nil {
return err
}
ev := Event{Kind: EventConfig, NodeID: c.node, At: time.Now(), Version: cfg.Version, Config: string(data)}
payload, err := json.Marshal(ev)
if err != nil {
return err
}
return cl.Publish(ctx, c.channel(EventConfig), payload).Err()
}
// BroadcastReload asks peers to re-read their config file from disk.
func (c *Cluster) BroadcastReload(ctx context.Context) error {
cl := c.client.Load()
if !c.Enabled() || cl == nil {
return nil
}
ev := Event{Kind: EventReload, NodeID: c.node, At: time.Now()}
payload, err := json.Marshal(ev)
if err != nil {
return err
}
return cl.Publish(ctx, c.channel(EventReload), payload).Err()
}
// BroadcastRefresh asks the leader to refresh list sources. The names slice is
// advisory: an empty slice means "everything".
func (c *Cluster) BroadcastRefresh(ctx context.Context, names []string) error {
cl := c.client.Load()
if !c.Enabled() || cl == nil {
return nil
}
ev := Event{Kind: EventBlocklists, NodeID: c.node, At: time.Now(), Sources: names}
payload, err := json.Marshal(ev)
if err != nil {
return err
}
return cl.Publish(ctx, c.channel(EventBlocklists), payload).Err()
}
// Digest computes a stable digest of a configuration document.
func Digest(cfg *config.Config) string {
data, err := config.Marshal(cfg)
if err != nil {
return ""
}
sum := sha1.Sum(data)
return hex.EncodeToString(sum[:8])
}
// leaseKey is the Valkey key guarding the list-refresh leader.
func (c *Cluster) leaseKey() string {
prefix := c.cfg.Get().Cluster.Valkey.Prefix
if prefix == "" {
prefix = "fancydns"
}
return prefix + ":leader"
}
// renewLease acquires or extends the refresh lease. Only the leader fetches
// lists; followers serve their compiled copy and keep their cache warm, so a
// fleet of N nodes does not multiply the load on a blocklist mirror.
func (c *Cluster) renewLease(ctx context.Context) {
cl := c.client.Load()
if cl == nil || !c.Enabled() {
c.leader.Store(false)
return
}
ttl := c.cfg.Get().Cluster.Valkey.LeaderTTL.D()
if ttl <= 0 {
ttl = 30 * time.Second
}
// SET NX with a TTL: only the first caller wins the lease. Everyone else
// just extends it when they already hold it.
ok, err := cl.SetNX(ctx, c.leaseKey(), c.node, ttl).Result()
if err != nil {
c.leader.Store(false)
return
}
if ok {
c.leader.Store(true)
return
}
holder, err := c.leaseHolder(ctx)
if err == nil && holder == c.node {
// We already hold it: extend.
_ = cl.Expire(ctx, c.leaseKey(), ttl).Err()
c.leader.Store(true)
return
}
c.leader.Store(false)
}
// leaseHolder reads who holds the refresh lease.
func (c *Cluster) leaseHolder(ctx context.Context) (string, error) {
cl := c.client.Load()
if cl == nil {
return "", errors.New("no client")
}
v, err := cl.Get(ctx, c.leaseKey()).Result()
if err != nil {
return "", err
}
return v, nil
}
// IsLeader reports the current lease holder, for the UI.
func (c *Cluster) LeaseHolder(ctx context.Context) string {
if !c.Enabled() {
return ""
}
h, err := c.leaseHolder(ctx)
if err != nil {
return ""
}
return h
}
// Status is the cluster view for the API.
type Status struct {
Enabled bool `json:"enabled"`
Connected bool `json:"connected"`
NodeID string `json:"node_id"`
Leader bool `json:"leader"`
LeaseOwner string `json:"lease_owner,omitempty"`
Peers []NodeState `json:"peers"`
// Divergent lists peers whose blocklist digest differs from ours, which
// is the one thing an operator really needs to see.
Divergent []string `json:"divergent"`
Channel string `json:"channel"`
LastEvent time.Time `json:"last_event,omitempty"`
}
// Status snapshots the cluster state.
func (c *Cluster) Status(ctx context.Context) Status {
st := Status{
Enabled: c.Enabled(),
Connected: c.Connected(),
NodeID: c.node,
Leader: c.leader.Load(),
Channel: c.channel(EventHeartbeat),
}
if c.Enabled() {
st.LeaseOwner = c.LeaseHolder(ctx)
}
st.Peers = c.Peers()
own := c.State()
for _, p := range st.Peers {
if p.BlocklistDigest != "" && own.BlocklistDigest != "" && p.BlocklistDigest != own.BlocklistDigest {
st.Divergent = append(st.Divergent, p.NodeID)
}
}
sort.Strings(st.Divergent)
if v := c.lastEvent.Load(); v > 0 {
st.LastEvent = time.Unix(v, 0)
}
return st
}
// Discover lists the node ids currently registered, for a fresh instance that
// has not heard a heartbeat yet.
func (c *Cluster) Discover(ctx context.Context) []NodeState {
if !c.Enabled() {
return nil
}
cl := c.client.Load()
if cl == nil {
return nil
}
fields, err := cl.HGetAll(ctx, c.heartbeatKey()).Result()
if err != nil {
return nil
}
out := make([]NodeState, 0, len(fields))
ids := make([]string, 0, len(fields))
for id := range fields {
ids = append(ids, id)
}
sort.Strings(ids)
for _, id := range ids {
if id == c.node {
continue
}
var st NodeState
if err := json.Unmarshal([]byte(fields[id]), &st); err == nil {
out = append(out, st)
}
}
return out
}
// Stop closes the connection and announces the departure.
func (c *Cluster) Stop() {
select {
case <-c.stop:
default:
close(c.stop)
}
cl := c.client.Load()
if cl == nil {
return
}
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
ev := Event{Kind: EventBye, NodeID: c.node, At: time.Now()}
if payload, err := json.Marshal(ev); err == nil {
_ = cl.Publish(ctx, c.channel(EventBye), payload).Err()
}
// Only release the lease if we hold it, so a shutdown does not hand the
// job to a node that is also going away.
if c.leader.Load() {
cl.Eval(ctx, `if redis.call("GET", KEYS[1]) == ARGV[1] then return redis.call("DEL", KEYS[1]) else return 0 end`, []string{c.leaseKey()}, c.node)
}
_ = cl.Close()
c.connected.Store(false)
c.leader.Store(false)
}
// String renders the node id for logs.
func (c *Cluster) String() string { return strings.TrimSpace(c.node) }
func cryptoRead(b []byte) (int, error) {
h := sha1.Sum([]byte(time.Now().String() + string(b)))
copy(b, h[:4])
return len(b), nil
}
File diff suppressed because it is too large. Load diff
+126
View File
@@ -0,0 +1,126 @@
package config
import (
"sync"
"sync/atomic"
)
// Store is the process-wide, hot-reloadable configuration holder. Readers take
// a snapshot pointer (cheap, no lock) and writers swap a fully-built *Config
// in atomically, so a request never observes a half-applied config.
type Store struct {
cur atomic.Pointer[Config]
mu sync.Mutex // serialises writers so LastError/Version stay coherent
// path is the file the config was loaded from, used by the API to
// persist mutations.
path string
// lastError records why the most recent reload attempt failed, so the
// health endpoint can report a stale-but-running state.
lastErr atomic.Pointer[string]
// generation increments on every successful swap.
generation atomic.Uint64
// hooks run after a successful swap (server re-binds listeners etc).
hooksMu sync.RWMutex
hooks []func(*Config)
}
// NewStore wraps cfg.
func NewStore(cfg *Config, path string) *Store {
s := &Store{path: path}
s.cur.Store(cfg)
return s
}
// Get returns the current configuration snapshot.
func (s *Store) Get() *Config { return s.cur.Load() }
// Path is the file this config was loaded from ("" when running without one).
func (s *Store) Path() string { return s.path }
// Generation counts successful swaps; the cluster uses it to detect
// divergence between instances.
func (s *Store) Generation() uint64 { return s.generation.Load() }
// LastError returns the error from the most recent failed reload, or nil.
func (s *Store) LastError() string {
if p := s.lastErr.Load(); p != nil {
return *p
}
return ""
}
// Swap installs cfg as the current configuration. Callers are responsible for
// validating first; Swap itself only guards structural invariants that would
// otherwise panic subsystems.
func (s *Store) Swap(cfg *Config) error {
if cfg == nil {
return errNilConfig
}
if cfg.Cache.Shards <= 0 {
cfg.Cache.Shards = 32
}
s.mu.Lock()
s.cur.Store(cfg)
s.generation.Add(1)
s.lastErr.Store(nil)
s.mu.Unlock()
s.runHooks(cfg)
return nil
}
// Reload re-reads the backing file and swaps it in on success. On failure the
// previous configuration stays active and the error is recorded.
func (s *Store) Reload() error {
s.mu.Lock()
defer s.mu.Unlock()
if s.path == "" {
return errNoPath
}
cfg, err := Load(s.path)
if err != nil {
msg := err.Error()
s.lastErr.Store(&msg)
return err
}
// Preserve the working dir of the running instance so relative paths keep
// resolving the same way even if the file moved.
cfg.Server.WorkingDir = s.cur.Load().Server.WorkingDir
s.cur.Store(cfg)
s.generation.Add(1)
s.lastErr.Store(nil)
s.runHooks(cfg)
return nil
}
// OnSwap registers a callback invoked with the new config after each swap.
// Hooks run outside the writer lock; a panicking hook is contained so one bad
// subsystem cannot take down a reload.
func (s *Store) OnSwap(fn func(*Config)) {
if fn == nil {
return
}
s.hooksMu.Lock()
s.hooks = append(s.hooks, fn)
s.hooksMu.Unlock()
}
func (s *Store) runHooks(cfg *Config) {
s.hooksMu.RLock()
hooks := s.hooks
s.hooksMu.RUnlock()
for _, h := range hooks {
func() {
defer func() { _ = recover() }()
h(cfg)
}()
}
}
type storeError string
func (e storeError) Error() string { return string(e) }
const (
errNilConfig = storeError("config: refusing to swap a nil configuration")
errNoPath = storeError("config: no config file path known, cannot reload")
)
File diff suppressed because it is too large. Load diff
+202
View File
@@ -0,0 +1,202 @@
package engine
import (
"context"
"net"
"sync"
"time"
"github.com/miekg/dns"
)
// ReverseCache maps client IPs to reverse-DNS names so the ACL can match on
// names without a synchronous PTR lookup on the query path.
//
// A miss schedules a background lookup and, for the duration of that lookup,
// matches on nothing. That is the right trade: a blocking PTR on the hot path
// would make a broken reverse zone a latency incident, whereas a miss only
// means an ACL rule keyed on a name does not fire for the first few queries.
type ReverseCache struct {
mu sync.RWMutex
entries map[string]reverseEntry
// negative caches the "no PTR exists" answer so we do not re-query for
// every query from the same client.
max int
ttl time.Duration
negative time.Duration
resolver *dns.Client
names string
timeout time.Duration
inflight map[string]bool
}
type reverseEntry struct {
names []string
fetched time.Time
negative bool
}
// ReverseOptions configures a ReverseCache.
type ReverseOptions struct {
// Max is the number of cached clients.
Max int
// TTL is how long a successful PTR is trusted.
TTL time.Duration
// NegativeTTL is how long a "no PTR" answer is trusted.
NegativeTTL time.Duration
// Nameserver is the server to ask, e.g. "9.9.9.9:53". Empty uses the
// system resolver via net.LookupAddr.
Nameserver string
// Timeout bounds one PTR lookup.
Timeout time.Duration
}
// NewReverseCache builds a cache.
func NewReverseCache(opt ReverseOptions) *ReverseCache {
if opt.Max <= 0 {
opt.Max = 16384
}
if opt.TTL <= 0 {
opt.TTL = 10 * time.Minute
}
if opt.NegativeTTL <= 0 {
opt.NegativeTTL = 30 * time.Minute
}
if opt.Timeout <= 0 {
opt.Timeout = 2 * time.Second
}
rc := &ReverseCache{
entries: make(map[string]reverseEntry, 1024),
max: opt.Max,
ttl: opt.TTL,
negative: opt.NegativeTTL,
names: opt.Nameserver,
timeout: opt.Timeout,
inflight: make(map[string]bool),
}
if opt.Nameserver != "" {
rc.resolver = &dns.Client{Net: "udp", Timeout: opt.Timeout}
}
return rc
}
// Names returns the cached reverse names for an IP, and whether the entry was
// present. The second return is false for a miss (including a cached negative).
func (r *ReverseCache) Names(ip string) ([]string, bool) {
if r == nil {
return nil, false
}
r.mu.RLock()
e, ok := r.entries[ip]
r.mu.RUnlock()
if !ok {
r.Prime(ip)
return nil, false
}
ttl := r.ttl
if e.negative {
ttl = r.negative
}
if time.Since(e.fetched) > ttl {
r.Prime(ip)
return e.names, true // serve the stale value while refreshing
}
return e.names, !e.negative
}
// Prime schedules a background PTR lookup for ip, at most one in flight.
func (r *ReverseCache) Prime(ip string) {
if r == nil || ip == "" {
return
}
r.mu.Lock()
if r.inflight[ip] {
r.mu.Unlock()
return
}
r.inflight[ip] = true
r.mu.Unlock()
go r.lookup(ip)
}
func (r *ReverseCache) lookup(ip string) {
defer func() {
r.mu.Lock()
delete(r.inflight, ip)
r.mu.Unlock()
}()
names, err := r.query(ip)
r.mu.Lock()
defer r.mu.Unlock()
if len(r.entries) >= r.max {
// Simple bound: drop the oldest quarter. A full LRU here would cost
// more than it saves, since the working set is usually small.
cutoff := time.Now().Add(-r.ttl)
removed := 0
for k, e := range r.entries {
if e.fetched.Before(cutoff) || removed >= r.max/4 {
delete(r.entries, k)
removed++
}
}
if removed == 0 {
for k := range r.entries {
delete(r.entries, k)
if removed >= r.max/4 {
break
}
}
}
}
r.entries[ip] = reverseEntry{
names: names,
fetched: time.Now(),
negative: err != nil || len(names) == 0,
}
}
func (r *ReverseCache) query(ip string) ([]string, error) {
if r.resolver == nil {
ctx, cancel := context.WithTimeout(context.Background(), r.timeout)
defer cancel()
return net.DefaultResolver.LookupAddr(ctx, ip)
}
name, err := dns.ReverseAddr(ip)
if err != nil {
return nil, err
}
m := new(dns.Msg)
m.SetQuestion(name, dns.TypePTR)
m.RecursionDesired = true
resp, _, err := r.resolver.Exchange(m, r.names)
if err != nil {
return nil, err
}
var out []string
for _, rr := range resp.Answer {
if ptr, ok := rr.(*dns.PTR); ok {
out = append(out, ptr.Ptr)
}
}
return out, nil
}
// Len is the number of cached clients.
func (r *ReverseCache) Len() int {
if r == nil {
return 0
}
r.mu.RLock()
defer r.mu.RUnlock()
return len(r.entries)
}
// Flush empties the cache.
func (r *ReverseCache) Flush() {
if r == nil {
return
}
r.mu.Lock()
r.entries = make(map[string]reverseEntry, 1024)
r.mu.Unlock()
}
+451
View File
@@ -0,0 +1,451 @@
// Package localzone serves authoritative data from DNS master files: split
// DNS setups, internal service records and the odd "answer this one name
// locally" need, all without a second nameserver to run.
package localzone
import (
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"sync/atomic"
"time"
"github.com/miekg/dns"
"git.k8s-git-k6n.net/mats/fancy-dns/internal/zonefile"
)
// Store is an immutable, compiled set of local zones.
type Store struct {
// zones maps a lowercased apex (with trailing dot) to its compiled zone.
zones map[string]*zone
// count is the number of records held, for the UI.
count int
// files lists the sources that produced this store.
files []string
// builtAt is the compile timestamp.
builtAt time.Time
}
type zone struct {
origin string
// exact is keyed by "name|type".
exact map[string][]dns.RR
// wild holds "*.name" entries.
wild map[string]map[uint16][]dns.RR
// soa is the zone's SOA, used for NXDOMAIN and NODATA authority.
soa *dns.SOA
// ns holds the apex NS set.
ns map[uint16][]dns.RR
// delegations are the names served by NS records (a cut below the apex).
delegations map[string]bool
minTTL uint32
}
// Options tunes zone loading.
type Options struct {
// Origins overrides the per-file origin derivation.
Origins map[string]string
// MaxFiles bounds how many files are read (glob results included).
MaxFiles int
}
// Load compiles every master file in paths. A path may be a glob.
func Load(paths []string, opt Options) (*Store, error) {
files, err := expand(paths, opt.MaxFiles)
if err != nil {
return nil, err
}
s := &Store{
zones: make(map[string]*zone, len(files)),
files: files,
builtAt: time.Now(),
}
for _, f := range files {
origin := zonefile.OriginFromPath(f)
if o, ok := opt.Origins[f]; ok {
origin = dns.Fqdn(o)
}
z, err := zonefile.ParseFile(f, origin)
if err != nil {
return nil, fmt.Errorf("localzone: %w", err)
}
apex := strings.ToLower(z.Origin)
zz, ok := s.zones[apex]
if !ok {
zz = &zone{
origin: apex,
exact: make(map[string][]dns.RR),
wild: make(map[string]map[uint16][]dns.RR),
delegations: make(map[string]bool),
ns: make(map[uint16][]dns.RR),
minTTL: 0xFFFFFFFF,
}
s.zones[apex] = zz
}
for _, rec := range z.Records {
if add(zz, rec) {
s.count++
}
}
}
for _, zz := range s.zones {
if zz.minTTL == 0xFFFFFFFF {
zz.minTTL = 60
}
}
return s, nil
}
func add(z *zone, rec zonefile.Record) bool {
hdr := rec.RR.Header()
name := strings.ToLower(dns.Fqdn(hdr.Name))
// SOA is zone metadata, not a queryable record.
if soa, ok := rec.RR.(*dns.SOA); ok {
z.soa = soa
return false
}
key := name
isWild := false
if strings.HasPrefix(name, "*.") {
key = name[2:]
isWild = true
}
// A record must live inside its zone; silently dropping out-of-zone data
// would be a surprise, so refuse it loudly instead.
if !inZone(key, z.origin) {
return false
}
// NS at or below the apex marks a delegation: the data below belongs to
// another server, so we must not answer for it.
if hdr.Rrtype == dns.TypeNS && !isWild {
if key == z.origin {
z.ns[hdr.Rrtype] = append(z.ns[hdr.Rrtype], rec.RR)
return true
}
z.delegations[key] = true
}
if hdr.Ttl < z.minTTL {
z.minTTL = hdr.Ttl
}
if isWild {
m, ok := z.wild[key]
if !ok {
m = make(map[uint16][]dns.RR)
z.wild[key] = m
}
m[hdr.Rrtype] = append(m[hdr.Rrtype], rec.RR)
return true
}
k := name + "|" + dns.TypeToString[hdr.Rrtype]
z.exact[k] = append(z.exact[k], rec.RR)
return true
}
func inZone(name, origin string) bool {
if origin == "." {
return true
}
return name == origin || strings.HasSuffix(name, "."+origin)
}
// expand turns configured paths (possibly globs) into a concrete file list.
func expand(paths []string, max int) ([]string, error) {
var out []string
seen := map[string]bool{}
for _, p := range paths {
if strings.ContainsAny(p, "*?[") {
matches, err := filepath.Glob(p)
if err != nil {
return nil, fmt.Errorf("localzone: bad glob %q: %w", p, err)
}
sort.Strings(matches)
for _, m := range matches {
if !seen[m] {
seen[m] = true
out = append(out, m)
}
}
continue
}
if !seen[p] {
seen[p] = true
out = append(out, p)
}
}
// A path that is a directory contributes its *.zone files, which is the
// natural thing to point at.
var expanded []string
for _, p := range out {
fi, err := os.Stat(p)
if err != nil {
return nil, fmt.Errorf("localzone: %w", err)
}
if fi.IsDir() {
entries, err := filepath.Glob(filepath.Join(p, "*.zone"))
if err != nil {
return nil, err
}
sort.Strings(entries)
expanded = append(expanded, entries...)
continue
}
expanded = append(expanded, p)
}
if max > 0 && len(expanded) > max {
expanded = expanded[:max]
}
return expanded, nil
}
// Result is the outcome of a local lookup.
type Result struct {
// Found is true when the store has an answer (including a CNAME).
Found bool
// Records is the RRset for the queried type, or a CNAME when the name
// only has one.
Records []dns.RR
// Type is the type the records are for (may differ from the question for
// a CNAME answer).
Type uint16
// Delegated is true when the name is served by another server.
Delegated bool
// Authority is the SOA for NXDOMAIN/NODATA responses.
Authority *dns.SOA
// TTL is the smallest TTL in the answer.
TTL uint32
// Zone is the apex of the zone that answered.
Zone string
}
// Lookup resolves a question against the local store. It does not chase CNAMEs
// across the local store; the handler re-queries, which keeps the wildcard and
// delegation rules in one place.
func (s *Store) Lookup(q dns.Question) Result {
if s == nil || len(s.zones) == 0 {
return Result{}
}
name := strings.ToLower(dns.Fqdn(q.Name))
qtype := q.Qtype
// The cache and the resolver both skip the root and the local reverse
// zones, but a local zone may legitimately define them, so check first.
var best *zone
for apex, z := range s.zones {
if !inZone(name, apex) {
continue
}
if best == nil || len(apex) > len(best.origin) {
best = z
}
}
if best == nil {
return Result{}
}
if qtype == dns.TypeANY {
return s.any(best, name)
}
// A delegation means "ask them, not me".
if best.delegations[name] {
return Result{Found: false, Delegated: true, Zone: best.origin, Authority: best.soa, TTL: best.minTTL}
}
if rrs, ok := best.exact[name+"|"+dns.TypeToString[qtype]]; ok {
return s.result(best, name, qtype, rrs)
}
// Wildcard: "*.zone" covers one label; RFC 1034 §4.3.3 wants the closest
// enclosing wildcard, so try progressively shorter parents.
if rrs, ok := closestWildcard(best, name, qtype); ok {
return s.result(best, name, qtype, rrs)
}
// A CNAME at the name answers any other type.
if qtype != dns.TypeCNAME {
if rrs, ok := best.exact[name+"|CNAME"]; ok {
return s.result(best, name, dns.TypeCNAME, rrs)
}
}
// NODATA: the name exists but has no records of this type.
if nameExists(best, name) {
return Result{Found: true, Authority: best.soa, Zone: best.origin, TTL: best.minTTL}
}
return Result{Found: false, Authority: best.soa, Zone: best.origin, TTL: best.minTTL}
}
func (s *Store) result(z *zone, name string, qtype uint16, rrs []dns.RR) Result {
min := uint32(0xFFFFFFFF)
for _, rr := range rrs {
if t := rr.Header().Ttl; t < min {
min = t
}
}
if min == 0xFFFFFFFF {
min = z.minTTL
}
return Result{Found: true, Records: rrs, Type: qtype, TTL: min, Zone: z.origin, Authority: z.soa}
}
func (s *Store) any(z *zone, name string) Result {
var out []dns.RR
if rrs, ok := z.exact[name+"|A"]; ok {
out = append(out, rrs...)
}
if rrs, ok := z.exact[name+"|AAAA"]; ok {
out = append(out, rrs...)
}
if rrs, ok := z.exact[name+"|CNAME"]; ok {
out = append(out, rrs...)
}
if rrs, ok := z.exact[name+"|MX"]; ok {
out = append(out, rrs...)
}
if rrs, ok := z.exact[name+"|TXT"]; ok {
out = append(out, rrs...)
}
if rrs, ok := z.exact[name+"|SRV"]; ok {
out = append(out, rrs...)
}
if rrs, ok := z.exact[name+"|NS"]; ok {
out = append(out, rrs...)
}
if len(out) == 0 {
if rrs, ok := closestWildcardAny(z, name); ok {
out = append(out, rrs...)
}
}
if len(out) == 0 {
return Result{Found: nameExists(z, name), Authority: z.soa, Zone: z.origin, TTL: z.minTTL}
}
return s.result(z, name, dns.TypeANY, out)
}
func closestWildcard(z *zone, name string, qtype uint16) ([]dns.RR, bool) {
for parent := parentOf(name); parent != ""; parent = parentOf(parent) {
if m, ok := z.wild[parent]; ok {
if rrs, ok := m[qtype]; ok {
return rrs, true
}
}
if z.delegations[parent] {
return nil, false
}
}
return nil, false
}
func closestWildcardAny(z *zone, name string) ([]dns.RR, bool) {
for parent := parentOf(name); parent != ""; parent = parentOf(parent) {
if m, ok := z.wild[parent]; ok {
var out []dns.RR
for _, rrs := range m {
out = append(out, rrs...)
}
if len(out) > 0 {
return out, true
}
}
}
return nil, false
}
func parentOf(name string) string {
i := strings.IndexByte(name, '.')
if i < 0 {
return ""
}
return name[i+1:]
}
func nameExists(z *zone, name string) bool {
prefix := name + "|"
for k := range z.exact {
if strings.HasPrefix(k, prefix) {
return true
}
}
if _, ok := z.wild[name]; ok {
return true
}
return z.delegations[name]
}
// Count is the number of records in the store.
func (s *Store) Count() int {
if s == nil {
return 0
}
return s.count
}
// Zones lists the zone apexes.
func (s *Store) Zones() []string {
if s == nil {
return nil
}
out := make([]string, 0, len(s.zones))
for z := range s.zones {
out = append(out, z)
}
sort.Strings(out)
return out
}
// Files lists the source files.
func (s *Store) Files() []string {
if s == nil {
return nil
}
return s.files
}
// BuiltAt is the compile timestamp.
func (s *Store) BuiltAt() time.Time {
if s == nil {
return time.Time{}
}
return s.builtAt
}
// Records returns every record in a zone as zone-file text, for the API and
// the UI's zone browser.
func (s *Store) Records(origin string) []string {
if s == nil {
return nil
}
z, ok := s.zones[strings.ToLower(dns.Fqdn(origin))]
if !ok {
return nil
}
var out []string
for _, rrs := range z.exact {
for _, rr := range rrs {
out = append(out, rr.String())
}
}
for name, m := range z.wild {
for _, rrs := range m {
for _, rr := range rrs {
out = append(out, strings.Replace(rr.String(), "\t"+rr.Header().Name, "\t*."+name, 1))
}
}
}
sort.Strings(out)
return out
}
// Holder keeps the current Store for the hot path.
type Holder struct{ cur atomic.Pointer[Store] }
// NewHolder wraps a store.
func NewHolder(s *Store) *Holder {
h := &Holder{}
if s != nil {
h.cur.Store(s)
}
return h
}
// Get returns the current store (nil when nothing is configured).
func (h *Holder) Get() *Store { return h.cur.Load() }
// Set installs a new store.
func (h *Holder) Set(s *Store) { h.cur.Store(s) }
+478
View File
@@ -0,0 +1,478 @@
// Package metrics holds the Prometheus collectors and the in-process
// time-series buffers that back the admin UI's charts.
//
// The UI needs finer-grained series than Prometheus's default histogram gives,
// so a small fixed-bucket ring per metric is kept in memory: cheap, bounded,
// and enough to draw a dashboard without shipping raw samples to a browser.
package metrics
import (
"math"
"sync"
"sync/atomic"
"time"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/collectors"
"github.com/prometheus/client_golang/prometheus/promhttp"
"net/http"
)
// Namespace prefixes every metric name.
const Namespace = "fancydns"
// Counters for the request pipeline.
var (
queriesTotal prometheus.Counter
blockedTotal prometheus.Counter
cacheHits prometheus.Counter
cacheMisses prometheus.Counter
cacheStaleHits prometheus.Counter
upstreamErrors prometheus.Counter
dnsErrors prometheus.Counter
dropsTotal prometheus.Counter
refusedTotal prometheus.Counter
)
// Gauges for live state.
var (
upstreamHealth *prometheus.GaugeVec
concurrentNow prometheus.Gauge
listCount prometheus.Gauge
listBytes prometheus.Gauge
aclGroups prometheus.Gauge
localRecords prometheus.Gauge
up prometheus.Gauge
)
// Histograms for latency, with buckets that matter for DNS: sub-millisecond
// cache hits, a few milliseconds for a local answer, tens for an upstream.
var (
queryDuration *prometheus.HistogramVec
upstreamDur *prometheus.HistogramVec
parseDuration prometheus.Histogram
)
var registerOnce sync.Once
// Registry is the registry served on /metrics.
var Registry = prometheus.NewRegistry()
// initCollectors registers everything exactly once. It is safe to call from
// several components.
func initCollectors() {
registerOnce.Do(func() {
queriesTotal = prometheus.NewCounter(prometheus.CounterOpts{
Namespace: Namespace, Name: "queries_total",
Help: "Total DNS queries received across all transports.",
})
blockedTotal = prometheus.NewCounter(prometheus.CounterOpts{
Namespace: Namespace, Name: "blocked_total",
Help: "Queries answered with a blocklist response.",
})
cacheHits = prometheus.NewCounter(prometheus.CounterOpts{
Namespace: Namespace, Name: "cache_hits_total",
Help: "Cache hits (fresh).",
})
cacheMisses = prometheus.NewCounter(prometheus.CounterOpts{
Namespace: Namespace, Name: "cache_misses_total",
Help: "Cache misses that required an upstream lookup.",
})
cacheStaleHits = prometheus.NewCounter(prometheus.CounterOpts{
Namespace: Namespace, Name: "cache_stale_hits_total",
Help: "Answers served from the serve-stale window.",
})
upstreamErrors = prometheus.NewCounter(prometheus.CounterOpts{
Namespace: Namespace, Name: "upstream_errors_total",
Help: "Upstream exchange failures.",
})
dnsErrors = prometheus.NewCounter(prometheus.CounterOpts{
Namespace: Namespace, Name: "errors_total",
Help: "Responses with a non-success RCODE.",
})
dropsTotal = prometheus.NewCounter(prometheus.CounterOpts{
Namespace: Namespace, Name: "dropped_total",
Help: "Queries dropped without a response (blocklist drop or overload).",
})
refusedTotal = prometheus.NewCounter(prometheus.CounterOpts{
Namespace: Namespace, Name: "refused_total",
Help: "Queries answered REFUSED by ACL policy.",
})
upstreamHealth = prometheus.NewGaugeVec(prometheus.GaugeOpts{
Namespace: Namespace, Name: "upstream_health",
Help: "1 when an upstream is up, 0 when down.",
}, []string{"addr", "proto"})
concurrentNow = prometheus.NewGauge(prometheus.GaugeOpts{
Namespace: Namespace, Name: "concurrent_queries",
Help: "Queries currently in flight.",
})
listCount = prometheus.NewGauge(prometheus.GaugeOpts{
Namespace: Namespace, Name: "blocklist_rules",
Help: "Total compiled blocklist rules.",
})
listBytes = prometheus.NewGauge(prometheus.GaugeOpts{
Namespace: Namespace, Name: "blocklist_source_bytes",
Help: "Bytes of source data compiled into the blocklist.",
})
aclGroups = prometheus.NewGauge(prometheus.GaugeOpts{
Namespace: Namespace, Name: "acl_groups",
Help: "Number of configured ACL groups.",
})
localRecords = prometheus.NewGauge(prometheus.GaugeOpts{
Namespace: Namespace, Name: "local_records",
Help: "Records in the local zone store.",
})
up = prometheus.NewGauge(prometheus.GaugeOpts{
Namespace: Namespace, Name: "up",
Help: "1 if the process is serving, 0 otherwise.",
})
queryDuration = prometheus.NewHistogramVec(prometheus.HistogramOpts{
Namespace: Namespace, Name: "query_duration_seconds",
Help: "End-to-end query handling time.",
Buckets: []float64{0.0002, 0.0005, 0.001, 0.002, 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5},
}, []string{"transport", "outcome"})
upstreamDur = prometheus.NewHistogramVec(prometheus.HistogramOpts{
Namespace: Namespace, Name: "upstream_duration_seconds",
Help: "Time spent talking to a specific upstream.",
Buckets: []float64{0.001, 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2},
}, []string{"addr"})
parseDuration = prometheus.NewHistogram(prometheus.HistogramOpts{
Namespace: Namespace, Name: "blocklist_compile_seconds",
Help: "Time to fetch and compile all blocklist sources.",
Buckets: []float64{0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10, 30},
})
Registry.MustRegister(
queriesTotal, blockedTotal, cacheHits, cacheMisses, cacheStaleHits,
upstreamErrors, dnsErrors, dropsTotal, refusedTotal, upstreamHealth,
concurrentNow, listCount, listBytes, aclGroups, localRecords, up,
queryDuration, upstreamDur, parseDuration,
collectors.NewGoCollector(),
collectors.NewProcessCollector(collectors.ProcessCollectorOpts{}),
)
})
}
// Outcome labels for queryDuration.
const (
OutcomeCache = "cache"
OutcomeBlocked = "blocked"
OutcomeLocal = "local"
OutcomeUpstream = "upstream"
OutcomeError = "error"
OutcomeRefused = "refused"
OutcomeDropped = "dropped"
)
// Record is the single entry point for a pipeline outcome. Both the Prometheus
// counters and the in-process atomics the dashboard reads are bumped here, so
// the two views can never disagree.
//
// Callers must not also call CountBlocked/CountError/etc: that would double
// count. Those helpers exist for the few places that report an outcome without
// going through Record.
func Record(transport, outcome string, d time.Duration) {
initCollectors()
queriesTotal.Inc()
queries.Add(1)
if d > 0 {
queryDuration.WithLabelValues(transport, outcome).Observe(d.Seconds())
}
switch outcome {
case OutcomeCache:
cacheHits.Inc()
hits.Add(1)
case OutcomeBlocked:
blockedTotal.Inc()
blocked.Add(1)
case OutcomeError:
dnsErrors.Inc()
errs.Add(1)
case OutcomeDropped:
dropsTotal.Inc()
drops.Add(1)
// A drop is both a drop and an error for the operator's purposes.
errs.Add(1)
case OutcomeRefused:
refusedTotal.Inc()
refused.Add(1)
}
}
// RecordMiss counts a cache miss. A miss is not an error: it just means the
// cache could not answer, so only the Prometheus counter moves.
func RecordMiss() { initCollectors(); cacheMisses.Inc() }
// RecordStaleHit counts a serve-stale hit.
func RecordStaleHit() { initCollectors(); cacheStaleHits.Inc() }
// RecordUpstreamError counts an upstream failure.
func RecordUpstreamError() { initCollectors(); upstreamErrors.Inc() }
// RecordUpstreamLatency observes a single upstream exchange.
func RecordUpstreamLatency(addr string, d time.Duration) {
initCollectors()
upstreamDur.WithLabelValues(addr).Observe(d.Seconds())
}
// SetConcurrent publishes the in-flight gauge.
func SetConcurrent(n int) { initCollectors(); concurrentNow.Set(float64(n)) }
// SetUp publishes the liveness gauge.
func SetUp(v bool) {
initCollectors()
if v {
up.Set(1)
return
}
up.Set(0)
}
// SetUpstreamHealth publishes per-upstream health.
func SetUpstreamHealth(addr, proto string, healthy bool) {
initCollectors()
v := 0.0
if healthy {
v = 1
}
upstreamHealth.WithLabelValues(addr, proto).Set(v)
}
// SetBlocklistStats publishes the compiled rule count and source bytes.
func SetBlocklistStats(rules int, sourceBytes int64) {
initCollectors()
listCount.Set(float64(rules))
listBytes.Set(float64(sourceBytes))
}
// SetACLGroups publishes the group count.
func SetACLGroups(n int) { initCollectors(); aclGroups.Set(float64(n)) }
// SetLocalRecords publishes the local zone record count.
func SetLocalRecords(n int) { initCollectors(); localRecords.Set(float64(n)) }
// RecordBlocklistCompile observes a full compile pass.
func RecordBlocklistCompile(d time.Duration) {
initCollectors()
parseDuration.Observe(d.Seconds())
}
// Handler returns the HTTP handler for /metrics.
func Handler() http.Handler {
initCollectors()
return promhttp.HandlerFor(Registry, promhttp.HandlerOpts{
Registry: Registry,
EnableOpenMetrics: true,
MaxRequestsInFlight: 4,
})
}
// Totals is a point-in-time snapshot of the counters, used by the dashboard.
type Totals struct {
Queries uint64
Blocked uint64
Hits uint64
Misses uint64
StaleHits uint64
Errors uint64
Drops uint64
Refused uint64
Concurrent int64
}
var (
queries atomic.Uint64
blocked atomic.Uint64
hits atomic.Uint64
misses atomic.Uint64
staleHits atomic.Uint64
errs atomic.Uint64
drops atomic.Uint64
refused atomic.Uint64
inflight atomic.Int64
)
// TotalsNow returns the current totals. These mirror the Prometheus counters
// (both are incremented together) so the dashboard does not have to scrape.
func TotalsNow() Totals {
return Totals{
Queries: queries.Load(),
Blocked: blocked.Load(),
Hits: hits.Load(),
Misses: misses.Load(),
StaleHits: staleHits.Load(),
Errors: errs.Load(),
Drops: drops.Load(),
Refused: refused.Load(),
Concurrent: inflight.Load(),
}
}
func bumpCounter(c *atomic.Uint64) func() {
c.Add(1)
return func() { c.Add(^uint64(0)) }
}
// BeginQuery marks a query as in flight and returns its completion func.
func BeginQuery() func() {
inflight.Add(1)
queries.Add(1)
return func() { inflight.Add(-1) }
}
// CountBlocked increments the blocked counter.
func CountBlocked() { blocked.Add(1) }
// CountError increments the error counter.
func CountError() { errs.Add(1) }
// CountDrop increments the drop counter.
func CountDrop() { drops.Add(1) }
// CountRefused increments the refused counter.
func CountRefused() { refused.Add(1) }
// Series is a fixed-window time series for the UI charts.
type Series struct {
// Buckets are the sample timestamps (epoch seconds), oldest first.
Buckets []int64 `json:"t"`
Values []int64 `json:"v"`
// ResolutionMS is the bucket width.
ResolutionMS int64 `json:"resolution_ms"`
}
// Ring is a fixed-bucket counter ring: a circular array of counts indexed by
// bucket number. Lock-free on the write path, which matters because it is hit
// once per DNS query.
type Ring struct {
buckets []atomic.Int64
res time.Duration
start atomic.Int64 // first bucket timestamp, epoch seconds
pos atomic.Uint32
sealed atomic.Bool
count int
}
// NewRing builds a ring of n buckets at the given resolution.
func NewRing(n int, res time.Duration) *Ring {
if n <= 0 {
n = 120
}
if res <= 0 {
res = 5 * time.Second
}
r := &Ring{buckets: make([]atomic.Int64, n), res: res, count: n}
r.start.Store(time.Now().Unix())
return r
}
// Inc bumps the current bucket by 1, rotating when the window advances.
func (r *Ring) Inc() { r.Add(1) }
// Add bumps the current bucket.
func (r *Ring) Add(delta int64) {
if r == nil {
return
}
now := time.Now().Unix()
bucket := now / int64(r.res.Seconds())
start := r.start.Load()
// If more than count buckets elapsed, we skipped a full window: reset
// everything so a long idle period does not report phantom zeros followed
// by a sudden spike.
if bucket-start >= int64(r.count) {
if r.start.CompareAndSwap(start, bucket-int64(r.count)+1) {
for i := range r.buckets {
r.buckets[i].Store(0)
}
}
start = r.start.Load()
}
if bucket < start {
return
}
idx := int(bucket - start)
if idx >= r.count {
idx = r.count - 1
}
// Store at the position implied by the bucket so readers can map index to
// time without extra bookkeeping.
r.buckets[idx%r.count].Add(delta)
}
// AddAt bumps an explicit bucket (used by tests).
func (r *Ring) AddAt(bucket int64, delta int64) {
start := r.start.Load()
idx := int(bucket - start)
if idx < 0 || idx >= r.count {
return
}
r.buckets[idx%r.count].Add(delta)
}
// Series snapshots the ring into aligned arrays.
func (r *Ring) Snapshot() ([]int64, []int64) {
if r == nil {
return nil, nil
}
start := r.start.Load()
now := time.Now().Unix()
step := int64(r.res.Seconds())
ts := make([]int64, r.count)
vs := make([]int64, r.count)
for i := 0; i < r.count; i++ {
b := start + int64(i)
ts[i] = b * step
vs[i] = r.buckets[i%r.count].Load()
}
// Trim leading buckets that are in the future (ring not yet filled) so the
// chart starts at "now" rather than at zero time.
if now > ts[len(ts)-1] {
shift := int((now-ts[len(ts)-1])/step) + 1
if shift >= len(ts) {
return []int64{now}, []int64{0}
}
ts = append(ts[:0], ts[shift:]...)
vs = append(vs[:0], vs[shift:]...)
}
return ts, vs
}
// Series is the snapshot form.
func (r *Ring) Series() Series {
ts, vs := r.Snapshot()
return Series{Buckets: ts, Values: vs, ResolutionMS: r.res.Milliseconds()}
}
// LCG is a tiny deterministic PRNG for jitter, so the test suite does not
// depend on the global rand.
type LCG struct{ s uint64 }
func NewLCG(seed uint64) *LCG { return &LCG{s: seed | 1} }
func (l *LCG) Next() uint64 {
l.s = l.s*6364136223846793005 + 1442695040888963407
return l.s >> 11
}
func (l *LCG) Float() float64 { return float64(l.Next()) / float64(1<<53) }
// SafeFloat converts an int count to a float for charting, guarding NaN.
func SafeFloat(n int64) float64 {
if n <= 0 {
return 0
}
return float64(n)
}
// Percent computes a safe percentage.
func Percent(part, total int64) float64 {
if total <= 0 {
return 0
}
p := float64(part) / float64(total) * 100
if math.IsNaN(p) || math.IsInf(p, 0) {
return 0
}
return p
}
+148
View File
@@ -0,0 +1,148 @@
package qlog
import (
"math/rand/v2"
"sort"
"sync"
"time"
)
// randFloat is a 0..1 sample draw, isolated so tests can reason about
// sampling without seeding the global source.
func randFloat() float64 { return rand.Float64() }
// domainCount is a rolling per-domain counter.
type domainCount struct {
name string
qtype uint16
qtypeS string
total uint64
blocked uint64
last time.Time
}
// domainCounts is a bounded, TTL-evicting map of domain counters. It replaces
// the hot half of "what is everyone asking for" without the cost of a sorted
// set on every query.
type domainCounts struct {
mu sync.Mutex
counts map[string]*domainCount
limit int
ttl time.Duration
now func() time.Time
}
func newDomainCounts(limit int, ttl time.Duration) *domainCounts {
return &domainCounts{
counts: make(map[string]*domainCount, 1024),
limit: limit,
ttl: ttl,
now: time.Now,
}
}
func countKey(name string, qtype uint16) string {
return name + "|" + itoa(int(qtype))
}
func itoa(n int) string {
if n == 0 {
return "0"
}
var buf [8]byte
i := len(buf)
for n > 0 {
i--
buf[i] = byte('0' + n%10)
n /= 10
}
return string(buf[i:])
}
// observe records a query against its domain counter.
func (d *domainCounts) observe(e Entry) {
if e.Name == "" {
return
}
key := countKey(e.Name, e.QType)
now := d.now()
d.mu.Lock()
c, ok := d.counts[key]
if !ok {
if len(d.counts) >= d.limit {
d.evictLocked(now)
}
c = &domainCount{name: trimDot(e.Name), qtype: e.QType, qtypeS: e.QTypeStr}
d.counts[key] = c
}
c.total++
if e.Blocked {
c.blocked++
}
c.last = now
d.mu.Unlock()
}
// evictLocked removes expired counters, and if that is not enough, the oldest
// quarter. Callers hold d.mu.
func (d *domainCounts) evictLocked(now time.Time) {
cutoff := now.Add(-d.ttl)
removed := 0
for k, c := range d.counts {
if c.last.Before(cutoff) {
delete(d.counts, k)
removed++
}
}
if removed == 0 && len(d.counts) > 0 {
// Nothing has expired (a very busy window): drop the oldest quarter.
type kv struct {
k string
t time.Time
}
all := make([]kv, 0, len(d.counts))
for k, c := range d.counts {
all = append(all, kv{k, c.last})
}
sort.Slice(all, func(i, j int) bool { return all[i].t.Before(all[j].t) })
n := len(all) / 4
if n < 1 {
n = 1
}
for i := 0; i < n && i < len(all); i++ {
delete(d.counts, all[i].k)
}
}
}
// top returns the n hottest domains.
func (d *domainCounts) top(n int, blockedOnly bool) []DomainCount {
if n <= 0 {
n = 25
}
d.mu.Lock()
out := make([]DomainCount, 0, n)
for _, c := range d.counts {
if blockedOnly && c.blocked == 0 {
continue
}
dc := DomainCount{Name: c.name, Count: c.total, Blocked: c.blocked, QType: c.qtype, QTypeS: c.qtypeS}
if blockedOnly {
dc.Count = c.blocked
}
out = append(out, dc)
}
d.mu.Unlock()
sort.Slice(out, func(i, j int) bool { return out[i].Count > out[j].Count })
if len(out) > n {
out = out[:n]
}
return out
}
func trimDot(s string) string {
if n := len(s); n > 1 && s[n-1] == '.' {
return s[:n-1]
}
return s
}
+712
View File
@@ -0,0 +1,712 @@
// Package qlog records DNS queries for the admin console: a live in-memory
// ring for the SSE stream, a bbolt-backed ring for the query log, and rolling
// per-domain counters that back the "top blocked" and "top queried" panels.
//
// The write path is deliberately allocation-light: entries are value types, the
// live ring is a fixed array, and bbolt writes are batched on a background
// goroutine so a burst of queries never blocks a DNS response.
package qlog
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"sync"
"sync/atomic"
"time"
"github.com/miekg/dns"
bolt "go.etcd.io/bbolt"
)
// Action is the outcome classification shown in the log.
type Action string
const (
// ActionAllow is a normal resolution.
ActionAllow Action = "allow"
// ActionBlock is a blocklist hit.
ActionBlock Action = "block"
// ActionCached is a cache hit.
ActionCached Action = "cached"
// ActionError is a SERVFAIL or other failure.
ActionError Action = "error"
// ActionDrop is a query answered with silence.
ActionDrop Action = "drop"
// ActionRefused is an ACL refusal.
ActionRefused Action = "refused"
)
// Entry is one logged query.
type Entry struct {
ID uint64 `json:"id"`
TS time.Time `json:"ts"`
Client string `json:"client"`
Group string `json:"client_group,omitempty"`
Name string `json:"name"`
QType uint16 `json:"qtype"`
QTypeStr string `json:"qtype_name"`
RCode int `json:"rcode"`
Action Action `json:"action"`
Response string `json:"response,omitempty"`
Duration int64 `json:"duration_ms"`
Upstream string `json:"upstream,omitempty"`
List string `json:"list,omitempty"`
Protocol string `json:"protocol,omitempty"`
// Blocked is a convenience mirror of Action == ActionBlock so the UI can
// filter without parsing the action string.
Blocked bool `json:"blocked"`
}
// FromMsg fills the question-derived fields from a DNS message.
func (e *Entry) FromMsg(m *dns.Msg) {
if len(m.Question) > 0 {
q := m.Question[0]
e.Name = q.Name
e.QType = q.Qtype
e.QTypeStr = dns.Type(q.Qtype).String()
}
e.RCode = m.Rcode
}
// RCodeName renders the RCODE as its mnemonic.
func (e Entry) RCodeName() string {
if s, ok := dns.RcodeToString[e.RCode]; ok {
return s
}
return fmt.Sprintf("RCODE%d", e.RCode)
}
// liveEntry is a decoded copy kept in memory for the SSE stream.
type liveEntry struct {
entry Entry
}
// Logger batches query logging to bbolt and maintains the live ring.
type Logger struct {
opts Options
// seq assigns monotonic ids so the UI can detect drops.
seq atomic.Uint64
// live is a fixed-size ring of the most recent entries.
live []liveEntry
livePos atomic.Uint32
liveMu sync.RWMutex
liveCond *sync.Cond
liveFull atomic.Bool
// pending batches entries between bbolt transactions.
pendingMu sync.Mutex
pending []Entry
// counts are rolling per-domain counters.
counts *domainCounts
// logErrors counts persistence failures, which the API surfaces so a
// silently-failing query log never looks healthy.
logErrors atomic.Uint64
// subscribers is the set of live SSE consumers.
subMu sync.Mutex
subs map[int]chan Entry
subSeq int
db *bolt.DB
stop chan struct{}
done chan struct{}
wg sync.WaitGroup
}
// Options configures a Logger.
type Options struct {
// Path is the bbolt file; empty disables persistence.
Path string
// MaxEntries bounds the persisted log ring.
MaxEntries int
// LiveBuffer is the in-memory ring size for the SSE stream.
LiveBuffer int
// FlushInterval is the bbolt batch window.
FlushInterval time.Duration
// SampleRate is the fraction of non-blocked, non-error queries to keep.
SampleRate float64
// LogBlocked, LogCached, LogErrors are forced categories.
LogBlocked bool
LogCached bool
LogErrors bool
// TopDomains is the size of the rolling counter set.
TopDomains int
// TopTTL is how long a counter survives without being touched.
TopTTL time.Duration
// Now is injectable for tests.
Now func() time.Time
}
// DefaultOptions returns sane defaults.
func DefaultOptions() Options {
return Options{
Path: "data/queries.db",
MaxEntries: 200000,
LiveBuffer: 2000,
FlushInterval: time.Second,
SampleRate: 1,
LogBlocked: true,
LogCached: true,
LogErrors: true,
TopDomains: 5000,
TopTTL: 5 * time.Minute,
}
}
// New opens a Logger, creating the bbolt file when a path is given.
func New(opt Options) (*Logger, error) {
if opt.Now == nil {
opt.Now = time.Now
}
if opt.LiveBuffer <= 0 {
opt.LiveBuffer = 2000
}
if opt.FlushInterval <= 0 {
opt.FlushInterval = time.Second
}
if opt.SampleRate <= 0 {
opt.SampleRate = 1
}
if opt.SampleRate > 1 {
opt.SampleRate = 1
}
if opt.TopDomains <= 0 {
opt.TopDomains = 5000
}
if opt.TopTTL <= 0 {
opt.TopTTL = 5 * time.Minute
}
l := &Logger{
opts: opt,
live: make([]liveEntry, opt.LiveBuffer),
counts: newDomainCounts(opt.TopDomains, opt.TopTTL),
subs: make(map[int]chan Entry),
stop: make(chan struct{}),
done: make(chan struct{}),
pending: make([]Entry, 0, 4096),
}
l.liveCond = sync.NewCond(&l.liveMu)
if opt.Path != "" {
if err := os.MkdirAll(filepath.Dir(opt.Path), 0o755); err != nil {
return nil, fmt.Errorf("qlog: create data dir: %w", err)
}
db, err := bolt.Open(opt.Path, 0o600, &bolt.Options{Timeout: 5 * time.Second, NoFreelistSync: true})
if err != nil {
return nil, fmt.Errorf("qlog: open %s: %w", opt.Path, err)
}
if err := db.Update(func(tx *bolt.Tx) error {
_, err := tx.CreateBucketIfNotExists(bucketLog)
if err != nil {
return err
}
_, err = tx.CreateBucketIfNotExists(bucketMeta)
return err
}); err != nil {
_ = db.Close()
return nil, err
}
l.db = db
l.wg.Add(1)
go l.flushLoop()
} else {
close(l.done)
}
return l, nil
}
var (
bucketLog = []byte("log")
bucketMeta = []byte("meta")
keyNextID = []byte("next_id")
)
// ShouldLog reports whether an entry with this action passes the sampling
// policy. Blocked, cached and error entries are always kept when their
// category flag is set; ordinary resolutions are sampled.
func (l *Logger) ShouldLog(a Action) bool {
switch a {
case ActionBlock:
return l.opts.LogBlocked
case ActionCached:
return l.opts.LogCached
case ActionError, ActionDrop:
return l.opts.LogErrors
default:
if l.opts.SampleRate >= 1 {
return true
}
return randFloat() < l.opts.SampleRate
}
}
// Log records a query. It never blocks on bbolt: the entry goes to the live
// ring and a batch, and a background goroutine does the disk write.
func (l *Logger) Log(e Entry) {
if e.TS.IsZero() {
e.TS = l.opts.Now()
}
if e.QTypeStr == "" && e.QType != 0 {
e.QTypeStr = dns.Type(e.QType).String()
}
e.Blocked = e.Action == ActionBlock
e.ID = l.seq.Add(1)
l.counts.observe(e)
// Live ring.
pos := l.livePos.Add(1) - 1
l.live[pos%uint32(len(l.live))] = liveEntry{entry: e}
// Fan out to SSE subscribers (non-blocking: a slow consumer drops entries
// rather than stalling the query path).
l.subMu.Lock()
for _, ch := range l.subs {
select {
case ch <- e:
default:
}
}
l.subMu.Unlock()
if l.db == nil {
return
}
l.pendingMu.Lock()
l.pending = append(l.pending, e)
n := len(l.pending)
l.pendingMu.Unlock()
if n >= 2048 {
go l.Flush()
}
}
// flushLoop writes pending batches on a timer.
func (l *Logger) flushLoop() {
defer l.wg.Done()
t := time.NewTicker(l.opts.FlushInterval)
defer t.Stop()
for {
select {
case <-l.stop:
l.Flush()
return
case <-t.C:
l.Flush()
}
}
}
// Flush writes any pending entries to bbolt and trims the ring.
func (l *Logger) Flush() {
if l.db == nil {
return
}
l.pendingMu.Lock()
if len(l.pending) == 0 {
l.pendingMu.Unlock()
return
}
batch := l.pending
l.pending = l.pending[:0]
l.pendingMu.Unlock()
err := l.db.Update(func(tx *bolt.Tx) error {
b := tx.Bucket(bucketLog)
if b == nil {
return nil
}
meta := tx.Bucket(bucketMeta)
nextID := uint64(1)
if meta != nil {
if v := meta.Get(keyNextID); len(v) == 8 {
nextID = beUint64(v)
}
}
for _, e := range batch {
data, err := json.Marshal(e)
if err != nil {
continue
}
if err := b.Put(be64b(nextID), data); err != nil {
return err
}
nextID++
}
if meta != nil {
meta.Put(keyNextID, be64b(nextID))
}
return l.trim(b, nextID)
})
if err != nil {
// A logging failure must never take the resolver down; surface it via
// the error counter the API reads.
l.logErrors.Add(1)
}
}
// trim enforces the entry cap by deleting the oldest ids.
func (l *Logger) trim(b *bolt.Bucket, nextID uint64) error {
if l.opts.MaxEntries <= 0 {
return nil
}
stats := b.Stats()
if stats.KeyN <= l.opts.MaxEntries {
return nil
}
excess := stats.KeyN - l.opts.MaxEntries
cur := b.Cursor()
for k, _ := cur.First(); k != nil && excess > 0; k, _ = cur.Next() {
if err := b.Delete(k); err != nil {
return err
}
excess--
}
return nil
}
// ErrorCount reports how many batch writes have failed.
func (l *Logger) ErrorCount() uint64 { return l.logErrors.Load() }
// be64b encodes a uint64 big-endian for use as a bbolt key.
func be64b(v uint64) []byte {
return []byte{
byte(v >> 56), byte(v >> 48), byte(v >> 40), byte(v >> 32),
byte(v >> 24), byte(v >> 16), byte(v >> 8), byte(v),
}
}
// beUint64 decodes what be64b wrote.
func beUint64(b []byte) uint64 {
var v uint64
for _, c := range b {
v = v<<8 | uint64(c)
}
return v
}
// Query filters the persisted log.
type Query struct {
// Limit is the page size.
Limit int
// Offset skips the newest Offset entries.
Offset int
// Text matches name, client or list.
Text string
// Action filters by classification.
Action string
// Client filters by client address.
Client string
// RCode filters by numeric rcode (0 = no filter).
RCode int
// BlockedOnly keeps only blocklist hits.
BlockedOnly bool
// Since keeps only entries at or after this time.
Since time.Time
}
// Page is a page of log entries plus the total match count.
type Page struct {
Items []Entry `json:"items"`
Total int `json:"total"`
Offset int `json:"offset"`
Limit int `json:"limit"`
}
// Recent returns the newest entries from the in-memory ring, newest first.
// It powers the SSE initial burst so a client connecting mid-stream sees
// recent history immediately.
func (l *Logger) Recent(limit int) []Entry {
if limit <= 0 {
limit = 200
}
if limit > len(l.live) {
limit = len(l.live)
}
out := make([]Entry, 0, limit)
pos := l.livePos.Load()
total := pos
if total > uint32(len(l.live)) {
total = uint32(len(l.live))
}
for i := uint32(0); i < total; i++ {
idx := (pos - 1 - i) % uint32(len(l.live))
out = append(out, l.live[idx].entry)
if len(out) >= limit {
break
}
}
return out
}
// Search queries the persisted log.
func (l *Logger) Search(q Query) (Page, error) {
l.Flush()
page := Page{Offset: q.Offset, Limit: q.Limit}
if q.Limit <= 0 {
q.Limit = 200
}
if q.Limit > 1000 {
q.Limit = 1000
}
if l.db == nil {
// No persistence: serve from the live ring.
items := l.Recent(1000)
filtered := l.filter(items, q)
page.Total = len(filtered)
page.Items = paginate(filtered, q.Offset, q.Limit)
return page, nil
}
var (
matched []Entry
total int
)
err := l.db.View(func(tx *bolt.Tx) error {
b := tx.Bucket(bucketLog)
if b == nil {
return nil
}
// Walk newest-first.
c := b.Cursor()
for k, v := c.Last(); k != nil; k, v = c.Prev() {
var e Entry
if err := json.Unmarshal(v, &e); err != nil {
continue
}
if !matchEntry(e, q) {
continue
}
total++
if total > q.Offset && len(matched) < q.Limit {
matched = append(matched, e)
}
if total >= q.Offset+q.Limit+1 && q.Offset+q.Limit > 0 {
// We have enough to fill the page and know the total is at
// least this high; keep scanning only if we still need an
// exact total. For the UI, a "has more" hint is enough.
break
}
}
return nil
})
if err != nil {
return page, err
}
page.Items = matched
page.Total = total
return page, nil
}
func (l *Logger) filter(items []Entry, q Query) []Entry {
out := make([]Entry, 0, len(items))
for _, e := range items {
if matchEntry(e, q) {
out = append(out, e)
}
}
return out
}
func matchEntry(e Entry, q Query) bool {
if q.BlockedOnly && !e.Blocked {
return false
}
if q.Action != "" && string(e.Action) != q.Action {
return false
}
if q.Client != "" && e.Client != q.Client {
return false
}
if q.RCode != 0 && e.RCode != q.RCode {
return false
}
if !q.Since.IsZero() && e.TS.Before(q.Since) {
return false
}
if q.Text != "" {
t := q.Text
if !containsFold(e.Name, t) && !containsFold(e.Client, t) && !containsFold(e.List, t) {
return false
}
}
return true
}
func paginate(items []Entry, offset, limit int) []Entry {
if offset >= len(items) {
return []Entry{}
}
end := offset + limit
if end > len(items) {
end = len(items)
}
return items[offset:end]
}
func containsFold(hay, needle string) bool {
if len(needle) == 0 {
return true
}
return indexFold(hay, needle) >= 0
}
func indexFold(hay, needle string) int {
n := len(needle)
if n == 0 {
return 0
}
if n > len(hay) {
return -1
}
for i := 0; i+n <= len(hay); i++ {
match := true
for j := 0; j < n; j++ {
if lower(hay[i+j]) != lower(needle[j]) {
match = false
break
}
}
if match {
return i
}
}
return -1
}
func lower(b byte) byte {
if b >= 'A' && b <= 'Z' {
return b + 32
}
return b
}
// Subscribe registers a live-stream consumer. The returned cancel func must
// be called. Entries are delivered best-effort: a full channel drops rather
// than blocking the DNS path.
func (l *Logger) Subscribe() (<-chan Entry, func()) {
l.subMu.Lock()
defer l.subMu.Unlock()
l.subSeq++
id := l.subSeq
ch := make(chan Entry, 256)
l.subs[id] = ch
return ch, func() {
l.subMu.Lock()
if c, ok := l.subs[id]; ok {
delete(l.subs, id)
close(c)
}
l.subMu.Unlock()
}
}
// SubscriberCount reports how many live streams are attached.
func (l *Logger) SubscriberCount() int {
l.subMu.Lock()
defer l.subMu.Unlock()
return len(l.subs)
}
// Clear removes every persisted entry.
func (l *Logger) Clear() error {
if l.db == nil {
return nil
}
err := l.db.Update(func(tx *bolt.Tx) error {
if tx.Bucket(bucketLog) == nil {
return nil
}
return tx.DeleteBucket(bucketLog)
})
if err != nil {
l.logErrors.Add(1)
}
return err
}
// Count is the number of persisted entries.
func (l *Logger) Count() int {
if l.db == nil {
return int(l.seq.Load())
}
n := 0
_ = l.db.View(func(tx *bolt.Tx) error {
if b := tx.Bucket(bucketLog); b != nil {
n = b.Stats().KeyN
}
return nil
})
return n
}
// TopDomains returns the hottest domains, optionally only blocked ones.
type DomainCount struct {
Name string `json:"domain"`
Count uint64 `json:"count"`
Blocked uint64 `json:"blocked"`
QType uint16 `json:"qtype"`
QTypeS string `json:"qtype_name"`
}
// TopDomains returns the top n domains by query count.
func (l *Logger) TopDomains(n int, blockedOnly bool) []DomainCount {
return l.counts.top(n, blockedOnly)
}
// TopBlocked returns the top blocked domains.
func (l *Logger) TopBlocked(n int) []DomainCount { return l.counts.top(n, true) }
// TopQueried returns the top queried domains.
func (l *Logger) TopQueried(n int) []DomainCount { return l.counts.top(n, false) }
// Stats is a JSON-friendly summary.
type Stats struct {
Enabled bool `json:"enabled"`
Path string `json:"path"`
Entries int `json:"entries"`
MaxEntries int `json:"max_entries"`
LiveBuffer int `json:"live_buffer"`
Subscribers int `json:"subscribers"`
SampleRate float64 `json:"sample_rate"`
TotalLogged uint64 `json:"total_logged"`
Pending int `json:"pending"`
Errors uint64 `json:"errors"`
}
// Stats snapshots the logger.
func (l *Logger) Stats() Stats {
l.pendingMu.Lock()
pending := len(l.pending)
l.pendingMu.Unlock()
return Stats{
Enabled: l.db != nil,
Path: l.opts.Path,
Entries: l.Count(),
MaxEntries: l.opts.MaxEntries,
LiveBuffer: l.opts.LiveBuffer,
Subscribers: l.SubscriberCount(),
SampleRate: l.opts.SampleRate,
TotalLogged: l.seq.Load(),
Pending: pending,
Errors: l.logErrors.Load(),
}
}
// Close flushes and releases resources.
func (l *Logger) Close() error {
select {
case <-l.stop:
default:
close(l.stop)
}
l.wg.Wait()
if l.db != nil {
l.Flush()
return l.db.Close()
}
return nil
}
+598
View File
@@ -0,0 +1,598 @@
// Package resolver forwards queries to upstream recursive servers with
// health tracking, per-upstream circuit breakers and parallel racing.
package resolver
import (
"context"
"crypto/tls"
"errors"
"fmt"
"math/rand/v2"
"net"
"sort"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/miekg/dns"
"git.k8s-git-k6n.net/mats/fancy-dns/internal/config"
)
// ErrNoUpstream is returned when every configured upstream is unavailable.
var ErrNoUpstream = errors.New("resolver: no healthy upstream available")
// ErrCircuitOpen is returned by a single upstream whose breaker is open.
var ErrCircuitOpen = errors.New("resolver: upstream circuit breaker open")
// Health is an upstream's current state.
type Health string
const (
// HealthUp means the last probes and queries succeeded.
HealthUp Health = "up"
// HealthDegraded means recent failures but still serving.
HealthDegraded Health = "degraded"
// HealthDown means failing or the breaker is open.
HealthDown Health = "down"
// HealthUnknown means not probed yet.
HealthUnknown Health = "unknown"
)
// Upstream is one configured server plus its live health.
type Upstream struct {
cfg config.UpstreamServer
// addr is the address actually dialled: a bootstrap IP for TLS (SNI keeps
// the hostname) and the literal address otherwise.
addr string
// resolved holds the current bootstrap addresses when the configured
// address is a name.
resolved []string
queries atomic.Uint64
failures atomic.Uint64
consecutive atomic.Uint32
latencySum atomic.Uint64 // microseconds
latencyN atomic.Uint64
p95 []float64 // recent latencies in ms, for the UI
p95mu sync.Mutex
p95ring []float64
breakerOpenUntil atomic.Int64
health atomic.Pointer[Health]
lastErr atomic.Pointer[string]
}
// Addr is the address the resolver dials.
func (u *Upstream) Addr() string { return u.addr }
// Config returns the upstream's configuration entry.
func (u *Upstream) Config() config.UpstreamServer { return u.cfg }
// Health returns the current health state.
func (u *Upstream) Health() Health {
if h := u.health.Load(); h != nil {
return *h
}
return HealthUnknown
}
func (u *Upstream) setHealth(h Health) { u.health.Store(&h) }
// SetError records the last error message for the UI.
func (u *Upstream) SetError(err error) {
if err == nil {
u.lastErr.Store(nil)
return
}
s := err.Error()
u.lastErr.Store(&s)
}
// LastError returns the last error message, or "".
func (u *Upstream) LastError() string {
if p := u.lastErr.Load(); p != nil {
return *p
}
return ""
}
// Success records a successful query.
func (u *Upstream) Success(d time.Duration) {
u.queries.Add(1)
u.consecutive.Store(0)
u.latencySum.Add(uint64(d.Microseconds()))
u.latencyN.Add(1)
u.recordLatency(d)
u.SetError(nil)
if u.Health() != HealthUp {
u.setHealth(HealthUp)
}
}
// Failure records a failed query and trips the breaker once the threshold is
// crossed.
func (u *Upstream) Failure(err error, threshold int, cooldown time.Duration) {
u.queries.Add(1)
u.failures.Add(1)
n := u.consecutive.Add(1)
u.SetError(err)
if threshold > 0 && int(n) >= threshold {
u.breakerOpenUntil.Store(time.Now().Add(cooldown).UnixNano())
u.setHealth(HealthDown)
return
}
if u.Health() != HealthDown {
u.setHealth(HealthDegraded)
}
}
// BreakerOpen reports whether the upstream's breaker is open.
func (u *Upstream) BreakerOpen() bool {
until := u.breakerOpenUntil.Load()
return until > time.Now().UnixNano()
}
func (u *Upstream) recordLatency(d time.Duration) {
ms := float64(d.Microseconds()) / 1000
u.p95mu.Lock()
if len(u.p95ring) < 512 {
u.p95ring = append(u.p95ring, ms)
} else {
copy(u.p95ring, u.p95ring[1:])
u.p95ring[511] = ms
}
u.p95mu.Unlock()
}
// LatencyStats returns average and p95 latency in milliseconds.
func (u *Upstream) LatencyStats() (avg, p95 float64) {
n := u.latencyN.Load()
if n == 0 {
return 0, 0
}
avg = float64(u.latencySum.Load()) / float64(n) / 1000
u.p95mu.Lock()
samples := append([]float64(nil), u.p95ring...)
u.p95mu.Unlock()
if len(samples) == 0 {
return avg, 0
}
sort.Float64s(samples)
idx := int(float64(len(samples)) * 0.95)
if idx >= len(samples) {
idx = len(samples) - 1
}
return avg, samples[idx]
}
// Stats is the JSON-friendly upstream state for the API.
type Stats struct {
Addr string `json:"addr"`
Proto string `json:"proto"`
ServerName string `json:"server_name"`
Weight int `json:"weight"`
Comment string `json:"comment,omitempty"`
Health Health `json:"health"`
LatencyMS float64 `json:"latency_ms"`
AvgMS float64 `json:"avg_ms"`
P95MS float64 `json:"p95_ms"`
FailRate float64 `json:"fail_rate"`
Queries uint64 `json:"queries"`
Failures uint64 `json:"failures"`
BreakerOpen bool `json:"breaker_open"`
LastError string `json:"last_error,omitempty"`
}
// Stats snapshots one upstream.
func (u *Upstream) Stats() Stats {
q := u.queries.Load()
f := u.failures.Load()
var rate float64
if q > 0 {
rate = float64(f) / float64(q)
}
avg, p95 := u.LatencyStats()
return Stats{
Addr: u.addr,
Proto: u.cfg.Proto,
ServerName: u.cfg.ServerName,
Weight: u.cfg.Weight,
Comment: u.cfg.Comment,
Health: u.Health(),
LatencyMS: p95,
AvgMS: avg,
P95MS: p95,
FailRate: rate,
Queries: q,
Failures: f,
BreakerOpen: u.BreakerOpen(),
LastError: u.LastError(),
}
}
// Pool is the set of upstreams plus the policy that selects among them.
type Pool struct {
cfg config.Upstream
mu sync.RWMutex
ups []*Upstream
byTag map[string][]*Upstream
// clients are reused per upstream: one UDP socket, one TCP dialer, one TLS
// session pool. Reusing them is what keeps the per-query cost low.
clients map[*Upstream]*dns.Client
// bootstrap resolves names to addresses.
bootstrap map[string][]string
rng *rand.Rand
mu2 sync.Mutex
}
// NewPool builds a pool from config. bootstrapIPs may be empty; then upstream
// hostnames must be literal IPs (or resolved by the OS).
func NewPool(cfg config.Upstream) *Pool {
p := &Pool{
cfg: cfg,
byTag: make(map[string][]*Upstream),
clients: make(map[*Upstream]*dns.Client),
bootstrap: make(map[string][]string),
rng: rand.New(rand.NewPCG(uint64(time.Now().UnixNano()), 0x9e3779b9)),
}
for _, b := range cfg.Bootstraps {
p.bootstrap[b.Name] = append([]string(nil), b.Addresses...)
}
for _, s := range cfg.Servers {
up := &Upstream{cfg: s}
up.setHealth(HealthUnknown)
p.ups = append(p.ups, up)
}
p.resolveAll()
return p
}
func (p *Pool) resolveAll() {
for _, up := range p.ups {
host, port, err := net.SplitHostPort(up.cfg.Addr)
if err != nil {
continue
}
if net.ParseIP(host) != nil {
up.addr = up.cfg.Addr
continue
}
if addrs := p.bootstrap[host]; len(addrs) > 0 {
up.resolved = addrs
up.addr = net.JoinHostPort(addrs[0], port)
continue
}
// Fall back to the system resolver.
ips, err := net.LookupIP(host)
if err != nil || len(ips) == 0 {
up.addr = up.cfg.Addr
continue
}
strs := make([]string, 0, len(ips))
for _, ip := range ips {
strs = append(strs, ip.String())
}
up.resolved = strs
up.addr = net.JoinHostPort(strs[0], port)
}
}
// Config returns the pool's upstream config.
func (p *Pool) Config() config.Upstream { return p.cfg }
// All returns every upstream.
func (p *Pool) All() []*Upstream { return p.ups }
// Get returns an upstream by address, or nil.
func (p *Pool) Get(addr string) *Upstream {
for _, u := range p.ups {
if u.addr == addr || u.cfg.Addr == addr {
return u
}
}
return nil
}
// Healthy returns upstreams whose breaker is closed, in configured order.
func (p *Pool) Healthy() []*Upstream {
out := make([]*Upstream, 0, len(p.ups))
for _, u := range p.ups {
if !u.BreakerOpen() {
out = append(out, u)
}
}
return out
}
// Stats snapshots every upstream.
func (p *Pool) Stats() []Stats {
out := make([]Stats, 0, len(p.ups))
for _, u := range p.ups {
out = append(out, u.Stats())
}
return out
}
// clientFor returns the memoised dns.Client for an upstream.
func (p *Pool) clientFor(u *Upstream) *dns.Client {
p.mu.RLock()
c, ok := p.clients[u]
p.mu.RUnlock()
if ok {
return c
}
var nc *dns.Client
switch u.cfg.Proto {
case "tls":
tlsCfg := &tls.Config{
ServerName: u.cfg.ServerName,
InsecureSkipVerify: u.cfg.InsecureSkipVerify,
MinVersion: tls.VersionTLS12,
NextProtos: []string{"dot"},
}
nc = &dns.Client{
Net: "tcp-tls",
TLSConfig: tlsCfg,
Dialer: &net.Dialer{Timeout: 3 * time.Second},
Timeout: 5 * time.Second,
}
case "tcp":
nc = &dns.Client{Net: "tcp", Timeout: 5 * time.Second}
default:
nc = &dns.Client{Net: "udp", UDPSize: 1232, Timeout: 3 * time.Second}
}
p.mu.Lock()
p.clients[u] = nc
p.mu.Unlock()
return nc
}
// selectFor returns the upstreams to try, honouring the policy. tags, if
// non-empty, restricts the pool to a group's own upstreams.
func (p *Pool) selectFor(policy string, maxAttempts int) []*Upstream {
healthy := p.Healthy()
if len(healthy) == 0 {
// Every breaker is open: try them all anyway rather than failing the
// query outright. A blip should not become an outage.
healthy = append(healthy, p.ups...)
}
if len(healthy) == 0 {
return nil
}
if maxAttempts <= 0 || maxAttempts > len(healthy) {
maxAttempts = len(healthy)
}
switch policy {
case "failover":
// Configured order, and rotate past the unhealthy ones.
return healthy[:maxAttempts]
case "random":
p.mu2.Lock()
p.rng.Shuffle(len(healthy), func(i, j int) { healthy[i], healthy[j] = healthy[j], healthy[i] })
p.mu2.Unlock()
return healthy[:maxAttempts]
default: // parallel
return healthy
}
}
// Exchange forwards q to the pool and returns the first acceptable answer.
//
// policy is the pool's configured policy; tags restricts the pool to a
// per-client upstream override. edns0Size is the client's advertised buffer,
// used to decide TCP fallback on truncation.
func (p *Pool) Exchange(ctx context.Context, q *dns.Msg, tags []string, timeout time.Duration) (*dns.Msg, string, error) {
candidates := p.ups
if len(tags) > 0 {
if tagged := p.byTag[strings.Join(tags, ",")]; len(tagged) > 0 {
candidates = tagged
}
}
if len(candidates) == 0 {
return nil, "", ErrNoUpstream
}
attempts := p.cfg.MaxAttempts
chosen := selectFrom(candidates, p.cfg.Policy, attempts)
if len(chosen) == 0 {
return nil, "", ErrNoUpstream
}
if len(chosen) == 1 {
msg, up, err := p.exchangeOne(ctx, chosen[0], q, timeout)
return msg, up, err
}
// Parallel: send to all at once, take the first answer that is not a
// transport failure. Cancellable so we do not leak requests to a slow
// upstream after another one has answered.
type res struct {
msg *dns.Msg
up string
err error
}
results := make(chan res, len(chosen))
sub, cancel := context.WithCancel(ctx)
defer cancel()
for _, up := range chosen {
go func(up *Upstream) {
msg, addr, err := p.exchangeOne(sub, up, q, timeout)
results <- res{msg: msg, up: addr, err: err}
}(up)
}
var lastErr error
for i := 0; i < len(chosen); i++ {
select {
case r := <-results:
if r.err == nil && r.msg != nil && !isTransportFailure(r.msg.Rcode) {
return r.msg, r.up, nil
}
if r.err != nil {
lastErr = r.err
} else if r.msg != nil {
lastErr = fmt.Errorf("upstream %s returned %s", r.up, dns.RcodeToString[r.msg.Rcode])
}
case <-ctx.Done():
return nil, "", ctx.Err()
}
}
if lastErr == nil {
lastErr = ErrNoUpstream
}
return nil, "", lastErr
}
func selectFrom(ups []*Upstream, policy string, maxAttempts int) []*Upstream {
// Filter out open breakers but keep everything if that empties the list.
open := make([]*Upstream, 0, len(ups))
for _, u := range ups {
if !u.BreakerOpen() {
open = append(open, u)
}
}
if len(open) == 0 {
open = ups
}
if maxAttempts <= 0 || maxAttempts > len(open) {
maxAttempts = len(open)
}
switch policy {
case "failover":
return open[:maxAttempts]
case "random":
rand.Shuffle(len(open), func(i, j int) { open[i], open[j] = open[j], open[i] })
return open[:maxAttempts]
default:
return open
}
}
// exchangeOne sends q to a single upstream, retrying over TCP when the UDP
// answer comes back truncated (RFC 1035 §4.2.1).
func (p *Pool) exchangeOne(ctx context.Context, up *Upstream, q *dns.Msg, timeout time.Duration) (*dns.Msg, string, error) {
if timeout <= 0 {
timeout = 2 * time.Second
}
client := p.clientFor(up)
msg := q.Copy()
start := time.Now()
resp, rtt, err := client.ExchangeContext(ctx, msg, up.addr)
up.Success(rtt)
if err != nil {
return nil, up.addr, err
}
_ = start
if !resp.Truncated {
return resp, up.addr, nil
}
// Retry over TCP to get the full answer. Some upstreams are configured
// UDP-only in which case this simply fails and we use the TC answer.
tcpClient := &dns.Client{Net: "tcp", Timeout: timeout}
resp2, rtt2, err2 := tcpClient.ExchangeContext(ctx, q.Copy(), up.addr)
if err2 == nil && resp2 != nil && !resp2.Truncated {
up.Success(rtt2)
return resp2, up.addr, nil
}
up.Failure(fmt.Errorf("truncated answer, TCP retry failed: %w", err2), up.breakerThreshold(), p.cfg.BreakerCooldown.D())
// Return the truncated answer; the client will retry over TCP itself.
return resp, up.addr, nil
}
func isTransportFailure(rcode int) bool {
return rcode == dns.RcodeServerFailure
}
// breakerThreshold is the effective failure threshold for an upstream.
func (u *Upstream) breakerThreshold() int {
if u.cfg.MaxFails > 0 {
return u.cfg.MaxFails
}
return 0
}
// HealthCheck probes every upstream with a cheap query, refreshing health
// state even when no client traffic is flowing. It returns the number of
// upstreams that answered.
func (p *Pool) HealthCheck(ctx context.Context, timeout time.Duration) int {
probe := new(dns.Msg)
probe.SetQuestion(".", dns.TypeNS)
probe.RecursionDesired = true
ok := 0
for _, up := range p.ups {
if up.BreakerOpen() {
continue
}
c := p.clientFor(up)
_, rtt, err := c.ExchangeContext(ctx, probe, up.addr)
if err != nil {
up.Failure(err, p.breakerThresholdFor(up), p.cfg.BreakerCooldown.D())
continue
}
up.Success(rtt)
ok++
}
return ok
}
func (p *Pool) breakerThresholdFor(u *Upstream) int {
if t := u.breakerThreshold(); t > 0 {
return t
}
return p.cfg.BreakerThreshold
}
// Rebind applies a new upstream configuration in place, keeping the live
// health state of upstreams whose address is unchanged.
func (p *Pool) Rebind(cfg config.Upstream) {
p.mu.Lock()
defer p.mu.Unlock()
p.cfg = cfg
old := make(map[string]*Upstream, len(p.ups))
for _, u := range p.ups {
old[u.cfg.Addr+"/"+u.cfg.Proto] = u
}
next := make([]*Upstream, 0, len(cfg.Servers))
for _, s := range cfg.Servers {
if u, ok := old[s.Addr+"/"+s.Proto]; ok {
u.cfg = s
next = append(next, u)
delete(old, s.Addr+"/"+s.Proto)
continue
}
u := &Upstream{cfg: s}
u.setHealth(HealthUnknown)
next = append(next, u)
}
p.ups = next
// Drop clients for removed upstreams.
for u := range p.clients {
if old[u.cfg.Addr+"/"+u.cfg.Proto] != nil {
delete(p.clients, u)
}
}
p.resolveAllLocked()
}
func (p *Pool) resolveAllLocked() {
for _, b := range p.cfg.Bootstraps {
p.bootstrap[b.Name] = append([]string(nil), b.Addresses...)
}
for _, up := range p.ups {
host, port, err := net.SplitHostPort(up.cfg.Addr)
if err != nil {
continue
}
if net.ParseIP(host) != nil {
up.addr = up.cfg.Addr
continue
}
if addrs := p.bootstrap[host]; len(addrs) > 0 {
up.resolved = addrs
up.addr = net.JoinHostPort(addrs[0], port)
continue
}
up.addr = up.cfg.Addr
}
}
+848
View File
@@ -0,0 +1,848 @@
// Package server runs the DNS listeners: plain DNS on UDP/TCP, DNS-over-TLS
// (RFC 7858) and DNS-over-HTTPS (RFC 8484).
//
// All four transports funnel into one handler, so a query behaves identically
// whichever way it arrived. The transports are deliberately thin: parse, hand
// to the engine, write. Anything heavier belongs behind the engine.
package server
import (
"context"
"crypto/tls"
"crypto/x509"
"encoding/base64"
"errors"
"fmt"
"io"
"log/slog"
"net"
"net/http"
"net/netip"
"os"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/miekg/dns"
"golang.org/x/net/http2"
"golang.org/x/net/http2/h2c"
"git.k8s-git-k6n.net/mats/fancy-dns/internal/config"
"git.k8s-git-k6n.net/mats/fancy-dns/internal/engine"
)
// Handler is the interface the transports use to answer a query.
type Handler interface {
Handle(ctx context.Context, req engine.Request) engine.Response
Inflight() int64
Flush()
}
// identityFunc resolves a client IP to its reverse-DNS names. The app installs
// it so the server does not depend on the engine's cache directly.
type identityFunc func(ip string) []string
// tagFunc derives ACL tags from a client IP.
type tagFunc func(ip string) []string
// Server owns every DNS listener.
type Server struct {
eng Handler
cfg *config.Store
log *slog.Logger
version string
names atomic.Pointer[identityFunc]
tags atomic.Pointer[tagFunc]
mu sync.Mutex
started bool
shutdown context.CancelFunc
wait sync.WaitGroup
listeners []listener
// counters is keyed by "<transport>.<field>".
counters sync.Map
// active is the listener snapshot the API serves.
active atomic.Pointer[[]ListenerInfo]
}
// listener couples a close function with the info the API reports for it.
type listener struct {
close func() error
info ListenerInfo
}
// ListenerInfo describes one bound listener.
type ListenerInfo struct {
Transport string `json:"transport"`
Addr string `json:"addr"`
Enabled bool `json:"enabled"`
Running bool `json:"running"`
Err string `json:"err,omitempty"`
}
// Options configures a Server.
type Options struct {
// Engine handles queries.
Engine Handler
// Config is the hot-reloadable config store; the server subscribes to it.
Config *config.Store
// Logger is the structured logger.
Logger *slog.Logger
// Version is reported in the DoH server header and the admin API.
Version string
}
// New builds a Server.
func New(opt Options) *Server {
log := opt.Logger
if log == nil {
log = slog.Default()
}
return &Server{
eng: opt.Engine,
cfg: opt.Config,
log: log.With("component", "dns"),
version: opt.Version,
}
}
// SetIdentity installs the client reverse-name and tag resolvers.
func (s *Server) SetIdentity(names identityFunc, tags tagFunc) {
s.names.Store(&names)
s.tags.Store(&tags)
}
// Start binds every enabled listener and begins serving. It returns once all
// listeners have been bound, so a bind failure surfaces synchronously.
func (s *Server) Start(ctx context.Context) error {
cfg := s.cfg.Get()
ctx, cancel := context.WithCancel(ctx)
s.mu.Lock()
if s.started {
s.mu.Unlock()
cancel()
return errors.New("server: already started")
}
s.started = true
s.shutdown = cancel
s.mu.Unlock()
var failures []error
for _, addr := range cfg.DNS.UDP {
if err := s.serveUDP(ctx, addr); err != nil {
failures = append(failures, err)
}
}
for _, addr := range cfg.DNS.TCP {
if err := s.serveTCP(ctx, addr); err != nil {
failures = append(failures, err)
}
}
if cfg.DNS.DoT.Enabled {
if err := s.serveDoT(ctx, cfg); err != nil {
failures = append(failures, err)
}
}
if cfg.DNS.DoH.Enabled {
if err := s.serveDoH(ctx, cfg); err != nil {
failures = append(failures, err)
}
}
s.publish()
if err := errors.Join(failures...); err != nil {
s.log.Warn("some listeners failed to start", "error", err)
}
if s.count() == 0 && len(failures) > 0 {
cancel()
return fmt.Errorf("server: no listener started: %w", errors.Join(failures...))
}
return nil
}
func (s *Server) count() int {
s.mu.Lock()
defer s.mu.Unlock()
return len(s.listeners)
}
func (s *Server) add(l listener) {
s.mu.Lock()
s.listeners = append(s.listeners, l)
s.mu.Unlock()
}
func (s *Server) publish() {
s.mu.Lock()
infos := make([]ListenerInfo, 0, len(s.listeners))
for _, l := range s.listeners {
infos = append(infos, l.info)
}
s.mu.Unlock()
s.active.Store(&infos)
}
// Listeners returns the current listener snapshot.
func (s *Server) Listeners() []ListenerInfo {
if p := s.active.Load(); p != nil {
return *p
}
return nil
}
// Stop shuts every listener down and waits for their goroutines.
func (s *Server) Stop(ctx context.Context) error {
s.mu.Lock()
if !s.started {
s.mu.Unlock()
return nil
}
s.started = false
shutdown := s.shutdown
ls := s.listeners
s.listeners = nil
s.mu.Unlock()
if shutdown != nil {
shutdown()
}
var errs []error
for _, l := range ls {
if err := l.close(); err != nil {
errs = append(errs, err)
}
}
done := make(chan struct{})
go func() {
s.wait.Wait()
close(done)
}()
select {
case <-done:
case <-ctx.Done():
errs = append(errs, ctx.Err())
}
s.publish()
return errors.Join(errs...)
}
// serveUDP binds a UDP listener. One short-lived goroutine per packet keeps
// lock hold times at zero; a large socket buffer absorbs bursts instead of
// dropping them.
func (s *Server) serveUDP(ctx context.Context, addr string) error {
pc, err := net.ListenPacket("udp", addr)
if err != nil {
return fmt.Errorf("bind udp %s: %w", addr, err)
}
if uc, ok := pc.(*net.UDPConn); ok {
_ = uc.SetReadBuffer(4 << 20)
_ = uc.SetWriteBuffer(4 << 20)
}
// The read deadline is what lets the loop notice cancellation promptly;
// without it a blocked ReadFrom would outlive shutdown. It has to be reset
// on every iteration: a deadline set once expires for good and turns the
// loop into a spin that never reads another packet.
const readTick = 500 * time.Millisecond
s.add(listener{close: pc.Close, info: ListenerInfo{
Transport: "udp", Addr: pc.LocalAddr().String(), Enabled: true, Running: true,
}})
s.log.Info("dns listener up", "transport", "udp", "addr", pc.LocalAddr().String())
s.wait.Add(1)
go func() {
defer s.wait.Done()
buf := make([]byte, dns.MaxMsgSize)
for {
if ctx.Err() != nil {
return
}
_ = pc.SetReadDeadline(time.Now().Add(readTick))
n, from, err := pc.ReadFrom(buf)
if err != nil {
if ctx.Err() != nil {
return
}
if isTimeout(err) {
continue
}
// A closed listener during shutdown lands here.
if errors.Is(err, net.ErrClosed) {
return
}
s.log.Debug("udp read", "error", err)
continue
}
pkt := make([]byte, n)
copy(pkt, buf[:n])
addr, _ := net.ResolveUDPAddr("udp", from.String())
go s.servePacket(ctx, pc, pkt, from, addr)
}
}()
return nil
}
func isTimeout(err error) bool {
var ne net.Error
return errors.As(err, &ne) && ne.Timeout()
}
// servePacket answers one UDP query. The listener is passed in so the reply
// goes out on the very same socket the query arrived on: a fresh socket per
// answer would burn an ephemeral port and a connect handshake per query.
func (s *Server) servePacket(ctx context.Context, pc net.PacketConn, pkt []byte, from net.Addr, to *net.UDPAddr) {
m := new(dns.Msg)
if err := m.Unpack(pkt); err != nil {
// An unparsable message cannot be replied to: there is no ID and no
// question to echo. Dropping it is the only correct response.
s.bump("udp", "errors")
return
}
// A message with no question is still answerable (FORMERR), so it goes
// through the engine's validation rather than being dropped here.
// Cap the client's advertised buffer: nobody needs 64 KiB of DNS, and an
// unbounded value is a cheap amplification vector.
if o := m.IsEdns0(); o != nil && o.UDPSize() > 4096 {
o.SetUDPSize(4096)
}
req := s.request(ctx, m, from, "udp")
res := s.eng.Handle(ctx, req)
s.bump("udp", "queries")
s.classify("udp", res)
if res.Drop || res.Msg == nil {
s.bump("udp", "dropped")
return
}
if to == nil {
return
}
out := s.fitUDP(res.Msg, m, 512)
if out == nil {
return
}
if c, ok := pc.(interface{ SetWriteDeadline(time.Time) error }); ok {
_ = c.SetWriteDeadline(time.Now().Add(2 * time.Second))
}
s.bump("udp", "sent")
_, _ = pc.WriteTo(out, to)
}
// fitUDP packs a reply into a UDP datagram, setting TC and returning a
// question-only response when it does not fit (RFC 1035 §4.2.1, RFC 2181 §9).
func (s *Server) fitUDP(res *dns.Msg, q *dns.Msg, floor int) []byte {
limit := floor
if o := q.IsEdns0(); o != nil {
if n := int(o.UDPSize()); n >= 512 {
limit = n
}
}
if limit > 4096 {
limit = 4096
}
out, err := res.Pack()
if err != nil {
return nil
}
if len(out) <= limit {
return out
}
tc := new(dns.Msg)
tc.SetReply(q)
tc.Rcode = res.Rcode
tc.Truncated = true
tc.AuthenticatedData = res.AuthenticatedData
if o := q.IsEdns0(); o != nil {
tc.SetEdns0(uint16(limit), o.Do())
}
out, err = tc.Pack()
if err != nil {
return nil
}
s.bump("udp", "truncated")
return out
}
// request builds an engine.Request, filling in the client's identity.
func (s *Server) request(ctx context.Context, m *dns.Msg, from net.Addr, transport string) engine.Request {
ip := addrToAddr(from)
key := ip.String()
req := engine.Request{
Msg: m,
Remote: ip,
Transport: transport,
Start: time.Now(),
}
if f := s.names.Load(); f != nil {
req.ClientNames = (*f)(key)
}
if f := s.tags.Load(); f != nil {
req.ClientTags = (*f)(key)
}
return req
}
// classify records per-transport outcome counters.
func (s *Server) classify(transport string, res engine.Response) {
switch res.Action {
case "block":
s.bump(transport, "blocked")
case "cached":
s.bump(transport, "cached")
case "error", "drop":
s.bump(transport, "errors")
}
}
// serveTCP binds a TCP listener for plain DNS.
func (s *Server) serveTCP(ctx context.Context, addr string) error {
ln, err := net.Listen("tcp", addr)
if err != nil {
return fmt.Errorf("bind tcp %s: %w", addr, err)
}
return s.serveStream(ctx, ln, "tcp", nil, 0)
}
// serveStream runs the accept loop shared by TCP and DoT.
func (s *Server) serveStream(ctx context.Context, ln net.Listener, transport string, tlsCfg *tls.Config, idleTimeout time.Duration) error {
if tlsCfg != nil {
ln = tls.NewListener(ln, tlsCfg)
}
s.add(listener{close: ln.Close, info: ListenerInfo{
Transport: transport, Addr: ln.Addr().String(), Enabled: true, Running: true,
}})
s.log.Info("dns listener up", "transport", transport, "addr", ln.Addr().String())
s.wait.Add(1)
go func() {
defer s.wait.Done()
var wg sync.WaitGroup
defer wg.Wait()
for {
conn, err := ln.Accept()
if err != nil {
if ctx.Err() != nil {
return
}
if isTimeout(err) {
continue
}
return
}
wg.Add(1)
go func() {
defer wg.Done()
s.serveConn(ctx, conn, transport, idleTimeout)
}()
}
}()
return nil
}
// serveConn handles one stream connection with RFC 1035 §4.2.2 length
// prefixes, so a client may pipeline several queries.
func (s *Server) serveConn(ctx context.Context, conn net.Conn, transport string, idleTimeout time.Duration) {
defer func() { _ = conn.Close() }()
if idleTimeout > 0 {
_ = conn.SetDeadline(time.Now().Add(idleTimeout))
}
co := &dns.Conn{Conn: conn}
for {
if ctx.Err() != nil {
return
}
if idleTimeout > 0 {
_ = conn.SetReadDeadline(time.Now().Add(idleTimeout))
}
m, err := co.ReadMsg()
if err != nil {
return
}
if len(m.Question) == 0 {
s.bump(transport, "errors")
return
}
req := s.request(ctx, m, conn.RemoteAddr(), transport)
req.TCP = true
res := s.eng.Handle(ctx, req)
s.bump(transport, "queries")
s.classify(transport, res)
if res.Drop || res.Msg == nil {
s.bump(transport, "dropped")
return
}
if idleTimeout > 0 {
_ = conn.SetWriteDeadline(time.Now().Add(idleTimeout))
}
if err := co.WriteMsg(res.Msg); err != nil {
return
}
s.bump(transport, "sent")
}
}
// serveDoT binds the DNS-over-TLS listener.
func (s *Server) serveDoT(ctx context.Context, cfg *config.Config) error {
tlsCfg, err := tlsConfig(cfg.DNS.DoT.CertFile, cfg.DNS.DoT.KeyFile, cfg.DNS.DoT.ClientCA, cfg.DNS.DoT.MinVersion)
if err != nil {
return fmt.Errorf("dot: %w", err)
}
if tlsCfg == nil {
return errors.New("dot: enabled but no certificate configured")
}
tlsCfg.NextProtos = []string{"dot"}
timeout := cfg.Server.Timeouts.TLS.D()
var errs []error
for _, addr := range cfg.DNS.DoT.Listen {
ln, err := net.Listen("tcp", addr)
if err != nil {
errs = append(errs, fmt.Errorf("bind dot %s: %w", addr, err))
continue
}
if err := s.serveStream(ctx, ln, "dot", tlsCfg, timeout); err != nil {
errs = append(errs, err)
}
}
return errors.Join(errs...)
}
// serveDoH binds the DNS-over-HTTPS listener. GET (with ?dns=) and POST
// (application/dns-message) are both supported per RFC 8484, and the handler
// speaks h2 with an h2c fallback so it also works on a plain TCP listener.
func (s *Server) serveDoH(ctx context.Context, cfg *config.Config) error {
base := normaliseBase(cfg.DNS.DoH.BasePath)
mux := http.NewServeMux()
h := s.doHHandler(base, cfg.DNS.DoH.AllowPost)
mux.Handle(base, h)
// RFC 8484 §4.1 reserves /.well-known/dns-query as an alias.
if base != "/dns-query" {
mux.Handle("/.well-known/dns-query", h)
}
mux.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "text/plain")
_, _ = w.Write([]byte("ok\n"))
})
h2s := &http2.Server{IdleTimeout: 90 * time.Second}
handler := h2c.NewHandler(mux, h2s)
tlsCfg, tlsErr := tlsConfig(cfg.DNS.DoH.CertFile, cfg.DNS.DoH.KeyFile, "", cfg.DNS.DoH.MinVersion)
if tlsCfg != nil {
tlsCfg.NextProtos = []string{"h2", "http/1.1"}
}
var errs []error
for _, addr := range cfg.DNS.DoH.Listen {
ln, err := net.Listen("tcp", addr)
if err != nil {
errs = append(errs, fmt.Errorf("bind doh %s: %w", addr, err))
continue
}
actual := ln.Addr().String()
srv := &http.Server{
Handler: handler,
ReadHeaderTimeout: 10 * time.Second,
IdleTimeout: 90 * time.Second,
// No WriteTimeout: a long-lived h2 stream should not be cut off,
// and ReadHeaderTimeout plus IdleTimeout already bound the damage.
}
closeFn := ln.Close
if tlsCfg != nil {
srv.TLSConfig = tlsCfg
ln = tls.NewListener(ln, tlsCfg)
closeFn = ln.Close
}
s.add(listener{close: closeFn, info: ListenerInfo{
Transport: "doh", Addr: actual, Enabled: true, Running: true,
}})
if tlsErr != nil {
s.log.Warn("doh: running without TLS", "error", tlsErr)
}
s.log.Info("dns listener up", "transport", "doh", "addr", actual, "path", base)
s.wait.Add(1)
go func() {
defer s.wait.Done()
if err := srv.Serve(ln); err != nil && !errors.Is(err, http.ErrServerClosed) {
s.log.Debug("doh server stopped", "error", err)
}
}()
s.wait.Add(1)
go func() {
defer s.wait.Done()
<-ctx.Done()
shutCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_ = srv.Shutdown(shutCtx)
}()
}
return errors.Join(errs...)
}
const contentTypeDNS = "application/dns-message"
// maxGETResponse bounds a DoH GET response so no intermediary truncates it.
const maxGETResponse = 65535
// doHHandler builds the RFC 8484 handler.
func (s *Server) doHHandler(base string, allowPost bool) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet && r.Method != http.MethodPost {
w.Header().Set("Allow", "GET, POST")
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
var (
payload []byte
err error
)
switch r.Method {
case http.MethodGet:
// RFC 8484 §4.1 defines exactly one parameter, "dns", holding
// unpadded base64url. An unknown parameter is a 400 rather than a
// 404 so a client can tell its own mistake from a wrong path.
q := r.URL.Query()
if !q.Has("dns") {
writeDoHError(w, http.StatusBadRequest, "missing dns parameter")
return
}
if len(q) > 1 {
for k := range q {
if k != "dns" {
writeDoHError(w, http.StatusBadRequest, "unsupported parameter "+k)
return
}
}
}
payload, err = decodeBase64URL(q.Get("dns"))
if err != nil {
writeDoHError(w, http.StatusBadRequest, "malformed dns parameter")
return
}
case http.MethodPost:
if !allowPost {
w.Header().Set("Allow", "GET")
http.Error(w, "POST not allowed", http.StatusMethodNotAllowed)
return
}
if ct := r.Header.Get("Content-Type"); ct != contentTypeDNS {
writeDoHError(w, http.StatusUnsupportedMediaType, "content-type must be "+contentTypeDNS)
return
}
payload, err = readCapped(r, dns.MaxMsgSize)
if err != nil {
writeDoHError(w, http.StatusBadRequest, "cannot read body")
return
}
}
m := new(dns.Msg)
if err := m.Unpack(payload); err != nil {
writeDoHError(w, http.StatusBadRequest, "malformed DNS message")
return
}
if len(m.Question) == 0 {
writeDoHError(w, http.StatusBadRequest, "no question")
return
}
req := s.request(r.Context(), m, httpRemoteAddr(r), "doh")
req.TCP = true
req.DoHPath = r.URL.Path
res := s.eng.Handle(r.Context(), req)
s.bump("doh", "queries")
s.classify("doh", res)
if res.Drop || res.Msg == nil {
// A dropped query still needs a body: unlike UDP, this is a
// well-formed HTTP exchange, so hanging up would read as a
// transport error rather than a policy decision.
res.Msg = new(dns.Msg)
res.Msg.SetRcode(m, dns.RcodeSuccess)
}
wire, err := res.Msg.Pack()
if err != nil {
http.Error(w, "pack failed", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", contentTypeDNS)
w.Header().Set("Cache-Control", "max-age=0")
if !cfgHideIdentity(s) {
w.Header().Set("Server", "fancy-dns/"+s.version)
}
w.WriteHeader(http.StatusOK)
_, _ = w.Write(wire)
})
}
func cfgHideIdentity(s *Server) bool {
if s.cfg == nil {
return false
}
return s.cfg.Get().DNS.HideIdentity
}
func writeDoHError(w http.ResponseWriter, code int, msg string) {
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.Header().Set("X-Content-Type-Options", "nosniff")
http.Error(w, msg, code)
}
// tlsConfig loads a certificate for the encrypted listeners. It returns
// (nil, nil) when no certificate is configured, which means "run plaintext".
func tlsConfig(certFile, keyFile, clientCA, minVersion string) (*tls.Config, error) {
if certFile == "" || keyFile == "" {
return nil, nil
}
cert, err := tls.LoadX509KeyPair(certFile, keyFile)
if err != nil {
return nil, fmt.Errorf("load certificate: %w", err)
}
cfg := &tls.Config{
Certificates: []tls.Certificate{cert},
MinVersion: parseTLSVersion(minVersion),
}
if clientCA != "" {
pem, err := os.ReadFile(clientCA)
if err != nil {
return nil, fmt.Errorf("read client CA: %w", err)
}
pool := x509.NewCertPool()
if !pool.AppendCertsFromPEM(pem) {
return nil, errors.New("client CA: no certificates found")
}
cfg.ClientCAs = pool
cfg.ClientAuth = tls.RequireAndVerifyClientCert
}
return cfg, nil
}
func parseTLSVersion(s string) uint16 {
switch s {
case "1.3":
return tls.VersionTLS13
case "1.0":
return tls.VersionTLS10
default:
return tls.VersionTLS12
}
}
func normaliseBase(p string) string {
if p == "" {
return "/dns-query"
}
if !strings.HasPrefix(p, "/") {
p = "/" + p
}
for len(p) > 1 && p[len(p)-1] == '/' {
p = p[:len(p)-1]
}
return p
}
func readCapped(r *http.Request, max int64) ([]byte, error) {
if r.ContentLength > max {
return nil, fmt.Errorf("body too large: %d", r.ContentLength)
}
// LimitReader plus one extra byte is how you detect "too large" without
// trusting Content-Length, which a client may lie about.
body := io.LimitReader(r.Body, max+1)
buf, err := io.ReadAll(body)
if err != nil {
return nil, err
}
if int64(len(buf)) > max {
return nil, errors.New("body too large")
}
return buf, nil
}
func decodeBase64URL(s string) ([]byte, error) {
// RFC 8484 requires unpadded base64url, but every major client sends
// padding, so accept both rather than fail a legitimate request.
if b, err := base64.RawURLEncoding.DecodeString(s); err == nil {
return b, nil
}
return base64.URLEncoding.DecodeString(s)
}
// httpRemoteAddr turns an http.Request's RemoteAddr into a net.Addr so the
// shared request builder can be used. Over plain HTTP this is the socket peer;
// behind a reverse proxy it is the proxy, which is why the caller must trust
// X-Forwarded-For before that matters (handled in the API layer).
func httpRemoteAddr(r *http.Request) net.Addr {
if r.RemoteAddr == "" {
return &net.TCPAddr{}
}
ap, err := netip.ParseAddrPort(r.RemoteAddr)
if err != nil {
return &net.TCPAddr{}
}
return net.UDPAddrFromAddrPort(ap)
}
// addrToAddr extracts a netip.Addr from a net.Addr.
func addrToAddr(a net.Addr) netip.Addr {
switch v := a.(type) {
case *net.UDPAddr:
return v.AddrPort().Addr().Unmap()
case *net.TCPAddr:
return v.AddrPort().Addr().Unmap()
default:
ap, err := netip.ParseAddrPort(a.String())
if err == nil {
return ap.Addr().Unmap()
}
if ip, err := netip.ParseAddr(a.String()); err == nil {
return ip.Unmap()
}
return netip.Addr{}
}
}
// Stats aggregates the per-transport counters plus the engine's own view.
func (s *Server) Stats() HandlerStats {
var out HandlerStats
s.counters.Range(func(k, v any) bool {
key, _ := k.(string)
n := v.(*atomic.Uint64).Load()
dot := strings.IndexByte(key, '.')
if dot < 0 {
return true
}
switch key[dot+1:] {
case "queries":
out.Queries += n
case "blocked":
out.Blocked += n
case "cached":
out.Cached += n
case "errors":
out.Errors += n
case "dropped":
out.Dropped += n
}
return true
})
if s.eng != nil {
out.Inflight = s.eng.Inflight()
}
return out
}
func (s *Server) bump(transport, field string) {
if transport == "" {
transport = "udp"
}
key := transport + "." + field
v, _ := s.counters.LoadOrStore(key, new(atomic.Uint64))
v.(*atomic.Uint64).Add(1)
}
// flusher is implemented by the engine: it pushes buffered cache and log state
// to disk on demand (used by the admin API's "flush" action).
type flusher interface{ Flush() }
// Flush pushes buffered state to disk.
func (s *Server) Flush() {
if f, ok := s.eng.(flusher); ok {
f.Flush()
}
}
+56
View File
@@ -0,0 +1,56 @@
package server
import "sync/atomic"
// HandlerStats aggregates the per-transport counters plus the engine's own
// view. It is the "what is this thing doing right now" summary the admin API
// and the health endpoint read.
type HandlerStats struct {
// Queries is every query the server accepted across all transports.
Queries uint64 `json:"queries"`
// Blocked is queries answered by a blocklist rule.
Blocked uint64 `json:"blocked"`
// Cached is queries answered from the cache.
Cached uint64 `json:"cached"`
// Errors is queries answered with SERVFAIL, dropped or malformed.
Errors uint64 `json:"errors"`
// Dropped is queries answered with silence.
Dropped uint64 `json:"dropped"`
// Inflight is the current in-flight count.
Inflight int64 `json:"inflight"`
}
// ByTransport returns a per-transport breakdown for the UI's transport tab.
func (s *Server) ByTransport() map[string]TransportStats {
out := map[string]TransportStats{}
for _, t := range []string{"udp", "tcp", "dot", "doh"} {
ts := TransportStats{Transport: t}
ts.Queries = s.load(t, "queries")
ts.Blocked = s.load(t, "blocked")
ts.Cached = s.load(t, "cached")
ts.Errors = s.load(t, "errors")
ts.Truncated = s.load(t, "truncated")
ts.Sent = s.load(t, "sent")
out[t] = ts
}
return out
}
func (s *Server) load(transport, field string) uint64 {
v, ok := s.counters.Load(transport + "." + field)
if !ok {
return 0
}
return v.(*atomic.Uint64).Load()
}
// TransportStats is a per-transport counter set.
type TransportStats struct {
Transport string `json:"transport"`
Queries uint64 `json:"queries"`
Blocked uint64 `json:"blocked"`
Cached uint64 `json:"cached"`
Errors uint64 `json:"errors"`
Truncated uint64 `json:"truncated"`
Sent uint64 `json:"sent"`
}
+1
View File
@@ -0,0 +1 @@
Build artifacts land here: run `make web` (npm ci && npm run build) then rebuild the binary.
+3
View File
@@ -0,0 +1,3 @@
<!doctype html><html><head><meta charset="utf-8"><title>fancy-dns</title></head>
<body style="font:16px system-ui;padding:2rem"><h1>fancy-dns</h1>
<p>Web console assets are not built in this binary. Run <code>make web</code> and rebuild.</p></body></html>
+99
View File
@@ -0,0 +1,99 @@
// Package web serves the embedded admin console as a single-page app.
//
// The assets are compiled into the binary, so a deployment is one file plus a
// config; there is no node process in production and no CDN dependency. When
// the assets are absent (a `go build` without `npm run build`) the server says
// so plainly instead of returning a confusing 404.
package web
import (
"embed"
"fmt"
"io/fs"
"net/http"
"strconv"
"strings"
)
//go:embed all:dist
var dist embed.FS
// Available reports whether compiled assets are present in this binary.
func Available() bool {
_, err := dist.Open("dist/index.html")
return err == nil
}
// Handler serves the console, falling back to index.html for client-side
// routes so a deep link like /logs works on a hard refresh.
func Handler(basePath string) (http.Handler, error) {
sub, err := fs.Sub(dist, "dist")
if err != nil {
return nil, fmt.Errorf("web: open assets: %w", err)
}
if _, err := fs.Stat(sub, "index.html"); err != nil {
return http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(http.StatusServiceUnavailable)
_, _ = w.Write([]byte(missingAssets))
}), nil
}
files := http.FileServer(http.FS(sub))
prefix := "/"
if basePath != "" && basePath != "/" {
prefix = "/" + strings.Trim(basePath, "/") + "/"
}
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Hashed asset filenames are immutable, so they can be cached hard.
if strings.HasPrefix(r.URL.Path, prefix+"assets/") {
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
} else {
w.Header().Set("Cache-Control", "no-cache")
}
clean := strings.TrimPrefix(r.URL.Path, strings.TrimSuffix(prefix, "/"))
clean = strings.TrimPrefix(clean, "/")
if clean == "" {
clean = "index.html"
}
if _, err := fs.Stat(sub, clean); err != nil {
// Unknown path: hand it to the SPA router.
b, rerr := fs.ReadFile(sub, "index.html")
if rerr != nil {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Cache-Control", "no-cache")
w.WriteHeader(http.StatusOK)
_, _ = w.Write(b)
return
}
files.ServeHTTP(w, r)
}), nil
}
const missingAssets = `<!doctype html>
<html><head><meta charset="utf-8"><title>fancy-dns</title>
<style>body{font:16px/1.6 system-ui,sans-serif;max-width:40rem;margin:4rem auto;padding:0 1rem;color:#0f172a;background:#f8fafc}
code{background:#e2e8f0;padding:.15em .4em;border-radius:.25rem}</style></head>
<body><h1>fancy-dns</h1>
<p>The admin API is running, but the web console was not built into this binary.</p>
<p>Build it with <code>make web</code> (or <code>cd web &amp;&amp; npm ci &amp;&amp; npm run build</code>), then rebuild the binary.</p>
<p>The JSON API is available under <code>/api</code>.</p></body></html>`
// Version is the asset build stamp, exposed in the UI footer.
func Version() string {
b, err := dist.ReadFile("dist/.build-version")
if err != nil {
return ""
}
return strings.TrimSpace(string(b))
}
// CacheControlMaxAge is the immutable-asset max age in seconds.
const CacheControlMaxAge = 31536000
// maxAgeHeader builds the Cache-Control value for a hashed asset.
func maxAgeHeader(seconds int) string {
return "public, max-age=" + strconv.Itoa(seconds) + ", immutable"
}
+532
View File
@@ -0,0 +1,532 @@
// Package zonefile parses RFC 1035 master files ("zone files") with the
// $TTL/$ORIGIN directives, parenthesised continuations and RFC 1982/1996
// serial arithmetic.
//
// fancy-dns uses master files for two things: locally defined authoritative
// data, and RPZ-style response-policy zones that double as a blocklist source.
// Sharing one parser keeps operator expectations ("it is just a zone file")
// true for both.
package zonefile
import (
"bufio"
"fmt"
"io"
"os"
"strconv"
"strings"
"github.com/miekg/dns"
)
// Record is one parsed resource record with its origin resolved.
type Record struct {
// Name is the fully-qualified, lowercased owner name.
Name string
TTL uint32
RR dns.RR
// Line is the 1-based source line, for error reporting.
Line int
// File is the source path, for error reporting.
File string
}
// Text renders the record back into zone-file syntax.
func (r Record) Text() string { return r.RR.String() }
// Zone is the result of parsing a master file.
type Zone struct {
// Origin is the zone's apex as configured.
Origin string
// Records are in file order.
Records []Record
// File is the path parsed, or "" for reader input.
File string
// MinTTL is the smallest explicit TTL seen.
MinTTL uint32
// SOA holds the zone's start-of-authority record, if present.
SOA *dns.SOA
// DefaultTTL is the $TTL directive value, else the SOA minimum.
DefaultTTL uint32
}
// Options tunes parsing.
type Options struct {
// Origin overrides $ORIGIN and is the default when a file has none.
Origin string
// File is used in error messages.
File string
// MaxIncludeDepth bounds $INCLUDE recursion; 0 disables $INCLUDE.
MaxIncludeDepth int
// BaseDir is where relative $INCLUDE paths resolve; empty means the
// directory of the primary file.
BaseDir string
// DefaultTTL is the TTL applied to records that carry none, when the
// file has neither $TTL nor an SOA.
DefaultTTL uint32
}
// Parse reads a master file from r.
func Parse(r io.Reader, opt Options) (*Zone, error) {
origin := ensureDot(opt.Origin)
if origin == "" {
origin = "."
}
if opt.BaseDir == "" && opt.File != "" {
opt.BaseDir = dirOf(opt.File)
}
z := &Zone{Origin: origin, File: opt.File, DefaultTTL: opt.DefaultTTL}
var lastOwner string
lineno := 0
lines := bufio.NewScanner(r)
lines.Buffer(make([]byte, 0, 64*1024), 8*1024*1024)
for lines.Scan() {
startLine := lineno + 1
lineno++
stripped := strings.TrimSpace(stripComment(lines.Text()))
if stripped == "" {
continue
}
// Fold parenthesised continuations into one logical line.
logical, err := fold(lines, stripped, &lineno, opt)
if err != nil {
return nil, fmt.Errorf("%s:%d: %w", where(opt.File), startLine, err)
}
if strings.HasPrefix(logical, "$") {
if err := z.directive(logical, opt); err != nil {
return nil, fmt.Errorf("%s:%d: %w", where(opt.File), startLine, err)
}
lastOwner = z.Origin
continue
}
assembled, err := z.assemble(logical, lastOwner)
if err != nil {
return nil, fmt.Errorf("%s:%d: %w", where(opt.File), startLine, err)
}
rr, err := dns.NewRR(assembled)
if err != nil {
return nil, fmt.Errorf("%s:%d: %q: %w", where(opt.File), startLine, assembled, err)
}
rec := Record{
Name: strings.ToLower(ensureDot(rr.Header().Name)),
TTL: rr.Header().Ttl,
RR: rr,
Line: startLine,
File: opt.File,
}
if rec.TTL == 0 {
rec.TTL = z.DefaultTTL
}
if rec.TTL > 0 && (z.MinTTL == 0 || rec.TTL < z.MinTTL) {
z.MinTTL = rec.TTL
}
if soa, ok := rr.(*dns.SOA); ok {
z.SOA = cloneSOA(soa)
if rec.TTL > 0 {
z.DefaultTTL = rec.TTL
} else if z.DefaultTTL == 0 {
z.DefaultTTL = soa.Minttl
}
}
z.Records = append(z.Records, rec)
lastOwner = rec.Name
}
if err := lines.Err(); err != nil {
return nil, fmt.Errorf("%s: read: %w", opt.File, err)
}
return z, nil
}
// fold joins a line with any parenthesised continuation lines still pending on
// the scanner. open reports how many continuations are still outstanding after
// the returned line, so a caller can loop until it reaches zero.
func fold(lines *bufio.Scanner, first string, lineno *int, opt Options) (string, error) {
depth := strings.Count(first, "(") - strings.Count(first, ")")
var sb strings.Builder
sb.WriteString(deparen(first))
for depth > 0 {
if !lines.Scan() {
return "", fmt.Errorf("unterminated parenthesis")
}
*lineno++
next := strings.TrimSpace(stripComment(lines.Text()))
if next == "" {
continue
}
depth += strings.Count(next, "(") - strings.Count(next, ")")
sb.WriteString(" ")
sb.WriteString(deparen(next))
}
return strings.TrimSpace(sb.String()), nil
}
// deparen removes continuation markers, leaving a single-space token stream.
func deparen(s string) string {
s = strings.ReplaceAll(s, "(", " ")
s = strings.ReplaceAll(s, ")", " ")
return strings.TrimSpace(s)
}
// assemble fills in the omitted owner name, TTL and class so that the result
// is a complete RR line that dns.NewRR will accept.
func (z *Zone) assemble(line, lastOwner string) (string, error) {
fields := splitFields(line)
if len(fields) == 0 {
return "", fmt.Errorf("empty record")
}
var head string
var rest []string
if isRRTYPE(fields[0]) {
// Owner omitted: it repeats the previous record's owner.
head = lastOwner
rest = fields
} else {
head = fields[0]
rest = fields[1:]
}
if head == "" {
return "", fmt.Errorf("record has no owner name and no previous owner to inherit")
}
head = relative(head, z.Origin)
out := []string{head}
// Consume optional TTL and class in any order, then the type.
for len(rest) > 0 && !isRRTYPE(rest[0]) {
switch {
case isTTLToken(rest[0]):
out = append(out, rest[0])
case isClassToken(rest[0]):
out = append(out, strings.ToUpper(rest[0]))
default:
return "", fmt.Errorf("unexpected token %q before the record type", rest[0])
}
rest = rest[1:]
}
if len(rest) == 0 {
return "", fmt.Errorf("record %q has no type", head)
}
out = append(out, rest...)
return strings.Join(out, " "), nil
}
// directive handles $TTL, $ORIGIN and $INCLUDE.
func (z *Zone) directive(line string, opt Options) error {
fields := strings.Fields(line)
switch strings.ToUpper(fields[0]) {
case "$TTL":
if len(fields) < 2 {
return fmt.Errorf("$TTL needs a value")
}
ttl, err := ParseTTL(fields[1])
if err != nil {
return err
}
z.DefaultTTL = ttl
case "$ORIGIN":
if len(fields) < 2 {
return fmt.Errorf("$ORIGIN needs a name")
}
z.Origin = ensureDot(fields[1])
case "$INCLUDE":
if len(fields) < 2 {
return fmt.Errorf("$INCLUDE needs a file name")
}
if opt.MaxIncludeDepth <= 0 {
return fmt.Errorf("$INCLUDE %q is disabled in this context", fields[1])
}
return fmt.Errorf("$INCLUDE %q is not supported; use blocklist.local_zones or a list source instead", fields[1])
}
return nil
}
// rrTypes is the set of record types the assembler recognises as a type
// token; anything absent is treated as an owner name.
var rrTypes = map[string]bool{}
func init() {
for _, t := range []uint16{
dns.TypeA, dns.TypeAAAA, dns.TypeNS, dns.TypeCNAME, dns.TypeSOA, dns.TypePTR,
dns.TypeMX, dns.TypeTXT, dns.TypeSRV, dns.TypeCAA, dns.TypeDS, dns.TypeDNSKEY,
dns.TypeNSEC, dns.TypeNSEC3, dns.TypeNSEC3PARAM, dns.TypeRRSIG, dns.TypeNSEC,
dns.TypeNAPTR, dns.TypeTLSA, dns.TypeSSHFP, dns.TypeSVCB, dns.TypeHTTPS,
dns.TypeHINFO, dns.TypeLOC, dns.TypeSPF, dns.TypeAXFR,
dns.TypeIXFR, dns.TypeANY, dns.TypeURI, dns.TypeDHCID, dns.TypeSMIMEA,
} {
rrTypes[dns.TypeToString[t]] = true
}
}
// cloneSOA copies an SOA because dns.SOA.Copy is unexported.
func cloneSOA(in *dns.SOA) *dns.SOA {
if in == nil {
return nil
}
out := *in // value copy is already deep: Ns/Mbox are plain strings
return &out
}
func isRRTYPE(s string) bool { return rrTypes[strings.ToUpper(s)] }
func isTTLToken(s string) bool {
if s == "" || s[0] < '0' || s[0] > '9' {
return false
}
if _, err := ParseTTL(s); err != nil {
return false
}
return !isClassToken(s)
}
func isClassToken(s string) bool {
switch strings.ToUpper(s) {
case "IN", "CH", "HS":
return true
}
return false
}
func where(file string) string {
if file == "" {
return "zone"
}
return file
}
func dirOf(p string) string {
i := strings.LastIndexByte(p, '/')
if i < 0 {
return "."
}
return p[:i]
}
func ensureDot(s string) string {
if s == "" {
return ""
}
if strings.HasSuffix(s, ".") {
return s
}
return s + "."
}
// relative expands a possibly-relative name against origin.
func relative(name, origin string) string {
if name == "@" {
return ensureDot(origin)
}
// A name that already ends in a dot is absolute, so it never gets the
// origin appended. This is the common case in RPZ-style policy files,
// where owners are written fully qualified.
if strings.HasSuffix(name, ".") {
return name
}
if origin == "" || origin == "." {
return ensureDot(name)
}
return ensureDot(name) + origin
}
func stripComment(s string) string {
inQuote := false
for i := 0; i < len(s); i++ {
switch s[i] {
case '\\':
i++
case '"':
inQuote = !inQuote
case ';':
if !inQuote {
return s[:i]
}
}
}
return s
}
// splitFields splits on whitespace while keeping quoted strings intact (TXT
// payloads are handed to dns.NewRR untouched).
func splitFields(s string) []string {
var out []string
var cur strings.Builder
inQuote := false
started := false
for i := 0; i < len(s); i++ {
c := s[i]
switch {
case c == '\\' && i+1 < len(s):
cur.WriteByte(c)
i++
cur.WriteByte(s[i])
started = true
case c == '"':
inQuote = !inQuote
cur.WriteByte(c)
started = true
case (c == ' ' || c == '\t') && !inQuote:
if started {
out = append(out, cur.String())
cur.Reset()
started = false
}
default:
cur.WriteByte(c)
started = true
}
}
if started {
out = append(out, cur.String())
}
return out
}
// ParseTTL parses the RFC 1035 TTL grammar (unit suffixes s/m/h/d/w) and a
// bare number of seconds.
func ParseTTL(s string) (uint32, error) {
s = strings.TrimSpace(s)
if s == "" {
return 0, fmt.Errorf("empty TTL")
}
var total uint64
i := 0
for i < len(s) {
start := i
for i < len(s) && s[i] >= '0' && s[i] <= '9' {
i++
}
if start == i {
return 0, fmt.Errorf("bad TTL %q", s)
}
n, err := strconv.ParseUint(s[start:i], 10, 64)
if err != nil {
return 0, fmt.Errorf("bad TTL %q", s)
}
unitStart := i
for i < len(s) && (s[i] < '0' || s[i] > '9') {
i++
}
var mult uint64
switch strings.ToLower(s[unitStart:i]) {
case "", "s", "sec", "secs", "second", "seconds":
mult = 1
case "m", "min", "mins", "minute", "minutes":
mult = 60
case "h", "hour", "hours":
mult = 3600
case "d", "day", "days":
mult = 86400
case "w", "week", "weeks":
mult = 604800
default:
return 0, fmt.Errorf("bad TTL unit in %q", s)
}
total += n * mult
if total > 0xFFFFFFFF {
return 0, fmt.Errorf("TTL %q overflows", s)
}
}
return uint32(total), nil
}
// FormatTTL renders a TTL in the compact unit form operators expect.
func FormatTTL(ttl uint32) string {
switch {
case ttl >= 604800 && ttl%604800 == 0:
return strconv.FormatUint(uint64(ttl/604800), 10) + "w"
case ttl >= 86400 && ttl%86400 == 0:
return strconv.FormatUint(uint64(ttl/86400), 10) + "d"
case ttl >= 3600 && ttl%3600 == 0:
return strconv.FormatUint(uint64(ttl/3600), 10) + "h"
case ttl >= 60 && ttl%60 == 0:
return strconv.FormatUint(uint64(ttl/60), 10) + "m"
default:
return strconv.FormatUint(uint64(ttl), 10)
}
}
// SerialNext applies RFC 1982 serial arithmetic: "+N"/"-N" or a plain number.
func SerialNext(current uint32, spec string) (uint32, error) {
spec = strings.TrimSpace(spec)
switch {
case spec == "":
return 0, fmt.Errorf("empty serial")
case spec[0] == '+':
n, err := strconv.ParseUint(spec[1:], 10, 32)
if err != nil {
return 0, fmt.Errorf("bad serial %q", spec)
}
return addSerial(current, uint32(n)), nil
case spec[0] == '-':
n, err := strconv.ParseUint(spec[1:], 10, 32)
if err != nil {
return 0, fmt.Errorf("bad serial %q", spec)
}
return addSerial(current, -uint32(n)), nil
default:
n, err := strconv.ParseUint(spec, 10, 32)
if err != nil {
return 0, fmt.Errorf("bad serial %q", spec)
}
return uint32(n), nil
}
}
func addSerial(a, b uint32) uint32 {
sum := (uint64(a) + uint64(b)) & 0xFFFFFFFF
// Both operands in the "high" half means the sum stays inside the epoch,
// so no wrap correction applies (RFC 1982 §3.2).
if a >= 0x80000000 && b >= 0x80000000 {
return uint32(sum)
}
if sum >= 0x100000000 {
return uint32(sum - 0x100000000)
}
return uint32(sum)
}
// ParseFile reads and parses a master file from disk.
func ParseFile(path string, origin string) (*Zone, error) {
f, err := os.Open(path)
if err != nil {
return nil, err
}
defer f.Close()
return Parse(f, Options{Origin: origin, File: path, BaseDir: dirOf(path)})
}
// ParseFiles parses every file in paths and merges the records into one zone.
// Origins default to the file's base name, which is what a directory of
// per-list policy zones usually wants.
func ParseFiles(paths []string, originFor func(string) string) (*Zone, error) {
z := &Zone{Origin: "."}
for _, p := range paths {
origin := originFor(p)
sub, err := ParseFile(p, origin)
if err != nil {
return nil, err
}
z.Records = append(z.Records, sub.Records...)
if sub.DefaultTTL > 0 {
z.DefaultTTL = sub.DefaultTTL
}
if sub.SOA != nil {
z.SOA = sub.SOA
}
}
return z, nil
}
// OriginFromPath derives a zone origin from a file path: "rpz/ads.zone" and
// "ads.zone" both become "ads.zone.".
func OriginFromPath(p string) string {
base := p
if i := strings.LastIndexByte(base, '/'); i >= 0 {
base = base[i+1:]
}
if i := strings.IndexByte(base, '.'); i > 0 {
base = base[:i]
}
return ensureDot(base)
}