initial commit
This commit is contained in:
1 parent
5b935301e5
commit
85453852d5
105 files changed
+24846
No files matched your search
@@ -0,0 +1,623 @@
|
||||
// Package api serves the admin REST API, the Prometheus metrics endpoint and
|
||||
// the embedded web console.
|
||||
//
|
||||
// The API is the only component that mutates state, so every handler funnels
|
||||
// through one of three paths: a read from a live component, a config mutation
|
||||
// (validate -> write file -> swap -> broadcast), or a targeted runtime action
|
||||
// (flush cache, refresh lists, reload config).
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/subtle"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.k8s-git-k6n.net/mats/fancy-dns/internal/config"
|
||||
)
|
||||
|
||||
// Principal is the authenticated identity behind a request.
|
||||
type Principal struct {
|
||||
// Mode is "apikey", "basic" or "anonymous".
|
||||
Mode string
|
||||
// Name is a human label for the key or user.
|
||||
Name string
|
||||
// ReadOnly is true for a read-only API key.
|
||||
ReadOnly bool
|
||||
// Loopback is true when the request came from the local machine and
|
||||
// trust_loopback is on.
|
||||
Loopback bool
|
||||
}
|
||||
|
||||
// CanWrite reports whether the principal may perform mutations.
|
||||
func (p Principal) CanWrite() bool { return !p.ReadOnly }
|
||||
|
||||
// Hooks are the live components the API reads and drives. They are interfaces
|
||||
// so the api package does not depend on the engine internals.
|
||||
type Hooks struct {
|
||||
// Store is the config store (required).
|
||||
Store *config.Store
|
||||
// Logger is the structured logger (required).
|
||||
Logger *slog.Logger
|
||||
// Version is the running build's version.
|
||||
Version string
|
||||
// StartedAt is the process start time.
|
||||
StartedAt time.Time
|
||||
|
||||
// Dashboard returns the aggregated dashboard payload.
|
||||
Dashboard func(ctx context.Context, rangeKey string) (any, error)
|
||||
// BlocklistState returns the compiled blocklist detail.
|
||||
BlocklistState func() any
|
||||
// BlocklistStats returns the compiled rule counts.
|
||||
BlocklistStats func() any
|
||||
// RefreshBlocklists recompiles the blocklist; empty names means all.
|
||||
RefreshBlocklists func(ctx context.Context, names []string) error
|
||||
// ToggleBlocklist enables or disables one source at runtime.
|
||||
ToggleBlocklist func(name string, enabled bool) error
|
||||
// DomainCheck explains how a name would be treated.
|
||||
DomainCheck func(name string) (any, error)
|
||||
// ListDomains pages the compiled rules.
|
||||
ListDomains func(q DomainQuery) (any, error)
|
||||
// AddDomain adds an inline rule and persists it.
|
||||
AddDomain func(ctx context.Context, in DomainInput) (any, error)
|
||||
// DeleteDomain removes a rule and persists the change.
|
||||
DeleteDomain func(ctx context.Context, name, action, list string) error
|
||||
// GetACL returns the ACL groups.
|
||||
GetACL func() any
|
||||
// PutACL validates and persists new ACL groups.
|
||||
PutACL func(ctx context.Context, groups []config.Group, def string) (int64, error)
|
||||
// GetUpstream returns the upstream pool state.
|
||||
GetUpstream func() any
|
||||
// PutUpstream validates and persists a new upstream config.
|
||||
PutUpstream func(ctx context.Context, up config.Upstream) (int64, error)
|
||||
// SearchLog pages the query log.
|
||||
SearchLog func(ctx context.Context, q LogQuery) (any, error)
|
||||
// LogStats is the live stats event pushed over SSE.
|
||||
LogStats func() any
|
||||
// SubscribeLog streams new log entries; the cancel func must be called.
|
||||
SubscribeLog func() (<-chan LogEntry, func())
|
||||
// ClearLog empties the query log.
|
||||
ClearLog func() error
|
||||
// CacheStats returns the cache snapshot.
|
||||
CacheStats func() any
|
||||
// CacheTop returns the hottest cached names.
|
||||
CacheTop func(limit int) any
|
||||
// FlushCache empties the cache.
|
||||
FlushCache func() int64
|
||||
// Health returns the health payload for /healthz.
|
||||
Health func() any
|
||||
// Status returns the per-component runtime status.
|
||||
Status func() any
|
||||
// Flush persists buffered state.
|
||||
Flush func()
|
||||
}
|
||||
|
||||
// DomainQuery pages the compiled rules.
|
||||
type DomainQuery struct {
|
||||
Text string
|
||||
Action string
|
||||
List string
|
||||
Page int
|
||||
PerPage int
|
||||
}
|
||||
|
||||
// DomainInput adds a rule.
|
||||
type DomainInput struct {
|
||||
Name string `json:"name"`
|
||||
Action string `json:"action"`
|
||||
List string `json:"list"`
|
||||
Source string `json:"source"`
|
||||
Content string `json:"content,omitempty"`
|
||||
}
|
||||
|
||||
// LogQuery pages the query log.
|
||||
type LogQuery struct {
|
||||
Limit int
|
||||
Offset int
|
||||
Text string
|
||||
Action string
|
||||
Client string
|
||||
RCode int
|
||||
BlockedOnly bool
|
||||
Since time.Time
|
||||
}
|
||||
|
||||
// LogEntry is the JSON shape of a query-log row, matching what the DNS
|
||||
// handlers record.
|
||||
type LogEntry struct {
|
||||
ID uint64 `json:"id"`
|
||||
TS time.Time `json:"ts"`
|
||||
Client string `json:"client"`
|
||||
Group string `json:"client_group,omitempty"`
|
||||
Name string `json:"name"`
|
||||
QType uint16 `json:"qtype"`
|
||||
QTypeStr string `json:"qtype_name"`
|
||||
RCode int `json:"rcode"`
|
||||
RCodeStr string `json:"rcode_name"`
|
||||
Action string `json:"action"`
|
||||
Response string `json:"response,omitempty"`
|
||||
Duration int64 `json:"duration_ms"`
|
||||
Upstream string `json:"upstream,omitempty"`
|
||||
List string `json:"list,omitempty"`
|
||||
Protocol string `json:"protocol,omitempty"`
|
||||
Blocked bool `json:"blocked"`
|
||||
}
|
||||
|
||||
// Server is the admin HTTP server.
|
||||
type Server struct {
|
||||
hooks Hooks
|
||||
log *slog.Logger
|
||||
base string
|
||||
mux *http.ServeMux
|
||||
auth *authenticator
|
||||
trusted []netip.Prefix
|
||||
|
||||
// sessions tracks issued session tokens for the basic-auth flow.
|
||||
sessMu sync.Mutex
|
||||
sessions map[string]session
|
||||
|
||||
// limits tracks per-IP rate limits and lockouts.
|
||||
limMu sync.Mutex
|
||||
limits map[string]*limitState
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
type session struct {
|
||||
principal Principal
|
||||
expires time.Time
|
||||
}
|
||||
|
||||
type limitState struct {
|
||||
window time.Time
|
||||
count int
|
||||
failures int
|
||||
lockedTill time.Time
|
||||
}
|
||||
|
||||
// New builds the API server.
|
||||
func New(hooks Hooks) (*Server, error) {
|
||||
if hooks.Store == nil {
|
||||
return nil, errors.New("api: Store is required")
|
||||
}
|
||||
log := hooks.Logger
|
||||
if log == nil {
|
||||
log = slog.Default()
|
||||
}
|
||||
cfg := hooks.Store.Get()
|
||||
s := &Server{
|
||||
hooks: hooks,
|
||||
log: log.With("component", "api"),
|
||||
base: cfg.API.BasePath,
|
||||
now: time.Now,
|
||||
sessions: make(map[string]session),
|
||||
limits: make(map[string]*limitState),
|
||||
auth: newAuthenticator(cfg.API.Auth),
|
||||
}
|
||||
for _, c := range cfg.API.TrustedProxies {
|
||||
if p, err := netip.ParsePrefix(c); err == nil {
|
||||
s.trusted = append(s.trusted, p)
|
||||
}
|
||||
}
|
||||
s.mux = s.routes(cfg)
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// Handler returns the HTTP handler, wrapped with logging and panic recovery.
|
||||
func (s *Server) Handler() http.Handler {
|
||||
return s.recoverer(s.cors(s.mux))
|
||||
}
|
||||
|
||||
// routes builds the route table. The stdlib mux handles the path prefix, so
|
||||
// handlers see paths relative to the base.
|
||||
func (s *Server) routes(cfg *config.Config) *http.ServeMux {
|
||||
mux := http.NewServeMux()
|
||||
// base is the prefix every route is built from, so it must end in a
|
||||
// slash: "GET " + base + "healthz" has to produce "/api/healthz", not
|
||||
// "/apihealthz".
|
||||
base := strings.TrimSuffix(s.base, "/") + "/"
|
||||
|
||||
// Unauthenticated: health and metrics are for probes, not operators.
|
||||
mux.Handle("GET "+base+"healthz", s.public(s.handleHealth))
|
||||
if cfg.Metrics.Enabled {
|
||||
// Bound late so the metrics package's init has run by the time the
|
||||
// handler is invoked; a nil handler means "not wired up" (a test, or
|
||||
// an embedder that does not import metrics), which must not panic the
|
||||
// route table.
|
||||
mux.Handle("GET "+base+"metrics", s.protectMetrics(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if h := metricsHandler(); h != nil {
|
||||
h.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
http.Error(w, "metrics not available", http.StatusNotImplemented)
|
||||
})))
|
||||
}
|
||||
mux.Handle("POST "+base+"auth/login", s.public(s.handleLogin))
|
||||
mux.Handle("POST "+base+"auth/logout", s.authed(s.handleLogout, false))
|
||||
|
||||
// Everything below requires a principal.
|
||||
mux.Handle("GET "+base+"dashboard", s.authed(s.handleDashboard, false))
|
||||
mux.Handle("GET "+base+"status", s.authed(s.handleStatus, false))
|
||||
mux.Handle("GET "+base+"blocklists", s.authed(s.handleBlocklists, false))
|
||||
mux.Handle("POST "+base+"blocklists/refresh", s.authed(s.handleBlocklistRefresh, true))
|
||||
mux.Handle("POST "+base+"blocklists/toggle", s.authed(s.handleBlocklistToggle, true))
|
||||
mux.Handle("GET "+base+"domains", s.authed(s.handleDomains, false))
|
||||
mux.Handle("POST "+base+"domains", s.authed(s.handleDomainAdd, true))
|
||||
mux.Handle("DELETE "+base+"domains/{name...}", s.authed(s.handleDomainDelete, true))
|
||||
mux.Handle("GET "+base+"domains/check", s.authed(s.handleDomainCheck, false))
|
||||
mux.Handle("GET "+base+"clients", s.authed(s.handleClients, false))
|
||||
mux.Handle("PUT "+base+"clients", s.authed(s.handleClientsPut, true))
|
||||
mux.Handle("GET "+base+"upstreams", s.authed(s.handleUpstreams, false))
|
||||
mux.Handle("PUT "+base+"upstreams", s.authed(s.handleUpstreamsPut, true))
|
||||
mux.Handle("GET "+base+"logs", s.authed(s.handleLogs, false))
|
||||
mux.Handle("GET "+base+"logs/stream", s.authed(s.handleLogStream, false))
|
||||
mux.Handle("POST "+base+"logs/clear", s.authed(s.handleLogsClear, true))
|
||||
mux.Handle("GET "+base+"cache", s.authed(s.handleCache, false))
|
||||
mux.Handle("GET "+base+"cache/top", s.authed(s.handleCacheTop, false))
|
||||
mux.Handle("POST "+base+"cache/flush", s.authed(s.handleCacheFlush, true))
|
||||
mux.Handle("GET "+base+"settings", s.authed(s.handleSettingsGet, false))
|
||||
mux.Handle("PUT "+base+"settings", s.authed(s.handleSettingsPut, true))
|
||||
mux.Handle("POST "+base+"settings/validate", s.authed(s.handleSettingsValidate, false))
|
||||
mux.Handle("POST "+base+"settings/reload", s.authed(s.handleSettingsReload, true))
|
||||
mux.Handle("GET "+base+"whoami", s.authed(s.handleWhoami, false))
|
||||
return mux
|
||||
}
|
||||
|
||||
// recoverer turns a panic in a handler into a 500 instead of a dropped
|
||||
// connection, and logs it.
|
||||
func (s *Server) recoverer(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
defer func() {
|
||||
if rec := recover(); rec != nil {
|
||||
s.log.Error("panic in api handler", "path", r.URL.Path, "panic", rec)
|
||||
writeError(w, http.StatusInternalServerError, "internal error")
|
||||
}
|
||||
}()
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// cors adds the configured origins. A wildcard is allowed but echoed per
|
||||
// request rather than as "*", so credentials keep working.
|
||||
func (s *Server) cors(next http.Handler) http.Handler {
|
||||
allowed := map[string]bool{}
|
||||
wildcard := false
|
||||
for _, o := range s.hooks.Store.Get().API.CORSOrigins {
|
||||
if o == "*" {
|
||||
wildcard = true
|
||||
}
|
||||
allowed[o] = true
|
||||
}
|
||||
if len(allowed) == 0 && !wildcard {
|
||||
return next
|
||||
}
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
origin := r.Header.Get("Origin")
|
||||
if origin != "" && (wildcard || allowed[origin]) {
|
||||
w.Header().Set("Access-Control-Allow-Origin", origin)
|
||||
w.Header().Add("Vary", "Origin")
|
||||
w.Header().Set("Access-Control-Allow-Credentials", "true")
|
||||
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
|
||||
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, X-API-Key, Authorization")
|
||||
}
|
||||
if r.Method == http.MethodOptions {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// public marks a handler as reachable without a principal.
|
||||
func (s *Server) public(h http.HandlerFunc) http.Handler { return h }
|
||||
|
||||
// cfg is a small accessor so handlers read the current config once.
|
||||
|
||||
// authed wraps a handler with authentication and, for mutations, a write
|
||||
// permission check.
|
||||
func (s *Server) authed(h http.HandlerFunc, write bool) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
p, err := s.principal(r)
|
||||
if err != nil {
|
||||
if s.hooks.Store.Get().API.Auth.Mode != "none" && !s.cfgTrustLoopback(r) {
|
||||
writeError(w, http.StatusUnauthorized, "authentication required")
|
||||
return
|
||||
}
|
||||
// auth mode none, or a trusted loopback client: run as a local
|
||||
// principal.
|
||||
p = Principal{Mode: "loopback", Name: "loopback", Loopback: true}
|
||||
}
|
||||
if write && !p.CanWrite() {
|
||||
writeError(w, http.StatusForbidden, "this key is read-only")
|
||||
return
|
||||
}
|
||||
if !s.allowRequest(w, r) {
|
||||
return
|
||||
}
|
||||
ctx := context.WithValue(r.Context(), principalKey, p)
|
||||
h(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
|
||||
type ctxKey string
|
||||
|
||||
const principalKey ctxKey = "fancydns.principal"
|
||||
|
||||
// PrincipalFrom returns the authenticated principal, if any.
|
||||
func PrincipalFrom(ctx context.Context) (Principal, bool) {
|
||||
p, ok := ctx.Value(principalKey).(Principal)
|
||||
return p, ok
|
||||
}
|
||||
|
||||
// principal authenticates a request.
|
||||
func (s *Server) principal(r *http.Request) (Principal, error) {
|
||||
// API key in the header wins: it is the machine path.
|
||||
if key := r.Header.Get("X-API-Key"); key != "" {
|
||||
return s.auth.apiKey(key)
|
||||
}
|
||||
if h := r.Header.Get("Authorization"); h != "" {
|
||||
scheme, value, ok := strings.Cut(h, " ")
|
||||
if !ok {
|
||||
return Principal{}, errors.New("malformed Authorization header")
|
||||
}
|
||||
switch strings.ToLower(scheme) {
|
||||
case "bearer":
|
||||
return s.auth.session(value)
|
||||
case "basic":
|
||||
user, pass, ok := r.BasicAuth()
|
||||
if !ok {
|
||||
return Principal{}, errors.New("malformed basic credentials")
|
||||
}
|
||||
return s.auth.basic(user, pass)
|
||||
}
|
||||
return Principal{}, fmt.Errorf("unsupported auth scheme %q", scheme)
|
||||
}
|
||||
if s.cfgTrustLoopback(r) {
|
||||
return Principal{Mode: "loopback", Name: "loopback", Loopback: true}, nil
|
||||
}
|
||||
return Principal{}, errors.New("no credentials")
|
||||
}
|
||||
|
||||
func (s *Server) cfgTrustLoopback(r *http.Request) bool {
|
||||
cfg := s.hooks.Store.Get()
|
||||
if !cfg.API.Auth.TrustLoopback {
|
||||
return false
|
||||
}
|
||||
ip := s.clientIP(r)
|
||||
a, err := netip.ParseAddr(ip)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return a.IsLoopback()
|
||||
}
|
||||
|
||||
// clientIP resolves the client address, honouring X-Forwarded-For only from a
|
||||
// trusted proxy. Trusting it from anyone would let a client spoof its identity
|
||||
// past the ACL and the rate limiter.
|
||||
func (s *Server) clientIP(r *http.Request) string {
|
||||
host, _, err := net.SplitHostPort(r.RemoteAddr)
|
||||
if err != nil {
|
||||
host = r.RemoteAddr
|
||||
}
|
||||
addr, err := netip.ParseAddr(host)
|
||||
if err != nil {
|
||||
return host
|
||||
}
|
||||
if !s.isTrusted(addr) {
|
||||
return addr.String()
|
||||
}
|
||||
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
|
||||
// Left-most entry is the original client.
|
||||
first := strings.TrimSpace(strings.Split(xff, ",")[0])
|
||||
if a, err := netip.ParseAddr(first); err == nil {
|
||||
return a.String()
|
||||
}
|
||||
}
|
||||
if xr := r.Header.Get("X-Real-IP"); xr != "" {
|
||||
if a, err := netip.ParseAddr(strings.TrimSpace(xr)); err == nil {
|
||||
return a.String()
|
||||
}
|
||||
}
|
||||
return addr.String()
|
||||
}
|
||||
|
||||
func (s *Server) isTrusted(a netip.Addr) bool {
|
||||
for _, p := range s.trusted {
|
||||
if p.Contains(a) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// allowRequest applies the API rate limit per client IP.
|
||||
func (s *Server) allowRequest(w http.ResponseWriter, r *http.Request) bool {
|
||||
cfg := s.hooks.Store.Get()
|
||||
if cfg.API.RateLimit.PerMinute <= 0 {
|
||||
return true
|
||||
}
|
||||
ip := s.clientIP(r)
|
||||
now := s.now()
|
||||
s.limMu.Lock()
|
||||
defer s.limMu.Unlock()
|
||||
st, ok := s.limits[ip]
|
||||
if !ok {
|
||||
st = &limitState{window: now}
|
||||
s.limits[ip] = st
|
||||
}
|
||||
if now.Before(st.lockedTill) {
|
||||
w.Header().Set("Retry-After", strconv.Itoa(int(st.lockedTill.Sub(now).Seconds())+1))
|
||||
writeError(w, http.StatusTooManyRequests, "rate limit exceeded")
|
||||
return false
|
||||
}
|
||||
if now.Sub(st.window) >= time.Minute {
|
||||
st.window = now
|
||||
st.count = 0
|
||||
}
|
||||
st.count++
|
||||
if st.count > cfg.API.RateLimit.PerMinute {
|
||||
w.Header().Set("Retry-After", "60")
|
||||
writeError(w, http.StatusTooManyRequests, "rate limit exceeded")
|
||||
return false
|
||||
}
|
||||
// Opportunistic cleanup keeps the map bounded without a sweeper.
|
||||
if len(s.limits) > 4096 {
|
||||
for k, v := range s.limits {
|
||||
if now.Sub(v.window) > 10*time.Minute {
|
||||
delete(s.limits, k)
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// recordLoginFailure locks an IP out after repeated bad logins.
|
||||
func (s *Server) recordLoginFailure(ip string) {
|
||||
cfg := s.hooks.Store.Get()
|
||||
if cfg.API.RateLimit.Failures <= 0 {
|
||||
return
|
||||
}
|
||||
s.limMu.Lock()
|
||||
defer s.limMu.Unlock()
|
||||
st, ok := s.limits[ip]
|
||||
if !ok {
|
||||
st = &limitState{window: s.now()}
|
||||
s.limits[ip] = st
|
||||
}
|
||||
st.failures++
|
||||
if st.failures >= cfg.API.RateLimit.Failures {
|
||||
st.lockedTill = s.now().Add(cfg.API.RateLimit.Lockout.D())
|
||||
st.failures = 0
|
||||
}
|
||||
s.log.Warn("failed admin login", "client", ip)
|
||||
}
|
||||
|
||||
func (s *Server) clearLoginFailures(ip string) {
|
||||
s.limMu.Lock()
|
||||
delete(s.limits, ip)
|
||||
s.limMu.Unlock()
|
||||
}
|
||||
|
||||
// protectMetrics gates /metrics behind an API key when configured.
|
||||
func (s *Server) protectMetrics(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
cfg := s.hooks.Store.Get()
|
||||
if !cfg.Metrics.Protect {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
if _, err := s.principal(r); err != nil {
|
||||
writeError(w, http.StatusUnauthorized, "metrics are protected")
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// ---- response helpers -------------------------------------------------------
|
||||
|
||||
func writeJSON(w http.ResponseWriter, code int, v any) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.WriteHeader(code)
|
||||
enc := json.NewEncoder(w)
|
||||
enc.SetEscapeHTML(false)
|
||||
if err := enc.Encode(v); err != nil {
|
||||
// The status line is already sent, so the only useful thing left is a
|
||||
// truncated-object fallback the client can detect.
|
||||
_, _ = w.Write([]byte(`{"error":"response encoding failed"}`))
|
||||
}
|
||||
}
|
||||
|
||||
type apiError struct {
|
||||
Error string `json:"error"`
|
||||
Fields []string `json:"fields,omitempty"`
|
||||
}
|
||||
|
||||
func writeError(w http.ResponseWriter, code int, msg string) {
|
||||
writeJSON(w, code, apiError{Error: msg})
|
||||
}
|
||||
|
||||
func writeErrorFields(w http.ResponseWriter, code int, msg string, fields []string) {
|
||||
writeJSON(w, code, apiError{Error: msg, Fields: fields})
|
||||
}
|
||||
|
||||
// decodeJSON reads a JSON body with a size cap and strict unknown-field
|
||||
// rejection, so a typo in a UI payload is an error rather than a silent no-op.
|
||||
func decodeJSON(r *http.Request, v any) error {
|
||||
defer func() { _ = r.Body.Close() }()
|
||||
dec := json.NewDecoder(http.MaxBytesReader(nil, r.Body, 4<<20))
|
||||
dec.DisallowUnknownFields()
|
||||
if err := dec.Decode(v); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func queryInt(r *http.Request, key string, def int) int {
|
||||
v := r.URL.Query().Get(key)
|
||||
if v == "" {
|
||||
return def
|
||||
}
|
||||
n, err := strconv.Atoi(v)
|
||||
if err != nil {
|
||||
return def
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func queryBool(r *http.Request, key string) bool {
|
||||
v := strings.ToLower(r.URL.Query().Get(key))
|
||||
return v == "1" || v == "true" || v == "yes"
|
||||
}
|
||||
|
||||
func queryTime(r *http.Request, key string) time.Time {
|
||||
v := r.URL.Query().Get(key)
|
||||
if v == "" {
|
||||
return time.Time{}
|
||||
}
|
||||
if n, err := strconv.ParseInt(v, 10, 64); err == nil {
|
||||
if n > 1e12 {
|
||||
return time.UnixMilli(n)
|
||||
}
|
||||
return time.Unix(n, 0)
|
||||
}
|
||||
if t, err := time.Parse(time.RFC3339, v); err == nil {
|
||||
return t
|
||||
}
|
||||
return time.Time{}
|
||||
}
|
||||
|
||||
func constantTimeEqual(a, b string) bool {
|
||||
return subtle.ConstantTimeCompare([]byte(a), []byte(b)) == 1
|
||||
}
|
||||
|
||||
func isFileWritable(path string) bool {
|
||||
if path == "" {
|
||||
return false
|
||||
}
|
||||
f, err := os.OpenFile(path, os.O_WRONLY, 0)
|
||||
if err != nil {
|
||||
// A missing file in a writable directory is still writable.
|
||||
dir := path
|
||||
if i := strings.LastIndexByte(dir, '/'); i > 0 {
|
||||
dir = dir[:i]
|
||||
}
|
||||
d, derr := os.Stat(dir)
|
||||
if derr != nil {
|
||||
return false
|
||||
}
|
||||
return d.IsDir()
|
||||
}
|
||||
_ = f.Close()
|
||||
return true
|
||||
}
|
||||
Reference in new issue
Block a user