Files
edgeguard-native/internal/haproxy/haproxy.go
Debian 51ea1fc802 feat: Zonen als first-class Entity + Domain↔Backend-Verknüpfung sichtbar
* Migration 0012: firewall_zones (id, name UNIQUE, description, builtin),
  Seed wan/lan/dmz/mgmt/cluster als builtin. CHECK-Constraints auf
  network_interfaces.role + firewall_rules.{src,dst}_zone +
  firewall_nat_rules.{in,out}_zone gedroppt — Validation lebt jetzt
  app-side (Handler prüft Existenz in firewall_zones).
* Backend: firewall.ZonesRepo (CRUD + Exists + References-Lookup),
  /api/v1/firewall/zones, builtin geschützt (Name nicht änderbar,
  Delete blockiert), Rename eines Custom-Zone aktuell ohne Cascade
  (Handler-Sorge bei Rules/NAT/Networks).
* Handler-Validation in CreateRule/UpdateRule/CreateNAT/UpdateNAT +
  NetworksHandler: Zone-Existence-Check pro Mutation, 400 bei Tippfehler.
* Frontend: Firewall-Tab "Zonen" (CRUD mit builtin-Schutz). Networks-
  Form lädt Rollen aus /firewall/zones (statt hardcoded Liste); Rules-
  und NAT-Forms ziehen die Zone-Auswahl ebenfalls aus der API.
* Domain-Form bekommt Primary-Backend-Picker (Field war im Modell,
  fehlte im UI). Backends-Tabelle zeigt umgekehrt welche Domains
  darauf zeigen — bidirektionale Sicht ohne Schemaänderung.
* HAProxy-Renderer: safeID-FuncMap escaped Server-Namen mit Whitespace
  ("Control Master 1" → "Control_Master_1"). Vorher ist haproxy beim
  Reload an Spaces im Backend-Namen kaputt gegangen.
* Version 1.0.3 → 1.0.6.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 18:05:27 +02:00

165 lines
3.9 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/domains"
"git.netcell-it.de/projekte/edgeguard-native/internal/services/routingrules"
)
//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
RoutingRepo *routingrules.Repo
OutputPath string
SkipReload bool
}
func New(pool *pgxpool.Pool) *Generator {
return &Generator{
Pool: pool,
DomainsRepo: domains.New(pool),
BackendsRepo: backends.New(pool),
RoutingRepo: routingrules.New(pool),
}
}
func (g *Generator) Name() string { return "haproxy" }
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.
type View struct {
Domains []DomainView
Backends []models.Backend
}
type DomainView struct {
models.Domain
Routes []RouteView
}
type RouteView struct {
PathPrefix string
BackendID int64
}
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)
}
rules, err := g.RoutingRepo.List(ctx)
if err != nil {
return nil, fmt.Errorf("list routing rules: %w", err)
}
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,
})
}
activeBackends := make([]models.Backend, 0, len(bes))
for _, b := range bes {
if b.Active {
activeBackends = append(activeBackends, b)
}
}
domViews := make([]DomainView, 0, len(doms))
for _, d := range doms {
if !d.Active {
continue
}
domViews = append(domViews, DomainView{
Domain: d,
Routes: rulesByDomain[d.ID],
})
}
return &View{Domains: domViews, Backends: activeBackends}, nil
}