- SPOE-Config: option continue-on-error — HAProxy blockt nie wegen SPOE-Timeout (z.B. CRS-Engine-Load beim ersten Request). Ohne dieses Flag waren alle Requests geblockt wenn der WAF-Agent kurz nicht antwortete, auch für Domains ohne WAF-Konfiguration. - SPOE-Config: timeout processing 50ms → 1s — CRS-Load braucht >50ms - postinst: *.conf → * beim CRS-Copy — .data-Dateien wurden nicht mitkopiert, SecRule @pmFromFile scanners-user-agents.data fehlte Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
432 lines
13 KiB
Go
432 lines
13 KiB
Go
// Package haproxy renders /etc/edgeguard/haproxy/haproxy.cfg from
|
|
// the relational state in PG. v1 is the full ingress: HAProxy
|
|
// terminates TLS on :443, redirects :80 → :443 (except ACME), routes
|
|
// by Host header to user backends, and falls through to edgeguard-api
|
|
// for management UI + ACME webroot.
|
|
package haproxy
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
_ "embed"
|
|
"fmt"
|
|
"path/filepath"
|
|
"strings"
|
|
"text/template"
|
|
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
|
|
"git.netcell-it.de/projekte/edgeguard-native/internal/configgen"
|
|
"git.netcell-it.de/projekte/edgeguard-native/internal/models"
|
|
"git.netcell-it.de/projekte/edgeguard-native/internal/services/backends"
|
|
"git.netcell-it.de/projekte/edgeguard-native/internal/services/backendservers"
|
|
"git.netcell-it.de/projekte/edgeguard-native/internal/services/domainheaders"
|
|
"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
|
|
var cfgTpl string
|
|
|
|
// safeID converts a free-form display name like "Control Master 1"
|
|
// into a single token HAProxy accepts as a server-id (no spaces /
|
|
// special chars). Anything outside [a-zA-Z0-9_-] becomes '_'.
|
|
func safeID(s string) string {
|
|
var b strings.Builder
|
|
b.Grow(len(s))
|
|
for _, r := range s {
|
|
switch {
|
|
case r >= 'a' && r <= 'z',
|
|
r >= 'A' && r <= 'Z',
|
|
r >= '0' && r <= '9',
|
|
r == '-', r == '_':
|
|
b.WriteRune(r)
|
|
default:
|
|
b.WriteByte('_')
|
|
}
|
|
}
|
|
out := b.String()
|
|
if out == "" {
|
|
out = "unnamed"
|
|
}
|
|
return out
|
|
}
|
|
|
|
var tpl = template.Must(template.New("haproxy").Funcs(template.FuncMap{
|
|
"safeID": safeID,
|
|
}).Parse(cfgTpl))
|
|
|
|
type Generator struct {
|
|
Pool *pgxpool.Pool
|
|
DomainsRepo *domains.Repo
|
|
BackendsRepo *backends.Repo
|
|
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
|
|
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),
|
|
WafRepo: wafsvc.New(pool),
|
|
SetupStore: setup.NewStore(setup.DefaultDir),
|
|
SPOEConfigPath: filepath.Join(configgen.EtcEdgeguard, "haproxy", "coraza-spoe.cfg"),
|
|
}
|
|
}
|
|
|
|
func (g *Generator) Name() string { return "haproxy" }
|
|
|
|
func (g *Generator) RenderToString(ctx context.Context) (string, error) {
|
|
view, err := g.loadView(ctx)
|
|
if err != nil {
|
|
return "", fmt.Errorf("haproxy: load state: %w", err)
|
|
}
|
|
var buf bytes.Buffer
|
|
if err := tpl.Execute(&buf, view); err != nil {
|
|
return "", fmt.Errorf("haproxy: render template: %w", err)
|
|
}
|
|
return buf.String(), nil
|
|
}
|
|
|
|
func (g *Generator) Render(ctx context.Context) error {
|
|
view, err := g.loadView(ctx)
|
|
if err != nil {
|
|
return fmt.Errorf("haproxy: load state: %w", err)
|
|
}
|
|
var buf bytes.Buffer
|
|
if err := tpl.Execute(&buf, view); err != nil {
|
|
return fmt.Errorf("haproxy: render template: %w", err)
|
|
}
|
|
out := g.OutputPath
|
|
if out == "" {
|
|
out = filepath.Join(configgen.EtcEdgeguard, "haproxy", "haproxy.cfg")
|
|
}
|
|
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
|
|
}
|
|
if err := configgen.ReloadService("haproxy"); err != nil {
|
|
return fmt.Errorf("haproxy: reload: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// spoeCfg is the static SPOE configuration for edgeguard-waf.
|
|
// HAProxy 3.x format: [<engine-name>] section + spoe-agent / spoe-message
|
|
// (no square brackets around spoe-agent/spoe-message keywords).
|
|
// spoeCfg uses `option continue-on-error` so that HAProxy never blocks
|
|
// a request when the SPOE agent is slow or unavailable. Without this,
|
|
// a timeout during CRS engine initialization would block all traffic,
|
|
// including domains without WAF configured.
|
|
const spoeCfg = `# Generated by edgeguard-api. DO NOT EDIT.
|
|
[edgeguard-waf]
|
|
spoe-agent edgeguard-waf-agent
|
|
messages edgeguard-waf-req
|
|
option var-prefix waf
|
|
option continue-on-error
|
|
timeout hello 100ms
|
|
timeout idle 30s
|
|
timeout processing 1s
|
|
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
|
|
// Server-Zeilen rendern kann.
|
|
type View struct {
|
|
Domains []DomainView
|
|
Backends []BackendView
|
|
|
|
// HTTPDomains: aktive Domains mit HTTPToHTTPS=false. Diese bekommen
|
|
// in public_http eigene use_backend-Regeln statt dem globalen Redirect.
|
|
HTTPDomains []DomainView
|
|
|
|
// GlobalMaintenance: wenn true emittiert public_https einen
|
|
// 503-Block ganz am Anfang (vor allen anderen ACLs), der
|
|
// alle Customer-Domains gleichzeitig stilllegt. mgmt_https
|
|
// (:3443) bleibt unverändert. Whole-Box-Mode für
|
|
// OS-Upgrades / Wartungsfenster.
|
|
GlobalMaintenance bool
|
|
GlobalMaintenanceMessage string
|
|
|
|
// 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 {
|
|
models.Domain
|
|
Routes []RouteView
|
|
|
|
// HSTSHeader: fertige Header-Value `max-age=…; includeSubDomains; preload`,
|
|
// nur gesetzt wenn HSTSEnabled — Template muss nichts mehr zusammenbauen.
|
|
HSTSHeader string
|
|
|
|
// MaintMessage: aus MaintenanceMessage übernommen, leer → Default.
|
|
// Bereits HAProxy-safe (kein " enthalten).
|
|
MaintMessage string
|
|
|
|
// RedirectFromHost: aliasing-Host der nach Name umgeleitet werden soll.
|
|
// Nur gesetzt wenn WWWRedirect != "":
|
|
// to-naked → Name="example.com" → "www.example.com"
|
|
// to-www → Name="www.example.com" → "example.com" (strip www.-Prefix)
|
|
RedirectFromHost string
|
|
|
|
// ResponseHeaders: Custom-Headers die HAProxy auf jede Response für
|
|
// diese Domain setzt. Werte sind bereits HAProxy-safe escaped
|
|
// (Quotes → ', Newlines → Space).
|
|
ResponseHeaders []ResponseHeaderView
|
|
|
|
// MaxBodyBytes: Content-Length-Cap in Bytes (max_body_kb * 1024).
|
|
// 0 = aus, kein Rendering.
|
|
MaxBodyBytes int
|
|
|
|
// RateLimitThreshold: requests-pro-10s über die Stick-Table.
|
|
// 0 = aus, kein Rendering. = RateLimitRPS * 10.
|
|
RateLimitThreshold int
|
|
}
|
|
|
|
// ResponseHeaderView: pre-escaped Name/Value damit das Template
|
|
// nicht escapen muss.
|
|
type ResponseHeaderView struct {
|
|
Name string
|
|
Value string
|
|
}
|
|
|
|
type RouteView struct {
|
|
PathPrefix string
|
|
BackendID int64
|
|
}
|
|
|
|
type BackendView struct {
|
|
models.Backend
|
|
Servers []models.BackendServer
|
|
}
|
|
|
|
func (g *Generator) loadView(ctx context.Context) (*View, error) {
|
|
doms, err := g.DomainsRepo.List(ctx)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("list domains: %w", err)
|
|
}
|
|
bes, err := g.BackendsRepo.List(ctx)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("list backends: %w", err)
|
|
}
|
|
srvs, err := g.ServersRepo.ListAll(ctx)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("list backend servers: %w", err)
|
|
}
|
|
rules, err := g.RoutingRepo.List(ctx)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("list routing rules: %w", err)
|
|
}
|
|
headers, err := g.HeadersRepo.ListAll(ctx)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("list domain headers: %w", err)
|
|
}
|
|
headersByDomain := map[int64][]ResponseHeaderView{}
|
|
for _, h := range headers {
|
|
headersByDomain[h.DomainID] = append(headersByDomain[h.DomainID], ResponseHeaderView{
|
|
Name: h.Name,
|
|
Value: sanitizeHeaderValue(h.Value),
|
|
})
|
|
}
|
|
|
|
rulesByDomain := map[int64][]RouteView{}
|
|
for _, r := range rules {
|
|
if !r.Active {
|
|
continue
|
|
}
|
|
rulesByDomain[r.DomainID] = append(rulesByDomain[r.DomainID], RouteView{
|
|
PathPrefix: r.PathPrefix,
|
|
BackendID: r.BackendID,
|
|
})
|
|
}
|
|
|
|
srvByBackend := map[int64][]models.BackendServer{}
|
|
for _, s := range srvs {
|
|
if !s.Active {
|
|
continue
|
|
}
|
|
srvByBackend[s.BackendID] = append(srvByBackend[s.BackendID], s)
|
|
}
|
|
|
|
activeBackends := make([]BackendView, 0, len(bes))
|
|
for _, b := range bes {
|
|
if !b.Active {
|
|
continue
|
|
}
|
|
activeBackends = append(activeBackends, BackendView{
|
|
Backend: b,
|
|
Servers: srvByBackend[b.ID],
|
|
})
|
|
}
|
|
|
|
domViews := make([]DomainView, 0, len(doms))
|
|
for _, d := range doms {
|
|
if !d.Active {
|
|
continue
|
|
}
|
|
dv := DomainView{
|
|
Domain: d,
|
|
Routes: rulesByDomain[d.ID],
|
|
HSTSHeader: buildHSTSHeader(d),
|
|
MaintMessage: buildMaintMessage(d),
|
|
RedirectFromHost: buildRedirectFromHost(d),
|
|
ResponseHeaders: headersByDomain[d.ID],
|
|
}
|
|
if d.MaxBodyKB > 0 {
|
|
dv.MaxBodyBytes = d.MaxBodyKB * 1024
|
|
}
|
|
if d.RateLimitRPS > 0 {
|
|
// Stick-Table store-window ist 10s (siehe Template); deshalb
|
|
// ist die Threshold-Größe rps * 10.
|
|
dv.RateLimitThreshold = d.RateLimitRPS * 10
|
|
}
|
|
domViews = append(domViews, dv)
|
|
}
|
|
|
|
httpDomains := make([]DomainView, 0)
|
|
for _, dv := range domViews {
|
|
if !dv.HTTPToHTTPS && (dv.PrimaryBackendID != nil || len(dv.Routes) > 0) {
|
|
httpDomains = append(httpDomains, dv)
|
|
}
|
|
}
|
|
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
|
|
v.GlobalMaintenanceMessage = sanitizeHeaderValue(st.MaintenanceMessage)
|
|
if v.GlobalMaintenanceMessage == "" && v.GlobalMaintenance {
|
|
v.GlobalMaintenanceMessage = "EdgeGuard maintenance in progress."
|
|
}
|
|
v.IPv6Enabled = st.IPv6Enabled
|
|
}
|
|
}
|
|
return v, nil
|
|
}
|
|
|
|
// buildHSTSHeader baut den fertigen Strict-Transport-Security Wert.
|
|
// Leer wenn HSTS für die Domain aus ist — das Template prüft dann.
|
|
func buildHSTSHeader(d models.Domain) string {
|
|
if !d.HSTSEnabled {
|
|
return ""
|
|
}
|
|
maxAge := d.HSTSMaxAge
|
|
if maxAge <= 0 {
|
|
maxAge = 31536000
|
|
}
|
|
out := fmt.Sprintf("max-age=%d", maxAge)
|
|
if d.HSTSSubdomains {
|
|
out += "; includeSubDomains"
|
|
}
|
|
if d.HSTSPreload {
|
|
out += "; preload"
|
|
}
|
|
return out
|
|
}
|
|
|
|
// buildMaintMessage liefert die Wartungs-Meldung HAProxy-safe (ohne ").
|
|
// Default-Text wenn nichts gesetzt ist.
|
|
func buildMaintMessage(d models.Domain) string {
|
|
if !d.MaintenanceMode {
|
|
return ""
|
|
}
|
|
msg := ""
|
|
if d.MaintenanceMessage != nil {
|
|
msg = *d.MaintenanceMessage
|
|
}
|
|
msg = strings.TrimSpace(msg)
|
|
if msg == "" {
|
|
msg = "Service temporarily unavailable for maintenance."
|
|
}
|
|
// HAProxy kennt kein Escape innerhalb von "…". Lieber " durch ' tauschen
|
|
// und Newlines flatten, damit der Template-Output garantiert parst.
|
|
msg = strings.ReplaceAll(msg, `"`, "'")
|
|
msg = strings.ReplaceAll(msg, "\n", " ")
|
|
msg = strings.ReplaceAll(msg, "\r", " ")
|
|
return msg
|
|
}
|
|
|
|
// sanitizeHeaderValue macht den Wert HAProxy-safe für `set-header "…"`.
|
|
// HAProxy versteht innerhalb einer "…"-Sequenz keine Escapes, deshalb
|
|
// werden Quotes durch ' ersetzt und CR/LF entfernt (sonst sprengt ein
|
|
// böser Wert die Config oder ermöglicht Header-Injection).
|
|
func sanitizeHeaderValue(v string) string {
|
|
v = strings.ReplaceAll(v, `"`, "'")
|
|
v = strings.ReplaceAll(v, "\n", " ")
|
|
v = strings.ReplaceAll(v, "\r", " ")
|
|
return v
|
|
}
|
|
|
|
// buildRedirectFromHost gibt den Alias-Host zurück der nach Name umgeleitet
|
|
// werden soll, oder "" wenn kein Redirect konfiguriert ist.
|
|
func buildRedirectFromHost(d models.Domain) string {
|
|
switch d.WWWRedirect {
|
|
case "to-naked":
|
|
// Name ist die nackte Form → wir leiten www.Name → Name um.
|
|
if strings.HasPrefix(strings.ToLower(d.Name), "www.") {
|
|
// User-Fehlkonfiguration — Name beginnt schon mit www. Skip.
|
|
return ""
|
|
}
|
|
return "www." + d.Name
|
|
case "to-www":
|
|
// Name ist die www-Form → wir leiten Name-ohne-www → Name um.
|
|
lower := strings.ToLower(d.Name)
|
|
if !strings.HasPrefix(lower, "www.") {
|
|
return ""
|
|
}
|
|
return d.Name[4:]
|
|
default:
|
|
return ""
|
|
}
|
|
}
|