feat(waf): Phase 3 — HAProxy SPOE-Integration — v1.2.68

- haproxy.cfg.tpl: filter spoe + deny_status 403 + spoe-edgeguard-waf
  Backend (nur gerendert wenn WAFEnabled=true)
- haproxy.go: WAFEnabled in View; WafRepo.ListEnabled() prüft ob WAF
  aktiv; SPOE-Config-File (coraza-spoe.cfg) wird bei WAFEnabled
  atomar geschrieben; SPOEConfigPath konfigurierbar
- waf/spoe.go: uri statt path+query (HAProxy url-Sample = volle URI)

SPOE-Config definiert:
  - Agent: edgeguard-waf-agent, var-prefix=waf, timeout processing 50ms
  - Message: src, method, uri=url, ver=req.ver, headers=req.hdrs,
             host=req.hdr(host)
  - Backend: spoe-edgeguard-waf → 127.0.0.1:9000

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Debian
2026-06-02 15:53:07 +02:00
parent bd32bc343a
commit bf16ce6666
4 changed files with 84 additions and 25 deletions

View File

@@ -1 +1 @@
1.2.67
1.2.68

View File

@@ -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}}

View File

@@ -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

View File

@@ -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"