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,146 @@
// Package firewall renders /etc/edgeguard/nftables.d/ruleset.nft from
// the relational state in PG (firewall_rules + ha_nodes).
//
// The base ruleset is hard-coded in the template (default-deny input,
// stateful baseline, SSH rate-limit, public :80 / :443, peer mTLS on
// :8443 from cluster IPs). Operator-defined rows in firewall_rules
// land at the bottom of input/forward/output.
//
// Reload uses `nft -f <path>` (atomic ruleset replace) — there is no
// systemctl reload for nftables.
package firewall
import (
"bytes"
"context"
_ "embed"
"fmt"
"net"
"os/exec"
"path/filepath"
"text/template"
"github.com/jackc/pgx/v5/pgxpool"
"git.netcell-it.de/projekte/edgeguard-native/internal/configgen"
)
//go:embed ruleset.nft.tpl
var rulesTpl string
var tpl = template.Must(template.New("ruleset").Parse(rulesTpl))
type Generator struct {
Pool *pgxpool.Pool
OutputPath string
SkipReload bool
}
func New(pool *pgxpool.Pool) *Generator { return &Generator{Pool: pool} }
func (g *Generator) Name() string { return "nftables" }
func (g *Generator) Render(ctx context.Context) error {
view, err := g.loadView(ctx)
if err != nil {
return fmt.Errorf("nftables: load state: %w", err)
}
var buf bytes.Buffer
if err := tpl.Execute(&buf, view); err != nil {
return fmt.Errorf("nftables: render template: %w", err)
}
out := g.OutputPath
if out == "" {
out = filepath.Join(configgen.EtcEdgeguard, "nftables.d", "ruleset.nft")
}
if err := configgen.AtomicWrite(out, buf.Bytes(), 0o644); err != nil {
return fmt.Errorf("nftables: write: %w", err)
}
if g.SkipReload {
return nil
}
if err := exec.Command("nft", "-f", out).Run(); err != nil {
return fmt.Errorf("nftables: nft -f %s: %w", out, err)
}
return nil
}
type View struct {
PeerIPv4 []string
PeerIPv6 []string
// Custom rules grouped by chain — the template iterates each
// section independently so input/forward/output stay separate.
CustomRulesInput []Rule
CustomRulesForward []Rule
CustomRulesOutput []Rule
}
type Rule struct {
MatchExpr string
Action string
Comment string
}
func (g *Generator) loadView(ctx context.Context) (*View, error) {
view := &View{}
// Peer IPs from ha_nodes — splits IPv4 vs IPv6 so the template
// can populate the right named set without runtime branching.
peerRows, err := g.Pool.Query(ctx,
`SELECT public_ip, internal_ip FROM ha_nodes`)
if err != nil {
return nil, fmt.Errorf("query ha_nodes: %w", err)
}
defer peerRows.Close()
for peerRows.Next() {
var pub, internal *string
if err := peerRows.Scan(&pub, &internal); err != nil {
return nil, err
}
for _, ip := range []*string{pub, internal} {
if ip == nil {
continue
}
parsed := net.ParseIP(*ip)
if parsed == nil {
continue
}
if parsed.To4() != nil {
view.PeerIPv4 = append(view.PeerIPv4, parsed.String())
} else {
view.PeerIPv6 = append(view.PeerIPv6, parsed.String())
}
}
}
if err := peerRows.Err(); err != nil {
return nil, err
}
// Custom firewall_rules — only active, ordered by priority.
ruleRows, err := g.Pool.Query(ctx, `
SELECT chain, match_expr, action, COALESCE(comment, '')
FROM firewall_rules
WHERE active
ORDER BY chain ASC, priority DESC, id ASC`)
if err != nil {
return nil, fmt.Errorf("query firewall_rules: %w", err)
}
defer ruleRows.Close()
for ruleRows.Next() {
var chain, match, action, comment string
if err := ruleRows.Scan(&chain, &match, &action, &comment); err != nil {
return nil, err
}
r := Rule{MatchExpr: match, Action: action, Comment: comment}
switch chain {
case "input":
view.CustomRulesInput = append(view.CustomRulesInput, r)
case "forward":
view.CustomRulesForward = append(view.CustomRulesForward, r)
case "output":
view.CustomRulesOutput = append(view.CustomRulesOutput, r)
}
}
return view, ruleRows.Err()
}

View File

