build / build-push (push) Successful in 29s
Local zones were only editable as mounted files, so operators could not manage DNS entries from the console. Add a Local zones editor backed by a shared Kubernetes ConfigMap, validate master files before saving, scope the pod service account to that ConfigMap, and reload changed zones on every replica while flushing stale cache entries.
670 lines
21 KiB
Go
670 lines
21 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"
|
|
"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
|
|
}
|