* 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>
165 lines
3.9 KiB
Go
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
|
|
}
|