Jarl keeps a BIND 9.20 server's whole configuration (views, ACLs, TSIG keys, zones of every type, records, named.conf options) in SQLite and renders named.conf, keys.conf and zone files from it. Every change goes through one changeset pipeline: whole-configuration validation, named-checkconf -z and named-checkzone in a staging directory, atomic install, rndc reconfig/reload, serial verification with rndc zonestatus, and rollback if named does not load the change. Changes apply one at a time by default or are bundled and applied together. Applied changesets can be reverted. - jarld: the daemon with the REST API and an embedded web UI, plus import, adopt, check, resync, backup and local user recovery commands - jarl: the CLI, including ACME DNS-01 hooks for certbot and acme.sh - users with argon2id passwords and CSRF-protected sessions; hashed API tokens with granular grants and roles; an audit log - systemd unit, sysusers.d/tmpfiles.d files, Makefile - unit tests, integration tests against a real named, scripts/e2e.sh - docs/ guides, README and CHANGELOG Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
380 lines
9.1 KiB
Go
380 lines
9.1 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/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
|
|
}
|
|
|
|
// 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
|
|
|
|
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}
|
|
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
|
|
}
|