Files
fancy-dns/internal/api/api.go
T
2026-09-27 11:20:34 +00:00

624 lines
19 KiB
Go

// 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
}