The zone's Name servers tab no longer waits for the other servers before
showing: it lists each query as a dig command line with a waiting
counter, fills in the answers as they arrive, then shows the result with
the queries under "Queries sent". Results (serials and delegation) are
kept for 10 minutes so revisiting the tab is instant; "Check again" asks
anew, and a check keeps running when the tab is left.
GET /zones/{zone}/serials?stream=1 streams the queries as
newline-delimited JSON (nscheck.Checker.Watch); without it the endpoint
is unchanged.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
534 lines
15 KiB
Go
534 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)
|
|
}
|
|
|
|
// Unwrap lets http.ResponseController reach the connection (to flush a
|
|
// streamed response).
|
|
func (w *statusWriter) Unwrap() http.ResponseWriter { return w.ResponseWriter }
|
|
|
|
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 "", ""
|
|
}
|