@@ -0,0 +1,78 @@
package firewall
import (
"bytes"
"strings"
"testing"
)
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_BaselineHasMandatorySections(t *testing.T) {
out := renderView(t, View{})
for _, w := range []string{
"flush ruleset",
"table inet edgeguard",
"set peer_ipv4",
"set peer_ipv6",
"chain input",
"type filter hook input priority 0; policy drop;",
"ct state established,related accept",
"iif lo accept",
"tcp dport 22 ct state new limit rate 10/minute accept",
"tcp dport { 80, 443 } accept",
"tcp dport 8443 ip saddr @peer_ipv4 accept",
"chain forward",
"chain output",
} {
if !strings.Contains(out, w) {
t.Errorf("missing %q in baseline:\n%s", w, out)
}
}
}
func TestRender_PeerIPsPopulateSets(t *testing.T) {
v := View{
PeerIPv4: []string{"10.0.0.11", "10.0.0.12"},
PeerIPv6: []string{"fd00::1"},
}
out := renderView(t, v)
for _, w := range []string{
"elements = { 10.0.0.11, 10.0.0.12 }",
"elements = { fd00::1 }",
} {
if !strings.Contains(out, w) {
t.Errorf("missing %q:\n%s", w, out)
}
}
}
func TestRender_CustomRulesLandInChain(t *testing.T) {
v := View{
CustomRulesInput: []Rule{
{MatchExpr: "ip saddr 192.168.0.0/16 tcp dport 9090", Action: "accept", Comment: "monitoring"},
},
CustomRulesForward: []Rule{
{MatchExpr: "iif eth0 oif eth1", Action: "accept", Comment: "lan to wan"},
},
}
out := renderView(t, v)
want := []string{
"# monitoring",
"ip saddr 192.168.0.0/16 tcp dport 9090 accept",
"# lan to wan",
"iif eth0 oif eth1 accept",
}
for _, w := range want {
if !strings.Contains(out, w) {
t.Errorf("missing %q:\n%s", w, out)
}
}
}

View File

@@ -0,0 +1,69 @@
#!/usr/sbin/nft -f
# Generated by edgeguard-api — DO NOT EDIT.
# Source: internal/firewall/firewall.go (template: ruleset.nft.tpl).
# Re-generate via `edgeguard-ctl render-config`.
flush ruleset
table inet edgeguard {
set peer_ipv4 {
type ipv4_addr; flags interval
{{- if .PeerIPv4}}
elements = { {{range $i, $ip := .PeerIPv4}}{{if $i}}, {{end}}{{$ip}}{{end}} }
{{- end}}
}
set peer_ipv6 {
type ipv6_addr; flags interval
{{- if .PeerIPv6}}
elements = { {{range $i, $ip := .PeerIPv6}}{{if $i}}, {{end}}{{$ip}}{{end}} }
{{- end}}
}
chain input {
type filter hook input priority 0; policy drop;
# Stateful baseline
ct state established,related accept
ct state invalid drop
iif lo accept
# ICMP keep PMTUD and basic diagnostics
ip protocol icmp icmp type { echo-request, destination-unreachable, time-exceeded, parameter-problem } accept
ip6 nexthdr icmpv6 icmpv6 type { echo-request, destination-unreachable, packet-too-big, time-exceeded, parameter-problem, nd-router-solicit, nd-router-advert, nd-neighbor-solicit, nd-neighbor-advert } accept
# SSH rate-limit to keep brute-force out of the auth log
tcp dport 22 ct state new limit rate 10/minute accept
tcp dport 22 drop
# Public ingress: HAProxy terminates TLS on :443 and serves :80
tcp dport { 80, 443 } accept
# Cluster-internal: peers reach edgeguard-api over mTLS on :8443
tcp dport 8443 ip saddr @peer_ipv4 accept
tcp dport 8443 ip6 saddr @peer_ipv6 accept
{{- range .CustomRulesInput}}
# {{.Comment}}
{{.MatchExpr}} {{.Action}}
{{- end}}
}
chain forward {
type filter hook forward priority 0; policy drop;
ct state established,related accept
ct state invalid drop
{{- range .CustomRulesForward}}
# {{.Comment}}
{{.MatchExpr}} {{.Action}}
{{- end}}
}
chain output {
type filter hook output priority 0; policy accept;
{{- range .CustomRulesOutput}}
# {{.Comment}}
{{.MatchExpr}} {{.Action}}
{{- end}}
}
}