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