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