18 Commits

Author SHA1 Message Date
Debian
7611572062 refactor(cluster): promote auf Logical-Replication umgestellt + internal/proxy-Stub entfernt — v1.2.99
Code-Altlasten aus dem Architektur-Audit bereinigt:
- internal/proxy: leerer .gitkeep-Stub (geplanter Write-Proxy nie implementiert) entfernt — keine Go-Referenzen.
- promote.go: war reines Physical-Replication-Failover (standby.signal + pg_ctlcluster promote + pg_is_in_recovery) und damit auf dem Logical-Setup TOT (ein Subscriber hat kein standby.signal / ist nie in recovery → Abbruch bei Schritt 1). Neu Logical-aware: Idempotenz-Check (schon Publisher ohne Subscription → fertig) → Subscription lösen (DISABLE+slot_name=NONE+DROP, hängt nicht am toten Publisher) → setupReplicationPrimary (Publisher werden) → ha_nodes.pg_role=primary → keepalived MASTER. Toter KeyDB-Update (cluster:pg-primary-url, wurde nie gelesen) entfernt.
- setupReplicationPrimary + dropSubscriptionIfExists aus cluster-init-replication/cluster-setup-standby extrahiert (DRY, bewährte SQL wiederverwendet). WICHTIG: setupReplicationPrimary stellt jetzt sicher dass wal_level=logical AKTIV ist — PG-RESTART falls nötig (reload reicht für wal_level/max_wal_senders nicht; Secondary hat wal_level=replica). Idempotent: Restart nur wenn wal_level != logical.
- Doku (CLAUDE.md + architecture.md) auf den bereinigten Stand gezogen.
Hinweis: echtes Cross-Node-Failover ist nur im Drill testbar; Build/vet/Tests grün, Bausteine sind die bereits produktiv genutzten SQL-Primitive.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 18:26:52 +02:00
Debian
3b5bf578a0 docs(architecture): Cluster/HA-Abschnitte an Ist-Stand angeglichen (code-verifiziert)
architecture.md war Entwurfsstand; Cluster/HA wich stark vom Code ab. Per 5 parallelen Code-Audits verifiziert + korrigiert:
- Replikation: Streaming/physisch → LOGICAL (edgeguard_shared/edgeguard_sub, wal_level=logical, copy_data=true); localOnlyTables dokumentiert; pg_basebackup nur Legacy.
- VIP/Ingress (§9): 'Floating-IP statt VRRP' war invertiert → real keepalived/VRRP (prio aus pg_role, VIPs aus ip_addresses); kein Hoster-API/promote-this-node.
- KeyDB (§7): Active-Active-State-Layer NICHT umgesetzt (kein Redis-Client in go.mod); Cluster-State/Heartbeat/Locks in PostgreSQL; KeyDB optional (Recommends). license-leader/acme:lock/cluster:nodes = nur Kommentare.
- Write-Path: internal/proxy ist leerer Stub; kein Write-Proxy → Writes am Primary.
- Plattform: nur Debian 13 trixie (Pipeline), Ubuntu/noble nicht implementiert.
- §1/§5/§8 + Strukturbaum/Depends/Units an reale Renderer (keepalived/chrony/kea/freeradius/crowdsec/waf) angeglichen. Offene Punkte: Code-Altlasten (proxy-Stub, standby.signal in promote.go).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 17:46:56 +02:00
Debian
bf211ca273 docs: Feature-Stand nachgezogen — WAF/IDS-IPS/DHCP/RADIUS/OIDC/2FA umgesetzt
CLAUDE.md + architecture.md spiegelten noch den ursprünglichen v1-Scope (WAF/IDS/IPS/DHCP/RADIUS als Nicht-Ziele) wider. Real umgesetzt: WAF (Coraza+SPOE), IDS/IPS (CrowdSec, managed-wenn-installiert), DHCP (Kea), RADIUS (FreeRADIUS), OIDC-SSO, 2FA, IPv6-FW. Nicht-Ziele bereinigt (verbleibend: Suricata-Network-IDS, Docker, Mail, Multi-Tenant, ISO, Debian-only). Stack-Tabelle + Paket-Tabelle/Depends an control angeglichen; edgeguard-waf als Binary im edgeguard-api-Paket dokumentiert. KeyDB als optional (Recommends) korrigiert; Cluster-Primary-Ermittlung via pg_publication/setup.json statt KeyDB; Doku-Drift internal/proxy (nicht vorhanden) markiert.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 17:34:10 +02:00
Debian
becd068637 fix(ui): freeradius + kea-dhcp4 in Service-Status-Grid aufnehmen — v1.2.98
Das Dashboard-Service-Grid (servicesToCheck in system.go) listete weder freeradius (RADIUS, v1.2.93) noch kea-dhcp4-server (DHCP, v1.2.92). Beide sind via Depends installiert + default-disabled → erscheinen jetzt als 'Inaktiv' bis aktiviert. systemctl show liefert für disabled Units sauber inactive, kein Fehler.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 17:05:01 +02:00
Debian
b3dda81b49 feat(cluster): bidirektionaler Peer-Heartbeat (Primary→Secondary Push) — v1.2.97
Bisher pushte nur der Secondary seine Liveness an den Primary (runPrimaryPush). Der Primary pushte nichts → in der lokalen ha_nodes des Secondary fror die Primary-Row nach dem Boot ein → die vom Secondary ausgelieferte UI zeigte den Primary als offline.
Neu: runPeerPush auf dem Primary/Founder pusht alle 30s self (role=primary) an jeden Peer via mTLS (/agent/cluster/peers). PushSelfToPeer(role) generalisiert PushSelfToPrimary; registerPeerRequest+AgentRegisterPeer akzeptieren ein role-Feld (default 'peer' → joining-Peer-Verhalten unverändert). Peer-Register-Log bei Routine-Pushes auf Debug (Info nur bei neuem Peer/IP-Wechsel) gegen 30s-Spam.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 13:24:13 +02:00
Debian
b20ace8763 fix(cluster): periodischer Peer-Heartbeat (30s) + Rolling-Update candidate-aware — v1.2.96
Fix 1 — Peer zeigt fälschlich 'offline': runPrimaryPush (Secondary→Primary, einziger periodischer Cross-Node-ha_nodes-Refresh) tickte mit 5 min, SweepStaleNodes-Threshold ist aber 2 min → Secondary war 2 min online, dann 3 min offline, im 5-min-Takt. Tick auf 30s (4× Marge unter Threshold). Receiver lädt nftables nur bei IP-Änderung → kein Reload-Sturm.
Fix 2 — Rolling-Update konnte nie fertig werden wenn der Secondary die Zielversion schon hatte (baseline==target → Warten auf unmöglichen Flip → 10-min-Timeout). runRollingUpdate ist jetzt candidate-aware: ermittelt apt-Candidate, überspringt den Secondary-Schritt wenn dieser schon aktuell ist, erkennt den Flip via 'erreicht candidate ODER bewegt sich von baseline', und schließt direkt mit 'done' wenn auch der Primary schon aktuell ist. FinishRollingUpdateIfPending setzt hängende updating/waiting-secondary-Phasen beim Boot auf idle zurück (tote Orchestrierungs-Goroutine nach Restart).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 13:10:40 +02:00
Debian
053b38e46c fix: AlertWriter graceful flush (#15) + Rolling-Update Robustheit (#19) — v1.2.95
#15 waf/alerts.go: AlertWriter.Close() flusht gepufferte Alerts + stoppt die Goroutine (stop/done-Channels, sync.Once, atomic closed; Kanal wird NIE geschlossen → Send racet ohne Panic). Wiring in cmd/edgeguard-waf nach ListenAndServe (graceful shutdown). -race-Test alerts_test.go.
#19 handlers/cluster_rollingupdate.go: (a) RollingUpdateStatus mutiert State nicht mehr beim GET — terminale Zustände altern in readRollingUpdateState nach 10 min aus (kein verlorenes 'done' bei parallelen Pollern). (b) State-File via sync.Mutex + configgen.AtomicWrite (kein partieller Read / Race zwischen Handler & Goroutine). (c) Version-Flip wird gegen die VORHER erfasste Secondary-Baseline geprüft statt gegen die Primary-Version (verhindert sofort-/nie-Flip).
Bewusst belassen: geteilter upgrade.sh-Pfad ist deterministischer Inhalt + an exakte sudoers-Zeile gebunden → Überschreib-Race benign; MST-Timestamp-Parse locale (Server laufen C-Locale).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 11:21:50 +02:00
Debian
df31bfa720 fix: Audit-Bugfixes (Auth/WAF/Firewall/Cluster/Renderer) — v1.2.94
Verifizierte Bugs aus dem Code-Audit behoben (je mit Test/Build/nft -c geprüft):
- session: IssueWithRoleTTL mutierte geteiltes s.TTL (Data-Race + falsche TTL) → interne issue(); -race-Test.
- auth: Fallback/Federation leiteten role/TOTP nicht aus DB ab (2FA-Bypass auf Secondary, Rolle aus Remote) → viaDB-Flag + DB-Re-Lookup.
- waf: TrustedProxies waren No-op (bogus-Direktive) → XFF-Auflösung im SPOE-Agent (rightmostXFF/ipMatchesAny); RuleExclusions/TrustedProxies validiert (Direktiven-Injection); GetForHost via net.SplitHostPort.
- firewall: Auto-Rule mit IPv6-DstIP erzeugte 'ip daddr <v6>' → bricht ganzes nft-Ruleset; jetzt familienbewusst (ip/ip6, ungültige raus).
- kea: 'interfaces': null bei 0 Subnets → leeres Array.
- cluster_repair: nodeHasPublication schluckte DB-Fehler (Resync auf falschem Node) → (bool,error) fail-closed; IPv6-Primary-URL via net.JoinHostPort.
- cluster_replication: Replikations-Passwort via stdin statt psql -c (nicht mehr in argv/Logs).
- wireguard: Config (Private Key) jetzt configgen.AtomicWrite VOR Symlink/enable; SkipReload-Feld.
- render.go: --no-reload jetzt für alle Renderer (squid/unbound/chrony/wireguard).
- radius: leeres Secret/Passwort + Newlines abgelehnt; freeradius confEscape strippt CR/LF.
- configorch: continue-on-error + errors.Join statt Abbruch mitten in der Sequenz.
- i18n: fehlender Key common.status (de/en).
Verworfen als kein Bug: WAF detection-'blocked' (DetectionOnly liefert keine Interruption), render secrets.New('') (nutzt Default-Masterkey), FanOut-Sort (nur Kommentar), pg_hba (durch nft abgesichert).
Offen/bewusst zurückgestellt (low/risk): AlertWriter-Close (langlebiger Worker, vernachlässigbar), Rolling-Update-Kleinkram (sudoers-gebundener Script-Pfad / GET-State).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 10:55:21 +02:00
Debian
5f92851a96 feat(radius): RADIUS-Server via FreeRADIUS (PAP/CHAP) — v1.2.93
Files-basierter RADIUS-Server (Clients + Users), managed analog DHCP/WireGuard.
- Migration 0042: radius_settings (singleton, node-lokal), radius_clients (secret_enc), radius_users (password_enc) — Secrets via secrets.Box verschlüsselt.
- internal/freeradius: Multi-File-Renderer (clients.conf + authorize) via Box.Open, Secret-Escaping (" \), Service default-off/an enabled gekoppelt. internal/services/radius + internal/handlers/radius.go: Settings + Client/User-CRUD, write-only Secret-Semantik, Validierung (IP/CIDR, name-charset), GET liefert secret_configured statt Secret.
- Firewall: udp 1812/1813 Auto-Rule bei enabled. Cluster: clients/users repliziert (hashSpec), radius_settings node-lokal.
- main.go + render.go + WithAllReloaders. Packaging: freeradius Dependency, setgid-Dir /etc/edgeguard/freeradius (Gruppe freeradius), Symlinks clients.conf+authorize, disable-on-install, sudoers.
- UI: RADIUS-Seite (Einstellungen + Clients + Benutzer) unter Sicherheit, Route/Nav/i18n de/en.
- Tests (guarded): Renderer-Inhalt + Secret-Escaping/Roundtrip + Masking. Scope v1: PAP/CHAP files-based (kein EAP/802.1X).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 19:46:42 +02:00
Debian
bac7c7e349 feat(dhcp): DHCPv4-Server via Kea (kea-dhcp4-server) — v1.2.92
Verwalteter DHCPv4-Server analog Unbound/Squid/Chrony.
- Migration 0041: dhcp_settings (singleton, node-lokal), dhcp_subnets, dhcp_reservations.
- internal/kea: Renderer baut Kea-JSON via Go-Struct→Marshal (garantiert valide), managed /etc/edgeguard/kea/kea-dhcp4.conf (Symlink von /etc/kea), Service-Lifecycle an enabled gekoppelt (default AUS, kein rogue DHCP). Interface per NAME (cluster-sicher, kein node-lokaler FK).
- internal/services/dhcp + internal/handlers/dhcp.go: Settings + Subnet/Reservation-CRUD, Validierung (CIDR/IP/MAC/interface exists).
- configgen: Stop/Enable/DisableService. Firewall: AutoFWRule.Iface → udp/67 pro LAN-Interface gescopt (kein WAN). Cluster: subnets/reservations repliziert (hashSpec), dhcp_settings node-lokal (localOnlyTables).
- main.go + render.go + WithAllReloaders Wiring. Packaging: kea-dhcp4-server Dependency, /etc/edgeguard/kea Dir, Symlink, disable-on-install, sudoers (restart/stop/enable/disable).
- UI: DHCP-Seite (Settings + Subnets + Reservierungen pro Subnet), Route/Nav/i18n de/en, HA-Warnung 'nur auf einer Node aktivieren'.
- Tests (guarded EG_FWTEST_DSN): Kea-Renderer gegen DB (valides JSON + Felder), FW-Auto-Rule-Iface inkl. nft -c. Scope v1: DHCPv4.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 18:56:30 +02:00
Debian
3a707e2e3f feat(auth): OIDC/Keycloak SSO-Login (additiv) — v1.2.91
SSO per OpenID Connect (Authorization Code + PKCE) zusätzlich zum lokalen Login.
- Regeln: kein Auto-Provisioning (E-Mail muss als User existieren), Rolle aus DB (nie aus Token), lokaler Login+TOTP unangetastet.
- Migration 0040: oidc_settings (Singleton, client_secret_enc via secrets.Box) + users.oidc_subject.
- internal/services/oidc: Settings-Repo (write-only Secret) + lazy go-oidc Client (testbarer Authenticator-Seam).
- internal/handlers/oidc.go: GET/PUT /oidc/settings (admin), GET /auth/oidc/{settings,login,callback}. Flow-State (state/PKCE/nonce) stateless im 5-min signierten HttpOnly-Cookie (SameSite=Lax). email_verified erzwungen, opportunistisches sub-Linking, Session via setSessionCookie+Signer.
- session.SignBlob/VerifyBlob; users.Get/SetOIDCSubject; main.go-Wiring.
- Frontend: App.tsx /auth/me-Bootstrap (für Cookie-Session nach Callback), Login-SSO-Button + sso_error, Settings OIDC-Card, i18n de/en.
- Tests (guarded EG_FWTEST_DSN): Secret-Roundtrip + Callback (Rolle-aus-DB, no_account, disabled, unverified, nonce, state).
Deps: go-oidc/v3, x/oauth2. Scope v1: nur Login (kein SLO/Refresh).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 16:04:13 +02:00
Debian
f85552a475 test(firewall): End-to-End-IPv6-Test (echte DB + Generator + nft -c)
Guarded per EG_FWTEST_DSN (sonst skip). Migrations + v4/v6-Adressobjekte/Gruppe/icmpv6/v6-DNAT seeden, echten Generator.RenderToString laufen lassen, Output mit nft -c (via sudo) validieren. Verifiziert u.a.: gemischte Adressgruppe splittet in ip+ip6, gemischte v4/v6-NAT wird übersprungen, v6-DNAT-Target [..]:port.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 10:15:06 +02:00
Debian
92c5e25557 test(firewall): nft -c-Check via sudo lauffähig machen (netlink braucht root)
nft -c liest den Kernel-Ruleset-Cache via netlink → root nötig. Test nutzt nun sudo -n (sonst skip). Verifiziert: gerendertes Dual-Stack-Ruleset (v4+v6-Regeln, v6-DNAT [..]:port, v6-SNAT/Masquerade, icmpv6) ist mit nft v1.1.3 syntaktisch gültig.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 22:17:34 +02:00
Debian
9383b870b0 feat(firewall): IPv6 in Regeln + NAT (familienbewusstes nft-Rendering) — v1.2.90
Bisher rendete das Template alle Adress-Matches als 'ip saddr/daddr' (v4-only); ein v6-Eintrag hätte 'nft -f' (und damit das ganze Ruleset) gebrochen. Jetzt:
- Adressausdrücke werden je Eintrag als v4/v6 klassifiziert (addrFamily).
- Regeln mit Adressen werden pro Familie als separate nft-Zeile gerendert (ip vs ip6 saddr/daddr); adresslose Regeln bleiben eine familienagnostische Zeile (v4-Verhalten unverändert).
- icmp nur auf v4-, icmpv6 nur auf v6-Zeilen.
- NAT familienbewusst inkl. v6-DNAT-Target [..]:port; gemischte v4/v6-NAT-Regeln werden übersprungen (statt nft -f zu brechen) + geloggt.
- WireGuard site-to-site Masquerade v6-fähig.
Eingabeseite war bereits v6-fähig (validateAddrObjValue/validateRule via net.ParseIP/ParseCIDR; Service-Proto-CHECK erlaubt icmpv6; Builtin PING-v6). Neue Unit-Tests (firewall_ipv6_test.go) inkl. optionalem 'nft -c'-Syntaxcheck.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 21:04:38 +02:00
Debian
4416d361a0 docs(migrations): 0030-Kommentar korrigieren — network/ip node-lokal, nicht repliziert
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 13:39:44 +02:00
Debian
58e42eb269 fix(cluster): node-lokale Tabellen aus Drift-Hash entfernen — v1.2.89
network_interfaces + ip_addresses standen in confighash hashSpec, aber in cluster_replication.go localOnlyTables (= nicht repliziert, node-spezifische IPs). Dadurch waren die config_hash-Werte zweier Nodes ZWANGSLÄUFIG dauerhaft verschieden → Drift-Banner, das kein Resync beheben konnte. Beide Tabellen aus dem Hash entfernt; Drift erkennt jetzt nur noch wirklich replizierte Service-Config. Muss auf BEIDEN Nodes installiert sein (gleicher hashSpec für vergleichbare Hashes).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 13:35:32 +02:00
Debian
90f0df4c45 fix(cluster): Repair-Rollenerkennung über pg_publication statt ha_nodes.role — v1.2.88
Bug: Dispatch an utm-2 schlug fehl ('dieser Node ist der Cluster-Primary'), weil ha_nodes je Node lokal ist und sich JEDE Node selbst als role=primary markiert. Fix: Primary-Erkennung über pg_publication (edgeguard_shared, für jeden DB-User lesbar) statt role/pg_role. Primary gibt dem Subscriber seine eigene Adresse als primary_host mit (PostPeerWithBody); Agent-Handler vertraut dem mTLS-Dispatch mit Safety-Guard 'läuft nie auf dem Publication-Primary'. Funktioniert auch bei Direktzugriff auf den Subscriber. UI-Gating vereinfacht (Drift + Peer + Admin).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 13:18:54 +02:00
Debian
66c71c5fa8 fix(cluster): Repair-Button-Gating auf role statt pg_role — v1.2.87
pg_role bleibt nach cluster-setup-standby auf 'standalone' (nur 'promote' setzt 'primary'), daher erschien der Button auf dem Primary (role=primary, pg_role=standalone) nicht. Gating + Dispatch + Status nutzen jetzt isPrimaryNode = role=='primary' || pg_role=='primary' (wie keepalived); Resync-Ziel = Nicht-Primary-Peer. Backend (cluster_repair.go) + UI (Cluster/index.tsx).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 13:03:17 +02:00
68 changed files with 5799 additions and 518 deletions

View File

@@ -13,7 +13,9 @@ Vor jeder Entscheidung über Feldwerte, API-Shapes, Dateinamen, Funktions-Signat
# EdgeGuard Native (`eg`)
> Native Neufassung des Docker-basierten EdgeGuard-Stacks. Kein Docker, kein WAF in v1. Zielplattform: **Debian 13 (Trixie), amd64 + arm64**. Auslieferung als signiertes `.deb`.
> Native Neufassung des Docker-basierten EdgeGuard-Stacks. Kein Docker — alle Dienste nativ unter systemd. Zielplattform: **Debian 13 (Trixie), amd64 + arm64**. Auslieferung als signiertes `.deb`.
>
> **Hinweis:** Mehrere ursprüngliche v1-Nicht-Ziele (WAF, IDS/IPS, DHCP, RADIUS) sind inzwischen umgesetzt — siehe „Feature-Stand" weiter unten.
---
@@ -67,26 +69,47 @@ ac_search_code(query="<Stichworte>", project_id=8, session_name="$(printenv ARCH
| **API** | Go 1.26, Gin, GORM (Queries), goose (Migrations) |
| **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) |
| **State/HA** | PostgreSQL Logical Replication + Cluster-Agent-Heartbeat (mTLS); KeyDB Active-Active nur optional (`Recommends`, für Lizenz-Leader-Election/Shared-Cache) |
| **Proxy/LB** | HAProxy (Distro) — TLS-Termination, L7-Routing, LB |
| **WAF** | Coraza v3 + OWASP CRS, via HAProxy SPOE (`edgeguard-waf`-Agent) |
| **IDS/IPS** | CrowdSec + `crowdsec-firewall-bouncer` (nftables) — managed-wenn-installiert (kein Depends) |
| **VPN** | WireGuard (Kernel-Modul ab 5.6, `wireguard-tools`) |
| **DNS** | Unbound (Distro) — Forwarder+Cache mit DNSSEC, Cluster-internes Split-Horizon |
| **DHCP** | Kea (`kea-dhcp4-server`) — managed, default-off |
| **AAA/RADIUS** | FreeRADIUS (PAP/CHAP, files-based) — managed, default-off |
| **NTP** | chrony (Distro) |
| **VIP/HA** | keepalived (VRRP) |
| **FW** | nftables (Distro) |
| **Forward-Proxy** | Squid (Distro) |
| **Auth/SSO** | JWT (lokal) + 2FA/TOTP + OIDC/OAuth2 (Keycloak u. a.) |
| **TLS** | certbot + webroot-Plugin |
| **Packaging** | dpkg-deb (direkt, wie mail-gateway + netcell-webpanel) |
| **Plattform** | Debian 13 Trixie · amd64 + arm64 |
---
## Nicht-Ziele (v1)
## Feature-Stand (Stand 2026-06)
Über den ursprünglichen v1-Scope hinaus inzwischen **umgesetzt** (waren mal Nicht-Ziele):
- **WAF** — Coraza v3 + OWASP CRS via HAProxy SPOE (`edgeguard-waf`)
- **IDS/IPS** — CrowdSec + `crowdsec-firewall-bouncer` (nftables-Bouncer); managed-wenn-installiert, kein hartes Depends
- **DHCP** — Kea `kea-dhcp4-server` (managed, default-off)
- **RADIUS** — FreeRADIUS PAP/CHAP, files-based (managed, default-off)
- **SSO** — OIDC/OAuth2 (additiv, Rolle aus DB, lokaler Login bleibt)
- **2FA** — TOTP
- **IPv6** — Firewall-Regeln + NAT familienbewusst
- **HA** — VIP via keepalived (VRRP), PG-Logical-Replication, bidirektionaler Cluster-Heartbeat
Damit ist die ursprüngliche v2-Roadmap (WAF, 2FA, IPv6-FW, OIDC, DHCP, RADIUS) abgearbeitet.
## Nicht-Ziele (weiterhin)
- **Kein Docker** — alle Dienste nativ unter systemd
- **Kein WAF** (kein Coraza, kein ModSecurity)
- **Kein IDS/IPS** (kein Suricata, kein CrowdSec)
- **Kein DHCP-Server** (kein Kea)
- **Kein RADIUS** (kein FreeRADIUS)
- **Kein Network-IDS Suricata** — Intrusion-Detection läuft über CrowdSec, nicht über Suricata-Paket-Inspektion
- **Keine Mail-Verarbeitung** (eigenes Produkt: mail-gateway)
- **Keine Multi-Tenant-GuardZones**
- **Keine ISO-Builds** (kein EdgeGuardOS-Klon — nur APT)
- **Nur Debian 13** — kein Ubuntu, kein Debian 12, kein RHEL
---
@@ -154,7 +177,6 @@ cd management-ui && bun run dev
│ ├── unbound/ # Config-Generator (Forwarder + Cluster-DNS)
│ ├── firewall/ # nftables-Generator
│ ├── cluster/ # Join/Promote/Peer-Discovery
│ ├── proxy/ # Write-Proxy → Cluster-Primary
│ ├── aggregator/ # Cluster-View APIs
│ └── license/ # Lizenz-Validierung
├── management-ui/ # React 19 + AntD 6 (1:1 enconf-Pattern)
@@ -185,7 +207,8 @@ cd management-ui && bun run dev
- **ORM:** GORM für Queries, nicht für Schema-Verwaltung
- **Config-Generierung:** Template-Datei in `deploy/*/`, Generator in `internal/*/`
- **Config-Reload:** `systemctl reload <service>` nach Config-Schreiben
- **Cluster-Writes:** immer über `internal/proxy` → Primary-URL aus KeyDB `cluster:pg-primary-url`
- **Cluster-Primary-Ermittlung:** zuverlässig über `pg_publication` (`edgeguard_shared`); Primary-URL aus `setup.json` `PrimaryFQDN` via `clusterjoin.NormalizePrimaryURL`. **Kein Write-Proxy** — Schreibzugriffe auf geteilte Tabellen erfolgen am Primary.
- **Failover:** `edgeguard-ctl promote` ist Logical-Replication-aware (Subscription lösen → `setupReplicationPrimary` → Publisher werden, inkl. PG-Restart für `wal_level=logical`); erholte Nodes danach via `cluster-setup-standby <neuer-primary>` zurückhängen.
### Packaging
- `dpkg-deb` direkt (wie mail-gateway) — kein dh_make/debhelper/fpm

View File

@@ -1 +1 @@
1.2.86
1.2.99

View File

@@ -28,6 +28,8 @@ import (
squidrender "git.netcell-it.de/projekte/edgeguard-native/internal/squid"
unboundrender "git.netcell-it.de/projekte/edgeguard-native/internal/unbound"
wgrender "git.netcell-it.de/projekte/edgeguard-native/internal/wireguard"
kearender "git.netcell-it.de/projekte/edgeguard-native/internal/kea"
radiusrender "git.netcell-it.de/projekte/edgeguard-native/internal/freeradius"
"git.netcell-it.de/projekte/edgeguard-native/internal/handlers/response"
"git.netcell-it.de/projekte/edgeguard-native/internal/services/acme"
"git.netcell-it.de/projekte/edgeguard-native/internal/services/alerts"
@@ -58,6 +60,9 @@ import (
"git.netcell-it.de/projekte/edgeguard-native/internal/services/setup"
"git.netcell-it.de/projekte/edgeguard-native/internal/services/tlscerts"
wgsvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/wireguard"
dhcpsvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/dhcp"
oidcsvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/oidc"
radiussvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/radius"
usersvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/users"
wafsvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/waf"
)
@@ -189,6 +194,12 @@ func main() {
}
// runSecondaryConfigRender wird weiter unten gestartet sobald
// clusterAggregator verfügbar ist (braucht mTLS-Client für Cert-Sync).
} else if nodeID != "" && st != nil && st.Completed && st.FQDN != "" {
// Primary/Founder (kein joined Secondary): self (role=primary) an
// alle Peers pushen, damit deren lokale ha_nodes den Primary frisch
// hält — sonst zeigt die vom Secondary ausgelieferte UI den Primary
// als offline. No-op solange keine Peers existieren (Single-Node).
go runPeerPush(context.Background(), pool, clusterStore, nodeID, st.FQDN, version)
}
// Phase 3.3: Cluster-CA + Peer-Cert. Founder-Pfad — auf einem
@@ -316,6 +327,14 @@ func main() {
authed.Use(requireAuth, handlers.RequireAdminForMutations())
setupHdl.RegisterAuthed(authed)
handlers.NewUsersHandler(usersRepo, auditRepo, nodeID).Register(authed)
// OIDC/Keycloak SSO — public Flow-Endpoints auf v1 (hinter SetupGate),
// Admin-Settings auf authed (PUT nur admin via RequireAdminForMutations).
oidcRepo := oidcsvc.New(pool, secretsBox)
oidcHdl := handlers.NewOIDCHandler(oidcRepo, oidcsvc.NewClient(oidcRepo), usersRepo, signer, setupStore).
WithAudit(auditRepo, nodeID)
oidcHdl.RegisterPublic(v1)
oidcHdl.RegisterAdmin(authed)
handlers.NewDomainsHandler(domainsRepo, routingRepo, domainHeadersRepo, auditRepo, nodeID, haproxyReloader).Register(authed)
handlers.NewBackendsHandler(backendsRepo, auditRepo, nodeID, haproxyReloader).Register(authed)
handlers.NewBackendServersHandler(backendServersRepo, auditRepo, nodeID, haproxyReloader).Register(authed)
@@ -436,14 +455,28 @@ func main() {
}
handlers.NewNTPHandler(ntpRepo, auditRepo, nodeID, withFW(chronyReloader)).Register(authed)
// DHCP (Kea) — re-render kea-dhcp4.conf + manage service lifecycle.
keaReloader := func(ctx context.Context) error {
return kearender.New(pool).Render(ctx)
}
handlers.NewDHCPHandler(dhcpsvc.New(pool), auditRepo, nodeID, withFW(keaReloader)).Register(authed)
// RADIUS (FreeRADIUS) — re-render clients.conf + authorize + service lifecycle.
radiusReloader := func(ctx context.Context) error {
return radiusrender.New(pool, secretsBox).Render(ctx)
}
handlers.NewRADIUSHandler(radiussvc.New(pool, secretsBox), auditRepo, nodeID, withFW(radiusReloader)).Register(authed)
// Wire all service reloaders into systemHdl so RenderConfigs
// re-renders every service from DB state in one shot.
systemHdl.WithAllReloaders(map[string]func(context.Context) error{
"nftables": fwReloader,
"wireguard": wgReloader,
"squid": squidReloader,
"unbound": unboundReloader,
"chrony": chronyReloader,
"nftables": fwReloader,
"wireguard": wgReloader,
"squid": squidReloader,
"unbound": unboundReloader,
"chrony": chronyReloader,
"kea": keaReloader,
"freeradius": radiusReloader,
})
// License — node-local key store + DB-mirror of last verify
@@ -799,12 +832,20 @@ func runSecondaryConfigRender(ctx context.Context, pool *pgxpoolPool, box *secre
}
// runPrimaryPush periodically pushes this secondary node's config_hash to the
// primary via mTLS. The primary's ha_nodes view only gets config_hash written
// during join-time autoRegister — after that the primary never hears about
// hash changes unless we push. Without this, the drift banner shows stale
// hashes from join-time forever.
// primary via mTLS. The primary's ha_nodes view only gets config_hash + last_seen
// written during join-time autoRegister — after that the primary never hears about
// the secondary unless we push. Without this, the drift banner shows stale hashes
// from join-time forever AND the secondary's last_seen freezes → SweepStaleNodes
// marks it offline.
//
// WICHTIG: tick MUSS deutlich unter dem Stale-Threshold (4× 30s = 2 min, siehe
// scheduler.staleThreshold / cluster.SweepStaleNodes) liegen. Sonst flippt der
// Secondary zwischen den Pushes zwangsläufig auf "offline" (bei 5-min-Tick:
// 2 min online, 3 min offline). 30s = 4 Pushes pro Stale-Fenster → ein
// verpasster Push (Netz-Glitch) ist unkritisch. Der Receiver (AgentRegisterPeer)
// lädt nftables nur bei IP-Änderung neu → kein Reload-Sturm durch häufige Pushes.
func runPrimaryPush(ctx context.Context, pool *pgxpoolPool, nodeID, fqdn, version, primaryURL string) {
const tick = 5 * time.Minute
const tick = 30 * time.Second
t := time.NewTicker(tick)
defer t.Stop()
push := func() {
@@ -828,6 +869,51 @@ func runPrimaryPush(ctx context.Context, pool *pgxpoolPool, nodeID, fqdn, versio
}
}
// runPeerPush läuft auf dem Primary/Founder und pusht alle 30s die eigene
// Identität (role=primary) an jeden Peer via mTLS — das Gegenstück zu
// runPrimaryPush (Secondary→Primary). Zusammen ergibt das einen
// bidirektionalen Cross-Node-Heartbeat: beide Nodes sehen sich gegenseitig
// als online, egal von welchem Node die UI ausgeliefert wird. Tick wie
// runPrimaryPush deutlich unter dem 2-min-Stale-Threshold. No-op solange
// keine Peers existieren (Single-Node) bzw. wenn ein Peer down ist (Debug-Log).
func runPeerPush(ctx context.Context, pool *pgxpoolPool, store *cluster.Store, nodeID, fqdn, version string) {
const tick = 30 * time.Second
t := time.NewTicker(tick)
defer t.Stop()
push := func() {
pCtx, cancel := context.WithTimeout(ctx, 25*time.Second)
defer cancel()
peers, err := store.List(pCtx)
if err != nil {
slog.Warn("cluster: peer-push list failed", "error", err)
return
}
hash, _ := cluster.ComputeConfigHash(pCtx, pool)
for i := range peers {
p := peers[i]
if p.ID == nodeID {
continue // nicht an sich selbst pushen
}
target := p.APIURL
if target == "" {
target = "https://" + p.FQDN
}
if err := clusterjoin.PushSelfToPeer(target, "", nodeID, fqdn, version, hash, "primary"); err != nil {
slog.Debug("cluster: push-to-peer failed", "peer", p.FQDN, "error", err)
}
}
}
push() // immediate push on API startup
for {
select {
case <-ctx.Done():
return
case <-t.C:
push()
}
}
}
func randomEphemeralSecret() []byte {
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {

View File

@@ -7,6 +7,7 @@ import (
"encoding/json"
"flag"
"fmt"
"net"
"net/http"
"os"
"os/exec"
@@ -79,6 +80,8 @@ var localOnlyTables = []string{
"cluster_settings", // VIP-Interface kann pro Node unterschiedlich sein
"dns_settings", // listen_addresses ist node-spezifisch
"ntp_settings", // listen_addresses ist node-spezifisch
"dhcp_settings", // ob DIESE Node DHCP betreibt (Dual-DHCP vermeiden)
"radius_settings", // ob DIESE Node RADIUS betreibt + Listen-Adressen
"system_settings", // Hostname, Maintenance-Mode etc.
"join_tokens_used", // Token-Tracking nur auf Primary relevant
"audit_log", // Lokales Audit-Protokoll
@@ -112,11 +115,29 @@ func cmdClusterInitReplication(args []string) int {
}
fmt.Printf("→ PostgreSQL %s/%s erkannt\n", pg.Version, pg.Cluster)
if err := setupReplicationPrimary(pg); err != nil {
fmt.Fprintln(os.Stderr, "cluster-init-replication:", err)
return 1
}
fmt.Println()
fmt.Println("Nächste Schritte:")
fmt.Println(" 1) Auf dem Secondary: edgeguard-ctl cluster-setup-standby <primary-ip>")
fmt.Println(" 2) Cluster-Settings (VIP) auf BEIDEN Nodes separat konfigurieren")
fmt.Println(" → Settings → Cluster → VIP/Keepalived")
return 0
}
// setupReplicationPrimary konfiguriert die lokale PG-Instanz als Logical-
// Replication-Primary: Replikations-Rolle + Secret, conf.d (wal_level=logical),
// pg_hba, SELECT-Grants, PUBLICATION. Stellt sicher dass wal_level=logical
// AKTIV ist (Restart nur falls nötig — für wal_level reicht reload nicht).
// Idempotent. Gemeinsam genutzt von cluster-init-replication und promote.
func setupReplicationPrimary(pg pgConfig) error {
// 1. Passwort generieren
pass, err := generatePassword(32)
if err != nil {
fmt.Fprintln(os.Stderr, "cluster-init-replication: generate password:", err)
return 1
return fmt.Errorf("generate password: %w", err)
}
// 2. edgeguard_replicator-Rolle anlegen/updaten
@@ -130,21 +151,17 @@ BEGIN
END
$$`, egReplUser, egReplUser, pass, egReplUser, pass)
if err := psqlExec(roleSQL); err != nil {
fmt.Fprintln(os.Stderr, "cluster-init-replication: create replication role:", err)
return 1
return fmt.Errorf("create replication role: %w", err)
}
fmt.Printf("✓ Replication-Rolle %q angelegt/aktualisiert\n", egReplUser)
// 3. Passwort speichern
// 3. Passwort speichern (Ownership an edgeguard-User, damit die API liest)
if err := os.MkdirAll(filepath.Dir(egReplSecret), 0o750); err != nil {
fmt.Fprintln(os.Stderr, "cluster-init-replication: mkdir:", err)
return 1
return fmt.Errorf("mkdir: %w", err)
}
if err := os.WriteFile(egReplSecret, []byte(pass), 0o600); err != nil {
fmt.Fprintln(os.Stderr, "cluster-init-replication: write secret:", err)
return 1
return fmt.Errorf("write secret: %w", err)
}
// Ownership an edgeguard-api-User übergeben damit die API lesen kann
if u, err := user.Lookup("edgeguard"); err == nil {
uid, _ := strconv.Atoi(u.Uid)
gid, _ := strconv.Atoi(u.Gid)
@@ -153,71 +170,88 @@ $$`, egReplUser, egReplUser, pass, egReplUser, pass)
fmt.Printf("✓ Replication-Secret gespeichert: %s\n", egReplSecret)
// 4. conf.d/edgeguard-replication.conf schreiben
// wal_level=logical ist eine Obermenge von replica — unterstützt
// sowohl Logical Replication als auch ggfs. physisches WAL-Archiving.
if err := os.MkdirAll(pg.ConfD, 0o755); err != nil {
fmt.Fprintln(os.Stderr, "cluster-init-replication: conf.d mkdir:", err)
return 1
return fmt.Errorf("conf.d mkdir: %w", err)
}
replConf := `# EdgeGuard Logical Replication — automatisch generiert
# Nicht manuell bearbeiten; wird von edgeguard-ctl cluster-init-replication verwaltet.
# Nicht manuell bearbeiten; wird von edgeguard-ctl verwaltet.
wal_level = logical
max_wal_senders = 10
max_replication_slots = 20
max_logical_replication_workers = 4
wal_keep_size = 512MB
# Lausche auf localhost + alle konfigurierten Interfaces damit Cluster-Peers
# sich verbinden können. '*' ist sicher weil pg_hba.conf den Zugriff auf
# bekannte Replikations-User beschränkt.
# '*' ist sicher weil pg_hba.conf den Zugriff auf bekannte Replikations-User beschränkt.
listen_addresses = '*'
`
confPath := filepath.Join(pg.ConfD, "edgeguard-replication.conf")
if err := os.WriteFile(confPath, []byte(replConf), 0o644); err != nil {
fmt.Fprintln(os.Stderr, "cluster-init-replication: write postgresql conf:", err)
return 1
return fmt.Errorf("write postgresql conf: %w", err)
}
fmt.Printf("✓ %s geschrieben (wal_level=logical)\n", confPath)
// 5. pg_hba.conf aktualisieren
if err := ensureHBAReplication(pg.HBAPath); err != nil {
fmt.Fprintln(os.Stderr, "cluster-init-replication: pg_hba.conf:", err)
return 1
return fmt.Errorf("pg_hba.conf: %w", err)
}
fmt.Printf("✓ %s aktualisiert\n", pg.HBAPath)
// 6. PG reload (damit wal_level + pg_hba aktiv werden)
// 6. PG reload (pg_hba aktiv). wal_level/max_wal_senders sind aber
// postmaster-Parameter → nur per RESTART aktiv. Nur restarten wenn nötig.
if out, err := exec.Command("pg_ctlcluster", pg.Version, pg.Cluster, "reload").CombinedOutput(); err != nil {
fmt.Fprintf(os.Stderr, "cluster-init-replication: pg reload failed: %v\n%s\n", err, out)
return 1
return fmt.Errorf("pg reload: %w: %s", err, strings.TrimSpace(string(out)))
}
fmt.Printf("✓ PostgreSQL %s/%s neu geladen\n", pg.Version, pg.Cluster)
if cur, _ := psqlRun([]string{"-tA", "-c", "SHOW wal_level;"}); strings.TrimSpace(string(cur)) != "logical" {
fmt.Println("→ wal_level wechselt auf 'logical' — PostgreSQL-Restart nötig...")
if out, err := exec.Command("pg_ctlcluster", pg.Version, pg.Cluster, "restart").CombinedOutput(); err != nil {
return fmt.Errorf("pg restart: %w: %s", err, strings.TrimSpace(string(out)))
}
ready := false
deadline := time.Now().Add(60 * time.Second)
for time.Now().Before(deadline) {
if _, err := psqlRun([]string{"-tA", "-c", "SELECT 1;"}); err == nil {
ready = true
break
}
time.Sleep(2 * time.Second)
}
if !ready {
return fmt.Errorf("PostgreSQL kam nach Restart binnen 60s nicht zurück — prüfe PG-Logs")
}
fmt.Println("✓ PostgreSQL neu gestartet (wal_level=logical aktiv)")
}
// 7. SELECT-Grants: edgeguard_replicator muss alle zu replizierenden
// Tabellen lesen können. DEFAULT PRIVILEGES sichert zukünftige Tabellen.
// 7. SELECT-Grants (DEFAULT PRIVILEGES sichert zukünftige Tabellen)
grantSQL := fmt.Sprintf(`
GRANT SELECT ON ALL TABLES IN SCHEMA public TO %s;
ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT ON TABLES TO %s;
`, egReplUser, egReplUser)
if err := psqlDBExec("edgeguard", grantSQL); err != nil {
fmt.Fprintln(os.Stderr, "cluster-init-replication: grant SELECT:", err)
return 1
return fmt.Errorf("grant SELECT: %w", err)
}
fmt.Printf("✓ SELECT auf alle Tabellen für %q gewährt\n", egReplUser)
// 8. PUBLICATION erstellen — alle public-Tabellen außer localOnlyTables.
// Idempotent: DROP IF EXISTS + CREATE.
// 8. PUBLICATION (idempotent: DROP IF EXISTS + CREATE)
if err := createPublication(); err != nil {
fmt.Fprintln(os.Stderr, "cluster-init-replication: create publication:", err)
return 1
return fmt.Errorf("create publication: %w", err)
}
fmt.Printf("✓ PUBLICATION %q erstellt\n", egPubName)
return nil
}
fmt.Println()
fmt.Println("Nächste Schritte:")
fmt.Println(" 1) Auf dem Secondary: edgeguard-ctl cluster-setup-standby <primary-ip>")
fmt.Println(" 2) Cluster-Settings (VIP) auf BEIDEN Nodes separat konfigurieren")
fmt.Println(" → Settings → Cluster → VIP/Keepalived")
return 0
// dropSubscriptionIfExists entfernt die lokale Logical-Replication-Subscription
// idempotent. DISABLE + slot_name=NONE VOR DROP, damit DROP nicht versucht den
// Slot auf dem (beim Failover evtl. toten) Publisher zu löschen → kein Hängen.
func dropSubscriptionIfExists() error {
dropSQL := fmt.Sprintf(`
DO $$ BEGIN
IF EXISTS (SELECT FROM pg_subscription WHERE subname = '%s') THEN
ALTER SUBSCRIPTION %s DISABLE;
ALTER SUBSCRIPTION %s SET (slot_name = NONE);
DROP SUBSCRIPTION %s;
END IF;
END $$;`, egSubName, egSubName, egSubName, egSubName)
return psqlDBExec("edgeguard", dropSQL)
}
// createPublication baut die PUBLICATION dynamisch aus allen Tabellen
@@ -331,15 +365,7 @@ func cmdClusterSetupStandby(args []string) int {
fmt.Printf("✓ Replication-Credentials von %s:%d erhalten\n", primaryHost, *agentPort)
// 2. Bestehende Subscription löschen (idempotent)
dropSQL := fmt.Sprintf(`
DO $$ BEGIN
IF EXISTS (SELECT FROM pg_subscription WHERE subname = '%s') THEN
ALTER SUBSCRIPTION %s DISABLE;
ALTER SUBSCRIPTION %s SET (slot_name = NONE);
DROP SUBSCRIPTION %s;
END IF;
END $$;`, egSubName, egSubName, egSubName, egSubName)
if err := psqlDBExec("edgeguard", dropSQL); err != nil {
if err := dropSubscriptionIfExists(); err != nil {
// Nicht fatal — wenn PG noch keine Subscription kennt ist das OK
fmt.Printf(" → keine bestehende Subscription gefunden (ok)\n")
} else {
@@ -357,7 +383,9 @@ END $$;`, egSubName, egSubName, egSubName, egSubName)
"CREATE SUBSCRIPTION %s CONNECTION '%s' PUBLICATION %s WITH (copy_data = true, enabled = true);",
egSubName, connStr, egPubName,
)
if err := psqlDBExec("edgeguard", createSQL); err != nil {
// Via stdin (nicht -c), damit das Replikations-Passwort nicht in der
// Prozess-Argv (ps/proc) oder in PG-log_statement landet.
if err := psqlDBExecStdin("edgeguard", createSQL); err != nil {
fmt.Fprintf(os.Stderr, "cluster-setup-standby: create subscription: %v\n", err)
return 1
}
@@ -468,7 +496,7 @@ func fetchReplicationCreds(host string, agentPort int, tlsDir string) (*pgReplic
},
}
url := fmt.Sprintf("https://%s:%d/agent/cluster/pg-replication-info", host, agentPort)
url := "https://" + net.JoinHostPort(host, strconv.Itoa(agentPort)) + "/agent/cluster/pg-replication-info"
resp, err := client.Get(url)
if err != nil {
return nil, fmt.Errorf("GET %s: %w", url, err)
@@ -514,7 +542,7 @@ func syncMasterKey(host string, agentPort int, tlsDir string) error {
},
},
}
url := fmt.Sprintf("https://%s:%d/agent/cluster/master-key", host, agentPort)
url := "https://" + net.JoinHostPort(host, strconv.Itoa(agentPort)) + "/agent/cluster/master-key"
resp, err := client.Get(url)
if err != nil {
return fmt.Errorf("GET %s: %w", url, err)
@@ -566,6 +594,17 @@ func psqlDBExec(db, sql string) error {
return err
}
// psqlDBExecStdin führt SQL über stdin (`-f -`) aus statt `-c`, damit
// Secrets im SQL nicht in der Prozess-Argv / PG-Statement-Logs erscheinen.
func psqlDBExecStdin(db, sql string) error {
cmd := buildPsqlCmd([]string{"-d", db, "-v", "ON_ERROR_STOP=1", "-f", "-"})
cmd.Stdin = strings.NewReader(sql)
if out, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("%w: %s", err, strings.TrimSpace(string(out)))
}
return nil
}
// psqlDBRun führt psql-Kommandos gegen eine bestimmte Datenbank aus.
func psqlDBRun(db string, args []string) ([]byte, error) {
baseArgs := []string{"-d", db}

View File

@@ -4,8 +4,6 @@ import (
"context"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
@@ -14,18 +12,22 @@ import (
"git.netcell-it.de/projekte/edgeguard-native/internal/keepalived"
)
// cmdPromote promotes this node's PostgreSQL instance from Hot-Standby
// to Primary. Manual failover — keine automatische Promotion, um Split-Brain
// in 2-Node-Clustern ohne externen Quorum zu verhindern.
// cmdPromote befördert diese Node zum Logical-Replication-Primary. Manuelles
// Failover — keine automatische Promotion, um Split-Brain in 2-Node-Clustern
// ohne externes Quorum zu verhindern.
//
// Hintergrund: Die Replikation ist LOGICAL (Publication/Subscription), nicht
// physisch. Ein Subscriber ist eine normale beschreibbare PG-Instanz (nie „in
// recovery", kein standby.signal). „Promote" heißt darum: Subscription zum
// (toten/alten) Primary lösen und selbst Publisher werden.
//
// Ablauf:
// 1. Prüfen ob standby.signal vorhanden (wir sind wirklich Standby)
// 2. pg_ctlcluster promote → PG wird Primary
// 3. Warten bis pg_is_in_recovery() = false
// 4. ha_nodes.pg_role auf 'primary' setzen
// 5. KeyDB cluster:pg-primary-url auf lokal setzen
// 6. keepalived.conf neu rendern (Primary bekommt Priorität 200)
// 7. keepalived reload
// 1. Idempotenz-Check: schon Publisher ohne Subscription → fertig
// 2. Subscription lösen (DISABLE + slot_name=NONE + DROP)
// 3. setupReplicationPrimary: Rolle/Secret/conf.d/pg_hba/Grants/Publication
// + sicherstellen dass wal_level=logical aktiv ist (PG-Restart falls nötig)
// 4. ha_nodes.pg_role/role = 'primary'
// 5. keepalived neu rendern (Primary = Priorität 200 = MASTER → übernimmt VIP)
func cmdPromote(args []string) int {
pg, err := detectPGConfig()
if err != nil {
@@ -33,51 +35,43 @@ func cmdPromote(args []string) int {
return 1
}
// 1. Standby-Signal prüfen
signalPath := filepath.Join(pg.DataDir, "standby.signal")
if _, err := os.Stat(signalPath); os.IsNotExist(err) {
fmt.Fprintf(os.Stderr,
"promote: %s nicht gefunden — diese Node ist kein PG-Standby oder wurde bereits promoted.\n",
signalPath)
return 1
// 1. Idempotenz: bereits Publisher (Primary) ohne Subscription?
pubOut, _ := psqlDBRun("edgeguard", []string{"-tA", "-c",
fmt.Sprintf("SELECT count(*) FROM pg_publication WHERE pubname='%s';", egPubName)})
subOut, _ := psqlDBRun("edgeguard", []string{"-tA", "-c",
fmt.Sprintf("SELECT count(*) FROM pg_subscription WHERE subname='%s';", egSubName)})
hasPub := strings.TrimSpace(string(pubOut)) == "1"
hasSub := strings.TrimSpace(string(subOut)) == "1"
if hasPub && !hasSub {
fmt.Println("✓ Diese Node ist bereits Logical-Replication-Primary (Publication vorhanden, keine Subscription). Nichts zu tun.")
return 0
}
fmt.Printf("→ Promoting PostgreSQL %s/%s zu Primary...\n", pg.Version, pg.Cluster)
if out, err := exec.Command("pg_ctlcluster", pg.Version, pg.Cluster, "promote").
CombinedOutput(); err != nil {
fmt.Fprintf(os.Stderr, "promote: pg_ctlcluster promote: %v\n%s\n", err, out)
return 1
}
fmt.Println("✓ pg_ctlcluster promote gesendet")
fmt.Printf("→ Promote zu Logical-Replication-Primary (PostgreSQL %s/%s)...\n", pg.Version, pg.Cluster)
// 2. Warten bis PG wirklich Primary ist (pg_is_in_recovery = false)
fmt.Print("→ Warte auf PG Primary-Mode")
deadline := time.Now().Add(60 * time.Second)
for time.Now().Before(deadline) {
out, err := psqlRun([]string{"-tA", "-c", "SELECT pg_is_in_recovery();"})
if err == nil && strings.TrimSpace(string(out)) == "f" {
break
// 2. Subscription zum alten/toten Primary lösen
if hasSub {
if err := dropSubscriptionIfExists(); err != nil {
fmt.Fprintln(os.Stderr, "promote: Subscription lösen:", err)
return 1
}
fmt.Print(".")
time.Sleep(2 * time.Second)
fmt.Println("✓ Subscription zum alten Primary entfernt")
}
fmt.Println()
// Nochmal prüfen
out, err := psqlRun([]string{"-tA", "-c", "SELECT pg_is_in_recovery();"})
if err != nil || strings.TrimSpace(string(out)) != "f" {
fmt.Fprintln(os.Stderr, "promote: PG ist nach 60s noch in recovery — prüfe PG-Logs")
// 3. Diese Node als Publisher einrichten (inkl. wal_level=logical + Restart)
if err := setupReplicationPrimary(pg); err != nil {
fmt.Fprintln(os.Stderr, "promote:", err)
return 1
}
fmt.Println("✓ PostgreSQL ist jetzt Primary")
// 3. ha_nodes.pg_role + role aktualisieren
// 4. ha_nodes-Rolle aktualisieren
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
pool, err := database.Open(ctx, database.ConnStringFromEnv())
if err != nil {
fmt.Fprintln(os.Stderr, "promote: db connect:", err)
fmt.Println(" → ha_nodes manuell updaten: UPDATE ha_nodes SET pg_role='primary', role='primary' WHERE id='<local-id>';")
fmt.Println(" → ha_nodes manuell: UPDATE ha_nodes SET pg_role='primary', role='primary' WHERE id='<local-id>';")
} else {
defer pool.Close()
localID, err := loadLocalID()
@@ -93,15 +87,7 @@ func cmdPromote(args []string) int {
}
}
// 4. KeyDB cluster:pg-primary-url updaten
if err := updateKeyDBPrimaryURL(); err != nil {
fmt.Fprintf(os.Stderr, "promote: KeyDB update: %v\n", err)
fmt.Println(" → Manuell: redis-cli SET cluster:pg-primary-url 'postgres://edgeguard@/edgeguard'")
} else {
fmt.Println("✓ KeyDB cluster:pg-primary-url aktualisiert")
}
// 5. Keepalived.conf neu rendern (Primary = Priorität 200)
// 5. keepalived.conf neu rendern (Primary = MASTER, Priority 200 → VIP)
if pool != nil {
localID, _ := loadLocalID()
kg := keepalived.New(pool, localID)
@@ -109,9 +95,9 @@ func cmdPromote(args []string) int {
defer renderCancel()
if err := kg.Render(renderCtx); err != nil {
fmt.Fprintf(os.Stderr, "promote: keepalived render: %v\n", err)
fmt.Println(" → Manuell: edgeguard-ctl render-config --only=keepalived")
fmt.Println(" → Manuell: sudo -u edgeguard edgeguard-ctl render-config --only=keepalived")
} else {
fmt.Println("✓ keepalived.conf neu gerendert (Priority 200)")
fmt.Println("✓ keepalived.conf neu gerendert (MASTER, Priority 200)")
}
}
@@ -119,8 +105,8 @@ func cmdPromote(args []string) int {
fmt.Println("✓ Promotion abgeschlossen. Diese Node ist jetzt der primäre EdgeGuard-Knoten.")
fmt.Println()
fmt.Println("Empfohlene Nachschritte:")
fmt.Println(" 1) sudo systemctl restart edgeguard-api (falls noch nicht laufend)")
fmt.Println(" 2) Alte Primary-Node nach Recovery als neuen Standby einrichten:")
fmt.Println(" 1) sudo systemctl restart edgeguard-api")
fmt.Println(" 2) Übrige/erholte Nodes als Standby auf DIESE Node zeigen lassen:")
fmt.Println(" edgeguard-ctl cluster-setup-standby <diese-node-ip>")
return 0
}
@@ -136,25 +122,3 @@ func loadLocalID() (string, error) {
}
return c.NodeID, nil
}
// updateKeyDBPrimaryURL schreibt den lokalen PG-DSN als cluster:pg-primary-url
// in KeyDB, damit alle Nodes im Cluster Writes an diese Node schicken.
func updateKeyDBPrimaryURL() error {
// edgeguard-api nutzt Unix-Socket-Auth, der DSN ist immer lokal.
const localDSN = "postgres://edgeguard@/edgeguard?host=/var/run/postgresql"
out, err := exec.Command("redis-cli",
"-s", "/var/run/keydb/keydb.sock",
"SET", "cluster:pg-primary-url", localDSN,
).CombinedOutput()
if err != nil {
// Fallback: Standard-Port
out2, err2 := exec.Command("redis-cli",
"-p", "6379",
"SET", "cluster:pg-primary-url", localDSN,
).CombinedOutput()
if err2 != nil {
return fmt.Errorf("%v: %s / %v: %s", err, out, err2, out2)
}
}
return nil
}

View File

@@ -8,6 +8,8 @@ import (
"time"
"git.netcell-it.de/projekte/edgeguard-native/internal/chrony"
"git.netcell-it.de/projekte/edgeguard-native/internal/freeradius"
"git.netcell-it.de/projekte/edgeguard-native/internal/kea"
"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"
@@ -61,9 +63,17 @@ func cmdRenderConfig(args []string) int {
wg := wireguard.New(pool, secrets.New(""))
ub := unbound.New(pool)
cn := chrony.New(pool)
ke := kea.New(pool)
fr := freeradius.New(pool, secrets.New(""))
if skipReload {
hap.SkipReload = true
fw.SkipReload = true
sq.SkipReload = true
wg.SkipReload = true
ub.SkipReload = true
cn.SkipReload = true
ke.SkipReload = true
fr.SkipReload = true
}
// keepalived: Node-ID aus node.conf für Prioritäts-Berechnung
@@ -72,7 +82,7 @@ func cmdRenderConfig(args []string) int {
ka = keepalived.New(pool, lc.NodeID)
}
gens := []configgen.Generator{hap, fw, sq, wg, ub, cn}
gens := []configgen.Generator{hap, fw, sq, wg, ub, cn, ke, fr}
if ka != nil {
gens = append(gens, ka)
}

View File

@@ -83,6 +83,8 @@ func main() {
slog.Error("waf: SPOE agent stopped", "error", err)
os.Exit(1)
}
// Graceful shutdown (ctx cancelled): gepufferte Alerts flushen.
alertWriter.Close()
}
// reload fetches all domain+waf_config pairs from DB and rebuilds engines.

View File

@@ -1,6 +1,8 @@
# EdgeGuard — Architektur
> Status: **v0.1 (Entwurf)** · Stand: 2026-05-08 · Ziel-Plattformen: Debian 13 (Trixie) + Ubuntu 24.04 LTS (Noble Numbat), Architekturen amd64 + arm64.
> Status: **in Produktion** (v1.2.x) · Entwurf: 2026-05-08 · **Cluster/HA-Abschnitte an Ist-Stand angeglichen: 2026-06-06** · Plattform: Debian 13 (Trixie), amd64 + arm64.
>
> ⚠️ **Lesehinweis:** Dieses Dokument war ursprünglich ein Entwurf. Mehrere Cluster/HA-Annahmen wurden anders umgesetzt — maßgeblich sind die mit „**Ist-Stand**" markierten Stellen (§0, §6§9). Kurzfassung: **Logical Replication** statt Streaming, **keepalived/VRRP** statt Hoster-Floating-IP, **KeyDB optional/ungenutzt** (Cluster-State in PostgreSQL), **kein Write-Proxy**.
EdgeGuard ist die native Neufassung des bisherigen Docker-basierten Reverse-Proxy/Loadbalancer/Forward-Proxy/VPN-Stacks. Vorbild für Architektur, Build-System und Cluster-Modell ist [`mail-gateway`](../../mail-gateway/docs/architecture.md) (`nmg`); UI-Pattern und Bootstrap-Onliner stammen aus [`netcell-webpanel`](../../netcell-webpanel/CLAUDE.md) (`enconf`).
@@ -8,14 +10,14 @@ 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, 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.
- **Kein Docker.** Alle Dienste nativ unter `systemd`, installiert via `apt`. Distro-Pakete für Drittsoftware (HAProxy, Squid, WireGuard, Unbound, PostgreSQL, keepalived, chrony, certbot), eigene `.deb`-Pakete für EdgeGuard-Code (api, ui, ctl).
- **Plattform:** **Debian 13 (Trixie), amd64 + arm64.** Nur Trixie — die Build-/Publish-Pipeline (`Makefile`, `scripts/apt-repo/`) zielt ausschließlich auf `trixie`. _(Eine frühere Ubuntu-24.04-Matrix war geplant, ist aber nicht implementiert.)_
- **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).
- **Kein WAF, kein IDS, kein DHCP, kein RADIUS, keine Mail-Funktion in v1.** Mail-Gateway ist eigenes Produkt (`nmg`); WAF/CrowdSec/Suricata kommen ggf. in v2.
- **HA nativ als Cluster (Ist-Stand 2026-06):** N symmetrische Peers, **PostgreSQL Logical Replication** (ein Publisher/Primary `edgeguard_shared` → N Subscriber; node-lokale Tabellen ausgenommen) + **keepalived/VRRP** für den VIP-Failover (HTTP/HTTPS-Ingress) + **mTLS-Cluster-Agent** (:8443) für Heartbeat/Cert-Sync/Aggregation. **KeyDB ist optional** (`Recommends`) und im Code praktisch ungenutzt; ein Write-Proxy existiert nicht (Writes erfolgen am Primary). _(Der ursprüngliche Entwurf — KeyDB Active-Active, PG-Streaming-Replication mit transparentem Write-Proxy, Floating-IP-statt-VRRP — wurde so nicht umgesetzt; Details in §6§9.)_
- **WAF, IDS/IPS, DHCP, RADIUS inzwischen umgesetzt** (Stand 2026-06): WAF via Coraza+SPOE, IDS/IPS via CrowdSec, DHCP via Kea, RADIUS via FreeRADIUS. Mail-Funktion bleibt ausgeschlossen — Mail-Gateway ist eigenes Produkt (`nmg`).
- **Migrations:** `goose` (SQL-Dateien), nicht GORM AutoMigrate.
**Nicht-Ziele (ausdrücklich):** kein WAF, kein Network-IDS (Suricata), kein IPS (CrowdSec), kein DHCP-Server (Kea), kein RADIUS, keine Mail-Verarbeitung, keine Multi-Tenant-GuardZones in v1, keine ISO-Builds (kein EdgeGuardOS-Klon — nur APT).
**Nicht-Ziele (weiterhin):** kein Network-IDS via Suricata (IDS/IPS läuft über CrowdSec), keine Mail-Verarbeitung, keine Multi-Tenant-GuardZones, keine ISO-Builds (kein EdgeGuardOS-Klon — nur APT). _(Historisch waren auch WAF/DHCP/RADIUS/IPS Nicht-Ziele — siehe oben, inzwischen umgesetzt.)_
---
@@ -33,12 +35,13 @@ EdgeGuard ist die native Neufassung des bisherigen Docker-basierten Reverse-Prox
| Komponente | Rolle |
|---|---|
| `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`) |
| `edgeguard-api` | Go/Gin REST-API, bindet `127.0.0.1:9443`, Reads/Writes auf lokaler PG. Geteilte Tabellen werden vom Primary per Logical Replication an Subscriber verteilt; Writes sollen am Primary erfolgen (keine Write-Proxy-Umleitung im Code). |
| `edgeguard-waf` | Coraza-WAF-Agent (HAProxy SPOE) — Binary im `edgeguard-api`-Paket, eigene systemd-Unit |
| `edgeguard-scheduler` | Cron-artige Jobs (ACME-Renewal-Hook, Backup, Health-Aggregation, Stale-Node-Sweep, License-Heartbeat) |
| `edgeguard-ctl` | CLI für Setup/Wartung (`initdb`, `migrate`, `cluster-join`, `promote`, `cluster-init-replication`, `cluster-setup-standby`, `dump-config`) |
| `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 |
| **PostgreSQL 16/17** | Single Source of Truth — Domains, Backends, Routing-Rules, ACLs, Peers, Cluster-State (`ha_nodes`), Lizenz etc. |
| **KeyDB** (optional) | `Recommends`, im Code praktisch ungenutzt — kein Redis-Client in `go.mod`. Cluster-State/Heartbeat/Locks liegen in PostgreSQL, nicht in KeyDB. |
---
@@ -61,10 +64,15 @@ EdgeGuard ist die native Neufassung des bisherigen Docker-basierten Reverse-Prox
│ ├── wireguard/ # WireGuard-Config-Generator (wg-quick + wg syncconf)
│ ├── unbound/ # Unbound-Config-Generator (Forwarder + Cluster-DNS)
│ ├── firewall/ # nftables-Ruleset-Generator
│ ├── cluster/ # Join/Promote/Peer-Discovery, KeyDB-Replication-Setup, pg_basebackup
│ ├── proxy/ # API-Write-Proxy-Middleware (Replica → Primary), mTLS-Calls
│ ├── aggregator/ # Cluster-View-APIs (alle Backends, alle Peers, alle Health-States)
── license/ # License-Validation, License-Leader-Election (KeyDB-Lock)
│ ├── cluster/ # Join/Promote/Peer-Discovery, Heartbeat, Logical-Replication-Setup, confighash
│ ├── keepalived/ # keepalived/VRRP-Config-Generator (VIP-Failover)
│ ├── chrony/ # chrony-Config-Generator (NTP)
── kea/ # Kea-DHCP4-Config-Generator
│ ├── freeradius/ # FreeRADIUS-Config-Generator (RADIUS)
│ ├── crowdsec/ # CrowdSec-IDS/IPS-Management (managed-wenn-installiert)
│ ├── waf/ # Coraza-WAF-Engine + SPOE-Agent-Logik
│ ├── aggregator/ # Cluster-View-APIs via mTLS (read-only Fan-Out + Trigger-Actions)
│ └── license/ # License-Validation (jeder Node verifiziert eigenständig — KEINE KeyDB-Leader-Election)
├── management-ui/ # React 19 + AntD 6 + Vite (Struktur 1:1 wie netcell-webpanel/management-ui/)
├── packaging/
│ └── debian/
@@ -94,17 +102,17 @@ EdgeGuard ist die native Neufassung des bisherigen Docker-basierten Reverse-Prox
## 3. Debian-Pakete
Drei Pakete + Meta — analog nmg, kein WAF-Paket weil kein WAF in v1.
Drei Pakete + Meta — analog nmg. Der WAF-Agent `edgeguard-waf` ist **kein eigenes Paket**, sondern liegt als zusätzliches Binary im `edgeguard-api`-Paket (eigene systemd-Unit).
| Paket | Arch | Inhalt | Depends |
|---|---|---|---|
| `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-api` | amd64, arm64 | `/usr/bin/edgeguard-{api,scheduler,ctl,waf}`, Unit-Files, Migrations, Default-Configs | `postgresql-16 \| postgresql-17`, `haproxy (>=2.8)`, `squid`, `wireguard-tools`, `unbound`, `chrony`, `kea-dhcp4-server`, `freeradius`, `nftables`, `keepalived`, `certbot`, `openssl`, `sudo`, `adduser`, `systemd`, `ca-certificates`, `ulogd2`, `ulogd2-json` u. a. · _Recommends:_ `edgeguard-keydb`, `apparmor`, `fail2ban` · _CrowdSec: managed-wenn-installiert (kein Depends)_ |
| `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**.
Pro Release: 1 arch-spezifisches Paket (`edgeguard-api`) × **1 Dist (trixie)** × 2 Arches = 2 `.deb` + 2 arch-agnostische (`edgeguard-ui`, `edgeguard-meta`) = **4 Artefakte je Release**. (Build/Publish-Pipeline zielt nur auf `trixie`.)
**KeyDB-Herkunft:** KeyDB ist weder in `trixie` noch `noble` in den offiziellen Repos. Wir bauen es aus Source (amd64 + arm64), veröffentlichen es parallel im eigenen APT-Repo. `edgeguard-api` `Depends: keydb-server` löst aus unserem Repo aus.
**KeyDB-Herkunft:** KeyDB ist optional (`Recommends: edgeguard-keydb`), nicht in den offiziellen trixie-Repos. Falls genutzt, aus Source gebaut + im eigenen APT-Repo veröffentlicht. Im aktuellen Code wird KeyDB nicht benötigt — siehe §7.
**Build-Werkzeug:** **direkter `dpkg-deb`-Build** analog WebPanel/EdgeGuardOS-Pattern. **Nicht** `dh_make`/`debhelper`, **nicht** `fpm`. Konsistenz mit existierendem Workflow.
@@ -162,7 +170,8 @@ Entspricht FHS — keine Überraschungen für Admins, Lintian-clean.
| Unit | Typ | Depends-on | User | Restart |
|---|---|---|---|---|
| `edgeguard-api.service` | `simple` | `postgresql.service`, `keydb-server.service` | `edgeguard` | `on-failure`, `RestartSec=5` |
| `edgeguard-api.service` | `simple` | `Requires=postgresql.service`; `After=`/`Wants=keydb-server.service` (KeyDB nur weich/optional) | `edgeguard` | `on-failure`, `RestartSec=5` |
| `edgeguard-waf.service` | `simple` | `edgeguard-api.service` (Coraza SPOE-Agent) | `edgeguard` | `on-failure` |
| `edgeguard-scheduler.service` | `simple` | `edgeguard-api.service` | `edgeguard` | `on-failure` |
| `edgeguard-cert-deploy.path` | `path` | — | — | — |
| `edgeguard-firewall.service` | `oneshot`, `RemainAfterExit=true` | — | root | — |
@@ -183,7 +192,7 @@ SystemCallFilter=@system-service
ReadWritePaths=/var/lib/edgeguard /var/log/edgeguard /etc/edgeguard
```
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**.
Drittsoftware läuft als **Distro-Units** EdgeGuard generiert deren Config + signalisiert Reload/Restart, übernimmt aber die Service-Verwaltung weitgehend nicht. Renderer existieren für: **HAProxy, Squid, WireGuard (`wg-quick@.service`), Unbound, nftables, keepalived, chrony, Kea (`kea-dhcp4-server`), FreeRADIUS** (letzte beide default-off). **CrowdSec** (`crowdsec` + `crowdsec-firewall-bouncer`) wird gemanagt, wenn installiert (kein Depends).
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.
@@ -191,29 +200,28 @@ API bindet auf `127.0.0.1:9443` (nicht öffentlich). HAProxy terminiert TLS auf
## 6. Datenbank-Setup
- **PostgreSQL 16**, Distro-Paket `postgresql-16`.
- **Verbindung:** Unix-Socket (`/var/run/postgresql`) für lokale Reads + Writes der API. TCP/5432 mit TLS-Client-Cert nur zwischen Cluster-Peers für Streaming Replication.
- **Topologie:** **ein logischer Primary** zu jedem Zeitpunkt, N Read-Replicas. Lokale API liest immer aus lokaler PG; Writes routet die API-Write-Proxy-Middleware transparent an den aktuellen Primary (KeyDB-Key `cluster:pg-primary-url`).
- **Migrations:** `goose` (SQL-Dateien in `internal/database/migrations/`, via `//go:embed` ins Binary gepackt). **Nicht** GORM AutoMigrate.
- **PostgreSQL 16/17**, Distro-Paket `postgresql-16 | postgresql-17`.
- **Verbindung:** Unix-Socket (`/var/run/postgresql`) für lokale Reads + Writes der API. TCP/5432 (Rolle `edgeguard_replicator`) nur zwischen Cluster-Peers für die Logical-Replication-Verbindung.
- **Topologie (Ist-Stand):** **Logical Replication** — ein Primary publiziert `edgeguard_shared` (alle Tabellen außer `localOnlyTables`), N Subscriber (`edgeguard_sub`, `wal_level=logical`, Initialkopie via `copy_data=true`). Jeder Node hat eine **eigene beschreibbare** PG-Instanz; geteilte Config fließt vom Primary zu den Subscribern. **Es gibt keinen Write-Proxy** — Schreibzugriffe auf geteilte Tabellen müssen am Primary erfolgen; ein Subscriber-Write auf eine replizierte Tabelle würde nicht propagieren (Drift-Banner erkennt das via `config_hash`). Primary-Erkennung zuverlässig über `pg_publication`; der Standby-Bootstrap läuft per Logical Subscription (kein `pg_basebackup` im aktiven Pfad).
- **node-lokale Tabellen** (nicht repliziert): `ha_nodes`, `network_interfaces`, `ip_addresses`, `static_routes`, `cluster_settings`, `dns_settings`, `ntp_settings`, `dhcp_settings`, `radius_settings`, `system_settings`, `join_tokens_used`, `audit_log`, `alert_events`, `backups`, `goose_db_version` (Liste: `cmd/edgeguard-ctl/cluster_replication.go` `localOnlyTables`).
- **Migrations:** `goose` (SQL-Dateien in `internal/database/migrations/`, via `//go:embed`). **Nicht** GORM AutoMigrate.
GORM bleibt als ORM für Query-Komfort; nur das Schema-Management wechselt zu `goose`.
GORM bleibt als ORM für Query-Komfort; Schema-Management läuft über `goose`.
---
## 7. KeyDB Active-Active
## 7. Cluster-State & KeyDB (Ist-Stand: PostgreSQL-zentrisch)
KeyDB ersetzt Redis. **Active-Active Replication** (Multi-Master, operation-basiert, split-brain-tolerant).
> **Hinweis:** Der ursprüngliche Entwurf sah KeyDB Active-Active als Cluster-State-Layer vor. **Im Code ist das nicht umgesetzt** — es gibt **keinen Redis/KeyDB-Client** (`go.mod` enthält nur `pgx`). KeyDB ist optional (`Recommends`) und wird vom laufenden System nicht benötigt.
**Verwendung:**
- `cluster:pg-primary-url` — wer ist aktueller PG-Primary?
- `cluster:license-leader` — Lock für License-Heartbeat (`SET … NX EX 60`)
- `cluster:license-status` — Cache des Lizenz-Validate-Ergebnisses (TTL 24 h)
- `cluster:nodes:<node-id>` — Heartbeat-Marker (TTL 2 min)
- `ratelimit:<scope>:<key>` — Rate-Counter (HINCRBY-Ops mergen korrekt)
- `acme:lock:<domain>` — verhindert Parallel-Issue auf zwei Nodes
- Pub/Sub: `edgeguard:config-changed` — alle Nodes regenerieren Config
**Wie Cluster-State tatsächlich gehalten wird:**
- **PG-Primary** — über `pg_publication` (`edgeguard_shared`) ermittelt; die Peer-Adresse für Pushes stammt aus `setup.json` `PrimaryFQDN`.
- **Node-Heartbeat/-Status** — Spalten `last_seen`/`status` in PG `ha_nodes`. Jeder Node bumpt seine Row alle 30s (`runClusterHeartbeat`); Secondary→Primary (`runPrimaryPush`) und Primary→Secondary (`runPeerPush`) pushen sich gegenseitig per mTLS (30s, bidirektional). `SweepStaleNodes` (Scheduler) flippt Peers nach 2 min ohne Heartbeat auf `offline`.
- **Lizenz** — jeder Node verifiziert **eigenständig** gegen `license.netcell-it.com` (kein Leader-Lock); Ergebnis in PG `licenses`.
- **ACME** — kein verteilter Issue-Lock implementiert (Single-Node-Default; bei Cluster Issue am aktiven/Primary-Node).
- `cluster:pg-primary-url` in KeyDB wird von `edgeguard-ctl promote` **geschrieben, falls KeyDB läuft**, aber von der API **nie gelesen** (advisory/Altlast).
KeyDB hört nur auf `127.0.0.1:6379` für lokale Clients und `<node-ip>:16379` (TLS) für Peer-Replication.
_Falls KeyDB künftig wieder eingeführt wird (Rate-Limiting-Counter, Pub/Sub-Config-Reload): hört auf `127.0.0.1:6379` lokal und `<node-ip>:16379` (TLS) für Peer-Replication. Derzeit ungenutzt._
---
@@ -234,7 +242,7 @@ Unbound erfüllt zwei Rollen, beide aus PG generiert:
- **Local-Zone** `eg.cluster.` enthält A/AAAA-Records aller Cluster-Peers (Node-Hostnamen aus PG `ha_nodes`).
- Beispiel: `node1.eg.cluster → 10.42.0.11`, `node2.eg.cluster → 10.42.0.12`.
- Wird bei jedem Node-Join/-Leave aus PG regeneriert + via `edgeguard:config-changed` Pub/Sub auf allen Peers neu geladen (`unbound-control reload`).
- Cluster-interner Traffic (PG-Replication, KeyDB-Replication, mTLS-API-Calls, Cert-Push) löst Peer-Adressen ausschließlich über diese Zone auf — kein DNS-Roundtrip ins öffentliche Internet, keine `/etc/hosts`-Synchronisation.
- Cluster-interner Traffic (PG-Logical-Replication, mTLS-Agent-Calls auf :8443, Cert-Push) löst Peer-Adressen ausschließlich über diese Zone auf — kein DNS-Roundtrip ins öffentliche Internet, keine `/etc/hosts`-Synchronisation.
- `<node-name>.eg.cluster` ist **nicht extern erreichbar** (nur über Unbound der Cluster-Peers).
### Config-Schichten
@@ -253,54 +261,56 @@ Reload via `unbound-control reload` (kein Restart, keine Cache-Invalidierung au
## 8. Cluster-Topologie & HA pro Service
**N symmetrische Peers** (1 … N Nodes, jeder vollwertig). Keine VRRP, keine Master/Backup-Rollen für Daten-Services. Public-IP: **Floating-IP des Hosters** (siehe §9).
**N symmetrische Peers** (1 … N Nodes, jeder vollwertig). Public-IP-Failover via **VIP/VRRP (keepalived)** — siehe §9 (der ursprünglich geplante „Floating-IP statt VRRP"-Ansatz wurde **nicht** umgesetzt).
| 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. 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. |
| **VIP/keepalived** | VRRP (`vrrp_instance`), MASTER/BACKUP per `pg_role` (primary→prio 200/MASTER, standby→100/BACKUP). VIPs aus `ip_addresses` (`is_vip=true`). Trägt den HTTP/HTTPS-Ingress. |
| **HAProxy** | stateless, pro Node identisch. Hört auf der VIP des aktiven Node. ACME-Issue ohne verteilten Lock (Single-/Primary-Node); Zerts werden via mTLS (`/agent/cluster/tls-certs`) an alle verteilt. |
| **Squid** | stateless (Cache lokal). 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). |
| **nftables** | pro Node identisch, Ruleset aus PG generiert. `crowdsec_blocklist`/`threat_intel_blocklist`-Sets entfallen in v1 (kein CrowdSec). |
| **edgeguard-api** | pro Node, Reads lokal, Writes via Proxy zu Primary. |
| **edgeguard-ui** | statisch, pro Node identisch. |
| **PostgreSQL** | Streaming Replication, manueller Promote (siehe nmg §6.2). |
| **KeyDB** | Active-Active. |
| **Unbound** | stateless (Cache lokal). Pro Node identische Forwarder-Config + Cluster-Local-Zones (§7.5). |
| **nftables** | pro Node, Ruleset aus PG generiert. CrowdSec-Blocklist via `crowdsec-firewall-bouncer` (eigene Sets), wenn CrowdSec installiert. |
| **edgeguard-api** | pro Node, Reads lokal. Writes auf geteilte Tabellen am Primary (kein Write-Proxy). |
| **edgeguard-ui / edgeguard-waf** | statisch bzw. pro Node identisch. |
| **PostgreSQL** | **Logical Replication** (Publisher→Subscriber), manueller Promote (§8.2). |
| **KeyDB** | optional/ungenutzt (§7). |
### 8.1 WireGuard im Cluster
Drei Optionen, für v1 wählen wir **Option A**:
- **A — Geteilte Server-Identität (gewählt):** alle Peers haben **denselben** Server-Privatkey + dasselbe Listen-Port. Floating-IP routet UDP zum aktiven Node. Bei Failover: Floating-IP wandert, Clients schicken Pakete zum neuen Node, neuer Handshake (~12s Latenz beim ersten Paket). Replay-Protection-Counter werden nicht repliziert — beim Failover macht der Client neuen Handshake, alte Counter sind irrelevant.
- **A — Geteilte Server-Identität (gewählt):** alle Peers haben **denselben** Server-Privatkey + dasselbe Listen-Port. Die **VIP (keepalived)** trägt das WireGuard-UDP zum aktiven Node. Bei Failover: VIP wandert, Clients schicken Pakete zum neuen Node, neuer Handshake (~12s Latenz beim ersten Paket). Replay-Protection-Counter werden nicht repliziert — beim Failover macht der Client neuen Handshake, alte Counter sind irrelevant.
- B — Pro Node eigene Identität, Client kennt alle: Client-Configs haben mehrere `[Peer]`-Blöcke. Aufwendiger zu provisionieren, kein Failover-Vorteil.
- C — Aktiv/Standby per License-Leader-Pattern: nur ein Node hat WireGuard aktiv, andere idle. Verschwendet Kapazität.
**Begründung A:** Privatkey ist in PG (verschlüsselt mit `edgeguard.key`), wird beim Cluster-Join an neue Peers verteilt. WireGuard handelt selbständig neue Sessions aus, kein State-Sync nötig. Operation-Tools (Peer hinzufügen/entfernen) wirken auf alle Nodes via `edgeguard:config-changed` Pub/Sub + lokales `wg syncconf`.
**Begründung A:** Privatkey liegt verschlüsselt in PG, wird per Logical Replication an die Peers verteilt. WireGuard handelt selbständig neue Sessions aus, kein State-Sync nötig. Peer-Änderungen propagieren über die Logical Replication; Secondaries erkennen die Änderung am `config_hash` (`runSecondaryConfigRender`, 5-min-Tick) und re-rendern lokal `wg syncconf`.
### 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/Squid/WireGuard/Unbound) läuft unbeeinträchtigt weiter, weil jeder Node eine lokale PG-Replica hat.
Bei Ausfall des Primary läuft die Datenebene (HAProxy/Squid/WireGuard/Unbound) weiter, weil jeder Node eine lokale, lesbare PG-Instanz (Logical-Subscriber) hat. Schreibzugriffe auf geteilte Config müssen am Primary erfolgen — fällt der Primary aus, promotet der Admin manuell via **`edgeguard-ctl promote`**. Das ist Logical-Replication-aware: es löst die Subscription zum toten Primary (`DISABLE` + `slot_name=NONE` + `DROP`, hängt also nicht am toten Publisher), richtet die Node via `setupReplicationPrimary` als Publisher ein (Rolle/Secret/`wal_level=logical` inkl. **PG-Restart** falls nötig/Publication), setzt `ha_nodes.pg_role='primary'` und rendert keepalived (→ MASTER, übernimmt die VIP). Erholte Nodes danach mit `edgeguard-ctl cluster-setup-standby <neuer-primary>` zurückhängen. **Achtung:** echtes Cross-Node-Failover ist nur im Drill testbar — die Bausteine (Drop-Subscription, Publication, Restart) sind dieselben wie in `cluster-init-replication`/`cluster-setup-standby`.
### 8.3 License-Leader-Election
### 8.3 License-Verifikation
Ein einziger Node kontaktiert `license.netcell-it.com` (KeyDB-Lock, 60-s-TTL). Ergebnis cluster-weit in `cluster:license-status` (TTL 24 h). `active_servers`-Verbrauchswert = Count der Peers mit Heartbeat < 2 min.
**Kein Leader-Election** (anders als ursprünglich geplant). Jeder Node verifiziert **eigenständig** gegen `license.netcell-it.com` (Scheduler-Tick), Ergebnis in PG `licenses`. `active_servers` = Anzahl Peers mit Heartbeat < 2 min (aus `ha_nodes`). Ein KeyDB-Lock existiert nicht.
---
## 9. Public-Ingress — Floating-IP statt VRRP
## 9. Public-Ingress — VIP via keepalived/VRRP
**Problem:** HTTP-Clients machen kein automatisches Failover bei DNS-RR (anders als MTAs). Ein toter A-Record = 50% Fehler bis DNS-TTL.
> **Ist-Stand:** Umgesetzt ist **VIP-Failover über keepalived (VRRP)** — nicht der ursprünglich angedachte „Floating-IP des Hosters"-Ansatz. Es gibt **keinen** Hoster-API-Code und **keinen** `POST /cluster/promote-this-node`-Endpoint.
**Entscheidung:** **Floating-IP des Hosters**. Der Hoster bietet eine API zum Umroute der IP zwischen Servern (z. B. via REST oder DNS-Update bei dynamischer Anycast-Lösung). Failover dauert Sekunden, kein VRRP-Drama, kein "VIP verschwindet"-Problem aus dem alten Setup.
**Mechanik (`internal/keepalived`):**
- Renderer erzeugt `/etc/keepalived/keepalived.conf` mit `vrrp_instance` (unicast peer, `virtual_router_id`, `authentication`).
- **State/Priorität aus `pg_role`:** Primary → `state MASTER`, `priority 200`; Standby → `state BACKUP`, `priority 100`.
- **VIPs** kommen aus `ip_addresses` (`is_vip=true`, `active=true`), inkl. Interface; managed via `systemctl reload-or-restart keepalived`.
- Bei Node-/PG-Ausfall übernimmt VRRP die VIP auf den verbleibenden Node (Sekundenbereich).
Optionen pro Hoster:
1. **Provider-Floating-IP** (gewünscht): API-Call schaltet IP um. EdgeGuard exponiert `POST /api/v1/cluster/promote-this-node`, das die Hoster-API aufruft.
2. **DNS-RR mit kurzer TTL (60s)** als Notlösung wenn keine Floating-IP verfügbar.
3. **Anycast/BGP** als Premium-Variante (für Enterprise).
**Tooling:** `GET/PUT /cluster/vip-settings`, `GET /cluster/vip-status`, `POST /cluster/vip-test` (Letzteres bewegt eine VIP testweise per `ip addr add/del` zwischen Nodes — kein Hoster-Call).
**v1-Default:** Single-Node mit fest zugewiesener Floating-IP. Cluster-Erweiterung kommt mit Phase 2.
**v1-Default:** Single-Node. Im Cluster trägt der MASTER (Primary) die VIP.
**OFFEN:** Welcher Hoster ist Standard? API-Spec dokumentieren sobald geklärt.
**OFFEN (Altlast-Bereinigung):** Doku-Abschnitte/Code, die noch „Floating-IP des Hosters" implizieren, sind historisch — der reale Pfad ist keepalived/VRRP.
---
@@ -312,7 +322,7 @@ curl -fsSL https://get.edgeguard.netcell-it.de | sudo bash
Schritte (idempotent, analog `netcell-webpanel/install.sh`):
1. **OS-Detection** (`/etc/os-release`): nur Trixie *oder* Noble, sonst Abbruch.
1. **OS-Detection** (`/etc/os-release`): nur Debian 13 (Trixie), sonst Abbruch.
2. **Arch-Detection**: nur amd64 *oder* arm64.
3. **Base-Deps:** `curl gnupg ca-certificates apt-transport-https`.
4. **APT-Keyrings:**
@@ -329,7 +339,7 @@ curl -fsSL https://get.edgeguard.netcell-it.de | sudo bash -s -- \
--token <cluster-join-token>
```
`edgeguard-ctl cluster-join` führt aus: PG-Basebackup vom Primary, KeyDB-Replication-Setup, Node-Registrierung in `ha_nodes`, TLS-Cert-Pull via mTLS, Config-Regeneration, Service-Start.
`edgeguard-ctl cluster-join` führt aus: TLS-Cert-Pull via mTLS (CSR→issue-cert), Node-Registrierung in `ha_nodes` (`autoRegister`), Setup als **Logical-Replication-Subscriber** (`cluster-setup-standby`: `CREATE SUBSCRIPTION … copy_data=true`, Initialkopie der geteilten Tabellen), Config-Regeneration, Service-Start. _(Kein `pg_basebackup`, kein KeyDB-Setup — beides war nur im ursprünglichen Entwurf.)_
---
@@ -337,7 +347,7 @@ curl -fsSL https://get.edgeguard.netcell-it.de | sudo bash -s -- \
- **Primärquelle:** Gitea Package Registry (`https://git.netcell-it.de/api/packages/projekte/debian`).
- **Kunden-Mirror:** `https://apt.netcell-it.de/edgeguard/` (rsync von Gitea).
- **Suiten:** `stable` · `testing` · `security` pro Codename (`trixie`, `noble`).
- **Suiten:** `stable` · `testing` · `security` — Codename `trixie`.
- **Signatur:** GPG-Key `netcell-edgeguard-signing`, ausgeliefert in `/etc/apt/keyrings/`.
- **Update-Check-API:** `GET /api/v1/system/package-versions` → pro `edgeguard-*`-Paket `{name, installed, available, reboot_required}`.
- **Upgrade-Trigger:** `POST /api/v1/system/upgrade` startet `systemd-run --unit=edgeguard-upgrade.service --collect …` (HTTP-Response geht VOR dem Upgrade raus, weil API beim Self-Update stirbt — Pattern aus `netcell-webpanel/management-agent/internal/handlers/update.go:105`).
@@ -355,13 +365,13 @@ Build-/Release-Scripts identisch zu `mail-gateway/scripts/apt-repo/`.
- **Lizenzserver:** `https://license.netcell-it.com` (öffentlich, kein API-Key).
- **Verify-Endpoint:** `GET /api/v1/licenses/{key}/verify?system_id={fp}&system_name={host}&active_domains={n}`.
- **Fingerprint:** `SHA256(/etc/machine-id + erste-aktive-MAC + hostname)`.
- **Caching:** Live → KeyDB `cluster:license-status` (TTL 24h)`/var/lib/edgeguard/trial.json` (30 Tage) → `expired`.
- **Leader-Election** wie nmg §6.3.
- **Caching:** Live-Verify → Ergebnis in PG `licenses``/var/lib/edgeguard/trial.json` (30-Tage-Trial-Fallback) → `expired`.
- **Keine Leader-Election** — jeder Node verifiziert eigenständig (§8.3).
### 12.2 ACME
- **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.
- **Cluster-Locking:** derzeit **kein** verteilter Issue-Lock implementiert (Single-Node-Default; im Cluster sollte ACME am Primary/aktiven Node laufen). _(Der ursprünglich geplante KeyDB-`acme:lock:<domain>` existiert nicht.)_
- **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/`.
@@ -384,14 +394,12 @@ Komponentenbibliothek, Theme, Layouts, Navigations-Struktur, Form-Patterns, i18n
## 14. Plattform-Matrix
| Distribution | Codename | Arch | Status v1 |
| Distribution | Codename | Arch | Status |
|---|---|---|---|
| Debian 13 | trixie | amd64 | Tier 1 |
| Debian 13 | trixie | arm64 | Tier 1 |
| Ubuntu 24.04 LTS | noble | amd64 | Tier 1 |
| Ubuntu 24.04 LTS | noble | arm64 | Tier 1 |
Andere Distributionen (Debian 12, Ubuntu 22.04, RHEL/Rocky) sind **nicht unterstützt**. Installer bricht hart ab.
**Nur Debian 13 (Trixie).** Die Build-/Publish-Pipeline (`Makefile`, `scripts/apt-repo/`) zielt ausschließlich auf `trixie`; der Installer bricht auf anderem OS hart ab. _(Eine ursprünglich geplante Ubuntu-24.04-„noble"-Matrix ist nicht implementiert.)_ Andere Distributionen (Debian 12, Ubuntu, RHEL/Rocky) sind **nicht unterstützt**.
---
@@ -402,7 +410,7 @@ EdgeGuard-Native ist eigenes Repo (`git.netcell-it.de/projekte/edgeguard-native`
1. **Frische Installation** auf Test-VM via `install.sh`.
2. **Config-Export** aus altem Stack (`edgeguard-ctl export --from-docker`) — liest aus alter PG, schreibt in neues Format.
3. **Validierung** Side-by-Side (alter Stack auf einem Server, neuer Stack auf anderem, Traffic vergleichen).
4. **Cutover** via Floating-IP-Switch.
4. **Cutover** via VIP-Umzug (keepalived) bzw. DNS-Umstellung.
Der alte `proxy-lb-waf`-Code bleibt für Bestandskunden im Wartungsmodus, keine neuen Features.
@@ -410,6 +418,6 @@ Der alte `proxy-lb-waf`-Code bleibt für Bestandskunden im Wartungsmodus, keine
## Offene Punkte
- **Hoster + Floating-IP-API** (§9): Spec dokumentieren.
- **WireGuard-State-Replication** in der Praxis testen (Handshake-Latenz nach Floating-IP-Switch messen).
- **Failover-Drill:** `edgeguard-ctl promote` (Logical-aware) + anschließendes `cluster-setup-standby` in einem echten 2-Node-Failover durchspielen (inkl. VIP-Umzug, WireGuard-Handshake-Latenz). _(Code-Altlasten `internal/proxy`-Stub und `promote.go`-`standby.signal` wurden 2026-06 bereinigt.)_
- **Optional KeyDB** (Rate-Limit-Counter, Pub/Sub-Config-Reload) — falls je benötigt; aktuell ungenutzt.
- **`get.edgeguard.netcell-it.de`** anlegen oder Übergangs-URL auf `apt.netcell-it.de/edgeguard/install.sh` nutzen.

2
go.mod
View File

@@ -4,6 +4,7 @@ go 1.26.0
require (
github.com/corazawaf/coraza/v3 v3.7.0
github.com/coreos/go-oidc/v3 v3.18.0
github.com/dropmorepackets/haproxy-go v0.0.8
github.com/fsnotify/fsnotify v1.10.1
github.com/gin-gonic/gin v1.10.0
@@ -16,6 +17,7 @@ require (
github.com/pressly/goose/v3 v3.27.1
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
golang.org/x/crypto v0.51.0
golang.org/x/oauth2 v0.36.0
)
require (

4
go.sum
View File

@@ -19,6 +19,8 @@ github.com/corazawaf/coraza/v3 v3.7.0 h1:LIQqu1r+l6e/U/gyiZeykWaNNBY1TzRLz+aaI+Q
github.com/corazawaf/coraza/v3 v3.7.0/go.mod h1:dOSt5evqC7EstouEv6ghhui01+oVUwp9X1vybWwqTlo=
github.com/corazawaf/libinjection-go v0.3.2 h1:9rrKt0lpg4WvUXt+lwS06GywfqRXXsa/7JcOw5cQLwI=
github.com/corazawaf/libinjection-go v0.3.2/go.mod h1:Ik/+w3UmTWH9yn366RgS9D95K3y7Atb5m/H/gXzzPCk=
github.com/coreos/go-oidc/v3 v3.18.0 h1:V9orjXynvu5wiC9SemFTWnG4F45v403aIcjWo0d41+A=
github.com/coreos/go-oidc/v3 v3.18.0/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26KVTCI9SrY4=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@@ -190,6 +192,8 @@ golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM=
golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU=
golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs=
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=

View File

@@ -71,33 +71,26 @@ var hashSpec = []hashTable{
{Name: "ntp_pools", MigrationDefault: true},
// network_interfaces + ip_addresses werden seit 0030 repliziert
// VLAN/Bridge/Bond-Definitionen und Gateway-IPs müssen auf dem Secondary
// für Failover bereitstehen. Ethernet-IPs werden im Secondary-Renderer
// herausgefiltert (eth0 = cloud-init / Keepalived).
//
// ip_addresses.interface_id ist ein node-lokaler Autoincrement-PK, der
// zwischen zwei unabhängigen DBs divergiert (utm-1: eth0=6, utm-2: eth0=1).
// Wir hashen daher semantisch: address + prefix + flags + interface_name
// statt interface_id — sonst False-Positive-Drift auf logisch identischen Nodes.
{Name: "network_interfaces"},
{Name: "ip_addresses", CustomSQL: `
SELECT COALESCE(md5(string_agg(rh, '|' ORDER BY rh)), '')
FROM (
SELECT md5(jsonb_build_object(
'address', ia.address,
'prefix', ia.prefix,
'is_vip', ia.is_vip,
'active', ia.active,
'vip_priority', ia.vip_priority,
'description', ia.description,
'iface', ni.name
)::text) AS rh
FROM ip_addresses ia
JOIN network_interfaces ni ON ia.interface_id = ni.id
) sub`},
// DHCP: Subnets + Reservierungen sind geteilte Config (repliziert).
// dhcp_settings ist node-lokal (ob DIESE Node DHCP betreibt) → NICHT hier.
{Name: "dhcp_subnets"},
{Name: "dhcp_reservations"},
// static_routes, dns_settings, ntp_settings bleiben node-spezifisch.
// RADIUS: Clients + Users sind geteilte Config (repliziert).
// radius_settings ist node-lokal → NICHT hier.
{Name: "radius_clients"},
{Name: "radius_users"},
// network_interfaces + ip_addresses sind BEWUSST NICHT im Drift-Hash.
// Sie stehen in cluster_replication.go localOnlyTables, werden also NICHT
// repliziert und sind per Design node-spezifisch (jede Node hat eigene
// Mgmt-/Host-IPs, z.B. utm-1=.6, utm-2=.8). Würde man sie hashen, wäre
// der config_hash zwischen zwei Nodes ZWANGSLÄUFIG dauerhaft verschieden
// → Drift-Banner, das kein Resync je beheben kann (Resync kopiert nur
// replizierte Tabellen). Migration 0030 wollte sie zwar replizieren,
// localOnlyTables schließt sie aber weiter aus → wir hashen sie nicht.
//
// static_routes, dns_settings, ntp_settings bleiben ebenfalls node-spezifisch.
}
// hashSQL rendert die SHA-Input-SQL für eine Tabelle.

View File

@@ -99,6 +99,33 @@ func RestartService(name string) error {
return nil
}
// StopService runs `sudo -n systemctl stop <name>.service`.
func StopService(name string) error {
cmd := exec.Command("sudo", "-n", "/usr/bin/systemctl", "stop", name+".service")
if out, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("sudo systemctl stop %s.service: %w (output: %s)", name, err, strings.TrimSpace(string(out)))
}
return nil
}
// EnableService runs `sudo -n systemctl enable <name>.service` (boot-persistent).
func EnableService(name string) error {
cmd := exec.Command("sudo", "-n", "/usr/bin/systemctl", "enable", name+".service")
if out, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("sudo systemctl enable %s.service: %w (output: %s)", name, err, strings.TrimSpace(string(out)))
}
return nil
}
// DisableService runs `sudo -n systemctl disable <name>.service`.
func DisableService(name string) error {
cmd := exec.Command("sudo", "-n", "/usr/bin/systemctl", "disable", name+".service")
if out, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("sudo systemctl disable %s.service: %w (output: %s)", name, err, strings.TrimSpace(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

@@ -1,10 +1,14 @@
-- +goose Up
-- +goose StatementBegin
-- network_interfaces und ip_addresses werden in die Cluster-Replikation
-- aufgenommen. Das ALTER PUBLICATION erfordert den Superuser (postgres),
-- daher läuft es im postinst via `sudo -u postgres psql`, nicht hier.
-- Diese Migration dient nur als Versions-Marker für goose.
-- HINWEIS (korrigiert v1.2.89): Diese Migration war urspr. dafür gedacht,
-- network_interfaces und ip_addresses in die Cluster-Replikation aufzunehmen.
-- Das wurde NICHT umgesetzt und ist auch NICHT gewollt: beide Tabellen sind
-- node-spezifisch (jede Node hat eigene Mgmt-/Host-IPs) und stehen weiterhin
-- in cluster_replication.go localOnlyTables → sie werden bewusst NICHT
-- repliziert. Sie sind auch aus dem Drift-Hash (confighash.go) entfernt,
-- da sie sonst dauerhaften False-Positive-Drift erzeugen.
-- Diese Migration ist ein No-op / reiner Versions-Marker für goose.
SELECT 1;
-- +goose StatementEnd

View File

@@ -0,0 +1,35 @@
-- +goose Up
-- +goose StatementBegin
-- OIDC / Keycloak SSO — Singleton-Settings (analog forward_proxy_settings).
-- client_secret_enc: secrets.Box.Seal-Output (AES-256-GCM), NULL = nicht gesetzt.
-- Rolle kommt bewusst NICHT aus dem Token, daher keine group/role-claim-Spalten.
CREATE TABLE IF NOT EXISTS oidc_settings (
id INTEGER PRIMARY KEY DEFAULT 1,
enabled BOOLEAN NOT NULL DEFAULT false,
issuer_url TEXT NOT NULL DEFAULT '',
client_id TEXT NOT NULL DEFAULT '',
client_secret_enc BYTEA,
scopes TEXT NOT NULL DEFAULT 'openid email profile',
email_claim TEXT NOT NULL DEFAULT 'email',
button_label TEXT NOT NULL DEFAULT 'Sign in with SSO',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT oidc_settings_singleton CHECK (id = 1)
);
INSERT INTO oidc_settings (id) VALUES (1) ON CONFLICT DO NOTHING;
-- Opportunistisches Linking: beim ersten SSO-Login wird der OIDC-'sub'
-- gespeichert; weicht er später ab, wird der Login abgelehnt. Nullable,
-- kein Backfill (Match-Schlüssel bleibt die verifizierte E-Mail).
ALTER TABLE users ADD COLUMN IF NOT EXISTS oidc_subject TEXT;
-- +goose StatementEnd
-- +goose Down
-- +goose StatementBegin
ALTER TABLE users DROP COLUMN IF EXISTS oidc_subject;
DROP TABLE IF EXISTS oidc_settings;
-- +goose StatementEnd

View File

@@ -0,0 +1,62 @@
-- +goose Up
-- +goose StatementBegin
-- DHCP (Kea) — globale Singleton-Settings (node-lokal, wie dns_settings/
-- ntp_settings: ob DIESE Node DHCP betreibt). Subnets/Reservierungen sind
-- geteilte Config und werden repliziert.
CREATE TABLE IF NOT EXISTS dhcp_settings (
id INTEGER PRIMARY KEY DEFAULT 1,
enabled BOOLEAN NOT NULL DEFAULT false,
default_lease INTEGER NOT NULL DEFAULT 3600,
max_lease INTEGER NOT NULL DEFAULT 7200,
domain_name TEXT NOT NULL DEFAULT '',
dns_servers TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT dhcp_settings_singleton CHECK (id = 1)
);
INSERT INTO dhcp_settings (id) VALUES (1) ON CONFLICT DO NOTHING;
-- Subnets: an ein Interface per NAME gebunden (nicht per node-lokaler FK,
-- damit die Replikation nicht an divergierenden interface_id bricht).
CREATE TABLE IF NOT EXISTS dhcp_subnets (
id BIGSERIAL PRIMARY KEY,
name TEXT NOT NULL,
interface_name TEXT NOT NULL,
subnet_cidr TEXT NOT NULL,
pool_start TEXT NOT NULL DEFAULT '',
pool_end TEXT NOT NULL DEFAULT '',
gateway TEXT NOT NULL DEFAULT '',
dns_servers TEXT NOT NULL DEFAULT '',
lease_time INTEGER,
active BOOLEAN NOT NULL DEFAULT true,
description TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT dhcp_subnets_name_unique UNIQUE (name)
);
CREATE TABLE IF NOT EXISTS dhcp_reservations (
id BIGSERIAL PRIMARY KEY,
subnet_id BIGINT NOT NULL REFERENCES dhcp_subnets(id) ON DELETE CASCADE,
name TEXT NOT NULL DEFAULT '',
mac_address TEXT NOT NULL,
ip_address TEXT NOT NULL,
hostname TEXT NOT NULL DEFAULT '',
active BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT dhcp_reservations_subnet_mac_unique UNIQUE (subnet_id, mac_address)
);
CREATE INDEX IF NOT EXISTS idx_dhcp_reservations_subnet ON dhcp_reservations(subnet_id);
-- +goose StatementEnd
-- +goose Down
-- +goose StatementBegin
DROP TABLE IF EXISTS dhcp_reservations;
DROP TABLE IF EXISTS dhcp_subnets;
DROP TABLE IF EXISTS dhcp_settings;
-- +goose StatementEnd

View File

@@ -0,0 +1,51 @@
-- +goose Up
-- +goose StatementBegin
-- RADIUS (FreeRADIUS) — node-lokale Singleton-Settings (ob DIESE Node
-- RADIUS betreibt + Listen). Clients/Users sind geteilte Config (repliziert).
CREATE TABLE IF NOT EXISTS radius_settings (
id INTEGER PRIMARY KEY DEFAULT 1,
enabled BOOLEAN NOT NULL DEFAULT false,
listen_addresses TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT radius_settings_singleton CHECK (id = 1)
);
INSERT INTO radius_settings (id) VALUES (1) ON CONFLICT DO NOTHING;
-- NAS-Clients (Geräte, die RADIUS-Requests senden): IP/CIDR + Shared Secret
-- (verschlüsselt via secrets.Box).
CREATE TABLE IF NOT EXISTS radius_clients (
id BIGSERIAL PRIMARY KEY,
name TEXT NOT NULL,
ipaddr TEXT NOT NULL,
secret_enc BYTEA,
active BOOLEAN NOT NULL DEFAULT true,
description TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT radius_clients_name_unique UNIQUE (name)
);
-- Benutzer (PAP/CHAP): Name + Passwort (verschlüsselt; Cleartext nur zur
-- Render-Zeit in die freeradius-lesbare authorize-Datei).
CREATE TABLE IF NOT EXISTS radius_users (
id BIGSERIAL PRIMARY KEY,
username TEXT NOT NULL,
password_enc BYTEA,
active BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT radius_users_username_unique UNIQUE (username)
);
-- +goose StatementEnd
-- +goose Down
-- +goose StatementBegin
DROP TABLE IF EXISTS radius_users;
DROP TABLE IF EXISTS radius_clients;
DROP TABLE IF EXISTS radius_settings;
-- +goose StatementEnd

View File

@@ -22,6 +22,7 @@ import (
"context"
_ "embed"
"fmt"
"log/slog"
"net"
"os/exec"
"path/filepath"
@@ -138,6 +139,7 @@ type View struct {
type WGSiteMasqEntry struct {
Iface string // wg interface name, e.g. "wg7"
VPNNet string // network CIDR of the VPN subnet, e.g. "192.168.99.0/24"
L3 string // "ip" oder "ip6" — Familie von VPNNet
}
// AutoFWRule is one auto-emitted inbound rule. Proto is "tcp" or
@@ -148,6 +150,8 @@ type AutoFWRule struct {
Proto string
Port int
DstIP string
L3 string // "ip"/"ip6" — gesetzt für DstIP-Rules (Familie); leer = agnostisch
Iface string // optional: scope auf ein iifname (z.B. DHCP udp/67 nur auf LAN)
Comment string
}
@@ -162,7 +166,11 @@ type RuleLeg struct {
DstIfaces []string
SrcAddrs []string
DstAddrs []string
Service ResolvedService // Proto="" → no service match (any)
// L3 ist "ip" (IPv4) oder "ip6" (IPv6) für das Adress-Matching —
// gesetzt, sobald SrcAddrs/DstAddrs nicht leer sind. Bei adresslosen
// Regeln bleibt es "" (familienagnostisch, kein ip/ip6-Match).
L3 string
Service ResolvedService // Proto="" → no service match (any)
}
// ResolvedRule has all addresses + services already expanded so the
@@ -195,6 +203,12 @@ type ResolvedNATRule struct {
DPortStart, DPortEnd int
TargetAddr string
TargetPortStart, TargetPortEnd int
// L3 ist "ip" oder "ip6" — Adressfamilie der Regel (aus SrcCIDR/
// DstCIDR/TargetAddr abgeleitet). TargetHost ist TargetAddr, bei
// IPv6 MIT Port in eckigen Klammern ("[2001:db8::1]") für korrekte
// nft-dnat-Syntax.
L3 string
TargetHost string
Comment string
}
@@ -280,26 +294,15 @@ func (g *Generator) loadView(ctx context.Context) (*View, error) {
if err != nil {
return nil, err
}
// Expand to one Leg per (rule × service); rules without a service
// produce one leg with empty Proto.
// Expand to one Leg per (rule × service × address-family). Rules
// without a service produce one leg-set with empty Proto.
for _, r := range rules {
if len(r.Services) == 0 {
view.Legs = append(view.Legs, RuleLeg{
RuleID: r.ID, Action: r.Action, Log: r.Log, Name: r.Name,
Comment: r.Comment,
SrcIfaces: r.SrcIfaces, DstIfaces: r.DstIfaces,
SrcAddrs: r.SrcAddrs, DstAddrs: r.DstAddrs,
})
view.Legs = append(view.Legs, expandFamilyLegs(r, ResolvedService{}, false)...)
continue
}
for _, svc := range r.Services {
view.Legs = append(view.Legs, RuleLeg{
RuleID: r.ID, Action: r.Action, Log: r.Log, Name: r.Name,
Comment: r.Comment,
SrcIfaces: r.SrcIfaces, DstIfaces: r.DstIfaces,
SrcAddrs: r.SrcAddrs, DstAddrs: r.DstAddrs,
Service: svc,
})
view.Legs = append(view.Legs, expandFamilyLegs(r, svc, true)...)
}
}
@@ -323,9 +326,14 @@ func (g *Generator) loadView(ctx context.Context) (*View, error) {
if wgRows.Scan(&name, &cidr) == nil {
view.WGServerIfaces = append(view.WGServerIfaces, name)
if _, ipNet, err := net.ParseCIDR(cidr); err == nil {
l3 := addrFamily(ipNet.String())
if l3 == "" {
l3 = "ip"
}
view.WGSiteMasq = append(view.WGSiteMasq, WGSiteMasqEntry{
Iface: name,
VPNNet: ipNet.String(),
L3: l3,
})
}
}
@@ -411,7 +419,60 @@ func (g *Generator) loadAutoRules(ctx context.Context) []AutoFWRule {
}
}
return out
// DHCP (Kea): wenn auf DIESER Node aktiviert → udp/67 pro aktivem
// Subnet-Interface (gescopt auf die LAN-iface, NICHT global/WAN).
var dhcpEnabled bool
if err := g.Pool.QueryRow(ctx, `SELECT enabled FROM dhcp_settings WHERE id=1`).Scan(&dhcpEnabled); err == nil && dhcpEnabled {
rows, err := g.Pool.Query(ctx, `SELECT DISTINCT interface_name FROM dhcp_subnets WHERE active AND interface_name <> ''`)
if err == nil {
defer rows.Close()
for rows.Next() {
var iface string
if rows.Scan(&iface) == nil && iface != "" {
out = append(out, AutoFWRule{Proto: "udp", Port: 67, Iface: iface, Comment: "DHCP (Kea) auf " + iface})
}
}
}
}
// RADIUS (FreeRADIUS): wenn aktiviert → udp 1812 (auth) + 1813 (acct).
// Pro listen-IP, sonst global. FreeRADIUS verwirft unbekannte Clients selbst.
var radiusEnabled bool
var radiusListen string
if err := g.Pool.QueryRow(ctx, `SELECT enabled, listen_addresses FROM radius_settings WHERE id=1`).Scan(&radiusEnabled, &radiusListen); err == nil && radiusEnabled {
ips := splitCSV(radiusListen)
emit := func(ip string) {
out = append(out,
AutoFWRule{Proto: "udp", Port: 1812, DstIP: ip, Comment: "RADIUS-Auth (FreeRADIUS)"},
AutoFWRule{Proto: "udp", Port: 1813, DstIP: ip, Comment: "RADIUS-Acct (FreeRADIUS)"},
)
}
if len(ips) == 0 {
emit("")
} else {
for _, ip := range ips {
if !isLoopback(ip) && ip != "0.0.0.0" && ip != "::" {
emit(ip)
}
}
}
}
// Familien-Tag (ip/ip6) für DstIP-basierte Auto-Rules setzen; eine
// IPv6-Listen-Adresse muss `ip6 daddr` ergeben (sonst lehnt nft das
// gesamte Ruleset ab). Unparsebare DstIPs werden verworfen.
tagged := out[:0]
for _, r := range out {
if r.DstIP != "" {
fam := addrFamily(r.DstIP)
if fam == "" {
continue
}
r.L3 = fam
}
tagged = append(tagged, r)
}
return tagged
}
// splitCSV — wie in den Service-renderern.
@@ -435,6 +496,131 @@ func isLoopback(ip string) bool {
return strings.HasPrefix(ip, "127.")
}
// addrFamily klassifiziert einen nft-Adressausdruck (host, CIDR oder
// range "a-b") als "ip" (IPv4), "ip6" (IPv6) oder "" (unbestimmt, z.B.
// FQDN-Platzhalter). Adressen enthalten selbst kein '-', daher trennt der
// erste Bindestrich sicher eine Range in ihr erstes Element.
func addrFamily(expr string) string {
expr = strings.TrimSpace(expr)
if expr == "" {
return ""
}
if i := strings.IndexByte(expr, '-'); i > 0 {
expr = strings.TrimSpace(expr[:i])
}
if i := strings.IndexByte(expr, '/'); i > 0 {
expr = expr[:i]
}
ip := net.ParseIP(expr)
if ip == nil {
return ""
}
if ip.To4() != nil {
return "ip"
}
return "ip6"
}
// splitByFamily teilt eine Liste von nft-Adressausdrücken in v4 und v6.
// Unbestimmte (FQDN o.ä.) werden verworfen.
func splitByFamily(exprs []string) (v4, v6 []string) {
for _, e := range exprs {
switch addrFamily(e) {
case "ip":
v4 = append(v4, e)
case "ip6":
v6 = append(v6, e)
}
}
return v4, v6
}
// serviceL3: icmp ist v4-only, icmpv6 v6-only, tcp/udp/leer agnostisch.
func serviceL3(svc ResolvedService) string {
switch svc.Proto {
case "icmp":
return "ip"
case "icmpv6":
return "ip6"
default:
return ""
}
}
// natFamily ermittelt die Adressfamilie einer NAT-Regel aus ihren
// Adressen. ok=false bei gemischten v4/v6-Adressen (ungültig → die Regel
// muss übersprungen werden, sonst bricht `nft -f` das gesamte Ruleset).
func natFamily(r ResolvedNATRule) (fam string, ok bool) {
for _, a := range []string{r.SrcCIDR, r.DstCIDR, r.TargetAddr} {
f := addrFamily(a)
if f == "" {
continue
}
if fam == "" {
fam = f
} else if fam != f {
return "", false
}
}
if fam == "" {
fam = "ip" // keine Adressen (reine iface/proto-Regel) → v4-Default
}
return fam, true
}
// expandFamilyLegs materialisiert die nft-Zeilen für eine Regel + optional
// einen Service, getrennt nach Adressfamilie. Adresslose Regeln ergeben eine
// einzige familienagnostische Zeile (unverändertes v4-Verhalten, greift
// zugleich für v6). Regeln mit Adressen werden pro Familie als separate
// Zeile emittiert — ein nft-Paket ist immer entweder v4 oder v6.
func expandFamilyLegs(r ResolvedRule, svc ResolvedService, hasSvc bool) []RuleLeg {
base := RuleLeg{
RuleID: r.ID, Action: r.Action, Log: r.Log, Name: r.Name, Comment: r.Comment,
SrcIfaces: r.SrcIfaces, DstIfaces: r.DstIfaces,
}
if hasSvc {
base.Service = svc
}
if len(r.SrcAddrs) == 0 && len(r.DstAddrs) == 0 {
// Kein Adress-Match → eine Zeile, L3 leer. Die Proto-Render-Logik
// im Template setzt icmp/icmpv6 selbst familienkorrekt.
return []RuleLeg{base}
}
src4, src6 := splitByFamily(r.SrcAddrs)
dst4, dst6 := splitByFamily(r.DstAddrs)
svcFam := ""
if hasSvc {
svcFam = serviceL3(svc)
}
var legs []RuleLeg
for _, fam := range []string{"ip", "ip6"} {
if svcFam != "" && svcFam != fam {
continue // icmp nur auf v4, icmpv6 nur auf v6
}
srcF, dstF := src4, dst4
if fam == "ip6" {
srcF, dstF = src6, dst6
}
// Eine eingeschränkte Seite ohne Mitglied dieser Familie → die
// Zeile würde nichts (oder Falsches) matchen → überspringen.
if len(r.SrcAddrs) > 0 && len(srcF) == 0 {
continue
}
if len(r.DstAddrs) > 0 && len(dstF) == 0 {
continue
}
leg := base
leg.L3 = fam
leg.SrcAddrs = srcF
leg.DstAddrs = dstF
legs = append(legs, leg)
}
return legs
}
// addrObjMap is keyed by id; value is the nft expression for that
// object (e.g. "1.2.3.4", "10.0.0.0/24", "1.2.3.4-1.2.3.10").
type addrObjMap map[int64]string
@@ -693,6 +879,19 @@ ORDER BY priority DESC, id ASC`)
if outZone != nil {
r.OutIfaces = zoneIfaces[*outZone]
}
fam, ok := natFamily(r)
if !ok {
// Gemischte v4/v6-Adressen → ungültige NAT-Regel. Überspringen
// statt das gesamte Ruleset mit `nft -f` zu brechen.
slog.Warn("firewall: NAT-Regel mit gemischten v4/v6-Adressen übersprungen", "id", r.ID)
continue
}
r.L3 = fam
r.TargetHost = r.TargetAddr
if fam == "ip6" && r.TargetAddr != "" && r.TargetPortStart > 0 {
// nft braucht [v6]:port für dnat-Targets mit Port.
r.TargetHost = "[" + r.TargetAddr + "]"
}
out = append(out, r)
}
return out, rows.Err()

View File

@@ -0,0 +1,64 @@
package firewall
import (
"bytes"
"os"
"os/exec"
"strings"
"testing"
)
// TestTemplate_autoRuleIface prüft, dass eine Auto-Rule mit Iface als
// `iifname "<x>"`-gescopte Zeile rendert (DHCP udp/67 auf LAN) und dass
// DstIP-basierte Auto-Rules unverändert bleiben.
func TestTemplate_autoRuleIface(t *testing.T) {
view := &View{
AutoRules: []AutoFWRule{
{Proto: "udp", Port: 67, Iface: "eth1", Comment: "DHCP (Kea) auf eth1"},
{Proto: "udp", Port: 53, DstIP: "10.0.0.1", L3: "ip", Comment: "DNS"},
{Proto: "udp", Port: 53, DstIP: "2001:db8::1", L3: "ip6", Comment: "DNS v6"},
},
}
var buf bytes.Buffer
if err := tpl.Execute(&buf, view); err != nil {
t.Fatalf("template execute: %v", err)
}
out := buf.String()
if !strings.Contains(out, `iifname "eth1" udp dport 67 accept comment "auto: DHCP (Kea) auf eth1"`) {
t.Errorf("missing iface-scoped DHCP auto-rule\n----\n%s", out)
}
// v4-DstIP-Auto-Rule: ip daddr.
if !strings.Contains(out, `ip daddr 10.0.0.1 udp dport 53 accept`) {
t.Errorf("v4 DstIP auto-rule wrong\n----\n%s", out)
}
// Fix #5: v6-DstIP muss `ip6 daddr` ergeben (sonst bricht nft das Ruleset).
if !strings.Contains(out, `ip6 daddr 2001:db8::1 udp dport 53 accept`) {
t.Errorf("v6 DstIP auto-rule must use ip6 daddr\n----\n%s", out)
}
// Echte nft-Syntaxvalidierung (braucht root → via sudo, sonst skip).
nft, err := exec.LookPath("nft")
if err != nil {
t.Skip("nft not in PATH")
}
f, err := os.CreateTemp(t.TempDir(), "autorule-*.nft")
if err != nil {
t.Fatal(err)
}
_, _ = f.WriteString(out)
f.Close()
var cmd *exec.Cmd
if os.Geteuid() == 0 {
cmd = exec.Command(nft, "-c", "-f", f.Name())
} else {
cmd = exec.Command("sudo", "-n", nft, "-c", "-f", f.Name())
}
if combined, err := cmd.CombinedOutput(); err != nil {
msg := string(combined)
if strings.Contains(msg, "Operation not permitted") || strings.Contains(msg, "password is required") {
t.Skipf("nft -c needs root: %s", strings.TrimSpace(msg))
}
t.Fatalf("nft -c rejected ruleset: %v\n%s\n----\n%s", err, combined, out)
}
}

View File

@@ -0,0 +1,142 @@
package firewall
import (
"context"
"os"
"os/exec"
"strings"
"testing"
"time"
"git.netcell-it.de/projekte/edgeguard-native/internal/database"
)
// TestE2E_IPv6Render fährt den ECHTEN Generator gegen eine Test-DB:
// alle Migrations + v4/v6-Seed + RenderToString + nft -c. Nur aktiv, wenn
// EG_FWTEST_DSN gesetzt ist (sonst Skip — `go test ./...` bleibt DB-frei).
func TestE2E_IPv6Render(t *testing.T) {
dsn := os.Getenv("EG_FWTEST_DSN")
if dsn == "" {
t.Skip("set EG_FWTEST_DSN to run the firewall end-to-end test")
}
ctx := context.Background()
// Retry: goose-Erst-Apply ist nicht concurrency-safe, wenn mehrere
// guarded Test-Pakete dieselbe frische DB parallel migrieren.
var mErr error
for i := 0; i < 3; i++ {
if mErr = database.Migrate(ctx, dsn); mErr == nil {
break
}
time.Sleep(700 * time.Millisecond)
}
if mErr != nil {
t.Fatalf("migrate: %v", mErr)
}
pool, err := database.Open(ctx, dsn)
if err != nil {
t.Fatalf("open: %v", err)
}
defer pool.Close()
for _, tbl := range []string{
"firewall_nat_rules", "firewall_rules",
"firewall_address_group_members", "firewall_address_groups",
"firewall_address_objects", "network_interfaces",
} {
if _, err := pool.Exec(ctx, "DELETE FROM "+tbl); err != nil {
t.Fatalf("clean %s: %v", tbl, err)
}
}
mustExec := func(sql string, args ...any) {
t.Helper()
if _, err := pool.Exec(ctx, sql, args...); err != nil {
t.Fatalf("seed failed (%s): %v", sql, err)
}
}
insID := func(sql string, args ...any) int64 {
t.Helper()
var id int64
if err := pool.QueryRow(ctx, sql, args...).Scan(&id); err != nil {
t.Fatalf("seed-id failed (%s): %v", sql, err)
}
return id
}
mustExec(`INSERT INTO network_interfaces (name,type,role) VALUES ('eth0','ethernet','wan'),('eth1','ethernet','lan')`)
v4net := insID(`INSERT INTO firewall_address_objects (name,kind,value) VALUES ('v4net','network','10.0.0.0/24') RETURNING id`)
v6net := insID(`INSERT INTO firewall_address_objects (name,kind,value) VALUES ('v6net','network','2001:db8:1::/64') RETURNING id`)
v6host := insID(`INSERT INTO firewall_address_objects (name,kind,value) VALUES ('v6host','host','2001:db8:2::5') RETURNING id`)
v6range := insID(`INSERT INTO firewall_address_objects (name,kind,value) VALUES ('v6range','range','2001:db8:3::1-2001:db8:3::9') RETURNING id`)
// Gemischte Gruppe (v4 + v6) → muss in zwei Familien-Zeilen splitten.
grp := insID(`INSERT INTO firewall_address_groups (name) VALUES ('mixed') RETURNING id`)
mustExec(`INSERT INTO firewall_address_group_members (group_id,object_id) VALUES ($1,$2),($1,$3)`, grp, v4net, v6net)
httpsSvc := insID(`INSERT INTO firewall_services (name,proto,port_start,port_end,builtin,description) VALUES ('t-https','tcp',443,443,false,'')
ON CONFLICT (name) DO UPDATE SET proto=excluded.proto RETURNING id`)
var pingV6 int64
_ = pool.QueryRow(ctx, `SELECT id FROM firewall_services WHERE proto='icmpv6' LIMIT 1`).Scan(&pingV6)
// (1) gemischte Gruppe + tcp443 → je eine ip- und ip6-Zeile.
mustExec(`INSERT INTO firewall_rules (name,action,src_zone,src_address_group_id,service_object_id) VALUES ('mixed-https','accept','any',$1,$2)`, grp, httpsSvc)
// (2) v6-host + icmpv6 → eine ip6-Zeile.
if pingV6 != 0 {
mustExec(`INSERT INTO firewall_rules (name,action,src_address_object_id,service_object_id) VALUES ('v6-ping','accept',$1,$2)`, v6host, pingV6)
}
// (3) v6-range src + v6net dst (kein Service).
mustExec(`INSERT INTO firewall_rules (name,action,src_address_object_id,dst_address_object_id) VALUES ('v6-range','drop',$1,$2)`, v6range, v6net)
// (a) v6-DNAT mit Port → dnat to [..]:port.
mustExec(`INSERT INTO firewall_nat_rules (name,kind,proto,match_dst_cidr,match_dport_start,target_addr,target_port_start) VALUES ('v6-dnat','dnat','tcp','2001:db8:9::/64',80,'2001:db8:9::2',8080)`)
// (b) v4-DNAT (Regression).
mustExec(`INSERT INTO firewall_nat_rules (name,kind,proto,match_dst_cidr,match_dport_start,target_addr,target_port_start) VALUES ('v4-dnat','dnat','tcp','1.2.3.4',80,'10.0.0.5',80)`)
// (c) gemischte Familie (v4 src, v6 target) → MUSS übersprungen werden.
mustExec(`INSERT INTO firewall_nat_rules (name,kind,proto,match_src_cidr,target_addr) VALUES ('mixed-snat','snat','any','10.0.0.0/24','2001:db8::99')`)
out, err := New(pool).RenderToString(ctx)
if err != nil {
t.Fatalf("render: %v", err)
}
for _, w := range []string{
"ip saddr { 10.0.0.0/24 }",
"ip6 saddr { 2001:db8:1::/64 }",
"ip6 nexthdr icmpv6",
"ip6 saddr { 2001:db8:3::1-2001:db8:3::9 }",
"dnat to [2001:db8:9::2]:8080",
"dnat to 10.0.0.5:80",
} {
if !strings.Contains(out, w) {
t.Errorf("rendered output missing %q\n----\n%s", w, out)
}
}
if strings.Contains(out, "2001:db8::99") {
t.Errorf("mixed-family NAT rule was not skipped\n----\n%s", out)
}
nft, err := exec.LookPath("nft")
if err != nil {
t.Skip("nft not in PATH — skipping syntax check")
}
f, err := os.CreateTemp(t.TempDir(), "e2e-*.nft")
if err != nil {
t.Fatal(err)
}
_, _ = f.WriteString(out)
f.Close()
var cmd *exec.Cmd
if os.Geteuid() == 0 {
cmd = exec.Command(nft, "-c", "-f", f.Name())
} else {
cmd = exec.Command("sudo", "-n", nft, "-c", "-f", f.Name())
}
if combined, err := cmd.CombinedOutput(); err != nil {
msg := string(combined)
if strings.Contains(msg, "Operation not permitted") || strings.Contains(msg, "password is required") {
t.Skipf("nft -c needs root (no usable sudo): %s", strings.TrimSpace(msg))
}
t.Fatalf("nft -c rejected the real-rendered ruleset: %v\n%s\n----\n%s", err, combined, out)
}
}

View File

@@ -0,0 +1,183 @@
package firewall
import (
"bytes"
"os"
"os/exec"
"strings"
"testing"
)
func TestAddrFamily(t *testing.T) {
cases := map[string]string{
"1.2.3.4": "ip",
"10.0.0.0/24": "ip",
"1.2.3.4-1.2.3.10": "ip",
"2001:db8::1": "ip6",
"fd00::/64": "ip6",
"2001:db8::1-2001:db8::5": "ip6",
"example.com": "",
"": "",
}
for in, want := range cases {
if got := addrFamily(in); got != want {
t.Errorf("addrFamily(%q)=%q want %q", in, got, want)
}
}
}
func TestExpandFamilyLegs_splitsByFamily(t *testing.T) {
r := ResolvedRule{
ID: 1, Action: "accept",
SrcAddrs: []string{"10.0.0.0/24", "fd00::/64"},
DstAddrs: []string{"1.2.3.4", "2001:db8::1"},
}
legs := expandFamilyLegs(r, ResolvedService{}, false)
if len(legs) != 2 {
t.Fatalf("want 2 legs (v4+v6), got %d", len(legs))
}
var v4, v6 *RuleLeg
for i := range legs {
switch legs[i].L3 {
case "ip":
v4 = &legs[i]
case "ip6":
v6 = &legs[i]
}
}
if v4 == nil || v6 == nil {
t.Fatalf("missing family leg: %+v", legs)
}
if len(v4.SrcAddrs) != 1 || v4.SrcAddrs[0] != "10.0.0.0/24" || v4.DstAddrs[0] != "1.2.3.4" {
t.Errorf("v4 leg wrong: src=%v dst=%v", v4.SrcAddrs, v4.DstAddrs)
}
if len(v6.SrcAddrs) != 1 || v6.SrcAddrs[0] != "fd00::/64" || v6.DstAddrs[0] != "2001:db8::1" {
t.Errorf("v6 leg wrong: src=%v dst=%v", v6.SrcAddrs, v6.DstAddrs)
}
}
func TestExpandFamilyLegs_addresslessIsAgnostic(t *testing.T) {
legs := expandFamilyLegs(ResolvedRule{ID: 2, Action: "accept"}, ResolvedService{}, false)
if len(legs) != 1 || legs[0].L3 != "" {
t.Fatalf("addressless rule must be a single agnostic leg, got %d legs L3=%q", len(legs), legs[0].L3)
}
}
func TestExpandFamilyLegs_oneFamilyOnly(t *testing.T) {
// src nur v4, dst nur v4 → genau eine v4-Zeile (kein leerer v6-Leg).
r := ResolvedRule{ID: 3, Action: "drop", SrcAddrs: []string{"10.0.0.0/8"}}
legs := expandFamilyLegs(r, ResolvedService{}, false)
if len(legs) != 1 || legs[0].L3 != "ip" {
t.Fatalf("v4-only rule want 1 ip leg, got %+v", legs)
}
}
func TestExpandFamilyLegs_icmpFamilyMatch(t *testing.T) {
r6 := ResolvedRule{ID: 4, Action: "accept", SrcAddrs: []string{"fd00::/64"}}
if legs := expandFamilyLegs(r6, ResolvedService{Proto: "icmpv6"}, true); len(legs) != 1 || legs[0].L3 != "ip6" {
t.Fatalf("icmpv6+v6 want 1 ip6 leg, got %+v", legs)
}
if legs := expandFamilyLegs(r6, ResolvedService{Proto: "icmp"}, true); len(legs) != 0 {
t.Fatalf("icmp on v6-only addrs want 0 legs, got %+v", legs)
}
}
func TestNatFamily(t *testing.T) {
if _, ok := natFamily(ResolvedNATRule{SrcCIDR: "10.0.0.0/24", TargetAddr: "2001:db8::1"}); ok {
t.Error("mixed v4/v6 NAT must be rejected (ok=false)")
}
if fam, ok := natFamily(ResolvedNATRule{TargetAddr: "2001:db8::1"}); !ok || fam != "ip6" {
t.Errorf("v6 NAT: fam=%q ok=%v want ip6/true", fam, ok)
}
if fam, ok := natFamily(ResolvedNATRule{SrcCIDR: "10.0.0.0/24"}); !ok || fam != "ip" {
t.Errorf("v4 NAT: fam=%q ok=%v want ip/true", fam, ok)
}
if fam, ok := natFamily(ResolvedNATRule{}); !ok || fam != "ip" {
t.Errorf("addressless NAT: fam=%q ok=%v want ip/true (v4 default)", fam, ok)
}
}
// renderView ist ein gemischter v4/v6-View, der alle geänderten
// Template-Zweige berührt.
func renderView(t *testing.T) string {
t.Helper()
view := &View{
PeerIPv4: []string{"10.0.0.1"},
PeerIPv6: []string{"fd00::1"},
Legs: []RuleLeg{
{RuleID: 1, Action: "accept", L3: "ip", SrcAddrs: []string{"10.0.0.0/24"}, Service: ResolvedService{Proto: "tcp", PortStart: 443}},
{RuleID: 1, Action: "accept", L3: "ip6", SrcAddrs: []string{"fd00::/64"}, Service: ResolvedService{Proto: "tcp", PortStart: 443}},
{RuleID: 2, Action: "accept", Service: ResolvedService{Proto: "icmpv6"}}, // adresslos, agnostisch
},
NATRules: []ResolvedNATRule{
{ID: 5, Kind: "dnat", L3: "ip6", DstCIDR: "2001:db8::/64", Proto: "tcp", DPortStart: 80, TargetAddr: "fd00::2", TargetHost: "[fd00::2]", TargetPortStart: 8080},
{ID: 6, Kind: "snat", L3: "ip6", SrcCIDR: "fd00::/64", TargetAddr: "2001:db8::99"},
{ID: 7, Kind: "dnat", L3: "ip", DstCIDR: "1.2.3.4", Proto: "tcp", DPortStart: 80, TargetAddr: "10.0.0.5", TargetHost: "10.0.0.5", TargetPortStart: 80},
},
WGSiteMasq: []WGSiteMasqEntry{{Iface: "wg7", VPNNet: "fd00:99::/64", L3: "ip6"}},
}
var buf bytes.Buffer
if err := tpl.Execute(&buf, view); err != nil {
t.Fatalf("template execute: %v", err)
}
return buf.String()
}
func TestTemplate_v6AndV4Render(t *testing.T) {
out := renderView(t)
mustContain := []string{
"ip saddr { 10.0.0.0/24 }", // v4-Regel unverändert
"ip6 saddr { fd00::/64 }", // v6-Regel
"ip6 daddr 2001:db8::/64", // v6-DNAT-Match
"dnat to [fd00::2]:8080", // v6-DNAT-Target geklammert
"dnat to 10.0.0.5:80", // v4-DNAT-Target unverändert
"ip6 saddr fd00::/64 snat to 2001:db8::99",
`oifname "wg7" ip6 saddr fd00:99::/64 masquerade`,
}
for _, w := range mustContain {
if !strings.Contains(out, w) {
t.Errorf("output missing %q\n----\n%s", w, out)
}
}
// v6-Adressen dürfen NIEMALS in einem ip-saddr/daddr-Set landen.
if strings.Contains(out, "ip saddr { fd00") || strings.Contains(out, "ip daddr { fd00") ||
strings.Contains(out, "ip saddr { 2001") {
t.Errorf("v6 address leaked into IPv4 match\n----\n%s", out)
}
}
// TestTemplate_nftSyntax validiert das gerenderte Ruleset mit `nft -c -f`
// (Check-Modus, kein Apply). Wird übersprungen, wenn nft nicht installiert
// ist (z.B. CI ohne nft).
func TestTemplate_nftSyntax(t *testing.T) {
nft, err := exec.LookPath("nft")
if err != nil {
t.Skip("nft binary not available — skipping syntax check")
}
out := renderView(t)
f, err := os.CreateTemp(t.TempDir(), "ruleset-*.nft")
if err != nil {
t.Fatal(err)
}
if _, err := f.WriteString(out); err != nil {
t.Fatal(err)
}
f.Close()
// `nft -c` liest die Kernel-Ruleset-Cache via netlink → braucht root.
// Als nicht-root via sudo -n versuchen; klappt das nicht, skip statt fail
// (auf den Nodes rendert/prüft edgeguard ohnehin als root).
var cmd *exec.Cmd
if os.Geteuid() == 0 {
cmd = exec.Command(nft, "-c", "-f", f.Name())
} else {
cmd = exec.Command("sudo", "-n", nft, "-c", "-f", f.Name())
}
combined, err := cmd.CombinedOutput()
if err != nil {
msg := string(combined)
if strings.Contains(msg, "Operation not permitted") || strings.Contains(msg, "a password is required") || strings.Contains(msg, "may not run sudo") {
t.Skipf("nft -c needs root (no usable sudo): %s", strings.TrimSpace(msg))
}
t.Fatalf("nft -c -f rejected the generated ruleset: %v\n%s\n----\n%s", err, combined, out)
}
}

View File

@@ -61,7 +61,7 @@ table inet edgeguard {
# editiert diese nicht. Wenn der Service entfernt/disabled
# wird, ist die Rule beim nächsten Render weg.
{{range .AutoRules}}
{{if .DstIP}}ip daddr {{.DstIP}} {{end}}{{.Proto}} dport {{.Port}} accept comment "auto: {{.Comment}}"
{{if .Iface}}iifname "{{.Iface}}" {{end}}{{if .DstIP}}{{.L3}} daddr {{.DstIP}} {{end}}{{.Proto}} dport {{.Port}} accept comment "auto: {{.Comment}}"
{{end}}
# ── Operator-defined rules ──
@@ -71,7 +71,7 @@ table inet edgeguard {
die Comment-Zeile angehängt — sonst frisst nft die rule
als Teil des # Kommentars). */ -}}
{{""}}
{{if .SrcIfaces}}iifname { {{join .SrcIfaces ", "}} } {{end}}{{if .DstIfaces}}oifname { {{join .DstIfaces ", "}} } {{end}}{{if .SrcAddrs}}ip saddr { {{join .SrcAddrs ", "}} } {{end}}{{if .DstAddrs}}ip daddr { {{join .DstAddrs ", "}} } {{end}}{{with .Service}}{{if and (or (eq .Proto "tcp") (eq .Proto "udp")) .PortStart}}{{.Proto}} dport {{.PortStart}}{{if and .PortEnd (ne .PortEnd .PortStart)}}-{{.PortEnd}}{{end}} {{else if eq .Proto "icmp"}}ip protocol icmp {{else if eq .Proto "icmpv6"}}ip6 nexthdr icmpv6 {{end}}{{end}}{{if .Log}}log prefix "edgeguard:{{.RuleID}} " group 0 {{end}}counter {{.Action}} comment "egid:{{.RuleID}}"
{{if .SrcIfaces}}iifname { {{join .SrcIfaces ", "}} } {{end}}{{if .DstIfaces}}oifname { {{join .DstIfaces ", "}} } {{end}}{{if .SrcAddrs}}{{.L3}} saddr { {{join .SrcAddrs ", "}} } {{end}}{{if .DstAddrs}}{{.L3}} daddr { {{join .DstAddrs ", "}} } {{end}}{{with .Service}}{{if and (or (eq .Proto "tcp") (eq .Proto "udp")) .PortStart}}{{.Proto}} dport {{.PortStart}}{{if and .PortEnd (ne .PortEnd .PortStart)}}-{{.PortEnd}}{{end}} {{else if eq .Proto "icmp"}}ip protocol icmp {{else if eq .Proto "icmpv6"}}ip6 nexthdr icmpv6 {{end}}{{end}}{{if .Log}}log prefix "edgeguard:{{.RuleID}} " group 0 {{end}}counter {{.Action}} comment "egid:{{.RuleID}}"
{{end}}
# ── DEFAULT-DROP LOGGING ───────────────────────────────────────
@@ -101,7 +101,7 @@ table inet edgeguard {
# nach und erlauben new-state-Pakete von dort. Return-Pakete
# gehen via ct state established schon durch.
{{range .NATRules}}{{if or (eq .Kind "snat") (eq .Kind "masquerade")}}{{if .SrcCIDR}}
ip saddr {{.SrcCIDR}} ct state new accept comment "auto-forward for NAT rule {{.ID}}"
{{.L3}} saddr {{.SrcCIDR}} ct state new accept comment "auto-forward for NAT rule {{.ID}}"
{{end}}{{end}}{{end}}
# Auto-Forward für WireGuard-Server-Interfaces: Peer-to-Peer-
@@ -128,7 +128,7 @@ table inet edgeguard {
{{""}}
{{/* nft-Syntax: erst L3-match (ip saddr/daddr), DANN L4 (tcp/udp dport).
Sonst quittiert der parser '... unexpected ip' an dieser Stelle. */}}
{{if .InIfaces}}iifname { {{join .InIfaces ", "}} } {{end}}{{if .SrcCIDR}}ip saddr {{.SrcCIDR}} {{end}}{{if .DstCIDR}}ip daddr {{.DstCIDR}} {{end}}{{if and .Proto (ne .Proto "any")}}{{.Proto}} {{else}}meta l4proto { tcp, udp } {{end}}{{if .DPortStart}}dport {{.DPortStart}}{{if and .DPortEnd (ne .DPortEnd .DPortStart)}}-{{.DPortEnd}}{{end}} {{end}}{{if .TargetAddr}}dnat to {{.TargetAddr}}{{if .TargetPortStart}}:{{.TargetPortStart}}{{if and .TargetPortEnd (ne .TargetPortEnd .TargetPortStart)}}-{{.TargetPortEnd}}{{end}}{{end}}{{end}}
{{if .InIfaces}}iifname { {{join .InIfaces ", "}} } {{end}}{{if .SrcCIDR}}{{.L3}} saddr {{.SrcCIDR}} {{end}}{{if .DstCIDR}}{{.L3}} daddr {{.DstCIDR}} {{end}}{{if and .Proto (ne .Proto "any")}}{{.Proto}} {{else}}meta l4proto { tcp, udp } {{end}}{{if .DPortStart}}dport {{.DPortStart}}{{if and .DPortEnd (ne .DPortEnd .DPortStart)}}-{{.DPortEnd}}{{end}} {{end}}{{if .TargetAddr}}dnat to {{.TargetHost}}{{if .TargetPortStart}}:{{.TargetPortStart}}{{if and .TargetPortEnd (ne .TargetPortEnd .TargetPortStart)}}-{{.TargetPortEnd}}{{end}}{{end}}{{end}}
{{end}}{{end}}
}
@@ -152,16 +152,16 @@ table inet edgeguard {
# Masquerade schreibt die Source auf die lokale Tunnel-IP um; Return-Traffic
# findet so den Weg zurück durch den Tunnel.
{{range .WGSiteMasq}}
oifname "{{.Iface}}" ip saddr {{.VPNNet}} masquerade comment "auto: WireGuard site-to-site masquerade {{.Iface}}"
oifname "{{.Iface}}" {{.L3}} saddr {{.VPNNet}} masquerade comment "auto: WireGuard site-to-site masquerade {{.Iface}}"
{{end}}
{{range .NATRules}}{{if eq .Kind "snat"}}
# NAT {{.ID}} (snat{{if .Comment}} — {{.Comment}}{{end}})
{{""}}
{{if .OutIfaces}}oifname { {{join .OutIfaces ", "}} } {{end}}{{if .SrcCIDR}}ip saddr {{.SrcCIDR}} {{end}}{{if .TargetAddr}}snat to {{.TargetAddr}}{{end}}
{{if .OutIfaces}}oifname { {{join .OutIfaces ", "}} } {{end}}{{if .SrcCIDR}}{{.L3}} saddr {{.SrcCIDR}} {{end}}{{if .TargetAddr}}snat to {{.TargetAddr}}{{end}}
{{end}}{{if eq .Kind "masquerade"}}
# NAT {{.ID}} (masquerade{{if .Comment}} — {{.Comment}}{{end}})
{{""}}
{{if .OutIfaces}}oifname { {{join .OutIfaces ", "}} } {{end}}{{if .SrcCIDR}}ip saddr {{.SrcCIDR}} {{end}}masquerade
{{if .OutIfaces}}oifname { {{join .OutIfaces ", "}} } {{end}}{{if .SrcCIDR}}{{.L3}} saddr {{.SrcCIDR}} {{end}}masquerade
{{end}}{{end}}
}
}

View File

@@ -0,0 +1,157 @@
// Package freeradius renders the FreeRADIUS client + user files from the
// radius_* tables and manages the freeradius service lifecycle.
//
// Two files are rendered (mirrors the multi-file WireGuard renderer):
// - clients.conf — NAS clients (ipaddr + shared secret)
// - authorize — users file ("name" Cleartext-Password := "pw")
// Both managed under /etc/edgeguard/freeradius/ and symlinked from the
// distro paths by postinst. Shared secrets / passwords are decrypted via
// secrets.Box at render time. Service runs ONLY when radius_settings.enabled
// is true on this node (default off).
package freeradius
import (
"bytes"
"context"
"fmt"
"strings"
"github.com/jackc/pgx/v5/pgxpool"
"git.netcell-it.de/projekte/edgeguard-native/internal/configgen"
radiussvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/radius"
"git.netcell-it.de/projekte/edgeguard-native/internal/services/secrets"
)
const (
ConfDir = configgen.EtcEdgeguard + "/freeradius"
ClientsPath = ConfDir + "/clients.conf"
AuthorizePath = ConfDir + "/authorize"
serviceName = "freeradius"
)
type Generator struct {
Pool *pgxpool.Pool
Repo *radiussvc.Repo
Box *secrets.Box
SkipReload bool
}
func New(pool *pgxpool.Pool, box *secrets.Box) *Generator {
return &Generator{Pool: pool, Repo: radiussvc.New(pool, box), Box: box}
}
func (g *Generator) Name() string { return "freeradius" }
// confEscape escaped FreeRADIUS-double-quoted-Strings (Backslash + Quote)
// und strippt Steuerzeichen (CR/LF) als Defense-in-Depth gegen Zeilen-
// Injection — die Werte werden zwar schon im Handler validiert.
func confEscape(s string) string {
s = strings.ReplaceAll(s, "\r", "")
s = strings.ReplaceAll(s, "\n", "")
s = strings.ReplaceAll(s, `\`, `\\`)
s = strings.ReplaceAll(s, `"`, `\"`)
return s
}
// buildClients rendert clients.conf. mask=true ersetzt Secrets durch *** (Preview).
func (g *Generator) buildClients(ctx context.Context, mask bool) (string, error) {
clients, err := g.Repo.ListClients(ctx)
if err != nil {
return "", err
}
var b bytes.Buffer
b.WriteString("# Generated by edgeguard-api — DO NOT EDIT.\n\n")
for _, c := range clients {
if !c.Active {
continue
}
secret := "***"
if !mask {
pt, err := g.Box.Open(c.SecretEnc)
if err != nil {
return "", fmt.Errorf("open secret for client %s: %w", c.Name, err)
}
secret = string(pt)
}
fmt.Fprintf(&b, "client %s {\n ipaddr = %s\n secret = \"%s\"\n shortname = %s\n}\n\n",
c.Name, c.IPAddr, confEscape(secret), c.Name)
}
return b.String(), nil
}
// buildAuthorize rendert die Users-Datei. mask=true ersetzt Passwörter durch ***.
func (g *Generator) buildAuthorize(ctx context.Context, mask bool) (string, error) {
users, err := g.Repo.ListUsers(ctx)
if err != nil {
return "", err
}
var b bytes.Buffer
b.WriteString("# Generated by edgeguard-api — DO NOT EDIT.\n\n")
for _, u := range users {
if !u.Active {
continue
}
pw := "***"
if !mask {
pt, err := g.Box.Open(u.PasswordEnc)
if err != nil {
return "", fmt.Errorf("open password for user %s: %w", u.Username, err)
}
pw = string(pt)
}
fmt.Fprintf(&b, "\"%s\" Cleartext-Password := \"%s\"\n", confEscape(u.Username), confEscape(pw))
}
return b.String(), nil
}
// RenderToString liefert beide Dateien (Secrets maskiert) für die Preview.
func (g *Generator) RenderToString(ctx context.Context) (string, error) {
clients, err := g.buildClients(ctx, true)
if err != nil {
return "", err
}
authorize, err := g.buildAuthorize(ctx, true)
if err != nil {
return "", err
}
return "# ── clients.conf ──\n" + clients + "\n# ── authorize ──\n" + authorize, nil
}
func (g *Generator) Render(ctx context.Context) error {
settings, err := g.Repo.GetSettings(ctx)
if err != nil {
return fmt.Errorf("get radius settings: %w", err)
}
if !settings.Enabled {
if g.SkipReload {
return nil
}
_ = configgen.DisableService(serviceName)
_ = configgen.StopService(serviceName)
return nil
}
clients, err := g.buildClients(ctx, false)
if err != nil {
return err
}
authorize, err := g.buildAuthorize(ctx, false)
if err != nil {
return err
}
if err := configgen.AtomicWrite(ClientsPath, []byte(clients), 0o640); err != nil {
return fmt.Errorf("write clients.conf: %w", err)
}
if err := configgen.AtomicWrite(AuthorizePath, []byte(authorize), 0o640); err != nil {
return fmt.Errorf("write authorize: %w", err)
}
if g.SkipReload {
return nil
}
if err := configgen.EnableService(serviceName); err != nil {
return err
}
return configgen.RestartService(serviceName)
}

View File

@@ -0,0 +1,83 @@
package freeradius
import (
"context"
"os"
"strings"
"testing"
"time"
"git.netcell-it.de/projekte/edgeguard-native/internal/database"
radiussvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/radius"
"git.netcell-it.de/projekte/edgeguard-native/internal/services/secrets"
)
// Guarded integration test: set EG_FWTEST_DSN (sonst skip).
func TestRender_ClientsAndUsers(t *testing.T) {
dsn := os.Getenv("EG_FWTEST_DSN")
if dsn == "" {
t.Skip("set EG_FWTEST_DSN to run the freeradius renderer test")
}
ctx := context.Background()
var mErr error
for i := 0; i < 3; i++ {
if mErr = database.Migrate(ctx, dsn); mErr == nil {
break
}
time.Sleep(700 * time.Millisecond)
}
if mErr != nil {
t.Fatalf("migrate: %v", mErr)
}
pool, err := database.Open(ctx, dsn)
if err != nil {
t.Fatalf("open: %v", err)
}
defer pool.Close()
for _, q := range []string{`DELETE FROM radius_clients`, `DELETE FROM radius_users`} {
if _, err := pool.Exec(ctx, q); err != nil {
t.Fatalf("clean: %v", err)
}
}
box := secrets.New(t.TempDir() + "/master_key")
repo := radiussvc.New(pool, box)
if _, err := repo.CreateClient(ctx, "testnas", "10.0.0.0/24", `s3c"ret\x`, true, "lab"); err != nil {
t.Fatalf("create client: %v", err)
}
if _, err := repo.CreateUser(ctx, "alice", "alicepw", true); err != nil {
t.Fatalf("create user: %v", err)
}
g := New(pool, box)
clients, err := g.buildClients(ctx, false)
if err != nil {
t.Fatalf("buildClients: %v", err)
}
for _, want := range []string{
"client testnas {",
"ipaddr = 10.0.0.0/24",
`secret = "s3c\"ret\\x"`, // " und \ escaped → Secret-Roundtrip + Escaping
"shortname = testnas",
} {
if !strings.Contains(clients, want) {
t.Errorf("clients.conf missing %q\n----\n%s", want, clients)
}
}
authorize, err := g.buildAuthorize(ctx, false)
if err != nil {
t.Fatalf("buildAuthorize: %v", err)
}
if !strings.Contains(authorize, `"alice" Cleartext-Password := "alicepw"`) {
t.Errorf("authorize missing alice entry\n----\n%s", authorize)
}
// Maskierte Preview enthält keine echten Secrets.
masked, _ := g.buildClients(ctx, true)
if strings.Contains(masked, "s3c") {
t.Errorf("masked preview leaked secret:\n%s", masked)
}
}

View File

@@ -110,6 +110,7 @@ func (h *AuthHandler) Login(c *gin.Context) {
actor, role := "", "admin"
remote := c.ClientIP()
var totpEnabled bool
var viaDB bool // true wenn Rolle/TOTP bereits aus der DB-Row stammen
// 1. Try DB users table first.
if h.Users != nil {
@@ -134,6 +135,7 @@ func (h *AuthHandler) Login(c *gin.Context) {
actor = ai.Email
role = ai.Role
totpEnabled = ai.TOTPEnabled
viaDB = true
h.Users.RecordLogin(c.Request.Context(), ai.ID)
}
}
@@ -168,6 +170,18 @@ func (h *AuthHandler) Login(c *gin.Context) {
return
}
// Bei Fallback (Setup-Store) / Federation (Primary) stammen role/TOTP
// NICHT aus der DB. Rolle + TOTP-Status autoritativ aus der lokalen
// (replizierten) users-Row ableiten — damit 2FA greift und die Rolle
// nie aus einer Remote-Payload kommt. Ist der User lokal (noch) nicht
// vorhanden (Replikations-Lag/DB aus), bleibt es beim Fallback-Wert.
if actor != "" && !viaDB && h.Users != nil {
if ai, err := h.Users.FindForAuth(c.Request.Context(), actor); err == nil {
role = ai.Role
totpEnabled = ai.TOTPEnabled
}
}
// TOTP gate: password OK but 2FA required → issue a short-lived pending
// cookie and tell the UI to show the TOTP input.
if totpEnabled {

View File

@@ -866,6 +866,7 @@ type registerPeerRequest struct {
MgmtIP string `json:"mgmt_ip"` // optional
Version string `json:"version"`
ConfigHash *string `json:"config_hash"` // nil=absent (don't change), ""=no user config
Role string `json:"role"` // "" → "peer" (joining peer); "primary" beim Push des Primary
}
// AgentRegisterPeer: vom Joiner nach issue-cert via mTLS aufgerufen.
@@ -904,12 +905,21 @@ func (h *ClusterHandler) AgentRegisterPeer(c *gin.Context) {
// Node, hier ist der „Self" der joining-Peer auf dieser Primary-Seite.
// Der Name passt nicht 100% semantisch, aber das SQL ist exakt das was
// wir brauchen.)
// Rolle aus dem Request (default "peer"). Ein joining-Peer sendet keine
// Rolle → "peer". Der Primary-Push sendet "primary", damit die vom
// Secondary ausgelieferte UI den Primary korrekt als primary zeigt.
// Cert-CN authentifiziert die FQDN; role ist node-lokal/Anzeige (echte
// Rollenerkennung läuft über pg_publication).
role := strings.TrimSpace(req.Role)
if role == "" {
role = "peer"
}
n := models.HANode{
ID: req.ID,
Name: req.Name,
FQDN: req.FQDN,
APIURL: req.APIURL,
Role: "peer",
Role: role,
Status: "online", // peer IS online — it just connected via mTLS
}
if req.PublicIP != "" {
@@ -965,7 +975,14 @@ func (h *ClusterHandler) AgentRegisterPeer(c *gin.Context) {
}()
}
slog.Info("cluster: peer registered via mTLS",
// Bei neuem Peer / IP-Wechsel als Info loggen (relevantes Ereignis),
// sonst Debug — die periodischen 30s-Pushes (runPrimaryPush/runPeerPush)
// würden sonst das Log fluten.
logFn := slog.Debug
if ipChanged {
logFn = slog.Info
}
logFn("cluster: peer registered via mTLS",
"id", out.ID, "fqdn", out.FQDN, "role", out.Role, "status", out.Status,
"client_cn", cn, "remote", c.ClientIP())
response.OK(c, out)

View File

@@ -2,6 +2,7 @@ package handlers
import (
"context"
"encoding/json"
"errors"
"fmt"
"log/slog"
@@ -26,132 +27,158 @@ import (
// Reparatur baut die Subscription neu auf und kopiert alle geteilten
// Tabellen frisch vom Primary (einseitig: Primary = Source of Truth).
//
// Der Resync MUSS auf dem Standby/Subscriber laufen (nur der hat eine
// Subscription). Operatoren erreichen die UI aber über die VIP, die immer
// auf den Primary zeigt. Deshalb:
// Rollen-Erkennung: NICHT über ha_nodes.role/pg_role — die sind je Node
// lokal und unzuverlässig (jede Node markiert sich selbst, pg_role bleibt
// 'standalone' bis `promote`). Verlässlich ist die PUBLICATION: nur der
// Primary hat `edgeguard_shared` (pg_publication ist für jeden DB-User
// lesbar). Der Subscriber hat sie nicht → er ist das Resync-Ziel.
//
// - Auf dem Primary geklickt → Dispatch via mTLS an den Standby
// (POST /agent/cluster/repair-replication), der dort lokal läuft.
// - Auf dem Standby direkt geklickt → läuft lokal.
// Ablauf:
// - Klick auf dem Primary → Dispatch via mTLS an den Peer
// (POST /agent/cluster/repair-replication) mit der eigenen Adresse als
// primary_host; der Peer resynct von dort.
// - Klick direkt auf dem Subscriber → läuft lokal (Quelle = der Peer).
//
// Die eigentliche Arbeit läuft — analog zum Rolling-Update — in einer
// transienten systemd-Unit, die das bereits getestete
// `edgeguard-ctl cluster-setup-standby <primary>` ausführt.
// transienten systemd-Unit, die `edgeguard-ctl cluster-setup-standby
// <primary>` ausführt.
const (
repairUnitName = "edgeguard-repair-replication.service"
repairScriptPath = "/var/lib/edgeguard/repair-replication.sh"
repairAgentPath = "/agent/cluster/repair-replication"
repairPubName = "edgeguard_shared" // muss zu cmd/edgeguard-ctl egPubName passen
)
// validRepairHost erlaubt nur IPv4/IPv6/Hostnamen — der Wert landet in
// einem Bash-Script das als root läuft, also strikt validieren (defense
// in depth, auch wenn er aus ha_nodes stammt).
// einem Bash-Script das als root läuft, also strikt validieren.
var validRepairHost = regexp.MustCompile(`^[A-Za-z0-9._:-]{1,253}$`)
// RepairReplication ist der UI-Endpoint. Läuft der lokale Node als
// Primary, wird der Resync an den Standby-Peer delegiert; auf dem Standby
// selbst läuft er lokal.
// repairDispatchBody ist der Body des Agent-Dispatch: der Primary teilt
// dem Subscriber seine Adresse mit, von der resynct werden soll.
type repairDispatchBody struct {
PrimaryHost string `json:"primary_host"`
}
// RepairReplication ist der UI-Endpoint. Hat dieser Node die Publication
// (= Primary), wird der Resync an den Peer delegiert; sonst (Subscriber)
// läuft er lokal mit dem Peer als Quelle.
func (h *ClusterHandler) RepairReplication(c *gin.Context) {
if h.Store == nil {
response.Internal(c, errors.New("cluster store unavailable"))
return
}
all, err := h.Store.List(c.Request.Context())
ctx := c.Request.Context()
all, err := h.Store.List(ctx)
if err != nil {
response.Internal(c, err)
return
}
local := findNode(all, h.LocalID)
standby := findByPGRole(all, "standby")
peer := findOtherPeer(all, h.LocalID)
if peer == nil {
response.BadRequest(c, errors.New("kein Peer-Node im Cluster — nichts zu resyncen"))
return
}
// Primary → an den Standby delegieren.
if local != nil && local.PGRole == "primary" {
isPrimary, err := h.nodeHasPublication(ctx)
if err != nil {
// Primary/Subscriber-Status nicht ermittelbar → NICHT raten
// (sonst Resync auf dem falschen Node). Abbrechen.
response.Internal(c, fmt.Errorf("primary-status nicht ermittelbar: %w", err))
return
}
if isPrimary {
// Primary → an den Subscriber-Peer delegieren, mit eigener Adresse.
if h.Aggregator == nil {
response.BadRequest(c, errors.New("kein mTLS-Aggregator verfügbar — Resync nicht delegierbar"))
return
}
if standby == nil {
response.BadRequest(c, errors.New("kein Standby-Node gefunden, an den der Resync delegiert werden könnte"))
primaryHost := pickPrimaryHost(local)
if primaryHost == "" || !validRepairHost.MatchString(primaryHost) {
response.BadRequest(c, errors.New("eigene Primary-Adresse (Mgmt/Internal/Public-IP/FQDN) fehlt oder ist ungültig"))
return
}
res := h.Aggregator.PostPeer(c.Request.Context(), *standby, repairAgentPath)
body, _ := json.Marshal(repairDispatchBody{PrimaryHost: primaryHost})
res := h.Aggregator.PostPeerWithBody(ctx, *peer, repairAgentPath, body)
if !res.OK {
response.Internal(c, fmt.Errorf("Resync auf %s anstoßen: %s", standby.FQDN, res.Err))
response.Internal(c, fmt.Errorf("Resync auf %s anstoßen: %s", peer.FQDN, res.Err))
return
}
slog.Info("cluster: replication repair delegated to standby", "standby", standby.FQDN)
slog.Info("cluster: replication repair delegated", "target", peer.FQDN, "primary_host", primaryHost)
if h.Audit != nil {
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "cluster.repair-replication",
standby.FQDN, gin.H{"target": "standby", "standby": standby.FQDN}, h.NodeID)
_ = h.Audit.Log(ctx, actorOf(c), "cluster.repair-replication",
peer.FQDN, gin.H{"target": "peer", "peer": peer.FQDN, "primary_host": primaryHost}, h.NodeID)
}
response.Accepted(c, gin.H{"dispatched": true, "target": "standby", "standby_fqdn": standby.FQDN})
response.Accepted(c, gin.H{"dispatched": true, "target": "peer", "peer_fqdn": peer.FQDN})
return
}
// Standby (oder Direktzugriff) → lokal ausführen.
host, err := h.runLocalRepair(c.Request.Context(), all)
if err != nil {
// Subscriber → lokal ausführen, Quelle = der Peer (Primary).
host := pickPrimaryHost(peer)
if err := h.startResync(ctx, host); err != nil {
response.BadRequest(c, err)
return
}
if h.Audit != nil {
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "cluster.repair-replication",
_ = h.Audit.Log(ctx, actorOf(c), "cluster.repair-replication",
host, gin.H{"target": "local", "primary": host}, h.NodeID)
}
response.Accepted(c, gin.H{"dispatched": true, "target": "local", "primary": host})
}
// AgentRepairReplication wird vom Primary via mTLS auf dem Standby
// aufgerufen und startet dort den lokalen Resync.
// AgentRepairReplication wird vom Primary via mTLS auf dem Subscriber
// aufgerufen und startet dort den lokalen Resync von primary_host.
func (h *ClusterHandler) AgentRepairReplication(c *gin.Context) {
if h.Store == nil {
response.Internal(c, errors.New("cluster store unavailable"))
return
}
all, err := h.Store.List(c.Request.Context())
if err != nil {
response.Internal(c, err)
return
ctx := c.Request.Context()
var body repairDispatchBody
_ = c.ShouldBindJSON(&body) // best-effort; Fallback unten
host := strings.TrimSpace(body.PrimaryHost)
if host == "" {
// Fallback: Quelle aus ha_nodes (der andere Node).
if all, err := h.Store.List(ctx); err == nil {
host = pickPrimaryHost(findOtherPeer(all, h.LocalID))
}
}
host, err := h.runLocalRepair(c.Request.Context(), all)
if err != nil {
if err := h.startResync(ctx, host); err != nil {
response.BadRequest(c, err)
return
}
slog.Info("cluster: replication repair triggered by peer", "primary", host, "node", h.LocalID)
if h.Audit != nil {
_ = h.Audit.Log(c.Request.Context(), "cluster-peer", "cluster.repair-replication",
_ = h.Audit.Log(ctx, "cluster-peer", "cluster.repair-replication",
host, gin.H{"target": "local", "primary": host, "via": "agent"}, h.NodeID)
}
response.Accepted(c, gin.H{"dispatched": true, "primary": host})
}
// runLocalRepair startet den Resync auf DIESEM Node. Verweigert auf dem
// Primary (kein Subscriber). Gibt den ermittelten Primary-Host zurück.
func (h *ClusterHandler) runLocalRepair(_ context.Context, all []models.HANode) (string, error) {
local := findNode(all, h.LocalID)
primary := findByPGRole(all, "primary")
if local != nil && local.PGRole == "primary" {
return "", errors.New("dieser Node ist der PostgreSQL-Primary — Resync läuft nur auf einem Standby/Subscriber")
// startResync schreibt das Repair-Script und startet die transiente
// systemd-Unit. Safety-Guard: läuft NIE auf dem Publication-Primary.
func (h *ClusterHandler) startResync(ctx context.Context, primaryHost string) error {
primaryHost = strings.TrimSpace(primaryHost)
if primaryHost == "" {
return errors.New("keine Primary-Adresse für den Resync ermittelbar")
}
if primary == nil {
return "", errors.New("kein PostgreSQL-Primary im Cluster gefunden — Resync-Quelle unbekannt")
if !validRepairHost.MatchString(primaryHost) {
return fmt.Errorf("ungültige Primary-Adresse: %q", primaryHost)
}
if primary.ID == h.LocalID {
return "", errors.New("der lokale Node ist als Primary markiert — Resync nicht möglich")
// Niemals auf dem Primary (Publication-Quelle) resyncen — würde die
// eigene Config mit sich selbst überschreiben bzw. ist sinnlos.
// Bei Statusfehler fail-closed (NICHT resyncen).
isPrimary, err := h.nodeHasPublication(ctx)
if err != nil {
return fmt.Errorf("publication-status nicht ermittelbar: %w", err)
}
host := pickPrimaryHost(primary)
if host == "" {
return "", errors.New("Primary hat keine erreichbare IP/FQDN in ha_nodes")
}
if !validRepairHost.MatchString(host) {
return "", fmt.Errorf("ungültige Primary-Adresse: %q", host)
if isPrimary {
return errors.New("dieser Node ist der Publication-Primary — Resync läuft nur auf einem Subscriber")
}
if st := repairUnitState(); st == "activating" || st == "active" {
return "", errors.New("Resync läuft bereits")
return errors.New("Resync läuft bereits")
}
script := fmt.Sprintf(`#!/bin/bash
@@ -165,10 +192,10 @@ if [ "$rc" -ne 0 ]; then
fi
echo "[repair] abgeschlossen — config_hash wird beim nächsten Cluster-Status neu berechnet"
rm -f %[2]s
`, host, repairScriptPath)
`, primaryHost, repairScriptPath)
if err := os.WriteFile(repairScriptPath, []byte(script), 0o755); err != nil {
return "", fmt.Errorf("write repair script: %w", err)
return fmt.Errorf("write repair script: %w", err)
}
_ = exec.Command("sudo", "-n", "/usr/bin/systemctl", "reset-failed", repairUnitName).Run()
cmd := exec.Command("sudo", "-n", "/usr/bin/systemd-run",
@@ -177,10 +204,28 @@ rm -f %[2]s
"--collect",
"bash", repairScriptPath)
if err := cmd.Run(); err != nil {
return "", fmt.Errorf("systemd-run failed: %w", err)
return fmt.Errorf("systemd-run failed: %w", err)
}
slog.Info("cluster: replication repair dispatched (local)", "primary", host, "node", h.LocalID)
return host, nil
slog.Info("cluster: replication repair dispatched (local)", "primary", primaryHost, "node", h.LocalID)
return nil
}
// nodeHasPublication prüft, ob dieser Node die Replikations-Publication
// besitzt — das verlässliche Primary-Signal. pg_publication ist für jeden
// DB-User lesbar (anders als pg_subscription).
func (h *ClusterHandler) nodeHasPublication(ctx context.Context) (bool, error) {
if h.Store == nil || h.Store.Pool == nil {
return false, errors.New("no db pool")
}
cctx, cancel := context.WithTimeout(ctx, 2*time.Second)
defer cancel()
var exists bool
if err := h.Store.Pool.QueryRow(cctx,
`SELECT EXISTS(SELECT 1 FROM pg_publication WHERE pubname = $1)`, repairPubName,
).Scan(&exists); err != nil {
return false, err
}
return exists, nil
}
// repairStatusResponse spiegelt den Zustand der transienten Repair-Unit.
@@ -195,21 +240,22 @@ type repairStatusResponse struct {
}
// RepairReplicationStatus liest den Job-Zustand. Auf dem Primary wird der
// Status vom Standby-Peer geholt (dort läuft der Job); sonst lokal.
// Status vom Subscriber-Peer geholt (dort läuft der Job); sonst lokal.
func (h *ClusterHandler) RepairReplicationStatus(c *gin.Context) {
if h.Store != nil {
if all, err := h.Store.List(c.Request.Context()); err == nil {
local := findNode(all, h.LocalID)
standby := findByPGRole(all, "standby")
if local != nil && local.PGRole == "primary" && h.Aggregator != nil && standby != nil {
results := h.Aggregator.FanOut(c.Request.Context(),
[]models.HANode{*standby}, repairAgentPath+"/status", h.LocalID)
ctx := c.Request.Context()
// Status-Poll: bei Fehler kein 500 — einfach lokalen Status liefern.
isPrimary, _ := h.nodeHasPublication(ctx)
if h.Store != nil && isPrimary && h.Aggregator != nil {
if all, err := h.Store.List(ctx); err == nil {
if peer := findOtherPeer(all, h.LocalID); peer != nil {
results := h.Aggregator.FanOut(ctx,
[]models.HANode{*peer}, repairAgentPath+"/status", h.LocalID)
if len(results) == 1 && results[0].OK && len(results[0].Data) > 0 {
c.Data(200, "application/json", wrapEnvelope(results[0].Data))
return
}
// Peer nicht erreichbar → idle zurückgeben statt Fehler,
// damit das UI-Polling nicht hart abbricht.
// Peer nicht erreichbar → idle statt Fehler, damit das
// UI-Polling nicht hart abbricht.
response.OK(c, repairStatusResponse{Phase: "idle", Log: []string{}})
return
}
@@ -295,7 +341,7 @@ func localRepairStatus() repairStatusResponse {
return out
}
// findNode / findByPGRole: kleine Helfer über die ha_nodes-Liste.
// findNode liefert die ha_nodes-Row mit der gegebenen ID.
func findNode(nodes []models.HANode, id string) *models.HANode {
for i := range nodes {
if nodes[i].ID == id {
@@ -305,19 +351,32 @@ func findNode(nodes []models.HANode, id string) *models.HANode {
return nil
}
func findByPGRole(nodes []models.HANode, role string) *models.HANode {
// findOtherPeer liefert den (einen) anderen Node im 2-Node-Cluster.
// Bevorzugt einen online erreichbaren Peer.
func findOtherPeer(nodes []models.HANode, localID string) *models.HANode {
var fallback *models.HANode
for i := range nodes {
if nodes[i].PGRole == role {
return &nodes[i]
n := &nodes[i]
if n.ID == localID {
continue
}
if n.Status == "online" {
return n
}
if fallback == nil {
fallback = n
}
}
return nil
return fallback
}
// pickPrimaryHost wählt die beste erreichbare Adresse des Primary:
// pickPrimaryHost wählt die beste erreichbare Adresse eines Node:
// Mgmt-IP → Internal-IP → Public-IP → FQDN. Strippt eine etwaige
// CIDR-Maske (inet-Spalten können "10.0.0.5/32" liefern).
func pickPrimaryHost(n *models.HANode) string {
if n == nil {
return ""
}
for _, cand := range []*string{n.MgmtIP, n.InternalIP, n.PublicIP} {
if cand != nil {
if h := strings.TrimSpace(strings.SplitN(*cand, "/", 2)[0]); h != "" {

View File

@@ -7,14 +7,21 @@ import (
"net/http"
"os"
"os/exec"
"sync"
"time"
"github.com/gin-gonic/gin"
"git.netcell-it.de/projekte/edgeguard-native/internal/configgen"
"git.netcell-it.de/projekte/edgeguard-native/internal/handlers/response"
"git.netcell-it.de/projekte/edgeguard-native/internal/models"
aptsvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/apt"
)
// ruStateMu serialisiert Lesen/Schreiben der Rolling-Update-State-Datei
// (HTTP-Handler + Hintergrund-Goroutine greifen gleichzeitig zu).
var ruStateMu sync.Mutex
const rollingUpdateStateFile = "/var/lib/edgeguard/rolling-update-state.json"
const (
@@ -26,17 +33,24 @@ const (
phaseFailed = "failed"
)
// FinishRollingUpdateIfPending wird beim API-Start aufgerufen. Wenn die
// State-Datei "updating-primary" enthält, bedeutet das dass der Primary
// gerade erfolgreich neugestartet ist → Update abgeschlossen → "done" schreiben.
// FinishRollingUpdateIfPending wird beim API-Start aufgerufen.
// - "updating-primary": der Primary ist gerade erfolgreich neugestartet →
// Update abgeschlossen → "done".
// - "updating-secondary"/"waiting-secondary": die orchestrierende Goroutine
// lief in DIESEM (jetzt neu gestarteten) Prozess und ist mit ihm gestorben.
// Die Phase kann nicht weiterlaufen → auf "idle" zurücksetzen, sonst zeigt
// die UI ewig "Rolling Update läuft". (Vorher blieb so ein Stand hängen.)
func FinishRollingUpdateIfPending() {
st := readRollingUpdateState()
if st.Phase == phaseUpdatingPrimary {
switch st.Phase {
case phaseUpdatingPrimary:
writeRollingUpdateState(RollingUpdateState{
Phase: phaseDone,
SecondaryID: st.SecondaryID,
SecondaryFQDN: st.SecondaryFQDN,
})
case phaseUpdatingSecondary, phaseWaitingSecondary:
writeRollingUpdateState(RollingUpdateState{Phase: phaseIdle})
}
}
@@ -53,6 +67,8 @@ type RollingUpdateState struct {
}
func readRollingUpdateState() RollingUpdateState {
ruStateMu.Lock()
defer ruStateMu.Unlock()
data, err := os.ReadFile(rollingUpdateStateFile)
if err != nil {
return RollingUpdateState{Phase: phaseIdle, UpdatedAt: time.Now()}
@@ -61,6 +77,13 @@ func readRollingUpdateState() RollingUpdateState {
if err := json.Unmarshal(data, &s); err != nil {
return RollingUpdateState{Phase: phaseIdle, UpdatedAt: time.Now()}
}
// Terminale Zustände altern aus (statt Mutation-on-GET): nach 10 min
// gilt done/failed als idle — so verliert kein paralleler Poller das
// Ergebnis und ein alter Stand bleibt nicht hängen.
if (s.Phase == phaseDone || s.Phase == phaseFailed) && !s.UpdatedAt.IsZero() &&
time.Since(s.UpdatedAt) > 10*time.Minute {
return RollingUpdateState{Phase: phaseIdle, UpdatedAt: time.Now()}
}
return s
}
@@ -71,7 +94,10 @@ func writeRollingUpdateState(s RollingUpdateState) {
slog.Warn("rolling-update: failed to marshal state", "error", err)
return
}
if err := os.WriteFile(rollingUpdateStateFile, data, 0o600); err != nil {
ruStateMu.Lock()
defer ruStateMu.Unlock()
// AtomicWrite (temp+rename) → Leser sehen nie einen partiellen Stand.
if err := configgen.AtomicWrite(rollingUpdateStateFile, data, 0o600); err != nil {
slog.Warn("rolling-update: failed to write state file", "error", err)
}
}
@@ -127,76 +153,108 @@ func (h *ClusterHandler) RollingUpdate(c *gin.Context) {
}
// RollingUpdateStatus gibt den aktuellen Rolling-Update-State zurück.
// Bei phase == "done" wird nach Auslieferung sofort auf idle zurückgesetzt
// damit der nächste Pageload keinen Stale-done vorfindet.
// Read-only — terminale Zustände altern in readRollingUpdateState aus
// (kein Reset-on-GET mehr, das parallelen Pollern das "done" wegnahm).
func (h *ClusterHandler) RollingUpdateStatus(c *gin.Context) {
st := readRollingUpdateState()
response.OK(c, st)
if st.Phase == phaseDone {
writeRollingUpdateState(RollingUpdateState{Phase: phaseIdle})
}
response.OK(c, readRollingUpdateState())
}
func (h *ClusterHandler) runRollingUpdate(secondary *models.HANode) {
ctx := context.Background()
// 1. Secondary triggern
slog.Info("rolling-update: posting trigger-update to secondary", "fqdn", secondary.FQDN)
result := h.Aggregator.PostPeer(ctx, *secondary, "/agent/cluster/trigger-update")
if !result.OK {
// Zielversion = das verfügbare apt-Candidate (worauf wir hochziehen) und
// die aktuelle Secondary-Version als Baseline. Beides steuert, ob der
// Secondary überhaupt etwas zu tun hat.
candidate := rollingCandidateVersion(ctx)
baseline := secondaryVersion(ctx, h, secondary)
// Ist der Secondary bereits auf der Zielversion, gibt es nichts
// hochzuziehen — KEIN Trigger, KEIN Warten. Sonst würde auf einen
// Version-Flip gewartet, der nie kommt → 10-min-Timeout (der frühere Bug,
// wenn beide Nodes schon aktuell waren).
secondaryUpToDate := candidate != "" && baseline != "" && baseline == candidate
if secondaryUpToDate {
slog.Info("rolling-update: secondary already at target — skipping secondary step",
"version", candidate)
} else {
// 1. Secondary triggern
slog.Info("rolling-update: posting trigger-update to secondary", "fqdn", secondary.FQDN)
result := h.Aggregator.PostPeer(ctx, *secondary, "/agent/cluster/trigger-update")
if !result.OK {
writeRollingUpdateState(RollingUpdateState{
Phase: phaseFailed,
SecondaryID: secondary.ID,
SecondaryFQDN: secondary.FQDN,
Error: "trigger-update failed: " + result.Err,
})
slog.Warn("rolling-update: secondary trigger failed", "error", result.Err)
return
}
// 2. Secondary-Version pollen — der Secondary restartet nach dem
// Upgrade, danach zeigt /agent/cluster/version eine neue Version.
writeRollingUpdateState(RollingUpdateState{
Phase: phaseFailed,
Phase: phaseWaitingSecondary,
SecondaryID: secondary.ID,
SecondaryFQDN: secondary.FQDN,
Error: "trigger-update failed: " + result.Err,
})
slog.Warn("rolling-update: secondary trigger failed", "error", result.Err)
return
}
slog.Info("rolling-update: waiting for secondary version flip",
"baseline", baseline, "candidate", candidate)
// 2. Secondary-Version pollen — der Secondary restartet nach dem
// Upgrade, danach zeigt /agent/cluster/version eine neue Version.
writeRollingUpdateState(RollingUpdateState{
Phase: phaseWaitingSecondary,
SecondaryID: secondary.ID,
SecondaryFQDN: secondary.FQDN,
})
slog.Info("rolling-update: waiting for secondary version flip")
// Kurze Wartezeit damit apt auf dem Secondary erst losläuft
time.Sleep(20 * time.Second)
// Kurze Wartezeit damit apt auf dem Secondary erst losläuft
time.Sleep(20 * time.Second)
deadline := time.Now().Add(10 * time.Minute)
versionFlipped := false
for time.Now().Before(deadline) {
results := h.Aggregator.FanOut(ctx, []models.HANode{*secondary}, "/agent/cluster/version", h.LocalID)
if len(results) > 0 && results[0].OK {
var ver struct {
Version string `json:"version"`
}
if err := json.Unmarshal(results[0].Data, &ver); err == nil {
slog.Info("rolling-update: secondary version", "version", ver.Version, "primary", h.Version)
if ver.Version != h.Version {
versionFlipped = true
break
deadline := time.Now().Add(10 * time.Minute)
versionFlipped := false
for time.Now().Before(deadline) {
results := h.Aggregator.FanOut(ctx, []models.HANode{*secondary}, "/agent/cluster/version", h.LocalID)
if len(results) > 0 && results[0].OK {
var ver struct {
Version string `json:"version"`
}
if err := json.Unmarshal(results[0].Data, &ver); err == nil {
slog.Info("rolling-update: secondary version", "version", ver.Version,
"baseline", baseline, "candidate", candidate)
// Erfolg = Secondary hat die Zielversion erreicht (candidate)
// ODER hat sich gegenüber der Baseline überhaupt bewegt
// (Fallback, wenn candidate nicht ermittelbar war).
if ver.Version != "" &&
((candidate != "" && ver.Version == candidate) || ver.Version != baseline) {
versionFlipped = true
break
}
}
}
time.Sleep(10 * time.Second)
}
if !versionFlipped {
writeRollingUpdateState(RollingUpdateState{
Phase: phaseFailed,
SecondaryID: secondary.ID,
SecondaryFQDN: secondary.FQDN,
Error: "timeout (10 min) waiting for secondary version flip",
})
slog.Warn("rolling-update: secondary version flip timeout")
return
}
time.Sleep(10 * time.Second)
}
if !versionFlipped {
// 3. Primary (uns selbst) aktualisieren — identisch zu /system/upgrade.
// Ist der Primary bereits auf der Zielversion (z. B. beide Nodes schon
// aktuell), gibt es nichts zu tun → direkt "done". Sonst liefe ein
// apt-Lauf ohne Paket-Wechsel → kein Restart → Phase hinge ewig in
// "updating-primary".
if candidate != "" && h.Version == candidate {
slog.Info("rolling-update: primary already at target — nothing to upgrade", "version", candidate)
writeRollingUpdateState(RollingUpdateState{
Phase: phaseFailed,
Phase: phaseDone,
SecondaryID: secondary.ID,
SecondaryFQDN: secondary.FQDN,
Error: "timeout (10 min) waiting for secondary version flip",
})
slog.Warn("rolling-update: secondary version flip timeout")
return
}
// 3. Primary (uns selbst) aktualisieren — identisch zu /system/upgrade
writeRollingUpdateState(RollingUpdateState{
Phase: phaseUpdatingPrimary,
SecondaryID: secondary.ID,
@@ -256,3 +314,26 @@ rm -f /var/lib/edgeguard/upgrade.sh
// UI erkennt Version-Flip via /system/health und schließt den Flow.
slog.Info("rolling-update: primary upgrade dispatched, process will restart")
}
// rollingCandidateVersion liefert best-effort die verfügbare apt-Candidate-
// Version des Meta-Pakets "edgeguard" — also die Version, auf die das Rolling-
// Update hochzieht. Leerer String, wenn apt sie nicht ermitteln kann (dann
// fällt runRollingUpdate auf reine Baseline-Flip-Erkennung zurück).
func rollingCandidateVersion(ctx context.Context) string {
vers := aptsvc.PackageVersions(ctx, false)
return vers["edgeguard_available"]
}
// secondaryVersion holt best-effort die laufende Version des Peers via mTLS.
func secondaryVersion(ctx context.Context, h *ClusterHandler, secondary *models.HANode) string {
results := h.Aggregator.FanOut(ctx, []models.HANode{*secondary}, "/agent/cluster/version", h.LocalID)
if len(results) > 0 && results[0].OK {
var ver struct {
Version string `json:"version"`
}
if json.Unmarshal(results[0].Data, &ver) == nil {
return ver.Version
}
}
return ""
}

346
internal/handlers/dhcp.go Normal file
View File

@@ -0,0 +1,346 @@
package handlers
import (
"context"
"errors"
"log/slog"
"net"
"strings"
"github.com/gin-gonic/gin"
"git.netcell-it.de/projekte/edgeguard-native/internal/handlers/response"
"git.netcell-it.de/projekte/edgeguard-native/internal/models"
"git.netcell-it.de/projekte/edgeguard-native/internal/services/audit"
dhcpsvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/dhcp"
)
// DHCPHandler exposes /api/v1/dhcp/{settings,subnets,reservations} for
// the Kea DHCPv4 server.
type DHCPHandler struct {
Repo *dhcpsvc.Repo
Audit *audit.Repo
NodeID string
Reloader func(ctx context.Context) error
}
func NewDHCPHandler(repo *dhcpsvc.Repo, a *audit.Repo, nodeID string, reloader func(context.Context) error) *DHCPHandler {
return &DHCPHandler{Repo: repo, Audit: a, NodeID: nodeID, Reloader: reloader}
}
func (h *DHCPHandler) reload(ctx context.Context, op string) {
if h.Reloader == nil {
return
}
if err := h.Reloader(ctx); err != nil {
slog.Warn("kea: reload after mutation failed", "op", op, "error", err)
}
}
func (h *DHCPHandler) Register(rg *gin.RouterGroup) {
g := rg.Group("/dhcp")
g.GET("/settings", h.GetSettings)
g.PUT("/settings", h.UpdateSettings)
s := g.Group("/subnets")
s.GET("", h.ListSubnets)
s.POST("", h.CreateSubnet)
s.GET("/:id", h.GetSubnet)
s.PUT("/:id", h.UpdateSubnet)
s.DELETE("/:id", h.DeleteSubnet)
s.GET("/:id/reservations", h.ListReservationsForSubnet)
s.POST("/:id/reservations", h.CreateReservation)
r := g.Group("/reservations")
r.GET("", h.ListAllReservations)
r.GET("/:id", h.GetReservation)
r.PUT("/:id", h.UpdateReservation)
r.DELETE("/:id", h.DeleteReservation)
}
// ── Settings ─────────────────────────────────────────────────────────
func (h *DHCPHandler) GetSettings(c *gin.Context) {
s, err := h.Repo.GetSettings(c.Request.Context())
if err != nil {
response.Internal(c, err)
return
}
response.OK(c, s)
}
func (h *DHCPHandler) UpdateSettings(c *gin.Context) {
var req models.DHCPSettings
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, err)
return
}
if req.DefaultLease <= 0 {
req.DefaultLease = 3600
}
if req.MaxLease < req.DefaultLease {
req.MaxLease = req.DefaultLease
}
if err := validateIPList(req.DNSServers); err != nil {
response.BadRequest(c, err)
return
}
out, err := h.Repo.UpdateSettings(c.Request.Context(), req)
if err != nil {
response.Internal(c, err)
return
}
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "dhcp.settings.update", "",
gin.H{"enabled": out.Enabled}, h.NodeID)
response.OK(c, out)
h.reload(c.Request.Context(), "settings.update")
}
// ── Subnets ──────────────────────────────────────────────────────────
func (h *DHCPHandler) ListSubnets(c *gin.Context) {
out, err := h.Repo.ListSubnets(c.Request.Context())
if err != nil {
response.Internal(c, err)
return
}
response.OK(c, gin.H{"subnets": out})
}
func (h *DHCPHandler) GetSubnet(c *gin.Context) {
id, ok := parseID(c)
if !ok {
return
}
s, err := h.Repo.GetSubnet(c.Request.Context(), id)
if err != nil {
h.subnetErr(c, err)
return
}
response.OK(c, s)
}
func (h *DHCPHandler) CreateSubnet(c *gin.Context) {
var req models.DHCPSubnet
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, err)
return
}
if err := h.validateSubnet(c, &req); err != nil {
response.BadRequest(c, err)
return
}
out, err := h.Repo.CreateSubnet(c.Request.Context(), req)
if err != nil {
response.Internal(c, err)
return
}
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "dhcp.subnet.create", out.Name, out, h.NodeID)
response.Created(c, out)
h.reload(c.Request.Context(), "subnet.create")
}
func (h *DHCPHandler) UpdateSubnet(c *gin.Context) {
id, ok := parseID(c)
if !ok {
return
}
var req models.DHCPSubnet
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, err)
return
}
if err := h.validateSubnet(c, &req); err != nil {
response.BadRequest(c, err)
return
}
out, err := h.Repo.UpdateSubnet(c.Request.Context(), id, req)
if err != nil {
h.subnetErr(c, err)
return
}
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "dhcp.subnet.update", out.Name, out, h.NodeID)
response.OK(c, out)
h.reload(c.Request.Context(), "subnet.update")
}
func (h *DHCPHandler) DeleteSubnet(c *gin.Context) {
id, ok := parseID(c)
if !ok {
return
}
if err := h.Repo.DeleteSubnet(c.Request.Context(), id); err != nil {
h.subnetErr(c, err)
return
}
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "dhcp.subnet.delete", "", gin.H{"id": id}, h.NodeID)
response.OK(c, gin.H{"ok": true})
h.reload(c.Request.Context(), "subnet.delete")
}
// ── Reservations ─────────────────────────────────────────────────────
func (h *DHCPHandler) ListAllReservations(c *gin.Context) {
out, err := h.Repo.ListAllReservations(c.Request.Context())
if err != nil {
response.Internal(c, err)
return
}
response.OK(c, gin.H{"reservations": out})
}
func (h *DHCPHandler) ListReservationsForSubnet(c *gin.Context) {
id, ok := parseID(c)
if !ok {
return
}
out, err := h.Repo.ListReservationsForSubnet(c.Request.Context(), id)
if err != nil {
response.Internal(c, err)
return
}
response.OK(c, gin.H{"reservations": out})
}
func (h *DHCPHandler) GetReservation(c *gin.Context) {
id, ok := parseID(c)
if !ok {
return
}
v, err := h.Repo.GetReservation(c.Request.Context(), id)
if err != nil {
h.resvErr(c, err)
return
}
response.OK(c, v)
}
func (h *DHCPHandler) CreateReservation(c *gin.Context) {
subnetID, ok := parseID(c)
if !ok {
return
}
var req models.DHCPReservation
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, err)
return
}
req.SubnetID = subnetID
if err := validateReservation(&req); err != nil {
response.BadRequest(c, err)
return
}
out, err := h.Repo.CreateReservation(c.Request.Context(), req)
if err != nil {
response.Internal(c, err)
return
}
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "dhcp.reservation.create", out.MACAddress, out, h.NodeID)
response.Created(c, out)
h.reload(c.Request.Context(), "reservation.create")
}
func (h *DHCPHandler) UpdateReservation(c *gin.Context) {
id, ok := parseID(c)
if !ok {
return
}
var req models.DHCPReservation
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, err)
return
}
if err := validateReservation(&req); err != nil {
response.BadRequest(c, err)
return
}
out, err := h.Repo.UpdateReservation(c.Request.Context(), id, req)
if err != nil {
h.resvErr(c, err)
return
}
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "dhcp.reservation.update", out.MACAddress, out, h.NodeID)
response.OK(c, out)
h.reload(c.Request.Context(), "reservation.update")
}
func (h *DHCPHandler) DeleteReservation(c *gin.Context) {
id, ok := parseID(c)
if !ok {
return
}
if err := h.Repo.DeleteReservation(c.Request.Context(), id); err != nil {
h.resvErr(c, err)
return
}
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "dhcp.reservation.delete", "", gin.H{"id": id}, h.NodeID)
response.OK(c, gin.H{"ok": true})
h.reload(c.Request.Context(), "reservation.delete")
}
// ── Validation + error mapping ───────────────────────────────────────
func (h *DHCPHandler) validateSubnet(c *gin.Context, s *models.DHCPSubnet) error {
s.Name = strings.TrimSpace(s.Name)
s.InterfaceName = strings.TrimSpace(s.InterfaceName)
if s.Name == "" {
return errors.New("name ist erforderlich")
}
if s.InterfaceName == "" {
return errors.New("interface_name ist erforderlich")
}
if exists, err := h.Repo.InterfaceExists(c.Request.Context(), s.InterfaceName); err == nil && !exists {
return errors.New("interface_name existiert nicht: " + s.InterfaceName)
}
if _, _, err := net.ParseCIDR(s.SubnetCIDR); err != nil {
return errors.New("subnet_cidr ist kein gültiges CIDR: " + s.SubnetCIDR)
}
if (s.PoolStart == "") != (s.PoolEnd == "") {
return errors.New("pool_start und pool_end müssen beide gesetzt sein (oder beide leer)")
}
for _, ip := range []string{s.PoolStart, s.PoolEnd, s.Gateway} {
if ip != "" && net.ParseIP(ip) == nil {
return errors.New("ungültige IP-Adresse: " + ip)
}
}
return validateIPList(s.DNSServers)
}
func validateReservation(r *models.DHCPReservation) error {
r.MACAddress = strings.TrimSpace(strings.ToLower(r.MACAddress))
r.IPAddress = strings.TrimSpace(r.IPAddress)
if _, err := net.ParseMAC(r.MACAddress); err != nil {
return errors.New("mac_address ist ungültig: " + r.MACAddress)
}
if net.ParseIP(r.IPAddress) == nil {
return errors.New("ip_address ist ungültig: " + r.IPAddress)
}
return nil
}
// validateIPList prüft eine optionale Komma-Liste von IPs.
func validateIPList(csv string) error {
for _, p := range strings.Split(csv, ",") {
p = strings.TrimSpace(p)
if p != "" && net.ParseIP(p) == nil {
return errors.New("ungültige IP in dns_servers: " + p)
}
}
return nil
}
func (h *DHCPHandler) subnetErr(c *gin.Context, err error) {
if errors.Is(err, dhcpsvc.ErrSubnetNotFound) {
response.NotFound(c, err)
return
}
response.Internal(c, err)
}
func (h *DHCPHandler) resvErr(c *gin.Context, err error) {
if errors.Is(err, dhcpsvc.ErrReservationNotFound) {
response.NotFound(c, err)
return
}
response.Internal(c, err)
}

349
internal/handlers/oidc.go Normal file
View File

@@ -0,0 +1,349 @@
package handlers
import (
"crypto/rand"
"crypto/subtle"
"encoding/base64"
"encoding/json"
"errors"
"net/http"
"net/url"
"strings"
"time"
"github.com/gin-gonic/gin"
"golang.org/x/oauth2"
"git.netcell-it.de/projekte/edgeguard-native/internal/handlers/response"
"git.netcell-it.de/projekte/edgeguard-native/internal/services/audit"
oidcsvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/oidc"
"git.netcell-it.de/projekte/edgeguard-native/internal/services/session"
"git.netcell-it.de/projekte/edgeguard-native/internal/services/setup"
usersvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/users"
)
// OIDC / Keycloak SSO. Additiv zum lokalen Passwort-Login. Regeln:
// - kein Auto-Provisioning (E-Mail muss als User existieren),
// - Rolle kommt aus der DB-Row (nie aus dem Token),
// - lokaler Login + TOTP bleiben unangetastet.
//
// Flow-State (state/PKCE-verifier/nonce) liegt stateless in einem 5-min
// signierten HttpOnly-Cookie (SameSite=Lax, da der IdP-Redirect ein
// top-level cross-site GET ist). Nach Erfolg wird dieselbe Session wie
// beim lokalen Login ausgestellt (setSessionCookie + Signer).
const (
oidcFlowCookie = "edgeguard_oidc_flow"
oidcFlowTTL = 5 * time.Minute
)
type OIDCHandler struct {
Repo *oidcsvc.Repo
Auth oidcsvc.Authenticator
Users *usersvc.Repo
Signer *session.Signer
Setup *setup.Store
Audit *audit.Repo
NodeID string
}
func NewOIDCHandler(repo *oidcsvc.Repo, auth oidcsvc.Authenticator, users *usersvc.Repo, signer *session.Signer, setupStore *setup.Store) *OIDCHandler {
return &OIDCHandler{Repo: repo, Auth: auth, Users: users, Signer: signer, Setup: setupStore}
}
func (h *OIDCHandler) WithAudit(a *audit.Repo, nodeID string) *OIDCHandler {
h.Audit = a
h.NodeID = nodeID
return h
}
// RegisterPublic mountet die unauth. Endpoints (auf v1, hinter SetupGate).
func (h *OIDCHandler) RegisterPublic(rg *gin.RouterGroup) {
g := rg.Group("/auth/oidc")
g.GET("/settings", h.PublicSettings)
g.GET("/login", h.Login)
g.GET("/callback", h.Callback)
}
// RegisterAdmin mountet die Admin-Endpoints (auf authed: requireAuth +
// RequireAdminForMutations → GET für alle, PUT nur admin).
func (h *OIDCHandler) RegisterAdmin(rg *gin.RouterGroup) {
g := rg.Group("/oidc")
g.GET("/settings", h.GetSettings)
g.PUT("/settings", h.UpdateSettings)
}
// PublicSettings: nur, was die Login-Seite braucht.
func (h *OIDCHandler) PublicSettings(c *gin.Context) {
s, err := h.Repo.Get(c.Request.Context())
if err != nil {
// Kein Datensatz/kein DB → SSO einfach „aus".
response.OK(c, gin.H{"enabled": false, "button_label": ""})
return
}
response.OK(c, gin.H{"enabled": s.Enabled, "button_label": s.ButtonLabel})
}
// GetSettings: Admin-Sicht ohne Secret, mit secret_configured + redirect_uri.
func (h *OIDCHandler) GetSettings(c *gin.Context) {
s, err := h.Repo.Get(c.Request.Context())
if err != nil {
response.Internal(c, err)
return
}
hasSecret, _ := h.Repo.HasSecret(c.Request.Context())
response.OK(c, gin.H{
"enabled": s.Enabled,
"issuer_url": s.IssuerURL,
"client_id": s.ClientID,
"scopes": s.Scopes,
"email_claim": s.EmailClaim,
"button_label": s.ButtonLabel,
"secret_configured": hasSecret,
"redirect_uri": h.redirectURI(c),
})
}
type oidcUpdateBody struct {
Enabled bool `json:"enabled"`
IssuerURL string `json:"issuer_url"`
ClientID string `json:"client_id"`
ClientSecret *string `json:"client_secret"` // nil = unverändert, "" = löschen
Scopes string `json:"scopes"`
EmailClaim string `json:"email_claim"`
ButtonLabel string `json:"button_label"`
}
// UpdateSettings: PUT (admin via RequireAdminForMutations).
func (h *OIDCHandler) UpdateSettings(c *gin.Context) {
var body oidcUpdateBody
if err := c.ShouldBindJSON(&body); err != nil {
response.BadRequest(c, err)
return
}
body.IssuerURL = strings.TrimSpace(body.IssuerURL)
body.ClientID = strings.TrimSpace(body.ClientID)
if body.Scopes == "" {
body.Scopes = "openid email profile"
}
if body.EmailClaim == "" {
body.EmailClaim = "email"
}
if body.ButtonLabel == "" {
body.ButtonLabel = "Sign in with SSO"
}
if body.Enabled {
if body.IssuerURL == "" || body.ClientID == "" {
response.BadRequest(c, errors.New("issuer_url und client_id sind erforderlich, wenn OIDC aktiviert ist"))
return
}
if u, err := url.Parse(body.IssuerURL); err != nil || u.Scheme != "https" || u.Host == "" {
response.BadRequest(c, errors.New("issuer_url muss eine gültige https-URL sein"))
return
}
hasSecret, _ := h.Repo.HasSecret(c.Request.Context())
providing := body.ClientSecret != nil && *body.ClientSecret != ""
if !hasSecret && !providing {
response.BadRequest(c, errors.New("client_secret ist erforderlich (noch keins gespeichert)"))
return
}
}
if err := h.Repo.Update(c.Request.Context(), oidcsvc.UpdateInput{
Enabled: body.Enabled,
IssuerURL: body.IssuerURL,
ClientID: body.ClientID,
Scopes: body.Scopes,
EmailClaim: body.EmailClaim,
ButtonLabel: body.ButtonLabel,
ClientSecret: body.ClientSecret,
}); err != nil {
response.Internal(c, err)
return
}
h.audit(c, actorOf(c), "oidc.settings.updated", actorOf(c),
gin.H{"enabled": body.Enabled, "issuer": body.IssuerURL})
response.OK(c, gin.H{"ok": true})
}
// Login: 302 zum IdP. Setzt das signierte Flow-Cookie.
func (h *OIDCHandler) Login(c *gin.Context) {
ctx := c.Request.Context()
s, err := h.Repo.Get(ctx)
if err != nil || !s.Enabled {
h.fail(c, "disabled")
return
}
state, err1 := randToken(24)
nonce, err2 := randToken(24)
if err1 != nil || err2 != nil {
h.fail(c, "server")
return
}
verifier := oauth2.GenerateVerifier()
redirectURI := h.redirectURI(c)
authURL, err := h.Auth.AuthCodeURL(ctx, redirectURI, state, nonce, verifier)
if err != nil {
h.fail(c, "config")
return
}
blob, _ := json.Marshal(oidcFlow{State: state, Verifier: verifier, Nonce: nonce})
signed, err := h.Signer.SignBlob(blob, oidcFlowTTL)
if err != nil {
h.fail(c, "server")
return
}
h.setFlowCookie(c, signed)
c.Redirect(http.StatusFound, authURL)
}
// Callback: verifiziert Flow + Token, mappt auf DB-User, stellt Session aus.
func (h *OIDCHandler) Callback(c *gin.Context) {
ctx := c.Request.Context()
// Flow-Cookie lesen + sofort entwerten (single-use).
rawFlow, _ := c.Cookie(oidcFlowCookie)
h.clearFlowCookie(c)
if rawFlow == "" {
h.fail(c, "expired")
return
}
payload, err := h.Signer.VerifyBlob(rawFlow)
if err != nil {
h.fail(c, "expired")
return
}
var flow oidcFlow
if json.Unmarshal(payload, &flow) != nil {
h.fail(c, "expired")
return
}
if c.Query("error") != "" {
h.fail(c, "denied")
return
}
if subtle.ConstantTimeCompare([]byte(c.Query("state")), []byte(flow.State)) != 1 {
h.fail(c, "state")
return
}
code := c.Query("code")
if code == "" {
h.fail(c, "exchange")
return
}
claims, err := h.Auth.Exchange(ctx, h.redirectURI(c), code, flow.Verifier)
if err != nil {
h.fail(c, "token")
return
}
if subtle.ConstantTimeCompare([]byte(claims.Nonce), []byte(flow.Nonce)) != 1 {
h.fail(c, "nonce")
return
}
if !claims.EmailVerified || claims.Email == "" {
h.audit(c, claims.Email, "auth.login.failed", claims.Email,
gin.H{"via": "oidc", "reason": "email_unverified", "remote": c.ClientIP()})
h.fail(c, "unverified")
return
}
u, _, err := h.Users.FindByEmail(ctx, claims.Email)
if err != nil {
reason := "oidc_no_account"
if !errors.Is(err, usersvc.ErrNotFound) {
reason = "server"
}
h.audit(c, claims.Email, "auth.login.failed", claims.Email,
gin.H{"via": "oidc", "reason": reason, "remote": c.ClientIP()})
h.fail(c, map[bool]string{true: "no_account", false: "server"}[reason == "oidc_no_account"])
return
}
if !u.Active {
h.audit(c, u.Email, "auth.login.failed", u.Email,
gin.H{"via": "oidc", "reason": "account_disabled", "remote": c.ClientIP()})
h.fail(c, "disabled")
return
}
// Opportunistisches sub-Linking + Schutz gegen E-Mail-Reassignment.
if stored, err := h.Users.GetOIDCSubject(ctx, u.ID); err == nil {
if stored != "" && stored != claims.Subject {
h.audit(c, u.Email, "auth.login.failed", u.Email,
gin.H{"via": "oidc", "reason": "subject_mismatch", "remote": c.ClientIP()})
h.fail(c, "subject_mismatch")
return
}
if stored == "" {
_ = h.Users.SetOIDCSubject(ctx, u.ID, claims.Subject)
}
}
h.Users.RecordLogin(ctx, u.ID)
// Rolle STRIKT aus der DB-Row (nie aus Claims).
raw, tok, err := h.Signer.IssueWithRole(u.Email, u.Role)
if err != nil {
h.fail(c, "server")
return
}
setSessionCookie(c, raw, tok.Exp)
h.audit(c, u.Email, "auth.login.success", u.Email,
gin.H{"via": "oidc", "role": u.Role, "remote": c.ClientIP()})
c.Redirect(http.StatusFound, "/dashboard")
}
// ── Helpers ──────────────────────────────────────────────────────────
type oidcFlow struct {
State string `json:"s"`
Verifier string `json:"v"`
Nonce string `json:"n"`
}
// redirectURI = https://<FQDN>/api/v1/auth/oidc/callback (FQDN aus setup.json,
// Fallback Request-Host). Muss im IdP als Redirect-URI registriert sein.
func (h *OIDCHandler) redirectURI(c *gin.Context) string {
host := ""
if h.Setup != nil {
if st, err := h.Setup.Load(); err == nil && st != nil {
host = strings.TrimSpace(st.FQDN)
}
}
if host == "" {
host = c.Request.Host
}
return "https://" + host + "/api/v1/auth/oidc/callback"
}
func (h *OIDCHandler) fail(c *gin.Context, reason string) {
c.Redirect(http.StatusFound, "/login?sso_error="+url.QueryEscape(reason))
}
func (h *OIDCHandler) audit(c *gin.Context, actor, action, subject string, detail any) {
if h.Audit != nil {
_ = h.Audit.Log(c.Request.Context(), actor, action, subject, detail, h.NodeID)
}
}
func (h *OIDCHandler) setFlowCookie(c *gin.Context, raw string) {
c.SetSameSite(http.SameSiteLaxMode)
c.SetCookie(oidcFlowCookie, raw, int(oidcFlowTTL.Seconds()), "/", "", true, true)
}
func (h *OIDCHandler) clearFlowCookie(c *gin.Context) {
c.SetSameSite(http.SameSiteLaxMode)
c.SetCookie(oidcFlowCookie, "", -1, "/", "", true, true)
}
func randToken(n int) (string, error) {
b := make([]byte, n)
if _, err := rand.Read(b); err != nil {
return "", err
}
return base64.RawURLEncoding.EncodeToString(b), nil
}

View File

@@ -0,0 +1,202 @@
package handlers
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"strings"
"testing"
"time"
"github.com/gin-gonic/gin"
"github.com/jackc/pgx/v5/pgxpool"
"git.netcell-it.de/projekte/edgeguard-native/internal/database"
oidcsvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/oidc"
"git.netcell-it.de/projekte/edgeguard-native/internal/services/session"
usersvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/users"
)
// mockAuth erfüllt oidcsvc.Authenticator und liefert vorgegebene Claims —
// kein echter IdP nötig.
type mockAuth struct {
claims *oidcsvc.Claims
err error
}
func (m *mockAuth) AuthCodeURL(_ context.Context, _, state, _, _ string) (string, error) {
return "https://idp.example/authorize?state=" + state, nil
}
func (m *mockAuth) Exchange(_ context.Context, _, _, _ string) (*oidcsvc.Claims, error) {
return m.claims, m.err
}
func oidcTestSetup(t *testing.T) (*usersvc.Repo, *pgxpool.Pool, *session.Signer) {
t.Helper()
dsn := os.Getenv("EG_FWTEST_DSN")
if dsn == "" {
t.Skip("set EG_FWTEST_DSN to run the oidc handler test")
}
ctx := context.Background()
// Retry: goose-Erst-Apply ist nicht concurrency-safe, wenn mehrere
// guarded Test-Pakete dieselbe frische DB parallel migrieren.
var mErr error
for i := 0; i < 3; i++ {
if mErr = database.Migrate(ctx, dsn); mErr == nil {
break
}
time.Sleep(700 * time.Millisecond)
}
if mErr != nil {
t.Fatalf("migrate: %v", mErr)
}
pool, err := database.Open(ctx, dsn)
if err != nil {
t.Fatalf("open: %v", err)
}
t.Cleanup(pool.Close)
return usersvc.New(pool), pool, session.NewSigner([]byte("0123456789abcdef0123456789abcdef"), nil, 0)
}
func seedUser(t *testing.T, repo *usersvc.Repo, pool *pgxpool.Pool, email, role string, active bool) {
t.Helper()
ctx := context.Background()
_, _ = pool.Exec(ctx, `DELETE FROM users WHERE email=$1`, email)
if _, err := repo.Create(ctx, email, "Sup3rSecret-pw-123", role, active); err != nil {
t.Fatalf("seed user: %v", err)
}
}
func runCallback(t *testing.T, h *OIDCHandler, flow oidcFlow, queryState, code string) *httptest.ResponseRecorder {
t.Helper()
gin.SetMode(gin.TestMode)
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
blob, _ := json.Marshal(flow)
signed, err := h.Signer.SignBlob(blob, oidcFlowTTL)
if err != nil {
t.Fatal(err)
}
req := httptest.NewRequest(http.MethodGet,
"/api/v1/auth/oidc/callback?state="+queryState+"&code="+code, nil)
req.AddCookie(&http.Cookie{Name: oidcFlowCookie, Value: signed})
c.Request = req
h.Callback(c)
return rec
}
func sessionCookie(rec *httptest.ResponseRecorder) string {
for _, ck := range rec.Result().Cookies() {
if ck.Name == cookieName && ck.Value != "" && ck.MaxAge >= 0 {
return ck.Value
}
}
return ""
}
func TestCallback_KnownActiveUser_RoleFromDB(t *testing.T) {
users, pool, signer := oidcTestSetup(t)
seedUser(t, users, pool, "sso-viewer@test.local", "viewer", true)
h := NewOIDCHandler(nil, &mockAuth{claims: &oidcsvc.Claims{
Email: "sso-viewer@test.local", EmailVerified: true, Subject: "sub-1", Nonce: "N",
}}, users, signer, nil)
rec := runCallback(t, h, oidcFlow{State: "S", Verifier: "v", Nonce: "N"}, "S", "code")
if loc := rec.Header().Get("Location"); loc != "/dashboard" {
t.Fatalf("expected redirect to /dashboard, got %q (body proves failure path)", loc)
}
raw := sessionCookie(rec)
if raw == "" {
t.Fatal("expected a session cookie to be set")
}
tok, err := signer.Verify(raw)
if err != nil {
t.Fatalf("session token invalid: %v", err)
}
// Kernbeweis: Rolle kommt aus der DB-Row (viewer), nicht aus Claims.
if tok.Role != "viewer" {
t.Errorf("token role = %q, want viewer (role must come from DB)", tok.Role)
}
if tok.Actor != "sso-viewer@test.local" {
t.Errorf("token actor = %q", tok.Actor)
}
}
func TestCallback_UnknownEmail_Rejected(t *testing.T) {
users, pool, signer := oidcTestSetup(t)
_, _ = pool.Exec(context.Background(), `DELETE FROM users WHERE email=$1`, "ghost@test.local")
h := NewOIDCHandler(nil, &mockAuth{claims: &oidcsvc.Claims{
Email: "ghost@test.local", EmailVerified: true, Subject: "x", Nonce: "N",
}}, users, signer, nil)
rec := runCallback(t, h, oidcFlow{State: "S", Nonce: "N"}, "S", "code")
if !strings.Contains(rec.Header().Get("Location"), "sso_error=no_account") {
t.Fatalf("expected sso_error=no_account, got %q", rec.Header().Get("Location"))
}
if sessionCookie(rec) != "" {
t.Fatal("no session cookie expected for unknown user")
}
}
func TestCallback_InactiveUser_Rejected(t *testing.T) {
users, pool, signer := oidcTestSetup(t)
seedUser(t, users, pool, "sso-disabled@test.local", "admin", false)
h := NewOIDCHandler(nil, &mockAuth{claims: &oidcsvc.Claims{
Email: "sso-disabled@test.local", EmailVerified: true, Subject: "x", Nonce: "N",
}}, users, signer, nil)
rec := runCallback(t, h, oidcFlow{State: "S", Nonce: "N"}, "S", "code")
if !strings.Contains(rec.Header().Get("Location"), "sso_error=disabled") {
t.Fatalf("expected sso_error=disabled, got %q", rec.Header().Get("Location"))
}
if sessionCookie(rec) != "" {
t.Fatal("no session cookie expected for inactive user")
}
}
func TestCallback_EmailUnverified_Rejected(t *testing.T) {
users, pool, signer := oidcTestSetup(t)
seedUser(t, users, pool, "sso-unverified@test.local", "admin", true)
h := NewOIDCHandler(nil, &mockAuth{claims: &oidcsvc.Claims{
Email: "sso-unverified@test.local", EmailVerified: false, Subject: "x", Nonce: "N",
}}, users, signer, nil)
rec := runCallback(t, h, oidcFlow{State: "S", Nonce: "N"}, "S", "code")
if !strings.Contains(rec.Header().Get("Location"), "sso_error=unverified") {
t.Fatalf("expected sso_error=unverified, got %q", rec.Header().Get("Location"))
}
if sessionCookie(rec) != "" {
t.Fatal("no session cookie expected for unverified email")
}
}
func TestCallback_NonceMismatch_Rejected(t *testing.T) {
users, pool, signer := oidcTestSetup(t)
seedUser(t, users, pool, "sso-nonce@test.local", "admin", true)
h := NewOIDCHandler(nil, &mockAuth{claims: &oidcsvc.Claims{
Email: "sso-nonce@test.local", EmailVerified: true, Subject: "x", Nonce: "WRONG",
}}, users, signer, nil)
rec := runCallback(t, h, oidcFlow{State: "S", Nonce: "N"}, "S", "code")
if !strings.Contains(rec.Header().Get("Location"), "sso_error=nonce") {
t.Fatalf("expected sso_error=nonce, got %q", rec.Header().Get("Location"))
}
}
func TestCallback_StateMismatch_Rejected(t *testing.T) {
users, _, signer := oidcTestSetup(t)
h := NewOIDCHandler(nil, &mockAuth{claims: &oidcsvc.Claims{}}, users, signer, nil)
rec := runCallback(t, h, oidcFlow{State: "S", Nonce: "N"}, "WRONG", "code")
if !strings.Contains(rec.Header().Get("Location"), "sso_error=state") {
t.Fatalf("expected sso_error=state, got %q", rec.Header().Get("Location"))
}
}

359
internal/handlers/radius.go Normal file
View File

@@ -0,0 +1,359 @@
package handlers
import (
"context"
"errors"
"log/slog"
"net"
"regexp"
"strings"
"github.com/gin-gonic/gin"
"git.netcell-it.de/projekte/edgeguard-native/internal/handlers/response"
"git.netcell-it.de/projekte/edgeguard-native/internal/models"
"git.netcell-it.de/projekte/edgeguard-native/internal/services/audit"
radiussvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/radius"
)
// RADIUSHandler exposes /api/v1/radius/{settings,clients,users} for the
// FreeRADIUS server (files-based PAP/CHAP).
type RADIUSHandler struct {
Repo *radiussvc.Repo
Audit *audit.Repo
NodeID string
Reloader func(ctx context.Context) error
}
func NewRADIUSHandler(repo *radiussvc.Repo, a *audit.Repo, nodeID string, reloader func(context.Context) error) *RADIUSHandler {
return &RADIUSHandler{Repo: repo, Audit: a, NodeID: nodeID, Reloader: reloader}
}
func (h *RADIUSHandler) reload(ctx context.Context, op string) {
if h.Reloader == nil {
return
}
if err := h.Reloader(ctx); err != nil {
slog.Warn("freeradius: reload after mutation failed", "op", op, "error", err)
}
}
func (h *RADIUSHandler) Register(rg *gin.RouterGroup) {
g := rg.Group("/radius")
g.GET("/settings", h.GetSettings)
g.PUT("/settings", h.UpdateSettings)
c := g.Group("/clients")
c.GET("", h.ListClients)
c.POST("", h.CreateClient)
c.GET("/:id", h.GetClient)
c.PUT("/:id", h.UpdateClient)
c.DELETE("/:id", h.DeleteClient)
u := g.Group("/users")
u.GET("", h.ListUsers)
u.POST("", h.CreateUser)
u.GET("/:id", h.GetUser)
u.PUT("/:id", h.UpdateUser)
u.DELETE("/:id", h.DeleteUser)
}
var validClientName = regexp.MustCompile(`^[A-Za-z0-9_.-]+$`)
// ── Settings ─────────────────────────────────────────────────────────
func (h *RADIUSHandler) GetSettings(c *gin.Context) {
s, err := h.Repo.GetSettings(c.Request.Context())
if err != nil {
response.Internal(c, err)
return
}
response.OK(c, s)
}
func (h *RADIUSHandler) UpdateSettings(c *gin.Context) {
var req models.RADIUSSettings
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, err)
return
}
if err := validateIPList(req.ListenAddresses); err != nil {
response.BadRequest(c, err)
return
}
out, err := h.Repo.UpdateSettings(c.Request.Context(), req)
if err != nil {
response.Internal(c, err)
return
}
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "radius.settings.update", "",
gin.H{"enabled": out.Enabled}, h.NodeID)
response.OK(c, out)
h.reload(c.Request.Context(), "settings.update")
}
// ── Clients ──────────────────────────────────────────────────────────
type clientView struct {
models.RADIUSClient
SecretConfigured bool `json:"secret_configured"`
}
func clientToView(c models.RADIUSClient) clientView {
return clientView{RADIUSClient: c, SecretConfigured: len(c.SecretEnc) > 0}
}
func (h *RADIUSHandler) ListClients(c *gin.Context) {
list, err := h.Repo.ListClients(c.Request.Context())
if err != nil {
response.Internal(c, err)
return
}
out := make([]clientView, 0, len(list))
for _, cl := range list {
out = append(out, clientToView(cl))
}
response.OK(c, gin.H{"clients": out})
}
func (h *RADIUSHandler) GetClient(c *gin.Context) {
id, ok := parseID(c)
if !ok {
return
}
cl, err := h.Repo.GetClient(c.Request.Context(), id)
if err != nil {
h.clientErr(c, err)
return
}
response.OK(c, clientToView(*cl))
}
type clientBody struct {
Name string `json:"name"`
IPAddr string `json:"ipaddr"`
Secret *string `json:"secret"` // create: required; update: nil=unchanged
Active bool `json:"active"`
Description string `json:"description"`
}
func (b *clientBody) validate(creating bool) error {
b.Name = strings.TrimSpace(b.Name)
b.IPAddr = strings.TrimSpace(b.IPAddr)
if !validClientName.MatchString(b.Name) {
return errors.New("name darf nur Buchstaben/Ziffern/._- enthalten")
}
if net.ParseIP(b.IPAddr) == nil {
if _, _, err := net.ParseCIDR(b.IPAddr); err != nil {
return errors.New("ipaddr ist keine gültige IP/CIDR: " + b.IPAddr)
}
}
if creating && b.Secret == nil {
return errors.New("secret ist erforderlich")
}
if b.Secret != nil {
if len(*b.Secret) < 6 {
return errors.New("secret muss mind. 6 Zeichen haben (leer löscht es nicht)")
}
if strings.ContainsAny(*b.Secret, "\r\n") {
return errors.New("secret darf keine Zeilenumbrüche enthalten")
}
}
return nil
}
func (h *RADIUSHandler) CreateClient(c *gin.Context) {
var b clientBody
if err := c.ShouldBindJSON(&b); err != nil {
response.BadRequest(c, err)
return
}
if err := b.validate(true); err != nil {
response.BadRequest(c, err)
return
}
out, err := h.Repo.CreateClient(c.Request.Context(), b.Name, b.IPAddr, *b.Secret, b.Active, b.Description)
if err != nil {
response.Internal(c, err)
return
}
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "radius.client.create", out.Name, gin.H{"ipaddr": out.IPAddr}, h.NodeID)
response.Created(c, clientToView(*out))
h.reload(c.Request.Context(), "client.create")
}
func (h *RADIUSHandler) UpdateClient(c *gin.Context) {
id, ok := parseID(c)
if !ok {
return
}
var b clientBody
if err := c.ShouldBindJSON(&b); err != nil {
response.BadRequest(c, err)
return
}
if err := b.validate(false); err != nil {
response.BadRequest(c, err)
return
}
out, err := h.Repo.UpdateClient(c.Request.Context(), id, b.Name, b.IPAddr, b.Secret, b.Active, b.Description)
if err != nil {
h.clientErr(c, err)
return
}
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "radius.client.update", out.Name, gin.H{"ipaddr": out.IPAddr}, h.NodeID)
response.OK(c, clientToView(*out))
h.reload(c.Request.Context(), "client.update")
}
func (h *RADIUSHandler) DeleteClient(c *gin.Context) {
id, ok := parseID(c)
if !ok {
return
}
if err := h.Repo.DeleteClient(c.Request.Context(), id); err != nil {
h.clientErr(c, err)
return
}
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "radius.client.delete", "", gin.H{"id": id}, h.NodeID)
response.OK(c, gin.H{"ok": true})
h.reload(c.Request.Context(), "client.delete")
}
// ── Users ────────────────────────────────────────────────────────────
type userView struct {
models.RADIUSUser
PasswordConfigured bool `json:"password_configured"`
}
func userToView(u models.RADIUSUser) userView {
return userView{RADIUSUser: u, PasswordConfigured: len(u.PasswordEnc) > 0}
}
func (h *RADIUSHandler) ListUsers(c *gin.Context) {
list, err := h.Repo.ListUsers(c.Request.Context())
if err != nil {
response.Internal(c, err)
return
}
out := make([]userView, 0, len(list))
for _, u := range list {
out = append(out, userToView(u))
}
response.OK(c, gin.H{"users": out})
}
func (h *RADIUSHandler) GetUser(c *gin.Context) {
id, ok := parseID(c)
if !ok {
return
}
u, err := h.Repo.GetUser(c.Request.Context(), id)
if err != nil {
h.userErr(c, err)
return
}
response.OK(c, userToView(*u))
}
type userBody struct {
Username string `json:"username"`
Password *string `json:"password"`
Active bool `json:"active"`
}
func (b *userBody) validate(creating bool) error {
b.Username = strings.TrimSpace(b.Username)
if b.Username == "" || strings.ContainsAny(b.Username, "\r\n") {
return errors.New("username ist erforderlich (ohne Zeilenumbrüche)")
}
if creating && (b.Password == nil || *b.Password == "") {
return errors.New("password ist erforderlich")
}
if b.Password != nil {
if *b.Password == "" {
return errors.New("password darf nicht leer sein (löscht es nicht)")
}
if strings.ContainsAny(*b.Password, "\r\n") {
return errors.New("password darf keine Zeilenumbrüche enthalten")
}
}
return nil
}
func (h *RADIUSHandler) CreateUser(c *gin.Context) {
var b userBody
if err := c.ShouldBindJSON(&b); err != nil {
response.BadRequest(c, err)
return
}
if err := b.validate(true); err != nil {
response.BadRequest(c, err)
return
}
out, err := h.Repo.CreateUser(c.Request.Context(), b.Username, *b.Password, b.Active)
if err != nil {
response.Internal(c, err)
return
}
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "radius.user.create", out.Username, nil, h.NodeID)
response.Created(c, userToView(*out))
h.reload(c.Request.Context(), "user.create")
}
func (h *RADIUSHandler) UpdateUser(c *gin.Context) {
id, ok := parseID(c)
if !ok {
return
}
var b userBody
if err := c.ShouldBindJSON(&b); err != nil {
response.BadRequest(c, err)
return
}
if err := b.validate(false); err != nil {
response.BadRequest(c, err)
return
}
out, err := h.Repo.UpdateUser(c.Request.Context(), id, b.Username, b.Password, b.Active)
if err != nil {
h.userErr(c, err)
return
}
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "radius.user.update", out.Username, nil, h.NodeID)
response.OK(c, userToView(*out))
h.reload(c.Request.Context(), "user.update")
}
func (h *RADIUSHandler) DeleteUser(c *gin.Context) {
id, ok := parseID(c)
if !ok {
return
}
if err := h.Repo.DeleteUser(c.Request.Context(), id); err != nil {
h.userErr(c, err)
return
}
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "radius.user.delete", "", gin.H{"id": id}, h.NodeID)
response.OK(c, gin.H{"ok": true})
h.reload(c.Request.Context(), "user.delete")
}
// ── error mapping ────────────────────────────────────────────────────
func (h *RADIUSHandler) clientErr(c *gin.Context, err error) {
if errors.Is(err, radiussvc.ErrClientNotFound) {
response.NotFound(c, err)
return
}
response.Internal(c, err)
}
func (h *RADIUSHandler) userErr(c *gin.Context, err error) {
if errors.Is(err, radiussvc.ErrUserNotFound) {
response.NotFound(c, err)
return
}
response.Internal(c, err)
}

View File

@@ -194,6 +194,8 @@ var servicesToCheck = []struct{ Label, Unit string }{
{"unbound", "unbound"},
{"chrony", "chrony"},
{"squid", "squid"},
{"kea-dhcp4", "kea-dhcp4-server"},
{"freeradius", "freeradius"},
{"postgresql", "postgresql"},
{"crowdsec", "crowdsec"},
{"crowdsec-firewall-bouncer", "crowdsec-firewall-bouncer"},

View File

@@ -4,8 +4,11 @@ import (
"context"
"errors"
"log/slog"
"net"
"net/http"
"regexp"
"strconv"
"strings"
"github.com/gin-gonic/gin"
@@ -15,6 +18,10 @@ import (
wafsvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/waf"
)
// wafRuleIDRe erlaubt nur einzelne CRS-Rule-IDs oder Ranges ("942100" /
// "942100-942999") als Exclusion — verhindert SecLang-Direktiven-Injection.
var wafRuleIDRe = regexp.MustCompile(`^[0-9]{1,9}(-[0-9]{1,9})?$`)
// WafHandler exposes the per-domain WAF configuration REST API:
//
// GET /waf/configs — list all configs (one per domain)
@@ -110,6 +117,27 @@ func (h *WafHandler) Upsert(c *gin.Context) {
if body.ExclusionNotes == nil {
body.ExclusionNotes = map[string]string{}
}
// Exclusions müssen reine Rule-IDs/Ranges sein (sonst Direktiven-Injection
// in die SecLang-Config via Newline).
for _, ex := range body.RuleExclusions {
if !wafRuleIDRe.MatchString(strings.TrimSpace(ex)) {
response.BadRequest(c, errors.New("ungültige Rule-Exclusion (nur IDs/Ranges erlaubt): "+ex))
return
}
}
// Trusted-Proxies müssen gültige IPs/CIDRs sein.
for _, p := range body.TrustedProxies {
p = strings.TrimSpace(p)
if p == "" {
continue
}
if net.ParseIP(p) == nil {
if _, _, err := net.ParseCIDR(p); err != nil {
response.BadRequest(c, errors.New("ungültiger Trusted-Proxy (IP/CIDR): "+p))
return
}
}
}
cfg := models.WafConfig{
DomainID: domainID,
Enabled: body.Enabled,

235
internal/kea/kea.go Normal file
View File

@@ -0,0 +1,235 @@
// Package kea renders the Kea DHCPv4 server config from the dhcp_*
// tables and manages the kea-dhcp4-server service lifecycle.
//
// The config is built as a Go struct and json-marshalled (NOT a text
// template) so the output is always syntactically valid JSON. Managed
// at /etc/edgeguard/kea/kea-dhcp4.conf (edgeguard-owned); postinst
// symlinks /etc/kea/kea-dhcp4.conf to it.
//
// Safety: the service runs ONLY when dhcp_settings.enabled is true on
// THIS node (a DHCP server is network-sensitive; default off). enabled
// → enable + restart; disabled → disable + stop.
package kea
import (
"context"
"encoding/json"
"fmt"
"os"
"os/exec"
"strings"
"github.com/jackc/pgx/v5/pgxpool"
"git.netcell-it.de/projekte/edgeguard-native/internal/configgen"
dhcpsvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/dhcp"
)
const (
ConfPath = configgen.EtcEdgeguard + "/kea/kea-dhcp4.conf"
serviceName = "kea-dhcp4-server"
leaseFile = "/var/lib/kea/kea-leases4.csv"
keaBinary = "/usr/sbin/kea-dhcp4"
)
type Generator struct {
Pool *pgxpool.Pool
Repo *dhcpsvc.Repo
SkipReload bool
}
func New(pool *pgxpool.Pool) *Generator {
return &Generator{Pool: pool, Repo: dhcpsvc.New(pool)}
}
func (g *Generator) Name() string { return "kea" }
// ── Kea config JSON shape ────────────────────────────────────────────
type keaConfig struct {
Dhcp4 dhcp4 `json:"Dhcp4"`
}
type dhcp4 struct {
InterfacesConfig ifcfg `json:"interfaces-config"`
LeaseDatabase leaseDB `json:"lease-database"`
ValidLifetime int `json:"valid-lifetime"`
MaxValidLifetime int `json:"max-valid-lifetime"`
OptionData []optionData `json:"option-data,omitempty"`
Subnet4 []subnet4 `json:"subnet4"`
Loggers []logger `json:"loggers"`
}
type ifcfg struct {
Interfaces []string `json:"interfaces"`
}
type leaseDB struct {
Type string `json:"type"`
Persist bool `json:"persist"`
Name string `json:"name"`
}
type optionData struct {
Name string `json:"name"`
Data string `json:"data"`
}
type subnet4 struct {
ID int64 `json:"id"`
Subnet string `json:"subnet"`
Pools []pool `json:"pools,omitempty"`
OptionData []optionData `json:"option-data,omitempty"`
Reservations []reservation `json:"reservations,omitempty"`
}
type pool struct {
Pool string `json:"pool"`
}
type reservation struct {
HWAddress string `json:"hw-address"`
IPAddress string `json:"ip-address"`
Hostname string `json:"hostname,omitempty"`
}
type logger struct {
Name string `json:"name"`
Severity string `json:"severity"`
OutputOptions []outOpt `json:"output_options"`
}
type outOpt struct {
Output string `json:"output"`
}
// buildConfig assembliert die Kea-Config aus dem DB-State.
func (g *Generator) buildConfig(ctx context.Context) (*keaConfig, *bool, error) {
settings, err := g.Repo.GetSettings(ctx)
if err != nil {
return nil, nil, fmt.Errorf("get dhcp settings: %w", err)
}
subnets, err := g.Repo.ListSubnets(ctx)
if err != nil {
return nil, nil, fmt.Errorf("list subnets: %w", err)
}
resv, err := g.Repo.ListAllReservations(ctx)
if err != nil {
return nil, nil, fmt.Errorf("list reservations: %w", err)
}
bySubnet := map[int64][]reservation{}
for _, r := range resv {
if !r.Active {
continue
}
bySubnet[r.SubnetID] = append(bySubnet[r.SubnetID], reservation{
HWAddress: r.MACAddress, IPAddress: r.IPAddress, Hostname: r.Hostname,
})
}
ifaceSet := map[string]bool{}
ifaces := []string{} // nie nil → JSON "[]" statt "null" (Kea lehnt null ab)
var sn4 []subnet4
for _, s := range subnets {
if !s.Active {
continue
}
if !ifaceSet[s.InterfaceName] {
ifaceSet[s.InterfaceName] = true
ifaces = append(ifaces, s.InterfaceName)
}
sub := subnet4{ID: s.ID, Subnet: s.SubnetCIDR}
if s.PoolStart != "" && s.PoolEnd != "" {
sub.Pools = []pool{{Pool: s.PoolStart + " - " + s.PoolEnd}}
}
if s.Gateway != "" {
sub.OptionData = append(sub.OptionData, optionData{Name: "routers", Data: s.Gateway})
}
dns := s.DNSServers
if dns == "" {
dns = settings.DNSServers
}
if dns != "" {
sub.OptionData = append(sub.OptionData, optionData{Name: "domain-name-servers", Data: normalizeCSV(dns)})
}
sub.Reservations = bySubnet[s.ID]
sn4 = append(sn4, sub)
}
d := dhcp4{
InterfacesConfig: ifcfg{Interfaces: ifaces},
LeaseDatabase: leaseDB{Type: "memfile", Persist: true, Name: leaseFile},
ValidLifetime: settings.DefaultLease,
MaxValidLifetime: settings.MaxLease,
Subnet4: sn4,
Loggers: []logger{{
Name: "kea-dhcp4", Severity: "INFO",
OutputOptions: []outOpt{{Output: "stdout"}},
}},
}
if settings.DNSServers != "" {
d.OptionData = append(d.OptionData, optionData{Name: "domain-name-servers", Data: normalizeCSV(settings.DNSServers)})
}
if settings.DomainName != "" {
d.OptionData = append(d.OptionData, optionData{Name: "domain-name", Data: settings.DomainName})
}
if d.Subnet4 == nil {
d.Subnet4 = []subnet4{}
}
return &keaConfig{Dhcp4: d}, &settings.Enabled, nil
}
func (g *Generator) RenderToString(ctx context.Context) (string, error) {
cfg, _, err := g.buildConfig(ctx)
if err != nil {
return "", err
}
b, err := json.MarshalIndent(cfg, "", " ")
if err != nil {
return "", err
}
return string(b) + "\n", nil
}
func (g *Generator) Render(ctx context.Context) error {
cfg, enabled, err := g.buildConfig(ctx)
if err != nil {
return err
}
// Default-off / disabled: Service stoppen + disablen, nichts weiter.
if enabled == nil || !*enabled {
if g.SkipReload {
return nil
}
_ = configgen.DisableService(serviceName)
_ = configgen.StopService(serviceName)
return nil
}
b, err := json.MarshalIndent(cfg, "", " ")
if err != nil {
return err
}
if err := configgen.AtomicWrite(ConfPath, append(b, '\n'), 0o644); err != nil {
return fmt.Errorf("write kea config: %w", err)
}
if g.SkipReload {
return nil
}
// Best-effort Config-Test (verhindert Restart mit kaputter Semantik).
if _, statErr := os.Stat(keaBinary); statErr == nil {
if out, terr := exec.Command(keaBinary, "-t", ConfPath).CombinedOutput(); terr != nil {
return fmt.Errorf("kea-dhcp4 -t rejected config: %w (output: %s)", terr, strings.TrimSpace(string(out)))
}
}
if err := configgen.EnableService(serviceName); err != nil {
return err
}
return configgen.RestartService(serviceName)
}
// normalizeCSV trimmt Whitespace um Komma-getrennte Werte (Kea will
// "a,b,c" ohne Leerzeichen-Toleranz-Probleme).
func normalizeCSV(s string) string {
parts := strings.Split(s, ",")
out := make([]string, 0, len(parts))
for _, p := range parts {
if t := strings.TrimSpace(p); t != "" {
out = append(out, t)
}
}
return strings.Join(out, ",")
}

89
internal/kea/kea_test.go Normal file
View File

@@ -0,0 +1,89 @@
package kea
import (
"context"
"encoding/json"
"os"
"os/exec"
"strings"
"testing"
"time"
"git.netcell-it.de/projekte/edgeguard-native/internal/database"
)
// Guarded integration test: set EG_FWTEST_DSN (sonst skip).
func TestRender_DHCPConfig(t *testing.T) {
dsn := os.Getenv("EG_FWTEST_DSN")
if dsn == "" {
t.Skip("set EG_FWTEST_DSN to run the kea renderer test")
}
ctx := context.Background()
var mErr error
for i := 0; i < 3; i++ {
if mErr = database.Migrate(ctx, dsn); mErr == nil {
break
}
time.Sleep(700 * time.Millisecond)
}
if mErr != nil {
t.Fatalf("migrate: %v", mErr)
}
pool, err := database.Open(ctx, dsn)
if err != nil {
t.Fatalf("open: %v", err)
}
defer pool.Close()
for _, q := range []string{
`DELETE FROM dhcp_reservations`,
`DELETE FROM dhcp_subnets`,
`UPDATE dhcp_settings SET enabled=true, default_lease=3600, max_lease=7200, domain_name='lan', dns_servers='1.1.1.1, 8.8.8.8' WHERE id=1`,
} {
if _, err := pool.Exec(ctx, q); err != nil {
t.Fatalf("seed (%s): %v", q, err)
}
}
var subID int64
if err := pool.QueryRow(ctx, `
INSERT INTO dhcp_subnets (name, interface_name, subnet_cidr, pool_start, pool_end, gateway, dns_servers, active)
VALUES ('lan','eth1','10.0.0.0/24','10.0.0.100','10.0.0.200','10.0.0.1','',true) RETURNING id`).Scan(&subID); err != nil {
t.Fatalf("seed subnet: %v", err)
}
if _, err := pool.Exec(ctx, `
INSERT INTO dhcp_reservations (subnet_id, mac_address, ip_address, hostname, active)
VALUES ($1,'aa:bb:cc:dd:ee:ff','10.0.0.50','printer',true)`, subID); err != nil {
t.Fatalf("seed reservation: %v", err)
}
out, err := New(pool).RenderToString(ctx)
if err != nil {
t.Fatalf("render: %v", err)
}
if !json.Valid([]byte(out)) {
t.Fatalf("rendered config is not valid JSON:\n%s", out)
}
for _, want := range []string{
`"10.0.0.0/24"`,
`"10.0.0.100 - 10.0.0.200"`,
`"aa:bb:cc:dd:ee:ff"`,
`"routers"`,
`"eth1"`,
`"hw-address"`,
`"valid-lifetime": 3600`,
} {
if !strings.Contains(out, want) {
t.Errorf("rendered config missing %q\n----\n%s", want, out)
}
}
// Best-effort: echte Kea-Validierung, falls die Binary da ist.
if _, statErr := os.Stat(keaBinary); statErr == nil {
f, _ := os.CreateTemp(t.TempDir(), "kea-*.conf")
_, _ = f.WriteString(out)
f.Close()
if combined, err := exec.Command(keaBinary, "-t", f.Name()).CombinedOutput(); err != nil {
t.Fatalf("kea-dhcp4 -t rejected rendered config: %v\n%s", err, combined)
}
}
}

53
internal/models/dhcp.go Normal file
View File

@@ -0,0 +1,53 @@
package models
import "time"
// DHCPSettings ist die node-lokale Singleton-Konfiguration des Kea-DHCPv4-
// Servers (ob diese Node DHCP betreibt + globale Defaults).
type DHCPSettings struct {
ID int `gorm:"column:id;primaryKey" json:"id"`
Enabled bool `gorm:"column:enabled" json:"enabled"`
DefaultLease int `gorm:"column:default_lease" json:"default_lease"`
MaxLease int `gorm:"column:max_lease" json:"max_lease"`
DomainName string `gorm:"column:domain_name" json:"domain_name"`
DNSServers string `gorm:"column:dns_servers" json:"dns_servers"`
CreatedAt time.Time `gorm:"column:created_at" json:"created_at"`
UpdatedAt time.Time `gorm:"column:updated_at" json:"updated_at"`
}
func (DHCPSettings) TableName() string { return "dhcp_settings" }
// DHCPSubnet ist ein vom DHCP-Server bedientes Subnetz (an ein Interface
// per NAME gebunden — cluster-sicher, da interface_id node-lokal wäre).
type DHCPSubnet struct {
ID int64 `gorm:"column:id;primaryKey" json:"id"`
Name string `gorm:"column:name" json:"name"`
InterfaceName string `gorm:"column:interface_name" json:"interface_name"`
SubnetCIDR string `gorm:"column:subnet_cidr" json:"subnet_cidr"`
PoolStart string `gorm:"column:pool_start" json:"pool_start"`
PoolEnd string `gorm:"column:pool_end" json:"pool_end"`
Gateway string `gorm:"column:gateway" json:"gateway"`
DNSServers string `gorm:"column:dns_servers" json:"dns_servers"`
LeaseTime *int `gorm:"column:lease_time" json:"lease_time,omitempty"`
Active bool `gorm:"column:active" json:"active"`
Description string `gorm:"column:description" json:"description"`
CreatedAt time.Time `gorm:"column:created_at" json:"created_at"`
UpdatedAt time.Time `gorm:"column:updated_at" json:"updated_at"`
}
func (DHCPSubnet) TableName() string { return "dhcp_subnets" }
// DHCPReservation ist eine statische MAC→IP-Zuordnung innerhalb eines Subnets.
type DHCPReservation struct {
ID int64 `gorm:"column:id;primaryKey" json:"id"`
SubnetID int64 `gorm:"column:subnet_id" json:"subnet_id"`
Name string `gorm:"column:name" json:"name"`
MACAddress string `gorm:"column:mac_address" json:"mac_address"`
IPAddress string `gorm:"column:ip_address" json:"ip_address"`
Hostname string `gorm:"column:hostname" json:"hostname"`
Active bool `gorm:"column:active" json:"active"`
CreatedAt time.Time `gorm:"column:created_at" json:"created_at"`
UpdatedAt time.Time `gorm:"column:updated_at" json:"updated_at"`
}
func (DHCPReservation) TableName() string { return "dhcp_reservations" }

View File

@@ -0,0 +1,21 @@
package models
import "time"
// OIDCSettings ist die Singleton-Konfiguration für OIDC/Keycloak-SSO.
// ClientSecretEnc trägt den verschlüsselten Client-Secret (secrets.Box)
// und wird NIE serialisiert (json:"-").
type OIDCSettings struct {
ID int `gorm:"column:id;primaryKey" json:"id"`
Enabled bool `gorm:"column:enabled" json:"enabled"`
IssuerURL string `gorm:"column:issuer_url" json:"issuer_url"`
ClientID string `gorm:"column:client_id" json:"client_id"`
ClientSecretEnc []byte `gorm:"column:client_secret_enc" json:"-"`
Scopes string `gorm:"column:scopes" json:"scopes"`
EmailClaim string `gorm:"column:email_claim" json:"email_claim"`
ButtonLabel string `gorm:"column:button_label" json:"button_label"`
CreatedAt time.Time `gorm:"column:created_at" json:"created_at"`
UpdatedAt time.Time `gorm:"column:updated_at" json:"updated_at"`
}
func (OIDCSettings) TableName() string { return "oidc_settings" }

43
internal/models/radius.go Normal file
View File

@@ -0,0 +1,43 @@
package models
import "time"
// RADIUSSettings ist die node-lokale Singleton-Konfiguration des
// FreeRADIUS-Servers (ob diese Node RADIUS betreibt + Listen-Adressen).
type RADIUSSettings struct {
ID int `gorm:"column:id;primaryKey" json:"id"`
Enabled bool `gorm:"column:enabled" json:"enabled"`
ListenAddresses string `gorm:"column:listen_addresses" json:"listen_addresses"`
CreatedAt time.Time `gorm:"column:created_at" json:"created_at"`
UpdatedAt time.Time `gorm:"column:updated_at" json:"updated_at"`
}
func (RADIUSSettings) TableName() string { return "radius_settings" }
// RADIUSClient ist ein NAS-Client (IP/CIDR + Shared Secret). SecretEnc
// wird via secrets.Box verschlüsselt und nie serialisiert.
type RADIUSClient struct {
ID int64 `gorm:"column:id;primaryKey" json:"id"`
Name string `gorm:"column:name" json:"name"`
IPAddr string `gorm:"column:ipaddr" json:"ipaddr"`
SecretEnc []byte `gorm:"column:secret_enc" json:"-"`
Active bool `gorm:"column:active" json:"active"`
Description string `gorm:"column:description" json:"description"`
CreatedAt time.Time `gorm:"column:created_at" json:"created_at"`
UpdatedAt time.Time `gorm:"column:updated_at" json:"updated_at"`
}
func (RADIUSClient) TableName() string { return "radius_clients" }
// RADIUSUser ist ein PAP/CHAP-Benutzer. PasswordEnc wird via secrets.Box
// verschlüsselt und nie serialisiert.
type RADIUSUser struct {
ID int64 `gorm:"column:id;primaryKey" json:"id"`
Username string `gorm:"column:username" json:"username"`
PasswordEnc []byte `gorm:"column:password_enc" json:"-"`
Active bool `gorm:"column:active" json:"active"`
CreatedAt time.Time `gorm:"column:created_at" json:"created_at"`
UpdatedAt time.Time `gorm:"column:updated_at" json:"updated_at"`
}
func (RADIUSUser) TableName() string { return "radius_users" }

View File

@@ -128,7 +128,7 @@ func Join(req Request) error {
// synchronous on the primary side.
var autoRegErr error
for i := 0; i < 3; i++ {
if err := autoRegister(primary, tlsDir, req.CommonName, req.Version, req.NodeID, ""); err == nil {
if err := autoRegister(primary, tlsDir, req.CommonName, req.Version, req.NodeID, "", "peer"); err == nil {
autoRegErr = nil
break
} else {
@@ -222,13 +222,21 @@ func issueCert(primary, token, csr string, insecure bool) (caCert, peerCert stri
// goroutine so the primary's ha_nodes always reflects the secondary's actual
// config_hash (not the stale join-time value).
func PushSelfToPrimary(primaryURL, tlsDir, nodeID, fqdn, version, configHash string) error {
return PushSelfToPeer(primaryURL, tlsDir, nodeID, fqdn, version, configHash, "peer")
}
// PushSelfToPeer sendet die eigene Identität an einen beliebigen Peer (mTLS,
// /agent/cluster/peers). role bestimmt, mit welcher Rolle sich dieser Node
// beim Empfänger einträgt: ein Secondary pusht "peer" an den Primary, der
// Primary pusht "primary" an jeden Secondary (bidirektionaler Heartbeat).
func PushSelfToPeer(peerURL, tlsDir, nodeID, fqdn, version, configHash, role string) error {
if tlsDir == "" {
tlsDir = clustertls.DefaultDir
}
return autoRegister(primaryURL, tlsDir, fqdn, version, nodeID, configHash)
return autoRegister(peerURL, tlsDir, fqdn, version, nodeID, configHash, role)
}
func autoRegister(primary, tlsDir, commonName, version, nodeID, configHash string) error {
func autoRegister(primary, tlsDir, commonName, version, nodeID, configHash, role string) error {
u, err := url.Parse(primary)
if err != nil {
return err
@@ -241,6 +249,9 @@ func autoRegister(primary, tlsDir, commonName, version, nodeID, configHash strin
nodeID = strings.TrimSpace(string(raw))
}
hostname, _ := os.Hostname()
if role == "" {
role = "peer"
}
body, _ := json.Marshal(map[string]string{
"id": nodeID,
"name": hostname,
@@ -248,6 +259,7 @@ func autoRegister(primary, tlsDir, commonName, version, nodeID, configHash strin
"api_url": "https://" + commonName + ":3443",
"version": version,
"config_hash": configHash,
"role": role,
})
pair, err := tls.LoadX509KeyPair(tlsDir+"/peer.crt", tlsDir+"/peer.key")

View File

@@ -44,6 +44,7 @@ func Run(ctx context.Context, gens []configgen.Generator, only []string) ([]Resu
whitelist[n] = true
}
out := make([]Result, 0, len(gens))
var errs []error
for _, g := range gens {
if len(whitelist) > 0 && !whitelist[g.Name()] {
out = append(out, Result{Name: g.Name(), Skipped: true})
@@ -52,11 +53,14 @@ func Run(ctx context.Context, gens []configgen.Generator, only []string) ([]Resu
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)
// Weitermachen: die Generatoren sind unabhängig und reloaden
// inline (nft/Service-Reload sind atomar). Abbrechen würde die
// restlichen Dienste auf altem Stand lassen → halb angewandt.
// Stattdessen alle versuchen und Fehler gesammelt zurückgeben.
errs = append(errs, fmt.Errorf("%s: %w", g.Name(), err))
}
}
return out, nil
return out, errors.Join(errs...)
}
// Summarise turns the result slice into a human-readable multiline

View File

@@ -0,0 +1,238 @@
// Package dhcp provides CRUD against the dhcp_settings (singleton),
// dhcp_subnets, and dhcp_reservations tables. The Kea config renderer
// in internal/kea consumes these.
package dhcp
import (
"context"
"errors"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"git.netcell-it.de/projekte/edgeguard-native/internal/models"
)
var (
ErrSubnetNotFound = errors.New("dhcp subnet not found")
ErrReservationNotFound = errors.New("dhcp reservation not found")
)
type Repo struct {
Pool *pgxpool.Pool
}
func New(pool *pgxpool.Pool) *Repo { return &Repo{Pool: pool} }
// ── Settings (singleton id=1) ────────────────────────────────────────
func (r *Repo) GetSettings(ctx context.Context) (*models.DHCPSettings, error) {
var s models.DHCPSettings
if err := r.Pool.QueryRow(ctx, `
SELECT id, enabled, default_lease, max_lease, domain_name, dns_servers, created_at, updated_at
FROM dhcp_settings WHERE id=1`).Scan(
&s.ID, &s.Enabled, &s.DefaultLease, &s.MaxLease, &s.DomainName, &s.DNSServers,
&s.CreatedAt, &s.UpdatedAt,
); err != nil {
return nil, err
}
return &s, nil
}
func (r *Repo) UpdateSettings(ctx context.Context, s models.DHCPSettings) (*models.DHCPSettings, error) {
row := r.Pool.QueryRow(ctx, `
UPDATE dhcp_settings
SET enabled=$1, default_lease=$2, max_lease=$3, domain_name=$4, dns_servers=$5, updated_at=NOW()
WHERE id=1
RETURNING id, enabled, default_lease, max_lease, domain_name, dns_servers, created_at, updated_at`,
s.Enabled, s.DefaultLease, s.MaxLease, s.DomainName, s.DNSServers)
var out models.DHCPSettings
if err := row.Scan(&out.ID, &out.Enabled, &out.DefaultLease, &out.MaxLease,
&out.DomainName, &out.DNSServers, &out.CreatedAt, &out.UpdatedAt); err != nil {
return nil, err
}
return &out, nil
}
// ── Subnets ──────────────────────────────────────────────────────────
const subnetCols = `id, name, interface_name, subnet_cidr, pool_start, pool_end,
gateway, dns_servers, lease_time, active, description, created_at, updated_at`
func scanSubnet(row pgx.Row) (*models.DHCPSubnet, error) {
var s models.DHCPSubnet
if err := row.Scan(&s.ID, &s.Name, &s.InterfaceName, &s.SubnetCIDR, &s.PoolStart,
&s.PoolEnd, &s.Gateway, &s.DNSServers, &s.LeaseTime, &s.Active, &s.Description,
&s.CreatedAt, &s.UpdatedAt); err != nil {
return nil, err
}
return &s, nil
}
func (r *Repo) ListSubnets(ctx context.Context) ([]models.DHCPSubnet, error) {
rows, err := r.Pool.Query(ctx, `SELECT `+subnetCols+` FROM dhcp_subnets ORDER BY name ASC`)
if err != nil {
return nil, err
}
defer rows.Close()
out := make([]models.DHCPSubnet, 0, 8)
for rows.Next() {
s, err := scanSubnet(rows)
if err != nil {
return nil, err
}
out = append(out, *s)
}
return out, rows.Err()
}
func (r *Repo) GetSubnet(ctx context.Context, id int64) (*models.DHCPSubnet, error) {
s, err := scanSubnet(r.Pool.QueryRow(ctx, `SELECT `+subnetCols+` FROM dhcp_subnets WHERE id=$1`, id))
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrSubnetNotFound
}
return nil, err
}
return s, nil
}
func (r *Repo) CreateSubnet(ctx context.Context, s models.DHCPSubnet) (*models.DHCPSubnet, error) {
return scanSubnet(r.Pool.QueryRow(ctx, `
INSERT INTO dhcp_subnets (name, interface_name, subnet_cidr, pool_start, pool_end,
gateway, dns_servers, lease_time, active, description)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)
RETURNING `+subnetCols,
s.Name, s.InterfaceName, s.SubnetCIDR, s.PoolStart, s.PoolEnd,
s.Gateway, s.DNSServers, s.LeaseTime, s.Active, s.Description))
}
func (r *Repo) UpdateSubnet(ctx context.Context, id int64, s models.DHCPSubnet) (*models.DHCPSubnet, error) {
out, err := scanSubnet(r.Pool.QueryRow(ctx, `
UPDATE dhcp_subnets
SET name=$1, interface_name=$2, subnet_cidr=$3, pool_start=$4, pool_end=$5,
gateway=$6, dns_servers=$7, lease_time=$8, active=$9, description=$10, updated_at=NOW()
WHERE id=$11
RETURNING `+subnetCols,
s.Name, s.InterfaceName, s.SubnetCIDR, s.PoolStart, s.PoolEnd,
s.Gateway, s.DNSServers, s.LeaseTime, s.Active, s.Description, id))
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrSubnetNotFound
}
return nil, err
}
return out, nil
}
func (r *Repo) DeleteSubnet(ctx context.Context, id int64) error {
tag, err := r.Pool.Exec(ctx, `DELETE FROM dhcp_subnets WHERE id=$1`, id)
if err != nil {
return err
}
if tag.RowsAffected() == 0 {
return ErrSubnetNotFound
}
return nil
}
// ── Reservations ─────────────────────────────────────────────────────
const resvCols = `id, subnet_id, name, mac_address, ip_address, hostname, active, created_at, updated_at`
func scanResv(row pgx.Row) (*models.DHCPReservation, error) {
var r models.DHCPReservation
if err := row.Scan(&r.ID, &r.SubnetID, &r.Name, &r.MACAddress, &r.IPAddress,
&r.Hostname, &r.Active, &r.CreatedAt, &r.UpdatedAt); err != nil {
return nil, err
}
return &r, nil
}
func (r *Repo) ListAllReservations(ctx context.Context) ([]models.DHCPReservation, error) {
rows, err := r.Pool.Query(ctx, `SELECT `+resvCols+` FROM dhcp_reservations ORDER BY subnet_id, ip_address`)
if err != nil {
return nil, err
}
defer rows.Close()
out := make([]models.DHCPReservation, 0, 16)
for rows.Next() {
v, err := scanResv(rows)
if err != nil {
return nil, err
}
out = append(out, *v)
}
return out, rows.Err()
}
func (r *Repo) ListReservationsForSubnet(ctx context.Context, subnetID int64) ([]models.DHCPReservation, error) {
rows, err := r.Pool.Query(ctx, `SELECT `+resvCols+` FROM dhcp_reservations WHERE subnet_id=$1 ORDER BY ip_address`, subnetID)
if err != nil {
return nil, err
}
defer rows.Close()
out := make([]models.DHCPReservation, 0, 8)
for rows.Next() {
v, err := scanResv(rows)
if err != nil {
return nil, err
}
out = append(out, *v)
}
return out, rows.Err()
}
func (r *Repo) GetReservation(ctx context.Context, id int64) (*models.DHCPReservation, error) {
v, err := scanResv(r.Pool.QueryRow(ctx, `SELECT `+resvCols+` FROM dhcp_reservations WHERE id=$1`, id))
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrReservationNotFound
}
return nil, err
}
return v, nil
}
func (r *Repo) CreateReservation(ctx context.Context, v models.DHCPReservation) (*models.DHCPReservation, error) {
return scanResv(r.Pool.QueryRow(ctx, `
INSERT INTO dhcp_reservations (subnet_id, name, mac_address, ip_address, hostname, active)
VALUES ($1,$2,$3,$4,$5,$6)
RETURNING `+resvCols,
v.SubnetID, v.Name, v.MACAddress, v.IPAddress, v.Hostname, v.Active))
}
func (r *Repo) UpdateReservation(ctx context.Context, id int64, v models.DHCPReservation) (*models.DHCPReservation, error) {
out, err := scanResv(r.Pool.QueryRow(ctx, `
UPDATE dhcp_reservations
SET name=$1, mac_address=$2, ip_address=$3, hostname=$4, active=$5, updated_at=NOW()
WHERE id=$6
RETURNING `+resvCols,
v.Name, v.MACAddress, v.IPAddress, v.Hostname, v.Active, id))
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrReservationNotFound
}
return nil, err
}
return out, nil
}
func (r *Repo) DeleteReservation(ctx context.Context, id int64) error {
tag, err := r.Pool.Exec(ctx, `DELETE FROM dhcp_reservations WHERE id=$1`, id)
if err != nil {
return err
}
if tag.RowsAffected() == 0 {
return ErrReservationNotFound
}
return nil
}
// InterfaceExists prüft, ob ein Interface-Name in network_interfaces existiert
// (für die Validierung beim Anlegen/Ändern von Subnets).
func (r *Repo) InterfaceExists(ctx context.Context, name string) (bool, error) {
var exists bool
err := r.Pool.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM network_interfaces WHERE name=$1)`, name).Scan(&exists)
return exists, err
}

View File

@@ -0,0 +1,181 @@
package oidc
import (
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"strings"
"sync"
gooidc "github.com/coreos/go-oidc/v3/oidc"
"golang.org/x/oauth2"
)
// Claims sind die aus dem ID-Token extrahierten Felder, die der Login-
// Flow braucht. Bewusst minimal — Rolle kommt NIE aus dem Token.
type Claims struct {
Subject string
Email string
EmailVerified bool
Nonce string
}
// Authenticator ist der testbare Seam: Aufbau der Auth-URL und der
// Code-Exchange inkl. ID-Token-Verifikation + Claim-Extraktion. Der
// Handler hängt nur hieran, sodass Tests einen Fake injizieren können.
type Authenticator interface {
// AuthCodeURL baut die Redirect-URL zum IdP (state + nonce + PKCE-Challenge).
AuthCodeURL(ctx context.Context, redirectURI, state, nonce, pkceVerifier string) (string, error)
// Exchange tauscht den Code (PKCE), verifiziert das ID-Token und gibt
// die Claims zurück. Prüft Issuer/Audience/Signatur/Expiry.
Exchange(ctx context.Context, redirectURI, code, pkceVerifier string) (*Claims, error)
}
// Client implementiert Authenticator gegen einen echten OIDC-Provider.
// Provider+Verifier werden lazy aufgebaut und gecached; bei geänderten
// Settings (Fingerprint) neu aufgebaut.
type Client struct {
repo *Repo
mu sync.Mutex
cacheKey string
provider *gooidc.Provider
verifier *gooidc.IDTokenVerifier
}
func NewClient(repo *Repo) *Client { return &Client{repo: repo} }
// loaded baut (oder reused) Provider+Verifier aus den aktuellen Settings.
// Cache-Key = Fingerprint(issuer, client_id, scopes); Rebuild bei Änderung.
func (c *Client) loaded(ctx context.Context) (*gooidc.Provider, *gooidc.IDTokenVerifier, error) {
s, err := c.repo.Get(ctx)
if err != nil {
return nil, nil, err
}
if !s.Enabled {
return nil, nil, ErrDisabled
}
if strings.TrimSpace(s.IssuerURL) == "" || strings.TrimSpace(s.ClientID) == "" {
return nil, nil, fmt.Errorf("oidc: issuer_url and client_id required")
}
key := fingerprint(s.IssuerURL, s.ClientID, s.Scopes)
c.mu.Lock()
defer c.mu.Unlock()
if c.provider == nil || c.cacheKey != key {
prov, err := gooidc.NewProvider(ctx, s.IssuerURL)
if err != nil {
return nil, nil, fmt.Errorf("oidc: discovery: %w", err)
}
c.provider = prov
c.verifier = prov.Verifier(&gooidc.Config{ClientID: s.ClientID})
c.cacheKey = key
}
return c.provider, c.verifier, nil
}
func (c *Client) oauthConfig(prov *gooidc.Provider, clientID, secret, redirectURI, scopes string) oauth2.Config {
return oauth2.Config{
ClientID: clientID,
ClientSecret: secret,
Endpoint: prov.Endpoint(),
RedirectURL: redirectURI,
Scopes: splitScopes(scopes),
}
}
// AuthCodeURL implementiert Authenticator.
func (c *Client) AuthCodeURL(ctx context.Context, redirectURI, state, nonce, pkceVerifier string) (string, error) {
s, err := c.repo.Get(ctx)
if err != nil {
return "", err
}
prov, _, err := c.loaded(ctx)
if err != nil {
return "", err
}
secret, _ := c.repo.ClientSecret(ctx)
cfg := c.oauthConfig(prov, s.ClientID, secret, redirectURI, s.Scopes)
return cfg.AuthCodeURL(state,
gooidc.Nonce(nonce),
oauth2.S256ChallengeOption(pkceVerifier),
), nil
}
// Exchange implementiert Authenticator.
func (c *Client) Exchange(ctx context.Context, redirectURI, code, pkceVerifier string) (*Claims, error) {
s, err := c.repo.Get(ctx)
if err != nil {
return nil, err
}
prov, verifier, err := c.loaded(ctx)
if err != nil {
return nil, err
}
secret, _ := c.repo.ClientSecret(ctx)
cfg := c.oauthConfig(prov, s.ClientID, secret, redirectURI, s.Scopes)
tok, err := cfg.Exchange(ctx, code, oauth2.VerifierOption(pkceVerifier))
if err != nil {
return nil, fmt.Errorf("oidc: code exchange: %w", err)
}
rawID, ok := tok.Extra("id_token").(string)
if !ok || rawID == "" {
return nil, errors.New("oidc: no id_token in response")
}
idToken, err := verifier.Verify(ctx, rawID)
if err != nil {
return nil, fmt.Errorf("oidc: id_token verify: %w", err)
}
return extractClaims(idToken, s.EmailClaim)
}
// extractClaims liest E-Mail (via konfigurierbarem Claim), email_verified,
// sub und nonce aus dem verifizierten ID-Token.
func extractClaims(idToken *gooidc.IDToken, emailClaim string) (*Claims, error) {
var raw map[string]any
if err := idToken.Claims(&raw); err != nil {
return nil, fmt.Errorf("oidc: decode claims: %w", err)
}
if emailClaim == "" {
emailClaim = "email"
}
out := &Claims{Subject: idToken.Subject}
if v, ok := raw[emailClaim].(string); ok {
out.Email = strings.TrimSpace(strings.ToLower(v))
}
// email_verified kann bool oder "true"/"false" sein.
switch ev := raw["email_verified"].(type) {
case bool:
out.EmailVerified = ev
case string:
out.EmailVerified = ev == "true"
}
if n, ok := raw["nonce"].(string); ok {
out.Nonce = n
}
return out, nil
}
func splitScopes(s string) []string {
out := []string{}
for _, p := range strings.Fields(s) {
if p != "" {
out = append(out, p)
}
}
if len(out) == 0 {
out = []string{gooidc.ScopeOpenID, "email", "profile"}
}
return out
}
func fingerprint(parts ...string) string {
h := sha256.New()
for _, p := range parts {
h.Write([]byte(p))
h.Write([]byte{0})
}
return hex.EncodeToString(h.Sum(nil))
}

View File

@@ -0,0 +1,111 @@
// Package oidc kapselt die OIDC/Keycloak-SSO-Konfiguration (Singleton-
// Settings + verschlüsseltes Client-Secret) und einen lazy aufgebauten
// OIDC-Provider/Verifier. Login-Flow-State ist stateless (signiertes
// Cookie im Handler), daher hält dieses Paket keinen Request-State.
package oidc
import (
"context"
"errors"
"github.com/jackc/pgx/v5/pgxpool"
"git.netcell-it.de/projekte/edgeguard-native/internal/models"
"git.netcell-it.de/projekte/edgeguard-native/internal/services/secrets"
)
// ErrDisabled signalisiert, dass OIDC nicht aktiviert/konfiguriert ist.
var ErrDisabled = errors.New("oidc: not enabled")
// Repo liest/schreibt die oidc_settings-Singleton-Row und ver-/entschlüsselt
// das Client-Secret via secrets.Box.
type Repo struct {
pool *pgxpool.Pool
box *secrets.Box
}
func New(pool *pgxpool.Pool, box *secrets.Box) *Repo {
return &Repo{pool: pool, box: box}
}
// Get liefert die Settings (client_secret_enc als Bytes, NULL → nil).
func (r *Repo) Get(ctx context.Context) (*models.OIDCSettings, error) {
var s models.OIDCSettings
if err := r.pool.QueryRow(ctx, `
SELECT id, enabled, issuer_url, client_id, client_secret_enc,
scopes, email_claim, button_label, created_at, updated_at
FROM oidc_settings WHERE id = 1`).Scan(
&s.ID, &s.Enabled, &s.IssuerURL, &s.ClientID, &s.ClientSecretEnc,
&s.Scopes, &s.EmailClaim, &s.ButtonLabel, &s.CreatedAt, &s.UpdatedAt,
); err != nil {
return nil, err
}
return &s, nil
}
// ClientSecret entschlüsselt das gespeicherte Client-Secret ("" wenn keins).
func (r *Repo) ClientSecret(ctx context.Context) (string, error) {
s, err := r.Get(ctx)
if err != nil {
return "", err
}
if len(s.ClientSecretEnc) == 0 {
return "", nil
}
pt, err := r.box.Open(s.ClientSecretEnc)
if err != nil {
return "", err
}
return string(pt), nil
}
// HasSecret meldet, ob ein Client-Secret hinterlegt ist (für die Admin-UI,
// ohne das Secret selbst preiszugeben).
func (r *Repo) HasSecret(ctx context.Context) (bool, error) {
var present bool
err := r.pool.QueryRow(ctx,
`SELECT client_secret_enc IS NOT NULL FROM oidc_settings WHERE id = 1`).Scan(&present)
return present, err
}
// UpdateInput beschreibt eine Settings-Änderung. ClientSecret nutzt
// write-only-Semantik: nil = unverändert, "" = löschen, sonst neu sealen.
type UpdateInput struct {
Enabled bool
IssuerURL string
ClientID string
Scopes string
EmailClaim string
ButtonLabel string
ClientSecret *string
}
// Update schreibt die Settings. Das Secret wird nur angefasst, wenn
// ClientSecret != nil.
func (r *Repo) Update(ctx context.Context, in UpdateInput) error {
if in.ClientSecret == nil {
_, err := r.pool.Exec(ctx, `
UPDATE oidc_settings
SET enabled=$1, issuer_url=$2, client_id=$3, scopes=$4,
email_claim=$5, button_label=$6, updated_at=NOW()
WHERE id=1`,
in.Enabled, in.IssuerURL, in.ClientID, in.Scopes, in.EmailClaim, in.ButtonLabel)
return err
}
var enc []byte
if *in.ClientSecret != "" {
sealed, err := r.box.Seal([]byte(*in.ClientSecret))
if err != nil {
return err
}
enc = sealed
}
_, err := r.pool.Exec(ctx, `
UPDATE oidc_settings
SET enabled=$1, issuer_url=$2, client_id=$3, scopes=$4,
email_claim=$5, button_label=$6, client_secret_enc=$7, updated_at=NOW()
WHERE id=1`,
in.Enabled, in.IssuerURL, in.ClientID, in.Scopes, in.EmailClaim, in.ButtonLabel, enc)
return err
}

View File

@@ -0,0 +1,90 @@
package oidc
import (
"context"
"os"
"testing"
"time"
"git.netcell-it.de/projekte/edgeguard-native/internal/database"
"git.netcell-it.de/projekte/edgeguard-native/internal/services/secrets"
)
// migrateRetry umgeht die goose-Erst-Apply-Race, wenn mehrere guarded
// Test-Pakete dieselbe frische DB parallel migrieren.
func migrateRetry(ctx context.Context, dsn string) error {
var err error
for i := 0; i < 3; i++ {
if err = database.Migrate(ctx, dsn); err == nil {
return nil
}
time.Sleep(700 * time.Millisecond)
}
return err
}
// Guarded integration test: set EG_FWTEST_DSN (sonst skip).
func testRepo(t *testing.T) *Repo {
t.Helper()
dsn := os.Getenv("EG_FWTEST_DSN")
if dsn == "" {
t.Skip("set EG_FWTEST_DSN to run the oidc settings test")
}
ctx := context.Background()
if err := migrateRetry(ctx, dsn); err != nil {
t.Fatalf("migrate: %v", err)
}
pool, err := database.Open(ctx, dsn)
if err != nil {
t.Fatalf("open: %v", err)
}
t.Cleanup(pool.Close)
box := secrets.New(t.TempDir() + "/master_key")
// Settings auf einen sauberen Default zurücksetzen.
if _, err := pool.Exec(ctx, `UPDATE oidc_settings SET enabled=false, issuer_url='', client_id='', client_secret_enc=NULL WHERE id=1`); err != nil {
t.Fatalf("reset: %v", err)
}
return New(pool, box)
}
func TestSettings_SecretWriteOnly(t *testing.T) {
r := testRepo(t)
ctx := context.Background()
str := func(s string) *string { return &s }
// 1) Neues Secret setzen.
if err := r.Update(ctx, UpdateInput{Enabled: true, IssuerURL: "https://idp.example/realms/x", ClientID: "eg", ClientSecret: str("s3cr3t")}); err != nil {
t.Fatal(err)
}
if has, _ := r.HasSecret(ctx); !has {
t.Fatal("HasSecret should be true after setting a secret")
}
got, err := r.ClientSecret(ctx)
if err != nil || got != "s3cr3t" {
t.Fatalf("ClientSecret = %q, %v; want s3cr3t", got, err)
}
// 2) Update mit nil → Secret bleibt unverändert.
if err := r.Update(ctx, UpdateInput{Enabled: true, IssuerURL: "https://idp.example/realms/x", ClientID: "eg2", ClientSecret: nil}); err != nil {
t.Fatal(err)
}
got, _ = r.ClientSecret(ctx)
if got != "s3cr3t" {
t.Fatalf("secret should be preserved on nil update, got %q", got)
}
if s, _ := r.Get(ctx); s.ClientID != "eg2" {
t.Fatalf("client_id should update to eg2, got %q", s.ClientID)
}
// 3) Update mit "" → Secret gelöscht.
if err := r.Update(ctx, UpdateInput{Enabled: false, IssuerURL: "", ClientID: "", ClientSecret: str("")}); err != nil {
t.Fatal(err)
}
if has, _ := r.HasSecret(ctx); has {
t.Fatal("HasSecret should be false after clearing the secret")
}
got, _ = r.ClientSecret(ctx)
if got != "" {
t.Fatalf("secret should be empty after clear, got %q", got)
}
}

View File

@@ -0,0 +1,239 @@
// Package radius provides CRUD against radius_settings (singleton),
// radius_clients and radius_users. Shared secrets / user passwords are
// sealed at rest via secrets.Box. The FreeRADIUS renderer in
// internal/freeradius consumes these.
package radius
import (
"context"
"errors"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"git.netcell-it.de/projekte/edgeguard-native/internal/models"
"git.netcell-it.de/projekte/edgeguard-native/internal/services/secrets"
)
var (
ErrClientNotFound = errors.New("radius client not found")
ErrUserNotFound = errors.New("radius user not found")
)
type Repo struct {
Pool *pgxpool.Pool
Box *secrets.Box
}
func New(pool *pgxpool.Pool, box *secrets.Box) *Repo { return &Repo{Pool: pool, Box: box} }
// ── Settings ─────────────────────────────────────────────────────────
func (r *Repo) GetSettings(ctx context.Context) (*models.RADIUSSettings, error) {
var s models.RADIUSSettings
if err := r.Pool.QueryRow(ctx, `
SELECT id, enabled, listen_addresses, created_at, updated_at FROM radius_settings WHERE id=1`).Scan(
&s.ID, &s.Enabled, &s.ListenAddresses, &s.CreatedAt, &s.UpdatedAt); err != nil {
return nil, err
}
return &s, nil
}
func (r *Repo) UpdateSettings(ctx context.Context, s models.RADIUSSettings) (*models.RADIUSSettings, error) {
var out models.RADIUSSettings
if err := r.Pool.QueryRow(ctx, `
UPDATE radius_settings SET enabled=$1, listen_addresses=$2, updated_at=NOW() WHERE id=1
RETURNING id, enabled, listen_addresses, created_at, updated_at`,
s.Enabled, s.ListenAddresses).Scan(
&out.ID, &out.Enabled, &out.ListenAddresses, &out.CreatedAt, &out.UpdatedAt); err != nil {
return nil, err
}
return &out, nil
}
// ── Clients ──────────────────────────────────────────────────────────
const clientCols = `id, name, ipaddr, secret_enc, active, description, created_at, updated_at`
func scanClient(row pgx.Row) (*models.RADIUSClient, error) {
var c models.RADIUSClient
if err := row.Scan(&c.ID, &c.Name, &c.IPAddr, &c.SecretEnc, &c.Active, &c.Description,
&c.CreatedAt, &c.UpdatedAt); err != nil {
return nil, err
}
return &c, nil
}
func (r *Repo) ListClients(ctx context.Context) ([]models.RADIUSClient, error) {
rows, err := r.Pool.Query(ctx, `SELECT `+clientCols+` FROM radius_clients ORDER BY name`)
if err != nil {
return nil, err
}
defer rows.Close()
out := make([]models.RADIUSClient, 0, 8)
for rows.Next() {
c, err := scanClient(rows)
if err != nil {
return nil, err
}
out = append(out, *c)
}
return out, rows.Err()
}
func (r *Repo) GetClient(ctx context.Context, id int64) (*models.RADIUSClient, error) {
c, err := scanClient(r.Pool.QueryRow(ctx, `SELECT `+clientCols+` FROM radius_clients WHERE id=$1`, id))
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrClientNotFound
}
return nil, err
}
return c, nil
}
func (r *Repo) CreateClient(ctx context.Context, name, ipaddr, secret string, active bool, desc string) (*models.RADIUSClient, error) {
enc, err := r.Box.Seal([]byte(secret))
if err != nil {
return nil, err
}
return scanClient(r.Pool.QueryRow(ctx, `
INSERT INTO radius_clients (name, ipaddr, secret_enc, active, description)
VALUES ($1,$2,$3,$4,$5) RETURNING `+clientCols,
name, ipaddr, enc, active, desc))
}
// UpdateClient: secret==nil → unverändert, ""→löschen, sonst neu sealen.
func (r *Repo) UpdateClient(ctx context.Context, id int64, name, ipaddr string, secret *string, active bool, desc string) (*models.RADIUSClient, error) {
if secret == nil {
out, err := scanClient(r.Pool.QueryRow(ctx, `
UPDATE radius_clients SET name=$1, ipaddr=$2, active=$3, description=$4, updated_at=NOW()
WHERE id=$5 RETURNING `+clientCols, name, ipaddr, active, desc, id))
return mapClientErr(out, err)
}
var enc []byte
if *secret != "" {
sealed, err := r.Box.Seal([]byte(*secret))
if err != nil {
return nil, err
}
enc = sealed
}
out, err := scanClient(r.Pool.QueryRow(ctx, `
UPDATE radius_clients SET name=$1, ipaddr=$2, secret_enc=$3, active=$4, description=$5, updated_at=NOW()
WHERE id=$6 RETURNING `+clientCols, name, ipaddr, enc, active, desc, id))
return mapClientErr(out, err)
}
func mapClientErr(c *models.RADIUSClient, err error) (*models.RADIUSClient, error) {
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrClientNotFound
}
return nil, err
}
return c, nil
}
func (r *Repo) DeleteClient(ctx context.Context, id int64) error {
tag, err := r.Pool.Exec(ctx, `DELETE FROM radius_clients WHERE id=$1`, id)
if err != nil {
return err
}
if tag.RowsAffected() == 0 {
return ErrClientNotFound
}
return nil
}
// ── Users ────────────────────────────────────────────────────────────
const userCols = `id, username, password_enc, active, created_at, updated_at`
func scanUser(row pgx.Row) (*models.RADIUSUser, error) {
var u models.RADIUSUser
if err := row.Scan(&u.ID, &u.Username, &u.PasswordEnc, &u.Active, &u.CreatedAt, &u.UpdatedAt); err != nil {
return nil, err
}
return &u, nil
}
func (r *Repo) ListUsers(ctx context.Context) ([]models.RADIUSUser, error) {
rows, err := r.Pool.Query(ctx, `SELECT `+userCols+` FROM radius_users ORDER BY username`)
if err != nil {
return nil, err
}
defer rows.Close()
out := make([]models.RADIUSUser, 0, 8)
for rows.Next() {
u, err := scanUser(rows)
if err != nil {
return nil, err
}
out = append(out, *u)
}
return out, rows.Err()
}
func (r *Repo) GetUser(ctx context.Context, id int64) (*models.RADIUSUser, error) {
u, err := scanUser(r.Pool.QueryRow(ctx, `SELECT `+userCols+` FROM radius_users WHERE id=$1`, id))
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrUserNotFound
}
return nil, err
}
return u, nil
}
func (r *Repo) CreateUser(ctx context.Context, username, password string, active bool) (*models.RADIUSUser, error) {
enc, err := r.Box.Seal([]byte(password))
if err != nil {
return nil, err
}
return scanUser(r.Pool.QueryRow(ctx, `
INSERT INTO radius_users (username, password_enc, active) VALUES ($1,$2,$3) RETURNING `+userCols,
username, enc, active))
}
func (r *Repo) UpdateUser(ctx context.Context, id int64, username string, password *string, active bool) (*models.RADIUSUser, error) {
if password == nil {
u, err := scanUser(r.Pool.QueryRow(ctx, `
UPDATE radius_users SET username=$1, active=$2, updated_at=NOW() WHERE id=$3 RETURNING `+userCols,
username, active, id))
return mapUserErr(u, err)
}
var enc []byte
if *password != "" {
sealed, err := r.Box.Seal([]byte(*password))
if err != nil {
return nil, err
}
enc = sealed
}
u, err := scanUser(r.Pool.QueryRow(ctx, `
UPDATE radius_users SET username=$1, password_enc=$2, active=$3, updated_at=NOW() WHERE id=$4 RETURNING `+userCols,
username, enc, active, id))
return mapUserErr(u, err)
}
func mapUserErr(u *models.RADIUSUser, err error) (*models.RADIUSUser, error) {
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrUserNotFound
}
return nil, err
}
return u, nil
}
func (r *Repo) DeleteUser(ctx context.Context, id int64) error {
tag, err := r.Pool.Exec(ctx, `DELETE FROM radius_users WHERE id=$1`, id)
if err != nil {
return err
}
if tag.RowsAffected() == 0 {
return ErrUserNotFound
}
return nil
}

View File

@@ -96,14 +96,15 @@ func loadOrCreateSecret(path string) ([]byte, error) {
return secret, nil
}
// IssueWithRole returns a signed token for the given actor + role.
func (s *Signer) IssueWithRole(actor, role string) (string, *Token, error) {
// issue builds + signs a token with an explicit TTL. No shared-state
// mutation — safe for concurrent use of the shared Signer singleton.
func (s *Signer) issue(actor, role string, ttl time.Duration) (string, *Token, error) {
now := s.Now()
t := Token{
Actor: actor,
Role: role,
Iat: now.Unix(),
Exp: now.Add(s.TTL).Unix(),
Exp: now.Add(ttl).Unix(),
}
data, err := json.Marshal(t)
if err != nil {
@@ -117,18 +118,20 @@ func (s *Signer) IssueWithRole(actor, role string) (string, *Token, error) {
return encoded, &t, nil
}
// Issue is IssueWithRole with empty role.
func (s *Signer) Issue(actor string) (string, *Token, error) {
return s.IssueWithRole(actor, "")
// IssueWithRole returns a signed token for the given actor + role.
func (s *Signer) IssueWithRole(actor, role string) (string, *Token, error) {
return s.issue(actor, role, s.TTL)
}
// IssueWithRoleTTL issues a token with a custom TTL (overrides s.TTL for this call).
// Issue is IssueWithRole with empty role.
func (s *Signer) Issue(actor string) (string, *Token, error) {
return s.issue(actor, "", s.TTL)
}
// IssueWithRoleTTL issues a token with a custom TTL — no longer mutates
// the shared Signer (previously a data race under concurrent logins).
func (s *Signer) IssueWithRoleTTL(actor, role string, ttl time.Duration) (string, *Token, error) {
orig := s.TTL
s.TTL = ttl
raw, tok, err := s.IssueWithRole(actor, role)
s.TTL = orig
return raw, tok, err
return s.issue(actor, role, ttl)
}
// Verify checks a token. Returns ErrInvalidToken or ErrExpiredToken.
@@ -174,3 +177,67 @@ var (
ErrInvalidToken = errors.New("invalid session token")
ErrExpiredToken = errors.New("session token expired")
)
// blobEnvelope umhüllt eine beliebige Payload mit einem Ablaufzeitpunkt.
type blobEnvelope struct {
Exp int64 `json:"exp"`
Payload []byte `json:"p"`
}
// SignBlob signiert beliebige Bytes mit dem Session-Secret (HMAC-SHA256,
// gleiches Format wie Tokens: base64url(json).base64url(sig)) und einer
// TTL. Für stateless, cluster-sichere Kurzzeit-Cookies (z.B. der
// OIDC-Flow-State). Das Secret ist clusterweit synchron (.jwt_fingerprint).
func (s *Signer) SignBlob(payload []byte, ttl time.Duration) (string, error) {
env := blobEnvelope{
Exp: s.Now().Add(ttl).Unix(),
Payload: payload,
}
data, err := json.Marshal(env)
if err != nil {
return "", err
}
mac := hmac.New(sha256.New, s.Secret)
mac.Write(data)
return base64.RawURLEncoding.EncodeToString(data) + "." +
base64.RawURLEncoding.EncodeToString(mac.Sum(nil)), nil
}
// VerifyBlob prüft Signatur + Ablauf und gibt die ursprüngliche Payload
// zurück. ErrInvalidToken / ErrExpiredToken bei Fehlern.
func (s *Signer) VerifyBlob(raw string) ([]byte, error) {
if raw == "" {
return nil, ErrInvalidToken
}
dot := -1
for i := 0; i < len(raw); i++ {
if raw[i] == '.' {
dot = i
break
}
}
if dot <= 0 || dot >= len(raw)-1 {
return nil, ErrInvalidToken
}
payload, err := base64.RawURLEncoding.DecodeString(raw[:dot])
if err != nil {
return nil, ErrInvalidToken
}
sig, err := base64.RawURLEncoding.DecodeString(raw[dot+1:])
if err != nil {
return nil, ErrInvalidToken
}
mac := hmac.New(sha256.New, s.Secret)
mac.Write(payload)
if subtle.ConstantTimeCompare(mac.Sum(nil), sig) != 1 {
return nil, ErrInvalidToken
}
var env blobEnvelope
if err := json.Unmarshal(payload, &env); err != nil {
return nil, ErrInvalidToken
}
if s.Now().Unix() >= env.Exp {
return nil, ErrExpiredToken
}
return env.Payload, nil
}

View File

@@ -0,0 +1,47 @@
package session
import (
"sync"
"sync/atomic"
"testing"
"time"
)
// TestSigner_TTLNotShared beweist Fix #1: IssueWithRoleTTL darf das geteilte
// s.TTL nicht mehr mutieren. Unter `go test -race` schlägt die alte Version
// als Data-Race an; zusätzlich prüfen wir, dass parallele normale Logins nie
// die kurze TOTP-TTL erben.
func TestSigner_TTLNotShared(t *testing.T) {
s := NewSigner([]byte("0123456789abcdef0123456789abcdef"), nil, time.Hour)
var wg sync.WaitGroup
var bad int32
for i := 0; i < 200; i++ {
wg.Add(2)
go func() {
defer wg.Done()
_, _, _ = s.IssueWithRoleTTL("a", "totp_pending", 2*time.Minute)
}()
go func() {
defer wg.Done()
_, tok, err := s.IssueWithRole("b", "admin")
if err != nil {
atomic.AddInt32(&bad, 1)
return
}
// Normale Session muss ~1h gelten, nie die 2-Min-TOTP-TTL.
if tok.Exp-tok.Iat < int64((30 * time.Minute).Seconds()) {
atomic.AddInt32(&bad, 1)
}
}()
}
wg.Wait()
if bad > 0 {
t.Fatalf("%d normale Tokens bekamen eine zu kurze TTL → geteilter Zustand", bad)
}
// TTL-Override wirkt weiterhin korrekt für den TOTP-Token.
_, ptok, _ := s.IssueWithRoleTTL("x", "totp_pending", 2*time.Minute)
if d := ptok.Exp - ptok.Iat; d > int64((3 * time.Minute).Seconds()) {
t.Fatalf("totp-pending TTL = %ds, want ~120s", d)
}
}

View File

@@ -240,6 +240,21 @@ func (r *Repo) RecordLogin(ctx context.Context, id int64) {
_, _ = r.pool.Exec(ctx, `UPDATE users SET last_login_at=NOW() WHERE id=$1`, id)
}
// GetOIDCSubject liefert den gespeicherten OIDC-'sub' des Users ("" wenn
// noch nicht verknüpft).
func (r *Repo) GetOIDCSubject(ctx context.Context, id int64) (string, error) {
var sub string
err := r.pool.QueryRow(ctx, `SELECT COALESCE(oidc_subject, '') FROM users WHERE id=$1`, id).Scan(&sub)
return sub, err
}
// SetOIDCSubject speichert den OIDC-'sub' beim ersten erfolgreichen
// SSO-Login (opportunistisches Linking).
func (r *Repo) SetOIDCSubject(ctx context.Context, id int64, sub string) error {
_, err := r.pool.Exec(ctx, `UPDATE users SET oidc_subject=$1, updated_at=NOW() WHERE id=$2`, sub, id)
return err
}
// VerifyPassword is a constant-time bcrypt compare.
func VerifyPassword(hash, password string) bool {
return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) == nil

View File

@@ -3,6 +3,8 @@ package waf
import (
"context"
"log/slog"
"sync"
"sync/atomic"
"time"
"github.com/jackc/pgx/v5/pgxpool"
@@ -26,8 +28,12 @@ type Alert struct {
// AlertWriter accepts Alert values via a buffered channel and writes
// them to PostgreSQL asynchronously so SPOE handling stays low-latency.
type AlertWriter struct {
pool *pgxpool.Pool
ch chan Alert
pool *pgxpool.Pool
ch chan Alert
stop chan struct{}
done chan struct{}
closeOnce sync.Once
closed atomic.Bool
}
// NewAlertWriter creates an AlertWriter and starts its background goroutine.
@@ -36,14 +42,19 @@ func NewAlertWriter(pool *pgxpool.Pool, bufSize int) *AlertWriter {
aw := &AlertWriter{
pool: pool,
ch: make(chan Alert, bufSize),
stop: make(chan struct{}),
done: make(chan struct{}),
}
go aw.run()
return aw
}
// Send enqueues an alert. Drops silently if the channel is full to
// avoid slowing down SPOE request handling.
// Send enqueues an alert. Drops silently if the channel is full (or the
// writer is closing) to avoid slowing down / panicking SPOE handling.
func (aw *AlertWriter) Send(a Alert) {
if aw.closed.Load() {
return
}
select {
case aw.ch <- a:
default:
@@ -51,9 +62,34 @@ func (aw *AlertWriter) Send(a Alert) {
}
}
// Close stops the writer and flushes buffered alerts (best-effort).
// Safe to call multiple times. The channel is never closed → Send never
// panics even if it races with Close.
func (aw *AlertWriter) Close() {
aw.closeOnce.Do(func() {
aw.closed.Store(true)
close(aw.stop)
})
<-aw.done
}
func (aw *AlertWriter) run() {
for a := range aw.ch {
aw.write(a)
defer close(aw.done)
for {
select {
case a := <-aw.ch:
aw.write(a)
case <-aw.stop:
// Restliche gepufferte Alerts noch wegschreiben, dann Ende.
for {
select {
case a := <-aw.ch:
aw.write(a)
default:
return
}
}
}
}
}

View File

@@ -0,0 +1,61 @@
package waf
import (
"context"
"os"
"sync"
"testing"
"time"
"git.netcell-it.de/projekte/edgeguard-native/internal/database"
)
// Beweist Fix #15: AlertWriter.Close() flusht, ist idempotent, und Send/Close
// racen ohne Panic (Kanal wird nie geschlossen). Guarded per EG_FWTEST_DSN.
func TestAlertWriter_CloseFlush(t *testing.T) {
dsn := os.Getenv("EG_FWTEST_DSN")
if dsn == "" {
t.Skip("set EG_FWTEST_DSN to run the alert-writer test")
}
ctx := context.Background()
var mErr error
for i := 0; i < 3; i++ {
if mErr = database.Migrate(ctx, dsn); mErr == nil {
break
}
time.Sleep(700 * time.Millisecond)
}
if mErr != nil {
t.Fatalf("migrate: %v", mErr)
}
pool, err := database.Open(ctx, dsn)
if err != nil {
t.Fatalf("open: %v", err)
}
defer pool.Close()
aw := NewAlertWriter(pool, 64)
for i := 0; i < 20; i++ {
aw.Send(Alert{Hostname: "t.local", ClientIP: "203.0.113.1", Method: "GET", URI: "/", Action: "detected"})
}
// Send parallel zu Close → darf nicht paniken.
var wg sync.WaitGroup
for i := 0; i < 10; i++ {
wg.Add(1)
go func() { defer wg.Done(); aw.Send(Alert{Hostname: "t.local", Action: "detected"}) }()
}
done := make(chan struct{})
go func() { aw.Close(); close(done) }()
select {
case <-done:
case <-time.After(10 * time.Second):
t.Fatal("Close() did not return (flush hung)")
}
wg.Wait()
// Idempotent + Send nach Close ist No-op (kein Panic).
aw.Close()
aw.Send(Alert{Hostname: "after.local", Action: "detected"})
}

View File

@@ -66,14 +66,10 @@ func buildDirectives(cfg models.WafConfig, crsDir string) string {
}
}
// Trusted proxies: tell Coraza to trust X-Forwarded-For from these IPs.
for _, ip := range cfg.TrustedProxies {
ip = strings.TrimSpace(ip)
if ip != "" {
sb.WriteString(fmt.Sprintf("SecRemoteRulesFailAction Abort\n"))
_ = ip // used in custom rules below if needed
}
}
// Trusted proxies are NOT a SecLang directive — they are applied in the
// SPOE agent (spoe.go): when the connection source is a trusted proxy,
// the real client IP is taken from X-Forwarded-For before Coraza sees
// it. (Previously this loop emitted a bogus, unrelated directive.)
// Custom rules (appended last so they can override CRS).
if strings.TrimSpace(cfg.CustomRules) != "" {

View File

@@ -3,6 +3,7 @@ package waf
import (
"fmt"
"log/slog"
"net"
"sync"
"github.com/corazawaf/coraza/v3"
@@ -12,8 +13,9 @@ import (
// DomainEngine bundles a Coraza WAF with its operating mode.
type DomainEngine struct {
WAF coraza.WAF
Mode string // "detection" | "blocking"
WAF coraza.WAF
Mode string // "detection" | "blocking"
TrustedProxies []string // wenn src ∈ diese → echte Client-IP aus X-Forwarded-For
}
// Manager holds per-domain Coraza engine instances. Engines are
@@ -90,7 +92,7 @@ func (m *Manager) Reload(domains []DomainConfig) error {
if err != nil {
return fmt.Errorf("waf: build engine for %s: %w", dc.Hostname, err)
}
newEngines[dc.Hostname] = &DomainEngine{WAF: waf, Mode: dc.Config.Mode}
newEngines[dc.Hostname] = &DomainEngine{WAF: waf, Mode: dc.Config.Mode, TrustedProxies: dc.Config.TrustedProxies}
slog.Info("waf: engine (re)loaded",
"host", dc.Hostname,
"mode", dc.Config.Mode,
@@ -110,8 +112,9 @@ func (m *Manager) Reload(domains []DomainConfig) error {
// (nil, false) when the domain has no WAF or WAF is disabled.
func (m *Manager) GetForHost(host string) (*DomainEngine, bool) {
// Strip port if present (e.g. "example.com:443" → "example.com").
if i := lastColon(host); i >= 0 {
host = host[:i]
// SplitHostPort errors for a bare host or bare IPv6 literal → keep as-is.
if h, _, err := net.SplitHostPort(host); err == nil {
host = h
}
m.mu.RLock()
de, ok := m.engines[host]
@@ -122,36 +125,3 @@ func (m *Manager) GetForHost(host string) (*DomainEngine, bool) {
return de, true
}
// lastColon returns the index of the last ':' in s that looks like a
// port separator (after the final ']' for IPv6), or -1.
func lastColon(s string) int {
// IPv6 addresses in brackets: "[::1]:443"
if len(s) > 0 && s[0] == '[' {
if rb := lastByte(s, ']'); rb >= 0 && rb < len(s)-1 && s[rb+1] == ':' {
return rb + 1
}
return -1
}
// Plain host — only strip port if there's exactly one colon.
count := 0
idx := -1
for i, c := range s {
if c == ':' {
count++
idx = i
}
}
if count == 1 {
return idx
}
return -1
}
func lastByte(s string, b byte) int {
for i := len(s) - 1; i >= 0; i-- {
if s[i] == b {
return i
}
}
return -1
}

View File

@@ -3,6 +3,7 @@ package waf
import (
"context"
"log/slog"
"net"
"net/http"
"strings"
@@ -75,6 +76,15 @@ func (a *SPOEAgent) handle(ctx context.Context, w *encoding.ActionWriter, m *enc
return // WAF not configured or disabled for this domain
}
// Trusted-Proxy-Handling: stammt die Verbindung von einem konfigurierten
// Trusted-Proxy, ist die echte Client-IP das letzte X-Forwarded-For-Glied
// (das der Proxy angehängt hat), nicht die Proxy-IP selbst.
if clientIP != "" && len(de.TrustedProxies) > 0 && ipMatchesAny(clientIP, de.TrustedProxies) {
if real := rightmostXFF(rawHdrs); real != "" {
clientIP = real
}
}
tx := de.WAF.NewTransaction()
defer func() {
tx.ProcessLogging()
@@ -165,6 +175,53 @@ func (a *SPOEAgent) sendAlert(host, clientIP, method, uri string, mr types.Match
})
}
// rightmostXFF gibt den letzten (vom nächstgelegenen Proxy angehängten)
// X-Forwarded-For-Eintrag zurück, sofern es eine gültige IP ist.
func rightmostXFF(rawHdrs string) string {
var val string
for _, line := range strings.Split(rawHdrs, "\n") {
line = strings.TrimRight(line, "\r")
idx := strings.IndexByte(line, ':')
if idx <= 0 {
continue
}
if strings.EqualFold(strings.TrimSpace(line[:idx]), "x-forwarded-for") {
val = strings.TrimSpace(line[idx+1:]) // letzter XFF-Header gewinnt
}
}
if val == "" {
return ""
}
parts := strings.Split(val, ",")
cand := strings.TrimSpace(parts[len(parts)-1])
if net.ParseIP(cand) == nil {
return ""
}
return cand
}
// ipMatchesAny prüft, ob ip exakt einer IP oder einem CIDR aus list entspricht.
func ipMatchesAny(ip string, list []string) bool {
parsed := net.ParseIP(ip)
if parsed == nil {
return false
}
for _, e := range list {
e = strings.TrimSpace(e)
if e == "" {
continue
}
if strings.Contains(e, "/") {
if _, n, err := net.ParseCIDR(e); err == nil && n.Contains(parsed) {
return true
}
} else if pe := net.ParseIP(e); pe != nil && pe.Equal(parsed) {
return true
}
}
return false
}
// parseHeaders splits HAProxy raw headers ("Name: value\r\n…") and
// calls fn for each valid header line.
func parseHeaders(raw string, fn func(name, val string)) {

37
internal/waf/spoe_test.go Normal file
View File

@@ -0,0 +1,37 @@
package waf
import "testing"
// Beweist Fix #2: Trusted-Proxy-XFF-Auflösung.
func TestRightmostXFF(t *testing.T) {
cases := map[string]string{
"X-Forwarded-For: 203.0.113.7": "203.0.113.7",
"X-Forwarded-For: 203.0.113.7, 10.0.0.1": "10.0.0.1", // rightmost
"x-forwarded-for: 1.2.3.4 , 5.6.7.8": "5.6.7.8",
"Host: x\r\nX-Forwarded-For: 2001:db8::1": "2001:db8::1",
"X-Forwarded-For: not-an-ip": "",
"User-Agent: foo": "",
"": "",
}
for raw, want := range cases {
if got := rightmostXFF(raw); got != want {
t.Errorf("rightmostXFF(%q) = %q, want %q", raw, got, want)
}
}
}
func TestIPMatchesAny(t *testing.T) {
list := []string{"10.0.0.5", "192.168.0.0/16", "2001:db8::/32"}
yes := []string{"10.0.0.5", "192.168.4.7", "2001:db8::abcd"}
no := []string{"10.0.0.6", "172.16.0.1", "2002::1", "garbage"}
for _, ip := range yes {
if !ipMatchesAny(ip, list) {
t.Errorf("ipMatchesAny(%q) = false, want true", ip)
}
}
for _, ip := range no {
if ipMatchesAny(ip, list) {
t.Errorf("ipMatchesAny(%q) = true, want false", ip)
}
}
}

View File

@@ -19,6 +19,7 @@ import (
"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/secrets"
wgsvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/wireguard"
@@ -27,10 +28,11 @@ import (
const ConfDir = "/etc/edgeguard/wireguard"
type Generator struct {
Pool *pgxpool.Pool
Box *secrets.Box
Ifaces *wgsvc.InterfacesRepo
Peers *wgsvc.PeersRepo
Pool *pgxpool.Pool
Box *secrets.Box
Ifaces *wgsvc.InterfacesRepo
Peers *wgsvc.PeersRepo
SkipReload bool // nur Configs schreiben, keine wg-quick@-Service-Aktionen
}
func New(pool *pgxpool.Pool, box *secrets.Box) *Generator {
@@ -151,8 +153,10 @@ func (g *Generator) Render(ctx context.Context) error {
continue
}
_ = os.Remove(filepath.Join(ConfDir, e.Name()))
_ = stopWGQuick(ifaceName)
_ = disableWGQuick(ifaceName)
if !g.SkipReload {
_ = stopWGQuick(ifaceName)
_ = disableWGQuick(ifaceName)
}
}
}
return nil
@@ -228,21 +232,31 @@ func (g *Generator) renderIface(ctx context.Context, ifc models.WireguardInterfa
}
path := filepath.Join(ConfDir, ifc.Name+".conf")
// Config (enthält den Private Key) ZUERST atomar schreiben — vorher
// keinen Symlink/Service auf eine evtl. fehlende/abgeschnittene Datei
// zeigen lassen. AtomicWrite = temp+fsync+rename, 0600.
changed := true
if existing, err := os.ReadFile(path); err == nil && bytes.Equal(existing, body.Bytes()) {
changed = false
}
if changed {
if err := configgen.AtomicWrite(path, body.Bytes(), 0o600); err != nil {
return fmt.Errorf("write %s: %w", path, err)
}
}
if g.SkipReload {
return nil
}
// wg-quick@<iface>.service liest /etc/wireguard/<iface>.conf (Distro-
// Default), nicht unseren ConfDir. Wir lassen die Quelle of truth in
// /etc/edgeguard/wireguard/ und symlinken via sudo — /etc/wireguard/
// ist root:root 700, daher braucht es sudo /bin/ln. Das sudoers-Entry
// wird von postinst angelegt.
// Default), nicht unseren ConfDir. Symlink via sudo (/etc/wireguard/
// ist root:root 700). Das sudoers-Entry wird von postinst angelegt.
if err := symlinkWGQuickConf(ifc.Name, path); err != nil {
return fmt.Errorf("symlink: %w", err)
}
_ = enableWGQuick(ifc.Name)
if existing, err := os.ReadFile(path); err == nil && bytes.Equal(existing, body.Bytes()) {
if !changed {
return startWGQuick(ifc.Name)
}
if err := os.WriteFile(path, body.Bytes(), 0o600); err != nil {
return fmt.Errorf("write %s: %w", path, err)
}
return restartWGQuick(ifc.Name)
}

View File

@@ -1,4 +1,4 @@
import { Suspense, lazy, useEffect, type ReactNode } from 'react'
import { Suspense, lazy, useEffect, useState, type ReactNode } from 'react'
import { BrowserRouter, Navigate, Route, Routes, useLocation } from 'react-router-dom'
import ErrorBoundary from './components/ErrorBoundary'
import { ConfigProvider, Spin } from 'antd'
@@ -26,7 +26,9 @@ const SSLPage = lazy(() => import('./pages/SSL'))
const FirewallPage = lazy(() => import('./pages/Firewall'))
const WireguardPage = lazy(() => import('./pages/Wireguard'))
const ForwardProxyPage = lazy(() => import('./pages/ForwardProxy'))
const RADIUSPage = lazy(() => import('./pages/RADIUS'))
const DNSPage = lazy(() => import('./pages/DNS'))
const DHCPPage = lazy(() => import('./pages/DHCP'))
const NTPPage = lazy(() => import('./pages/NTP'))
const ClusterPage = lazy(() => import('./pages/Cluster'))
const FirewallLivePage = lazy(() => import('./pages/FirewallLive'))
@@ -79,11 +81,37 @@ const antdTheme = {
function RequireAuth({ children }: { children: ReactNode }) {
const user = useAuthStore((s) => s.user)
const setUser = useAuthStore((s) => s.set)
const location = useLocation()
if (!user) {
return <Navigate to="/login" replace state={{ from: location }} />
// Wenn kein Store-User da ist (z.B. direkt nach SSO-Callback: Cookie
// gesetzt, sessionStorage leer — oder Hard-Refresh), einmal /auth/me
// probieren, bevor wir nach /login umleiten.
const [checking, setChecking] = useState(user === null)
useEffect(() => {
if (user !== null) {
setChecking(false)
return
}
let cancelled = false
apiClient.get('/auth/me')
.then((r) => {
if (!cancelled && isEnvelope(r.data)) setUser(r.data.data as SessionUser)
})
.catch(() => { /* 401 → Interceptor leitet auf /login */ })
.finally(() => { if (!cancelled) setChecking(false) })
return () => { cancelled = true }
}, [user, setUser])
if (user) return <>{children}</>
if (checking) {
return (
<div style={{ minHeight: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<Spin size="large" />
</div>
)
}
return <>{children}</>
return <Navigate to="/login" replace state={{ from: location }} />
}
function SetupGate({ children }: { children: ReactNode }) {
@@ -134,7 +162,9 @@ export default function App() {
<Route path="/firewall/live" element={<FirewallLivePage />} />
<Route path="/vpn/wireguard" element={<WireguardPage />} />
<Route path="/forward-proxy" element={<ForwardProxyPage />} />
<Route path="/radius" element={<RADIUSPage />} />
<Route path="/dns" element={<DNSPage />} />
<Route path="/dhcp" element={<DHCPPage />} />
<Route path="/ntp" element={<NTPPage />} />
<Route path="/cluster" element={<ClusterPage />} />
<Route path="/logs" element={<LogsPage />} />

View File

@@ -19,9 +19,11 @@ const PAGE_TITLES: Record<string, string> = {
'/ip-addresses': 'nav.ipAddresses',
'/ssl': 'nav.ssl',
'/dns': 'nav.dns',
'/dhcp': 'nav.dhcp',
'/ntp': 'nav.ntp',
'/vpn/wireguard': 'nav.wireguard',
'/forward-proxy': 'nav.forwardProxy',
'/radius': 'nav.radius',
'/firewall/live': 'nav.firewallLive',
'/firewall': 'nav.firewall',
'/cluster': 'nav.cluster',

View File

@@ -18,6 +18,7 @@ import {
DatabaseOutlined,
FireOutlined,
GlobalOutlined,
IdcardOutlined,
NodeIndexOutlined,
RadarChartOutlined,
SafetyCertificateOutlined,
@@ -67,6 +68,7 @@ const NAV: NavSection[] = [
{ path: '/ip-addresses', labelKey: 'nav.ipAddresses', icon: <NodeIndexOutlined /> },
{ path: '/ssl', labelKey: 'nav.ssl', icon: <SafetyCertificateOutlined /> },
{ path: '/dns', labelKey: 'nav.dns', icon: <GlobalOutlined /> },
{ path: '/dhcp', labelKey: 'nav.dhcp', icon: <CloudServerOutlined /> },
{ path: '/ntp', labelKey: 'nav.ntp', icon: <ClockCircleOutlined /> },
],
},
@@ -79,6 +81,7 @@ const NAV: NavSection[] = [
{ path: '/forward-proxy', labelKey: 'nav.forwardProxy', icon: <CloudServerOutlined /> },
{ path: '/crowdsec', labelKey: 'nav.crowdsec', icon: <RadarChartOutlined /> },
{ path: '/waf', labelKey: 'nav.waf', icon: <SafetyCertificateOutlined /> },
{ path: '/radius', labelKey: 'nav.radius', icon: <IdcardOutlined /> },
],
},
{

View File

@@ -15,6 +15,7 @@
"wireguard": "WireGuard",
"forwardProxy": "Forward-Proxy",
"dns": "DNS",
"dhcp": "DHCP",
"ntp": "Zeit (NTP)",
"firewall": "Firewall",
"firewallLive": "Firewall-Log",
@@ -35,7 +36,8 @@
"security": "Sicherheit",
"system": "System"
},
"waf": "WAF"
"waf": "WAF",
"radius": "RADIUS"
},
"fw": {
"title": "Firewall",
@@ -289,6 +291,24 @@
"forgotPassword": "Passwort vergessen?",
"viewerBadge": "Nur lesen",
"viewerHint": "Dieser Account hat die Rolle Betrachter — Änderungen sind gesperrt. Ein Admin kann die Rolle anpassen.",
"sso": {
"login": "Mit SSO anmelden",
"err": {
"no_account": "Kein EdgeGuard-Konto für diese E-Mail. Bitte wende dich an einen Administrator.",
"disabled": "Dieses Konto ist deaktiviert.",
"unverified": "Die E-Mail-Adresse ist beim Identity-Provider nicht verifiziert.",
"state": "Sicherheitsprüfung fehlgeschlagen (State). Bitte erneut versuchen.",
"nonce": "Sicherheitsprüfung fehlgeschlagen (Nonce). Bitte erneut versuchen.",
"token": "Das Token vom Identity-Provider konnte nicht verifiziert werden.",
"exchange": "Code-Austausch mit dem Identity-Provider fehlgeschlagen.",
"expired": "Die Anmeldesitzung ist abgelaufen. Bitte erneut versuchen.",
"denied": "Anmeldung beim Identity-Provider abgebrochen.",
"subject_mismatch": "Die Identität passt nicht zum hinterlegten Konto.",
"config": "SSO ist nicht korrekt konfiguriert.",
"server": "Interner Fehler bei der SSO-Anmeldung.",
"generic": "SSO-Anmeldung fehlgeschlagen."
}
},
"totp": {
"prompt": "Bitte gib den 6-stelligen Code aus deiner Authenticator-App ein.",
"verify": "Code bestätigen",
@@ -907,6 +927,24 @@
"configPreviewBtn": "Vorschau laden",
"configPreviewHint": "Rendert die gewählte Service-Config aus dem aktuellen DB-State. Nur Lesezugriff — es wird nichts auf Disk geschrieben oder neu geladen.",
"configCopied": "Config in Zwischenablage kopiert",
"oidc": {
"title": "Single Sign-On (OIDC)",
"intro": "SSO-Login per OpenID Connect (z. B. Keycloak), zusätzlich zum lokalen Login. Anmeldung gelingt nur für bereits angelegte Benutzer; die Rolle wird in EdgeGuard verwaltet.",
"enabled": "SSO aktiviert",
"issuerUrl": "Issuer-URL",
"issuerHint": "Basis-URL des Realms, z. B. https://keycloak.example.com/realms/edgeguard",
"clientId": "Client-ID",
"clientSecret": "Client-Secret",
"secretSet": "Gespeichert — leer lassen, um es unverändert zu lassen.",
"secretUnset": "Noch kein Secret gespeichert.",
"scopes": "Scopes",
"emailClaim": "E-Mail-Claim",
"buttonLabel": "Button-Beschriftung",
"redirectUri": "Redirect-URI",
"redirectHint": "Diese URL im Keycloak-Client als gültige Redirect-URI eintragen (pro Cluster-Node die jeweilige FQDN).",
"saved": "OIDC-Einstellungen gespeichert",
"saveFailed": "Speichern der OIDC-Einstellungen fehlgeschlagen"
},
"passwordCardTitle": "Admin-Passwort ändern",
"currentPassword": "Aktuelles Passwort",
"newPassword": "Neues Passwort",
@@ -1039,6 +1077,52 @@
"failed": "Import fehlgeschlagen"
}
},
"dhcp": {
"title": "DHCP (Kea)",
"intro": "DHCPv4-Server: globale Optionen, pro-Subnet-Pools und statische Reservierungen.",
"csvHint": "Komma-getrennt.",
"saved": "Gespeichert",
"saveFailed": "Speichern fehlgeschlagen",
"deleted": "Gelöscht",
"deleteFailed": "Löschen fehlgeschlagen",
"reservations": "Reservierungen",
"haWarnTitle": "Nur auf EINER Node aktivieren",
"haWarnDesc": "Zwei DHCP-Server im selben LAN führen zu Adresskonflikten. Diese Einstellung ist node-lokal — aktiviere DHCP nur auf einer Cluster-Node.",
"tabs": {
"settings": "Einstellungen",
"subnets": "Subnets"
},
"settings": {
"enabled": "DHCP auf dieser Node aktiv",
"defaultLease": "Standard-Lease-Zeit (Sekunden)",
"maxLease": "Max-Lease-Zeit (Sekunden)",
"domainName": "Domain-Name",
"dnsServers": "DNS-Server (global)"
},
"subnet": {
"name": "Name",
"interface": "Interface",
"cidr": "Subnetz (CIDR)",
"pool": "Pool",
"poolStart": "Pool-Start",
"poolEnd": "Pool-Ende",
"gateway": "Gateway",
"dnsServers": "DNS-Server",
"active": "Aktiv",
"add": "Subnet hinzufügen",
"edit": "Subnet bearbeiten",
"deleteConfirm": "Subnet \"{{name}}\" wirklich löschen?"
},
"reservation": {
"name": "Name",
"mac": "MAC-Adresse",
"ip": "IP-Adresse",
"hostname": "Hostname",
"add": "Reservierung hinzufügen",
"edit": "Reservierung bearbeiten",
"deleteConfirm": "Reservierung wirklich löschen?"
}
},
"ntp": {
"title": "Zeitserver (Chrony)",
"intro": "Chrony als Time-Sync-Daemon (NTP). Quellen oben, Listen-/Serve-Konfig im Settings-Tab. Wenn 'serve_clients' aktiv und LAN-IPs gebound sind, wird die Box selbst zum NTP-Server für das LAN.",
@@ -1287,6 +1371,8 @@
"common": {
"yes": "Ja",
"no": "Nein",
"or": "oder",
"status": "Status",
"save": "Speichern",
"cancel": "Abbrechen",
"loading": "Lädt …",
@@ -1771,6 +1857,39 @@
"confirmRemove": "Collection {{name}} wirklich entfernen?"
}
},
"radius": {
"title": "RADIUS (FreeRADIUS)",
"intro": "RADIUS-Authentifizierung (PAP/CHAP): NAS-Clients + Benutzer für VPN-/Switch-/AP-Login.",
"csvHint": "Komma-getrennt; leer = alle Adressen.",
"saved": "Gespeichert",
"saveFailed": "Speichern fehlgeschlagen",
"deleted": "Gelöscht",
"deleteFailed": "Löschen fehlgeschlagen",
"secretSet": "Gespeichert — leer lassen, um es unverändert zu lassen.",
"secretUnset": "Noch nichts gespeichert.",
"tabs": { "settings": "Einstellungen", "clients": "Clients (NAS)", "users": "Benutzer" },
"settings": {
"enabled": "RADIUS auf dieser Node aktiv",
"listen": "Listen-Adressen"
},
"client": {
"name": "Name",
"ipaddr": "IP / CIDR",
"secret": "Shared Secret",
"description": "Beschreibung",
"add": "Client hinzufügen",
"edit": "Client bearbeiten",
"deleteConfirm": "Client \"{{name}}\" wirklich löschen?"
},
"user": {
"username": "Benutzername",
"password": "Passwort",
"papHint": "Benutzer-Passwörter werden für PAP/CHAP als Cleartext in der FreeRADIUS-Datei hinterlegt (verschlüsselt at-rest in der DB, Datei nur für root/freeradius lesbar).",
"add": "Benutzer hinzufügen",
"edit": "Benutzer bearbeiten",
"deleteConfirm": "Benutzer \"{{name}}\" wirklich löschen?"
}
},
"waf": {
"title": "Web Application Firewall",
"intro": "Domänen-spezifische HTTP-Request-Inspektion via Coraza/OWASP CRS. Standard: für alle Domains deaktiviert.",

View File

@@ -15,6 +15,7 @@
"wireguard": "WireGuard",
"forwardProxy": "Forward proxy",
"dns": "DNS",
"dhcp": "DHCP",
"ntp": "Time (NTP)",
"firewall": "Firewall",
"firewallLive": "Firewall log",
@@ -35,7 +36,8 @@
"security": "Security",
"system": "System"
},
"waf": "WAF"
"waf": "WAF",
"radius": "RADIUS"
},
"fw": {
"title": "Firewall",
@@ -289,6 +291,24 @@
"forgotPassword": "Forgot your password?",
"viewerBadge": "Read-only",
"viewerHint": "Your account has viewer role — all changes are blocked. Contact an admin to change your role.",
"sso": {
"login": "Sign in with SSO",
"err": {
"no_account": "No EdgeGuard account for this email. Please contact an administrator.",
"disabled": "This account is disabled.",
"unverified": "The email address is not verified at the identity provider.",
"state": "Security check failed (state). Please try again.",
"nonce": "Security check failed (nonce). Please try again.",
"token": "Could not verify the token from the identity provider.",
"exchange": "Code exchange with the identity provider failed.",
"expired": "The sign-in session expired. Please try again.",
"denied": "Sign-in at the identity provider was cancelled.",
"subject_mismatch": "The identity does not match the linked account.",
"config": "SSO is not configured correctly.",
"server": "Internal error during SSO sign-in.",
"generic": "SSO sign-in failed."
}
},
"totp": {
"prompt": "Enter the 6-digit code from your authenticator app.",
"verify": "Verify code",
@@ -907,6 +927,24 @@
"configPreviewBtn": "Load preview",
"configPreviewHint": "Renders the selected service config from the current DB state. Read-only — nothing is written to disk or reloaded.",
"configCopied": "Config copied to clipboard",
"oidc": {
"title": "Single Sign-On (OIDC)",
"intro": "SSO login via OpenID Connect (e.g. Keycloak), in addition to local login. Only existing users can sign in; the role is managed in EdgeGuard.",
"enabled": "SSO enabled",
"issuerUrl": "Issuer URL",
"issuerHint": "Realm base URL, e.g. https://keycloak.example.com/realms/edgeguard",
"clientId": "Client ID",
"clientSecret": "Client secret",
"secretSet": "Stored — leave empty to keep unchanged.",
"secretUnset": "No secret stored yet.",
"scopes": "Scopes",
"emailClaim": "Email claim",
"buttonLabel": "Button label",
"redirectUri": "Redirect URI",
"redirectHint": "Register this URL as a valid redirect URI in the Keycloak client (per cluster node its own FQDN).",
"saved": "OIDC settings saved",
"saveFailed": "Failed to save OIDC settings"
},
"passwordCardTitle": "Change admin password",
"currentPassword": "Current password",
"newPassword": "New password",
@@ -1039,6 +1077,52 @@
"failed": "Import failed"
}
},
"dhcp": {
"title": "DHCP (Kea)",
"intro": "DHCPv4 server: global options, per-subnet pools and static reservations.",
"csvHint": "Comma-separated.",
"saved": "Saved",
"saveFailed": "Save failed",
"deleted": "Deleted",
"deleteFailed": "Delete failed",
"reservations": "Reservations",
"haWarnTitle": "Enable on ONE node only",
"haWarnDesc": "Two DHCP servers on the same LAN cause address conflicts. This setting is node-local — enable DHCP on a single cluster node only.",
"tabs": {
"settings": "Settings",
"subnets": "Subnets"
},
"settings": {
"enabled": "DHCP active on this node",
"defaultLease": "Default lease time (seconds)",
"maxLease": "Max lease time (seconds)",
"domainName": "Domain name",
"dnsServers": "DNS servers (global)"
},
"subnet": {
"name": "Name",
"interface": "Interface",
"cidr": "Subnet (CIDR)",
"pool": "Pool",
"poolStart": "Pool start",
"poolEnd": "Pool end",
"gateway": "Gateway",
"dnsServers": "DNS servers",
"active": "Active",
"add": "Add subnet",
"edit": "Edit subnet",
"deleteConfirm": "Really delete subnet \"{{name}}\"?"
},
"reservation": {
"name": "Name",
"mac": "MAC address",
"ip": "IP address",
"hostname": "Hostname",
"add": "Add reservation",
"edit": "Edit reservation",
"deleteConfirm": "Really delete this reservation?"
}
},
"ntp": {
"title": "Time server (Chrony)",
"intro": "Chrony as time-sync daemon (NTP). Sources on top, listen/serve config on the settings tab. With 'serve_clients' on and LAN-IPs bound, the box itself becomes an NTP server for the LAN.",
@@ -1287,6 +1371,8 @@
"common": {
"yes": "Yes",
"no": "No",
"or": "or",
"status": "Status",
"save": "Save",
"cancel": "Cancel",
"loading": "Loading …",
@@ -1771,6 +1857,39 @@
"confirmRemove": "Really remove collection {{name}}?"
}
},
"radius": {
"title": "RADIUS (FreeRADIUS)",
"intro": "RADIUS authentication (PAP/CHAP): NAS clients + users for VPN/switch/AP login.",
"csvHint": "Comma-separated; empty = all addresses.",
"saved": "Saved",
"saveFailed": "Save failed",
"deleted": "Deleted",
"deleteFailed": "Delete failed",
"secretSet": "Stored — leave empty to keep unchanged.",
"secretUnset": "Nothing stored yet.",
"tabs": { "settings": "Settings", "clients": "Clients (NAS)", "users": "Users" },
"settings": {
"enabled": "RADIUS active on this node",
"listen": "Listen addresses"
},
"client": {
"name": "Name",
"ipaddr": "IP / CIDR",
"secret": "Shared secret",
"description": "Description",
"add": "Add client",
"edit": "Edit client",
"deleteConfirm": "Really delete client \"{{name}}\"?"
},
"user": {
"username": "Username",
"password": "Password",
"papHint": "User passwords are stored as cleartext in the FreeRADIUS file for PAP/CHAP (encrypted at-rest in the DB; the file is readable only by root/freeradius).",
"add": "Add user",
"edit": "Edit user",
"deleteConfirm": "Really delete user \"{{name}}\"?"
}
},
"waf": {
"title": "Web Application Firewall",
"intro": "Per-domain HTTP request inspection via Coraza/OWASP CRS. Default: off for all domains.",

View File

@@ -392,14 +392,13 @@ export default function ClusterPage() {
const primaryFqdn = data?.local_node?.fqdn ?? window.location.hostname
// Repair-Button: sichtbar bei Drift, für Admins, wenn ein Resync-Ziel
// existiert — auf dem Standby (lokal) oder auf dem Primary (delegiert
// an den Standby-Peer).
const localRole = data?.local_node?.pg_role
// Repair-Button: sichtbar bei Drift, für Admins, sobald ein Peer
// existiert. Welche Node Primary (Publication-Quelle) bzw. Subscriber
// ist, entscheidet das Backend zur Laufzeit über pg_publication — die
// UI muss das nicht raten (ha_nodes.role ist je Node lokal/unzuverlässig).
const canRepair = !isViewer
&& !!data?.drift_found
&& (localRole === 'standby'
|| (localRole === 'primary' && (data?.peers?.some(p => p.pg_role === 'standby') ?? false)))
&& ((data?.peers?.length ?? 0) > 0)
const peerColumns: ColumnsType<HANode> = [
{
@@ -537,14 +536,7 @@ export default function ClusterPage() {
banner
className="mb-16"
message={t('cluster.driftBanner')}
description={
<>
<Paragraph style={{ marginBottom: 8 }}>{t('cluster.driftBannerDesc')}</Paragraph>
{data.local_node?.pg_role === 'primary'
&& !(data.peers?.some(p => p.pg_role === 'standby'))
&& <Text type="secondary">{t('cluster.repair.noStandbyHint')}</Text>}
</>
}
description={t('cluster.driftBannerDesc')}
action={
canRepair ? (
<Popconfirm

View File

@@ -0,0 +1,385 @@
import { useEffect, useState } from 'react'
import {
Alert, Button, Card, Form, Input, InputNumber, Modal, Popconfirm, Space,
Switch, Table, Tabs, Tag, Tooltip, Typography, message, Select,
} from 'antd'
import type { ColumnsType } from 'antd/es/table'
import { CloudServerOutlined, PlusOutlined } from '@ant-design/icons'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import apiClient, { isEnvelope } from '../../api/client'
import { useAuthStore } from '../../stores/auth'
import PageHeader from '../../components/PageHeader'
const { Text } = Typography
interface DHCPSettings {
id: number
enabled: boolean
default_lease: number
max_lease: number
domain_name: string
dns_servers: string
}
interface DHCPSubnet {
id: number
name: string
interface_name: string
subnet_cidr: string
pool_start: string
pool_end: string
gateway: string
dns_servers: string
lease_time?: number | null
active: boolean
description: string
}
interface DHCPReservation {
id: number
subnet_id: number
name: string
mac_address: string
ip_address: string
hostname: string
active: boolean
}
interface NetIface { name: string; role: string }
export default function DHCPPage() {
const { t } = useTranslation()
return (
<div>
<PageHeader icon={<CloudServerOutlined />} title={t('dhcp.title')} subtitle={t('dhcp.intro')} />
<Tabs
defaultActiveKey="settings"
items={[
{ key: 'settings', label: t('dhcp.tabs.settings'), children: <SettingsTab /> },
{ key: 'subnets', label: t('dhcp.tabs.subnets'), children: <SubnetsTab /> },
]}
/>
</div>
)
}
// ── Settings ─────────────────────────────────────────────────────────
function SettingsTab() {
const { t } = useTranslation()
const qc = useQueryClient()
const isViewer = useAuthStore((s) => s.user?.role) === 'viewer'
const [msg, msgCtx] = message.useMessage()
const [form] = Form.useForm<DHCPSettings>()
const { data } = useQuery({
queryKey: ['dhcp', 'settings'],
queryFn: async () => {
const r = await apiClient.get('/dhcp/settings')
return isEnvelope(r.data) ? (r.data.data as DHCPSettings) : null
},
})
useEffect(() => { if (data) form.setFieldsValue(data) }, [data, form])
const save = useMutation({
mutationFn: async (v: DHCPSettings) => apiClient.put('/dhcp/settings', v),
onSuccess: () => {
msg.success(t('dhcp.saved'))
void qc.invalidateQueries({ queryKey: ['dhcp', 'settings'] })
},
onError: (e: Error) => msg.error(t('dhcp.saveFailed') + ': ' + e.message),
})
return (
<Card size="small">
{msgCtx}
<Alert
type="warning"
showIcon
className="mb-16"
message={t('dhcp.haWarnTitle')}
description={t('dhcp.haWarnDesc')}
/>
<Form<DHCPSettings> form={form} layout="vertical" onFinish={(v) => save.mutate(v)}>
<Form.Item label={t('dhcp.settings.enabled')} name="enabled" valuePropName="checked">
<Switch disabled={isViewer} />
</Form.Item>
<Form.Item label={t('dhcp.settings.defaultLease')} name="default_lease">
<InputNumber min={60} style={{ width: 240 }} disabled={isViewer} />
</Form.Item>
<Form.Item label={t('dhcp.settings.maxLease')} name="max_lease">
<InputNumber min={60} style={{ width: 240 }} disabled={isViewer} />
</Form.Item>
<Form.Item label={t('dhcp.settings.domainName')} name="domain_name">
<Input placeholder="lan" disabled={isViewer} />
</Form.Item>
<Form.Item label={t('dhcp.settings.dnsServers')} name="dns_servers" extra={t('dhcp.csvHint')}>
<Input placeholder="1.1.1.1, 8.8.8.8" disabled={isViewer} />
</Form.Item>
<Form.Item style={{ marginBottom: 0 }}>
<Tooltip title={isViewer ? t('auth.viewerBadge') : undefined}>
<Button type="primary" htmlType="submit" loading={save.isPending} disabled={isViewer}>
{t('common.save')}
</Button>
</Tooltip>
</Form.Item>
</Form>
</Card>
)
}
// ── Subnets ──────────────────────────────────────────────────────────
function SubnetsTab() {
const { t } = useTranslation()
const qc = useQueryClient()
const isViewer = useAuthStore((s) => s.user?.role) === 'viewer'
const [msg, msgCtx] = message.useMessage()
const [form] = Form.useForm<DHCPSubnet>()
const [editing, setEditing] = useState<DHCPSubnet | null>(null)
const [open, setOpen] = useState(false)
const [resvFor, setResvFor] = useState<DHCPSubnet | null>(null)
const { data: subnets } = useQuery({
queryKey: ['dhcp', 'subnets'],
queryFn: async () => {
const r = await apiClient.get('/dhcp/subnets')
return isEnvelope(r.data) ? ((r.data.data as { subnets?: DHCPSubnet[] }).subnets ?? []) : []
},
})
const { data: ifaces } = useQuery({
queryKey: ['network-interfaces'],
queryFn: async () => {
const r = await apiClient.get('/network-interfaces')
return isEnvelope(r.data) ? ((r.data.data as { interfaces?: NetIface[] }).interfaces ?? []) : []
},
})
const save = useMutation({
mutationFn: async (v: DHCPSubnet) => {
if (editing) return apiClient.put(`/dhcp/subnets/${editing.id}`, v)
return apiClient.post('/dhcp/subnets', v)
},
onSuccess: () => {
msg.success(t('dhcp.saved'))
setOpen(false); setEditing(null)
void qc.invalidateQueries({ queryKey: ['dhcp', 'subnets'] })
},
onError: (e: Error) => msg.error(t('dhcp.saveFailed') + ': ' + e.message),
})
const del = useMutation({
mutationFn: async (id: number) => apiClient.delete(`/dhcp/subnets/${id}`),
onSuccess: () => {
msg.success(t('dhcp.deleted'))
void qc.invalidateQueries({ queryKey: ['dhcp', 'subnets'] })
},
onError: (e: Error) => msg.error(t('dhcp.deleteFailed') + ': ' + e.message),
})
const openNew = () => {
setEditing(null)
form.resetFields()
form.setFieldsValue({ active: true } as Partial<DHCPSubnet>)
setOpen(true)
}
const openEdit = (s: DHCPSubnet) => {
setEditing(s)
form.setFieldsValue(s)
setOpen(true)
}
const cols: ColumnsType<DHCPSubnet> = [
{ title: t('dhcp.subnet.name'), dataIndex: 'name' },
{ title: t('dhcp.subnet.interface'), dataIndex: 'interface_name' },
{ title: t('dhcp.subnet.cidr'), dataIndex: 'subnet_cidr' },
{
title: t('dhcp.subnet.pool'), key: 'pool',
render: (_, r) => (r.pool_start && r.pool_end ? `${r.pool_start} ${r.pool_end}` : '—'),
},
{ title: t('dhcp.subnet.gateway'), dataIndex: 'gateway', render: (v) => v || '—' },
{
title: t('common.status'), dataIndex: 'active',
render: (v: boolean) => <Tag color={v ? 'green' : 'default'}>{v ? t('common.active') : t('common.inactive')}</Tag>,
},
{
title: t('common.actions'), key: 'actions', width: 280,
render: (_, r) => (
<Space>
<Button size="small" onClick={() => setResvFor(r)}>{t('dhcp.reservations')}</Button>
{!isViewer && <Button size="small" onClick={() => openEdit(r)}>{t('common.edit')}</Button>}
{!isViewer && (
<Popconfirm title={t('dhcp.subnet.deleteConfirm', { name: r.name })} onConfirm={() => del.mutate(r.id)}>
<Button size="small" danger>{t('common.delete')}</Button>
</Popconfirm>
)}
</Space>
),
},
]
return (
<Card size="small">
{msgCtx}
<Space style={{ marginBottom: 12 }}>
<Button type="primary" icon={<PlusOutlined />} onClick={openNew} disabled={isViewer}>
{t('dhcp.subnet.add')}
</Button>
</Space>
<Table<DHCPSubnet> rowKey="id" size="small" columns={cols} dataSource={subnets ?? []} pagination={false} />
<Modal
title={editing ? t('dhcp.subnet.edit') : t('dhcp.subnet.add')}
open={open}
onCancel={() => setOpen(false)}
onOk={() => form.submit()}
confirmLoading={save.isPending}
destroyOnClose
>
<Form<DHCPSubnet> form={form} layout="vertical" onFinish={(v) => save.mutate(v)}>
<Form.Item label={t('dhcp.subnet.name')} name="name" rules={[{ required: true }]}>
<Input />
</Form.Item>
<Form.Item label={t('dhcp.subnet.interface')} name="interface_name" rules={[{ required: true }]}>
<Select
options={(ifaces ?? []).map((i) => ({ value: i.name, label: `${i.name} (${i.role})` }))}
showSearch
/>
</Form.Item>
<Form.Item label={t('dhcp.subnet.cidr')} name="subnet_cidr" rules={[{ required: true }]}>
<Input placeholder="10.0.0.0/24" />
</Form.Item>
<Space>
<Form.Item label={t('dhcp.subnet.poolStart')} name="pool_start">
<Input placeholder="10.0.0.100" />
</Form.Item>
<Form.Item label={t('dhcp.subnet.poolEnd')} name="pool_end">
<Input placeholder="10.0.0.200" />
</Form.Item>
</Space>
<Form.Item label={t('dhcp.subnet.gateway')} name="gateway">
<Input placeholder="10.0.0.1" />
</Form.Item>
<Form.Item label={t('dhcp.subnet.dnsServers')} name="dns_servers" extra={t('dhcp.csvHint')}>
<Input placeholder="1.1.1.1, 8.8.8.8" />
</Form.Item>
<Form.Item label={t('dhcp.subnet.active')} name="active" valuePropName="checked">
<Switch />
</Form.Item>
</Form>
</Modal>
{resvFor && (
<ReservationsModal subnet={resvFor} onClose={() => setResvFor(null)} />
)}
</Card>
)
}
// ── Reservations (per subnet) ────────────────────────────────────────
function ReservationsModal({ subnet, onClose }: { subnet: DHCPSubnet; onClose: () => void }) {
const { t } = useTranslation()
const qc = useQueryClient()
const isViewer = useAuthStore((s) => s.user?.role) === 'viewer'
const [msg, msgCtx] = message.useMessage()
const [form] = Form.useForm<DHCPReservation>()
const [editing, setEditing] = useState<DHCPReservation | null>(null)
const [formOpen, setFormOpen] = useState(false)
const key = ['dhcp', 'reservations', subnet.id]
const { data } = useQuery({
queryKey: key,
queryFn: async () => {
const r = await apiClient.get(`/dhcp/subnets/${subnet.id}/reservations`)
return isEnvelope(r.data) ? ((r.data.data as { reservations?: DHCPReservation[] }).reservations ?? []) : []
},
})
const save = useMutation({
mutationFn: async (v: DHCPReservation) => {
if (editing) return apiClient.put(`/dhcp/reservations/${editing.id}`, v)
return apiClient.post(`/dhcp/subnets/${subnet.id}/reservations`, v)
},
onSuccess: () => {
msg.success(t('dhcp.saved'))
setFormOpen(false); setEditing(null)
void qc.invalidateQueries({ queryKey: key })
},
onError: (e: Error) => msg.error(t('dhcp.saveFailed') + ': ' + e.message),
})
const del = useMutation({
mutationFn: async (id: number) => apiClient.delete(`/dhcp/reservations/${id}`),
onSuccess: () => {
msg.success(t('dhcp.deleted'))
void qc.invalidateQueries({ queryKey: key })
},
onError: (e: Error) => msg.error(t('dhcp.deleteFailed') + ': ' + e.message),
})
const cols: ColumnsType<DHCPReservation> = [
{ title: t('dhcp.reservation.mac'), dataIndex: 'mac_address' },
{ title: t('dhcp.reservation.ip'), dataIndex: 'ip_address' },
{ title: t('dhcp.reservation.hostname'), dataIndex: 'hostname', render: (v) => v || '—' },
{
title: t('common.actions'), key: 'actions', width: 180,
render: (_, r) => (
<Space>
{!isViewer && <Button size="small" onClick={() => { setEditing(r); form.setFieldsValue(r); setFormOpen(true) }}>{t('common.edit')}</Button>}
{!isViewer && (
<Popconfirm title={t('dhcp.reservation.deleteConfirm')} onConfirm={() => del.mutate(r.id)}>
<Button size="small" danger>{t('common.delete')}</Button>
</Popconfirm>
)}
</Space>
),
},
]
return (
<Modal
title={`${t('dhcp.reservations')}${subnet.name}`}
open
onCancel={onClose}
footer={null}
width={640}
>
{msgCtx}
<Space style={{ marginBottom: 12 }}>
<Button
type="primary" icon={<PlusOutlined />} disabled={isViewer}
onClick={() => { setEditing(null); form.resetFields(); form.setFieldsValue({ active: true } as Partial<DHCPReservation>); setFormOpen(true) }}
>
{t('dhcp.reservation.add')}
</Button>
</Space>
<Table<DHCPReservation> rowKey="id" size="small" columns={cols} dataSource={data ?? []} pagination={false} />
<Modal
title={editing ? t('dhcp.reservation.edit') : t('dhcp.reservation.add')}
open={formOpen}
onCancel={() => setFormOpen(false)}
onOk={() => form.submit()}
confirmLoading={save.isPending}
destroyOnClose
>
<Form<DHCPReservation> form={form} layout="vertical" onFinish={(v) => save.mutate(v)}>
<Form.Item label={t('dhcp.reservation.name')} name="name">
<Input />
</Form.Item>
<Form.Item label={t('dhcp.reservation.mac')} name="mac_address" rules={[{ required: true }]}>
<Input placeholder="aa:bb:cc:dd:ee:ff" />
</Form.Item>
<Form.Item label={t('dhcp.reservation.ip')} name="ip_address" rules={[{ required: true }]}>
<Input placeholder="10.0.0.50" />
</Form.Item>
<Form.Item label={t('dhcp.reservation.hostname')} name="hostname">
<Input />
</Form.Item>
<Form.Item label={t('dhcp.subnet.active')} name="active" valuePropName="checked">
<Switch />
</Form.Item>
</Form>
</Modal>
<Text type="secondary" style={{ fontSize: 12 }}>{subnet.subnet_cidr}</Text>
</Modal>
)
}

View File

@@ -1,6 +1,6 @@
import { useState } from 'react'
import { Button, Card, Form, Input, message, Typography } from 'antd'
import { KeyOutlined } from '@ant-design/icons'
import { useEffect, useState } from 'react'
import { Button, Card, Divider, Form, Input, message, Typography } from 'antd'
import { KeyOutlined, LoginOutlined } from '@ant-design/icons'
import { Link, useNavigate } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
@@ -22,6 +22,28 @@ export default function LoginPage({ onLogin }: Props) {
const [totpRequired, setTotpRequired] = useState(false)
const [totpCode, setTotpCode] = useState('')
const [verifying, setVerifying] = useState(false)
const [ssoEnabled, setSsoEnabled] = useState(false)
const [ssoLabel, setSsoLabel] = useState('')
// SSO-Verfügbarkeit prüfen + evtl. ?sso_error vom Callback anzeigen.
useEffect(() => {
apiClient.get('/auth/oidc/settings')
.then((r) => {
if (!isEnvelope(r.data)) return
const d = r.data.data as { enabled?: boolean; button_label?: string }
setSsoEnabled(!!d.enabled)
setSsoLabel(d.button_label || '')
})
.catch(() => { /* SSO optional */ })
const reason = new URLSearchParams(window.location.search).get('sso_error')
if (reason) {
const key = `auth.sso.err.${reason}`
const txt = t(key)
message.error(txt === key ? t('auth.sso.err.generic') : txt)
window.history.replaceState({}, '', window.location.pathname)
}
}, [t])
const onFinish = async (vals: LoginValues) => {
try {
@@ -106,6 +128,20 @@ export default function LoginPage({ onLogin }: Props) {
</div>
)}
{!totpRequired && ssoEnabled && (
<>
<Divider plain style={{ fontSize: 12, color: '#94a3b8' }}>{t('common.or')}</Divider>
<Button
block
icon={<LoginOutlined />}
style={{ marginBottom: 12 }}
onClick={() => { window.location.href = '/api/v1/auth/oidc/login' }}
>
{ssoLabel || t('auth.sso.login')}
</Button>
</>
)}
{!totpRequired && (
<div style={{ textAlign: 'center', fontSize: 12 }}>
<Link to="/reset-password">{t('auth.forgotPassword')}</Link>

View File

@@ -0,0 +1,226 @@
import { useEffect, useState } from 'react'
import {
Alert, Button, Card, Form, Input, Modal, Popconfirm, Space, Switch,
Table, Tabs, Tag, Tooltip, message,
} from 'antd'
import type { ColumnsType } from 'antd/es/table'
import { IdcardOutlined, PlusOutlined } from '@ant-design/icons'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import apiClient, { isEnvelope } from '../../api/client'
import { useAuthStore } from '../../stores/auth'
import PageHeader from '../../components/PageHeader'
interface RADIUSSettings { id: number; enabled: boolean; listen_addresses: string }
interface RADIUSClient {
id: number; name: string; ipaddr: string; active: boolean; description: string; secret_configured: boolean
}
interface RADIUSUser { id: number; username: string; active: boolean; password_configured: boolean }
export default function RADIUSPage() {
const { t } = useTranslation()
return (
<div>
<PageHeader icon={<IdcardOutlined />} title={t('radius.title')} subtitle={t('radius.intro')} />
<Tabs
defaultActiveKey="settings"
items={[
{ key: 'settings', label: t('radius.tabs.settings'), children: <SettingsTab /> },
{ key: 'clients', label: t('radius.tabs.clients'), children: <ClientsTab /> },
{ key: 'users', label: t('radius.tabs.users'), children: <UsersTab /> },
]}
/>
</div>
)
}
function SettingsTab() {
const { t } = useTranslation()
const qc = useQueryClient()
const isViewer = useAuthStore((s) => s.user?.role) === 'viewer'
const [msg, msgCtx] = message.useMessage()
const [form] = Form.useForm<RADIUSSettings>()
const { data } = useQuery({
queryKey: ['radius', 'settings'],
queryFn: async () => {
const r = await apiClient.get('/radius/settings')
return isEnvelope(r.data) ? (r.data.data as RADIUSSettings) : null
},
})
useEffect(() => { if (data) form.setFieldsValue(data) }, [data, form])
const save = useMutation({
mutationFn: async (v: RADIUSSettings) => apiClient.put('/radius/settings', v),
onSuccess: () => { msg.success(t('radius.saved')); void qc.invalidateQueries({ queryKey: ['radius', 'settings'] }) },
onError: (e: Error) => msg.error(t('radius.saveFailed') + ': ' + e.message),
})
return (
<Card size="small">
{msgCtx}
<Form<RADIUSSettings> form={form} layout="vertical" onFinish={(v) => save.mutate(v)}>
<Form.Item label={t('radius.settings.enabled')} name="enabled" valuePropName="checked">
<Switch disabled={isViewer} />
</Form.Item>
<Form.Item label={t('radius.settings.listen')} name="listen_addresses" extra={t('radius.csvHint')}>
<Input placeholder="0.0.0.0" disabled={isViewer} />
</Form.Item>
<Form.Item style={{ marginBottom: 0 }}>
<Tooltip title={isViewer ? t('auth.viewerBadge') : undefined}>
<Button type="primary" htmlType="submit" loading={save.isPending} disabled={isViewer}>{t('common.save')}</Button>
</Tooltip>
</Form.Item>
</Form>
</Card>
)
}
interface ClientForm { name: string; ipaddr: string; secret?: string; active: boolean; description: string }
function ClientsTab() {
const { t } = useTranslation()
const qc = useQueryClient()
const isViewer = useAuthStore((s) => s.user?.role) === 'viewer'
const [msg, msgCtx] = message.useMessage()
const [form] = Form.useForm<ClientForm>()
const [editing, setEditing] = useState<RADIUSClient | null>(null)
const [open, setOpen] = useState(false)
const { data } = useQuery({
queryKey: ['radius', 'clients'],
queryFn: async () => {
const r = await apiClient.get('/radius/clients')
return isEnvelope(r.data) ? ((r.data.data as { clients?: RADIUSClient[] }).clients ?? []) : []
},
})
const save = useMutation({
mutationFn: async (v: ClientForm) => {
const body: Record<string, unknown> = { ...v }
if (!v.secret) delete body.secret // leer = unverändert
return editing ? apiClient.put(`/radius/clients/${editing.id}`, body) : apiClient.post('/radius/clients', body)
},
onSuccess: () => { msg.success(t('radius.saved')); setOpen(false); setEditing(null); void qc.invalidateQueries({ queryKey: ['radius', 'clients'] }) },
onError: (e: Error) => msg.error(t('radius.saveFailed') + ': ' + e.message),
})
const del = useMutation({
mutationFn: async (id: number) => apiClient.delete(`/radius/clients/${id}`),
onSuccess: () => { msg.success(t('radius.deleted')); void qc.invalidateQueries({ queryKey: ['radius', 'clients'] }) },
onError: (e: Error) => msg.error(t('radius.deleteFailed') + ': ' + e.message),
})
const cols: ColumnsType<RADIUSClient> = [
{ title: t('radius.client.name'), dataIndex: 'name' },
{ title: t('radius.client.ipaddr'), dataIndex: 'ipaddr' },
{ title: t('radius.client.secret'), dataIndex: 'secret_configured', render: (v: boolean) => (v ? <Tag color="green"></Tag> : <Tag></Tag>) },
{ title: t('common.status'), dataIndex: 'active', render: (v: boolean) => <Tag color={v ? 'green' : 'default'}>{v ? t('common.active') : t('common.inactive')}</Tag> },
{
title: t('common.actions'), key: 'a', width: 200,
render: (_, r) => (
<Space>
{!isViewer && <Button size="small" onClick={() => { setEditing(r); form.setFieldsValue({ name: r.name, ipaddr: r.ipaddr, active: r.active, description: r.description, secret: '' }); setOpen(true) }}>{t('common.edit')}</Button>}
{!isViewer && <Popconfirm title={t('radius.client.deleteConfirm', { name: r.name })} onConfirm={() => del.mutate(r.id)}><Button size="small" danger>{t('common.delete')}</Button></Popconfirm>}
</Space>
),
},
]
return (
<Card size="small">
{msgCtx}
<Space style={{ marginBottom: 12 }}>
<Button type="primary" icon={<PlusOutlined />} disabled={isViewer}
onClick={() => { setEditing(null); form.resetFields(); form.setFieldsValue({ active: true } as Partial<ClientForm>); setOpen(true) }}>
{t('radius.client.add')}
</Button>
</Space>
<Table<RADIUSClient> rowKey="id" size="small" columns={cols} dataSource={data ?? []} pagination={false} />
<Modal title={editing ? t('radius.client.edit') : t('radius.client.add')} open={open} onCancel={() => setOpen(false)} onOk={() => form.submit()} confirmLoading={save.isPending} destroyOnClose>
<Form<ClientForm> form={form} layout="vertical" onFinish={(v) => save.mutate(v)}>
<Form.Item label={t('radius.client.name')} name="name" rules={[{ required: true }]}><Input placeholder="switch-core" /></Form.Item>
<Form.Item label={t('radius.client.ipaddr')} name="ipaddr" rules={[{ required: true }]}><Input placeholder="10.0.0.0/24" /></Form.Item>
<Form.Item label={t('radius.client.secret')} name="secret" extra={editing ? (editing.secret_configured ? t('radius.secretSet') : t('radius.secretUnset')) : undefined} rules={editing ? [] : [{ required: true }]}>
<Input.Password autoComplete="new-password" placeholder={editing?.secret_configured ? '••••••••' : ''} />
</Form.Item>
<Form.Item label={t('radius.client.description')} name="description"><Input /></Form.Item>
<Form.Item label={t('common.active')} name="active" valuePropName="checked"><Switch /></Form.Item>
</Form>
</Modal>
</Card>
)
}
interface UserForm { username: string; password?: string; active: boolean }
function UsersTab() {
const { t } = useTranslation()
const qc = useQueryClient()
const isViewer = useAuthStore((s) => s.user?.role) === 'viewer'
const [msg, msgCtx] = message.useMessage()
const [form] = Form.useForm<UserForm>()
const [editing, setEditing] = useState<RADIUSUser | null>(null)
const [open, setOpen] = useState(false)
const { data } = useQuery({
queryKey: ['radius', 'users'],
queryFn: async () => {
const r = await apiClient.get('/radius/users')
return isEnvelope(r.data) ? ((r.data.data as { users?: RADIUSUser[] }).users ?? []) : []
},
})
const save = useMutation({
mutationFn: async (v: UserForm) => {
const body: Record<string, unknown> = { ...v }
if (!v.password) delete body.password
return editing ? apiClient.put(`/radius/users/${editing.id}`, body) : apiClient.post('/radius/users', body)
},
onSuccess: () => { msg.success(t('radius.saved')); setOpen(false); setEditing(null); void qc.invalidateQueries({ queryKey: ['radius', 'users'] }) },
onError: (e: Error) => msg.error(t('radius.saveFailed') + ': ' + e.message),
})
const del = useMutation({
mutationFn: async (id: number) => apiClient.delete(`/radius/users/${id}`),
onSuccess: () => { msg.success(t('radius.deleted')); void qc.invalidateQueries({ queryKey: ['radius', 'users'] }) },
onError: (e: Error) => msg.error(t('radius.deleteFailed') + ': ' + e.message),
})
const cols: ColumnsType<RADIUSUser> = [
{ title: t('radius.user.username'), dataIndex: 'username' },
{ title: t('radius.user.password'), dataIndex: 'password_configured', render: (v: boolean) => (v ? <Tag color="green"></Tag> : <Tag></Tag>) },
{ title: t('common.status'), dataIndex: 'active', render: (v: boolean) => <Tag color={v ? 'green' : 'default'}>{v ? t('common.active') : t('common.inactive')}</Tag> },
{
title: t('common.actions'), key: 'a', width: 200,
render: (_, r) => (
<Space>
{!isViewer && <Button size="small" onClick={() => { setEditing(r); form.setFieldsValue({ username: r.username, active: r.active, password: '' }); setOpen(true) }}>{t('common.edit')}</Button>}
{!isViewer && <Popconfirm title={t('radius.user.deleteConfirm', { name: r.username })} onConfirm={() => del.mutate(r.id)}><Button size="small" danger>{t('common.delete')}</Button></Popconfirm>}
</Space>
),
},
]
return (
<Card size="small">
{msgCtx}
<Alert type="info" showIcon className="mb-16" message={t('radius.user.papHint')} />
<Space style={{ marginBottom: 12 }}>
<Button type="primary" icon={<PlusOutlined />} disabled={isViewer}
onClick={() => { setEditing(null); form.resetFields(); form.setFieldsValue({ active: true } as Partial<UserForm>); setOpen(true) }}>
{t('radius.user.add')}
</Button>
</Space>
<Table<RADIUSUser> rowKey="id" size="small" columns={cols} dataSource={data ?? []} pagination={false} />
<Modal title={editing ? t('radius.user.edit') : t('radius.user.add')} open={open} onCancel={() => setOpen(false)} onOk={() => form.submit()} confirmLoading={save.isPending} destroyOnClose>
<Form<UserForm> form={form} layout="vertical" onFinish={(v) => save.mutate(v)}>
<Form.Item label={t('radius.user.username')} name="username" rules={[{ required: true }]}><Input /></Form.Item>
<Form.Item label={t('radius.user.password')} name="password" extra={editing ? (editing.password_configured ? t('radius.secretSet') : t('radius.secretUnset')) : undefined} rules={editing ? [] : [{ required: true }]}>
<Input.Password autoComplete="new-password" placeholder={editing?.password_configured ? '••••••••' : ''} />
</Form.Item>
<Form.Item label={t('common.active')} name="active" valuePropName="checked"><Switch /></Form.Item>
</Form>
</Modal>
</Card>
)
}

View File

@@ -46,6 +46,27 @@ interface VIPSettingsValues {
gw_check_ip?: string
}
interface OIDCSettingsView {
enabled: boolean
issuer_url: string
client_id: string
scopes: string
email_claim: string
button_label: string
secret_configured: boolean
redirect_uri: string
}
interface OIDCFormValues {
enabled: boolean
issuer_url: string
client_id: string
client_secret?: string
scopes: string
email_claim: string
button_label: string
}
export default function SettingsPage() {
const { t } = useTranslation()
const qc = useQueryClient()
@@ -93,6 +114,41 @@ export default function SettingsPage() {
onError: (e: Error) => msg.error(t('cluster.vipCard.saveFailed') + ': ' + e.message),
})
const [oidcForm] = Form.useForm<OIDCFormValues>()
const { data: oidc } = useQuery({
queryKey: ['oidc', 'settings'],
queryFn: async () => {
const r = await apiClient.get('/oidc/settings')
return isEnvelope(r.data) ? r.data.data as OIDCSettingsView : null
},
})
useEffect(() => {
if (oidc) {
oidcForm.setFieldsValue({
enabled: oidc.enabled,
issuer_url: oidc.issuer_url,
client_id: oidc.client_id,
scopes: oidc.scopes,
email_claim: oidc.email_claim,
button_label: oidc.button_label,
client_secret: '',
})
}
}, [oidc, oidcForm])
const updateOIDC = useMutation({
mutationFn: async (v: OIDCFormValues) => {
const body: Record<string, unknown> = { ...v }
// leeres Secret = unverändert → Feld weglassen (Backend: nil)
if (!v.client_secret) delete body.client_secret
return apiClient.put('/oidc/settings', body)
},
onSuccess: () => {
msg.success(t('settings.oidc.saved'))
void qc.invalidateQueries({ queryKey: ['oidc', 'settings'] })
},
onError: (e: Error) => msg.error(t('settings.oidc.saveFailed') + ': ' + e.message),
})
const [emailForm] = Form.useForm<ContactEmailValues>()
const updateEmails = useMutation({
mutationFn: async (v: ContactEmailValues) => {
@@ -879,6 +935,49 @@ export default function SettingsPage() {
</Form>
</Card>
<Card title={<><GlobalOutlined /> {t('settings.oidc.title')}</>} className="mb-12" size="small">
<Typography.Paragraph type="secondary" style={{ marginBottom: 12 }}>
{t('settings.oidc.intro')}
</Typography.Paragraph>
<Form<OIDCFormValues> form={oidcForm} layout="vertical" onFinish={(v) => updateOIDC.mutate(v)}>
<Form.Item label={t('settings.oidc.enabled')} name="enabled" valuePropName="checked">
<Switch disabled={isViewer} />
</Form.Item>
<Form.Item label={t('settings.oidc.issuerUrl')} name="issuer_url" extra={t('settings.oidc.issuerHint')}>
<Input placeholder="https://keycloak.example.com/realms/edgeguard" disabled={isViewer} />
</Form.Item>
<Form.Item label={t('settings.oidc.clientId')} name="client_id">
<Input disabled={isViewer} />
</Form.Item>
<Form.Item
label={t('settings.oidc.clientSecret')}
name="client_secret"
extra={oidc?.secret_configured ? t('settings.oidc.secretSet') : t('settings.oidc.secretUnset')}
>
<Input.Password placeholder={oidc?.secret_configured ? '••••••••' : ''} autoComplete="new-password" disabled={isViewer} />
</Form.Item>
<Form.Item label={t('settings.oidc.scopes')} name="scopes">
<Input placeholder="openid email profile" disabled={isViewer} />
</Form.Item>
<Form.Item label={t('settings.oidc.emailClaim')} name="email_claim">
<Input placeholder="email" disabled={isViewer} />
</Form.Item>
<Form.Item label={t('settings.oidc.buttonLabel')} name="button_label">
<Input disabled={isViewer} />
</Form.Item>
<Form.Item label={t('settings.oidc.redirectUri')} extra={t('settings.oidc.redirectHint')}>
<Typography.Text copyable code style={{ fontSize: 12 }}>{oidc?.redirect_uri || ''}</Typography.Text>
</Form.Item>
<Form.Item style={{ marginBottom: 0 }}>
<Tooltip title={isViewer ? t('auth.viewerBadge') : undefined}>
<Button type="primary" htmlType="submit" loading={updateOIDC.isPending} disabled={isViewer}>
{t('common.save')}
</Button>
</Tooltip>
</Form.Item>
</Form>
</Card>
<Card title={<><LockOutlined /> {t('settings.passwordCardTitle')}</>} size="small">
<Form<ChangePasswordValues>
form={pwForm}

View File

@@ -12,7 +12,7 @@ Description: EdgeGuard — native Reverse-Proxy / LB / Forward-Proxy / VPN / Fir
PG Streaming Replication + provider Floating-IP for HTTP ingress).
.
This package ships the management API, scheduler and CLI.
Depends: postgresql-16 | postgresql-17, haproxy (>= 2.8), squid, wireguard-tools, unbound, chrony, nftables, keepalived, certbot, openssl, sudo, adduser, systemd, ca-certificates, ulogd2, ulogd2-json, iputils-ping, traceroute, dnsutils, curl, netcat-openbsd
Depends: postgresql-16 | postgresql-17, haproxy (>= 2.8), squid, wireguard-tools, unbound, chrony, kea-dhcp4-server, freeradius, nftables, keepalived, certbot, openssl, sudo, adduser, systemd, ca-certificates, ulogd2, ulogd2-json, iputils-ping, traceroute, dnsutils, curl, netcat-openbsd
Recommends: edgeguard-keydb (>= 6.3.4-edgeguard1), apparmor, fail2ban
Section: admin
Priority: optional

View File

@@ -31,6 +31,11 @@ case "$1" in
if getent group unbound >/dev/null; then
usermod -a -G unbound "$EG_USER" || true
fi
# freeradius: edgeguard muss die gerenderten clients.conf/authorize
# ins freeradius-lesbare Verzeichnis schreiben (setgid-Dir unten).
if getent group freeradius >/dev/null; then
usermod -a -G freeradius "$EG_USER" || true
fi
# systemd-journal + adm: damit edgeguard-api `journalctl -u …`
# ohne sudo lesen kann — wird für /api/v1/logs gebraucht
# (zentrale Log-Übersicht über alle Services).
@@ -48,6 +53,7 @@ case "$1" in
# gespeichert, alles sind Renderer-Outputs.
for d in /etc/edgeguard /etc/edgeguard/haproxy /etc/edgeguard/squid \
/etc/edgeguard/wireguard /etc/edgeguard/unbound \
/etc/edgeguard/kea \
/etc/edgeguard/nftables.d; do
install -d -m 0755 -o "$EG_USER" -g "$EG_USER" "$d"
done
@@ -118,6 +124,24 @@ edgeguard ALL=(root) NOPASSWD: /usr/bin/systemctl reload chrony.service
edgeguard ALL=(root) NOPASSWD: /bin/systemctl reload chrony.service
edgeguard ALL=(root) NOPASSWD: /usr/bin/systemctl restart chrony.service
edgeguard ALL=(root) NOPASSWD: /bin/systemctl restart chrony.service
# DHCP (Kea): Service-Lifecycle an dhcp_settings.enabled gekoppelt.
edgeguard ALL=(root) NOPASSWD: /usr/bin/systemctl restart kea-dhcp4-server.service
edgeguard ALL=(root) NOPASSWD: /bin/systemctl restart kea-dhcp4-server.service
edgeguard ALL=(root) NOPASSWD: /usr/bin/systemctl stop kea-dhcp4-server.service
edgeguard ALL=(root) NOPASSWD: /bin/systemctl stop kea-dhcp4-server.service
edgeguard ALL=(root) NOPASSWD: /usr/bin/systemctl enable kea-dhcp4-server.service
edgeguard ALL=(root) NOPASSWD: /bin/systemctl enable kea-dhcp4-server.service
edgeguard ALL=(root) NOPASSWD: /usr/bin/systemctl disable kea-dhcp4-server.service
edgeguard ALL=(root) NOPASSWD: /bin/systemctl disable kea-dhcp4-server.service
# RADIUS (FreeRADIUS): Service-Lifecycle an radius_settings.enabled gekoppelt.
edgeguard ALL=(root) NOPASSWD: /usr/bin/systemctl restart freeradius.service
edgeguard ALL=(root) NOPASSWD: /bin/systemctl restart freeradius.service
edgeguard ALL=(root) NOPASSWD: /usr/bin/systemctl stop freeradius.service
edgeguard ALL=(root) NOPASSWD: /bin/systemctl stop freeradius.service
edgeguard ALL=(root) NOPASSWD: /usr/bin/systemctl enable freeradius.service
edgeguard ALL=(root) NOPASSWD: /bin/systemctl enable freeradius.service
edgeguard ALL=(root) NOPASSWD: /usr/bin/systemctl disable freeradius.service
edgeguard ALL=(root) NOPASSWD: /bin/systemctl disable freeradius.service
edgeguard ALL=(root) NOPASSWD: /usr/bin/apt-get update -qq
edgeguard ALL=(root) NOPASSWD: /usr/bin/apt-get update
# Auto-Update-Toggle (Settings → Automatische Updates) schreibt/entfernt
@@ -228,6 +252,41 @@ SUDOERS
fi
chown "$EG_USER":"$EG_USER" /etc/chrony/conf.d/edgeguard.conf
chmod 0644 /etc/chrony/conf.d/edgeguard.conf
# Kea DHCPv4: managed config unter /etc/edgeguard/kea, vom Distro-
# Pfad symlinked (Distro-Original wird gesichert). Der Dienst läuft
# NUR, wenn der Operator DHCP aktiviert (Default AUS) — kein
# rogue DHCP-Server nach Install. Der Renderer enabled+restartet
# kea-dhcp4-server, sobald dhcp_settings.enabled=true gesetzt wird.
install -d /etc/kea
if [ -f /etc/kea/kea-dhcp4.conf ] && [ ! -L /etc/kea/kea-dhcp4.conf ]; then
mv /etc/kea/kea-dhcp4.conf /etc/kea/kea-dhcp4.conf.distro-bak
fi
ln -sfn /etc/edgeguard/kea/kea-dhcp4.conf /etc/kea/kea-dhcp4.conf
systemctl disable --now kea-dhcp4-server.service 2>/dev/null || true
# FreeRADIUS: managed clients.conf + authorize unter /etc/edgeguard/
# freeradius (setgid → Dateien erben Gruppe freeradius, 0640 → der
# freeradius-Dienst kann lesen). Symlinks von den Distro-Pfaden.
# Dienst default AUS (kein RADIUS bis Operator aktiviert).
if getent group freeradius >/dev/null; then
install -d -m 2750 -o "$EG_USER" -g freeradius /etc/edgeguard/freeradius
else
install -d -m 0750 -o "$EG_USER" -g "$EG_USER" /etc/edgeguard/freeradius
fi
if [ -d /etc/freeradius/3.0 ]; then
if [ -e /etc/freeradius/3.0/clients.conf ] && [ ! -L /etc/freeradius/3.0/clients.conf ]; then
mv /etc/freeradius/3.0/clients.conf /etc/freeradius/3.0/clients.conf.distro-bak
fi
ln -sfn /etc/edgeguard/freeradius/clients.conf /etc/freeradius/3.0/clients.conf
if [ -e /etc/freeradius/3.0/mods-config/files/authorize ] && [ ! -L /etc/freeradius/3.0/mods-config/files/authorize ]; then
mv /etc/freeradius/3.0/mods-config/files/authorize /etc/freeradius/3.0/mods-config/files/authorize.distro-bak
fi
install -d /etc/freeradius/3.0/mods-config/files
ln -sfn /etc/edgeguard/freeradius/authorize /etc/freeradius/3.0/mods-config/files/authorize
fi
systemctl disable --now freeradius.service 2>/dev/null || true
chmod 0440 /etc/sudoers.d/edgeguard
# ── Sysctl-Profil für Edge-Gateway (NAT + HAProxy + Forwarding) ──