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:
12
CLAUDE.md
12
CLAUDE.md
@@ -57,7 +57,7 @@ ac_search_code(query="<stichworte>", project_id=8, session_name=$(printenv ARCHI
|
||||
| **UI** | React 19, TypeScript strict, Vite, Ant Design 6, TanStack Query 5 |
|
||||
| **DB** | PostgreSQL 16 (Distro-Paket), goose-Migrations in `migrations/` |
|
||||
| **State/HA** | KeyDB Active-Active (Redis-kompatibel) |
|
||||
| **Proxy/LB** | HAProxy (Distro), nginx (Distro) |
|
||||
| **Proxy/LB** | HAProxy (Distro) — TLS-Termination, L7-Routing, LB |
|
||||
| **VPN** | WireGuard (Kernel-Modul ab 5.6, `wireguard-tools`) |
|
||||
| **DNS** | Unbound (Distro) — Forwarder+Cache mit DNSSEC, Cluster-internes Split-Horizon |
|
||||
| **FW** | nftables (Distro) |
|
||||
@@ -88,7 +88,7 @@ ac_search_code(query="<stichworte>", project_id=8, session_name=$(printenv ARCHI
|
||||
| `edgeguard-scheduler` | Cron-Jobs (ACME-Renew, Backup, Health) | — | `edgeguard` |
|
||||
| `edgeguard-ctl` | CLI: `initdb migrate cluster-join promote dump-config` | — | root/edgeguard |
|
||||
|
||||
nginx terminiert TLS auf `:443` und proxied an `127.0.0.1:9443`.
|
||||
HAProxy terminiert TLS auf `:443`, routet per Host-Header an Backends und fällt für Management-FQDN/ACME-Webroot auf `127.0.0.1:9443` (edgeguard-api) zurück. Die Management-UI wird von edgeguard-api ausgeliefert (statisch aus `/usr/share/edgeguard/ui/` oder embedded).
|
||||
|
||||
---
|
||||
|
||||
@@ -112,7 +112,7 @@ cd management-ui && bun install && bun run build
|
||||
|
||||
```bash
|
||||
# Abhängigkeiten installieren
|
||||
sudo apt-get install -y postgresql-16 haproxy nginx wireguard-tools squid unbound nftables certbot
|
||||
sudo apt-get install -y postgresql-16 haproxy wireguard-tools squid unbound nftables certbot
|
||||
|
||||
# API starten (ohne systemd, für Entwicklung)
|
||||
go run ./cmd/edgeguard-api/
|
||||
@@ -137,8 +137,7 @@ cd management-ui && bun run dev
|
||||
│ ├── models/ # GORM-Models
|
||||
│ ├── handlers/ # HTTP-Handler (REST)
|
||||
│ ├── services/ # Business-Logik
|
||||
│ ├── haproxy/ # Config-Generator
|
||||
│ ├── nginx/ # Config-Generator
|
||||
│ ├── haproxy/ # Config-Generator (TLS + Routing + LB)
|
||||
│ ├── squid/ # Config-Generator
|
||||
│ ├── wireguard/ # Config-Generator
|
||||
│ ├── unbound/ # Config-Generator (Forwarder + Cluster-DNS)
|
||||
@@ -151,8 +150,7 @@ cd management-ui && bun run dev
|
||||
├── packaging/debian/ # control, postinst, postrm, systemd-Units
|
||||
├── deploy/
|
||||
│ ├── systemd/ # *.service, *.target, *.timer
|
||||
│ ├── haproxy/ # haproxy.cfg.tpl
|
||||
│ ├── nginx/ # vhost.conf.tpl, sni-map.tpl
|
||||
│ ├── haproxy/ # (Templates liegen jetzt neben Renderer in internal/<svc>/)
|
||||
│ ├── squid/ # squid.conf.tpl
|
||||
│ ├── unbound/ # unbound.conf.tpl
|
||||
│ └── nftables/ # ruleset.nft.tpl
|
||||
|
||||
@@ -14,7 +14,7 @@ curl -fsSL https://get.edgeguard.netcell-it.de | sudo bash
|
||||
|
||||
## Architektur in Kürze
|
||||
|
||||
- **Daten-Services (v1):** HAProxy, nginx, Squid, WireGuard, Unbound, nftables — alle nativ via APT, Configs aus PostgreSQL generiert.
|
||||
- **Daten-Services (v1):** HAProxy (TLS-Termination + LB + L7-Routing), Squid, WireGuard, Unbound, nftables — alle nativ via APT, Configs aus PostgreSQL generiert.
|
||||
- **Control-Plane:** `edgeguard-api` (Go/Gin), `management-ui` (React/AntD), PostgreSQL 16, KeyDB Active-Active.
|
||||
- **Cluster:** N symmetrische Peers, KeyDB AA für Shared State, PG Streaming Replication, Floating-IP des Hosters statt VRRP.
|
||||
- **Auslieferung:** signierte `.deb`, Update via `apt`. Update-Trigger via UI/API.
|
||||
|
||||
4
agent.md
4
agent.md
@@ -19,7 +19,7 @@ Der Architect Center Orchestrator dispatcht mehrere Claude Code Agenten, jeder s
|
||||
|-------|-------|-----------------|
|
||||
| **DB-Architect** | Datenbankschema, goose-Migrations, GORM-Models | mail-gateway models/ |
|
||||
| **API-Engineer** | Gin-Router, alle Handler, Middleware (Auth/JWT/RBAC) | mail-gateway handlers/ |
|
||||
| **Config-Generator** | HAProxy, nginx, Squid, WireGuard, Unbound, nftables Templates + Renderer | mail-gateway config/ |
|
||||
| **Config-Generator** | HAProxy, Squid, WireGuard, Unbound, nftables Templates + Renderer | mail-gateway config/ |
|
||||
| **Cluster-Engineer** | KeyDB AA, PG Streaming Replication, Join/Promote, Write-Proxy | mail-gateway cluster/ |
|
||||
| **Scheduler-Engineer** | ACME-Renewal, Backup, Health-Aggregation, License-Heartbeat | mail-gateway scheduler |
|
||||
| **CLI-Engineer** | edgeguard-ctl: initdb, migrate, cluster-join, promote, dump-config | mail-gateway cmd/nmg-ctl/ |
|
||||
@@ -38,7 +38,7 @@ Phase 1 (parallel):
|
||||
|
||||
Phase 2 (parallel, braucht Phase 1):
|
||||
API-Engineer → internal/handlers/ + cmd/edgeguard-api/
|
||||
Config-Generator → internal/{haproxy,nginx,squid,wireguard,unbound,firewall}/
|
||||
Config-Generator → internal/{haproxy,squid,wireguard,unbound,firewall}/
|
||||
CLI-Engineer → internal/services/ + cmd/edgeguard-ctl/
|
||||
|
||||
Phase 3 (parallel, braucht Phase 2):
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// Command edgeguard-api serves the management REST API on
|
||||
// 127.0.0.1:9443. nginx (or a dev curl) terminates TLS in front of
|
||||
// 127.0.0.1:9443. HAProxy (or a dev curl) terminates TLS in front of
|
||||
// it; this process is plain HTTP behind that.
|
||||
package main
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ Commands:
|
||||
migrate check Validate embedded migrations (no DB connect)
|
||||
migrate dump [dir] Write embedded SQL files to dir (default: ./migrations)
|
||||
initdb Create PostgreSQL role + database (idempotent)
|
||||
render-config Regenerate haproxy / nftables configs from PG (--no-reload, --only=)
|
||||
cluster-join Join an existing cluster (Phase 3, not yet implemented)
|
||||
promote Promote this node's PG to primary (Phase 3, not yet implemented)
|
||||
dump-config Print effective config (Phase 3, not yet implemented)
|
||||
@@ -42,6 +43,8 @@ func main() {
|
||||
os.Exit(cmdMigrate(os.Args[2:]))
|
||||
case "initdb":
|
||||
os.Exit(cmdInitDB(os.Args[2:]))
|
||||
case "render-config":
|
||||
os.Exit(cmdRenderConfig(os.Args[2:]))
|
||||
case "cluster-join", "cluster-leave", "promote", "dump-config":
|
||||
fmt.Fprintf(os.Stderr, "edgeguard-ctl: %q is a Phase-3 stub — not yet implemented\n", os.Args[1])
|
||||
os.Exit(1)
|
||||
|
||||
73
cmd/edgeguard-ctl/render.go
Normal file
73
cmd/edgeguard-ctl/render.go
Normal file
@@ -0,0 +1,73 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/configgen"
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/database"
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/firewall"
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/haproxy"
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/services/configorch"
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/squid"
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/unbound"
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/wireguard"
|
||||
)
|
||||
|
||||
// cmdRenderConfig regenerates every per-service config file from PG
|
||||
// state. Used after package install (postinst), after admin
|
||||
// mutations (UI button), or as a manual recovery action.
|
||||
//
|
||||
// Flags:
|
||||
//
|
||||
// --no-reload Write configs but skip systemctl reload.
|
||||
// --only=svc1,svc2 Run only the named generators.
|
||||
func cmdRenderConfig(args []string) int {
|
||||
skipReload := false
|
||||
var only []string
|
||||
for i := 0; i < len(args); i++ {
|
||||
switch {
|
||||
case args[i] == "--no-reload":
|
||||
skipReload = true
|
||||
case strings.HasPrefix(args[i], "--only="):
|
||||
val := strings.TrimPrefix(args[i], "--only=")
|
||||
only = strings.Split(val, ",")
|
||||
case args[i] == "-h" || args[i] == "--help":
|
||||
fmt.Println("Usage: edgeguard-ctl render-config [--no-reload] [--only=svc1,svc2]")
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||
defer cancel()
|
||||
|
||||
pool, err := database.Open(ctx, database.ConnStringFromEnv())
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "render-config: open db:", err)
|
||||
return 1
|
||||
}
|
||||
defer pool.Close()
|
||||
|
||||
hap := haproxy.New(pool)
|
||||
fw := firewall.New(pool)
|
||||
sq := squid.New()
|
||||
wg := wireguard.New()
|
||||
ub := unbound.New()
|
||||
if skipReload {
|
||||
hap.SkipReload = true
|
||||
fw.SkipReload = true
|
||||
}
|
||||
|
||||
gens := []configgen.Generator{hap, fw, sq, wg, ub}
|
||||
|
||||
results, runErr := configorch.Run(ctx, gens, only)
|
||||
fmt.Print(configorch.Summarise(results))
|
||||
if runErr != nil {
|
||||
fmt.Fprintln(os.Stderr, "render-config aborted:", runErr)
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
@@ -8,7 +8,7 @@ EdgeGuard ist die native Neufassung des bisherigen Docker-basierten Reverse-Prox
|
||||
|
||||
## 0. Leitplanken (nicht verhandelbar)
|
||||
|
||||
- **Kein Docker.** Alle Dienste nativ unter `systemd`, installiert via `apt`. Distro-Pakete für Drittsoftware (HAProxy, nginx, Squid, WireGuard, Unbound, PostgreSQL, KeyDB, certbot), eigene `.deb`-Pakete für EdgeGuard-Code (api, ui, ctl).
|
||||
- **Kein Docker.** Alle Dienste nativ unter `systemd`, installiert via `apt`. Distro-Pakete für Drittsoftware (HAProxy, Squid, WireGuard, Unbound, PostgreSQL, KeyDB, certbot), eigene `.deb`-Pakete für EdgeGuard-Code (api, ui, ctl).
|
||||
- **Plattform-Matrix:** Debian 13 (Trixie) **und** Ubuntu 24.04 LTS (Noble Numbat), je amd64 + arm64. Alle vier Targets gleichberechtigt.
|
||||
- **Auslieferung:** signierte `.deb`-Pakete + Meta-Paket via APT. Bootstrap ist der enconf-analoge curl-Onliner `curl -fsSL https://get.edgeguard.netcell-it.de | sudo bash`.
|
||||
- **HA nativ als Cluster:** N symmetrische Peers, **KeyDB Active-Active** für Shared State + **PostgreSQL Streaming Replication** (single writer, transparenter API-Write-Proxy) + **Floating-IP des Hosters** für HTTP/HTTPS-Ingress (nicht VRRP, nicht DNS-RR).
|
||||
@@ -23,8 +23,7 @@ EdgeGuard ist die native Neufassung des bisherigen Docker-basierten Reverse-Prox
|
||||
|
||||
| Service | Rolle | Distro-Paket | Config-Quelle |
|
||||
|---|---|---|---|
|
||||
| **HAProxy** | Public-Ingress (80/443), interner LB (8081), TLS-PassThrough | `haproxy` (Debian/Ubuntu) | aus PG generiert, `systemctl reload haproxy` |
|
||||
| **nginx** | Reverse-Proxy, vHost-Routing, ACME-Webroot | `nginx` (Debian/Ubuntu) | aus PG generiert, `systemctl reload nginx` |
|
||||
| **HAProxy** | Public-Ingress :80 + :443, **TLS-Termination**, L7-Routing per Host-Header, LB. Proxied `/.well-known/acme-challenge/*` und Management-FQDN-Traffic an `edgeguard-api` auf 127.0.0.1:9443; rest geht an User-Backends aus `backends`-Tabelle. | `haproxy` (Debian/Ubuntu) | aus PG generiert, `systemctl reload haproxy` |
|
||||
| **Squid** | Forward-Proxy mit ACL/Auth | `squid` | aus PG generiert, `systemctl reload squid` |
|
||||
| **WireGuard** | Site-to-Site- + Road-Warrior-VPN | `wireguard-tools` (Kernel-Modul ab Kernel 5.6) | aus PG generiert, `wg syncconf` |
|
||||
| **Unbound** | Caching-Forwarder mit DNSSEC + Cluster-internes Split-Horizon (siehe §7.5) | `unbound` (Debian/Ubuntu) | aus PG generiert, `unbound-control reload` |
|
||||
@@ -37,7 +36,7 @@ EdgeGuard ist die native Neufassung des bisherigen Docker-basierten Reverse-Prox
|
||||
| `edgeguard-api` | Go/Gin REST-API, bindet `127.0.0.1:9443`, Reads aus lokaler PG, Writes an Cluster-Primary |
|
||||
| `edgeguard-scheduler` | Cron-artige Jobs (ACME-Renewal-Hook, Backup, Health-Aggregation, License-Heartbeat) |
|
||||
| `edgeguard-ctl` | CLI für Setup/Wartung (`initdb`, `migrate`, `cluster-join`, `promote`, `dump-config`) |
|
||||
| `management-ui` | React 19 + AntD 6 + Vite, statisch unter `/usr/share/edgeguard/ui/`, nginx liefert aus |
|
||||
| `management-ui` | React 19 + AntD 6 + Vite, statisch unter `/usr/share/edgeguard/ui/`, von `edgeguard-api` per gin `StaticFS` ausgeliefert (HAProxy proxied Management-FQDN dorthin) |
|
||||
| **PostgreSQL 16** | Single Source of Truth — Domains, Backends, Routing-Rules, ACLs, Peers, etc. |
|
||||
| **KeyDB** (Redis-kompatibel) | Active-Active-Replication, Cluster-State, Locks, Rate-Counter, Pub/Sub für Config-Reload |
|
||||
|
||||
@@ -57,8 +56,7 @@ EdgeGuard ist die native Neufassung des bisherigen Docker-basierten Reverse-Prox
|
||||
│ ├── models/ # GORM-Models (domain, backend, routing_rule, acl, peer, …)
|
||||
│ ├── handlers/ # HTTP-Handler (REST)
|
||||
│ ├── services/ # Business-Logik (config-render, health-check, cluster-sync)
|
||||
│ ├── haproxy/ # HAProxy-Config-Generator
|
||||
│ ├── nginx/ # nginx-Config-Generator (vHosts, Upstreams, ACME)
|
||||
│ ├── haproxy/ # HAProxy-Config-Generator (TLS + Routing + LB)
|
||||
│ ├── squid/ # Squid-Config-Generator (squid.conf + squid.d/*)
|
||||
│ ├── wireguard/ # WireGuard-Config-Generator (wg-quick + wg syncconf)
|
||||
│ ├── unbound/ # Unbound-Config-Generator (Forwarder + Cluster-DNS)
|
||||
@@ -75,8 +73,7 @@ EdgeGuard ist die native Neufassung des bisherigen Docker-basierten Reverse-Prox
|
||||
│ └── edgeguard-meta/ # nur Depends, keine Dateien
|
||||
├── deploy/
|
||||
│ ├── systemd/ # *.service, *.target, *.timer
|
||||
│ ├── haproxy/ # haproxy.cfg.tpl
|
||||
│ ├── nginx/ # nginx.conf.tpl, sni-map.tpl
|
||||
│ ├── haproxy/ # (Templates jetzt embedded neben Renderer)
|
||||
│ ├── squid/ # squid.conf.tpl
|
||||
│ ├── unbound/ # unbound.conf.tpl
|
||||
│ └── nftables/ # ruleset.nft.tpl
|
||||
@@ -101,8 +98,8 @@ Drei Pakete + Meta — analog nmg, kein WAF-Paket weil kein WAF in v1.
|
||||
|
||||
| Paket | Arch | Inhalt | Depends |
|
||||
|---|---|---|---|
|
||||
| `edgeguard-api` | amd64, arm64 | `/usr/bin/edgeguard-{api,scheduler,ctl}`, Unit-Files, Migrations, Default-Configs | `postgresql-16`, `keydb-server`, `nginx`, `haproxy`, `squid`, `wireguard-tools`, `unbound`, `nftables`, `certbot` |
|
||||
| `edgeguard-ui` | all | `/usr/share/edgeguard/ui/` (statische Build-Artefakte), nginx-Site | `edgeguard-api (= ${binary:Version})`, `nginx` |
|
||||
| `edgeguard-api` | amd64, arm64 | `/usr/bin/edgeguard-{api,scheduler,ctl}`, Unit-Files, Migrations, Default-Configs | `postgresql-16`, `keydb-server`, `haproxy`, `squid`, `wireguard-tools`, `unbound`, `nftables`, `certbot`, `openssl` |
|
||||
| `edgeguard-ui` | all | `/usr/share/edgeguard/ui/` (statische Build-Artefakte) | `edgeguard-api (= ${binary:Version})` |
|
||||
| `edgeguard-meta` | all | keine Dateien, nur `Depends` | `edgeguard-api`, `edgeguard-ui` |
|
||||
|
||||
Pro Release: 1 arch-spezifisch × 2 Dists × 2 Arches = 4 `.deb` + 2 arch-agnostische × 2 Dists = 4 `.deb` → **8 Artefakte je Release**.
|
||||
@@ -129,8 +126,7 @@ Pro Release: 1 arch-spezifisch × 2 Dists × 2 Arches = 4 `.deb` + 2 arch-agnost
|
||||
/etc/edgeguard/
|
||||
├── edgeguard.yaml # Hauptconfig (conffile)
|
||||
├── api.env # API-Secrets (mode 0600, edgeguard:edgeguard)
|
||||
├── haproxy/ # haproxy.cfg-Fragmente (von edgeguard-api generiert)
|
||||
├── nginx/ # vHosts + sni-map (generiert)
|
||||
├── haproxy/ # haproxy.cfg (von edgeguard-api generiert)
|
||||
├── squid/ # squid.conf-Fragmente
|
||||
├── wireguard/ # wg0.conf etc. (generiert)
|
||||
├── unbound/ # unbound.conf + cluster-zone.conf (generiert)
|
||||
@@ -155,7 +151,7 @@ Pro Release: 1 arch-spezifisch × 2 Dists × 2 Arches = 4 `.deb` + 2 arch-agnost
|
||||
|
||||
/usr/share/edgeguard/
|
||||
├── ui/ # statische React-Build-Artefakte
|
||||
└── templates/ # Config-Templates für haproxy/nginx/squid/wireguard/unbound/nftables
|
||||
└── templates/ # Config-Templates für squid/wireguard/unbound (haproxy + nftables sind im Binary embedded)
|
||||
```
|
||||
|
||||
Entspricht FHS — keine Überraschungen für Admins, Lintian-clean.
|
||||
@@ -187,9 +183,9 @@ SystemCallFilter=@system-service
|
||||
ReadWritePaths=/var/lib/edgeguard /var/log/edgeguard /etc/edgeguard
|
||||
```
|
||||
|
||||
Drittsoftware (HAProxy, nginx, Squid, WireGuard via `wg-quick@.service`, Unbound, nftables) läuft als **Distro-Units**. EdgeGuard generiert deren Config + signalisiert Reload, übernimmt aber die Service-Verwaltung **nicht**.
|
||||
Drittsoftware (HAProxy, Squid, WireGuard via `wg-quick@.service`, Unbound, nftables) läuft als **Distro-Units**. EdgeGuard generiert deren Config + signalisiert Reload, übernimmt aber die Service-Verwaltung **nicht**.
|
||||
|
||||
API bindet auf `127.0.0.1:9443` (nicht öffentlich). nginx terminiert TLS auf `:443` und proxied an die API.
|
||||
API bindet auf `127.0.0.1:9443` (nicht öffentlich). HAProxy terminiert TLS auf `:443`, leitet `/.well-known/acme-challenge/*` und Management-FQDN-Traffic an die API weiter, routet alle anderen Hosts per ACL an die User-Backends.
|
||||
|
||||
---
|
||||
|
||||
@@ -231,7 +227,7 @@ Unbound erfüllt zwei Rollen, beide aus PG generiert:
|
||||
- **DNSSEC-Validation aktiv** (`auto-trust-anchor-file`).
|
||||
- Lokaler Cache (TTL nach Upstream-Antwort).
|
||||
- Listen: `127.0.0.1:53` für die EdgeGuard-Box selbst und `<node-internal-ip>:53` für VPN- und LAN-Clients (über nftables-ACL gefiltert).
|
||||
- Genutzt von `edgeguard-api`, `edgeguard-scheduler` (License-Heartbeat, ACME), Squid (für Forward-Proxy-Resolutions), HAProxy/nginx (Backend-Health-Checks).
|
||||
- Genutzt von `edgeguard-api`, `edgeguard-scheduler` (License-Heartbeat, ACME), Squid (für Forward-Proxy-Resolutions), HAProxy (Backend-Health-Checks).
|
||||
|
||||
### Rolle 2 — Cluster-internes Split-Horizon
|
||||
|
||||
@@ -261,8 +257,7 @@ Reload via `unbound-control reload` (kein Restart, keine Cache-Invalidierung au
|
||||
|
||||
| Service | HA-Strategie |
|
||||
|---|---|
|
||||
| **HAProxy** | stateless, pro Node identisch. Floating-IP zeigt zum aktuellen aktiven Node; bei Node-Ausfall API-Call zum Hoster (oder manueller Switch) reicht. |
|
||||
| **nginx** | stateless, pro Node identisch. ACME-Issue nur auf License-Leader (KeyDB-Lock); Zerts werden via PG/mTLS an alle verteilt. |
|
||||
| **HAProxy** | stateless, pro Node identisch. Floating-IP zeigt zum aktuellen aktiven Node; bei Node-Ausfall API-Call zum Hoster (oder manueller Switch) reicht. ACME-Issue nur auf License-Leader (KeyDB-Lock); Zerts werden via PG/mTLS an alle verteilt. |
|
||||
| **Squid** | stateless (Cache lokal, kein Sync nötig). Pro Node identische ACL-Config. |
|
||||
| **WireGuard** | siehe §8.1 |
|
||||
| **Unbound** | stateless (Cache lokal). Pro Node identische Forwarder-Config + identische Cluster-internen Local-Zones (siehe §7.5). |
|
||||
@@ -284,7 +279,7 @@ Drei Optionen, für v1 wählen wir **Option A**:
|
||||
|
||||
### 8.2 Manual Promote (PG-Primary-Failover)
|
||||
|
||||
1:1 nmg-Pattern (siehe `mail-gateway/docs/architecture.md` §6.2). Bei Ausfall des Primary antworten Config-Writes mit `503 + actionable Error`. Admin promotet via UI/CLI. Datenebene (HAProxy/nginx/Squid/WireGuard/Unbound) läuft unbeeinträchtigt weiter, weil jeder Node eine lokale PG-Replica hat.
|
||||
1:1 nmg-Pattern (siehe `mail-gateway/docs/architecture.md` §6.2). Bei Ausfall des Primary antworten Config-Writes mit `503 + actionable Error`. Admin promotet via UI/CLI. Datenebene (HAProxy/Squid/WireGuard/Unbound) läuft unbeeinträchtigt weiter, weil jeder Node eine lokale PG-Replica hat.
|
||||
|
||||
### 8.3 License-Leader-Election
|
||||
|
||||
@@ -365,9 +360,9 @@ Build-/Release-Scripts identisch zu `mail-gateway/scripts/apt-repo/`.
|
||||
|
||||
### 12.2 ACME
|
||||
|
||||
- **certbot** (Distro-Paket) mit `--webroot`-Plugin für nginx-vHosts.
|
||||
- **certbot** (Distro-Paket) mit `--webroot=/var/lib/edgeguard/acme` — HAProxy ACL `path_beg /.well-known/acme-challenge/` proxied diese Pfade an `edgeguard-api`, das die Challenge-Tokens aus der Webroot-Dir ausliefert.
|
||||
- **Lock vor Issue:** `acme:lock:<domain>` in KeyDB verhindert Parallel-Issue auf zwei Nodes.
|
||||
- **Deploy-Hook:** schreibt Sentinel-Datei → `edgeguard-cert-deploy.path`-Unit triggert nginx-/HAProxy-Reload.
|
||||
- **Deploy-Hook:** schreibt fertiges PEM (cert+chain+key kombiniert) nach `/etc/edgeguard/tls/<domain>.pem` und triggert `systemctl reload haproxy`. HAProxy lädt den `crt /etc/edgeguard/tls/`-Verzeichnisinhalt neu.
|
||||
- **Cert-Verteilung im Cluster:** Issuing-Node pushed via mTLS-API an alle Peers, Zerts landen in `/etc/edgeguard/tls/`.
|
||||
|
||||
---
|
||||
|
||||
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
|
||||
}
|
||||
@@ -4,14 +4,15 @@ Architecture: __ARCH__
|
||||
Maintainer: NetCell IT <support@netcell-it.de>
|
||||
Homepage: https://edgeguard.netcell-it.de
|
||||
Description: EdgeGuard — native Reverse-Proxy / LB / Forward-Proxy / VPN / Firewall
|
||||
EdgeGuard is a native Debian/Ubuntu edge gateway combining HAProxy,
|
||||
nginx, Squid, WireGuard, Unbound and nftables, configured from
|
||||
a PostgreSQL single-source-of-truth via a Go management API.
|
||||
EdgeGuard is a native Debian/Ubuntu edge gateway combining HAProxy
|
||||
(TLS termination + L7 routing + LB), Squid, WireGuard, Unbound and
|
||||
nftables, configured from a PostgreSQL single-source-of-truth via
|
||||
a Go management API.
|
||||
Deployable as a cluster of symmetric peers (KeyDB Active-Active +
|
||||
PG Streaming Replication + provider Floating-IP for HTTP ingress).
|
||||
.
|
||||
This package ships the management API, scheduler and CLI.
|
||||
Depends: postgresql-16 | postgresql-17, edgeguard-keydb (>= 6.3.4-edgeguard1), nginx, haproxy (>= 2.8), squid, wireguard-tools, unbound, nftables, certbot, sudo, adduser, systemd, ca-certificates
|
||||
Depends: postgresql-16 | postgresql-17, edgeguard-keydb (>= 6.3.4-edgeguard1), haproxy (>= 2.8), squid, wireguard-tools, unbound, nftables, certbot, openssl, sudo, adduser, systemd, ca-certificates
|
||||
Recommends: apparmor, fail2ban
|
||||
Section: admin
|
||||
Priority: optional
|
||||
|
||||
@@ -21,13 +21,35 @@ case "$1" in
|
||||
|
||||
# ── Directories ──────────────────────────────────────────────
|
||||
for d in /etc/edgeguard /var/lib/edgeguard /var/log/edgeguard \
|
||||
/etc/edgeguard/haproxy /etc/edgeguard/nginx \
|
||||
/etc/edgeguard/squid /etc/edgeguard/wireguard \
|
||||
/etc/edgeguard/unbound /etc/edgeguard/nftables.d \
|
||||
/etc/edgeguard/tls; do
|
||||
/etc/edgeguard/haproxy /etc/edgeguard/squid \
|
||||
/etc/edgeguard/wireguard /etc/edgeguard/unbound \
|
||||
/etc/edgeguard/nftables.d /etc/edgeguard/tls \
|
||||
/var/lib/edgeguard/acme; do
|
||||
install -d -m 0750 -o "$EG_USER" -g "$EG_USER" "$d"
|
||||
done
|
||||
|
||||
# ── Self-signed default cert so HAProxy starts cleanly ───────
|
||||
# HAProxy `bind :443 ssl crt /etc/edgeguard/tls/` needs at least
|
||||
# one PEM in the directory to come up. Operator runs certbot
|
||||
# later; until then, browsers see an unverified cert which is
|
||||
# the expected first-boot UX.
|
||||
DEFAULT_PEM="/etc/edgeguard/tls/_default.pem"
|
||||
if [ ! -f "$DEFAULT_PEM" ]; then
|
||||
HOSTNAME_FQDN="$(hostname -f 2>/dev/null || hostname)"
|
||||
TMP_KEY="$(mktemp)"
|
||||
TMP_CRT="$(mktemp)"
|
||||
openssl req -x509 -nodes -newkey rsa:2048 \
|
||||
-keyout "$TMP_KEY" -out "$TMP_CRT" \
|
||||
-days 3650 \
|
||||
-subj "/CN=$HOSTNAME_FQDN" \
|
||||
-addext "subjectAltName = DNS:$HOSTNAME_FQDN,DNS:localhost" \
|
||||
>/dev/null 2>&1
|
||||
cat "$TMP_CRT" "$TMP_KEY" > "$DEFAULT_PEM"
|
||||
chown "$EG_USER:$EG_USER" "$DEFAULT_PEM"
|
||||
chmod 0640 "$DEFAULT_PEM"
|
||||
rm -f "$TMP_KEY" "$TMP_CRT"
|
||||
fi
|
||||
|
||||
# ── Pre-flight: validate embedded migration set ──────────────
|
||||
# Catches duplicate version prefixes BEFORE we touch the DB,
|
||||
# so a broken upgrade can't half-apply migrations and leave
|
||||
|
||||
@@ -5,7 +5,7 @@ Maintainer: NetCell IT <support@netcell-it.de>
|
||||
Homepage: https://edgeguard.netcell-it.de
|
||||
Description: EdgeGuard — meta package
|
||||
Pulls the full EdgeGuard stack: management API, UI, configured
|
||||
third-party services (HAProxy, nginx, Squid, WireGuard, Unbound, nftables).
|
||||
third-party services (HAProxy, Squid, WireGuard, Unbound, nftables).
|
||||
.
|
||||
Install this package to get a complete EdgeGuard node.
|
||||
Depends: edgeguard-api (= __VERSION__), edgeguard-ui (= __VERSION__)
|
||||
|
||||
@@ -5,8 +5,9 @@ Maintainer: NetCell IT <support@netcell-it.de>
|
||||
Homepage: https://edgeguard.netcell-it.de
|
||||
Description: EdgeGuard — management UI (static React build)
|
||||
React 19 + Ant Design 6 single-page admin UI for EdgeGuard.
|
||||
Served by the nginx reverse proxy bundled in edgeguard-api.
|
||||
Depends: edgeguard-api (= __VERSION__), nginx
|
||||
Static build artefacts under /usr/share/edgeguard/ui/, served by
|
||||
edgeguard-api (gin StaticFS) on the management FQDN.
|
||||
Depends: edgeguard-api (= __VERSION__)
|
||||
Section: admin
|
||||
Priority: optional
|
||||
Installed-Size: 0
|
||||
|
||||
Reference in New Issue
Block a user