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>
147 lines
3.8 KiB
Go
147 lines
3.8 KiB
Go
// 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()
|
|
}
|