// Package server is Housecarl'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" "strconv" "strings" "sync" "syscall" "time" "housecarl/internal/auth" "housecarl/internal/certs" "housecarl/internal/engine" "housecarl/internal/model" "housecarl/internal/nft" "housecarl/internal/render" "housecarl/internal/store" "housecarl/internal/version" ) // 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 over HTTPS), always or never. CookieSecure string MaxBodyBytes int64 // Socket is the local unix socket ("" for none). Socket string // ACME keeps the certificate housecarld obtains itself (tls.acme; nil // when off). ACME *certs.Manager } // Interfaces lists the host's interfaces with their addresses, and // whether each is up. type Interfaces interface { All() (map[string][]netip.Prefix, error) Up(name string) bool } // Firewall is what the server needs from the running firewall beyond the // engine (internal/nft.Firewall). type Firewall interface { Render(st *model.State) *render.Ruleset Table(ctx context.Context) (*nft.Table, error) AddElement(ctx context.Context, set, addr string, timeout int) error DeleteElement(ctx context.Context, set, addr string) error ListRuleset(ctx context.Context) ([]byte, error) DeleteTable(ctx context.Context, family, name string) error TableText(ctx context.Context, family, name string) (string, error) ApplyScript(ctx context.Context, script string) error SaveBoot(st *model.State) error // ResetQuota starts a rule's quota again (perAddress: its sets). ResetQuota(ctx context.Context, ruleID int64, perAddress bool) error } // Server is the HTTP server. type Server struct { cfg Config eng *engine.Engine fw Firewall ifaces Interfaces store *store.Store log *slog.Logger mux *http.ServeMux limiter *rateLimiter web fs.FS started time.Time certs *certReloader mu sync.Mutex srvs []*http.Server probation probation logs *logRing // unitLinks are where an enabled nftables.service shows (tests change // them). unitLinks []string // csMu orders changes to changesets (staging, applying, discarding). csMu sync.Mutex nowFn func() time.Time // probationFor overrides the settings' probation (tests). probationFor time.Duration } // New makes a server. web holds the web UI (nil: API only). func New(cfg Config, eng *engine.Engine, fw Firewall, ifaces Interfaces, 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 = 8 << 20 } if cfg.CookieSecure == "" { cfg.CookieSecure = "auto" } if log == nil { log = slog.Default() } s := &Server{cfg: cfg, eng: eng, fw: fw, ifaces: ifaces, store: eng.Store(), log: log, mux: http.NewServeMux(), limiter: newRateLimiter(), web: web, started: time.Now(), logs: newLogRing(2000), nowFn: time.Now, unitLinks: nftablesUnitLinks} s.routes() return s } // Handler returns the HTTP handler with all middleware. func (s *Server) Handler() http.Handler { return s.recoverer(s.securityHeaders(s.logRequests(s.mux))) } func lowPort(addr string) bool { _, port, err := net.SplitHostPort(addr) if err != nil { return false } n, err := strconv.Atoi(port) return err == nil && n > 0 && n < 1024 } // Serve listens until ctx ends. ready is called once listening. func (s *Server) Serve(ctx context.Context, ready func()) error { if len(s.cfg.Listen) == 0 { return errors.New("no listen addresses configured") } // With a certificate (tls.cert_file, or one housecarld obtains, which // may turn up later), each listener serves HTTPS and plain HTTP side // by side (sniff.go); plain HTTP is redirected once there is one. 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 } if s.certs != nil || s.cfg.ACME != nil { tlsCfg = &tls.Config{MinVersion: tls.VersionTLS12, GetCertificate: s.webCertificate, NextProtos: []string{"h2", "http/1.1"}} } 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() } if errors.Is(err, syscall.EACCES) && lowPort(addr) { return fmt.Errorf("listening on %s: %w (ports below 1024 need CAP_NET_BIND_SERVICE, which housecarl.service grants)", addr, err) } return fmt.Errorf("listening on %s: %w", addr, err) } listeners = append(listeners, ln) } for i, ln := range listeners { handler := s.Handler() if tlsCfg != nil { handler = s.requireHTTPS(handler) } srv := &http.Server{ Handler: 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 { if s.httpsAvailable() { scheme = "https" } ln = newSniffListener(ln, tlsCfg) } if !s.httpsAvailable() && s.cfg.ACME == nil { if host, _, err := net.SplitHostPort(s.cfg.Listen[i]); 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", s.cfg.Listen[i]) } } } s.log.Info("listening", "addr", s.cfg.Listen[i], "url", scheme+"://"+ln.Addr().String()) go func(srv *http.Server, ln net.Listener) { if err := srv.Serve(ln); !errors.Is(err, http.ErrServerClosed) { errc <- err } }(srv, ln) } s.serveSocket(errc) 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) 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) } } 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() } } } } // ReloadTLS rereads the certificate (on SIGHUP). func (s *Server) ReloadTLS() { if s.certs != nil { s.certs.reload() } } // certReloader serves a certificate that is reread when its file changes. 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 the 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 the 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 a 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("Server", "Housecarl/"+version.Short()) 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 } func (w *statusWriter) WriteHeader(code int) { w.status = code w.ResponseWriter.WriteHeader(code) } func (w *statusWriter) Unwrap() http.ResponseWriter { return w.ResponseWriter } 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, status: 200} 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, "duration", time.Since(start).Round(time.Millisecond), "ip", s.clientIP(r), "user", user) }) } // clientIP is the client's address, from X-Forwarded-For when the request // came through a trusted proxy. 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 } parts := strings.Split(r.Header.Get("X-Forwarded-For"), ",") 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 host } 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 } // isHTTPS reports whether the request reached Housecarl over HTTPS: TLS to // housecarld, the local socket, or a trusted proxy that says so (the last // X-Forwarded-Proto value). func (s *Server) isHTTPS(r *http.Request) bool { if r.TLS != nil || peerOf(r.Context()) != nil { return true } host, _, err := net.SplitHostPort(r.RemoteAddr) if err != nil { return false } peer, err := netip.ParseAddr(host) if err != nil || !s.trusted(peer) { return false } vals := r.Header.Values("X-Forwarded-Proto") if len(vals) == 0 { return false } parts := strings.Split(strings.Join(vals, ","), ",") return strings.EqualFold(strings.TrimSpace(parts[len(parts)-1]), "https") }