shadow.enabled in housecarl.yaml loads Housecarl's ruleset so that it blocks, rejects and translates nothing: drops and rejects count and accept (an accept only ends Housecarl's chain), policies accept and log what they would drop, NAT only counts, MSS clamping and the flowtable are left out, and raw rules that do more than accept, drop or reject are left out with a warning. Comments mark what would happen ([would drop]). Filter chains run after the other firewall's (+10) or before (-10); NAT chains always first. Every change is checked by nftables in both forms. No probation; takeovers and closing connections are refused. The boot fallback filters nothing. internal/tablewatch puts the table back after a flush ruleset. Mode in status, healthz, whoami, the web UI, the log and the tracer; hc ruleset --enforcing. docs/shadow.md. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
512 lines
14 KiB
Go
512 lines
14 KiB
Go
// 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/api"
|
|
"housecarl/internal/auth"
|
|
"housecarl/internal/certs"
|
|
"housecarl/internal/engine"
|
|
"housecarl/internal/model"
|
|
"housecarl/internal/nft"
|
|
"housecarl/internal/render"
|
|
"housecarl/internal/stats"
|
|
"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
|
|
// Shadow is shadow mode (docs/shadow.md): nothing goes on probation,
|
|
// takeovers and closing connections are refused. ShadowPosition is
|
|
// after or before, for the status.
|
|
Shadow bool
|
|
ShadowPosition string
|
|
}
|
|
|
|
// 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
|
|
// RenderEnforcing is the ruleset outside shadow mode.
|
|
RenderEnforcing(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
|
|
// Trace follows packets (internal/trace) until ctx ends.
|
|
Trace(ctx context.Context, script string, line func(string)) 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
|
|
health healthCache
|
|
logs *logRing
|
|
// unitLinks are where an enabled nftables.service shows (tests change
|
|
// them).
|
|
unitLinks []string
|
|
// conns is the kernel's connection tracking (nil: unavailable).
|
|
conns Conntrack
|
|
// tracing is the packet trace running (or the last one).
|
|
tracing tracing
|
|
// stats collects statistics (nil: off).
|
|
stats *stats.Collector
|
|
// 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
|
|
}
|
|
|
|
// mode is housecarld's mode: enforcing, or shadow.
|
|
func (s *Server) mode() string {
|
|
if s.cfg.Shadow {
|
|
return api.ModeShadow
|
|
}
|
|
return api.ModeEnforcing
|
|
}
|
|
|
|
// refuseInShadow refuses what would change traffic in shadow mode.
|
|
func (s *Server) refuseInShadow(what string) error {
|
|
if !s.cfg.Shadow {
|
|
return nil
|
|
}
|
|
return conflict("housecarld is in shadow mode, where it changes nothing about traffic, so it doesn't %s; leave shadow mode first (shadow.enabled in housecarl.yaml, docs/shadow.md)", what)
|
|
}
|
|
|
|
// 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")
|
|
}
|