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:
90
internal/configgen/configgen.go
Normal file
90
internal/configgen/configgen.go
Normal file
@@ -0,0 +1,90 @@
|
||||
// Package configgen contains shared helpers used by the per-service
|
||||
// renderers in internal/{haproxy,firewall,squid,wireguard,unbound}/.
|
||||
//
|
||||
// Each renderer satisfies the Generator interface: Render reads state
|
||||
// from PG, writes the rendered config to /etc/edgeguard/<svc>/ via
|
||||
// AtomicWrite, then signals the running daemon via systemctl reload
|
||||
// (or its service-specific reload command). Failures are surfaced
|
||||
// to the caller — the orchestrator decides whether one bad renderer
|
||||
// aborts the whole run.
|
||||
package configgen
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// Generator is the contract every per-service renderer satisfies.
|
||||
//
|
||||
// Name returns a stable identifier ("haproxy", "nftables", …)
|
||||
// used in CLI output and audit logs. Render does the actual write +
|
||||
// reload work; ctx may be cancelled (e.g. orchestrator timeout).
|
||||
type Generator interface {
|
||||
Name() string
|
||||
Render(ctx context.Context) error
|
||||
}
|
||||
|
||||
// ErrNotImplemented is returned by stub renderers (squid, wireguard,
|
||||
// unbound in v1). The orchestrator treats it as a soft skip — logged
|
||||
// but never fatal.
|
||||
var ErrNotImplemented = errors.New("renderer not implemented yet")
|
||||
|
||||
// AtomicWrite writes data to path atomically via a temp file in the
|
||||
// same directory + rename. Both the temp file and the final file are
|
||||
// fsync'd to make the rename durable across an OS crash. Mode is
|
||||
// applied AFTER the rename so the previous file's permissions don't
|
||||
// leak through.
|
||||
func AtomicWrite(path string, data []byte, mode os.FileMode) error {
|
||||
dir := filepath.Dir(path)
|
||||
if err := os.MkdirAll(dir, 0o750); err != nil {
|
||||
return fmt.Errorf("mkdir %s: %w", dir, err)
|
||||
}
|
||||
tmp, err := os.CreateTemp(dir, ".eg-render-*")
|
||||
if err != nil {
|
||||
return fmt.Errorf("tempfile: %w", err)
|
||||
}
|
||||
tmpPath := tmp.Name()
|
||||
defer os.Remove(tmpPath) // no-op if rename succeeded
|
||||
|
||||
if _, err := tmp.Write(data); err != nil {
|
||||
tmp.Close()
|
||||
return fmt.Errorf("write %s: %w", tmpPath, err)
|
||||
}
|
||||
if err := tmp.Sync(); err != nil {
|
||||
tmp.Close()
|
||||
return fmt.Errorf("fsync %s: %w", tmpPath, err)
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
return fmt.Errorf("close %s: %w", tmpPath, err)
|
||||
}
|
||||
if err := os.Chmod(tmpPath, mode); err != nil {
|
||||
return fmt.Errorf("chmod %s: %w", tmpPath, err)
|
||||
}
|
||||
if err := os.Rename(tmpPath, path); err != nil {
|
||||
return fmt.Errorf("rename %s → %s: %w", tmpPath, path, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ReloadService sends `systemctl reload <name>`. Returns the
|
||||
// CombinedOutput on failure so the caller can surface the actual
|
||||
// systemd error to the operator.
|
||||
//
|
||||
// Some services don't support reload (nftables — no daemon); for
|
||||
// those, callers should run the service-specific reload directly
|
||||
// rather than calling this helper.
|
||||
func ReloadService(name string) error {
|
||||
cmd := exec.Command("systemctl", "reload", name)
|
||||
if out, err := cmd.CombinedOutput(); err != nil {
|
||||
return fmt.Errorf("systemctl reload %s: %w (output: %s)", name, err, string(out))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// EtcEdgeguard is the on-target config root. Templated path used by
|
||||
// all renderers — never let renderers hard-code their own.
|
||||
const EtcEdgeguard = "/etc/edgeguard"
|
||||
63
internal/configgen/configgen_test.go
Normal file
63
internal/configgen/configgen_test.go
Normal file
@@ -0,0 +1,63 @@
|
||||
package configgen
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestAtomicWrite_CreatesAndChmods(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
target := filepath.Join(dir, "sub", "out.conf")
|
||||
data := []byte("hello config\n")
|
||||
if err := AtomicWrite(target, data, 0o644); err != nil {
|
||||
t.Fatalf("AtomicWrite: %v", err)
|
||||
}
|
||||
got, err := os.ReadFile(target)
|
||||
if err != nil {
|
||||
t.Fatalf("read back: %v", err)
|
||||
}
|
||||
if string(got) != string(data) {
|
||||
t.Errorf("content mismatch: %q", got)
|
||||
}
|
||||
st, err := os.Stat(target)
|
||||
if err != nil {
|
||||
t.Fatalf("stat: %v", err)
|
||||
}
|
||||
if st.Mode().Perm() != 0o644 {
|
||||
t.Errorf("mode: %v", st.Mode().Perm())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAtomicWrite_OverwritesExisting(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
target := filepath.Join(dir, "out.conf")
|
||||
if err := os.WriteFile(target, []byte("old\n"), 0o600); err != nil {
|
||||
t.Fatalf("seed: %v", err)
|
||||
}
|
||||
if err := AtomicWrite(target, []byte("new\n"), 0o640); err != nil {
|
||||
t.Fatalf("AtomicWrite: %v", err)
|
||||
}
|
||||
got, _ := os.ReadFile(target)
|
||||
if string(got) != "new\n" {
|
||||
t.Errorf("expected 'new\\n', got %q", got)
|
||||
}
|
||||
st, _ := os.Stat(target)
|
||||
if st.Mode().Perm() != 0o640 {
|
||||
t.Errorf("mode: %v", st.Mode().Perm())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAtomicWrite_NoTempLeak(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
target := filepath.Join(dir, "out.conf")
|
||||
if err := AtomicWrite(target, []byte("x"), 0o644); err != nil {
|
||||
t.Fatalf("AtomicWrite: %v", err)
|
||||
}
|
||||
entries, _ := os.ReadDir(dir)
|
||||
for _, e := range entries {
|
||||
if e.Name() != "out.conf" {
|
||||
t.Errorf("unexpected leftover: %s", e.Name())
|
||||
}
|
||||
}
|
||||
}
|
||||
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}}
|
||||
}
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
74
internal/services/configorch/configorch.go
Normal file
74
internal/services/configorch/configorch.go
Normal file
@@ -0,0 +1,74 @@
|
||||
// Package configorch fans Render() out across every per-service
|
||||
// renderer in a stable order (haproxy → nftables → squid →
|
||||
// wireguard → unbound). The orchestrator stops on the first hard
|
||||
// error; ErrNotImplemented stubs are logged but do NOT abort the
|
||||
// run (squid/wireguard/unbound are stubs in Phase 2).
|
||||
package configorch
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/configgen"
|
||||
)
|
||||
|
||||
type Result struct {
|
||||
Name string
|
||||
Skipped bool
|
||||
Err error
|
||||
DurationS float64 // populated by callers if they care; orchestrator leaves it 0
|
||||
}
|
||||
|
||||
func (r Result) Status() string {
|
||||
switch {
|
||||
case r.Err == nil:
|
||||
return "ok"
|
||||
case errors.Is(r.Err, configgen.ErrNotImplemented):
|
||||
return "skipped (stub)"
|
||||
default:
|
||||
return "error: " + r.Err.Error()
|
||||
}
|
||||
}
|
||||
|
||||
// Run invokes Render on every generator in `gens`, returning a per-
|
||||
// generator Result slice. Stops on the first hard error so a broken
|
||||
// HAProxy config doesn't bleed into a partial nftables rewrite.
|
||||
//
|
||||
// only: optional whitelist of generator names — empty slice means
|
||||
// "all". Useful for `render-config --only=haproxy` debugging.
|
||||
func Run(ctx context.Context, gens []configgen.Generator, only []string) ([]Result, error) {
|
||||
whitelist := map[string]bool{}
|
||||
for _, n := range only {
|
||||
whitelist[n] = true
|
||||
}
|
||||
out := make([]Result, 0, len(gens))
|
||||
for _, g := range gens {
|
||||
if len(whitelist) > 0 && !whitelist[g.Name()] {
|
||||
out = append(out, Result{Name: g.Name(), Skipped: true})
|
||||
continue
|
||||
}
|
||||
err := g.Render(ctx)
|
||||
out = append(out, Result{Name: g.Name(), Err: err})
|
||||
if err != nil && !errors.Is(err, configgen.ErrNotImplemented) {
|
||||
// hard failure — surface it but return what's done so far
|
||||
return out, fmt.Errorf("%s: %w", g.Name(), err)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Summarise turns the result slice into a human-readable multiline
|
||||
// string. Used by `edgeguard-ctl render-config` to print to stdout.
|
||||
func Summarise(results []Result) string {
|
||||
var b strings.Builder
|
||||
for _, r := range results {
|
||||
if r.Skipped {
|
||||
fmt.Fprintf(&b, " %-10s skipped (filtered)\n", r.Name)
|
||||
continue
|
||||
}
|
||||
fmt.Fprintf(&b, " %-10s %s\n", r.Name, r.Status())
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
@@ -28,7 +28,7 @@ FROM routing_rules
|
||||
`
|
||||
|
||||
// List returns rules ordered by domain_id then priority desc — the
|
||||
// shape the config-renderer wants when building haproxy/nginx vhosts.
|
||||
// shape the config-renderer wants when building HAProxy backends.
|
||||
func (r *Repo) List(ctx context.Context) ([]models.RoutingRule, error) {
|
||||
rows, err := r.Pool.Query(ctx, baseSelect+
|
||||
" ORDER BY domain_id ASC, priority DESC, id ASC")
|
||||
|
||||
20
internal/squid/squid.go
Normal file
20
internal/squid/squid.go
Normal file
@@ -0,0 +1,20 @@
|
||||
// Package squid will render /etc/edgeguard/squid/squid.conf in
|
||||
// Phase 3. v1 ships a stub returning configgen.ErrNotImplemented so
|
||||
// the orchestrator can list it without crashing.
|
||||
package squid
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/configgen"
|
||||
)
|
||||
|
||||
type Generator struct{}
|
||||
|
||||
func New() *Generator { return &Generator{} }
|
||||
|
||||
func (g *Generator) Name() string { return "squid" }
|
||||
|
||||
func (g *Generator) Render(ctx context.Context) error {
|
||||
return configgen.ErrNotImplemented
|
||||
}
|
||||
20
internal/unbound/unbound.go
Normal file
20
internal/unbound/unbound.go
Normal file
@@ -0,0 +1,20 @@
|
||||
// Package unbound will render /etc/edgeguard/unbound/{forwarders,
|
||||
// cluster-zone,access}.conf in Phase 3 (forwarder + cluster-internal
|
||||
// split-horizon, see docs/architecture.md §7.5). v1 ships a stub.
|
||||
package unbound
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/configgen"
|
||||
)
|
||||
|
||||
type Generator struct{}
|
||||
|
||||
func New() *Generator { return &Generator{} }
|
||||
|
||||
func (g *Generator) Name() string { return "unbound" }
|
||||
|
||||
func (g *Generator) Render(ctx context.Context) error {
|
||||
return configgen.ErrNotImplemented
|
||||
}
|
||||
19
internal/wireguard/wireguard.go
Normal file
19
internal/wireguard/wireguard.go
Normal file
@@ -0,0 +1,19 @@
|
||||
// Package wireguard will render /etc/edgeguard/wireguard/wg0.conf in
|
||||
// Phase 3 (and run `wg syncconf` on reload). v1 ships a stub.
|
||||
package wireguard
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/configgen"
|
||||
)
|
||||
|
||||
type Generator struct{}
|
||||
|
||||
func New() *Generator { return &Generator{} }
|
||||
|
||||
func (g *Generator) Name() string { return "wireguard" }
|
||||
|
||||
func (g *Generator) Render(ctx context.Context) error {
|
||||
return configgen.ErrNotImplemented
|
||||
}
|
||||
Reference in New Issue
Block a user