Off by default (stats in jarl.yaml). When enabled, Jarl adds a loopback statistics channel and a response log to named's configuration (merged into existing logging/statistics-channels statements) and switches response logging with rndc responselog, because the responselog option only counts when named starts. jarld polls named's counters every minute and tails the response log (resuming after restarts and across rotation). It stores aggregates, never individual queries, in a separate SQLite database: per-minute, hourly and daily counters (queries, result codes, record types, views, zones, cache hits, upstream latency, DNSSEC) and hourly/daily top lists (names, clients, NXDOMAIN and SERVFAIL names, refused clients), each with its own retention. Clients can be anonymized or excluded. - Web UI: Statistics page with KPI tiles, queries and failed answers over time, a weekday x hour heatmap, top lists and breakdowns; SVG charts with hover/keyboard readouts, a table view and light/dark palettes validated for colour-vision deficiency. - CLI: jarl stats status|summary|top|timeline|heatmap|breakdown. - API: /api/v1/stats/*, behind a new "stats" permission resource. - jarld brings named's configuration up to date with jarl.yaml at startup (engine.SyncConfig), never overwriting hand-edited files. - resync now refuses to overwrite drifted files without force, as documented. - tmpfiles.d creates /var/log/named (allowed by named's AppArmor profile on Debian 13 and Ubuntu). - The e2e harness runs named with -f like the Debian unit. - docs/stats.md, plus API, CLI, configuration, permissions and README updates. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
387 lines
9.3 KiB
Go
387 lines
9.3 KiB
Go
// Package server implements Jarl's HTTP API and serves the web UI.
|
|
package server
|
|
|
|
import (
|
|
"context"
|
|
"crypto/tls"
|
|
"errors"
|
|
"fmt"
|
|
"io/fs"
|
|
"log/slog"
|
|
"net"
|
|
"net/http"
|
|
"net/netip"
|
|
"os"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"jarl/internal/auth"
|
|
"jarl/internal/engine"
|
|
"jarl/internal/stats"
|
|
"jarl/internal/store"
|
|
)
|
|
|
|
// Config configures the HTTP server.
|
|
type Config struct {
|
|
Listen []string
|
|
TLSCertFile string
|
|
TLSKeyFile string
|
|
TrustedProxies []netip.Prefix
|
|
SessionLifetime time.Duration
|
|
SessionIdle time.Duration
|
|
// CookieSecure is "auto" (secure when TLS is used or the request came
|
|
// through a trusted HTTPS proxy), "always" or "never".
|
|
CookieSecure string
|
|
MaxBodyBytes int64
|
|
Version string
|
|
// Stats is the query statistics collector (nil when disabled).
|
|
Stats *stats.Collector
|
|
}
|
|
|
|
// Server is the HTTP server.
|
|
type Server struct {
|
|
cfg Config
|
|
eng *engine.Engine
|
|
store *store.Store
|
|
log *slog.Logger
|
|
mux *http.ServeMux
|
|
limiter *rateLimiter
|
|
web fs.FS
|
|
certs *certReloader
|
|
statsQ *stats.Querier
|
|
|
|
mu sync.Mutex
|
|
srvs []*http.Server
|
|
}
|
|
|
|
// New creates a server. web holds the static web UI (may be nil).
|
|
func New(cfg Config, eng *engine.Engine, log *slog.Logger, web fs.FS) *Server {
|
|
if cfg.SessionLifetime == 0 {
|
|
cfg.SessionLifetime = 12 * time.Hour
|
|
}
|
|
if cfg.SessionIdle == 0 {
|
|
cfg.SessionIdle = 2 * time.Hour
|
|
}
|
|
if cfg.MaxBodyBytes == 0 {
|
|
cfg.MaxBodyBytes = 16 << 20
|
|
}
|
|
if cfg.CookieSecure == "" {
|
|
cfg.CookieSecure = "auto"
|
|
}
|
|
if log == nil {
|
|
log = slog.Default()
|
|
}
|
|
s := &Server{cfg: cfg, eng: eng, store: eng.Store(), log: log, mux: http.NewServeMux(), limiter: newRateLimiter(), web: web}
|
|
if cfg.Stats != nil {
|
|
s.statsQ = stats.NewQuerier(cfg.Stats)
|
|
}
|
|
s.routes()
|
|
return s
|
|
}
|
|
|
|
// Handler returns the server's HTTP handler with all middleware.
|
|
func (s *Server) Handler() http.Handler {
|
|
return s.recoverer(s.securityHeaders(s.logRequests(s.mux)))
|
|
}
|
|
|
|
// Serve listens on all configured addresses until ctx is canceled.
|
|
func (s *Server) Serve(ctx context.Context, ready func()) error {
|
|
if len(s.cfg.Listen) == 0 {
|
|
return errors.New("no listen addresses configured")
|
|
}
|
|
var tlsCfg *tls.Config
|
|
if s.cfg.TLSCertFile != "" {
|
|
cr, err := newCertReloader(s.cfg.TLSCertFile, s.cfg.TLSKeyFile, s.log)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
s.certs = cr
|
|
tlsCfg = &tls.Config{MinVersion: tls.VersionTLS12, GetCertificate: cr.get}
|
|
}
|
|
errc := make(chan error, len(s.cfg.Listen))
|
|
var listeners []net.Listener
|
|
for _, addr := range s.cfg.Listen {
|
|
ln, err := net.Listen("tcp", addr)
|
|
if err != nil {
|
|
for _, l := range listeners {
|
|
l.Close()
|
|
}
|
|
return fmt.Errorf("listening on %s: %w", addr, err)
|
|
}
|
|
listeners = append(listeners, ln)
|
|
}
|
|
for i, ln := range listeners {
|
|
srv := &http.Server{
|
|
Handler: s.Handler(),
|
|
ReadHeaderTimeout: 10 * time.Second,
|
|
ReadTimeout: 2 * time.Minute,
|
|
WriteTimeout: 5 * time.Minute,
|
|
IdleTimeout: 2 * time.Minute,
|
|
TLSConfig: tlsCfg,
|
|
ErrorLog: slog.NewLogLogger(s.log.Handler(), slog.LevelWarn),
|
|
}
|
|
s.mu.Lock()
|
|
s.srvs = append(s.srvs, srv)
|
|
s.mu.Unlock()
|
|
scheme := "http"
|
|
if tlsCfg != nil {
|
|
scheme = "https"
|
|
}
|
|
s.log.Info("listening", "addr", s.cfg.Listen[i], "url", scheme+"://"+ln.Addr().String())
|
|
go func(srv *http.Server, ln net.Listener) {
|
|
var err error
|
|
if tlsCfg != nil {
|
|
err = srv.ServeTLS(ln, "", "")
|
|
} else {
|
|
err = srv.Serve(ln)
|
|
}
|
|
if !errors.Is(err, http.ErrServerClosed) {
|
|
errc <- err
|
|
}
|
|
}(srv, ln)
|
|
}
|
|
if tlsCfg == nil {
|
|
for _, a := range s.cfg.Listen {
|
|
if host, _, err := net.SplitHostPort(a); err == nil {
|
|
if ip, err := netip.ParseAddr(host); host == "" || err == nil && !ip.IsLoopback() {
|
|
s.log.Warn("serving plain HTTP on a non-loopback address: passwords and API tokens travel unencrypted; configure tls or a TLS reverse proxy", "addr", a)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if ready != nil {
|
|
ready()
|
|
}
|
|
go s.housekeeping(ctx)
|
|
select {
|
|
case <-ctx.Done():
|
|
case err := <-errc:
|
|
s.shutdown()
|
|
return err
|
|
}
|
|
s.shutdown()
|
|
return nil
|
|
}
|
|
|
|
func (s *Server) shutdown() {
|
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
|
defer cancel()
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
for _, srv := range s.srvs {
|
|
srv.Shutdown(ctx)
|
|
}
|
|
}
|
|
|
|
// ReloadTLS rereads the TLS certificate (on SIGHUP).
|
|
func (s *Server) ReloadTLS() {
|
|
if s.certs != nil {
|
|
s.certs.reload()
|
|
}
|
|
}
|
|
|
|
func (s *Server) housekeeping(ctx context.Context) {
|
|
t := time.NewTicker(10 * time.Minute)
|
|
defer t.Stop()
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case <-t.C:
|
|
if err := s.store.PurgeSessions(ctx, time.Now()); err != nil {
|
|
s.log.Warn("purging sessions failed", "err", err)
|
|
}
|
|
s.limiter.cleanup()
|
|
if s.certs != nil {
|
|
s.certs.reloadIfChanged()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// certReloader serves a certificate that is reread when the files change,
|
|
// so renewed certificates (e.g. from Let's Encrypt) are picked up.
|
|
type certReloader struct {
|
|
certFile, keyFile string
|
|
log *slog.Logger
|
|
mu sync.RWMutex
|
|
cert *tls.Certificate
|
|
modTime time.Time
|
|
}
|
|
|
|
func newCertReloader(certFile, keyFile string, log *slog.Logger) (*certReloader, error) {
|
|
cr := &certReloader{certFile: certFile, keyFile: keyFile, log: log}
|
|
if err := cr.load(); err != nil {
|
|
return nil, err
|
|
}
|
|
return cr, nil
|
|
}
|
|
|
|
func (cr *certReloader) load() error {
|
|
cert, err := tls.LoadX509KeyPair(cr.certFile, cr.keyFile)
|
|
if err != nil {
|
|
return fmt.Errorf("loading TLS certificate: %w", err)
|
|
}
|
|
fi, _ := os.Stat(cr.certFile)
|
|
cr.mu.Lock()
|
|
cr.cert = &cert
|
|
if fi != nil {
|
|
cr.modTime = fi.ModTime()
|
|
}
|
|
cr.mu.Unlock()
|
|
return nil
|
|
}
|
|
|
|
func (cr *certReloader) reload() {
|
|
if err := cr.load(); err != nil {
|
|
cr.log.Error("reloading TLS certificate failed", "err", err)
|
|
return
|
|
}
|
|
cr.log.Info("TLS certificate reloaded", "file", cr.certFile)
|
|
}
|
|
|
|
func (cr *certReloader) reloadIfChanged() {
|
|
fi, err := os.Stat(cr.certFile)
|
|
if err != nil {
|
|
return
|
|
}
|
|
cr.mu.RLock()
|
|
changed := fi.ModTime().After(cr.modTime)
|
|
cr.mu.RUnlock()
|
|
if changed {
|
|
cr.reload()
|
|
}
|
|
}
|
|
|
|
func (cr *certReloader) get(*tls.ClientHelloInfo) (*tls.Certificate, error) {
|
|
cr.mu.RLock()
|
|
defer cr.mu.RUnlock()
|
|
return cr.cert, nil
|
|
}
|
|
|
|
// ---- middleware
|
|
|
|
type ctxKey int
|
|
|
|
const (
|
|
keyPrincipal ctxKey = iota
|
|
keySession
|
|
)
|
|
|
|
func (s *Server) recoverer(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
defer func() {
|
|
if v := recover(); v != nil {
|
|
if v == http.ErrAbortHandler {
|
|
panic(v)
|
|
}
|
|
s.log.Error("panic serving request", "path", r.URL.Path, "panic", v)
|
|
writeError(w, http.StatusInternalServerError, "internal", "internal server error")
|
|
}
|
|
}()
|
|
next.ServeHTTP(w, r)
|
|
})
|
|
}
|
|
|
|
func (s *Server) securityHeaders(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
h := w.Header()
|
|
h.Set("X-Content-Type-Options", "nosniff")
|
|
h.Set("X-Frame-Options", "DENY")
|
|
h.Set("Referrer-Policy", "no-referrer")
|
|
h.Set("Content-Security-Policy", "default-src 'self'; img-src 'self' data:; style-src 'self'; script-src 'self'; connect-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'")
|
|
h.Set("Cross-Origin-Opener-Policy", "same-origin")
|
|
if s.isHTTPS(r) {
|
|
h.Set("Strict-Transport-Security", "max-age=31536000")
|
|
}
|
|
next.ServeHTTP(w, r)
|
|
})
|
|
}
|
|
|
|
type statusWriter struct {
|
|
http.ResponseWriter
|
|
status int
|
|
bytes int
|
|
}
|
|
|
|
func (w *statusWriter) WriteHeader(code int) {
|
|
w.status = code
|
|
w.ResponseWriter.WriteHeader(code)
|
|
}
|
|
|
|
func (w *statusWriter) Write(b []byte) (int, error) {
|
|
if w.status == 0 {
|
|
w.status = http.StatusOK
|
|
}
|
|
n, err := w.ResponseWriter.Write(b)
|
|
w.bytes += n
|
|
return n, err
|
|
}
|
|
|
|
func (s *Server) logRequests(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
start := time.Now()
|
|
sw := &statusWriter{ResponseWriter: w}
|
|
next.ServeHTTP(sw, r)
|
|
if r.URL.Path == "/healthz" || strings.HasPrefix(r.URL.Path, "/assets/") {
|
|
return
|
|
}
|
|
user := ""
|
|
if p, ok := r.Context().Value(keyPrincipal).(*auth.Principal); ok {
|
|
user = p.Name()
|
|
}
|
|
s.log.Debug("http", "method", r.Method, "path", r.URL.Path, "status", sw.status, "bytes", sw.bytes, "duration", time.Since(start).Round(time.Millisecond), "ip", s.clientIP(r), "user", user)
|
|
})
|
|
}
|
|
|
|
// clientIP returns the client's address, honoring X-Forwarded-For from
|
|
// trusted proxies.
|
|
func (s *Server) clientIP(r *http.Request) string {
|
|
host, _, err := net.SplitHostPort(r.RemoteAddr)
|
|
if err != nil {
|
|
host = r.RemoteAddr
|
|
}
|
|
peer, err := netip.ParseAddr(host)
|
|
if err != nil || !s.trusted(peer) {
|
|
return host
|
|
}
|
|
xff := r.Header.Get("X-Forwarded-For")
|
|
if xff == "" {
|
|
return host
|
|
}
|
|
parts := strings.Split(xff, ",")
|
|
// Walk from the right, skipping trusted proxies.
|
|
for i := len(parts) - 1; i >= 0; i-- {
|
|
a, err := netip.ParseAddr(strings.TrimSpace(parts[i]))
|
|
if err != nil {
|
|
return host
|
|
}
|
|
if !s.trusted(a) {
|
|
return a.String()
|
|
}
|
|
}
|
|
return strings.TrimSpace(parts[0])
|
|
}
|
|
|
|
func (s *Server) trusted(a netip.Addr) bool {
|
|
a = a.Unmap()
|
|
for _, p := range s.cfg.TrustedProxies {
|
|
if p.Contains(a) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func (s *Server) isHTTPS(r *http.Request) bool {
|
|
if r.TLS != nil {
|
|
return true
|
|
}
|
|
host, _, _ := net.SplitHostPort(r.RemoteAddr)
|
|
if a, err := netip.ParseAddr(host); err == nil && s.trusted(a) {
|
|
return strings.EqualFold(r.Header.Get("X-Forwarded-Proto"), "https")
|
|
}
|
|
return false
|
|
}
|