705 lines
23 KiB
Go
705 lines
23 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)
|
|
// Resolve runs one query through the live pipeline and reports the
|
|
// outcome, so the console can test the server it is talking to.
|
|
Resolve func(ctx context.Context, name, qtype string) (any, error)
|
|
ResolveUpstream func(ctx context.Context, name, qtype, upstream 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
|
|
AddressOverrides func(ctx context.Context) (any, error)
|
|
SaveAddressOverride func(ctx context.Context, domain, address string) error
|
|
DeleteAddressOverride func(ctx context.Context, domain string) error
|
|
// ListZones returns the compiled local zones with their records and
|
|
// delegations, and whether the API may rewrite them.
|
|
ListZones func() (any, error)
|
|
// AddZoneRecord adds one record to a local zone and persists it.
|
|
AddZoneRecord func(ctx context.Context, in ZoneRecordInput) (any, error)
|
|
// PutZoneRecord replaces the whole RRset of one name and type.
|
|
PutZoneRecord func(ctx context.Context, in ZoneRecordInput) (any, error)
|
|
// DeleteZoneRecord removes a record, or its whole RRset when value is
|
|
// empty, and persists the change.
|
|
DeleteZoneRecord func(ctx context.Context, zone, name, rrtype, value string) error
|
|
// DeleteZone removes a zone the console owns, so its names go back to
|
|
// whatever answered for them before.
|
|
DeleteZone func(ctx context.Context, zone string) error
|
|
// AddZoneDelegation hands a name below an apex to other servers.
|
|
AddZoneDelegation func(ctx context.Context, in ZoneDelegationInput) (any, error)
|
|
// DeleteZoneDelegation removes a cut and persists the change.
|
|
DeleteZoneDelegation func(ctx context.Context, zone, 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("POST "+base+"dns/test", s.authed(s.handleDNSTest, false))
|
|
mux.Handle("GET "+base+"address-overrides", s.authed(s.handleAddressOverrides, false))
|
|
mux.Handle("PUT "+base+"address-overrides", s.authed(s.handleAddressOverridePut, true))
|
|
mux.Handle("DELETE "+base+"address-overrides/{domain}", s.authed(s.handleAddressOverrideDelete, true))
|
|
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+"zones", s.authed(s.handleZones, false))
|
|
mux.Handle("DELETE "+base+"zones", s.authed(s.handleZoneDelete, true))
|
|
mux.Handle("POST "+base+"zones/records", s.authed(s.handleZoneRecordAdd, true))
|
|
mux.Handle("PUT "+base+"zones/records", s.authed(s.handleZoneRecordPut, true))
|
|
mux.Handle("DELETE "+base+"zones/records", s.authed(s.handleZoneRecordDelete, true))
|
|
mux.Handle("POST "+base+"zones/delegations", s.authed(s.handleZoneDelegationAdd, true))
|
|
mux.Handle("DELETE "+base+"zones/delegations", s.authed(s.handleZoneDelegationDelete, 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
|
|
}
|