// 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/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 // 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 "", "" }