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:
@@ -80,6 +80,11 @@ frontend public_https
|
|||||||
bind [::]:443 ssl crt /etc/edgeguard/tls/ alpn h2,http/1.1
|
bind [::]:443 ssl crt /etc/edgeguard/tls/ alpn h2,http/1.1
|
||||||
bind quic6@:443 ssl crt /etc/edgeguard/tls/ alpn h3
|
bind quic6@:443 ssl crt /etc/edgeguard/tls/ alpn h3
|
||||||
{{- end}}
|
{{- 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.
|
# Alt-Svc: signalisiert dass h3 auf demselben Port verfügbar ist.
|
||||||
# ma=86400 = Browser darf den Hinweis 24h cachen.
|
# ma=86400 = Browser darf den Hinweis 24h cachen.
|
||||||
@@ -91,6 +96,10 @@ frontend public_https
|
|||||||
# echte Source-IP ohne XFF-Chain-Parsing brauchen.
|
# echte Source-IP ohne XFF-Chain-Parsing brauchen.
|
||||||
http-request set-header X-Forwarded-Proto https
|
http-request set-header X-Forwarded-Proto https
|
||||||
http-request set-header X-Real-IP %[src]
|
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}}
|
{{- if .GlobalMaintenance}}
|
||||||
# Whole-Box-Maintenance — Settings → Maintenance-Mode aktiv. Dieser
|
# Whole-Box-Maintenance — Settings → Maintenance-Mode aktiv. Dieser
|
||||||
@@ -195,6 +204,16 @@ backend rl_{{$d.ID}}
|
|||||||
{{- end}}
|
{{- end}}
|
||||||
{{- 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}}
|
{{- range $b := .Backends}}
|
||||||
|
|
||||||
backend eg_backend_{{$b.ID}}
|
backend eg_backend_{{$b.ID}}
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import (
|
|||||||
"git.netcell-it.de/projekte/edgeguard-native/internal/services/domains"
|
"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/routingrules"
|
||||||
"git.netcell-it.de/projekte/edgeguard-native/internal/services/setup"
|
"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
|
//go:embed haproxy.cfg.tpl
|
||||||
@@ -64,25 +65,29 @@ type Generator struct {
|
|||||||
ServersRepo *backendservers.Repo
|
ServersRepo *backendservers.Repo
|
||||||
RoutingRepo *routingrules.Repo
|
RoutingRepo *routingrules.Repo
|
||||||
HeadersRepo *domainheaders.Repo
|
HeadersRepo *domainheaders.Repo
|
||||||
|
WafRepo *wafsvc.Repo
|
||||||
|
|
||||||
// SetupStore (optional): wenn gesetzt, lesen wir Whole-Box-
|
// SetupStore (optional): wenn gesetzt, lesen wir Whole-Box-
|
||||||
// Maintenance-Status hieraus und reichen ihn als View.GlobalMaintenance
|
// Maintenance-Status hieraus und reichen ihn als View.GlobalMaintenance
|
||||||
// ans Template weiter.
|
// ans Template weiter.
|
||||||
SetupStore *setup.Store
|
SetupStore *setup.Store
|
||||||
|
|
||||||
OutputPath string
|
OutputPath string
|
||||||
SkipReload bool
|
SPOEConfigPath string
|
||||||
|
SkipReload bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func New(pool *pgxpool.Pool) *Generator {
|
func New(pool *pgxpool.Pool) *Generator {
|
||||||
return &Generator{
|
return &Generator{
|
||||||
Pool: pool,
|
Pool: pool,
|
||||||
DomainsRepo: domains.New(pool),
|
DomainsRepo: domains.New(pool),
|
||||||
BackendsRepo: backends.New(pool),
|
BackendsRepo: backends.New(pool),
|
||||||
ServersRepo: backendservers.New(pool),
|
ServersRepo: backendservers.New(pool),
|
||||||
RoutingRepo: routingrules.New(pool),
|
RoutingRepo: routingrules.New(pool),
|
||||||
HeadersRepo: domainheaders.New(pool),
|
HeadersRepo: domainheaders.New(pool),
|
||||||
SetupStore: setup.NewStore(setup.DefaultDir),
|
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 {
|
if err := configgen.AtomicWrite(out, buf.Bytes(), 0o644); err != nil {
|
||||||
return fmt.Errorf("haproxy: write: %w", err)
|
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 {
|
if g.SkipReload {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -125,6 +141,22 @@ func (g *Generator) Render(ctx context.Context) error {
|
|||||||
return nil
|
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-
|
// View is what the template consumes. Routes per domain are pre-
|
||||||
// joined here so the template can stay declarative; Servers leben pro
|
// joined here so the template can stay declarative; Servers leben pro
|
||||||
// BackendView, damit das Template einen `backend …`-Block mit den N
|
// 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
|
// IPv6Enabled: wenn true fügt das Template zusätzliche
|
||||||
// bind-Direktiven für [::]:80, [::]:443 und [::]:3443 hinzu.
|
// bind-Direktiven für [::]:80, [::]:443 und [::]:3443 hinzu.
|
||||||
IPv6Enabled bool
|
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 {
|
type DomainView struct {
|
||||||
@@ -289,6 +326,14 @@ func (g *Generator) loadView(ctx context.Context) (*View, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
v := &View{Domains: domViews, Backends: activeBackends, HTTPDomains: httpDomains}
|
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 g.SetupStore != nil {
|
||||||
if st, err := g.SetupStore.Load(); err == nil && st != nil {
|
if st, err := g.SetupStore.Load(); err == nil && st != nil {
|
||||||
v.GlobalMaintenance = st.MaintenanceMode
|
v.GlobalMaintenance = st.MaintenanceMode
|
||||||
|
|||||||
@@ -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.
|
// 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) {
|
func (a *SPOEAgent) handle(ctx context.Context, w *encoding.ActionWriter, m *encoding.Message) {
|
||||||
var (
|
var (
|
||||||
clientIP string
|
clientIP string
|
||||||
method string
|
method string
|
||||||
path string
|
uri string // full request URI (path + optional ?query)
|
||||||
query string
|
httpVer string
|
||||||
httpVer string
|
host string
|
||||||
host string
|
rawHdrs string
|
||||||
rawHdrs string
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Iterate over the key-value pairs HAProxy sent with this message.
|
// 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"):
|
case entry.NameEquals("method"):
|
||||||
method = string(entry.ValueBytes())
|
method = string(entry.ValueBytes())
|
||||||
case entry.NameEquals("path"):
|
case entry.NameEquals("uri"):
|
||||||
path = string(entry.ValueBytes())
|
uri = string(entry.ValueBytes())
|
||||||
case entry.NameEquals("query"):
|
|
||||||
query = string(entry.ValueBytes())
|
|
||||||
case entry.NameEquals("ver"):
|
case entry.NameEquals("ver"):
|
||||||
httpVer = string(entry.ValueBytes())
|
httpVer = string(entry.ValueBytes())
|
||||||
case entry.NameEquals("host"):
|
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)
|
tx.ProcessConnection(clientIP, 0, "", 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build full URI.
|
if uri == "" {
|
||||||
uri := path
|
uri = "/"
|
||||||
if query != "" {
|
|
||||||
uri += "?" + query
|
|
||||||
}
|
}
|
||||||
if httpVer == "" {
|
if httpVer == "" {
|
||||||
httpVer = "HTTP/1.1"
|
httpVer = "HTTP/1.1"
|
||||||
|
|||||||
Reference in New Issue
Block a user