// 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" "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", "oidc" 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 // LocalZones lists, saves and deletes UI-managed DNS master files. LocalZones func(ctx context.Context) ([]ZoneFile, error) SaveLocalZone func(ctx context.Context, name, content string) error DeleteLocalZone func(ctx context.Context, name 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"` } // ZoneFile is a UI-managed authoritative master file. type ZoneFile struct { Name string `json:"name"` Content string `json:"content"` } // 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 one query-log row as the admin API serves it. // // These fields are not omitempty, and that is deliberate: omitempty makes the // key set depend on the row, so a query with no ACL group arrived without // `client_group` and the console rendered "undefined" where the column should // be empty. The contract test in internal/app pins the key set, and it can // only pin one shape if the server emits one. type LogEntry struct { ID uint64 `json:"id"` TS time.Time `json:"ts"` Client string `json:"client"` Group string `json:"client_group"` 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"` Duration int64 `json:"duration_ms"` Upstream string `json:"upstream"` List string `json:"list"` Protocol string `json:"protocol"` 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.limited(s.handleLogin)) mux.Handle("GET "+base+"auth/config", s.public(s.handleAuthConfig)) 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+"local-zones", s.authed(s.handleLocalZones, false)) mux.Handle("PUT "+base+"local-zones/{name}", s.authed(s.handleLocalZonePut, true)) mux.Handle("DELETE "+base+"local-zones/{name}", s.authed(s.handleLocalZoneDelete, true)) 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 } // limited applies the per-IP rate limit to a handler that is reachable without // a principal. The login endpoint is the reason the limit exists: guessing // API keys is bounded by nothing but the attacker's patience, so the lockout // the login handler records has to be able to stop the *next* attempt. It did // not, because the limiter ran behind authed() and login is public, so N bad // keys gave N 401s and the lockout only locked the caller out of the rest of // the API. func (s *Server) limited(h http.HandlerFunc) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if !s.allowRequest(w, r) { return } h(w, r) }) } // 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": // Local sessions minted by /auth/login are 64-character hex // strings. OIDC access tokens are compact JWTs (three segments). // Keep key-based login sessions usable alongside OIDC for scripted // clients, while sending JWT-shaped credentials to the verifier. if s.auth.mode == "oidc" && strings.Count(value, ".") == 2 { return s.auth.oidcPrincipal(value) } 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() defer s.limMu.Unlock() st, ok := s.limits[ip] if !ok { return } // Only the auth-failure state is reset. Deleting the whole entry would // also reset the per-minute request window, so a single successful login // in the middle of a burst would hand the client a fresh budget — which // is exactly what an attacker holding one valid key wants. st.failures = 0 st.lockedTill = time.Time{} } // 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 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 }