diff --git a/CLAUDE.md b/CLAUDE.md index 2edf767..ac8453f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -57,7 +57,7 @@ ac_search_code(query="", 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="", 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//) │ ├── squid/ # squid.conf.tpl │ ├── unbound/ # unbound.conf.tpl │ └── nftables/ # ruleset.nft.tpl diff --git a/README.md b/README.md index 24942a3..805d367 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/agent.md b/agent.md index 679a59e..854c77f 100644 --- a/agent.md +++ b/agent.md @@ -19,7 +19,7 @@ Der Architect Center Orchestrator dispatcht mehrere Claude Code Agenten, jeder s |-------|-------|-----------------| | **DB-Architect** | Datenbankschema, goose-Migrations, GORM-Models | mail-gateway models/ | | **API-Engineer** | Gin-Router, alle Handler, Middleware (Auth/JWT/RBAC) | mail-gateway handlers/ | -| **Config-Generator** | HAProxy, nginx, Squid, WireGuard, Unbound, nftables Templates + Renderer | mail-gateway config/ | +| **Config-Generator** | HAProxy, Squid, WireGuard, Unbound, nftables Templates + Renderer | mail-gateway config/ | | **Cluster-Engineer** | KeyDB AA, PG Streaming Replication, Join/Promote, Write-Proxy | mail-gateway cluster/ | | **Scheduler-Engineer** | ACME-Renewal, Backup, Health-Aggregation, License-Heartbeat | mail-gateway scheduler | | **CLI-Engineer** | edgeguard-ctl: initdb, migrate, cluster-join, promote, dump-config | mail-gateway cmd/nmg-ctl/ | @@ -38,7 +38,7 @@ Phase 1 (parallel): Phase 2 (parallel, braucht Phase 1): API-Engineer → internal/handlers/ + cmd/edgeguard-api/ - Config-Generator → internal/{haproxy,nginx,squid,wireguard,unbound,firewall}/ + Config-Generator → internal/{haproxy,squid,wireguard,unbound,firewall}/ CLI-Engineer → internal/services/ + cmd/edgeguard-ctl/ Phase 3 (parallel, braucht Phase 2): diff --git a/cmd/edgeguard-api/main.go b/cmd/edgeguard-api/main.go index 7fc6878..1156533 100644 --- a/cmd/edgeguard-api/main.go +++ b/cmd/edgeguard-api/main.go @@ -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 diff --git a/cmd/edgeguard-ctl/main.go b/cmd/edgeguard-ctl/main.go index 2439115..e1fcee5 100644 --- a/cmd/edgeguard-ctl/main.go +++ b/cmd/edgeguard-ctl/main.go @@ -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) diff --git a/cmd/edgeguard-ctl/render.go b/cmd/edgeguard-ctl/render.go new file mode 100644 index 0000000..2f73cd4 --- /dev/null +++ b/cmd/edgeguard-ctl/render.go @@ -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 +} diff --git a/deploy/nginx/.gitkeep b/deploy/nginx/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/docs/architecture.md b/docs/architecture.md index 39db9cd..bb48c10 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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 `: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:` 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/.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/`. --- diff --git a/internal/configgen/configgen.go b/internal/configgen/configgen.go new file mode 100644 index 0000000..cefb1d5 --- /dev/null +++ b/internal/configgen/configgen.go @@ -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// 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 `. 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" diff --git a/internal/configgen/configgen_test.go b/internal/configgen/configgen_test.go new file mode 100644 index 0000000..6188cd2 --- /dev/null +++ b/internal/configgen/configgen_test.go @@ -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()) + } + } +} diff --git a/internal/firewall/firewall.go b/internal/firewall/firewall.go new file mode 100644 index 0000000..fbdf1a7 --- /dev/null +++ b/internal/firewall/firewall.go @@ -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 ` (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() +} diff --git a/internal/firewall/firewall_test.go b/internal/firewall/firewall_test.go new file mode 100644 index 0000000..0a1ab93 --- /dev/null +++ b/internal/firewall/firewall_test.go @@ -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) + } + } +} diff --git a/internal/firewall/ruleset.nft.tpl b/internal/firewall/ruleset.nft.tpl new file mode 100644 index 0000000..f822570 --- /dev/null +++ b/internal/firewall/ruleset.nft.tpl @@ -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}} + } +} diff --git a/internal/haproxy/haproxy.cfg.tpl b/internal/haproxy/haproxy.cfg.tpl new file mode 100644 index 0000000..b0c712d --- /dev/null +++ b/internal/haproxy/haproxy.cfg.tpl @@ -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}} diff --git a/internal/haproxy/haproxy.go b/internal/haproxy/haproxy.go new file mode 100644 index 0000000..6f741fb --- /dev/null +++ b/internal/haproxy/haproxy.go @@ -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 +} diff --git a/internal/haproxy/haproxy_test.go b/internal/haproxy/haproxy_test.go new file mode 100644 index 0000000..81cfb66 --- /dev/null +++ b/internal/haproxy/haproxy_test.go @@ -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) + } +} diff --git a/internal/nginx/.gitkeep b/internal/nginx/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/internal/services/configorch/configorch.go b/internal/services/configorch/configorch.go new file mode 100644 index 0000000..d669224 --- /dev/null +++ b/internal/services/configorch/configorch.go @@ -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() +} diff --git a/internal/services/routingrules/routingrules.go b/internal/services/routingrules/routingrules.go index 28ac3fb..2787145 100644 --- a/internal/services/routingrules/routingrules.go +++ b/internal/services/routingrules/routingrules.go @@ -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") diff --git a/internal/squid/squid.go b/internal/squid/squid.go new file mode 100644 index 0000000..125324b --- /dev/null +++ b/internal/squid/squid.go @@ -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 +} diff --git a/internal/unbound/unbound.go b/internal/unbound/unbound.go new file mode 100644 index 0000000..7e73658 --- /dev/null +++ b/internal/unbound/unbound.go @@ -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 +} diff --git a/internal/wireguard/wireguard.go b/internal/wireguard/wireguard.go new file mode 100644 index 0000000..96f7995 --- /dev/null +++ b/internal/wireguard/wireguard.go @@ -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 +} diff --git a/packaging/debian/edgeguard-api/DEBIAN/control b/packaging/debian/edgeguard-api/DEBIAN/control index 68df596..6bd801e 100644 --- a/packaging/debian/edgeguard-api/DEBIAN/control +++ b/packaging/debian/edgeguard-api/DEBIAN/control @@ -4,14 +4,15 @@ Architecture: __ARCH__ Maintainer: NetCell IT 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 diff --git a/packaging/debian/edgeguard-api/DEBIAN/postinst b/packaging/debian/edgeguard-api/DEBIAN/postinst index eb1e0a5..23b2668 100755 --- a/packaging/debian/edgeguard-api/DEBIAN/postinst +++ b/packaging/debian/edgeguard-api/DEBIAN/postinst @@ -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 diff --git a/packaging/debian/edgeguard-meta/DEBIAN/control b/packaging/debian/edgeguard-meta/DEBIAN/control index 7e14989..21ddf11 100644 --- a/packaging/debian/edgeguard-meta/DEBIAN/control +++ b/packaging/debian/edgeguard-meta/DEBIAN/control @@ -5,7 +5,7 @@ Maintainer: NetCell IT 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__) diff --git a/packaging/debian/edgeguard-ui/DEBIAN/control b/packaging/debian/edgeguard-ui/DEBIAN/control index 5c862ee..5cea39b 100644 --- a/packaging/debian/edgeguard-ui/DEBIAN/control +++ b/packaging/debian/edgeguard-ui/DEBIAN/control @@ -5,8 +5,9 @@ Maintainer: NetCell IT 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