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 |
| **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

View File

@@ -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.

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/ |
| **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):

View File

@@ -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

View File

@@ -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)

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)
- **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/`.
---

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
// 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
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>
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

View File

@@ -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

View File

@@ -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__)

View File

@@ -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