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:
146
internal/firewall/firewall.go
Normal file
146
internal/firewall/firewall.go
Normal 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()
|
||||
}
|
||||
78
internal/firewall/firewall_test.go
Normal file
78
internal/firewall/firewall_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
69
internal/firewall/ruleset.nft.tpl
Normal file
69
internal/firewall/ruleset.nft.tpl
Normal 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}}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user