Files
jarl/internal/server/server.go
T
abrown38andClaude Opus 5.5 3b7bee67f8 Add optional query statistics
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>
2026-10-07 20:21:34 +00:00

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
}