diff --git a/VERSION b/VERSION index 29ea5f5..98c8dc5 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.2.67 +1.2.68 diff --git a/internal/haproxy/haproxy.cfg.tpl b/internal/haproxy/haproxy.cfg.tpl index 0dbb3f1..f0b2e6e 100644 --- a/internal/haproxy/haproxy.cfg.tpl +++ b/internal/haproxy/haproxy.cfg.tpl @@ -80,6 +80,11 @@ frontend public_https bind [::]:443 ssl crt /etc/edgeguard/tls/ alpn h2,http/1.1 bind quic6@:443 ssl crt /etc/edgeguard/tls/ alpn h3 {{- end}} + {{- if .WAFEnabled}} + # WAF: SPOE-Filter — edgeguard-waf inspiziert jeden Request. + # filter muss vor allen http-request/http-response-Direktiven stehen. + filter spoe engine edgeguard-waf cfg-file /etc/edgeguard/haproxy/coraza-spoe.cfg + {{- end}} # Alt-Svc: signalisiert dass h3 auf demselben Port verfügbar ist. # ma=86400 = Browser darf den Hinweis 24h cachen. @@ -91,6 +96,10 @@ frontend public_https # echte Source-IP ohne XFF-Chain-Parsing brauchen. http-request set-header X-Forwarded-Proto https http-request set-header X-Real-IP %[src] + {{- if .WAFEnabled}} + # WAF: Request blockieren wenn edgeguard-waf txn.waf.status gesetzt hat. + http-request deny deny_status 403 if { var(txn.waf.status) -m found } + {{- end}} {{- if .GlobalMaintenance}} # Whole-Box-Maintenance — Settings → Maintenance-Mode aktiv. Dieser @@ -195,6 +204,16 @@ backend rl_{{$d.ID}} {{- end}} {{- end}} +{{- if .WAFEnabled}} + +# SPOE-Backend für edgeguard-waf (TCP, kein HTTP-Parsing). +backend spoe-edgeguard-waf + mode tcp + timeout connect 100ms + timeout server 50ms + server spoe-waf-1 127.0.0.1:9000 +{{- end}} + {{- range $b := .Backends}} backend eg_backend_{{$b.ID}} diff --git a/internal/haproxy/haproxy.go b/internal/haproxy/haproxy.go index 9276357..da92551 100644 --- a/internal/haproxy/haproxy.go +++ b/internal/haproxy/haproxy.go @@ -24,6 +24,7 @@ import ( "git.netcell-it.de/projekte/edgeguard-native/internal/services/domains" "git.netcell-it.de/projekte/edgeguard-native/internal/services/routingrules" "git.netcell-it.de/projekte/edgeguard-native/internal/services/setup" + wafsvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/waf" ) //go:embed haproxy.cfg.tpl @@ -64,25 +65,29 @@ type Generator struct { ServersRepo *backendservers.Repo RoutingRepo *routingrules.Repo HeadersRepo *domainheaders.Repo + WafRepo *wafsvc.Repo // SetupStore (optional): wenn gesetzt, lesen wir Whole-Box- // Maintenance-Status hieraus und reichen ihn als View.GlobalMaintenance // ans Template weiter. SetupStore *setup.Store - OutputPath string - SkipReload bool + OutputPath string + SPOEConfigPath string + SkipReload bool } func New(pool *pgxpool.Pool) *Generator { return &Generator{ - Pool: pool, - DomainsRepo: domains.New(pool), - BackendsRepo: backends.New(pool), - ServersRepo: backendservers.New(pool), - RoutingRepo: routingrules.New(pool), - HeadersRepo: domainheaders.New(pool), - SetupStore: setup.NewStore(setup.DefaultDir), + Pool: pool, + DomainsRepo: domains.New(pool), + BackendsRepo: backends.New(pool), + ServersRepo: backendservers.New(pool), + RoutingRepo: routingrules.New(pool), + HeadersRepo: domainheaders.New(pool), + WafRepo: wafsvc.New(pool), + SetupStore: setup.NewStore(setup.DefaultDir), + SPOEConfigPath: filepath.Join(configgen.EtcEdgeguard, "haproxy", "coraza-spoe.cfg"), } } @@ -116,6 +121,17 @@ func (g *Generator) Render(ctx context.Context) error { if err := configgen.AtomicWrite(out, buf.Bytes(), 0o644); err != nil { return fmt.Errorf("haproxy: write: %w", err) } + // Write SPOE config whenever WAF is enabled; remove it when disabled + // so HAProxy doesn't fail on a missing backend reference. + if view.WAFEnabled { + spoeOut := g.SPOEConfigPath + if spoeOut == "" { + spoeOut = filepath.Join(configgen.EtcEdgeguard, "haproxy", "coraza-spoe.cfg") + } + if err := configgen.AtomicWrite(spoeOut, []byte(spoeCfg), 0o644); err != nil { + return fmt.Errorf("haproxy: write spoe config: %w", err) + } + } if g.SkipReload { return nil } @@ -125,6 +141,22 @@ func (g *Generator) Render(ctx context.Context) error { return nil } +// spoeCfg is the static SPOE configuration for edgeguard-waf. +// It tells HAProxy which variables to forward and where the agent listens. +const spoeCfg = `# Generated by edgeguard-api. DO NOT EDIT. +[spoe-agent edgeguard-waf-agent] +messages edgeguard-waf-req +option var-prefix waf +timeout hello 100ms +timeout idle 30s +timeout processing 50ms +use-backend spoe-edgeguard-waf + +[spoe-message edgeguard-waf-req] +args src=src method=method uri=url ver=req.ver headers=req.hdrs host=req.hdr(host) +event on-frontend-http-request +` + // View is what the template consumes. Routes per domain are pre- // joined here so the template can stay declarative; Servers leben pro // BackendView, damit das Template einen `backend …`-Block mit den N @@ -148,6 +180,11 @@ type View struct { // IPv6Enabled: wenn true fügt das Template zusätzliche // bind-Direktiven für [::]:80, [::]:443 und [::]:3443 hinzu. IPv6Enabled bool + + // WAFEnabled: wenn true wird der SPOE-Filter für edgeguard-waf + // in public_https eingebunden und das spoe-Backend gerendert. + // Wird gesetzt sobald mindestens eine Domain WAF enabled hat. + WAFEnabled bool } type DomainView struct { @@ -289,6 +326,14 @@ func (g *Generator) loadView(ctx context.Context) (*View, error) { } } v := &View{Domains: domViews, Backends: activeBackends, HTTPDomains: httpDomains} + + // Check whether any domain has WAF enabled. + if g.WafRepo != nil { + if wafEnabled, err := g.WafRepo.ListEnabled(ctx); err == nil { + v.WAFEnabled = len(wafEnabled) > 0 + } + } + if g.SetupStore != nil { if st, err := g.SetupStore.Load(); err == nil && st != nil { v.GlobalMaintenance = st.MaintenanceMode diff --git a/internal/waf/spoe.go b/internal/waf/spoe.go index a4d0bd8..04accea 100644 --- a/internal/waf/spoe.go +++ b/internal/waf/spoe.go @@ -32,13 +32,12 @@ func (a *SPOEAgent) ListenAndServe(ctx context.Context) error { // and optionally sets a txn.waf.status variable to trigger a deny ACL. func (a *SPOEAgent) handle(ctx context.Context, w *encoding.ActionWriter, m *encoding.Message) { var ( - clientIP string - method string - path string - query string - httpVer string - host string - rawHdrs string + clientIP string + method string + uri string // full request URI (path + optional ?query) + httpVer string + host string + rawHdrs string ) // Iterate over the key-value pairs HAProxy sent with this message. @@ -53,10 +52,8 @@ func (a *SPOEAgent) handle(ctx context.Context, w *encoding.ActionWriter, m *enc } case entry.NameEquals("method"): method = string(entry.ValueBytes()) - case entry.NameEquals("path"): - path = string(entry.ValueBytes()) - case entry.NameEquals("query"): - query = string(entry.ValueBytes()) + case entry.NameEquals("uri"): + uri = string(entry.ValueBytes()) case entry.NameEquals("ver"): httpVer = string(entry.ValueBytes()) case entry.NameEquals("host"): @@ -89,10 +86,8 @@ func (a *SPOEAgent) handle(ctx context.Context, w *encoding.ActionWriter, m *enc tx.ProcessConnection(clientIP, 0, "", 0) } - // Build full URI. - uri := path - if query != "" { - uri += "?" + query + if uri == "" { + uri = "/" } if httpVer == "" { httpVer = "HTTP/1.1"