feat(configgen): Phase 2 Config-Generator + nginx → HAProxy-only Pivot

Architektur-Pivot: nginx fällt komplett weg. HAProxy 2.8+ übernimmt
TLS-Termination, L7-Routing per Host-Header und LB. ACME-Webroot
und Management-UI werden von edgeguard-api ausgeliefert (Phase 3
implementiert die zugehörigen Handler); HAProxy proxied
/.well-known/acme-challenge/* und Management-FQDN-Traffic an
127.0.0.1:9443. Eine Distro-Abhängigkeit weniger, ein Renderer
weniger, sauberere Trennung.

Renderer (alle mit Embed-Templates + Tests):
* internal/configgen/  — atomic write + systemctl reload helpers
* internal/haproxy/    — :80 + :443, ACME-ACL, Host-Header-Routing,
                         Stats-Frontend, api_backend Fallback
* internal/firewall/   — default-deny input, stateful baseline,
                         SSH-Rate-Limit, :80/:443 accept,
                         Cluster-Peer-Set für mTLS :8443,
                         Custom-Rules aus PG
* internal/{squid,wireguard,unbound}/ — Stubs (ErrNotImplemented)

Orchestrator + CLI:
* internal/services/configorch/  — fester Reihenfolge-Run, Stubs
                                   sind soft-skip statt fatal
* cmd/edgeguard-ctl render-config [--no-reload] [--only=svc1,svc2]

Packaging:
* postinst: /etc/edgeguard/nginx raus, /var/lib/edgeguard/acme rein,
  self-signed _default.pem via openssl req (damit HAProxy startet
  bevor certbot etwas issuet hat)
* control: Depends nginx raus, openssl rein
* edgeguard-ui: dependency auf nginx weg, "Served by edgeguard-api
  gin StaticFS"

Live-Smoke: render-config gegen lokale PG schreibt /etc/edgeguard/
haproxy/haproxy.cfg + nftables.d/ruleset.nft korrekt; CRUD-Test aus
Phase 2 läuft weiter unverändert.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Debian
2026-05-09 10:59:52 +02:00
parent 0a6f81beaa
commit 914538eed1
26 changed files with 1002 additions and 44 deletions

View File

@@ -0,0 +1,71 @@
# Generated by edgeguard-api — DO NOT EDIT.
# Source: internal/haproxy/haproxy.go (template: haproxy.cfg.tpl).
# Re-generate via `edgeguard-ctl render-config`.
global
log /dev/log local0 info
log /dev/log local1 notice
user haproxy
group haproxy
daemon
ssl-default-bind-options ssl-min-ver TLSv1.2 no-tls-tickets
ssl-default-bind-ciphersuites TLS_AES_128_GCM_SHA256:TLS_AES_256_GCM_SHA384:TLS_CHACHA20_POLY1305_SHA256
defaults
log global
mode http
option httplog
option dontlognull
option forwardfor
timeout connect 5s
timeout client 30s
timeout server 30s
timeout http-request 10s
# ── Public :80 ─────────────────────────────────────────────────────────
# ACME-01 challenges proxy to edgeguard-api which serves the webroot.
# Everything else redirects to HTTPS.
frontend public_http
bind :80
acl is_acme path_beg /.well-known/acme-challenge/
use_backend api_backend if is_acme
http-request redirect scheme https code 301 unless { ssl_fc } is_acme
# ── Public :443 ────────────────────────────────────────────────────────
# TLS termination. Reads certs from /etc/edgeguard/tls/ — postinst
# seeds a self-signed _default.pem so HAProxy starts before certbot
# has issued anything.
frontend public_https
bind :443 ssl crt /etc/edgeguard/tls/ alpn h2,http/1.1
http-response set-header Strict-Transport-Security "max-age=31536000"
{{- range $d := .Domains}}
{{- range $r := $d.Routes}}
use_backend eg_backend_{{$r.BackendID}} if { hdr(host) -i {{$d.Name}} } { path_beg {{$r.PathPrefix}} }
{{- end}}
{{- end}}
default_backend api_backend
# ── Internal stats ─────────────────────────────────────────────────────
frontend internal_stats
bind 127.0.0.1:8404
stats enable
stats uri /stats
stats refresh 10s
stats admin if { src 127.0.0.1 }
# ── Backends ───────────────────────────────────────────────────────────
# edgeguard-api itself: management UI, REST API, ACME webroot.
backend api_backend
server api1 127.0.0.1:9443 check
{{- range .Backends}}
backend eg_backend_{{.ID}}
server {{.Name}} {{.Address}}:{{.Port}}{{if .HealthCheckPath}} check inter 5s{{end}}
{{- end}}

137
internal/haproxy/haproxy.go Normal file
View File

@@ -0,0 +1,137 @@
// 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"
"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
var tpl = template.Must(template.New("haproxy").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
}

View File

@@ -0,0 +1,78 @@
package haproxy
import (
"bytes"
"strings"
"testing"
"git.netcell-it.de/projekte/edgeguard-native/internal/models"
)
func renderView(t *testing.T, v View) string {
t.Helper()
var buf bytes.Buffer
if err := tpl.Execute(&buf, v); err != nil {
t.Fatalf("template execute: %v", err)
}
return buf.String()
}
func TestRender_BaselineHasFrontendsAndApiBackend(t *testing.T) {
out := renderView(t, View{})
for _, w := range []string{
"frontend public_http",
"frontend public_https",
"frontend internal_stats",
"backend api_backend",
"server api1 127.0.0.1:9443 check",
"bind :443 ssl crt /etc/edgeguard/tls/",
"path_beg /.well-known/acme-challenge/",
"http-request redirect scheme https",
} {
if !strings.Contains(out, w) {
t.Errorf("missing %q in baseline output:\n%s", w, out)
}
}
}
func TestRender_DomainRoutesEmitUseBackend(t *testing.T) {
v := View{
Backends: []models.Backend{
{ID: 1, Name: "app", Address: "10.0.0.10", Port: 8080, Active: true},
{ID: 2, Name: "api", Address: "10.0.0.20", Port: 9000, Active: true},
},
Domains: []DomainView{{
Domain: models.Domain{ID: 1, Name: "example.com", Active: true},
Routes: []RouteView{
{PathPrefix: "/", BackendID: 1},
{PathPrefix: "/api", BackendID: 2},
},
}},
}
out := renderView(t, v)
for _, w := range []string{
"backend eg_backend_1",
"server app 10.0.0.10:8080",
"backend eg_backend_2",
"server api 10.0.0.20:9000",
"use_backend eg_backend_1 if { hdr(host) -i example.com } { path_beg / }",
"use_backend eg_backend_2 if { hdr(host) -i example.com } { path_beg /api }",
} {
if !strings.Contains(out, w) {
t.Errorf("missing %q in output:\n%s", w, out)
}
}
}
func TestRender_HealthCheckPathAddsCheckInter(t *testing.T) {
hcp := "/health"
v := View{
Backends: []models.Backend{
{ID: 1, Name: "app", Address: "10.0.0.10", Port: 8080, Active: true, HealthCheckPath: &hcp},
},
}
out := renderView(t, v)
if !strings.Contains(out, "server app 10.0.0.10:8080 check inter 5s") {
t.Errorf("expected `check inter 5s` for backend with health_check_path:\n%s", out)
}
}