Files
jarl/internal/server/server.go
T
abrown38andClaude Opus 5.5 a95dffb471 Merge branch 'totp'
Both branches added database migration 3: DNSSEC (merged first) keeps 3
and TOTP becomes migration 4.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 15:17:12 +00:00

530 lines
15 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/api"
"jarl/internal/auth"
"jarl/internal/engine"
"jarl/internal/nscheck"
"jarl/internal/porkbun"
"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
// HTTPReq configures the endpoint for lego's httpreq DNS provider.
HTTPReq HTTPReq
// Socket is the local unix socket's path ("" for none); see socket.go.
Socket string
// BackupDir and BackupWarnAfter are the backup settings the dashboard
// reports on (backup.dir and backup.warn_after).
BackupDir string
BackupWarnAfter time.Duration
// Porkbun manages domains' delegation at the registrar Porkbun (nil
// without an API key); PorkbunAllows says which domains it may touch.
Porkbun *porkbun.Client
PorkbunAllows func(domain string) bool
// TOTPRequire is totp.require (none, admins or all) and TOTPIssuer the
// name authenticator apps show (h_totp.go).
TOTPRequire string
TOTPIssuer string
}
// 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
// oldSecretWarn limits warnings about rotated tokens' old secrets.
oldSecretWarn throttle
// expiryWarn limits warnings about expiring tokens to one a day each.
expiryWarn throttle
// pendingLogins are logins waiting for their TOTP code.
pendingLogins pendingLogins
// nsChecker, when set, replaces the default name server checker
// (tests point it at local DNS servers).
nsChecker *nscheck.Checker
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 cfg.TOTPRequire == "" {
cfg.TOTPRequire = "none"
}
if cfg.TOTPIssuer == "" {
cfg.TOTPIssuer = "Jarl"
}
if log == nil {
log = slog.Default()
}
s := &Server{cfg: cfg, eng: eng, store: eng.Store(), log: log, mux: http.NewServeMux(), limiter: newRateLimiter(), web: web}
s.expiryWarn.every = 24 * time.Hour
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)+1)
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)
}
}
}
}
s.serveSocket(errc)
s.logHTTPReq(tlsCfg != nil)
if ready != nil {
ready()
}
go s.housekeeping(ctx)
select {
case <-ctx.Done():
case err := <-errc:
s.shutdown()
return err
}
s.shutdown()
return nil
}
// serveSocket serves the API on the local unix socket. A socket that
// can't be created (e.g. /run/jarl missing outside systemd) is logged, not
// fatal: the network listeners still work.
func (s *Server) serveSocket(errc chan error) {
if s.cfg.Socket == "" {
return
}
ln, err := listenSocket(s.cfg.Socket)
if err != nil {
s.log.Warn("not listening on the local socket", "path", s.cfg.Socket, "err", err)
return
}
srv := &http.Server{
Handler: s.Handler(),
ConnContext: socketConnContext,
ReadHeaderTimeout: 10 * time.Second,
WriteTimeout: 5 * time.Minute,
IdleTimeout: 2 * time.Minute,
ErrorLog: slog.NewLogLogger(s.log.Handler(), slog.LevelWarn),
}
s.mu.Lock()
s.srvs = append(s.srvs, srv)
s.mu.Unlock()
s.log.Info("listening", "socket", s.cfg.Socket)
go func() {
if err := srv.Serve(ln); !errors.Is(err, http.ErrServerClosed) {
errc <- err
}
}()
}
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)
}
if s.cfg.Socket != "" {
os.Remove(s.cfg.Socket)
}
}
// 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()
s.warnExpiringTokens(ctx, time.Now())
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()
// The version is no secret: /healthz reports it too.
h.Set("Server", "Jarl/"+s.cfg.Version)
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 {
if peerOf(r.Context()) != nil {
return "local"
}
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
}
// logHTTPReq says at startup whether the httpreq endpoint can work.
func (s *Server) logHTTPReq(tls bool) {
h := s.cfg.HTTPReq
switch {
case !h.Enabled:
case h.AllowPlainHTTP:
s.log.Warn("acme.httpreq accepts plain HTTP (require_https: false): API tokens may travel unencrypted", "path", api.Prefix+"/acme/httpreq")
case !tls && len(s.cfg.TrustedProxies) == 0 && !h.AllowLoopbackHTTP:
s.log.Warn("acme.httpreq is enabled but jarld has no tls and no trusted_proxies, so every request will be refused as not HTTPS", "path", api.Prefix+"/acme/httpreq")
default:
s.log.Info("acme.httpreq endpoint enabled", "path", api.Prefix+"/acme/httpreq", "tls", tls, "trusted_proxies", len(s.cfg.TrustedProxies), "allow_loopback_http", h.AllowLoopbackHTTP)
}
}
func (s *Server) isHTTPS(r *http.Request) bool {
https, _ := s.transport(r)
return https
}
// transport reports whether r reached Jarl over HTTPS, and explains how
// that was decided (for error messages and the httpreq status check).
//
// A request is HTTPS when jarld terminated TLS itself (tls in jarl.yaml),
// or when it came straight from a trusted proxy (trusted_proxies) that
// says the client used HTTPS: X-Forwarded-Proto, or, if that is absent,
// the proto parameter of Forwarded (RFC 7239). Proxies append to these
// headers, so the last value is the one the nearest proxy set; anything a
// client sent itself comes earlier and is ignored. From any other address
// the headers are ignored, since anyone could send them.
func (s *Server) transport(r *http.Request) (https bool, why string) {
if r.TLS != nil {
return true, "HTTPS to jarld (tls in jarl.yaml)"
}
if peerOf(r.Context()) != nil {
return true, "jarld's local unix socket (never crosses a network)"
}
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
host = r.RemoteAddr
}
peer, err := netip.ParseAddr(host)
if err != nil || !s.trusted(peer) {
if r.Header.Get("X-Forwarded-Proto") != "" || r.Header.Get("Forwarded") != "" {
return false, fmt.Sprintf("plain HTTP from %s, which sent X-Forwarded-Proto or Forwarded but isn't in trusted_proxies, so the header was ignored", host)
}
return false, fmt.Sprintf("plain HTTP from %s (jarld has no tls configured, and %s isn't a trusted proxy)", host, host)
}
proto, header := forwardedProto(r)
switch {
case proto == "":
return false, fmt.Sprintf("trusted proxy %s, which sent neither X-Forwarded-Proto nor Forwarded: proto=, so the client's protocol is unknown", host)
case proto == "https":
return true, fmt.Sprintf("HTTPS to trusted proxy %s (%s: https)", host, header)
default:
return false, fmt.Sprintf("trusted proxy %s, which says the client used %s (%s: %s)", host, proto, header, proto)
}
}
// forwardedProto returns the client's protocol as the nearest proxy
// reported it, and the header it came from.
func forwardedProto(r *http.Request) (proto, header string) {
if vals := r.Header.Values("X-Forwarded-Proto"); len(vals) > 0 {
parts := strings.Split(strings.Join(vals, ","), ",")
return strings.ToLower(strings.TrimSpace(parts[len(parts)-1])), "X-Forwarded-Proto"
}
if vals := r.Header.Values("Forwarded"); len(vals) > 0 {
elems := strings.Split(strings.Join(vals, ","), ",")
for _, pair := range strings.Split(elems[len(elems)-1], ";") {
k, v, _ := strings.Cut(strings.TrimSpace(pair), "=")
if strings.EqualFold(k, "proto") {
return strings.ToLower(strings.Trim(strings.TrimSpace(v), `"`)), "Forwarded"
}
}
}
return "", ""
}