Files
edgeguard-native/internal/haproxy/haproxy.go
Debian 99982ef9d1 feat(ui): Quick-Toggles, Config-Preview, Dashboard-Alerts, Domain-Detail-Health
Quick-Toggle-Switches (kein Modal nötig) für: Backends, Backend-Server,
DNS-Zonen, DNS-Records, Domains (active), Firewall-Rules, NAT-Rules,
Forward-Proxy ACLs, Routing-Rules.

Dashboard: Alert-Banner für komplett ausgefallene Backends (HAProxy-Stats)
und Domains im Maintenance-Mode.

Domain-Detail: HAProxy-Live-Health-Badge (15s Polling), TLS-Cert
ausstellen/erneuern direkt aus dem Detail, Routing-Rules-Panel inline.

Config-Preview (Settings): alle 4 Generatoren (haproxy, nftables, squid,
unbound) rendern via RenderToString ohne Disk-Write — GET /system/config-preview.

ActionButtons: Viewer-Rolle blendet Delete aus (RBAC-Ergänzung).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 08:55:22 +02:00

370 lines
10 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"
)
//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
// 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
}
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),
}
}
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)
}
if g.SkipReload {
return nil
}
if err := configgen.ReloadService("haproxy"); err != nil {
return fmt.Errorf("haproxy: reload: %w", err)
}
return nil
}
// 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
// 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
}
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)
}
v := &View{Domains: domViews, Backends: activeBackends}
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 ""
}
}