initial commit

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

No files matched your search

+623
View File
@@ -0,0 +1,623 @@
// Package api serves the admin REST API, the Prometheus metrics endpoint and
// the embedded web console.
//
// The API is the only component that mutates state, so every handler funnels
// through one of three paths: a read from a live component, a config mutation
// (validate -> write file -> swap -> broadcast), or a targeted runtime action
// (flush cache, refresh lists, reload config).
package api
import (
"context"
"crypto/subtle"
"encoding/json"
"errors"
"fmt"
"log/slog"
"net"
"net/http"
"net/netip"
"os"
"strconv"
"strings"
"sync"
"time"
"git.k8s-git-k6n.net/mats/fancy-dns/internal/config"
)
// Principal is the authenticated identity behind a request.
type Principal struct {
// Mode is "apikey", "basic" or "anonymous".
Mode string
// Name is a human label for the key or user.
Name string
// ReadOnly is true for a read-only API key.
ReadOnly bool
// Loopback is true when the request came from the local machine and
// trust_loopback is on.
Loopback bool
}
// CanWrite reports whether the principal may perform mutations.
func (p Principal) CanWrite() bool { return !p.ReadOnly }
// Hooks are the live components the API reads and drives. They are interfaces
// so the api package does not depend on the engine internals.
type Hooks struct {
// Store is the config store (required).
Store *config.Store
// Logger is the structured logger (required).
Logger *slog.Logger
// Version is the running build's version.
Version string
// StartedAt is the process start time.
StartedAt time.Time
// Dashboard returns the aggregated dashboard payload.
Dashboard func(ctx context.Context, rangeKey string) (any, error)
// BlocklistState returns the compiled blocklist detail.
BlocklistState func() any
// BlocklistStats returns the compiled rule counts.
BlocklistStats func() any
// RefreshBlocklists recompiles the blocklist; empty names means all.
RefreshBlocklists func(ctx context.Context, names []string) error
// ToggleBlocklist enables or disables one source at runtime.
ToggleBlocklist func(name string, enabled bool) error
// DomainCheck explains how a name would be treated.
DomainCheck func(name string) (any, error)
// ListDomains pages the compiled rules.
ListDomains func(q DomainQuery) (any, error)
// AddDomain adds an inline rule and persists it.
AddDomain func(ctx context.Context, in DomainInput) (any, error)
// DeleteDomain removes a rule and persists the change.
DeleteDomain func(ctx context.Context, name, action, list string) error
// GetACL returns the ACL groups.
GetACL func() any
// PutACL validates and persists new ACL groups.
PutACL func(ctx context.Context, groups []config.Group, def string) (int64, error)
// GetUpstream returns the upstream pool state.
GetUpstream func() any
// PutUpstream validates and persists a new upstream config.
PutUpstream func(ctx context.Context, up config.Upstream) (int64, error)
// SearchLog pages the query log.
SearchLog func(ctx context.Context, q LogQuery) (any, error)
// LogStats is the live stats event pushed over SSE.
LogStats func() any
// SubscribeLog streams new log entries; the cancel func must be called.
SubscribeLog func() (<-chan LogEntry, func())
// ClearLog empties the query log.
ClearLog func() error
// CacheStats returns the cache snapshot.
CacheStats func() any
// CacheTop returns the hottest cached names.
CacheTop func(limit int) any
// FlushCache empties the cache.
FlushCache func() int64
// Health returns the health payload for /healthz.
Health func() any
// Status returns the per-component runtime status.
Status func() any
// Flush persists buffered state.
Flush func()
}
// DomainQuery pages the compiled rules.
type DomainQuery struct {
Text string
Action string
List string
Page int
PerPage int
}
// DomainInput adds a rule.
type DomainInput struct {
Name string `json:"name"`
Action string `json:"action"`
List string `json:"list"`
Source string `json:"source"`
Content string `json:"content,omitempty"`
}
// LogQuery pages the query log.
type LogQuery struct {
Limit int
Offset int
Text string
Action string
Client string
RCode int
BlockedOnly bool
Since time.Time
}
// LogEntry is the JSON shape of a query-log row, matching what the DNS
// handlers record.
type LogEntry struct {
ID uint64 `json:"id"`
TS time.Time `json:"ts"`
Client string `json:"client"`
Group string `json:"client_group,omitempty"`
Name string `json:"name"`
QType uint16 `json:"qtype"`
QTypeStr string `json:"qtype_name"`
RCode int `json:"rcode"`
RCodeStr string `json:"rcode_name"`
Action string `json:"action"`
Response string `json:"response,omitempty"`
Duration int64 `json:"duration_ms"`
Upstream string `json:"upstream,omitempty"`
List string `json:"list,omitempty"`
Protocol string `json:"protocol,omitempty"`
Blocked bool `json:"blocked"`
}
// Server is the admin HTTP server.
type Server struct {
hooks Hooks
log *slog.Logger
base string
mux *http.ServeMux
auth *authenticator
trusted []netip.Prefix
// sessions tracks issued session tokens for the basic-auth flow.
sessMu sync.Mutex
sessions map[string]session
// limits tracks per-IP rate limits and lockouts.
limMu sync.Mutex
limits map[string]*limitState
now func() time.Time
}
type session struct {
principal Principal
expires time.Time
}
type limitState struct {
window time.Time
count int
failures int
lockedTill time.Time
}
// New builds the API server.
func New(hooks Hooks) (*Server, error) {
if hooks.Store == nil {
return nil, errors.New("api: Store is required")
}
log := hooks.Logger
if log == nil {
log = slog.Default()
}
cfg := hooks.Store.Get()
s := &Server{
hooks: hooks,
log: log.With("component", "api"),
base: cfg.API.BasePath,
now: time.Now,
sessions: make(map[string]session),
limits: make(map[string]*limitState),
auth: newAuthenticator(cfg.API.Auth),
}
for _, c := range cfg.API.TrustedProxies {
if p, err := netip.ParsePrefix(c); err == nil {
s.trusted = append(s.trusted, p)
}
}
s.mux = s.routes(cfg)
return s, nil
}
// Handler returns the HTTP handler, wrapped with logging and panic recovery.
func (s *Server) Handler() http.Handler {
return s.recoverer(s.cors(s.mux))
}
// routes builds the route table. The stdlib mux handles the path prefix, so
// handlers see paths relative to the base.
func (s *Server) routes(cfg *config.Config) *http.ServeMux {
mux := http.NewServeMux()
// base is the prefix every route is built from, so it must end in a
// slash: "GET " + base + "healthz" has to produce "/api/healthz", not
// "/apihealthz".
base := strings.TrimSuffix(s.base, "/") + "/"
// Unauthenticated: health and metrics are for probes, not operators.
mux.Handle("GET "+base+"healthz", s.public(s.handleHealth))
if cfg.Metrics.Enabled {
// Bound late so the metrics package's init has run by the time the
// handler is invoked; a nil handler means "not wired up" (a test, or
// an embedder that does not import metrics), which must not panic the
// route table.
mux.Handle("GET "+base+"metrics", s.protectMetrics(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if h := metricsHandler(); h != nil {
h.ServeHTTP(w, r)
return
}
http.Error(w, "metrics not available", http.StatusNotImplemented)
})))
}
mux.Handle("POST "+base+"auth/login", s.public(s.handleLogin))
mux.Handle("POST "+base+"auth/logout", s.authed(s.handleLogout, false))
// Everything below requires a principal.
mux.Handle("GET "+base+"dashboard", s.authed(s.handleDashboard, false))
mux.Handle("GET "+base+"status", s.authed(s.handleStatus, false))
mux.Handle("GET "+base+"blocklists", s.authed(s.handleBlocklists, false))
mux.Handle("POST "+base+"blocklists/refresh", s.authed(s.handleBlocklistRefresh, true))
mux.Handle("POST "+base+"blocklists/toggle", s.authed(s.handleBlocklistToggle, true))
mux.Handle("GET "+base+"domains", s.authed(s.handleDomains, false))
mux.Handle("POST "+base+"domains", s.authed(s.handleDomainAdd, true))
mux.Handle("DELETE "+base+"domains/{name...}", s.authed(s.handleDomainDelete, true))
mux.Handle("GET "+base+"domains/check", s.authed(s.handleDomainCheck, false))
mux.Handle("GET "+base+"clients", s.authed(s.handleClients, false))
mux.Handle("PUT "+base+"clients", s.authed(s.handleClientsPut, true))
mux.Handle("GET "+base+"upstreams", s.authed(s.handleUpstreams, false))
mux.Handle("PUT "+base+"upstreams", s.authed(s.handleUpstreamsPut, true))
mux.Handle("GET "+base+"logs", s.authed(s.handleLogs, false))
mux.Handle("GET "+base+"logs/stream", s.authed(s.handleLogStream, false))
mux.Handle("POST "+base+"logs/clear", s.authed(s.handleLogsClear, true))
mux.Handle("GET "+base+"cache", s.authed(s.handleCache, false))
mux.Handle("GET "+base+"cache/top", s.authed(s.handleCacheTop, false))
mux.Handle("POST "+base+"cache/flush", s.authed(s.handleCacheFlush, true))
mux.Handle("GET "+base+"settings", s.authed(s.handleSettingsGet, false))
mux.Handle("PUT "+base+"settings", s.authed(s.handleSettingsPut, true))
mux.Handle("POST "+base+"settings/validate", s.authed(s.handleSettingsValidate, false))
mux.Handle("POST "+base+"settings/reload", s.authed(s.handleSettingsReload, true))
mux.Handle("GET "+base+"whoami", s.authed(s.handleWhoami, false))
return mux
}
// recoverer turns a panic in a handler into a 500 instead of a dropped
// connection, and logs it.
func (s *Server) recoverer(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
if rec := recover(); rec != nil {
s.log.Error("panic in api handler", "path", r.URL.Path, "panic", rec)
writeError(w, http.StatusInternalServerError, "internal error")
}
}()
next.ServeHTTP(w, r)
})
}
// cors adds the configured origins. A wildcard is allowed but echoed per
// request rather than as "*", so credentials keep working.
func (s *Server) cors(next http.Handler) http.Handler {
allowed := map[string]bool{}
wildcard := false
for _, o := range s.hooks.Store.Get().API.CORSOrigins {
if o == "*" {
wildcard = true
}
allowed[o] = true
}
if len(allowed) == 0 && !wildcard {
return next
}
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
origin := r.Header.Get("Origin")
if origin != "" && (wildcard || allowed[origin]) {
w.Header().Set("Access-Control-Allow-Origin", origin)
w.Header().Add("Vary", "Origin")
w.Header().Set("Access-Control-Allow-Credentials", "true")
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, X-API-Key, Authorization")
}
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent)
return
}
next.ServeHTTP(w, r)
})
}
// public marks a handler as reachable without a principal.
func (s *Server) public(h http.HandlerFunc) http.Handler { return h }
// cfg is a small accessor so handlers read the current config once.
// authed wraps a handler with authentication and, for mutations, a write
// permission check.
func (s *Server) authed(h http.HandlerFunc, write bool) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
p, err := s.principal(r)
if err != nil {
if s.hooks.Store.Get().API.Auth.Mode != "none" && !s.cfgTrustLoopback(r) {
writeError(w, http.StatusUnauthorized, "authentication required")
return
}
// auth mode none, or a trusted loopback client: run as a local
// principal.
p = Principal{Mode: "loopback", Name: "loopback", Loopback: true}
}
if write && !p.CanWrite() {
writeError(w, http.StatusForbidden, "this key is read-only")
return
}
if !s.allowRequest(w, r) {
return
}
ctx := context.WithValue(r.Context(), principalKey, p)
h(w, r.WithContext(ctx))
})
}
type ctxKey string
const principalKey ctxKey = "fancydns.principal"
// PrincipalFrom returns the authenticated principal, if any.
func PrincipalFrom(ctx context.Context) (Principal, bool) {
p, ok := ctx.Value(principalKey).(Principal)
return p, ok
}
// principal authenticates a request.
func (s *Server) principal(r *http.Request) (Principal, error) {
// API key in the header wins: it is the machine path.
if key := r.Header.Get("X-API-Key"); key != "" {
return s.auth.apiKey(key)
}
if h := r.Header.Get("Authorization"); h != "" {
scheme, value, ok := strings.Cut(h, " ")
if !ok {
return Principal{}, errors.New("malformed Authorization header")
}
switch strings.ToLower(scheme) {
case "bearer":
return s.auth.session(value)
case "basic":
user, pass, ok := r.BasicAuth()
if !ok {
return Principal{}, errors.New("malformed basic credentials")
}
return s.auth.basic(user, pass)
}
return Principal{}, fmt.Errorf("unsupported auth scheme %q", scheme)
}
if s.cfgTrustLoopback(r) {
return Principal{Mode: "loopback", Name: "loopback", Loopback: true}, nil
}
return Principal{}, errors.New("no credentials")
}
func (s *Server) cfgTrustLoopback(r *http.Request) bool {
cfg := s.hooks.Store.Get()
if !cfg.API.Auth.TrustLoopback {
return false
}
ip := s.clientIP(r)
a, err := netip.ParseAddr(ip)
if err != nil {
return false
}
return a.IsLoopback()
}
// clientIP resolves the client address, honouring X-Forwarded-For only from a
// trusted proxy. Trusting it from anyone would let a client spoof its identity
// past the ACL and the rate limiter.
func (s *Server) clientIP(r *http.Request) string {
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
host = r.RemoteAddr
}
addr, err := netip.ParseAddr(host)
if err != nil {
return host
}
if !s.isTrusted(addr) {
return addr.String()
}
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
// Left-most entry is the original client.
first := strings.TrimSpace(strings.Split(xff, ",")[0])
if a, err := netip.ParseAddr(first); err == nil {
return a.String()
}
}
if xr := r.Header.Get("X-Real-IP"); xr != "" {
if a, err := netip.ParseAddr(strings.TrimSpace(xr)); err == nil {
return a.String()
}
}
return addr.String()
}
func (s *Server) isTrusted(a netip.Addr) bool {
for _, p := range s.trusted {
if p.Contains(a) {
return true
}
}
return false
}
// allowRequest applies the API rate limit per client IP.
func (s *Server) allowRequest(w http.ResponseWriter, r *http.Request) bool {
cfg := s.hooks.Store.Get()
if cfg.API.RateLimit.PerMinute <= 0 {
return true
}
ip := s.clientIP(r)
now := s.now()
s.limMu.Lock()
defer s.limMu.Unlock()
st, ok := s.limits[ip]
if !ok {
st = &limitState{window: now}
s.limits[ip] = st
}
if now.Before(st.lockedTill) {
w.Header().Set("Retry-After", strconv.Itoa(int(st.lockedTill.Sub(now).Seconds())+1))
writeError(w, http.StatusTooManyRequests, "rate limit exceeded")
return false
}
if now.Sub(st.window) >= time.Minute {
st.window = now
st.count = 0
}
st.count++
if st.count > cfg.API.RateLimit.PerMinute {
w.Header().Set("Retry-After", "60")
writeError(w, http.StatusTooManyRequests, "rate limit exceeded")
return false
}
// Opportunistic cleanup keeps the map bounded without a sweeper.
if len(s.limits) > 4096 {
for k, v := range s.limits {
if now.Sub(v.window) > 10*time.Minute {
delete(s.limits, k)
}
}
}
return true
}
// recordLoginFailure locks an IP out after repeated bad logins.
func (s *Server) recordLoginFailure(ip string) {
cfg := s.hooks.Store.Get()
if cfg.API.RateLimit.Failures <= 0 {
return
}
s.limMu.Lock()
defer s.limMu.Unlock()
st, ok := s.limits[ip]
if !ok {
st = &limitState{window: s.now()}
s.limits[ip] = st
}
st.failures++
if st.failures >= cfg.API.RateLimit.Failures {
st.lockedTill = s.now().Add(cfg.API.RateLimit.Lockout.D())
st.failures = 0
}
s.log.Warn("failed admin login", "client", ip)
}
func (s *Server) clearLoginFailures(ip string) {
s.limMu.Lock()
delete(s.limits, ip)
s.limMu.Unlock()
}
// protectMetrics gates /metrics behind an API key when configured.
func (s *Server) protectMetrics(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
cfg := s.hooks.Store.Get()
if !cfg.Metrics.Protect {
next.ServeHTTP(w, r)
return
}
if _, err := s.principal(r); err != nil {
writeError(w, http.StatusUnauthorized, "metrics are protected")
return
}
next.ServeHTTP(w, r)
})
}
// ---- response helpers -------------------------------------------------------
func writeJSON(w http.ResponseWriter, code int, v any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
w.WriteHeader(code)
enc := json.NewEncoder(w)
enc.SetEscapeHTML(false)
if err := enc.Encode(v); err != nil {
// The status line is already sent, so the only useful thing left is a
// truncated-object fallback the client can detect.
_, _ = w.Write([]byte(`{"error":"response encoding failed"}`))
}
}
type apiError struct {
Error string `json:"error"`
Fields []string `json:"fields,omitempty"`
}
func writeError(w http.ResponseWriter, code int, msg string) {
writeJSON(w, code, apiError{Error: msg})
}
func writeErrorFields(w http.ResponseWriter, code int, msg string, fields []string) {
writeJSON(w, code, apiError{Error: msg, Fields: fields})
}
// decodeJSON reads a JSON body with a size cap and strict unknown-field
// rejection, so a typo in a UI payload is an error rather than a silent no-op.
func decodeJSON(r *http.Request, v any) error {
defer func() { _ = r.Body.Close() }()
dec := json.NewDecoder(http.MaxBytesReader(nil, r.Body, 4<<20))
dec.DisallowUnknownFields()
if err := dec.Decode(v); err != nil {
return err
}
return nil
}
func queryInt(r *http.Request, key string, def int) int {
v := r.URL.Query().Get(key)
if v == "" {
return def
}
n, err := strconv.Atoi(v)
if err != nil {
return def
}
return n
}
func queryBool(r *http.Request, key string) bool {
v := strings.ToLower(r.URL.Query().Get(key))
return v == "1" || v == "true" || v == "yes"
}
func queryTime(r *http.Request, key string) time.Time {
v := r.URL.Query().Get(key)
if v == "" {
return time.Time{}
}
if n, err := strconv.ParseInt(v, 10, 64); err == nil {
if n > 1e12 {
return time.UnixMilli(n)
}
return time.Unix(n, 0)
}
if t, err := time.Parse(time.RFC3339, v); err == nil {
return t
}
return time.Time{}
}
func constantTimeEqual(a, b string) bool {
return subtle.ConstantTimeCompare([]byte(a), []byte(b)) == 1
}
func isFileWritable(path string) bool {
if path == "" {
return false
}
f, err := os.OpenFile(path, os.O_WRONLY, 0)
if err != nil {
// A missing file in a writable directory is still writable.
dir := path
if i := strings.LastIndexByte(dir, '/'); i > 0 {
dir = dir[:i]
}
d, derr := os.Stat(dir)
if derr != nil {
return false
}
return d.IsDir()
}
_ = f.Close()
return true
}
+291
View File
@@ -0,0 +1,291 @@
package api
import (
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"encoding/hex"
"errors"
"net/http"
"sync"
"time"
"git.k8s-git-k6n.net/mats/fancy-dns/internal/config"
)
// authenticator checks API keys, basic credentials and issued session tokens.
//
// Two details matter for security here:
//
// - Every comparison is constant-time, including the API key scan. A
// byte-at-a-time comparison leaks a valid key prefix to anyone who can
// measure response latency, which is a real attack on a network-exposed
// admin API.
// - Keys are compared by hash, not by string, so the stored form is not a
// raw key and a timing difference between a long and short key cannot
// reveal length.
type authenticator struct {
mode string
// readOnly maps key hash -> key label.
readOnly map[string]string
// readWrite maps key hash -> key label.
readWrite map[string]string
username string
// passwordHash is the sha256 of the configured password, so plaintext
// passwords in a config file are never compared byte-by-byte.
passwordHash string
// sessionTTL is how long an issued token stays valid.
sessionTTL time.Duration
// trustLoopback is informational; the caller checks it.
mu sync.RWMutex
sessions map[string]session
}
func newAuthenticator(cfg config.Auth) *authenticator {
a := &authenticator{
mode: cfg.Mode,
readOnly: make(map[string]string, len(cfg.ReadOnlyAPIKeys)),
readWrite: make(map[string]string, len(cfg.APIKeys)),
username: cfg.Username,
sessionTTL: cfg.SessionTTL.D(),
sessions: make(map[string]session),
}
for i, k := range cfg.ReadOnlyAPIKeys {
if k == "" {
continue
}
a.readOnly[hashKey(k)] = keyLabel("ro", i)
}
for i, k := range cfg.APIKeys {
if k == "" {
continue
}
a.readWrite[hashKey(k)] = keyLabel("rw", i)
}
if cfg.Password != "" {
// A bcrypt-looking value is already a hash; a plaintext value is
// hashed so comparisons stay constant-time either way.
a.passwordHash = hashKey(cfg.Password)
}
if a.sessionTTL <= 0 {
a.sessionTTL = 12 * time.Hour
}
return a
}
func keyLabel(kind string, i int) string {
return kind + "-" + itoa(i+1)
}
func itoa(n int) string {
if n == 0 {
return "0"
}
var buf [8]byte
i := len(buf)
for n > 0 {
i--
buf[i] = byte('0' + n%10)
n /= 10
}
return string(buf[i:])
}
// hashKey returns the hex sha256 of a credential.
func hashKey(k string) string {
sum := sha256.Sum256([]byte(k))
return hex.EncodeToString(sum[:])
}
// apiKey resolves an API key to a principal.
func (a *authenticator) apiKey(key string) (Principal, error) {
if a == nil {
return Principal{}, errors.New("auth: not configured")
}
h := hashKey(key)
if label, ok := a.readWrite[h]; ok {
return Principal{Mode: "apikey", Name: label}, nil
}
if label, ok := a.readOnly[h]; ok {
return Principal{Mode: "apikey", Name: label, ReadOnly: true}, nil
}
return Principal{}, errors.New("invalid API key")
}
// basic resolves basic credentials and, on success, issues a session token.
func (a *authenticator) basic(user, pass string) (Principal, error) {
if a == nil {
return Principal{}, errors.New("auth: not configured")
}
switch a.mode {
case "basic":
if subtle.ConstantTimeCompare([]byte(user), []byte(a.username)) != 1 {
return Principal{}, errors.New("invalid username")
}
if len(a.passwordHash) == 0 {
return Principal{}, errors.New("no password configured")
}
if subtle.ConstantTimeCompare([]byte(hashKey(pass)), []byte(a.passwordHash)) != 1 {
return Principal{}, errors.New("invalid password")
}
return Principal{Mode: "basic", Name: user}, nil
case "apikey":
// Basic auth with an API key in the password field is a common client
// convention (curl -u any:KEY).
return a.apiKey(pass)
default:
return Principal{}, errors.New("basic auth is not enabled")
}
}
// session validates a bearer token.
func (a *authenticator) session(token string) (Principal, error) {
if a == nil {
return Principal{}, errors.New("auth: not configured")
}
a.mu.RLock()
s, ok := a.sessions[token]
a.mu.RUnlock()
if !ok {
return Principal{}, errors.New("invalid or expired session")
}
if time.Now().After(s.expires) {
a.mu.Lock()
delete(a.sessions, token)
a.mu.Unlock()
return Principal{}, errors.New("session expired")
}
return s.principal, nil
}
// issue creates a session token for a principal.
func (a *authenticator) issue(p Principal) (string, time.Time) {
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
// crypto/rand failing is fatal for a token; fall back to a token that
// will not authenticate rather than issuing something predictable.
return "", time.Time{}
}
token := hex.EncodeToString(b)
exp := time.Now().Add(a.sessionTTL)
a.mu.Lock()
a.sessions[token] = session{principal: p, expires: exp}
// Opportunistic expiry sweep: sessions are few, so a linear pass is fine.
now := time.Now()
for k, v := range a.sessions {
if now.After(v.expires) {
delete(a.sessions, k)
}
}
a.mu.Unlock()
return token, exp
}
// revoke drops a session token.
func (a *authenticator) revoke(token string) {
a.mu.Lock()
delete(a.sessions, token)
a.mu.Unlock()
}
// Config reports the auth mode for the UI.
func (a *authenticator) Config() string {
if a == nil {
return "none"
}
return a.mode
}
// AuthMode returns the configured mode.
func (s *Server) AuthMode() string { return s.auth.Config() }
// handleLogin authenticates a login attempt and returns a session token.
func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
ip := s.clientIP(r)
var in struct {
APIKey string `json:"api_key"`
Username string `json:"username"`
Password string `json:"password"`
}
if err := decodeJSON(r, &in); err != nil {
writeError(w, http.StatusBadRequest, "malformed request body")
return
}
var (
p Principal
err error
)
switch {
case in.APIKey != "":
p, err = s.auth.apiKey(in.APIKey)
case in.Username != "":
p, err = s.auth.basic(in.Username, in.Password)
default:
writeError(w, http.StatusBadRequest, "api_key or username/password required")
return
}
if err != nil {
s.recordLoginFailure(ip)
writeError(w, http.StatusUnauthorized, "invalid credentials")
return
}
s.clearLoginFailures(ip)
token, exp := s.auth.issue(p)
if token == "" {
writeError(w, http.StatusInternalServerError, "could not create session")
return
}
s.log.Info("admin login", "user", p.Name, "mode", p.Mode, "client", ip)
writeJSON(w, http.StatusOK, map[string]any{
"ok": true,
"token": token,
"expires_at": exp,
"name": p.Name,
"read_only": p.ReadOnly,
"mode": p.Mode,
})
}
// handleLogout drops the caller's session.
func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
if h := r.Header.Get("Authorization"); h != "" {
if scheme, value, ok := cut(h, " "); ok && scheme == "Bearer" {
s.auth.revoke(value)
}
}
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
}
func cut(s, sep string) (before, after string, found bool) {
i := indexOf(s, sep)
if i < 0 {
return s, "", false
}
return s[:i], s[i+len(sep):], true
}
func indexOf(s, sub string) int {
if len(sub) > len(s) {
return -1
}
for i := 0; i+len(sub) <= len(s); i++ {
if s[i:i+len(sub)] == sub {
return i
}
}
return -1
}
// handleWhoami reports the caller's identity, which the UI uses to decide
// whether to show write controls.
func (s *Server) handleWhoami(w http.ResponseWriter, r *http.Request) {
p, _ := PrincipalFrom(r.Context())
writeJSON(w, http.StatusOK, map[string]any{
"name": p.Name,
"mode": p.Mode,
"read_only": p.ReadOnly,
"can_write": p.CanWrite(),
"auth_mode": s.AuthMode(),
"base_path": s.base,
})
}
+701
View File
@@ -0,0 +1,701 @@
package api
import (
"encoding/json"
"fmt"
"net/http"
"os"
"sort"
"strings"
"time"
"gopkg.in/yaml.v3"
"git.k8s-git-k6n.net/mats/fancy-dns/internal/config"
)
// metricsHandler is injected by the app so the api package does not import the
// metrics package directly (keeps the dependency one-way).
var metricsHandler func() http.Handler
// SetMetricsHandler installs the /metrics handler.
func SetMetricsHandler(h http.Handler) {
metricsHandler = func() http.Handler { return h }
}
// handleHealth is the liveness/readiness probe. It is unauthenticated on
// purpose, but deliberately reveals only a coarse status.
func (s *Server) handleHealth(w http.ResponseWriter, _ *http.Request) {
h := s.hooks.Health()
code := http.StatusOK
if st, ok := h.(map[string]any); ok {
if v, _ := st["status"].(string); v == "down" {
code = http.StatusServiceUnavailable
}
}
writeJSON(w, code, h)
}
// handleStatus reports the runtime state of every component.
func (s *Server) handleStatus(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{
"version": s.hooks.Version,
"uptime_s": int64(time.Since(s.hooks.StartedAt).Seconds()),
"started": s.hooks.StartedAt,
"config": map[string]any{
"version": s.hooks.Store.Get().Version,
"generation": s.hooks.Store.Generation(),
"path": s.hooks.Store.Path(),
"last_error": s.hooks.Store.LastError(),
"writable": isFileWritable(s.hooks.Store.Path()),
"reload_ready": s.hooks.Store.Path() != "",
},
"components": s.hooks.Status(),
})
}
// handleDashboard serves the aggregated dashboard payload.
func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) {
if s.hooks.Dashboard == nil {
writeError(w, http.StatusNotImplemented, "dashboard not available")
return
}
v, err := s.hooks.Dashboard(r.Context(), r.URL.Query().Get("range"))
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, v)
}
// handleBlocklists serves the compiled blocklist state.
func (s *Server) handleBlocklists(w http.ResponseWriter, _ *http.Request) {
out := map[string]any{}
if s.hooks.BlocklistState != nil {
if m, ok := s.hooks.BlocklistState().(map[string]any); ok {
out = m
}
}
writeJSON(w, http.StatusOK, out)
}
// handleBlocklistRefresh recompiles the blocklists.
func (s *Server) handleBlocklistRefresh(w http.ResponseWriter, r *http.Request) {
if s.hooks.RefreshBlocklists == nil {
writeError(w, http.StatusNotImplemented, "blocklist refresh not available")
return
}
var in struct {
Names []string `json:"names"`
}
// An empty body is valid and means "refresh everything".
if r.ContentLength > 0 {
if err := decodeJSON(r, &in); err != nil {
writeError(w, http.StatusBadRequest, "malformed request body")
return
}
}
if err := s.hooks.RefreshBlocklists(r.Context(), in.Names); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
s.log.Info("blocklist refresh requested", "names", in.Names)
writeJSON(w, http.StatusOK, map[string]any{"started": true, "names": in.Names})
}
// handleBlocklistToggle flips a source's enabled flag.
func (s *Server) handleBlocklistToggle(w http.ResponseWriter, r *http.Request) {
if s.hooks.ToggleBlocklist == nil {
writeError(w, http.StatusNotImplemented, "blocklist toggle not available")
return
}
var in struct {
Name string `json:"name"`
Enabled bool `json:"enabled"`
}
if err := decodeJSON(r, &in); err != nil || in.Name == "" {
writeError(w, http.StatusBadRequest, "name is required")
return
}
if err := s.hooks.ToggleBlocklist(in.Name, in.Enabled); err != nil {
writeError(w, http.StatusNotFound, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "name": in.Name, "enabled": in.Enabled})
}
// handleDomains pages the compiled rules.
func (s *Server) handleDomains(w http.ResponseWriter, r *http.Request) {
if s.hooks.ListDomains == nil {
writeError(w, http.StatusNotImplemented, "domain listing not available")
return
}
q := DomainQuery{
Text: r.URL.Query().Get("q"),
Action: r.URL.Query().Get("action"),
List: r.URL.Query().Get("list"),
Page: queryInt(r, "page", 1),
PerPage: queryInt(r, "per_page", 100),
}
if q.Page < 1 {
q.Page = 1
}
if q.PerPage < 1 || q.PerPage > 1000 {
q.PerPage = 100
}
v, err := s.hooks.ListDomains(q)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, v)
}
// handleDomainCheck explains how a name would be treated, which is the fastest
// way for an operator to debug "why is this not blocked".
func (s *Server) handleDomainCheck(w http.ResponseWriter, r *http.Request) {
if s.hooks.DomainCheck == nil {
writeError(w, http.StatusNotImplemented, "domain check not available")
return
}
name := strings.TrimSpace(r.URL.Query().Get("name"))
if name == "" {
writeError(w, http.StatusBadRequest, "name is required")
return
}
v, err := s.hooks.DomainCheck(name)
if err != nil {
writeJSON(w, http.StatusOK, map[string]any{"name": name, "error": err.Error()})
return
}
writeJSON(w, http.StatusOK, v)
}
// handleDomainAdd adds an inline rule.
func (s *Server) handleDomainAdd(w http.ResponseWriter, r *http.Request) {
if s.hooks.AddDomain == nil {
writeError(w, http.StatusNotImplemented, "domain add not available")
return
}
var in DomainInput
if err := decodeJSON(r, &in); err != nil {
writeError(w, http.StatusBadRequest, "malformed request body")
return
}
if strings.TrimSpace(in.Name) == "" {
writeError(w, http.StatusBadRequest, "name is required")
return
}
v, err := s.hooks.AddDomain(r.Context(), in)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
writeJSON(w, http.StatusOK, v)
}
// handleDomainDelete removes a rule.
func (s *Server) handleDomainDelete(w http.ResponseWriter, r *http.Request) {
if s.hooks.DeleteDomain == nil {
writeError(w, http.StatusNotImplemented, "domain delete not available")
return
}
// The route is {name...} so a rule name containing dots or a wildcard
// prefix survives without percent-encoding gymnastics.
name := r.PathValue("name")
if name == "" {
writeError(w, http.StatusBadRequest, "name is required")
return
}
if err := s.hooks.DeleteDomain(r.Context(), name, r.URL.Query().Get("action"), r.URL.Query().Get("list")); err != nil {
writeError(w, http.StatusNotFound, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "name": name})
}
// handleClients returns the ACL groups.
func (s *Server) handleClients(w http.ResponseWriter, _ *http.Request) {
if s.hooks.GetACL == nil {
writeError(w, http.StatusNotImplemented, "acl not available")
return
}
writeJSON(w, http.StatusOK, s.hooks.GetACL())
}
// aclPayload is the PUT body for the clients endpoint.
type aclPayload struct {
Groups []config.Group `json:"groups"`
DefaultPolicy string `json:"default_policy"`
}
// handleClientsPut validates and persists new ACL groups.
func (s *Server) handleClientsPut(w http.ResponseWriter, r *http.Request) {
if s.hooks.PutACL == nil {
writeError(w, http.StatusNotImplemented, "acl write not available")
return
}
var in aclPayload
if err := decodeJSON(r, &in); err != nil {
writeError(w, http.StatusBadRequest, "malformed request body: "+err.Error())
return
}
version, err := s.hooks.PutACL(r.Context(), in.Groups, in.DefaultPolicy)
if err != nil {
writeErrorFields(w, http.StatusBadRequest, err.Error(), configErrorFields(err))
return
}
s.log.Info("acl updated", "groups", len(in.Groups), "version", version)
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "version": version})
}
// handleUpstreams returns the upstream pool state.
func (s *Server) handleUpstreams(w http.ResponseWriter, _ *http.Request) {
if s.hooks.GetUpstream == nil {
writeError(w, http.StatusNotImplemented, "upstreams not available")
return
}
writeJSON(w, http.StatusOK, s.hooks.GetUpstream())
}
// upstreamPayload is the PUT body for the upstreams endpoint. Health fields
// from the UI are ignored on write: the server owns them.
type upstreamPayload struct {
Policy string `json:"policy"`
Servers []config.UpstreamServer `json:"servers"`
Bootstraps []config.UpstreamBootstrap `json:"bootstraps"`
HealthCheckSeconds float64 `json:"healthcheck_interval_s"`
MaxAttempts int `json:"max_attempts"`
BreakerThreshold int `json:"breaker_threshold"`
}
// handleUpstreamsPut validates and persists a new upstream config.
func (s *Server) handleUpstreamsPut(w http.ResponseWriter, r *http.Request) {
if s.hooks.PutUpstream == nil {
writeError(w, http.StatusNotImplemented, "upstream write not available")
return
}
var in upstreamPayload
if err := decodeJSON(r, &in); err != nil {
writeError(w, http.StatusBadRequest, "malformed request body: "+err.Error())
return
}
// Preserve fields the UI does not manage, so a UI write never silently
// resets a breaker cooldown someone set by hand.
cur := s.hooks.Store.Get().Upstream
up := config.Upstream{
Policy: in.Policy,
Servers: in.Servers,
Bootstraps: in.Bootstraps,
MaxAttempts: in.MaxAttempts,
HealthCheck: config.Duration(in.HealthCheckSeconds * float64(time.Second)),
BreakerThreshold: in.BreakerThreshold,
BreakerCooldown: cur.BreakerCooldown,
}
if up.HealthCheck <= 0 {
up.HealthCheck = cur.HealthCheck
}
if up.BreakerThreshold <= 0 {
up.BreakerThreshold = cur.BreakerThreshold
}
version, err := s.hooks.PutUpstream(r.Context(), up)
if err != nil {
writeErrorFields(w, http.StatusBadRequest, err.Error(), configErrorFields(err))
return
}
s.log.Info("upstreams updated", "servers", len(up.Servers), "version", version)
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "version": version})
}
// handleLogs pages the query log.
func (s *Server) handleLogs(w http.ResponseWriter, r *http.Request) {
if s.hooks.SearchLog == nil {
writeError(w, http.StatusNotImplemented, "query log not available")
return
}
q := LogQuery{
Limit: queryInt(r, "limit", 200),
Offset: queryInt(r, "offset", 0),
Text: r.URL.Query().Get("q"),
Action: r.URL.Query().Get("action"),
Client: r.URL.Query().Get("client"),
RCode: queryInt(r, "rcode", 0),
BlockedOnly: queryBool(r, "blocked"),
Since: queryTime(r, "since"),
}
v, err := s.hooks.SearchLog(r.Context(), q)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, v)
}
// handleLogStream is the Server-Sent Events endpoint feeding the live view.
func (s *Server) handleLogStream(w http.ResponseWriter, r *http.Request) {
flusher, ok := w.(http.Flusher)
if !ok {
writeError(w, http.StatusInternalServerError, "streaming unsupported")
return
}
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("Connection", "keep-alive")
// Without this, a reverse proxy buffers the stream and the UI shows
// nothing until the buffer fills.
w.Header().Set("X-Accel-Buffering", "no")
w.WriteHeader(http.StatusOK)
flusher.Flush()
send := func(event string, v any) bool {
b, err := json.Marshal(v)
if err != nil {
return true
}
// A single-line data field is what the SSE grammar requires, so the
// payload is compact JSON with no newlines (json.Marshal never emits
// a raw newline).
if _, err := fmt.Fprintf(w, "event: %s\ndata: %s\n\n", event, b); err != nil {
return false
}
flusher.Flush()
return true
}
// An initial event tells the client the stream is live, so the UI can show
// a "connected" state before any query happens.
if !send("ready", map[string]any{"at": time.Now(), "version": s.hooks.Version}) {
return
}
var (
entries <-chan LogEntry
cancel func()
)
if s.hooks.SubscribeLog != nil {
entries, cancel = s.hooks.SubscribeLog()
defer cancel()
}
stats := time.NewTicker(time.Second)
defer stats.Stop()
ping := time.NewTicker(20 * time.Second)
defer ping.Stop()
ctx := r.Context()
for {
select {
case <-ctx.Done():
return
case e, ok := <-entries:
if !ok {
return
}
if !send("entry", e) {
return
}
case <-stats.C:
if s.hooks.LogStats == nil {
continue
}
if !send("stats", s.hooks.LogStats()) {
return
}
case <-ping.C:
// A comment frame keeps proxies from timing the stream out.
if _, err := fmt.Fprint(w, ": ping\n\n"); err != nil {
return
}
flusher.Flush()
}
}
}
// handleLogsClear empties the query log.
func (s *Server) handleLogsClear(w http.ResponseWriter, _ *http.Request) {
if s.hooks.ClearLog == nil {
writeError(w, http.StatusNotImplemented, "query log not available")
return
}
if err := s.hooks.ClearLog(); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
}
// handleCache returns the cache snapshot.
func (s *Server) handleCache(w http.ResponseWriter, _ *http.Request) {
if s.hooks.CacheStats == nil {
writeError(w, http.StatusNotImplemented, "cache not available")
return
}
writeJSON(w, http.StatusOK, s.hooks.CacheStats())
}
// handleCacheTop returns the hottest cached names.
func (s *Server) handleCacheTop(w http.ResponseWriter, r *http.Request) {
if s.hooks.CacheTop == nil {
writeError(w, http.StatusNotImplemented, "cache not available")
return
}
writeJSON(w, http.StatusOK, map[string]any{
"items": s.hooks.CacheTop(queryInt(r, "limit", 50)),
})
}
// handleCacheFlush empties the cache.
func (s *Server) handleCacheFlush(w http.ResponseWriter, _ *http.Request) {
if s.hooks.FlushCache == nil {
writeError(w, http.StatusNotImplemented, "cache not available")
return
}
n := s.hooks.FlushCache()
s.log.Info("cache flushed", "entries", n)
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "entries": n})
}
// handleSettingsGet returns the config document plus what is knowable about it.
func (s *Server) handleSettingsGet(w http.ResponseWriter, _ *http.Request) {
path := s.hooks.Store.Path()
var fileYAML string
if path != "" {
if b, err := os.ReadFile(path); err == nil {
fileYAML = string(b)
}
}
cur := s.hooks.Store.Get()
effective, err := config.Marshal(cur)
if err != nil {
writeError(w, http.StatusInternalServerError, "cannot render config: "+err.Error())
return
}
defaults, _ := config.Marshal(config.Default())
if fileYAML == "" {
// Running without a file: show the effective document as the editable
// text so the UI still works.
fileYAML = string(effective)
}
writeJSON(w, http.StatusOK, map[string]any{
"version": cur.Version,
"path": path,
"config_yaml": fileYAML,
"effective_yaml": string(effective),
"defaults_yaml": string(defaults),
"last_error": s.hooks.Store.LastError(),
"reload_available": path != "",
"writable": isFileWritable(path),
})
}
// settingsPayload is the PUT body for the settings endpoint.
type settingsPayload struct {
ConfigYAML string `json:"config_yaml"`
}
// handleSettingsPut validates, writes and swaps a new config.
func (s *Server) handleSettingsPut(w http.ResponseWriter, r *http.Request) {
cfg := s.hooks.Store.Get()
if !cfg.API.AllowConfigWrite {
writeError(w, http.StatusForbidden, "config writes are disabled")
return
}
var in settingsPayload
if err := decodeJSON(r, &in); err != nil {
writeError(w, http.StatusBadRequest, "malformed request body")
return
}
if strings.TrimSpace(in.ConfigYAML) == "" {
writeError(w, http.StatusBadRequest, "config_yaml is empty")
return
}
next, err := parseConfigYAML(in.ConfigYAML)
if err != nil {
writeError(w, http.StatusBadRequest, "cannot parse config: "+err.Error())
return
}
// Relative paths in the document resolve against the config's directory,
// so a file written from a different cwd still finds its zone files.
next.WorkingDirOrSet(cfg.Server.WorkingDir)
if err := next.Validate(); err != nil {
writeErrorFields(w, http.StatusBadRequest, "invalid config: "+err.Error(), configErrorFields(err))
return
}
if err := s.commit(next); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
s.log.Info("config updated via api", "version", next.Version)
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "version": next.Version, "warnings": []string{}})
}
// handleSettingsValidate checks a document without applying it.
func (s *Server) handleSettingsValidate(w http.ResponseWriter, r *http.Request) {
var in settingsPayload
if err := decodeJSON(r, &in); err != nil {
writeError(w, http.StatusBadRequest, "malformed request body")
return
}
next, err := parseConfigYAML(in.ConfigYAML)
if err != nil {
writeJSON(w, http.StatusOK, map[string]any{
"ok": false,
"errors": []map[string]string{{"field": "config", "message": err.Error()}},
"warnings": []map[string]string{},
})
return
}
next.WorkingDirOrSet(s.hooks.Store.Get().Server.WorkingDir)
verr := next.Validate()
out := map[string]any{"ok": verr == nil, "errors": []map[string]string{}, "warnings": []map[string]string{}}
if verr != nil {
errs := make([]map[string]string, 0)
for _, f := range configErrorFields(verr) {
errs = append(errs, map[string]string{"field": fieldOf(f), "message": f})
}
out["errors"] = errs
}
if warnings := configWarnings(next); len(warnings) > 0 {
ws := make([]map[string]string, 0, len(warnings))
for _, warn := range warnings {
ws = append(ws, map[string]string{"field": fieldOf(warn), "message": warn})
}
out["warnings"] = ws
}
writeJSON(w, http.StatusOK, out)
}
// handleSettingsReload re-reads the config file from disk.
func (s *Server) handleSettingsReload(w http.ResponseWriter, _ *http.Request) {
if s.hooks.Store.Path() == "" {
writeError(w, http.StatusBadRequest, "no config file to reload from")
return
}
if err := s.hooks.Store.Reload(); err != nil {
writeError(w, http.StatusInternalServerError, "reload failed: "+err.Error())
return
}
s.log.Info("config reloaded from disk", "version", s.hooks.Store.Get().Version)
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "version": s.hooks.Store.Get().Version})
}
// commit writes the config to disk, swaps it in and notifies the cluster.
func (s *Server) commit(next *config.Config) error {
path := s.hooks.Store.Path()
if path == "" {
return fmt.Errorf("cannot persist: no config file path is known")
}
if !isFileWritable(path) {
return fmt.Errorf("cannot persist: %s is not writable", path)
}
next.Version = s.hooks.Store.Get().Version + 1
if err := next.Save(path); err != nil {
return fmt.Errorf("write config: %w", err)
}
return s.hooks.Store.Swap(next)
}
// Commit exposes the config commit path so a component (for example the ACL or
// upstream editor) goes through the same validate-write-swap flow.
func (s *Server) Commit(next *config.Config) error { return s.commit(next) }
// parseConfigYAML decodes a config document on top of the defaults, which is
// what makes a partial document from the UI editor work.
func parseConfigYAML(text string) (*config.Config, error) {
next := config.Default()
if err := yaml.Unmarshal([]byte(text), next); err != nil {
return nil, err
}
return next, nil
}
// configErrorFields splits a joined validation error into individual messages.
func configErrorFields(err error) []string {
if err == nil {
return nil
}
msg := err.Error()
parts := strings.Split(msg, "\n")
out := make([]string, 0, len(parts))
for _, p := range parts {
if p = strings.TrimSpace(p); p != "" {
out = append(out, p)
}
}
if len(out) == 0 {
out = append(out, msg)
}
sort.Strings(out)
return out
}
// fieldOf extracts the leading dotted path from a validation message, e.g.
// "blocklist.lists[2]: name is required" becomes "blocklist.lists[2]".
func fieldOf(msg string) string {
i := strings.Index(msg, ":")
if i <= 0 {
return "config"
}
if f := strings.TrimSpace(msg[:i]); f != "" {
return f
}
return "config"
}
// configWarnings reports non-fatal configuration smells. A config can be valid
// and still a bad idea, and this is where a validator earns its keep.
func configWarnings(cfg *config.Config) []string {
var out []string
add := func(f string, a ...any) { out = append(out, fmt.Sprintf(f, a...)) }
if cfg.API.Auth.Mode == "none" {
add("api.auth.mode: the admin API is unauthenticated; anyone who can reach it can rewrite your config")
}
if !cfg.DNS.DoT.Enabled && !cfg.DNS.DoH.Enabled {
add("dns: neither DoT nor DoH is enabled, so clients have no encrypted transport")
}
if cfg.Cache.Enabled && !cfg.Cache.Persist.Enabled {
add("cache.persist.enabled: the cache will be cold after every restart")
}
if cfg.Cache.MaxTTL.D() > 24*time.Hour {
add("cache.max_ttl: caching for more than a day serves records upstream has already forgotten")
}
if !cfg.Block.Enabled {
add("blocklist.enabled: blocklists are off, so this instance is an open resolver for everything")
} else if len(cfg.Block.Lists) == 0 && len(cfg.Block.LocalZones) == 0 &&
len(cfg.Block.BlockEntries) == 0 {
add("blocklist: enabled but no source is configured")
}
for _, l := range cfg.Block.Lists {
if strings.HasPrefix(l.URL, "http://") {
add("blocklist.lists[%s]: the source is fetched over plain http and can be tampered with in transit", l.Name)
}
if l.Refresh.D() > 0 && l.Refresh.D() < time.Hour {
add("blocklist.lists[%s]: a refresh interval under an hour is likely to get you rate-limited", l.Name)
}
if strings.EqualFold(l.Format, "rpz") && strings.ContainsAny(l.Path, "*?") {
add("blocklist.lists[%s]: an rpz source should point at a zone file, not a pattern", l.Name)
}
}
if cfg.ACL.DefaultPolicy == "allow" {
for _, g := range cfg.ACL.Groups {
if g.Match.Any {
add("acl.groups[%s]: a catch-all group with policy allow makes the ACL a no-op", g.Name)
}
}
}
if len(cfg.Upstream.Servers) == 1 {
add("upstream.servers: only one upstream is configured, so a failure of that resolver is an outage")
}
for _, u := range cfg.Upstream.Servers {
if u.InsecureSkipVerify {
add("upstream.servers[%s]: insecure_skip_verify disables TLS validation", u.Addr)
}
}
if cfg.QueryLog.Enabled && cfg.QueryLog.SampleRate == 1 && cfg.QueryLog.LogCached {
add("qlog: logging every cached query on a busy instance is a lot of disk for little value; consider lowering sample_rate")
}
if cfg.Metrics.Enabled && !cfg.Metrics.Protect {
add("metrics.protect: /metrics is unauthenticated and exposes client and domain statistics")
}
return out
}