// 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. // 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 // 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 "" } }