// 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/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 ServersRepo *backendservers.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), ServersRepo: backendservers.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; Servers leben pro // BackendView, damit das Template einen `backend …`-Block mit den N // Server-Zeilen rendern kann. type View struct { Domains []DomainView Backends []BackendView } type DomainView struct { models.Domain Routes []RouteView } 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) } 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 } domViews = append(domViews, DomainView{ Domain: d, Routes: rulesByDomain[d.ID], }) } return &View{Domains: domViews, Backends: activeBackends}, nil }