Changesets, as in Jarl: configuration changes are operations that can be staged in a changeset, checked as they are added (validation and nft -c), reviewed with the diff of the ruleset, and applied together as one history entry, then on probation like any change made over the network. The web UI collects edits for review by default (pending rows, a bar with Review and Apply now, a Changes page, a switch for live edits); hc stages with -C ID or HOUSECARL_CHANGESET (checked like JARL_CHANGESET) and has hc changeset create/list/show/diff/apply/discard/remove. Undo, import and takeover refuse a changeset. --dry-run shows the ruleset's diff. Bandwidth and quotas: rate limits in bytes, per source or destination; quotas per rule or per address that reset daily, weekly, monthly on a day, or by hand, with their usage carried across reloads, saved for reboots, shown by hc quotas and on the rules page, and reset with hc rule reset-quota. Rules that count traffic are checked before established connections are accepted (so they see every packet) and can't accept. Per-address limits group IPv6 by /64 by default (--group-v4/-v6). Per-source rate meters use update. The importer turns meters, byte rates and quotas into these fields. The examples in the web UI, hc's help and the docs use documentation addresses and generic names instead of the maintainer's network. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
475 lines
12 KiB
Go
475 lines
12 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/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
|
|
// 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}
|
|
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")
|
|
}
|