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:
71
internal/haproxy/haproxy.cfg.tpl
Normal file
71
internal/haproxy/haproxy.cfg.tpl
Normal 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
137
internal/haproxy/haproxy.go
Normal 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
|
||||
}
|
||||
78
internal/haproxy/haproxy_test.go
Normal file
78
internal/haproxy/haproxy_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user