The console's TypeScript types are hand-written and apiFetch ends in
`JSON.parse(text) as T`, so nothing in the toolchain compared them with the
Go structs. A field that lost its json tag, or a response a struct never had,
showed up as an empty table in a browser and nowhere else: the Go suite was
green and the console was wrong. internal/app/contract_test.go now asserts the
exact JSON key set of every admin endpoint, driven through the real router
with the real hook implementations, plus a test that drives the two write
endpoints with the exact payload web/src builds.
The drift it found, all four fixed here:
blocklist.Source and SourceMeta had no json tags at all, so GET /api/blocklists
sent Go field names ("URL"/"Format") where the console expects snake_case: the
Sources table rendered blank rows and its per-row toggle posted
`name: undefined`. Tagged to match ParseStats.
acl.GroupSummary was flat where the console reads a nested `match` object — the
same shape config.Group has — so every group card read "matches nothing". The
four match fields are nested now, under a new acl.Match.
PUT /api/clients answered 400 `json: unknown field "ptr_suffixes"` to the
console's payload, because config.Group and config.Match carried yaml tags only
and the decoder runs with DisallowUnknownFields. Both structs have json tags
now; the same applies to config.UpstreamServer and UpstreamBootstrap.
PUT /api/upstreams answered 400 `json: unknown field "health"`, because the
console round-trips the whole server object it read, runtime health fields
included. They are accepted and ignored, because the server owns them, and they
are `yaml:"-"` so settings.yaml does not gain runtime state.
Also fixed, each with a test:
A duration on the wire was a nanosecond count. config.Duration had a
MarshalYAML but no MarshalJSON, so bootstraps[].refresh arrived as 30000000000
while the console typed the field as a string, and healthcheck_interval was a
float of seconds in one place and a string in another. Durations on the wire
are duration strings everywhere now — the same spelling config.yaml uses — and a
bare JSON number is still accepted and means seconds, not nanoseconds.
TestDurationJSONForm used to pin the old behaviour and now pins the new one,
saying in its comment that the old behaviour was the bug.
A log row's key set depended on the row. api.LogEntry was omitempty on
client_group, response, upstream and list, so a query with no ACL group
arrived without the key and the console rendered "undefined" where the column
should be empty. The contract test can only pin one shape if the server emits
one, so nothing on that struct is omitempty any more.
A dead peer stayed in the cluster peer list forever. Cluster.seen was only ever
added to and deleted from on an explicit `bye`, so a node that crashed — or
whose Valkey connection died — was reported as a live peer for the life of the
process, and its stale blocklist digest was flagged as divergence forever.
Peers() now reaps peers unheard for peer_ttl*4 and stamps each state with the
receiver's own clock, so clock skew between two machines can neither reap a
healthy peer nor keep a dead one. Discover() ages out the same way on the
publisher's own last_seen stamp, because the keyed Valkey record outlives a
crash: a live node keeps refreshing the key's TTL, so a dead node's field stayed
readable for the life of the fleet.
peer_ttl: 0 would have crashed the process. time.NewTicker(PeerTTL/2) panics on
a non-positive period and `EXPIRE key 0` deletes the heartbeat key, so a config
that left peer_ttl unset would have taken the process down at Start, or made
every instance believe it is alone in the fleet. Both go through peerTTL() and
peerDeadline(), which fall back to the default.
Two cluster tests raced on the wire, not on the product, and one was
environment-dependent rather than wrong. A node that joined after another's
heartbeat was in flight hears about it on the next tick, so "b does not see a"
read immediately after "a sees b" is a coin flip; the divergence test waited for
a digest on the wrong node's view of the fleet; and 127.0.0.1:1 is only
unbindable outside a container that sets
net.ipv4.ip_unprivileged_port_start=0, where it binds and the partial-bind test
fails with both transports up. That test now occupies a port with a live
listener in the test process, which is a deterministic EADDRINUSE on every
machine and every uid.
internal/server/counters_test.go covers the counter-key layer that no other
test touched: the "<transport>.<field>" keyspace, that the aggregate folds only
the known fields, and that the snapshot is safe while counters move.
Two pinned behaviours changed on purpose, each with a test that says so:
An RPZ record did not cover the name it names. rpzRule gave a bare owner name
the subdomain form (*.name), so analytics.example.org. in a policy zone blocked
cdn.analytics.example.org. and left analytics.example.org. resolving normally —
and an rpz-passthru. record did not allowlist its own domain either. RPZ
semantics say the record applies to the domain and everything under it, so a
bare owner now takes the apex form **.name; an owner written with an explicit *.
is still strict, so a zone that wants subdomain-only behaviour can still ask for
it. This changes which queries get blocked for an existing policy zone, which is
why it wanted a decision. TestExampleRPZZoneCoversTheApexItNames pins both
halves and the example zone now says which half it relies on.
metrics.OutcomeLocal had no producer. The engine reported a local-zone answer as
qlog.ActionAllow, which outcomeFor mapped to OutcomeUpstream, so every locally
answered query was counted as an upstream one and the outcome="local" series was
never published — the dashboard's local column was permanently empty.
Response.Local is now set on the local-zone path and outcomeFor consults it
first. That moves published counters, so the README's metrics section says so
too.
649 lines
20 KiB
Go
649 lines
20 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" 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 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("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 }
|
|
|
|
// 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":
|
|
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
|
|
}
|