The console could not see local zones at all. `blocklist.local_zones` compiled a store that nothing read back: there was no API for a zone, its records, its delegations or the file a record came from, and no way to change one without editing YAML and reloading. The Domains page is the blocklist's inline rules, not local data, so nothing in the console covered the half of the server that answers authoritatively from disk. There is now a Zones page and the endpoints behind it: `GET /api/zones`, `POST`/`PUT`/`DELETE /api/zones/records`, `POST`/`DELETE /api/zones/delegations` and `DELETE /api/zones?zone=`. **The console owns one directory and nothing else.** `blocklist.managed_zones` holds one `<apex>.zone` per zone, with that zone's SOA, apex NS set and the records added there, and it is compiled by the same `localzone.Load` as the operator's files — so a record added from the console is served on the next query, with no reload and no restart. Files in `blocklist.local_zones` are listed and shown read-only and are never rewritten; the key defaults to empty, so a deployment that never sets it gets a zone browser and no zone writes. Which file a record came from is now part of the store (`zone.files`, keyed by the index the store already uses), so the snapshot can say "console" or "your file" per record; the query path does not read it. **The writer proves its own output.** A record is validated by the project's own master-file parser, so `www` means `www.<zone>` and a bare CNAME target is completed exactly as it would be in a zone file; the file is rendered, and the rendering is re-parsed and compared with the records it meant to write before the file is renamed into place. A writer bug that dropped a record would otherwise present as a zone that silently stopped answering. A zone that cannot answer is refused rather than created: no SOA means no NXDOMAIN or NODATA authority, and no apex NS means nothing is delegated and no server is named. The SOA's primary follows the apex NS set — a zone whose SOA names a server outside its own NS set hands out a referral no resolver can follow — and the serial moves on every write. **A delegation takes the names under it, so a record below a cut is refused.** The store answers those names as delegated without reading the zone file, which would make the record data that looks alive and never answers. `DELETE /api/zones?zone=` exists for the same reason from the other side: a zone the console created is a real zone, so it says NXDOMAIN for what it does not hold, and deleting a record is not what gives a name back to upstream. It is refused for a zone that also has records outside the managed directory, because removing that file would remove none of the zone. **Two bugs the work turned up.** `engine.applyConfig` published local-record metrics only when `blocklist.local_zones` was non-empty, so a zone the console created was served but never counted; the gate is now the store's own record count. And the record-value renderer fell back to `RR.String()` for every type it did not know, which is the whole line rather than the RDATA — it wrote a name and a TTL into the value column for DHCID, LOC, SSHFP and SVCB, and would have fed that back as a record value on the next edit. Every write recompiles the store and flushes the cache. Which names changed is not knowable from the API, and a cached upstream answer for a name the zone now answers is simply wrong, so the flush is blunt on purpose. The wire shapes are type aliases in `internal/api` rather than copies, so there is one set of json tags, and `TestZoneEndpointKeySets` pins the key set at every level with a record and a delegation actually present — an empty list only proves the envelope. `TestZoneWritesNeedAManagedDirectory` pins the read-only default, and `TestZoneConsoleRoundTrip` walks the whole thing: a record added through the API is answered authoritatively on the next query, a cut stops the parent answering below it, and deleting the zone hands the name back. Verified against a running instance built from this tree: records and wildcards answer with `aa`, relative names are qualified, a record below a cut is refused with a message that says why, and the file on disk is a zone file an operator can read. The console page is typechecked and built; the browser was not available in this round, so it has not been looked at.
694 lines
23 KiB
Go
694 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)
|
|
// 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
|
|
// 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("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
|
|
}
|