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

+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)
}
}