feat(configgen): Phase 2 Config-Generator + nginx → HAProxy-only Pivot

Architektur-Pivot: nginx fällt komplett weg. HAProxy 2.8+ übernimmt
TLS-Termination, L7-Routing per Host-Header und LB. ACME-Webroot
und Management-UI werden von edgeguard-api ausgeliefert (Phase 3
implementiert die zugehörigen Handler); HAProxy proxied
/.well-known/acme-challenge/* und Management-FQDN-Traffic an
127.0.0.1:9443. Eine Distro-Abhängigkeit weniger, ein Renderer
weniger, sauberere Trennung.

Renderer (alle mit Embed-Templates + Tests):
* internal/configgen/  — atomic write + systemctl reload helpers
* internal/haproxy/    — :80 + :443, ACME-ACL, Host-Header-Routing,
                         Stats-Frontend, api_backend Fallback
* internal/firewall/   — default-deny input, stateful baseline,
                         SSH-Rate-Limit, :80/:443 accept,
                         Cluster-Peer-Set für mTLS :8443,
                         Custom-Rules aus PG
* internal/{squid,wireguard,unbound}/ — Stubs (ErrNotImplemented)

Orchestrator + CLI:
* internal/services/configorch/  — fester Reihenfolge-Run, Stubs
                                   sind soft-skip statt fatal
* cmd/edgeguard-ctl render-config [--no-reload] [--only=svc1,svc2]

Packaging:
* postinst: /etc/edgeguard/nginx raus, /var/lib/edgeguard/acme rein,
  self-signed _default.pem via openssl req (damit HAProxy startet
  bevor certbot etwas issuet hat)
* control: Depends nginx raus, openssl rein
* edgeguard-ui: dependency auf nginx weg, "Served by edgeguard-api
  gin StaticFS"

Live-Smoke: render-config gegen lokale PG schreibt /etc/edgeguard/
haproxy/haproxy.cfg + nftables.d/ruleset.nft korrekt; CRUD-Test aus
Phase 2 läuft weiter unverändert.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Debian
2026-05-09 10:59:52 +02:00
parent 0a6f81beaa
commit 914538eed1
26 changed files with 1002 additions and 44 deletions

View File

@@ -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 | | **UI** | React 19, TypeScript strict, Vite, Ant Design 6, TanStack Query 5 |
| **DB** | PostgreSQL 16 (Distro-Paket), goose-Migrations in `migrations/` | | **DB** | PostgreSQL 16 (Distro-Paket), goose-Migrations in `migrations/` |
| **State/HA** | KeyDB Active-Active (Redis-kompatibel) | | **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`) | | **VPN** | WireGuard (Kernel-Modul ab 5.6, `wireguard-tools`) |
| **DNS** | Unbound (Distro) — Forwarder+Cache mit DNSSEC, Cluster-internes Split-Horizon | | **DNS** | Unbound (Distro) — Forwarder+Cache mit DNSSEC, Cluster-internes Split-Horizon |
| **FW** | nftables (Distro) | | **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-scheduler` | Cron-Jobs (ACME-Renew, Backup, Health) | — | `edgeguard` |
| `edgeguard-ctl` | CLI: `initdb migrate cluster-join promote dump-config` | — | root/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 ```bash
# Abhängigkeiten installieren # 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) # API starten (ohne systemd, für Entwicklung)
go run ./cmd/edgeguard-api/ go run ./cmd/edgeguard-api/
@@ -137,8 +137,7 @@ cd management-ui && bun run dev
│ ├── models/ # GORM-Models │ ├── models/ # GORM-Models
│ ├── handlers/ # HTTP-Handler (REST) │ ├── handlers/ # HTTP-Handler (REST)
│ ├── services/ # Business-Logik │ ├── services/ # Business-Logik
│ ├── haproxy/ # Config-Generator │ ├── haproxy/ # Config-Generator (TLS + Routing + LB)
│ ├── nginx/ # Config-Generator
│ ├── squid/ # Config-Generator │ ├── squid/ # Config-Generator
│ ├── wireguard/ # Config-Generator │ ├── wireguard/ # Config-Generator
│ ├── unbound/ # Config-Generator (Forwarder + Cluster-DNS) │ ├── unbound/ # Config-Generator (Forwarder + Cluster-DNS)
@@ -151,8 +150,7 @@ cd management-ui && bun run dev
├── packaging/debian/ # control, postinst, postrm, systemd-Units ├── packaging/debian/ # control, postinst, postrm, systemd-Units
├── deploy/ ├── deploy/
│ ├── systemd/ # *.service, *.target, *.timer │ ├── systemd/ # *.service, *.target, *.timer
│ ├── haproxy/ # haproxy.cfg.tpl │ ├── haproxy/ # (Templates liegen jetzt neben Renderer in internal/<svc>/)
│ ├── nginx/ # vhost.conf.tpl, sni-map.tpl
│ ├── squid/ # squid.conf.tpl │ ├── squid/ # squid.conf.tpl
│ ├── unbound/ # unbound.conf.tpl │ ├── unbound/ # unbound.conf.tpl
│ └── nftables/ # ruleset.nft.tpl │ └── nftables/ # ruleset.nft.tpl

View File

@@ -14,7 +14,7 @@ curl -fsSL https://get.edgeguard.netcell-it.de | sudo bash
## Architektur in Kürze ## 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. - **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. - **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. - **Auslieferung:** signierte `.deb`, Update via `apt`. Update-Trigger via UI/API.

View File

@@ -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/ | | **DB-Architect** | Datenbankschema, goose-Migrations, GORM-Models | mail-gateway models/ |
| **API-Engineer** | Gin-Router, alle Handler, Middleware (Auth/JWT/RBAC) | mail-gateway handlers/ | | **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/ | | **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 | | **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/ | | **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): Phase 2 (parallel, braucht Phase 1):
API-Engineer → internal/handlers/ + cmd/edgeguard-api/ 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/ CLI-Engineer → internal/services/ + cmd/edgeguard-ctl/
Phase 3 (parallel, braucht Phase 2): Phase 3 (parallel, braucht Phase 2):

View File

@@ -1,5 +1,5 @@
// Command edgeguard-api serves the management REST API on // 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. // it; this process is plain HTTP behind that.
package main package main

View File

@@ -23,6 +23,7 @@ Commands:
migrate check Validate embedded migrations (no DB connect) migrate check Validate embedded migrations (no DB connect)
migrate dump [dir] Write embedded SQL files to dir (default: ./migrations) migrate dump [dir] Write embedded SQL files to dir (default: ./migrations)
initdb Create PostgreSQL role + database (idempotent) 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) cluster-join Join an existing cluster (Phase 3, not yet implemented)
promote Promote this node's PG to primary (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) dump-config Print effective config (Phase 3, not yet implemented)
@@ -42,6 +43,8 @@ func main() {
os.Exit(cmdMigrate(os.Args[2:])) os.Exit(cmdMigrate(os.Args[2:]))
case "initdb": case "initdb":
os.Exit(cmdInitDB(os.Args[2:])) os.Exit(cmdInitDB(os.Args[2:]))
case "render-config":
os.Exit(cmdRenderConfig(os.Args[2:]))
case "cluster-join", "cluster-leave", "promote", "dump-config": 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]) fmt.Fprintf(os.Stderr, "edgeguard-ctl: %q is a Phase-3 stub — not yet implemented\n", os.Args[1])
os.Exit(1) os.Exit(1)

View 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
}

View File

View File

@@ -8,7 +8,7 @@ EdgeGuard ist die native Neufassung des bisherigen Docker-basierten Reverse-Prox
## 0. Leitplanken (nicht verhandelbar) ## 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. - **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`. - **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). - **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 | | 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` | | **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` |
| **nginx** | Reverse-Proxy, vHost-Routing, ACME-Webroot | `nginx` (Debian/Ubuntu) | aus PG generiert, `systemctl reload nginx` |
| **Squid** | Forward-Proxy mit ACL/Auth | `squid` | aus PG generiert, `systemctl reload squid` | | **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` | | **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` | | **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-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-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`) | | `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. | | **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 | | **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, …) │ ├── models/ # GORM-Models (domain, backend, routing_rule, acl, peer, …)
│ ├── handlers/ # HTTP-Handler (REST) │ ├── handlers/ # HTTP-Handler (REST)
│ ├── services/ # Business-Logik (config-render, health-check, cluster-sync) │ ├── services/ # Business-Logik (config-render, health-check, cluster-sync)
│ ├── haproxy/ # HAProxy-Config-Generator │ ├── haproxy/ # HAProxy-Config-Generator (TLS + Routing + LB)
│ ├── nginx/ # nginx-Config-Generator (vHosts, Upstreams, ACME)
│ ├── squid/ # Squid-Config-Generator (squid.conf + squid.d/*) │ ├── squid/ # Squid-Config-Generator (squid.conf + squid.d/*)
│ ├── wireguard/ # WireGuard-Config-Generator (wg-quick + wg syncconf) │ ├── wireguard/ # WireGuard-Config-Generator (wg-quick + wg syncconf)
│ ├── unbound/ # Unbound-Config-Generator (Forwarder + Cluster-DNS) │ ├── 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 │ └── edgeguard-meta/ # nur Depends, keine Dateien
├── deploy/ ├── deploy/
│ ├── systemd/ # *.service, *.target, *.timer │ ├── systemd/ # *.service, *.target, *.timer
│ ├── haproxy/ # haproxy.cfg.tpl │ ├── haproxy/ # (Templates jetzt embedded neben Renderer)
│ ├── nginx/ # nginx.conf.tpl, sni-map.tpl
│ ├── squid/ # squid.conf.tpl │ ├── squid/ # squid.conf.tpl
│ ├── unbound/ # unbound.conf.tpl │ ├── unbound/ # unbound.conf.tpl
│ └── nftables/ # ruleset.nft.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 | | 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-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), nginx-Site | `edgeguard-api (= ${binary:Version})`, `nginx` | | `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` | | `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**. 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/ /etc/edgeguard/
├── edgeguard.yaml # Hauptconfig (conffile) ├── edgeguard.yaml # Hauptconfig (conffile)
├── api.env # API-Secrets (mode 0600, edgeguard:edgeguard) ├── api.env # API-Secrets (mode 0600, edgeguard:edgeguard)
├── haproxy/ # haproxy.cfg-Fragmente (von edgeguard-api generiert) ├── haproxy/ # haproxy.cfg (von edgeguard-api generiert)
├── nginx/ # vHosts + sni-map (generiert)
├── squid/ # squid.conf-Fragmente ├── squid/ # squid.conf-Fragmente
├── wireguard/ # wg0.conf etc. (generiert) ├── wireguard/ # wg0.conf etc. (generiert)
├── unbound/ # unbound.conf + cluster-zone.conf (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/ /usr/share/edgeguard/
├── ui/ # statische React-Build-Artefakte ├── 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. 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 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`). - **DNSSEC-Validation aktiv** (`auto-trust-anchor-file`).
- Lokaler Cache (TTL nach Upstream-Antwort). - 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). - 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 ### Rolle 2 — Cluster-internes Split-Horizon
@@ -261,8 +257,7 @@ Reload via `unbound-control reload` (kein Restart, keine Cache-Invalidierung au
| Service | HA-Strategie | | 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. | | **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. |
| **nginx** | stateless, pro Node identisch. 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. | | **Squid** | stateless (Cache lokal, kein Sync nötig). Pro Node identische ACL-Config. |
| **WireGuard** | siehe §8.1 | | **WireGuard** | siehe §8.1 |
| **Unbound** | stateless (Cache lokal). Pro Node identische Forwarder-Config + identische Cluster-internen Local-Zones (siehe §7.5). | | **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) ### 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 ### 8.3 License-Leader-Election
@@ -365,9 +360,9 @@ Build-/Release-Scripts identisch zu `mail-gateway/scripts/apt-repo/`.
### 12.2 ACME ### 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. - **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/`. - **Cert-Verteilung im Cluster:** Issuing-Node pushed via mTLS-API an alle Peers, Zerts landen in `/etc/edgeguard/tls/`.
--- ---

View 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"

View 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())
}
}
}

View File

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

View File

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

View File

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

View 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
View 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
}

View 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)
}
}

View 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()
}

View File

@@ -28,7 +28,7 @@ FROM routing_rules
` `
// List returns rules ordered by domain_id then priority desc — the // 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) { func (r *Repo) List(ctx context.Context) ([]models.RoutingRule, error) {
rows, err := r.Pool.Query(ctx, baseSelect+ rows, err := r.Pool.Query(ctx, baseSelect+
" ORDER BY domain_id ASC, priority DESC, id ASC") " ORDER BY domain_id ASC, priority DESC, id ASC")

20
internal/squid/squid.go Normal file
View 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
}

View 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
}

View 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
}

View File

@@ -4,14 +4,15 @@ Architecture: __ARCH__
Maintainer: NetCell IT <support@netcell-it.de> Maintainer: NetCell IT <support@netcell-it.de>
Homepage: https://edgeguard.netcell-it.de Homepage: https://edgeguard.netcell-it.de
Description: EdgeGuard — native Reverse-Proxy / LB / Forward-Proxy / VPN / Firewall Description: EdgeGuard — native Reverse-Proxy / LB / Forward-Proxy / VPN / Firewall
EdgeGuard is a native Debian/Ubuntu edge gateway combining HAProxy, EdgeGuard is a native Debian/Ubuntu edge gateway combining HAProxy
nginx, Squid, WireGuard, Unbound and nftables, configured from (TLS termination + L7 routing + LB), Squid, WireGuard, Unbound and
a PostgreSQL single-source-of-truth via a Go management API. nftables, configured from a PostgreSQL single-source-of-truth via
a Go management API.
Deployable as a cluster of symmetric peers (KeyDB Active-Active + Deployable as a cluster of symmetric peers (KeyDB Active-Active +
PG Streaming Replication + provider Floating-IP for HTTP ingress). PG Streaming Replication + provider Floating-IP for HTTP ingress).
. .
This package ships the management API, scheduler and CLI. 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 Recommends: apparmor, fail2ban
Section: admin Section: admin
Priority: optional Priority: optional

View File

@@ -21,13 +21,35 @@ case "$1" in
# ── Directories ────────────────────────────────────────────── # ── Directories ──────────────────────────────────────────────
for d in /etc/edgeguard /var/lib/edgeguard /var/log/edgeguard \ for d in /etc/edgeguard /var/lib/edgeguard /var/log/edgeguard \
/etc/edgeguard/haproxy /etc/edgeguard/nginx \ /etc/edgeguard/haproxy /etc/edgeguard/squid \
/etc/edgeguard/squid /etc/edgeguard/wireguard \ /etc/edgeguard/wireguard /etc/edgeguard/unbound \
/etc/edgeguard/unbound /etc/edgeguard/nftables.d \ /etc/edgeguard/nftables.d /etc/edgeguard/tls \
/etc/edgeguard/tls; do /var/lib/edgeguard/acme; do
install -d -m 0750 -o "$EG_USER" -g "$EG_USER" "$d" install -d -m 0750 -o "$EG_USER" -g "$EG_USER" "$d"
done 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 ────────────── # ── Pre-flight: validate embedded migration set ──────────────
# Catches duplicate version prefixes BEFORE we touch the DB, # Catches duplicate version prefixes BEFORE we touch the DB,
# so a broken upgrade can't half-apply migrations and leave # so a broken upgrade can't half-apply migrations and leave

View File

@@ -5,7 +5,7 @@ Maintainer: NetCell IT <support@netcell-it.de>
Homepage: https://edgeguard.netcell-it.de Homepage: https://edgeguard.netcell-it.de
Description: EdgeGuard — meta package Description: EdgeGuard — meta package
Pulls the full EdgeGuard stack: management API, UI, configured 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. Install this package to get a complete EdgeGuard node.
Depends: edgeguard-api (= __VERSION__), edgeguard-ui (= __VERSION__) Depends: edgeguard-api (= __VERSION__), edgeguard-ui (= __VERSION__)

View File

@@ -5,8 +5,9 @@ Maintainer: NetCell IT <support@netcell-it.de>
Homepage: https://edgeguard.netcell-it.de Homepage: https://edgeguard.netcell-it.de
Description: EdgeGuard — management UI (static React build) Description: EdgeGuard — management UI (static React build)
React 19 + Ant Design 6 single-page admin UI for EdgeGuard. React 19 + Ant Design 6 single-page admin UI for EdgeGuard.
Served by the nginx reverse proxy bundled in edgeguard-api. Static build artefacts under /usr/share/edgeguard/ui/, served by
Depends: edgeguard-api (= __VERSION__), nginx edgeguard-api (gin StaticFS) on the management FQDN.
Depends: edgeguard-api (= __VERSION__)
Section: admin Section: admin
Priority: optional Priority: optional
Installed-Size: 0 Installed-Size: 0