55 Commits

Author SHA1 Message Date
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
Debian
f7dd7a3a4b feat(cluster): GUI-Repair-Button für Config-Drift + Stale-Chunk-Auto-Reload — v1.2.86
Cluster/Replication:
- Drift-Banner: Button 'Resync erzwingen' baut die PG-Logical-Replication-
  Subscription neu auf (via edgeguard-ctl cluster-setup-standby).
- Primary-Dispatch: Button auf dem Primary delegiert per mTLS an den
  Standby (POST /agent/cluster/repair-replication); auf dem Standby lokal.
- Status-Proxy Primary->Standby via Aggregator.FanOut; Erfolg = Job-success
  ODER drift_found wird false (--collect-Unit verschwindet nach Erfolg).
- Job als transiente systemd-Unit edgeguard-repair-replication.service
  (sudoers exact-match + festes Script wie upgrade.sh).
- Banner-Text korrigiert (keine 'Outbox').

Frontend-Stabilität:
- Stale-Chunk-Auto-Reload: Lazy-Import-Fehler nach Deploy ('Failed to fetch
  dynamically imported module') lösen einen einmaligen Reload aus (Loop-
  Schutz via sessionStorage) statt einer Fehlerseite. Globaler
  vite:preloadError-Listener + ErrorBoundary-Integration.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 12:10:41 +02:00
Debian
025854150d feat(waf): ausgeschlossene Regeln grün + Bereits-Ausnahme-Tag — v1.2.84
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 13:52:03 +02:00
Debian
134466293d fix(waf): Setup-Regeln (900xxx–909xxx) nicht als Alerts speichern — v1.2.83
CRS 900xxx/901xxx (init, body inspection, paranoia setup) feuern auf
JEDEM Request — keine Security-Events. Filter: nur rule_id >= 910000
wird als Alert in DB geschrieben. Buffer 512 → 2048.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 13:45:04 +02:00
Debian
13d0b557c1 fix(waf): letzter String-Fehler in crsRules.ts
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 13:30:26 +02:00
Debian
ac3223411a fix(waf): \" → ' in crsRules.ts
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 13:28:45 +02:00
Debian
19107ee702 fix(waf): TS-Syntaxfehler in crsRules.ts — Backslash escaped
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 13:27:57 +02:00
Debian
2ab9da8e36 fix(waf): CRS v4 Regeln extrahiert + Control-Flow-Regeln filtern — v1.2.82
- crsRules.ts neu: 331 echte CRS v4.7.0 Regeln aus installierten Dateien
  (v3-Nummernschema war falsch, v4 hat andere IDs — 949152 war skip-Regel)
- spoe.go: Regeln ohne Message nicht als Alert speichern

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 13:25:46 +02:00
Debian
f0120b64f3 feat(waf): CRS-Regel-Beschreibungen als Tooltip — v1.2.81
~250 CRS-Regeln (Setup, Scanner, Protocol, IP-Reputation, LFI, RFI, RCE,
PHP/Node.js/Java Injection, XSS, SQLi, Session-Fixation, Data-Leakage).
Rule-ID-Tag in Alerts-Tabelle + Ausnahmen-Liste zeigt Tooltip bei Hover.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 11:43:07 +02:00
Debian
4f31e18d66 fix(waf): i18n-Keys für Exception-Modal in waf.alerts verschoben — v1.2.80
exceptionModalTitle/Hint/NotePlaceholder lagen in waf.config statt waf.alerts.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 11:35:48 +02:00
Debian
8a4fefee73 feat(waf): Ausnahmen mit Notiz + Ausnahmen-Liste im Drawer — v1.2.79
- Migration 0039: exclusion_notes JSONB in waf_configs (rule_id → note)
- Model/Service/Handler: exclusion_notes in Upsert + GET durchgereicht
- Alerts-Tab: "Als Ausnahme"-Button öffnet Modal mit Notiz-Textarea;
  Notiz wird in exclusion_notes gespeichert
- Config-Drawer: Ausnahmen als Liste (Rule-ID + Notiz + Entfernen-Button)
  statt rohem Textfeld; Ausnahmen nur noch via Alert-Tab hinzufügbar
- i18n EN + DE

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 11:31:49 +02:00
Debian
801fa26da7 feat(waf): Regel direkt aus Alert als Ausnahme hinzufügen — v1.2.78
Jede Alert-Zeile hat jetzt einen "Als Ausnahme"-Button. Klick:
1. Lädt aktuelle WAF-Config der betroffenen Domain
2. Fügt die Rule-ID zu rule_exclusions hinzu (dedupliziert)
3. Speichert via PUT /waf/configs/:domain_id → triggert HAProxy-Reload
4. Erfolgsmeldung + WAF-Config-Query invalidiert

Button disabled wenn domain_id fehlt (Domain nicht aufgelöst) oder Viewer-Rolle.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 11:20:52 +02:00
Debian
220d9d7050 feat(waf): Alerts — Regelübereinstimmungen in DB + UI — v1.2.77
- Migration 0038: waf_alerts-Tabelle
- AlertWriter (Buffered-Channel → async DB-Write)
- SPOE: MatchedRules → sendAlert() nach ProcessRequestHeaders()
- API: GET /waf/alerts + DELETE /waf/alerts
- WAF-Page: Tabs Domains | Alarme; Alarme-Tabelle mit Rule-ID,
  Severity, Aktion (Detected/Blocked), URI, Client-IP + Purge-Button

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 10:40:43 +02:00
Debian
c83bb7b137 fix(haproxy): option tcplog + no option forwardfor entfernt — v1.2.76
HAProxy 3.x kennt weder 'option tcplog' in Backends noch 'no option forwardfor'
als Negation — beides ALERT-Fehler die HAProxy am Start hindern.
SPOE-Backend benötigt weder forwardfor noch tcplog.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 10:27:05 +02:00
Debian
08119f8ccf fix(waf): Engine nur bei Konfigurationsänderung neu bauen — v1.2.75
Manager.Reload() hat bisher bei jedem 30s-Tick alle Engines neu gebaut
(BuildEngine mit CRS = 2-5s). Fix: configKey (enabled, mode, paranoia_level,
updatedAt) cachen — Engine wird nur neu gebaut wenn sich der Key ändert.
Spart CPU und verhindert sporadische Latenzen im SPOE-Handling.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 07:07:30 +02:00
Debian
8041e3924d fix(waf): no option forwardfor + timeout server 1s für SPOE-Backend — v1.2.74
SPOE-Backend ist TCP-Mode — forwardfor aus defaults-Block gilt nicht,
HAProxy wirft Warning. no option forwardfor explizit setzen unterdrückt das.
timeout server auf 1s angehoben (konsistent mit processing-Timeout).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 07:04:26 +02:00
Debian
884c52a8f3 fix(waf): continue-on-error + timeout 1s + alle CRS-Dateien — v1.2.73
- SPOE-Config: option continue-on-error — HAProxy blockt nie wegen
  SPOE-Timeout (z.B. CRS-Engine-Load beim ersten Request). Ohne dieses
  Flag waren alle Requests geblockt wenn der WAF-Agent kurz nicht
  antwortete, auch für Domains ohne WAF-Konfiguration.
- SPOE-Config: timeout processing 50ms → 1s — CRS-Load braucht >50ms
- postinst: *.conf → * beim CRS-Copy — .data-Dateien wurden nicht
  mitkopiert, SecRule @pmFromFile scanners-user-agents.data fehlte

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-02 22:30:17 +02:00
Debian
05ac3344fa fix(waf): HAProxy-Reload nach WAF-Config + cfg-file → config — v1.2.72
- handlers/waf.go: Reloader-Func; nach Upsert wird HAProxy async
  neu gerendert (SPOE-Filter erscheint/verschwindet je nach enabled-Stand)
- main.go: haproxyReloader an NewWafHandler übergeben
- haproxy.cfg.tpl: cfg-file → config (HAProxy 3.0 kennt cfg-file nicht)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-02 22:10:55 +02:00
Debian
4d81d31022 fix(waf): TS-Fehler in WAF-Page beheben
- Badge-Import entfernt (unused)
- fetchWafConfig: r.data.data korrekt gecastet
- WafFormValues-Interface für Form (rule_exclusions_str, trusted_proxies_str)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-02 22:04:11 +02:00
Debian
4f887df658 feat(waf): Phase 5 — WAF-UI (per-Domain Konfiguration) — v1.2.71
- pages/WAF/index.tsx: neue WAF-Seite mit Status-Strip,
  Domänen-Tabelle (Toggle/Mode/PL) + Konfigurations-Drawer pro Domain
  (enabled, mode, paranoia_level 1-4, rule_exclusions, trusted_proxies,
  custom_rules). Quick-Toggle ohne Drawer; Hinweis: erst Detection, dann Blocking.
- App.tsx: /waf Route + lazy import
- Sidebar.tsx: WAF im Security-Bereich
- i18n EN + DE

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-02 22:03:01 +02:00
Debian
66dec8cf61 fix(ui): ErrorBoundary bei Navigation resetten — v1.2.70
ErrorBoundary lag in main.tsx außerhalb des Routers und hatte keinen
Zugriff auf useLocation. Einmal gecatchter Fehler blieb erhalten bis
zum nächsten Reload — daher "EdgeGuard konnte nicht laden" bei
Navigation zum Dashboard.

Fix: LocationKeyBoundary-Wrapper innerhalb des BrowserRouter mit
key={pathname} — React remountet die ErrorBoundary bei jedem
Routenwechsel und löscht damit den Error-State automatisch.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-02 21:03:23 +02:00
Debian
3a122ffb0f feat(waf): Phase 4 — Packaging, CRS, systemd, sudoers — v1.2.69
- Makefile: edgeguard-waf zu BINARIES hinzugefügt
- deploy/systemd/edgeguard-waf.service: systemd-Unit (User=edgeguard,
  After=postgresql + edgeguard-api, Hardening, ReadWritePaths=/var/log/edgeguard)
- build-package.sh: edgeguard-waf Binary + Service ins .deb
- postinst: OWASP CRS v4.7.0 Download bei Erstinstall nach
  /usr/share/edgeguard/waf/crs/; graceful wenn kein Internet vorhanden
- postinst: edgeguard-waf.service enable + start bei install/upgrade
- postinst: sudoers für start/stop/enable/disable/restart edgeguard-waf.service
- system.go: edgeguard-waf in servicesToCheck + toggleAllowlist

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-02 16:03:37 +02:00
Debian
bf16ce6666 feat(waf): Phase 3 — HAProxy SPOE-Integration — v1.2.68
- haproxy.cfg.tpl: filter spoe + deny_status 403 + spoe-edgeguard-waf
  Backend (nur gerendert wenn WAFEnabled=true)
- haproxy.go: WAFEnabled in View; WafRepo.ListEnabled() prüft ob WAF
  aktiv; SPOE-Config-File (coraza-spoe.cfg) wird bei WAFEnabled
  atomar geschrieben; SPOEConfigPath konfigurierbar
- waf/spoe.go: uri statt path+query (HAProxy url-Sample = volle URI)

SPOE-Config definiert:
  - Agent: edgeguard-waf-agent, var-prefix=waf, timeout processing 50ms
  - Message: src, method, uri=url, ver=req.ver, headers=req.hdrs,
             host=req.hdr(host)
  - Backend: spoe-edgeguard-waf → 127.0.0.1:9000

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-02 15:53:07 +02:00
Debian
bd32bc343a feat(waf): Phase 2 — edgeguard-waf Binary + SPOE + Coraza Engine — v1.2.67
- cmd/edgeguard-waf/: neues Binary — lädt WAF-Configs aus DB, startet
  SPOE-Agent auf 127.0.0.1:9000, refreshed Configs alle 30s
- internal/waf/engine.go: BuildEngine() — Coraza WAF aus WafConfig bauen
  (SecLang-Direktiven: RuleEngine, PL, CRS-Include, Exclusions, Custom)
- internal/waf/manager.go: Manager — per-Hostname Coraza-Engine-Cache
  (thread-safe, Lazy-Init via Reload(), Port-Strip, IPv6-Brackets)
- internal/waf/spoe.go: SPOEAgent — haproxy-go SPOE-Handler
  (src/method/path/query/ver/host/headers aus HAProxy-Vars,
   Coraza-Transaction, Blocking: txn.waf.status=403 setzen)
- services/waf/waf.go: ListAllWithDomain() — JOIN domains+waf_configs
- go.mod: coraza/v3 v3.7.0 + dropmorepackets/haproxy-go v0.0.8

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-02 15:43:15 +02:00
Debian
72f793552e feat(waf): Phase 1 — Migration + Service + Handler — v1.2.66
- Migration 0037: waf_configs-Tabelle (domain_id FK, enabled=false default,
  mode/paranoia_level/rule_exclusions/trusted_proxies/custom_rules)
- models/waf.go: WafConfig-Model
- services/waf/waf.go: Repo (List, GetByDomain, Upsert, ListEnabled)
- handlers/waf.go: GET /waf/configs, GET /waf/configs/:id, PUT /waf/configs/:id
  — GET liefert Default-Config (disabled) wenn noch kein Row existiert
- main.go: WafHandler registriert

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-02 15:28:29 +02:00
Debian
b7b40ad641 fix(firewall): groupedSections nach filteredRules + pagination-Prop entfernt
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-02 14:06:24 +02:00
Debian
d425b696f1 feat(firewall): Regeln nach Zone-Pair gruppieren — v1.2.65
- Regeln werden standardmäßig nach Zone-Pair (src→dst) gruppiert
- Jede Gruppe hat einen farbigen Header mit Zone-Badges + Regelanzahl
- Toggle-Button in der Filter-Bar: Gruppen-Ansicht ↔ flache Liste
- Move-up/down bleibt global korrekt (Priority über alle Gruppen)
- CSS: .fw-zone-section* mit nahtlosem Header → Tabelle Übergang
- i18n EN + DE

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-02 14:04:56 +02:00
Debian
9580070a50 fix(crowdsec): cscli-Aufrufe nur wenn Agent läuft — v1.2.64
Wenn CrowdSec gestoppt war, hing ServiceStatus auf cscli decisions/alerts/
bouncers/machines (Socket des gestoppten Agents). Der Status-Endpoint
antwortete nie → status=undefined im UI → Switch war disabled (rotes Schild).

Fix: cscli-Datenabrufe (decisions, alerts, bouncers, machines) nur wenn
AgentRunning=true. Version-Abruf via cscli version bleibt (schlägt schnell
fehl wenn Agent gestoppt, kein Hang).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-02 09:09:39 +02:00
Debian
414dad6b3b feat(crowdsec): IDS ein-/ausschalten via Switch — v1.2.63
- system.go: ServiceToggle-Endpoint (POST /system/service-toggle)
  start/stop + enable/disable für crowdsec + crowdsec-firewall-bouncer
- system.go: crowdsec + crowdsec-firewall-bouncer in servicesToCheck
- postinst: sudoers-Einträge für systemctl start/stop/enable/disable
  beider CrowdSec-Units
- UI: Switch im StatusStrip für Agent + Bouncer, getrennt schaltbar,
  disabled wenn CrowdSec nicht installiert oder Viewer-Rolle

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 22:17:22 +02:00
Debian
112a945b5c fix(ui): Design-Harmonisierung mit mail-gateway — v1.2.62
- Header: blur 12px, box-shadow, padding 0 24px, bg rgba(0.97)
- Content-Area: padding 24px (war 12px/16px); @992px 16px (war 8px)
- Sidebar: active-color via CSS-Variable statt hardcodiertem #60a5fa
- Sidebar: item font-size 14px, padding 8px 10px (wie mail-gateway)
- Tabs: ink-bar height 3px (war 2px)
- stats-window-label: Pill-Style statt Block+kursiv
- page-toolbar: align flex-start, margin-bottom 20px, gap 16px
- ant-alert: left-accent 3px pro Typ (info/success/warning/error)
- Neu: ant-statistic, stat-card-*, ant-descriptions-*, ant-form-item rhythm

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 21:06:33 +02:00
Debian
1740cb7ae2 feat(crowdsec): CrowdSec IDS/IPS Management — v1.2.61
- Backend: internal/crowdsec/service.go — vollständige cscli-Wrapper
  (Decisions, Alerts, Bouncers, Machines, Collections, ServiceStatus)
- Handler: 12 REST-Endpoints mit Audit-Logging unter /crowdsec/*
- Migration 0036: crowdsec_settings-Tabelle
- postinst: CrowdSec-Auto-Install (crowdsec + crowdsec-firewall-bouncer-nftables)
  inkl. sudoers-Einträge für alle cscli-Operationen
- systemd: /var/lib/crowdsec in ReadWritePaths
- UI: CrowdSec-Page mit StatusStrip + 5 Tabs (Decisions, Alerts, Bouncers,
  Machines, Collections), Sidebar-Eintrag, i18n EN+DE
- firewall: flush ruleset → flush table inet edgeguard (CrowdSec-nftables-Table
  bleibt bei Firewall-Render erhalten)
- cluster: Firewall-Reload nur bei echter IP-Änderung, nicht bei jedem
  periodischen Secondary-Heartbeat (verhindert nftables-Counter-Reset)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 20:25:48 +02:00
Debian
e999eb68c2 fix(firewall/ui): Card-Tabs + no-cache Header für index.html
- Tabs type="card": jeder Tab hat eigene sichtbare Box statt Unterstrich
- cardBg: #F1F5F9 für inaktive Card-Tabs
- main.go: index.html + SPA-Fallback mit no-cache Header ausliefern
  (Vite hashed assets /assets/* bleiben immutable=1Jahr cached)
- Verhindert dass der Browser veraltete index.html nach Updates cached

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-31 23:37:56 +02:00
Debian
a84b9ae10a fix(firewall): Tab-Farben im globalen ConfigProvider — AntD 6 Tokens korrekt
Nested ConfigProvider überschreibt Root-Provider in AntD 6 nicht zuverlässig.
Tabs-Tokens (itemColor #334155, hover #0F172A, selected/inkBar #0EA5E9,
fontSize 13, padding 10px 18px) direkt in globalem antdTheme in App.tsx.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-31 23:16:41 +02:00
Debian
836016648c fix(firewall): Tab-Farben via ConfigProvider statt CSS-Override
AntD 6 CSS-in-JS überschreibt externe Styles — ConfigProvider mit
Tabs-Tokens ist der korrekte Weg: itemColor #475569, hover #1e293b,
active #1677ff, fontSize 13px, Padding 10px 16px.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-31 19:15:37 +02:00
Debian
6a4460dfdc fix(firewall): Tab-Navigation deutlich besser lesbar
- fw-tabs: Tab-Schrift 13px, font-weight 500, Farbe #64748B (statt AntD-Standard grau)
- Aktiver Tab: #1677ff + font-weight 600 + 2px Unterstrich
- Hover: #334155 für klare Reaktion
- Ink-Bar: 2px Höhe mit abgerundeten Ecken
- Nav-Separator: #E2E8F0 statt default hellgrau

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-31 19:02:25 +02:00
Debian
98aa7c0bcd fix(sidebar): Navigationspunkte besser lesbar — Kontrast + Trennlinien
- sidebar-section-label: #475569 → rgba(255,255,255,0.38) (Kontrast ~5:1)
- Aktiver Eintrag: color: #60a5fa + font-weight 500 statt #1677ff
- Hover: rgba(255,255,255,0.88) statt #CBD5E1
- Ruhige Items: rgba(255,255,255,0.6) statt #94A3B8
- Child-Items: rgba(255,255,255,0.42), eingerückt 32px
- sidebar-section--bordered: Trennlinie zwischen Sektionsgruppen
- Sektions-Padding leicht erhöht (18px oben)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-31 18:54:51 +02:00
Debian
b48ba65ce3 feat(firewall): Inline-Note + Labels direkt in der Tabelle
- Migration 0035: note TEXT + labels TEXT[] für firewall_rules + nat_rules
- PATCH /firewall/rules/:id + /nat-rules/:id für note/labels Updates
- InlineNote: gold Tag mit MessageOutlined, Klick zum Bearbeiten (Enter/Blur speichert)
- InlineLabels: geekblue Tags mit X-Button zum Entfernen, "+" zum Hinzufügen
- Name-Spalte: Name + Labels + Note in Zeile 1, comment/auto-desc in Zeile 2
- Gleiche UX für NAT-Regeln

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-31 18:46:54 +02:00
Debian
13cb9a8fc4 feat(firewall): NAT-Regeln enterprise-Design + Duplicate
- Status-Dot, monospace Priorität, icon-only Hover-Actions
- Duplicate-Button (CopyOutlined) — disabled=false copy + priority+1
- rowClassName für deaktivierte NAT-Regeln (fw-rule-row--disabled)
- Spaltenheader bereinigt (kein 'Edit'-Text mehr)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-31 18:27:28 +02:00
Debian
1d06b28064 feat: HA-Cluster v1.2.x — Split-Brain, TOTP, Enterprise-FW, Drift-Fix, VIP-Recovery
- keepalived: pg_role='standby' hat Vorrang vor role für BACKUP-Bestimmung
- keepalived-master.sh: gecrasht Dienste beim MASTER-Übergang starten (nicht nur reload)
- confighash: ip_addresses per Interface-Name hashen statt per FK (Cross-Node-Drift-Fix)
- TOTP/2FA: RFC 6238 — Setup-Flow, QR-Code, Admin-Disable; two-step Login
- Firewall-UI: Enterprise-Design — auto-Beschreibung, icon-only Actions, zero-hit Indikator
- fe80-Filter: Link-local IPv6 aus NTP/DNS Listen-Dropdowns entfernen
- VIP-Dashboard, Dual-Path VRRP, GW-Tracking (Migrations 0033/0034)
- Forward Proxy + DNS erweiterte Einstellungen (Migrations 0031/0032)
- unbound-control: edgeguard in unbound-Gruppe via postinst

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-31 18:18:31 +02:00
Debian
49899e984c fix(network): context.Background() für applyAsync + StartLimitIntervalSec=0
applyAsync()-Goroutinen in networks.go und ipaddresses.go nutzten
c.Request.Context() — der wird gecancelt wenn der Handler zurückkehrt,
was zu "query: context canceled" in jedem Apply-Lauf führte.
Fix: context.Background() direkt in der Goroutine.

StartLimitIntervalSec=0 in beiden apply-Services verhindert dass
systemd bei mehrfachen schnellen Triggers (Burst > 5) drosselt.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-30 13:41:29 +02:00
Debian
0ee754e231 feat(network): VLAN/bridge/bond interface provisioner + IP-address apply
Fügt zwei neue systemd-oneshot-Services hinzu:
- edgeguard-interfaces.service: erstellt VLAN/bridge/bond-Interfaces
  (ip link add/del) wenn der Operator sie über die GUI anlegt/entfernt.
  Ethernet + WireGuard bleiben OS-managed.
- edgeguard-ipaddresses.service: bindet/entfernt IP-Adressen (ip addr
  add/del) nach jeder GUI-Mutation; läuft jetzt After=edgeguard-interfaces
  damit Interfaces immer vor den Adressen existieren.

Beide Services triggern per applyAsync() in den zugehörigen Handlern
(networks.go → Interfaces, ipaddresses.go → Adressen). Diff-Ansatz
über *-applied.conf verhindert dass manuell gebundene Adressen/
Interfaces angefasst werden.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-30 13:25:26 +02:00
131 changed files with 14469 additions and 952 deletions

View File

@@ -4,7 +4,7 @@
GO ?= $(shell which go || echo /usr/local/go/bin/go)
MODULE := git.netcell-it.de/projekte/edgeguard-native
BINARIES := edgeguard-api edgeguard-scheduler edgeguard-ctl
BINARIES := edgeguard-api edgeguard-scheduler edgeguard-ctl edgeguard-waf
VERSION := $(shell cat VERSION 2>/dev/null || echo 0.0.1-dev)
LDFLAGS := -s -w -X main.version=$(VERSION)
GOFLAGS := -trimpath -mod=readonly

View File

@@ -1 +1 @@
1.2.15
1.2.98

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,10 +60,14 @@ 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"
)
var version = "1.2.13"
var version = "1.2.35"
func main() {
addr := os.Getenv("EDGEGUARD_API_ADDR")
@@ -186,10 +192,14 @@ func main() {
} else {
slog.Warn("cluster: cannot normalize primary URL for push", "primary", st.PrimaryFQDN, "error", normErr)
}
// Logical Replication liefert Änderungen automatisch — aber Service-
// Configs (haproxy.cfg, nftables …) müssen nach jeder Änderung neu
// gerendert werden. Diese Goroutine erkennt hash-Änderungen und rendert.
go runSecondaryConfigRender(context.Background(), pool)
// 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
@@ -236,6 +246,12 @@ func main() {
}
}
// Secondary-Config-Render: jetzt wo der Aggregator bereit ist starten.
// Aggregator wird für Cert-Sync (mTLS GET /agent/cluster/tls-certs) benötigt.
if nodeID != "" && st != nil && st.IsClusterNode && st.PrimaryFQDN != "" {
go runSecondaryConfigRender(context.Background(), pool, secrets.New(""), clusterAggregator, nodeID)
}
auditRepo := audit.New(pool)
domainsRepo := domains.New(pool)
domainHeadersRepo := domainheaders.New(pool)
@@ -311,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)
@@ -342,6 +366,7 @@ func main() {
WithAggregator(clusterAggregator).
WithJoinFlow(clusterTLSStore, joinTokens).
WithPeerReloader(peerReloader).
WithAudit(auditRepo, nodeID).
WithVersion(version)
clusterHdl.Register(authed)
// /cluster/issue-cert läuft PUBLIC — joining Peer hat noch
@@ -378,6 +403,8 @@ func main() {
return firewallrender.New(pool).Render(ctx)
}
handlers.NewFirewallHandler(fwZones, fwAddrObj, fwAddrGrp, fwSvc, fwSvcGrp, fwRules, fwNAT, auditRepo, nodeID, fwReloader, pool).Register(authed)
handlers.NewCrowdSecHandler(auditRepo, nodeID).Register(authed)
handlers.NewWafHandler(wafsvc.New(pool), auditRepo, nodeID, haproxyReloader).Register(authed)
// withFW wraps a service-reloader so that AFTER the service is
// reloaded, the firewall is also re-rendered. Necessary for
@@ -428,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
@@ -582,10 +623,18 @@ func mountUI(r *gin.Engine) {
return
}
if info, err := os.Stat(full); err == nil && !info.IsDir() {
// Vite hashed assets are immutable — cache them forever.
// index.html must never be cached so updates take effect.
if strings.HasPrefix(clean, "/assets/") {
c.Header("Cache-Control", "public, max-age=31536000, immutable")
} else {
c.Header("Cache-Control", "no-cache, no-store, must-revalidate")
}
c.File(full)
return
}
// SPA fallback — React Router renders the right page.
c.Header("Cache-Control", "no-cache, no-store, must-revalidate")
c.File(indexPath)
})
}
@@ -706,14 +755,24 @@ func runClusterHeartbeat(ctx context.Context, pool *pgxpoolPool, localID, versio
// Service-Configs wenn die Logical Replication Änderungen vom Primary
// geliefert hat. Erkennt das an einem geänderten config_hash.
// Tick: 5 min — balanciert Reaktionszeit gegen Reload-Overhead.
func runSecondaryConfigRender(ctx context.Context, pool *pgxpoolPool) {
//
// Cert-Sync läuft auf jedem Tick unabhängig vom config_hash, da certbot-
// Renewals auf dem Primary den Hash nicht ändern.
func runSecondaryConfigRender(ctx context.Context, pool *pgxpoolPool, box *secrets.Box, agg *aggregator.Aggregator, localID string) {
const tick = 5 * time.Minute
t := time.NewTicker(tick)
defer t.Stop()
var lastHash string
render := func() {
rCtx, cancel := context.WithTimeout(ctx, 60*time.Second)
rCtx, cancel := context.WithTimeout(ctx, 90*time.Second)
defer cancel()
// TLS-Zertifikate bei jedem Tick synchronisieren — unabhängig vom
// config_hash, da certbot-Renewals den Hash nicht berühren.
if err := handlers.SyncTLSCertsFromPrimary(rCtx, pool, agg, localID); err != nil {
slog.Warn("cluster: cert sync failed", "error", err)
}
hash, err := cluster.ComputeConfigHash(rCtx, pool)
if err != nil || hash == lastHash {
return
@@ -728,10 +787,32 @@ func runSecondaryConfigRender(ctx context.Context, pool *pgxpoolPool) {
if err := firewallrender.New(pool).Render(rCtx); err != nil {
slog.Warn("cluster: secondary nftables render failed", "error", err)
}
// Weitere Dienste (Squid, Unbound, Chrony, WireGuard) werden bei
// Änderungen an ihren spezifischen Tabellen ebenfalls neu gerendert.
// render-config ohne Reload: die Dienste merken Änderungen selbst
// (HAProxy/nftables über systemctl reload, der oben bereits läuft).
// WireGuard — Interface-Configs + wg-quick@<iface> reload
if err := wgrender.New(pool, box).Render(rCtx); err != nil {
slog.Warn("cluster: secondary wireguard render failed", "error", err)
}
// Squid forward proxy
if err := squidrender.New(pool).Render(rCtx); err != nil {
slog.Warn("cluster: secondary squid render failed", "error", err)
}
// Unbound DNS
if err := unboundrender.New(pool).Render(rCtx); err != nil {
slog.Warn("cluster: secondary unbound render failed", "error", err)
}
// Chrony NTP
if err := chronyrender.New(pool).Render(rCtx); err != nil {
slog.Warn("cluster: secondary chrony render failed", "error", err)
}
// Netzwerk-Interfaces (VLAN/Bridge/Bond) — erstellt Interface-Objekte,
// weist aber KEINE IPs zu (das ist node-spezifisch und darf nicht aus
// der Replikation kommen — sonst IP-Konflikt mit dem Primary).
if err := networkifs.NewGenerator(networkifs.New(pool)).Render(rCtx); err != nil {
slog.Warn("cluster: secondary interfaces render failed", "error", err)
}
// IP-Adressen werden auf dem Secondary NICHT aus der Replikation
// angewendet. Jeder Node konfiguriert seine eigenen IPs statisch
// (z.B. /etc/network/interfaces). Floating-Service-IPs werden von
// Keepalived verwaltet — nicht vom Renderer.
}
// Initialer Check nach kurzem Delay (Replication braucht einen Moment)
select {
@@ -751,12 +832,20 @@ func runSecondaryConfigRender(ctx context.Context, pool *pgxpoolPool) {
}
// 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() {
@@ -780,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
@@ -357,7 +360,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 +473,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 +519,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 +571,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

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

@@ -41,7 +41,7 @@ import (
"git.netcell-it.de/projekte/edgeguard-native/internal/services/tlscerts"
)
var version = "1.2.15"
var version = "1.2.35"
const (
// renewTickInterval — how often we re-evaluate expiring certs.

107
cmd/edgeguard-waf/main.go Normal file
View File

@@ -0,0 +1,107 @@
// Command edgeguard-waf is the per-domain WAF SPOE agent for EdgeGuard.
// HAProxy connects to it via the SPOE protocol (127.0.0.1:9000).
// It loads per-domain WAF configs from PostgreSQL and uses Coraza v3
// with the OWASP Core Rule Set to inspect HTTP requests.
package main
import (
"context"
"log/slog"
"os"
"os/signal"
"syscall"
"time"
"git.netcell-it.de/projekte/edgeguard-native/internal/database"
"git.netcell-it.de/projekte/edgeguard-native/internal/models"
"git.netcell-it.de/projekte/edgeguard-native/internal/services/waf"
intwaf "git.netcell-it.de/projekte/edgeguard-native/internal/waf"
)
func main() {
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
dsn := database.ConnStringFromEnv()
pool, err := database.Open(ctx, dsn)
if err != nil {
slog.Error("waf: db connect", "error", err)
os.Exit(1)
}
defer pool.Close()
if err := database.Migrate(ctx, ""); err != nil {
slog.Error("waf: migrate", "error", err)
os.Exit(1)
}
repo := waf.New(pool)
crsDir := os.Getenv("EDGEGUARD_WAF_CRS_DIR")
if crsDir == "" {
crsDir = intwaf.DefaultCRSDir
}
spoeAddr := os.Getenv("EDGEGUARD_WAF_ADDR")
if spoeAddr == "" {
spoeAddr = intwaf.DefaultSPOEAddr
}
mgr := intwaf.NewManager(crsDir)
// Initial load.
if err := reload(ctx, repo, mgr); err != nil {
slog.Error("waf: initial load", "error", err)
os.Exit(1)
}
// Periodic config refresh every 30 seconds.
go func() {
t := time.NewTicker(30 * time.Second)
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case <-t.C:
if err := reload(ctx, repo, mgr); err != nil {
slog.Warn("waf: reload", "error", err)
}
}
}
}()
alertWriter := intwaf.NewAlertWriter(pool, 2048)
agent := intwaf.SPOEAgent{
Manager: mgr,
AlertWriter: alertWriter,
Addr: spoeAddr,
}
slog.Info("waf: SPOE agent starting", "addr", spoeAddr, "crs", crsDir)
if err := agent.ListenAndServe(ctx); err != nil && ctx.Err() == nil {
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.
func reload(ctx context.Context, repo *waf.Repo, mgr *intwaf.Manager) error {
configs, err := repo.ListAllWithDomain(ctx)
if err != nil {
return err
}
domains := make([]intwaf.DomainConfig, 0, len(configs))
for _, c := range configs {
domains = append(domains, intwaf.DomainConfig{
Hostname: c.Hostname,
Config: c.Config,
})
}
return mgr.Reload(domains)
}
// Ensure models package is used (imported transitively via services/waf).
var _ = models.WafConfig{}

View File

@@ -41,7 +41,7 @@ SystemCallFilter=@system-service
# direkt in den distro-Conf-Dir (chrony+unbound) bzw. legen Symlinks
# nach /etc/edgeguard/wireguard (wg). Ohne diese Pfade scheitern alle
# UI-Mutationen an DNS/NTP/WireGuard-Settings still mit EROFS.
ReadWritePaths=/etc/edgeguard /var/lib/edgeguard /var/log/edgeguard /var/backups/edgeguard /var/lib/apt /var/cache/apt /etc/apt/apt.conf.d /etc/chrony/conf.d /etc/unbound/unbound.conf.d /etc/wireguard
ReadWritePaths=/etc/edgeguard /var/lib/edgeguard /var/log/edgeguard /var/backups/edgeguard /var/lib/apt /var/cache/apt /etc/apt/apt.conf.d /etc/chrony/conf.d /etc/unbound/unbound.conf.d /etc/wireguard /var/lib/crowdsec
[Install]
WantedBy=multi-user.target

View File

@@ -0,0 +1,32 @@
[Unit]
Description=EdgeGuard WAF SPOE Agent (Coraza/OWASP CRS)
Documentation=https://git.netcell-it.de/projekte/edgeguard-native
After=network-online.target postgresql.service edgeguard-api.service
Wants=network-online.target
Requires=postgresql.service
[Service]
Type=simple
User=edgeguard
Group=edgeguard
ExecStart=/usr/bin/edgeguard-waf
Restart=on-failure
RestartSec=5
# Hardening — WAF agent only needs DB access and one TCP listen socket.
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectControlGroups=true
PrivateTmp=true
PrivateDevices=true
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
SystemCallFilter=@system-service
# CRS rules are read from /usr/share/edgeguard/waf/crs/ (read-only, OK).
# Alerts/logs are written to /var/log/edgeguard/.
ReadWritePaths=/var/log/edgeguard
[Install]
WantedBy=multi-user.target

27
go.mod
View File

@@ -3,23 +3,32 @@ module git.netcell-it.de/projekte/edgeguard-native
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
github.com/go-acme/lego/v4 v4.35.2
github.com/gorilla/websocket v1.5.3
github.com/jackc/pgx/v5 v5.9.2
github.com/minio/minio-go/v7 v7.1.0
github.com/pkg/sftp v1.13.10
github.com/pquerna/otp v1.5.0
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 (
github.com/boombuler/barcode v1.0.1 // indirect
github.com/bytedance/sonic v1.11.6 // indirect
github.com/bytedance/sonic/loader v0.1.1 // indirect
github.com/cenkalti/backoff/v5 v5.0.3 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/cloudwego/base64x v0.1.4 // indirect
github.com/cloudwego/iasm v0.2.0 // indirect
github.com/corazawaf/libinjection-go v0.3.2 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/gabriel-vasile/mimetype v1.4.13 // indirect
github.com/gin-contrib/sse v0.1.0 // indirect
@@ -28,34 +37,43 @@ require (
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.23.0 // indirect
github.com/goccy/go-json v0.10.2 // indirect
github.com/goccy/go-json v0.10.5 // indirect
github.com/goccy/go-yaml v1.19.2 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/gotnospirit/makeplural v0.0.0-20180622080156-a5f48d94d976 // indirect
github.com/gotnospirit/messageformat v0.0.0-20221001023931-dfe49f1eb092 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/json-iterator/go v1.1.13-0.20220915233716-71ac16282d12 // indirect
github.com/kaptinlin/go-i18n v0.1.4 // indirect
github.com/kaptinlin/jsonschema v0.4.6 // indirect
github.com/klauspost/compress v1.18.5 // indirect
github.com/klauspost/cpuid/v2 v2.2.11 // indirect
github.com/klauspost/crc32 v1.3.0 // indirect
github.com/kr/fs v0.1.0 // indirect
github.com/kr/pretty v0.3.1 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/magefile/mage v1.17.0 // indirect
github.com/mattn/go-isatty v0.0.21 // indirect
github.com/mfridman/interpolate v0.0.2 // indirect
github.com/miekg/dns v1.1.72 // indirect
github.com/minio/crc64nvme v1.1.1 // indirect
github.com/minio/md5-simd v1.1.2 // indirect
github.com/minio/minio-go/v7 v7.1.0 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect
github.com/pelletier/go-toml/v2 v2.2.2 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/petar-dambovaliev/aho-corasick v0.0.0-20250424160509-463d218d4745 // indirect
github.com/philhofer/fwd v1.2.0 // indirect
github.com/pkg/sftp v1.13.10 // indirect
github.com/rs/xid v1.6.0 // indirect
github.com/sethvargo/go-retry v0.3.0 // indirect
github.com/tidwall/gjson v1.18.0 // indirect
github.com/tidwall/match v1.1.1 // indirect
github.com/tidwall/pretty v1.2.1 // indirect
github.com/tinylib/msgp v1.6.1 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.12 // indirect
github.com/valllabh/ocsf-schema-golang v1.0.3 // indirect
github.com/zeebo/xxh3 v1.1.0 // indirect
go.uber.org/multierr v1.11.0 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
@@ -68,4 +86,5 @@ require (
golang.org/x/tools v0.44.0 // indirect
google.golang.org/protobuf v1.36.11 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
rsc.io/binaryregexp v0.2.0 // indirect
)

64
go.sum
View File

@@ -1,3 +1,6 @@
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
github.com/boombuler/barcode v1.0.1 h1:NDBbPmhS+EqABEs5Kg3n/5ZNjy73Pz7SIV+KCeqyXcs=
github.com/boombuler/barcode v1.0.1/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0=
github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4=
github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM=
@@ -10,13 +13,25 @@ github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/
github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg=
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
github.com/corazawaf/coraza-coreruleset v0.0.0-20240226094324-415b1017abdc h1:OlJhrgI3I+FLUCTI3JJW8MoqyM78WbqJjecqMnqG+wc=
github.com/corazawaf/coraza-coreruleset v0.0.0-20240226094324-415b1017abdc/go.mod h1:7rsocqNDkTCira5T0M7buoKR2ehh7YZiPkzxRuAgvVU=
github.com/corazawaf/coraza/v3 v3.7.0 h1:LIQqu1r+l6e/U/gyiZeykWaNNBY1TzRLz+aaI+QYEEM=
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=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dropmorepackets/haproxy-go v0.0.8 h1:kS2Wa8+ZDcnJdRSTiuBsaPun5hpdUPIuLQ+Drp9ZxYs=
github.com/dropmorepackets/haproxy-go v0.0.8/go.mod h1:4a2AmmVjvg2zPNdizGZrMN8ZSUpj90U43VlcdbOIBnU=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/foxcpp/go-mockdns v1.1.0 h1:jI0rD8M0wuYAxL7r/ynTrCQQq0BVqfB99Vgk7DlmewI=
github.com/foxcpp/go-mockdns v1.1.0/go.mod h1:IhLeSFGed3mJIAXPH2aiRQB+kqz7oqu8ld2qVbOu7Wk=
github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho=
github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo=
github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM=
@@ -39,8 +54,10 @@ github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJn
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.23.0 h1:/PwmTwZhS0dPkav3cdK9kV1FsAmrL8sThn8IHr/sO+o=
github.com/go-playground/validator/v10 v10.23.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
@@ -48,6 +65,10 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/gotnospirit/makeplural v0.0.0-20180622080156-a5f48d94d976 h1:b70jEaX2iaJSPZULSUxKtm73LBfsCrMsIlYCUgNGSIs=
github.com/gotnospirit/makeplural v0.0.0-20180622080156-a5f48d94d976/go.mod h1:ZGQeOwybjD8lkCjIyJfqR5LD2wMVHJ31d6GdPxoTsWY=
github.com/gotnospirit/messageformat v0.0.0-20221001023931-dfe49f1eb092 h1:c7gcNWTSr1gtLp6PyYi3wzvFCEcHJ4YRobDgqmIgf7Q=
github.com/gotnospirit/messageformat v0.0.0-20221001023931-dfe49f1eb092/go.mod h1:ZZAN4fkkful3l1lpJwF8JbW41ZiG9TwJ2ZlqzQovBNU=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
@@ -56,14 +77,18 @@ github.com/jackc/pgx/v5 v5.9.2 h1:3ZhOzMWnR4yJ+RW1XImIPsD1aNSz4T4fyP7zlQb56hw=
github.com/jackc/pgx/v5 v5.9.2/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/jcchavezs/mergefs v0.1.1 h1:D45R17m6dHnSVZefnhynoeZvcK2Uw0oTrRfoUOQ0S5Y=
github.com/jcchavezs/mergefs v0.1.1/go.mod h1:eRLTrsA+vFwQZ48hj8p8gki/5v9C2bFtHH5Mnn4bcGk=
github.com/json-iterator/go v1.1.13-0.20220915233716-71ac16282d12 h1:9Nu54bhS/H/Kgo2/7xNSUuC5G28VR8ljfrLKU2G4IjU=
github.com/json-iterator/go v1.1.13-0.20220915233716-71ac16282d12/go.mod h1:TBzl5BIHNXfS9+C35ZyJaklL7mLDbgUkcgXzSLa8Tk0=
github.com/kaptinlin/go-i18n v0.1.4 h1:wCiwAn1LOcvymvWIVAM4m5dUAMiHunTdEubLDk4hTGs=
github.com/kaptinlin/go-i18n v0.1.4/go.mod h1:g1fn1GvTgT4CiLE8/fFE1hboHWJ6erivrDpiDtCcFKg=
github.com/kaptinlin/jsonschema v0.4.6 h1:vOSFg5tjmfkOdKg+D6Oo4fVOM/pActWu/ntkPsI1T64=
github.com/kaptinlin/jsonschema v0.4.6/go.mod h1:1DUd7r5SdyB2ZnMtyB7uLv64dE3zTFTiYytDCd+AEL0=
github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE=
github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/klauspost/cpuid/v2 v2.0.1/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM=
github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
github.com/klauspost/cpuid/v2 v2.2.11 h1:0OwqZRYI2rFrjS4kvkDnqJkKHdHaRnCm68/DY4OxRzU=
github.com/klauspost/cpuid/v2 v2.2.11/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/klauspost/crc32 v1.3.0 h1:sSmTt3gUt81RP655XGZPElI0PelVTZ6YwCRnPSupoFM=
@@ -77,6 +102,8 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/magefile/mage v1.17.0 h1:dS4tkq997Ism03akafC8509iqDjeE7TNTexI25Y7sXM=
github.com/magefile/mage v1.17.0/go.mod h1:Yj51kqllmsgFpvvSzgrZPK9WtluG3kUhFaBUVLo4feA=
github.com/mattn/go-isatty v0.0.21 h1:xYae+lCNBP7QuW4PUnNG61ffM4hVIfm+zUzDuSzYLGs=
github.com/mattn/go-isatty v0.0.21/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY=
@@ -97,8 +124,10 @@ github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFd
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/petar-dambovaliev/aho-corasick v0.0.0-20250424160509-463d218d4745 h1:Vpr4VgAizEgEZsaMohpw6JYDP+i9Of9dmdY4ufNP6HI=
github.com/petar-dambovaliev/aho-corasick v0.0.0-20250424160509-463d218d4745/go.mod h1:EHPiTAKtiFmrMldLUNswFwfZ2eJIYBHktdaUTZxYWRw=
github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM=
github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
@@ -107,6 +136,8 @@ github.com/pkg/sftp v1.13.10/go.mod h1:bJ1a7uDhrX/4OII+agvy28lzRvQrmIQuaHrcI1Hbe
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pquerna/otp v1.5.0 h1:NMMR+WrmaqXU4EzdGJEE1aUUI0AMRzsp96fFFWNPwxs=
github.com/pquerna/otp v1.5.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg=
github.com/pressly/goose/v3 v3.27.1 h1:6uEvcprBybDmW4hcz3gYujhARhye+GoWKhEWyzD5sh4=
github.com/pressly/goose/v3 v3.27.1/go.mod h1:maruOxsPnIG2yHHyo8UqKWXYKFcH7Q76csUV7+7KYoM=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
@@ -122,22 +153,30 @@ github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDq
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/tinylib/msgp v1.6.1 h1:ESRv8eL3u+DNHUoSAAQRE50Hm162zqAnBoGv9PzScPY=
github.com/tinylib/msgp v1.6.1/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
github.com/valllabh/ocsf-schema-golang v1.0.3 h1:eR8k/3jP/OOqB8LRCtdJ4U+vlgd/gk5y3KMXoodrsrw=
github.com/valllabh/ocsf-schema-golang v1.0.3/go.mod h1:sZ3as9xqm1SSK5feFWIR2CuGeGRhsM7TR1MbpBctzPk=
github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ=
github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0=
github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs=
github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s=
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
@@ -153,11 +192,14 @@ 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.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4=
golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk=
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c=
@@ -179,4 +221,6 @@ modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw
modernc.org/sqlite v1.49.1 h1:dYGHTKcX1sJ+EQDnUzvz4TJ5GbuvhNJa8Fg6ElGx73U=
modernc.org/sqlite v1.49.1/go.mod h1:m0w8xhwYUVY3H6pSDwc3gkJ/irZT/0YEXwBlhaxQEew=
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
rsc.io/binaryregexp v0.2.0 h1:HfqmD5MEmC0zvwBuF187nq9mdnXjXsSivRiXN7SmRkE=
rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8=
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=

View File

@@ -229,6 +229,43 @@ func (a *Aggregator) PostPeer(ctx context.Context, p models.HANode, path string)
return res
}
// PostPeerWithBody sendet einen POST-Request mit JSON-Body an einen Peer.
// Wird für VIP-Schwenk-Tests genutzt (/agent/cluster/vip-cmd).
func (a *Aggregator) PostPeerWithBody(ctx context.Context, p models.HANode, path string, body []byte) PeerResult {
start := time.Now()
res := PeerResult{NodeID: p.ID, FQDN: p.FQDN}
target, err := agentURL(p.APIURL, a.AgentPort, path)
if err != nil {
res.Err = "bad api_url: " + err.Error()
return res
}
reqCtx, cancel := context.WithTimeout(ctx, 15*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(reqCtx, http.MethodPost, target, strings.NewReader(string(body)))
if err != nil {
res.Err = err.Error()
return res
}
req.Header.Set("Content-Type", "application/json")
resp, err := a.HTTPClient.Do(req)
if err != nil {
res.Err = err.Error()
res.Duration = time.Since(start).Milliseconds()
return res
}
defer resp.Body.Close()
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusAccepted && resp.StatusCode != http.StatusNoContent {
res.Err = fmt.Sprintf("HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(respBody)))
res.Duration = time.Since(start).Milliseconds()
return res
}
res.OK = true
res.Data = respBody
res.Duration = time.Since(start).Milliseconds()
return res
}
// Compile-time check dass cluster importiert wird (für Drift-Detection
// vom hashSpec — die Aggregator-Resultate werden parallel im Drift-
// Banner mitverarbeitet). Nicht runtime-essentiell, aber dokumentiert

View File

@@ -41,6 +41,7 @@ type hashTable struct {
SkipUpdatedAt bool // setze true wenn updated_at semantisch relevant ist
MigrationDefault bool // Tabelle hat migrations-erzeugte Default-Rows (firewall_zones, ntp_pools…)
// → zählt nicht als "user hat config" bei der Empty-DB-Erkennung
CustomSQL string // wenn gesetzt: direkt als Hash-Query verwenden (überschreibt hashSQL)
}
// hashSpec ist die Reihenfolge-stabile Liste. NEUE Tabellen hier
@@ -70,9 +71,26 @@ var hashSpec = []hashTable{
{Name: "ntp_pools", MigrationDefault: true},
// network_interfaces, ip_addresses, static_routes, dns_settings, ntp_settings
// sind node-spezifisch (jeder Node hat eigene IPs/Routes/Listen-Adressen)
// und fließen NICHT in den Drift-Hash ein.
// 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"},
// 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.
@@ -112,7 +130,11 @@ func ComputeConfigHash(ctx context.Context, pool *pgxpool.Pool) (string, error)
hasUserConfig := false
for _, t := range hashSpec {
var s string
if err := pool.QueryRow(ctx, hashSQL(t)).Scan(&s); err != nil {
sql := t.CustomSQL
if sql == "" {
sql = hashSQL(t)
}
if err := pool.QueryRow(ctx, sql).Scan(&s); err != nil {
// Migration fehlt o.ä. → leeren string nehmen, weiter.
s = ""
}

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

@@ -0,0 +1,432 @@
// Package crowdsec wraps sudo /usr/bin/cscli calls for the edgeguard
// management API. All list operations use -o json. Mutation operations
// (add/delete) use the appropriate cscli sub-commands.
//
// edgeguard runs as a non-root system user; every cscli call goes
// through sudo (allowed entries are in /etc/sudoers.d/edgeguard).
package crowdsec
import (
"bufio"
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"log/slog"
"os"
"os/exec"
"strings"
)
// ErrNotInstalled is returned when /usr/bin/cscli is not found.
var ErrNotInstalled = errors.New("crowdsec not installed")
// IsInstalled checks whether /usr/bin/cscli exists on this host.
func IsInstalled() bool {
_, err := os.Stat("/usr/bin/cscli")
return err == nil
}
// ---------- Types -----------------------------------------------------------
// Decision represents a single IP decision (ban/captcha/etc.) in CrowdSec.
type Decision struct {
ID int64 `json:"id"`
Origin string `json:"origin"`
Type string `json:"type"`
Scope string `json:"scope"`
Value string `json:"value"`
Duration string `json:"duration"`
Reason string `json:"reason"`
Country string `json:"country,omitempty"`
AS string `json:"as,omitempty"`
}
// Alert represents a CrowdSec alert with associated decisions.
type Alert struct {
ID int64 `json:"id"`
Scenario string `json:"scenario"`
EventsCount int `json:"events_count"`
Source AlertSource `json:"source"`
StartAt string `json:"start_at"`
StopAt string `json:"stop_at"`
Decisions []Decision `json:"decisions,omitempty"`
}
// AlertSource holds the source IP/range info for an alert.
type AlertSource struct {
IP string `json:"ip"`
Country string `json:"cn,omitempty"`
ASName string `json:"as_name,omitempty"`
Range string `json:"range,omitempty"`
Scope string `json:"scope,omitempty"`
Value string `json:"value,omitempty"`
}
// Bouncer represents a registered CrowdSec bouncer.
type Bouncer struct {
Name string `json:"name"`
IPAddress string `json:"ip_address,omitempty"`
Revoked bool `json:"revoked"`
LastPull string `json:"last_pull,omitempty"`
Type string `json:"type,omitempty"`
Version string `json:"version,omitempty"`
CreatedAt string `json:"created_at"`
AuthType string `json:"auth_type,omitempty"`
}
// Machine represents a registered CrowdSec agent/machine.
type Machine struct {
MachineID string `json:"machineId"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
LastPush string `json:"last_push,omitempty"`
IsValidated bool `json:"isValidated"`
Version string `json:"version,omitempty"`
Status string `json:"status,omitempty"`
}
// HubItem represents a CrowdSec hub item (collection, parser, scenario, etc.).
type HubItem struct {
Name string `json:"name"`
Description string `json:"description,omitempty"`
Status string `json:"status"`
LocalVersion string `json:"local_version,omitempty"`
LocalPath string `json:"local_path,omitempty"`
Author string `json:"author,omitempty"`
Type string `json:"type,omitempty"`
}
// Status summarises the runtime state of the CrowdSec stack on this node.
type Status struct {
Installed bool `json:"installed"`
AgentRunning bool `json:"agent_running"`
BouncerRunning bool `json:"bouncer_running"`
Version string `json:"version,omitempty"`
DecisionCount int `json:"decision_count"`
AlertCount int `json:"alert_count"`
BouncerCount int `json:"bouncer_count"`
MachineCount int `json:"machine_count"`
}
// ---------- Helpers ---------------------------------------------------------
// sudoCscli executes `sudo -n /usr/bin/cscli <args...>` and returns stdout.
func sudoCscli(ctx context.Context, args ...string) ([]byte, error) {
full := append([]string{"-n", "/usr/bin/cscli"}, args...)
cmd := exec.CommandContext(ctx, "sudo", full...)
var out, errBuf bytes.Buffer
cmd.Stdout = &out
cmd.Stderr = &errBuf
if err := cmd.Run(); err != nil {
slog.Error("crowdsec: sudoCscli failed", "args", args, "error", err, "stderr", errBuf.String())
return nil, err
}
if errBuf.Len() > 0 {
slog.Warn("crowdsec: sudoCscli stderr", "args", args, "stderr", errBuf.String())
}
slog.Debug("crowdsec: sudoCscli ok", "args", args[0], "bytes", out.Len())
return out.Bytes(), nil
}
// systemctlActive returns true when the named unit is "active".
func systemctlActive(ctx context.Context, unit string) bool {
cmd := exec.CommandContext(ctx, "systemctl", "is-active", "--quiet", unit)
return cmd.Run() == nil
}
// unmarshalSlice unmarshals JSON that may be "null" (cscli returns null
// instead of [] when no items exist). Returns an empty slice in that case.
func unmarshalSlice[T any](data []byte) ([]T, error) {
data = bytes.TrimSpace(data)
if bytes.Equal(data, []byte("null")) || len(data) == 0 {
return []T{}, nil
}
var result []T
if err := json.Unmarshal(data, &result); err != nil {
return nil, err
}
return result, nil
}
// ---------- ServiceStatus ---------------------------------------------------
// ServiceStatus returns a Status struct describing the current state of the
// CrowdSec agent and bouncer on this node. Does NOT need cscli installed —
// it uses systemctl for the running-state checks. Version is extracted via
// `cscli version` when available.
func ServiceStatus(ctx context.Context) Status {
st := Status{
Installed: IsInstalled(),
AgentRunning: systemctlActive(ctx, "crowdsec"),
BouncerRunning: systemctlActive(ctx, "crowdsec-firewall-bouncer"),
}
if st.Installed {
// Grab version from `sudo -n /usr/bin/cscli version` — first line only.
// Output is not JSON; it looks like "version: v1.6.3-..."
if out, err := sudoCscli(ctx, "version"); err == nil {
scanner := bufio.NewScanner(bytes.NewReader(out))
if scanner.Scan() {
st.Version = strings.TrimSpace(scanner.Text())
}
}
}
// Only query cscli data endpoints when the agent is running — cscli
// hangs on its local socket when the agent is stopped, which would
// block the entire status response and leave the UI with no data.
if st.AgentRunning {
if decisions, err := Decisions(ctx); err == nil {
st.DecisionCount = len(decisions)
}
if alerts, err := Alerts(ctx, 500); err == nil {
st.AlertCount = len(alerts)
}
if bouncers, err := Bouncers(ctx); err == nil {
st.BouncerCount = len(bouncers)
}
if machines, err := Machines(ctx); err == nil {
st.MachineCount = len(machines)
}
}
return st
}
// ---------- Decisions -------------------------------------------------------
// cscli decisions list -o json returns alert-level objects with nested
// decisions[] arrays. These intermediate types are used only for parsing.
type cscliDecisionRaw struct {
ID int64 `json:"id"`
Duration string `json:"duration"`
Origin string `json:"origin"`
Scope string `json:"scope"`
Type string `json:"type"`
Value string `json:"value"`
}
type cscliAlertRaw struct {
Scenario string `json:"scenario"`
Decisions []cscliDecisionRaw `json:"decisions"`
Source struct {
IP string `json:"ip"`
CN string `json:"cn"`
ASName string `json:"as_name"`
} `json:"source"`
}
// Decisions lists all active decisions by flattening the alert-level JSON
// that cscli emits (each alert contains a nested decisions[] array).
func Decisions(ctx context.Context) ([]Decision, error) {
if !IsInstalled() {
return nil, ErrNotInstalled
}
out, err := sudoCscli(ctx, "decisions", "list", "-o", "json")
if err != nil {
return nil, err
}
alerts, err := unmarshalSlice[cscliAlertRaw](out)
if err != nil {
return nil, err
}
var result []Decision
for _, a := range alerts {
for _, d := range a.Decisions {
result = append(result, Decision{
ID: d.ID,
Origin: d.Origin,
Type: d.Type,
Scope: d.Scope,
Value: d.Value,
Duration: d.Duration,
Reason: a.Scenario,
Country: a.Source.CN,
AS: a.Source.ASName,
})
}
}
if result == nil {
result = []Decision{}
}
return result, nil
}
// AddDecision creates a new ban/captcha decision for the given IP.
func AddDecision(ctx context.Context, ip, duration, reason, typ string) error {
if !IsInstalled() {
return ErrNotInstalled
}
_, err := sudoCscli(ctx, "decisions", "add",
"--ip", ip,
"--duration", duration,
"--reason", reason,
"--type", typ,
)
return err
}
// DeleteDecisionByIP removes all decisions for a given IP address.
func DeleteDecisionByIP(ctx context.Context, ip string) error {
if !IsInstalled() {
return ErrNotInstalled
}
_, err := sudoCscli(ctx, "decisions", "delete", "--ip", ip)
return err
}
// DeleteDecisionByID removes a single decision by its numeric ID.
func DeleteDecisionByID(ctx context.Context, id string) error {
if !IsInstalled() {
return ErrNotInstalled
}
_, err := sudoCscli(ctx, "decisions", "delete", "--id", id)
return err
}
// ---------- Alerts ----------------------------------------------------------
// Alerts lists recent alerts (up to limit).
func Alerts(ctx context.Context, limit int) ([]Alert, error) {
if !IsInstalled() {
return nil, ErrNotInstalled
}
out, err := sudoCscli(ctx, "alerts", "list", "-o", "json",
"-l", fmt.Sprintf("%d", limit))
if err != nil {
return nil, err
}
return unmarshalSlice[Alert](out)
}
// DeleteAlert discards (deletes) a single alert by its ID.
func DeleteAlert(ctx context.Context, id string) error {
if !IsInstalled() {
return ErrNotInstalled
}
_, err := sudoCscli(ctx, "alerts", "delete", "--id", id)
return err
}
// ---------- Bouncers --------------------------------------------------------
// Bouncers lists all registered bouncers.
func Bouncers(ctx context.Context) ([]Bouncer, error) {
if !IsInstalled() {
return nil, ErrNotInstalled
}
out, err := sudoCscli(ctx, "bouncers", "list", "-o", "json")
if err != nil {
return nil, err
}
return unmarshalSlice[Bouncer](out)
}
// DeleteBouncer removes a bouncer by name.
func DeleteBouncer(ctx context.Context, name string) error {
if !IsInstalled() {
return ErrNotInstalled
}
_, err := sudoCscli(ctx, "bouncers", "delete", name)
return err
}
// ---------- Machines --------------------------------------------------------
// cscliMachineRaw mirrors the actual cscli JSON with its mixed camelCase /
// snake_case field names. Only used inside Machines().
type cscliMachineRaw struct {
MachineID string `json:"machineId"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
LastPush string `json:"last_push"`
IsValidated bool `json:"isValidated"`
Version string `json:"version"`
Status string `json:"status"`
}
// Machines lists all registered machines/agents.
func Machines(ctx context.Context) ([]Machine, error) {
if !IsInstalled() {
return nil, ErrNotInstalled
}
out, err := sudoCscli(ctx, "machines", "list", "-o", "json")
if err != nil {
return nil, err
}
raw, err := unmarshalSlice[cscliMachineRaw](out)
if err != nil {
return nil, err
}
result := make([]Machine, len(raw))
for i, r := range raw {
result[i] = Machine{
MachineID: r.MachineID,
CreatedAt: r.CreatedAt,
UpdatedAt: r.UpdatedAt,
LastPush: r.LastPush,
IsValidated: r.IsValidated,
Version: r.Version,
Status: r.Status,
}
}
return result, nil
}
// DeleteMachine removes a machine by its machine ID.
func DeleteMachine(ctx context.Context, id string) error {
if !IsInstalled() {
return ErrNotInstalled
}
_, err := sudoCscli(ctx, "machines", "delete", "--machine-id", id)
return err
}
// ---------- Collections -----------------------------------------------------
// Collections lists installed/available hub collections.
// cscli returns {"collections": [...]} (not a flat array) — we unwrap the key.
func Collections(ctx context.Context) ([]HubItem, error) {
if !IsInstalled() {
return nil, ErrNotInstalled
}
out, err := sudoCscli(ctx, "collections", "list", "-o", "json")
if err != nil {
return nil, err
}
out = bytes.TrimSpace(out)
if bytes.Equal(out, []byte("null")) || len(out) == 0 {
return []HubItem{}, nil
}
// cscli wraps collections in {"collections": [...]}
var wrapper struct {
Collections []HubItem `json:"collections"`
}
if err := json.Unmarshal(out, &wrapper); err != nil {
return nil, err
}
if wrapper.Collections == nil {
return []HubItem{}, nil
}
return wrapper.Collections, nil
}
// InstallCollection installs a hub collection by name (--force to upgrade).
func InstallCollection(ctx context.Context, name string) error {
if !IsInstalled() {
return ErrNotInstalled
}
_, err := sudoCscli(ctx, "collections", "install", name, "--force")
return err
}
// RemoveCollection removes a hub collection by name.
func RemoveCollection(ctx context.Context, name string) error {
if !IsInstalled() {
return ErrNotInstalled
}
_, err := sudoCscli(ctx, "collections", "remove", name)
return err
}

View File

@@ -0,0 +1,21 @@
-- +goose Up
-- +goose StatementBegin
-- 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
-- +goose Down
-- +goose StatementBegin
SELECT 1;
-- +goose StatementEnd

View File

@@ -0,0 +1,25 @@
-- +goose Up
-- +goose StatementBegin
-- forward_proxy_settings — Singleton-Row für globale Squid-Einstellungen.
-- listen_addresses: Komma-separierte IPs auf denen Squid lauscht.
-- Leer = alle Interfaces (http_port 3128). Typisch: LAN/VLAN-Gateway-IPs.
CREATE TABLE IF NOT EXISTS forward_proxy_settings (
id INTEGER PRIMARY KEY DEFAULT 1,
listen_addresses TEXT NOT NULL DEFAULT '',
listen_port INTEGER NOT NULL DEFAULT 3128,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT forward_proxy_settings_singleton CHECK (id = 1)
);
INSERT INTO forward_proxy_settings (id) VALUES (1) ON CONFLICT DO NOTHING;
-- +goose StatementEnd
-- +goose Down
-- +goose StatementBegin
DROP TABLE IF EXISTS forward_proxy_settings;
-- +goose StatementEnd

View File

@@ -0,0 +1,37 @@
-- +goose Up
-- +goose StatementBegin
ALTER TABLE forward_proxy_settings
ADD COLUMN IF NOT EXISTS cache_mem_mb INTEGER NOT NULL DEFAULT 64,
ADD COLUMN IF NOT EXISTS cache_dir_mb INTEGER NOT NULL DEFAULT 100,
ADD COLUMN IF NOT EXISTS max_obj_size_mb INTEGER NOT NULL DEFAULT 4,
ADD COLUMN IF NOT EXISTS connect_timeout INTEGER NOT NULL DEFAULT 60,
ADD COLUMN IF NOT EXISTS read_timeout INTEGER NOT NULL DEFAULT 300,
ADD COLUMN IF NOT EXISTS request_timeout INTEGER NOT NULL DEFAULT 300;
ALTER TABLE dns_settings
ADD COLUMN IF NOT EXISTS prefetch BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN IF NOT EXISTS serve_expired BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN IF NOT EXISTS msg_cache_size_mb INTEGER NOT NULL DEFAULT 64,
ADD COLUMN IF NOT EXISTS rrset_cache_size_mb INTEGER NOT NULL DEFAULT 128;
-- +goose StatementEnd
-- +goose Down
-- +goose StatementBegin
ALTER TABLE forward_proxy_settings
DROP COLUMN IF EXISTS cache_mem_mb,
DROP COLUMN IF EXISTS cache_dir_mb,
DROP COLUMN IF EXISTS max_obj_size_mb,
DROP COLUMN IF EXISTS connect_timeout,
DROP COLUMN IF EXISTS read_timeout,
DROP COLUMN IF EXISTS request_timeout;
ALTER TABLE dns_settings
DROP COLUMN IF EXISTS prefetch,
DROP COLUMN IF EXISTS serve_expired,
DROP COLUMN IF EXISTS msg_cache_size_mb,
DROP COLUMN IF EXISTS rrset_cache_size_mb;
-- +goose StatementEnd

View File

@@ -0,0 +1,18 @@
-- +goose Up
-- Dual-path VRRP + Gateway-Tracking für Split-Brain-Schutz.
-- hb_* = zweite VRRP-Instanz (VI_HB) auf dediziertem Heartbeat-Interface.
-- gw_check_ip = Gateway-IP die von chk_gateway angepingt wird (weight -110).
ALTER TABLE cluster_settings
ADD COLUMN IF NOT EXISTS hb_interface VARCHAR,
ADD COLUMN IF NOT EXISTS hb_src_ip VARCHAR,
ADD COLUMN IF NOT EXISTS hb_peer_ip VARCHAR,
ADD COLUMN IF NOT EXISTS hb_router_id INTEGER NOT NULL DEFAULT 52,
ADD COLUMN IF NOT EXISTS gw_check_ip VARCHAR;
-- +goose Down
ALTER TABLE cluster_settings
DROP COLUMN IF EXISTS hb_interface,
DROP COLUMN IF EXISTS hb_src_ip,
DROP COLUMN IF EXISTS hb_peer_ip,
DROP COLUMN IF EXISTS hb_router_id,
DROP COLUMN IF EXISTS gw_check_ip;

View File

@@ -0,0 +1,9 @@
-- +goose Up
ALTER TABLE users
ADD COLUMN totp_secret TEXT,
ADD COLUMN totp_enabled BOOLEAN NOT NULL DEFAULT false;
-- +goose Down
ALTER TABLE users
DROP COLUMN totp_secret,
DROP COLUMN totp_enabled;

View File

@@ -0,0 +1,17 @@
-- +goose Up
ALTER TABLE firewall_rules
ADD COLUMN IF NOT EXISTS note TEXT,
ADD COLUMN IF NOT EXISTS labels TEXT[] NOT NULL DEFAULT '{}';
ALTER TABLE firewall_nat_rules
ADD COLUMN IF NOT EXISTS note TEXT,
ADD COLUMN IF NOT EXISTS labels TEXT[] NOT NULL DEFAULT '{}';
-- +goose Down
ALTER TABLE firewall_rules
DROP COLUMN IF EXISTS note,
DROP COLUMN IF EXISTS labels;
ALTER TABLE firewall_nat_rules
DROP COLUMN IF EXISTS note,
DROP COLUMN IF EXISTS labels;

View File

@@ -0,0 +1,12 @@
-- +goose Up
CREATE TABLE IF NOT EXISTS crowdsec_settings (
id INTEGER PRIMARY KEY DEFAULT 1 CHECK (id = 1),
enabled BOOLEAN NOT NULL DEFAULT false,
simulation_mode BOOLEAN NOT NULL DEFAULT false,
collections TEXT[] NOT NULL DEFAULT '{"crowdsecurity/linux","crowdsecurity/haproxy"}',
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
INSERT INTO crowdsec_settings (id) VALUES (1) ON CONFLICT DO NOTHING;
-- +goose Down
DROP TABLE IF EXISTS crowdsec_settings;

View File

@@ -0,0 +1,18 @@
-- +goose Up
CREATE TABLE IF NOT EXISTS waf_configs (
id SERIAL PRIMARY KEY,
domain_id BIGINT NOT NULL REFERENCES domains(id) ON DELETE CASCADE,
enabled BOOLEAN NOT NULL DEFAULT false,
mode TEXT NOT NULL DEFAULT 'detection'
CHECK (mode IN ('detection','blocking')),
paranoia_level INT NOT NULL DEFAULT 1
CHECK (paranoia_level BETWEEN 1 AND 4),
rule_exclusions TEXT[] NOT NULL DEFAULT '{}',
trusted_proxies TEXT[] NOT NULL DEFAULT '{}',
custom_rules TEXT NOT NULL DEFAULT '',
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT waf_configs_domain_unique UNIQUE (domain_id)
);
-- +goose Down
DROP TABLE IF EXISTS waf_configs;

View File

@@ -0,0 +1,20 @@
-- +goose Up
CREATE TABLE IF NOT EXISTS waf_alerts (
id BIGSERIAL PRIMARY KEY,
domain_id BIGINT REFERENCES domains(id) ON DELETE CASCADE,
hostname TEXT NOT NULL,
client_ip TEXT NOT NULL,
method TEXT NOT NULL,
uri TEXT NOT NULL,
rule_id INT NOT NULL DEFAULT 0,
rule_msg TEXT NOT NULL DEFAULT '',
severity TEXT NOT NULL DEFAULT '',
action TEXT NOT NULL, -- 'detected' | 'blocked'
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS waf_alerts_domain_created ON waf_alerts(domain_id, created_at DESC);
CREATE INDEX IF NOT EXISTS waf_alerts_created ON waf_alerts(created_at DESC);
-- +goose Down
DROP TABLE IF EXISTS waf_alerts;

View File

@@ -0,0 +1,6 @@
-- +goose Up
ALTER TABLE waf_configs
ADD COLUMN IF NOT EXISTS exclusion_notes JSONB NOT NULL DEFAULT '{}';
-- +goose Down
ALTER TABLE waf_configs DROP COLUMN IF EXISTS exclusion_notes;

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,
})
}
}
@@ -363,11 +371,24 @@ func (g *Generator) loadAutoRules(ctx context.Context) []AutoFWRule {
}
}
// Squid Forward-Proxy: wenn ≥1 aktive ACL → tcp 3128 inbound
// (squid bindet aktuell 0.0.0.0:3128, daher kein DstIP-Filter).
var aclCount int
if err := g.Pool.QueryRow(ctx, `SELECT count(*) FROM forward_proxy_acls WHERE active`).Scan(&aclCount); err == nil && aclCount > 0 {
out = append(out, AutoFWRule{Proto: "tcp", Port: 3128, Comment: "Forward-Proxy (Squid)"})
// Squid Forward-Proxy: lese Port + Listen-Adressen aus
// forward_proxy_settings. Für jede nicht-loopback IP eine
// Auto-Rule; leere Liste = alle Interfaces (generische Regel).
var squidAddrs string
var squidPort int
if err := g.Pool.QueryRow(ctx,
`SELECT listen_addresses, listen_port FROM forward_proxy_settings WHERE id=1`,
).Scan(&squidAddrs, &squidPort); err == nil && squidPort > 0 {
addrs := splitCSV(squidAddrs)
if len(addrs) == 0 {
out = append(out, AutoFWRule{Proto: "tcp", Port: squidPort, Comment: "Forward-Proxy (Squid)"})
} else {
for _, ip := range addrs {
if !isLoopback(ip) {
out = append(out, AutoFWRule{Proto: "tcp", Port: squidPort, DstIP: ip, Comment: "Forward-Proxy (Squid) auf " + ip})
}
}
}
}
// WireGuard server-mode: udp <listen_port> pro aktive iface.
@@ -398,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.
@@ -422,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
@@ -680,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

@@ -3,7 +3,8 @@
# Source: internal/firewall/firewall.go.
# Re-generate via `edgeguard-ctl render-config` or via API mutations.
flush ruleset
add table inet edgeguard
flush table inet edgeguard
table inet edgeguard {
set peer_ipv4 {
@@ -60,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 ──
@@ -70,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 ───────────────────────────────────────
@@ -100,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-
@@ -127,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}}
}
@@ -151,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

@@ -60,15 +60,22 @@ func (h *AuthHandler) WithClusterTLS(store *clustertls.Store) *AuthHandler {
return h
}
const totpPendingCookie = "edgeguard_totp_pending"
// Register mounts /auth/login + /logout (public) and /auth/me
// (gated by requireAuth, passed in as a per-route middleware).
func (h *AuthHandler) Register(rg *gin.RouterGroup, requireAuth gin.HandlerFunc) {
g := rg.Group("/auth")
g.POST("/login", h.Login)
g.POST("/logout", h.Logout)
g.POST("/totp-verify", h.TOTPVerify)
g.GET("/me", requireAuth, h.Me)
g.POST("/reset-password", h.ResetPassword)
g.POST("/change-password", requireAuth, h.ChangePassword)
// TOTP self-service (authenticated user manages own 2FA)
g.POST("/totp/setup", requireAuth, h.TOTPSetup)
g.POST("/totp/confirm", requireAuth, h.TOTPConfirm)
g.DELETE("/totp", requireAuth, h.TOTPDisable)
}
type loginRequest struct {
@@ -77,9 +84,10 @@ type loginRequest struct {
}
type loginResponse struct {
Actor string `json:"actor"`
Role string `json:"role"`
ExpiresAt time.Time `json:"expires_at"`
Actor string `json:"actor"`
Role string `json:"role"`
ExpiresAt time.Time `json:"expires_at"`
TOTPRequired bool `json:"totp_required,omitempty"`
}
func (h *AuthHandler) Login(c *gin.Context) {
@@ -101,12 +109,14 @@ func (h *AuthHandler) Login(c *gin.Context) {
email := strings.TrimSpace(req.Email)
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 {
u, hash, dbErr := h.Users.FindByEmail(c.Request.Context(), email)
ai, dbErr := h.Users.FindForAuth(c.Request.Context(), email)
if dbErr == nil {
if !u.Active {
if !ai.Active {
if h.Audit != nil {
_ = h.Audit.Log(c.Request.Context(), email, "auth.login.failed",
email, gin.H{"reason": "account_disabled", "remote": remote}, h.NodeID)
@@ -114,7 +124,7 @@ func (h *AuthHandler) Login(c *gin.Context) {
response.Unauthorized(c, errors.New("account_disabled"))
return
}
if !usersvc.VerifyPassword(hash, req.Password) {
if !usersvc.VerifyPassword(ai.PasswordHash, req.Password) {
if h.Audit != nil {
_ = h.Audit.Log(c.Request.Context(), email, "auth.login.failed",
email, gin.H{"reason": "invalid_credentials", "remote": remote}, h.NodeID)
@@ -122,9 +132,11 @@ func (h *AuthHandler) Login(c *gin.Context) {
response.Unauthorized(c, errors.New("invalid_credentials"))
return
}
actor = u.Email
role = u.Role
h.Users.RecordLogin(c.Request.Context(), u.ID)
actor = ai.Email
role = ai.Role
totpEnabled = ai.TOTPEnabled
viaDB = true
h.Users.RecordLogin(c.Request.Context(), ai.ID)
}
}
@@ -133,16 +145,13 @@ func (h *AuthHandler) Login(c *gin.Context) {
if strings.EqualFold(st.AdminEmail, email) && st.VerifyAdminPassword(req.Password) {
actor = st.AdminEmail
role = "admin"
// Auto-migrate: insert the setup-store admin into the DB so it
// shows up in user management from this point on.
if h.Users != nil {
_, _ = h.Users.Upsert(c.Request.Context(), st.AdminEmail, req.Password, "admin", true)
}
}
}
// 3. Auth federation: cluster nodes forward failed auth to the primary
// via mTLS so users can log in with their primary credentials on any node.
// 3. Auth federation: cluster nodes forward failed auth to the primary.
if actor == "" && st.IsClusterNode && st.PrimaryFQDN != "" && h.ClusterTLS != nil {
if a, r, err := h.checkWithPrimary(c.Request.Context(), st.PrimaryFQDN, email, req.Password); err == nil {
actor = a
@@ -161,6 +170,33 @@ 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 {
pending, ptok, err := h.Signer.IssueWithRoleTTL(actor, "totp_pending", 2*time.Minute)
if err != nil {
response.Internal(c, err)
return
}
c.SetSameSite(http.SameSiteStrictMode)
c.SetCookie(totpPendingCookie, pending, int(2*time.Minute/time.Second), "/", "", true, true)
_ = ptok
response.OK(c, loginResponse{TOTPRequired: true})
return
}
raw, tok, err := h.Signer.IssueWithRole(actor, role)
if err != nil {
response.Internal(c, err)
@@ -179,6 +215,146 @@ func (h *AuthHandler) Login(c *gin.Context) {
})
}
type totpVerifyRequest struct {
Code string `json:"code" binding:"required"`
}
// TOTPVerify completes the two-step login: verifies the TOTP code from the
// pending cookie and, on success, issues a full session JWT.
func (h *AuthHandler) TOTPVerify(c *gin.Context) {
var req totpVerifyRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, err)
return
}
pendingRaw, err := c.Cookie(totpPendingCookie)
if err != nil || pendingRaw == "" {
response.Unauthorized(c, errors.New("no_pending_totp"))
return
}
ptok, err := h.Signer.Verify(pendingRaw)
if err != nil || ptok.Role != "totp_pending" {
response.Unauthorized(c, errors.New("invalid_pending_token"))
return
}
if h.Users == nil {
response.Internal(c, errors.New("users repo unavailable"))
return
}
ai, err := h.Users.FindForAuth(c.Request.Context(), ptok.Actor)
if err != nil || !ai.TOTPEnabled || ai.TOTPSecret == nil {
response.Unauthorized(c, errors.New("totp_not_configured"))
return
}
if !usersvc.VerifyTOTP(*ai.TOTPSecret, req.Code) {
if h.Audit != nil {
_ = h.Audit.Log(c.Request.Context(), ptok.Actor, "auth.totp.failed",
ptok.Actor, gin.H{"remote": c.ClientIP()}, h.NodeID)
}
response.Unauthorized(c, errors.New("invalid_totp_code"))
return
}
// Clear pending cookie, issue full session.
c.SetSameSite(http.SameSiteStrictMode)
c.SetCookie(totpPendingCookie, "", -1, "/", "", true, true)
raw, tok, err := h.Signer.IssueWithRole(ptok.Actor, ai.Role)
if err != nil {
response.Internal(c, err)
return
}
setSessionCookie(c, raw, tok.Exp)
if h.Audit != nil {
_ = h.Audit.Log(c.Request.Context(), ptok.Actor, "auth.login.success",
ptok.Actor, gin.H{"role": ai.Role, "remote": c.ClientIP(), "totp": true}, h.NodeID)
}
response.OK(c, loginResponse{
Actor: tok.Actor,
Role: tok.Role,
ExpiresAt: time.Unix(tok.Exp, 0).UTC(),
})
}
// TOTPSetup generates a new TOTP secret for the authenticated user and returns
// the provisioning URI (renders as QR code in the UI). Secret is not saved yet.
func (h *AuthHandler) TOTPSetup(c *gin.Context) {
tok := CurrentToken(c)
if tok == nil {
response.Unauthorized(c, nil)
return
}
secret, uri, err := usersvc.GenerateTOTPSecret(tok.Actor)
if err != nil {
response.Internal(c, err)
return
}
response.OK(c, gin.H{"secret": secret, "uri": uri})
}
type totpConfirmRequest struct {
Secret string `json:"secret" binding:"required"`
Code string `json:"code" binding:"required"`
}
// TOTPConfirm verifies the code against the provisioned secret and, on success,
// enables TOTP for the user.
func (h *AuthHandler) TOTPConfirm(c *gin.Context) {
var req totpConfirmRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, err)
return
}
tok := CurrentToken(c)
if tok == nil || h.Users == nil {
response.Unauthorized(c, nil)
return
}
u, _, err := h.Users.FindByEmail(c.Request.Context(), tok.Actor)
if err != nil {
response.Internal(c, err)
return
}
if err := h.Users.ConfirmTOTP(c.Request.Context(), u.ID, req.Secret, req.Code); err != nil {
if err.Error() == "invalid_totp_code" {
response.Err(c, http.StatusUnprocessableEntity, err)
return
}
response.Internal(c, err)
return
}
if h.Audit != nil {
_ = h.Audit.Log(c.Request.Context(), tok.Actor, "auth.totp.enabled",
tok.Actor, nil, h.NodeID)
}
response.OK(c, gin.H{"ok": true})
}
// TOTPDisable disables TOTP for the authenticated user.
func (h *AuthHandler) TOTPDisable(c *gin.Context) {
tok := CurrentToken(c)
if tok == nil || h.Users == nil {
response.Unauthorized(c, nil)
return
}
u, _, err := h.Users.FindByEmail(c.Request.Context(), tok.Actor)
if err != nil {
response.Internal(c, err)
return
}
if err := h.Users.DisableTOTP(c.Request.Context(), u.ID); err != nil {
response.Internal(c, err)
return
}
if h.Audit != nil {
_ = h.Audit.Log(c.Request.Context(), tok.Actor, "auth.totp.disabled",
tok.Actor, nil, h.NodeID)
}
response.OK(c, gin.H{"ok": true})
}
func (h *AuthHandler) Logout(c *gin.Context) {
clearSessionCookie(c)
response.OK(c, gin.H{"logged_out": true})

View File

@@ -21,6 +21,7 @@ import (
"git.netcell-it.de/projekte/edgeguard-native/internal/cluster/jointoken"
"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"
)
// ClusterHandler exposes cluster-state endpoints. /status ist die
@@ -44,6 +45,11 @@ type ClusterHandler struct {
// PeerReloader: optional, gesetzt bei Phase 3.5. Nach Auto-Register
// triggert das den firewall-Render damit peer_ipv4 frisch ist.
PeerReloader PeerReloader
// Audit + NodeID: optional, gesetzt via WithAudit. Nötig für
// protokollierte, mutierende Aktionen wie den Replication-Repair.
Audit *audit.Repo
NodeID string
}
func NewClusterHandler(store *cluster.Store, localID string) *ClusterHandler {
@@ -65,6 +71,13 @@ func (h *ClusterHandler) WithJoinFlow(store *clustertls.Store, tokens *jointoken
return h
}
// WithAudit setzt den Audit-Repo + NodeID für protokollierte Aktionen.
func (h *ClusterHandler) WithAudit(a *audit.Repo, nodeID string) *ClusterHandler {
h.Audit = a
h.NodeID = nodeID
return h
}
func (h *ClusterHandler) Register(rg *gin.RouterGroup) {
g := rg.Group("/cluster")
g.GET("/nodes", h.ListNodes)
@@ -75,6 +88,10 @@ func (h *ClusterHandler) Register(rg *gin.RouterGroup) {
g.PUT("/vip-settings", h.UpdateVIPSettings)
g.POST("/rolling-update", h.RollingUpdate)
g.GET("/rolling-update/status", h.RollingUpdateStatus)
g.POST("/repair-replication", h.RepairReplication)
g.GET("/repair-replication/status", h.RepairReplicationStatus)
g.GET("/vip-status", h.VIPStatus)
g.POST("/vip-test", h.VIPTest)
if h.TLSStore != nil {
g.GET("/cert-status", h.CertStatus)
g.POST("/renew-self", h.RenewSelf)
@@ -130,9 +147,12 @@ func (h *ClusterHandler) GetVIPSettings(c *gin.Context) {
return
}
var cs vipSettingsRow
row := h.Store.Pool.QueryRow(c.Request.Context(),
`SELECT vip_address, vip_interface, vip_auth_pass, vrrp_router_id FROM cluster_settings WHERE id = 1`)
if err := row.Scan(&cs.VIPAddress, &cs.VIPInterface, &cs.VIPAuthPass, &cs.VRRPRouterID); err != nil {
row := h.Store.Pool.QueryRow(c.Request.Context(), `
SELECT vip_address, vip_interface, vip_auth_pass, vrrp_router_id,
hb_interface, hb_src_ip, hb_peer_ip, hb_router_id, gw_check_ip
FROM cluster_settings WHERE id = 1`)
if err := row.Scan(&cs.VIPAddress, &cs.VIPInterface, &cs.VIPAuthPass, &cs.VRRPRouterID,
&cs.HBInterface, &cs.HBSrcIP, &cs.HBPeerIP, &cs.HBRouterID, &cs.GWCheckIP); err != nil {
response.Internal(c, err)
return
}
@@ -154,10 +174,14 @@ func (h *ClusterHandler) UpdateVIPSettings(c *gin.Context) {
}
_, err := h.Store.Pool.Exec(c.Request.Context(), `
UPDATE cluster_settings
SET vip_address=$1, vip_interface=$2, vip_auth_pass=$3, vrrp_router_id=$4, updated_at=NOW()
SET vip_address=$1, vip_interface=$2, vip_auth_pass=$3, vrrp_router_id=$4,
hb_interface=$5, hb_src_ip=$6, hb_peer_ip=$7, hb_router_id=$8, gw_check_ip=$9,
updated_at=NOW()
WHERE id=1`,
nullIfEmpty(req.VIPAddress), nullIfEmpty(req.VIPInterface),
nullIfEmpty(req.VIPAuthPass), req.VRRPRouterID)
nullIfEmpty(req.VIPAuthPass), req.VRRPRouterID,
nullIfEmpty(req.HBInterface), nullIfEmpty(req.HBSrcIP),
nullIfEmpty(req.HBPeerIP), req.HBRouterID, nullIfEmpty(req.GWCheckIP))
if err != nil {
response.Internal(c, err)
return
@@ -181,6 +205,11 @@ type vipSettingsRow struct {
VIPInterface *string `json:"vip_interface"`
VIPAuthPass *string `json:"vip_auth_pass"`
VRRPRouterID int `json:"vrrp_router_id"`
HBInterface *string `json:"hb_interface"`
HBSrcIP *string `json:"hb_src_ip"`
HBPeerIP *string `json:"hb_peer_ip"`
HBRouterID int `json:"hb_router_id"`
GWCheckIP *string `json:"gw_check_ip"`
}
func nullIfEmpty(s *string) *string {
@@ -218,6 +247,11 @@ func (h *ClusterHandler) RegisterAgent(rg *gin.RouterGroup) {
g.GET("/master-key", h.AgentMasterKey)
g.GET("/version", h.AgentVersion)
g.POST("/trigger-update", h.AgentTriggerUpdate)
g.GET("/active-ips", h.AgentActiveIPs)
g.POST("/vip-cmd", h.AgentVIPCmd)
g.GET("/tls-certs", h.AgentTLSCerts)
g.POST("/repair-replication", h.AgentRepairReplication)
g.GET("/repair-replication/status", h.AgentRepairReplicationStatus)
}
// AgentIdentity gibt die eigene ha_nodes-Row zurück. Wird vom Primary
@@ -620,6 +654,14 @@ func (h *ClusterHandler) preRegisterJoiner(parent context.Context, clientIP, csr
slog.Info("cluster: joiner pre-registered, firewall updated", "fqdn", fqdn, "ip", clientIP)
}
// ptrStr dereferences a *string safely for comparison; nil → "".
func ptrStr(s *string) string {
if s == nil {
return ""
}
return *s
}
// cnFromCSR extracts the Subject Common Name from a PEM-encoded CSR.
// Returns empty string on any parse error.
func cnFromCSR(csrPEM string) string {
@@ -824,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.
@@ -862,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 != "" {
@@ -895,17 +947,25 @@ func (h *ClusterHandler) AgentRegisterPeer(c *gin.Context) {
// ha_nodes_fqdn_unique" scheitern und der Peer bliebe ewig "joining".
_ = h.Store.DeletePlaceholdersByFQDN(c.Request.Context(), req.FQDN, req.ID)
// Snapshot der aktuellen IPs VOR dem Upsert — zum Vergleich danach.
// Nur wenn sich public_ip oder internal_ip ändert, müssen wir nftables
// neu laden (@peer_ipv4-Set). Periodische Pushes vom Secondary (alle
// 5 min) ändern nur version/config_hash, nicht die IPs → kein Reset.
existing, _ := h.Store.Get(c.Request.Context(), req.ID)
out, err := h.Store.UpsertSelf(c.Request.Context(), n)
if err != nil {
response.Internal(c, err)
return
}
// Firewall-Reload damit peer_ipv4-Set die neue IP aufnimmt. Best-
// effort: Fehler loggen, Response weiter durchreichen — der Peer
// hat seine Identity erfolgreich registriert, Operator kann manuell
// nachrendern.
if h.PeerReloader != nil {
// Firewall-Reload nur wenn sich die Peer-IP geändert hat oder der
// Peer neu eingetragen wurde. Verhindert Counter-Reset alle 5 min
// durch den periodischen Secondary-Push (runPrimaryPush).
ipChanged := existing == nil ||
ptrStr(existing.PublicIP) != ptrStr(out.PublicIP) ||
ptrStr(existing.InternalIP) != ptrStr(out.InternalIP)
if ipChanged && h.PeerReloader != nil {
go func() {
rctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
@@ -915,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

@@ -0,0 +1,118 @@
package handlers
import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
"log/slog"
"os"
"os/exec"
"path/filepath"
"strings"
"github.com/gin-gonic/gin"
"github.com/jackc/pgx/v5/pgxpool"
"git.netcell-it.de/projekte/edgeguard-native/internal/aggregator"
"git.netcell-it.de/projekte/edgeguard-native/internal/handlers/response"
"git.netcell-it.de/projekte/edgeguard-native/internal/models"
)
const tlsCertDir = "/etc/edgeguard/tls"
// AgentTLSCerts liefert alle .pem-Dateien aus /etc/edgeguard/tls/ als
// Base64-Map. Wird vom Secondary via mTLS aufgerufen um Zertifikate
// des Primary zu spiegeln.
func (h *ClusterHandler) AgentTLSCerts(c *gin.Context) {
entries, err := os.ReadDir(tlsCertDir)
if err != nil {
response.Internal(c, err)
return
}
certs := make(map[string]string, len(entries))
for _, e := range entries {
if e.IsDir() || !strings.HasSuffix(e.Name(), ".pem") {
continue
}
data, err := os.ReadFile(filepath.Join(tlsCertDir, e.Name()))
if err != nil {
continue
}
certs[e.Name()] = base64.StdEncoding.EncodeToString(data)
}
response.OK(c, gin.H{"certs": certs})
}
// SyncTLSCertsFromPrimary holt alle TLS-Zertifikate vom Primary via mTLS
// und schreibt geänderte Dateien nach /etc/edgeguard/tls/. Relädt HAProxy
// wenn mindestens ein Zertifikat aktualisiert wurde.
//
// Läuft auf dem Secondary bei jedem runSecondaryConfigRender-Tick —
// nicht hash-gated, da certbot-Renewals den config_hash nicht ändern.
func SyncTLSCertsFromPrimary(ctx context.Context, pool *pgxpool.Pool, agg *aggregator.Aggregator, localID string) error {
if agg == nil {
return nil
}
// Primary-Peer aus ha_nodes ermitteln
rows, err := pool.Query(ctx,
`SELECT id, fqdn, api_url FROM ha_nodes WHERE id != $1 LIMIT 1`, localID)
if err != nil {
return err
}
defer rows.Close()
var primary *models.HANode
for rows.Next() {
n := &models.HANode{}
if err := rows.Scan(&n.ID, &n.FQDN, &n.APIURL); err != nil {
continue
}
primary = n
}
if primary == nil {
return nil // kein Peer → Single-Node
}
results := agg.FanOut(ctx, []models.HANode{*primary}, "/agent/cluster/tls-certs", localID)
if len(results) == 0 || !results[0].OK {
return nil // Primary nicht erreichbar — nächster Tick
}
var payload struct {
Certs map[string]string `json:"certs"`
}
if err := json.Unmarshal(results[0].Data, &payload); err != nil {
return err
}
if err := os.MkdirAll(tlsCertDir, 0o750); err != nil {
return err
}
changed := false
for name, b64 := range payload.Certs {
data, err := base64.StdEncoding.DecodeString(b64)
if err != nil {
slog.Warn("cert-sync: base64 decode failed", "file", name, "error", err)
continue
}
path := filepath.Join(tlsCertDir, name)
existing, readErr := os.ReadFile(path)
if readErr == nil && bytes.Equal(existing, data) {
continue // unverändert
}
if err := os.WriteFile(path, data, 0o640); err != nil {
slog.Warn("cert-sync: write failed", "file", name, "error", err)
continue
}
changed = true
slog.Info("cert-sync: updated", "file", name)
}
if changed {
if err := exec.Command("sudo", "-n", "/usr/bin/systemctl", "reload", "haproxy.service").Run(); err != nil {
slog.Warn("cert-sync: haproxy reload failed", "error", err)
}
}
return nil
}

View File

@@ -0,0 +1,403 @@
package handlers
import (
"context"
"encoding/json"
"errors"
"fmt"
"log/slog"
"os"
"os/exec"
"regexp"
"strconv"
"strings"
"time"
"github.com/gin-gonic/gin"
"git.netcell-it.de/projekte/edgeguard-native/internal/handlers/response"
"git.netcell-it.de/projekte/edgeguard-native/internal/models"
)
// Replication-Repair ("Resync erzwingen") für das Config-Drift-Banner.
//
// Drift entsteht, wenn ein Peer einen anderen config_hash hat als dieser
// Node — entweder weil die Logical-Replication-Subscription gestört ist
// oder weil direkt in die DB des Subscribers geschrieben wurde. Die
// Reparatur baut die Subscription neu auf und kopiert alle geteilten
// Tabellen frisch vom Primary (einseitig: Primary = Source of Truth).
//
// 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.
//
// 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 `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.
var validRepairHost = regexp.MustCompile(`^[A-Za-z0-9._:-]{1,253}$`)
// 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
}
ctx := c.Request.Context()
all, err := h.Store.List(ctx)
if err != nil {
response.Internal(c, err)
return
}
local := findNode(all, h.LocalID)
peer := findOtherPeer(all, h.LocalID)
if peer == nil {
response.BadRequest(c, errors.New("kein Peer-Node im Cluster — nichts zu resyncen"))
return
}
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
}
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
}
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", peer.FQDN, res.Err))
return
}
slog.Info("cluster: replication repair delegated", "target", peer.FQDN, "primary_host", primaryHost)
if h.Audit != nil {
_ = 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": "peer", "peer_fqdn": peer.FQDN})
return
}
// 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(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 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
}
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))
}
}
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(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})
}
// 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 !validRepairHost.MatchString(primaryHost) {
return fmt.Errorf("ungültige Primary-Adresse: %q", primaryHost)
}
// 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)
}
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")
}
script := fmt.Sprintf(`#!/bin/bash
set -uo pipefail
echo "[repair] resync der Logical-Replication-Subscription von Primary %[1]s"
/usr/bin/edgeguard-ctl cluster-setup-standby %[1]s
rc=$?
if [ "$rc" -ne 0 ]; then
echo "[repair] cluster-setup-standby fehlgeschlagen (rc=$rc)"
exit "$rc"
fi
echo "[repair] abgeschlossen — config_hash wird beim nächsten Cluster-Status neu berechnet"
rm -f %[2]s
`, primaryHost, repairScriptPath)
if err := os.WriteFile(repairScriptPath, []byte(script), 0o755); err != nil {
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",
"--unit="+repairUnitName,
"--description=EdgeGuard replication repair",
"--collect",
"bash", repairScriptPath)
if err := cmd.Run(); err != nil {
return fmt.Errorf("systemd-run failed: %w", err)
}
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.
type repairStatusResponse struct {
Phase string `json:"phase"` // idle | running | success | failed
State string `json:"state"`
Result string `json:"result"`
ExitCode int `json:"exit_code"`
StartedAt string `json:"started_at,omitempty"`
FinishedAt string `json:"finished_at,omitempty"`
Log []string `json:"log"`
}
// RepairReplicationStatus liest den Job-Zustand. Auf dem Primary wird der
// Status vom Subscriber-Peer geholt (dort läuft der Job); sonst lokal.
func (h *ClusterHandler) RepairReplicationStatus(c *gin.Context) {
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 statt Fehler, damit das
// UI-Polling nicht hart abbricht.
response.OK(c, repairStatusResponse{Phase: "idle", Log: []string{}})
return
}
}
}
response.OK(c, localRepairStatus())
}
// AgentRepairReplicationStatus liefert den lokalen Job-Zustand an den
// abfragenden Primary.
func (h *ClusterHandler) AgentRepairReplicationStatus(c *gin.Context) {
response.OK(c, localRepairStatus())
}
// wrapEnvelope verpackt eine bereits entpackte data-Payload wieder in die
// Standard-Envelope, damit das UI (isEnvelope) sie konsistent liest.
func wrapEnvelope(data []byte) []byte {
out := []byte(`{"data":`)
out = append(out, data...)
out = append(out, []byte(`,"error":null,"message":"ok"}`)...)
return out
}
// localRepairStatus liest den Zustand der lokalen Repair-Unit aus systemd
// (analog UpgradeStatus). Quelle der Wahrheit für Job-Ende ist die Unit.
func localRepairStatus() repairStatusResponse {
out := repairStatusResponse{Phase: "idle", Log: []string{}}
if data, err := exec.Command("systemctl", "show", repairUnitName,
"--no-page",
"-p", "ActiveState",
"-p", "Result",
"-p", "ExecMainStatus",
"-p", "ExecMainStartTimestamp",
"-p", "ExecMainExitTimestamp",
).CombinedOutput(); err == nil {
for _, line := range strings.Split(string(data), "\n") {
kv := strings.SplitN(strings.TrimSpace(line), "=", 2)
if len(kv) != 2 {
continue
}
switch kv[0] {
case "ActiveState":
out.State = kv[1]
case "Result":
out.Result = kv[1]
case "ExecMainStatus":
out.ExitCode, _ = strconv.Atoi(kv[1])
case "ExecMainStartTimestamp":
if t, err := time.Parse("Mon 2006-01-02 15:04:05 MST", kv[1]); err == nil {
out.StartedAt = t.UTC().Format(time.RFC3339)
}
case "ExecMainExitTimestamp":
if t, err := time.Parse("Mon 2006-01-02 15:04:05 MST", kv[1]); err == nil {
out.FinishedAt = t.UTC().Format(time.RFC3339)
}
}
}
}
switch out.State {
case "activating", "active", "deactivating":
out.Phase = "running"
case "failed":
out.Phase = "failed"
case "inactive":
if out.Result == "success" && out.ExitCode == 0 && out.FinishedAt != "" {
out.Phase = "success"
} else if out.Result != "" && out.Result != "success" {
out.Phase = "failed"
}
}
if data, err := exec.Command("journalctl",
"-u", repairUnitName,
"--no-pager", "-n", "100", "-o", "cat",
).CombinedOutput(); err == nil {
lines := strings.Split(strings.TrimRight(string(data), "\n"), "\n")
if !(len(lines) == 1 && (lines[0] == "" || strings.HasPrefix(lines[0], "-- No entries"))) {
out.Log = lines
}
}
return out
}
// 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 {
return &nodes[i]
}
}
return nil
}
// 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 {
n := &nodes[i]
if n.ID == localID {
continue
}
if n.Status == "online" {
return n
}
if fallback == nil {
fallback = n
}
}
return fallback
}
// 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 != "" {
return h
}
}
}
return strings.TrimSpace(n.FQDN)
}
// repairUnitState gibt den ActiveState der Repair-Unit zurück ("" wenn
// unbekannt). Für den Doppelstart-Schutz.
func repairUnitState() string {
out, err := exec.Command("systemctl", "show", repairUnitName, "--no-page", "-p", "ActiveState").CombinedOutput()
if err != nil {
return ""
}
for _, line := range strings.Split(string(out), "\n") {
if kv := strings.SplitN(strings.TrimSpace(line), "=", 2); len(kv) == 2 && kv[0] == "ActiveState" {
return kv[1]
}
}
return ""
}

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

View File

@@ -0,0 +1,319 @@
package handlers
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"os/exec"
"strings"
"github.com/gin-gonic/gin"
"github.com/jackc/pgx/v5/pgxpool"
"git.netcell-it.de/projekte/edgeguard-native/internal/handlers/response"
"git.netcell-it.de/projekte/edgeguard-native/internal/models"
)
// vipInfo enthält die für einen VIP-Schwenk notwendigen Daten.
type vipInfo struct {
ID int64 `json:"id"`
Address string `json:"address"`
Prefix int `json:"prefix"`
Device string `json:"device"`
}
// VIPStatusEntry kombiniert einen VIP mit den Nodes die ihn gerade halten.
type VIPStatusEntry struct {
VIP vipInfo `json:"vip"`
ActiveOn []string `json:"active_on"` // FQDNs der Nodes mit diesem VIP
}
// AgentActiveIPs gibt alle aktiven IPv4-Adressen dieses Nodes zurück.
// Wird vom Primary genutzt um zu prüfen welcher Node welchen VIP hält.
func (h *ClusterHandler) AgentActiveIPs(c *gin.Context) {
ips, err := localActiveIPs()
if err != nil {
response.Internal(c, err)
return
}
response.OK(c, gin.H{"ips": ips})
}
// vipCmdRequest ist der Body für den AgentVIPCmd-Endpoint.
type vipCmdRequest struct {
Action string `json:"action"` // "add" | "del"
Address string `json:"address"` // z.B. "10.0.5.1"
Prefix int `json:"prefix"` // z.B. 24
Device string `json:"device"` // z.B. "vlan100"
}
// AgentVIPCmd führt `ip addr add/del` auf diesem Node aus.
// Wird vom Primary via mTLS für VIP-Schwenk-Tests aufgerufen.
func (h *ClusterHandler) AgentVIPCmd(c *gin.Context) {
var req vipCmdRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, err)
return
}
if req.Action != "add" && req.Action != "del" {
response.BadRequest(c, simpleError("action must be 'add' or 'del'"))
return
}
if req.Address == "" || req.Device == "" || req.Prefix <= 0 || req.Prefix > 128 {
response.BadRequest(c, simpleError("address, device, prefix required"))
return
}
if err := runVIPCmd(req.Action, req.Address, req.Prefix, req.Device); err != nil {
slog.Warn("cluster: agent vip-cmd failed",
"action", req.Action, "addr", req.Address, "dev", req.Device, "error", err)
response.Internal(c, err)
return
}
slog.Info("cluster: agent vip-cmd ok",
"action", req.Action, "addr", req.Address, "prefix", req.Prefix,
"dev", req.Device, "caller", c.ClientIP())
response.OK(c, gin.H{"ok": true})
}
// VIPStatus liest alle VIPs (is_vip=true) aus der DB und fragt alle Nodes
// welche davon sie gerade aktiv haben. Nur sinnvoll im Cluster-Modus.
func (h *ClusterHandler) VIPStatus(c *gin.Context) {
vips, err := loadVIPs(c.Request.Context(), h.Store.Pool)
if err != nil {
response.Internal(c, err)
return
}
nodeIPs := h.collectActiveIPs(c.Request.Context())
result := make([]VIPStatusEntry, 0, len(vips))
for _, v := range vips {
entry := VIPStatusEntry{VIP: v}
for fqdn, ips := range nodeIPs {
for _, ip := range ips {
if ip == v.Address {
entry.ActiveOn = append(entry.ActiveOn, fqdn)
break
}
}
}
result = append(result, entry)
}
response.OK(c, gin.H{"vips": result})
}
// vipTestRequest steuert einen VIP-Schwenk.
type vipTestRequest struct {
IPAddressID int64 `json:"ip_address_id"`
Action string `json:"action"` // "to_secondary" | "restore"
}
// vipTestStep beschreibt einen Schritt des Schwenk-Prozesses.
type vipTestStep struct {
Step string `json:"step"`
OK bool `json:"ok"`
Message string `json:"message,omitempty"`
}
// VIPTest schwenkt einen VIP vom Primary auf den Secondary ("to_secondary")
// oder zurück ("restore"). Nur vom Primary aufzurufen.
func (h *ClusterHandler) VIPTest(c *gin.Context) {
var req vipTestRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, err)
return
}
if req.Action != "to_secondary" && req.Action != "restore" {
response.BadRequest(c, simpleError("action must be 'to_secondary' or 'restore'"))
return
}
vips, err := loadVIPs(c.Request.Context(), h.Store.Pool)
if err != nil {
response.Internal(c, err)
return
}
var target *vipInfo
for i := range vips {
if vips[i].ID == req.IPAddressID {
target = &vips[i]
break
}
}
if target == nil {
response.NotFound(c, simpleError("VIP not found or not marked as VIP"))
return
}
all, err := h.Store.List(c.Request.Context())
if err != nil {
response.Internal(c, err)
return
}
var peer *models.HANode
for i := range all {
if all[i].ID != h.LocalID {
peer = &all[i]
break
}
}
if peer == nil {
response.BadRequest(c, simpleError("kein Secondary-Peer gefunden"))
return
}
var steps []vipTestStep
addrPfx := fmt.Sprintf("%s/%d", target.Address, target.Prefix)
if req.Action == "to_secondary" {
// 1. VIP auf Secondary via mTLS hinzufügen
steps = append(steps, h.peerVIPCmd(c.Request.Context(), *peer, target, "add",
fmt.Sprintf("add %s dev %s auf %s", addrPfx, target.Device, peer.FQDN)))
// 2. VIP vom Primary entfernen (nur wenn Secondary-Add erfolgreich)
if steps[0].OK {
steps = append(steps, localVIPStep(target, "del",
fmt.Sprintf("del %s dev %s lokal", addrPfx, target.Device)))
}
} else {
// 1. VIP auf Primary zurückholen
steps = append(steps, localVIPStep(target, "add",
fmt.Sprintf("add %s dev %s lokal", addrPfx, target.Device)))
// 2. VIP auf Secondary entfernen
steps = append(steps, h.peerVIPCmd(c.Request.Context(), *peer, target, "del",
fmt.Sprintf("del %s dev %s auf %s", addrPfx, target.Device, peer.FQDN)))
}
slog.Info("cluster: vip-test", "action", req.Action, "vip", target.Address,
"dev", target.Device, "peer", peer.FQDN, "actor", actorOf(c))
response.OK(c, gin.H{"steps": steps})
}
// ── Hilfsfunktionen ───────────────────────────────────────────────────────
func loadVIPs(ctx context.Context, pool *pgxpool.Pool) ([]vipInfo, error) {
rows, err := pool.Query(ctx, `
SELECT ia.id, ia.address, ia.prefix, ni.name
FROM ip_addresses ia
JOIN network_interfaces ni ON ni.id = ia.interface_id
WHERE ia.is_vip = true AND ia.active = true
ORDER BY ni.name, ia.address`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []vipInfo
for rows.Next() {
var v vipInfo
if err := rows.Scan(&v.ID, &v.Address, &v.Prefix, &v.Device); err != nil {
return nil, err
}
out = append(out, v)
}
return out, rows.Err()
}
// collectActiveIPs fragt alle Cluster-Nodes (lokal + Peers via mTLS) nach
// ihren aktiven IPv4-Adressen und gibt eine Map[fqdn][]ip zurück.
func (h *ClusterHandler) collectActiveIPs(ctx context.Context) map[string][]string {
result := make(map[string][]string)
if h.Store == nil {
return result
}
all, err := h.Store.List(ctx)
if err != nil {
return result
}
// Lokaler Node
if ips, err := localActiveIPs(); err == nil {
for _, n := range all {
if n.ID == h.LocalID {
result[n.FQDN] = ips
break
}
}
}
// Peers via mTLS-Aggregator
if h.Aggregator != nil {
var peers []models.HANode
for _, n := range all {
if n.ID != h.LocalID {
peers = append(peers, n)
}
}
if len(peers) > 0 {
peerResults := h.Aggregator.FanOut(ctx, peers, "/agent/cluster/active-ips", h.LocalID)
for _, pr := range peerResults {
if !pr.OK || len(pr.Data) == 0 {
continue
}
var payload struct {
IPs []string `json:"ips"`
}
if err := json.Unmarshal(pr.Data, &payload); err == nil {
result[pr.FQDN] = payload.IPs
}
}
}
}
return result
}
// localActiveIPs liest alle aktiven IPv4-Adressen des lokalen Nodes via `ip`.
func localActiveIPs() ([]string, error) {
out, err := exec.Command("ip", "-4", "-o", "addr", "show").Output()
if err != nil {
return nil, err
}
var ips []string
for _, line := range strings.Split(string(out), "\n") {
parts := strings.Fields(line)
for i, p := range parts {
if p == "inet" && i+1 < len(parts) {
addr := strings.SplitN(parts[i+1], "/", 2)[0]
ips = append(ips, addr)
}
}
}
return ips, nil
}
// peerVIPCmd ruft AgentVIPCmd auf dem Peer via mTLS auf.
func (h *ClusterHandler) peerVIPCmd(ctx context.Context, peer models.HANode, vip *vipInfo, action, stepLabel string) vipTestStep {
step := vipTestStep{Step: stepLabel}
if h.Aggregator == nil {
step.Message = "aggregator nicht verfügbar"
return step
}
body, _ := json.Marshal(vipCmdRequest{
Action: action,
Address: vip.Address,
Prefix: vip.Prefix,
Device: vip.Device,
})
res := h.Aggregator.PostPeerWithBody(ctx, peer, "/agent/cluster/vip-cmd", body)
step.OK = res.OK
if !res.OK {
step.Message = res.Err
}
return step
}
// localVIPStep führt ip addr add/del auf dem lokalen Node aus.
func localVIPStep(vip *vipInfo, action, stepLabel string) vipTestStep {
step := vipTestStep{Step: stepLabel}
if err := runVIPCmd(action, vip.Address, vip.Prefix, vip.Device); err != nil {
step.Message = err.Error()
return step
}
step.OK = true
return step
}
// runVIPCmd führt `sudo /usr/lib/edgeguard/vip-cmd.sh {action} {addr/prefix} {dev}` aus.
func runVIPCmd(action, address string, prefix int, device string) error {
addrPfx := fmt.Sprintf("%s/%d", address, prefix)
out, err := exec.Command("sudo", "-n", "/usr/lib/edgeguard/vip-cmd.sh", action, addrPfx, device).CombinedOutput()
if err != nil {
return fmt.Errorf("vip-cmd.sh %s %s %s: %s", action, addrPfx, device, strings.TrimSpace(string(out)))
}
return nil
}

View File

@@ -0,0 +1,285 @@
package handlers
import (
"errors"
"net/http"
"github.com/gin-gonic/gin"
crowdsec "git.netcell-it.de/projekte/edgeguard-native/internal/crowdsec"
"git.netcell-it.de/projekte/edgeguard-native/internal/handlers/response"
"git.netcell-it.de/projekte/edgeguard-native/internal/services/audit"
)
// CrowdSecHandler exposes the CrowdSec IDS/IPS management REST API:
//
// GET /crowdsec/status
// GET /crowdsec/decisions
// POST /crowdsec/decisions
// DELETE /crowdsec/decisions (?ip=<ip> or ?id=<id>)
// GET /crowdsec/alerts
// DELETE /crowdsec/alerts/:id
// GET /crowdsec/bouncers
// DELETE /crowdsec/bouncers/:name
// GET /crowdsec/machines
// DELETE /crowdsec/machines/:id
// GET /crowdsec/collections
// POST /crowdsec/collections/:name/install
// DELETE /crowdsec/collections/:name
type CrowdSecHandler struct {
Audit *audit.Repo
NodeID string
}
// NewCrowdSecHandler returns a CrowdSecHandler wired with audit and node-id.
func NewCrowdSecHandler(a *audit.Repo, nodeID string) *CrowdSecHandler {
return &CrowdSecHandler{Audit: a, NodeID: nodeID}
}
// Register mounts all CrowdSec routes onto the provided authenticated router
// group.
func (h *CrowdSecHandler) Register(rg *gin.RouterGroup) {
g := rg.Group("/crowdsec")
g.GET("/status", h.Status)
g.GET("/decisions", h.ListDecisions)
g.POST("/decisions", h.AddDecision)
g.DELETE("/decisions", h.DeleteDecision)
g.GET("/alerts", h.ListAlerts)
g.DELETE("/alerts/:id", h.DeleteAlert)
g.GET("/bouncers", h.ListBouncers)
g.DELETE("/bouncers/:name", h.DeleteBouncer)
g.GET("/machines", h.ListMachines)
g.DELETE("/machines/:id", h.DeleteMachine)
g.GET("/collections", h.ListCollections)
g.POST("/collections/:name/install", h.InstallCollection)
g.DELETE("/collections/:name", h.RemoveCollection)
}
// csNotInstalled responds with 503 when cscli is absent.
func csNotInstalled(c *gin.Context) {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "crowdsec not installed"})
}
// ---------- Status ----------------------------------------------------------
// Status returns live status of the CrowdSec agent + bouncer.
// Does NOT require cscli — uses systemctl for running-state checks.
func (h *CrowdSecHandler) Status(c *gin.Context) {
st := crowdsec.ServiceStatus(c.Request.Context())
response.OK(c, st)
}
// ---------- Decisions -------------------------------------------------------
// ListDecisions returns all active decisions.
func (h *CrowdSecHandler) ListDecisions(c *gin.Context) {
if !crowdsec.IsInstalled() {
csNotInstalled(c)
return
}
list, err := crowdsec.Decisions(c.Request.Context())
if err != nil {
response.Internal(c, err)
return
}
response.OK(c, gin.H{"decisions": list})
}
// addDecisionBody is the expected JSON body for POST /crowdsec/decisions.
type addDecisionBody struct {
IP string `json:"ip" binding:"required"`
Duration string `json:"duration" binding:"required"`
Reason string `json:"reason"`
Type string `json:"type"`
}
// AddDecision creates a new ban/captcha decision.
func (h *CrowdSecHandler) AddDecision(c *gin.Context) {
if !crowdsec.IsInstalled() {
csNotInstalled(c)
return
}
var body addDecisionBody
if err := c.ShouldBindJSON(&body); err != nil {
response.BadRequest(c, err)
return
}
if body.Reason == "" {
body.Reason = "manual ban"
}
if body.Type == "" {
body.Type = "ban"
}
if err := crowdsec.AddDecision(c.Request.Context(), body.IP, body.Duration, body.Reason, body.Type); err != nil {
response.Internal(c, err)
return
}
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "crowdsec.decision.add", body.IP,
gin.H{"duration": body.Duration, "type": body.Type, "reason": body.Reason}, h.NodeID)
response.Created(c, gin.H{"ip": body.IP, "duration": body.Duration, "type": body.Type})
}
// DeleteDecision removes a decision by IP (?ip=) or by ID (?id=).
func (h *CrowdSecHandler) DeleteDecision(c *gin.Context) {
if !crowdsec.IsInstalled() {
csNotInstalled(c)
return
}
ip := c.Query("ip")
id := c.Query("id")
if ip == "" && id == "" {
response.BadRequest(c, errors.New("query parameter 'ip' or 'id' required"))
return
}
var err error
var target string
if ip != "" {
err = crowdsec.DeleteDecisionByIP(c.Request.Context(), ip)
target = ip
} else {
err = crowdsec.DeleteDecisionByID(c.Request.Context(), id)
target = id
}
if err != nil {
response.Internal(c, err)
return
}
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "crowdsec.decision.delete", target, nil, h.NodeID)
response.OK(c, gin.H{"deleted": target})
}
// ---------- Alerts ----------------------------------------------------------
// ListAlerts returns recent CrowdSec alerts.
func (h *CrowdSecHandler) ListAlerts(c *gin.Context) {
if !crowdsec.IsInstalled() {
csNotInstalled(c)
return
}
list, err := crowdsec.Alerts(c.Request.Context(), 200)
if err != nil {
response.Internal(c, err)
return
}
response.OK(c, gin.H{"alerts": list})
}
// DeleteAlert discards a single alert.
func (h *CrowdSecHandler) DeleteAlert(c *gin.Context) {
if !crowdsec.IsInstalled() {
csNotInstalled(c)
return
}
id := c.Param("id")
if err := crowdsec.DeleteAlert(c.Request.Context(), id); err != nil {
response.Internal(c, err)
return
}
response.OK(c, gin.H{"deleted": id})
}
// ---------- Bouncers --------------------------------------------------------
// ListBouncers returns all registered bouncers.
func (h *CrowdSecHandler) ListBouncers(c *gin.Context) {
if !crowdsec.IsInstalled() {
csNotInstalled(c)
return
}
list, err := crowdsec.Bouncers(c.Request.Context())
if err != nil {
response.Internal(c, err)
return
}
response.OK(c, gin.H{"bouncers": list})
}
// DeleteBouncer removes a bouncer by name.
func (h *CrowdSecHandler) DeleteBouncer(c *gin.Context) {
if !crowdsec.IsInstalled() {
csNotInstalled(c)
return
}
name := c.Param("name")
if err := crowdsec.DeleteBouncer(c.Request.Context(), name); err != nil {
response.Internal(c, err)
return
}
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "crowdsec.bouncer.delete", name, nil, h.NodeID)
response.OK(c, gin.H{"deleted": name})
}
// ---------- Machines --------------------------------------------------------
// ListMachines returns all registered machines.
func (h *CrowdSecHandler) ListMachines(c *gin.Context) {
if !crowdsec.IsInstalled() {
csNotInstalled(c)
return
}
list, err := crowdsec.Machines(c.Request.Context())
if err != nil {
response.Internal(c, err)
return
}
response.OK(c, gin.H{"machines": list})
}
// DeleteMachine removes a machine by ID.
func (h *CrowdSecHandler) DeleteMachine(c *gin.Context) {
if !crowdsec.IsInstalled() {
csNotInstalled(c)
return
}
id := c.Param("id")
if err := crowdsec.DeleteMachine(c.Request.Context(), id); err != nil {
response.Internal(c, err)
return
}
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "crowdsec.machine.delete", id, nil, h.NodeID)
response.OK(c, gin.H{"deleted": id})
}
// ---------- Collections -----------------------------------------------------
// ListCollections returns all hub collections and their install status.
func (h *CrowdSecHandler) ListCollections(c *gin.Context) {
if !crowdsec.IsInstalled() {
csNotInstalled(c)
return
}
list, err := crowdsec.Collections(c.Request.Context())
if err != nil {
response.Internal(c, err)
return
}
response.OK(c, gin.H{"collections": list})
}
// InstallCollection installs a hub collection by name.
func (h *CrowdSecHandler) InstallCollection(c *gin.Context) {
if !crowdsec.IsInstalled() {
csNotInstalled(c)
return
}
name := c.Param("name")
if err := crowdsec.InstallCollection(c.Request.Context(), name); err != nil {
response.Internal(c, err)
return
}
response.Created(c, gin.H{"installed": name})
}
// RemoveCollection removes a hub collection by name.
func (h *CrowdSecHandler) RemoveCollection(c *gin.Context) {
if !crowdsec.IsInstalled() {
csNotInstalled(c)
return
}
name := c.Param("name")
if err := crowdsec.RemoveCollection(c.Request.Context(), name); err != nil {
response.Internal(c, err)
return
}
response.OK(c, gin.H{"removed": name})
}

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

View File

@@ -306,7 +306,7 @@ func (h *DNSHandler) UpdateSettings(c *gin.Context) {
// cached RRs from the resolver. Useful after DNS propagation or when
// stale records need to be evicted immediately.
func (h *DNSHandler) FlushCache(c *gin.Context) {
out, err := exec.CommandContext(c.Request.Context(), "unbound-control", "flush_zone", ".").CombinedOutput()
out, err := exec.CommandContext(c.Request.Context(), "/usr/sbin/unbound-control", "flush_zone", ".").CombinedOutput()
if err != nil {
slog.Error("dns flush-cache failed", "err", err, "out", string(out))
response.Internal(c, err)
@@ -388,7 +388,7 @@ func validateZone(z *models.DNSZone) error {
// stats_noreset liest die Zähler ohne sie zurückzusetzen — safe für
// wiederholte Aufrufe aus dem UI.
func (h *DNSHandler) Stats(c *gin.Context) {
out, err := exec.Command("unbound-control", "stats_noreset").Output()
out, err := exec.Command("/usr/sbin/unbound-control", "stats_noreset").Output()
if err != nil {
response.OK(c, gin.H{
"error": "unbound-control nicht verfügbar: " + err.Error(),

View File

@@ -138,6 +138,7 @@ func (h *FirewallHandler) Register(rg *gin.RouterGroup) {
rl.POST("", h.CreateRule)
rl.GET("/:id", h.GetRule)
rl.PUT("/:id", h.UpdateRule)
rl.PATCH("/:id", h.PatchRule)
rl.DELETE("/:id", h.DeleteRule)
nat := g.Group("/nat-rules")
@@ -145,6 +146,7 @@ func (h *FirewallHandler) Register(rg *gin.RouterGroup) {
nat.POST("", h.CreateNAT)
nat.GET("/:id", h.GetNAT)
nat.PUT("/:id", h.UpdateNAT)
nat.PATCH("/:id", h.PatchNAT)
nat.DELETE("/:id", h.DeleteNAT)
}
@@ -758,6 +760,48 @@ func (h *FirewallHandler) DeleteRule(c *gin.Context) {
response.NoContent(c); h.reload(c.Request.Context(), "delete")
}
func (h *FirewallHandler) PatchRule(c *gin.Context) {
id, ok := parseID(c)
if !ok {
return
}
var body struct {
Note *string `json:"note"`
Labels []string `json:"labels"`
}
if err := c.ShouldBindJSON(&body); err != nil {
response.BadRequest(c, err)
return
}
ctx := c.Request.Context()
if body.Note != nil {
if err := h.Rules.PatchNote(ctx, id, *body.Note); err != nil {
if errors.Is(err, firewall.ErrRuleNotFound) {
response.NotFound(c, err)
return
}
response.Internal(c, err)
return
}
}
if body.Labels != nil {
if err := h.Rules.PatchLabels(ctx, id, body.Labels); err != nil {
if errors.Is(err, firewall.ErrRuleNotFound) {
response.NotFound(c, err)
return
}
response.Internal(c, err)
return
}
}
out, err := h.Rules.Get(ctx, id)
if err != nil {
response.Internal(c, err)
return
}
response.OK(c, out)
}
// ── NAT Rules ──────────────────────────────────────────────────────────
func (h *FirewallHandler) ListNAT(c *gin.Context) {
@@ -858,6 +902,48 @@ func (h *FirewallHandler) DeleteNAT(c *gin.Context) {
response.NoContent(c); h.reload(c.Request.Context(), "delete")
}
func (h *FirewallHandler) PatchNAT(c *gin.Context) {
id, ok := parseID(c)
if !ok {
return
}
var body struct {
Note *string `json:"note"`
Labels []string `json:"labels"`
}
if err := c.ShouldBindJSON(&body); err != nil {
response.BadRequest(c, err)
return
}
ctx := c.Request.Context()
if body.Note != nil {
if err := h.NATRules.PatchNote(ctx, id, *body.Note); err != nil {
if errors.Is(err, firewall.ErrNATRuleNotFound) {
response.NotFound(c, err)
return
}
response.Internal(c, err)
return
}
}
if body.Labels != nil {
if err := h.NATRules.PatchLabels(ctx, id, body.Labels); err != nil {
if errors.Is(err, firewall.ErrNATRuleNotFound) {
response.NotFound(c, err)
return
}
response.Internal(c, err)
return
}
}
out, err := h.NATRules.Get(ctx, id)
if err != nil {
response.Internal(c, err)
return
}
response.OK(c, out)
}
// ── Validators ─────────────────────────────────────────────────────────
func validateAddrObjValue(kind, value string) error {

View File

@@ -39,6 +39,8 @@ func (h *ForwardProxyHandler) reload(ctx context.Context, op string) {
func (h *ForwardProxyHandler) Register(rg *gin.RouterGroup) {
base := rg.Group("/forward-proxy")
base.GET("/stats", h.Stats)
base.GET("/settings", h.GetSettings)
base.PUT("/settings", h.UpdateSettings)
g := base.Group("/acls")
g.GET("", h.List)
@@ -48,6 +50,34 @@ func (h *ForwardProxyHandler) Register(rg *gin.RouterGroup) {
g.DELETE("/:id", h.Delete)
}
func (h *ForwardProxyHandler) 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 *ForwardProxyHandler) UpdateSettings(c *gin.Context) {
var req models.ForwardProxySettings
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, err)
return
}
if req.ListenPort <= 0 || req.ListenPort > 65535 {
req.ListenPort = 3128
}
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), "forward_proxy.settings.update", "settings", out, h.NodeID)
response.OK(c, out)
h.reload(c.Request.Context(), "settings.update")
}
func (h *ForwardProxyHandler) List(c *gin.Context) {
out, err := h.Repo.List(c.Request.Context())
if err != nil {

View File

@@ -1,7 +1,9 @@
package handlers
import (
"context"
"errors"
"log/slog"
"strconv"
"github.com/gin-gonic/gin"
@@ -13,13 +15,27 @@ import (
)
type IPAddressesHandler struct {
Repo *ipaddresses.Repo
Audit *audit.Repo
NodeID string
Repo *ipaddresses.Repo
Generator *ipaddresses.Generator
Audit *audit.Repo
NodeID string
}
func NewIPAddressesHandler(repo *ipaddresses.Repo, a *audit.Repo, nodeID string) *IPAddressesHandler {
return &IPAddressesHandler{Repo: repo, Audit: a, NodeID: nodeID}
return &IPAddressesHandler{
Repo: repo,
Generator: ipaddresses.NewGenerator(repo),
Audit: a,
NodeID: nodeID,
}
}
func (h *IPAddressesHandler) applyAsync() {
go func() {
if err := h.Generator.Render(context.Background()); err != nil {
slog.Warn("ip-addresses: apply failed", "error", err)
}
}()
}
func (h *IPAddressesHandler) Register(rg *gin.RouterGroup) {
@@ -70,6 +86,7 @@ func (h *IPAddressesHandler) Create(c *gin.Context) {
}
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "ip_address.create",
req.Address, out, h.NodeID)
h.applyAsync()
response.Created(c, out)
}
@@ -94,6 +111,7 @@ func (h *IPAddressesHandler) Update(c *gin.Context) {
}
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "ip_address.update",
out.Address, out, h.NodeID)
h.applyAsync()
response.OK(c, out)
}
@@ -112,5 +130,6 @@ func (h *IPAddressesHandler) Delete(c *gin.Context) {
}
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "ip_address.delete",
strconv.FormatInt(id, 10), gin.H{"id": id}, h.NodeID)
h.applyAsync()
response.NoContent(c)
}

View File

@@ -1,7 +1,9 @@
package handlers
import (
"context"
"errors"
"log/slog"
"strconv"
"github.com/gin-gonic/gin"
@@ -15,18 +17,34 @@ import (
)
type NetworksHandler struct {
Repo *networkifs.Repo
IPs *ipaddresses.Repo
Zones *firewall.ZonesRepo
Audit *audit.Repo
NodeID string
Repo *networkifs.Repo
Generator *networkifs.Generator
IPs *ipaddresses.Repo
Zones *firewall.ZonesRepo
Audit *audit.Repo
NodeID string
}
func NewNetworksHandler(
repo *networkifs.Repo, ips *ipaddresses.Repo,
zones *firewall.ZonesRepo, a *audit.Repo, nodeID string,
) *NetworksHandler {
return &NetworksHandler{Repo: repo, IPs: ips, Zones: zones, Audit: a, NodeID: nodeID}
return &NetworksHandler{
Repo: repo,
Generator: networkifs.NewGenerator(repo),
IPs: ips,
Zones: zones,
Audit: a,
NodeID: nodeID,
}
}
func (h *NetworksHandler) applyAsync() {
go func() {
if err := h.Generator.Render(context.Background()); err != nil {
slog.Warn("network-interfaces: apply failed", "error", err)
}
}()
}
func (h *NetworksHandler) Register(rg *gin.RouterGroup) {
@@ -88,6 +106,7 @@ func (h *NetworksHandler) Create(c *gin.Context) {
return
}
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "network_interface.create", req.Name, out, h.NodeID)
h.applyAsync()
response.Created(c, out)
}
@@ -122,6 +141,7 @@ func (h *NetworksHandler) Update(c *gin.Context) {
return
}
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "network_interface.update", out.Name, out, h.NodeID)
h.applyAsync()
response.OK(c, out)
}
@@ -140,6 +160,7 @@ func (h *NetworksHandler) Delete(c *gin.Context) {
}
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "network_interface.delete",
strconv.FormatInt(id, 10), gin.H{"id": id}, h.NodeID)
h.applyAsync()
response.NoContent(c)
}

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

@@ -118,10 +118,12 @@ func (h *SystemHandler) Register(rg *gin.RouterGroup) {
g.POST("/haproxy-reload", h.HAProxyReload)
g.POST("/render-configs", h.RenderConfigs)
g.POST("/service-restart", h.ServiceRestart)
g.POST("/service-toggle", h.ServiceToggle)
g.GET("/upgrade-status", h.UpgradeStatus)
g.GET("/ipv6", h.IPv6)
g.POST("/ipv6", h.SetIPv6)
g.GET("/config-preview", h.ConfigPreview)
g.GET("/vip-status", h.VIPStatus)
}
// RegisterAgent mountet die read-only System-Endpoints auf der mTLS-
@@ -188,10 +190,16 @@ var servicesToCheck = []struct{ Label, Unit string }{
{"edgeguard-scheduler", "edgeguard-scheduler"},
{"haproxy", "haproxy"},
{"nftables", "nftables"},
{"keepalived", "keepalived"},
{"unbound", "unbound"},
{"chrony", "chrony"},
{"squid", "squid"},
{"kea-dhcp4", "kea-dhcp4-server"},
{"freeradius", "freeradius"},
{"postgresql", "postgresql"},
{"crowdsec", "crowdsec"},
{"crowdsec-firewall-bouncer", "crowdsec-firewall-bouncer"},
{"edgeguard-waf", "edgeguard-waf"},
}
type serviceStatus struct {
@@ -630,6 +638,53 @@ func (h *SystemHandler) ServiceRestart(c *gin.Context) {
response.OK(c, gin.H{"ok": true, "service": svc})
}
// toggleAllowlist defines which services may be started/stopped via the UI.
var toggleAllowlist = map[string]bool{
"crowdsec": true,
"crowdsec-firewall-bouncer": true,
"edgeguard-waf": true,
"squid": true,
"unbound": true,
}
// ServiceToggle starts or stops (and enables/disables) a service.
// Body: {"service": "crowdsec", "enabled": true}
func (h *SystemHandler) ServiceToggle(c *gin.Context) {
var req struct {
Service string `json:"service" binding:"required"`
Enabled bool `json:"enabled"`
}
if err := c.ShouldBindJSON(&req); err != nil {
response.Err(c, http.StatusBadRequest, simpleErr("service and enabled required"))
return
}
svc := strings.TrimSpace(req.Service)
if !toggleAllowlist[svc] {
response.Err(c, http.StatusBadRequest, simpleErr("service not in toggle allowlist: "+svc))
return
}
unit := svc + ".service"
action := "stop"
sysdAction := "disable"
if req.Enabled {
action = "start"
sysdAction = "enable"
}
if out, err := exec.Command("sudo", "-n", "/usr/bin/systemctl", sysdAction, unit).CombinedOutput(); err != nil {
response.Err(c, http.StatusInternalServerError, simpleErr(strings.TrimSpace(string(out))+": "+err.Error()))
return
}
if out, err := exec.Command("sudo", "-n", "/usr/bin/systemctl", action, unit).CombinedOutput(); err != nil {
response.Err(c, http.StatusInternalServerError, simpleErr(strings.TrimSpace(string(out))+": "+err.Error()))
return
}
if h.Audit != nil {
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "system.service_toggle",
svc, gin.H{"service": svc, "enabled": req.Enabled}, h.NodeID)
}
response.OK(c, gin.H{"ok": true, "service": svc, "enabled": req.Enabled})
}
// RenderConfigs erzwingt ein Re-Render aller Service-Configs aus dem
// aktuellen DB-State. Läuft haproxy + alle ExtraReloaders (nftables,
// wireguard, squid, unbound, chrony) durch. Fehler werden gesammelt
@@ -1077,6 +1132,87 @@ func classifyLinkType(ifc net.Interface) string {
return ""
}
// VIPStatus returns the VRRP state and active VIPs for this node.
// Uses net.Interfaces() (no shell-out) to check which VIPs from
// ip_addresses WHERE is_vip=true are currently assigned locally.
// MASTER = at least one VIP is locally present; BACKUP = none present.
func (h *SystemHandler) VIPStatus(c *gin.Context) {
type vipEntry struct {
Address string `json:"address"`
Prefix int `json:"prefix"`
Device string `json:"device"`
Active bool `json:"active"`
}
type vipStatus struct {
VRRPState string `json:"vrrp_state"`
KeepalivedActive bool `json:"keepalived_active"`
VIPs []vipEntry `json:"vips"`
}
ctx := c.Request.Context()
// keepalived service active?
kaOut, _ := exec.CommandContext(ctx, "systemctl", "is-active", "keepalived").Output()
kaActive := strings.TrimSpace(string(kaOut)) == "active"
// query VIPs from DB
var dbVIPs []vipEntry
if h.Pool != nil {
rows, err := h.Pool.Query(ctx,
`SELECT a.address, a.prefix, COALESCE(i.name,'') AS device
FROM ip_addresses a
LEFT JOIN network_interfaces i ON i.id = a.interface_id
WHERE a.is_vip = true AND a.active = true
ORDER BY a.address`)
if err == nil {
defer rows.Close()
for rows.Next() {
var e vipEntry
if err2 := rows.Scan(&e.Address, &e.Prefix, &e.Device); err2 == nil {
dbVIPs = append(dbVIPs, e)
}
}
}
}
// build set of locally assigned IPs
localIPs := make(map[string]bool)
if ifaces, err := net.Interfaces(); err == nil {
for _, ifc := range ifaces {
if addrs, err2 := ifc.Addrs(); err2 == nil {
for _, a := range addrs {
if ipnet, ok := a.(*net.IPNet); ok {
localIPs[ipnet.IP.String()] = true
}
}
}
}
}
anyActive := false
for i := range dbVIPs {
dbVIPs[i].Active = localIPs[dbVIPs[i].Address]
if dbVIPs[i].Active {
anyActive = true
}
}
state := "UNKNOWN"
if kaActive {
if anyActive {
state = "MASTER"
} else {
state = "BACKUP"
}
}
response.OK(c, vipStatus{
VRRPState: state,
KeepalivedActive: kaActive,
VIPs: dbVIPs,
})
}
func flagsToList(f net.Flags) []string {
var out []string
if f&net.FlagUp != 0 {

View File

@@ -35,6 +35,7 @@ func (h *UsersHandler) Register(rg *gin.RouterGroup) {
g.PUT("/:id", h.Update)
g.POST("/:id/password", h.SetPassword)
g.DELETE("/:id", h.Delete)
g.DELETE("/:id/totp", h.DisableTOTP)
}
func (h *UsersHandler) List(c *gin.Context) {
@@ -164,3 +165,22 @@ func (h *UsersHandler) Delete(c *gin.Context) {
c.Param("id"), nil, h.NodeID)
response.OK(c, gin.H{"ok": true})
}
// DisableTOTP allows an admin to disable 2FA for any user.
func (h *UsersHandler) DisableTOTP(c *gin.Context) {
id, ok := parseID(c)
if !ok {
return
}
if err := h.Repo.DisableTOTP(c.Request.Context(), id); err != nil {
if errors.Is(err, users.ErrNotFound) {
response.NotFound(c, err)
return
}
response.Internal(c, err)
return
}
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "user.totp.disabled",
c.Param("id"), nil, h.NodeID)
response.OK(c, gin.H{"ok": true})
}

227
internal/handlers/waf.go Normal file
View File

@@ -0,0 +1,227 @@
package handlers
import (
"context"
"errors"
"log/slog"
"net"
"net/http"
"regexp"
"strconv"
"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"
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)
// GET /waf/configs/:domain_id — get config for a domain
// PUT /waf/configs/:domain_id — upsert config for a domain
type WafHandler struct {
Repo *wafsvc.Repo
Audit *audit.Repo
NodeID string
Reloader func(ctx context.Context) error
}
func NewWafHandler(repo *wafsvc.Repo, a *audit.Repo, nodeID string, reloader func(context.Context) error) *WafHandler {
return &WafHandler{Repo: repo, Audit: a, NodeID: nodeID, Reloader: reloader}
}
func (h *WafHandler) Register(rg *gin.RouterGroup) {
g := rg.Group("/waf")
g.GET("/configs", h.List)
g.GET("/configs/:domain_id", h.Get)
g.PUT("/configs/:domain_id", h.Upsert)
g.GET("/alerts", h.ListAlerts)
g.DELETE("/alerts", h.PurgeAlerts)
}
// List returns all WAF configs.
func (h *WafHandler) List(c *gin.Context) {
configs, err := h.Repo.List(c.Request.Context())
if err != nil {
response.Internal(c, err)
return
}
response.OK(c, gin.H{"configs": configs})
}
// Get returns the WAF config for a single domain.
// Returns a default (disabled) config when none exists yet.
func (h *WafHandler) Get(c *gin.Context) {
domainID, err := strconv.ParseInt(c.Param("domain_id"), 10, 64)
if err != nil {
response.BadRequest(c, errors.New("invalid domain_id"))
return
}
cfg, err := h.Repo.GetByDomain(c.Request.Context(), domainID)
if err != nil {
if errors.Is(err, wafsvc.ErrNotFound) {
// Return a default config so the UI always gets a usable object.
response.OK(c, gin.H{"config": defaultConfig(domainID)})
return
}
response.Internal(c, err)
return
}
response.OK(c, gin.H{"config": cfg})
}
// upsertBody is the accepted JSON for PUT /waf/configs/:domain_id.
type upsertBody struct {
Enabled bool `json:"enabled"`
Mode string `json:"mode"`
ParanoiaLevel int `json:"paranoia_level"`
RuleExclusions []string `json:"rule_exclusions"`
ExclusionNotes map[string]string `json:"exclusion_notes"`
TrustedProxies []string `json:"trusted_proxies"`
CustomRules string `json:"custom_rules"`
}
// Upsert creates or updates the WAF config for a domain.
func (h *WafHandler) Upsert(c *gin.Context) {
domainID, err := strconv.ParseInt(c.Param("domain_id"), 10, 64)
if err != nil {
response.BadRequest(c, errors.New("invalid domain_id"))
return
}
var body upsertBody
if err := c.ShouldBindJSON(&body); err != nil {
response.BadRequest(c, err)
return
}
if body.Mode == "" {
body.Mode = "detection"
}
if body.ParanoiaLevel < 1 || body.ParanoiaLevel > 4 {
body.ParanoiaLevel = 1
}
if body.RuleExclusions == nil {
body.RuleExclusions = []string{}
}
if body.TrustedProxies == nil {
body.TrustedProxies = []string{}
}
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,
Mode: body.Mode,
ParanoiaLevel: body.ParanoiaLevel,
RuleExclusions: body.RuleExclusions,
ExclusionNotes: body.ExclusionNotes,
TrustedProxies: body.TrustedProxies,
CustomRules: body.CustomRules,
}
result, err := h.Repo.Upsert(c.Request.Context(), cfg)
if err != nil {
response.Internal(c, err)
return
}
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "waf.config.upsert",
strconv.FormatInt(domainID, 10),
gin.H{"enabled": body.Enabled, "mode": body.Mode, "paranoia_level": body.ParanoiaLevel},
h.NodeID)
// Reload HAProxy so the SPOE filter is added/removed based on
// whether any domain now has WAF enabled.
if h.Reloader != nil {
go func() {
if err := h.Reloader(context.Background()); err != nil {
slog.Warn("waf: haproxy reload after config change failed", "error", err)
}
}()
}
c.JSON(http.StatusOK, gin.H{"config": result})
}
// ListAlerts returns recent WAF alerts. Optional: ?domain_id=X&limit=N
func (h *WafHandler) ListAlerts(c *gin.Context) {
var domainID *int64
if v := c.Query("domain_id"); v != "" {
id, err := strconv.ParseInt(v, 10, 64)
if err != nil {
response.BadRequest(c, errors.New("invalid domain_id"))
return
}
domainID = &id
}
limit := 200
if v := c.Query("limit"); v != "" {
if n, err := strconv.Atoi(v); err == nil && n > 0 {
limit = n
}
}
alerts, err := h.Repo.ListAlerts(c.Request.Context(), domainID, limit)
if err != nil {
response.Internal(c, err)
return
}
response.OK(c, gin.H{"alerts": alerts})
}
// PurgeAlerts deletes old WAF alerts. Optional: ?days=N (default 30)
func (h *WafHandler) PurgeAlerts(c *gin.Context) {
days := 30
if v := c.Query("days"); v != "" {
if n, err := strconv.Atoi(v); err == nil && n > 0 {
days = n
}
}
if err := h.Repo.PurgeAlerts(c.Request.Context(), days); err != nil {
response.Internal(c, err)
return
}
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "waf.alerts.purge",
"", gin.H{"days": days}, h.NodeID)
response.OK(c, gin.H{"ok": true, "days": days})
}
// defaultConfig returns a sensible disabled default for a domain
// that has no WAF config row yet.
func defaultConfig(domainID int64) models.WafConfig {
return models.WafConfig{
DomainID: domainID,
Enabled: false,
Mode: "detection",
ParanoiaLevel: 1,
RuleExclusions: []string{},
ExclusionNotes: map[string]string{},
TrustedProxies: []string{},
CustomRules: "",
}
}

View File

@@ -80,6 +80,11 @@ frontend public_https
bind [::]:443 ssl crt /etc/edgeguard/tls/ alpn h2,http/1.1
bind quic6@:443 ssl crt /etc/edgeguard/tls/ alpn h3
{{- end}}
{{- if .WAFEnabled}}
# WAF: SPOE-Filter — edgeguard-waf inspiziert jeden Request.
# filter muss vor allen http-request/http-response-Direktiven stehen.
filter spoe engine edgeguard-waf config /etc/edgeguard/haproxy/coraza-spoe.cfg
{{- end}}
# Alt-Svc: signalisiert dass h3 auf demselben Port verfügbar ist.
# ma=86400 = Browser darf den Hinweis 24h cachen.
@@ -91,6 +96,10 @@ frontend public_https
# echte Source-IP ohne XFF-Chain-Parsing brauchen.
http-request set-header X-Forwarded-Proto https
http-request set-header X-Real-IP %[src]
{{- if .WAFEnabled}}
# WAF: Request blockieren wenn edgeguard-waf txn.waf.status gesetzt hat.
http-request deny deny_status 403 if { var(txn.waf.status) -m found }
{{- end}}
{{- if .GlobalMaintenance}}
# Whole-Box-Maintenance — Settings → Maintenance-Mode aktiv. Dieser
@@ -195,6 +204,16 @@ backend rl_{{$d.ID}}
{{- end}}
{{- end}}
{{- if .WAFEnabled}}
# SPOE-Backend für edgeguard-waf (TCP, kein HTTP-Parsing).
backend spoe-edgeguard-waf
mode tcp
timeout connect 100ms
timeout server 1s
server spoe-waf-1 127.0.0.1:9000
{{- end}}
{{- range $b := .Backends}}
backend eg_backend_{{$b.ID}}

View File

@@ -24,6 +24,7 @@ import (
"git.netcell-it.de/projekte/edgeguard-native/internal/services/domains"
"git.netcell-it.de/projekte/edgeguard-native/internal/services/routingrules"
"git.netcell-it.de/projekte/edgeguard-native/internal/services/setup"
wafsvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/waf"
)
//go:embed haproxy.cfg.tpl
@@ -64,25 +65,29 @@ type Generator struct {
ServersRepo *backendservers.Repo
RoutingRepo *routingrules.Repo
HeadersRepo *domainheaders.Repo
WafRepo *wafsvc.Repo
// SetupStore (optional): wenn gesetzt, lesen wir Whole-Box-
// Maintenance-Status hieraus und reichen ihn als View.GlobalMaintenance
// ans Template weiter.
SetupStore *setup.Store
OutputPath string
SkipReload bool
OutputPath string
SPOEConfigPath string
SkipReload bool
}
func New(pool *pgxpool.Pool) *Generator {
return &Generator{
Pool: pool,
DomainsRepo: domains.New(pool),
BackendsRepo: backends.New(pool),
ServersRepo: backendservers.New(pool),
RoutingRepo: routingrules.New(pool),
HeadersRepo: domainheaders.New(pool),
SetupStore: setup.NewStore(setup.DefaultDir),
Pool: pool,
DomainsRepo: domains.New(pool),
BackendsRepo: backends.New(pool),
ServersRepo: backendservers.New(pool),
RoutingRepo: routingrules.New(pool),
HeadersRepo: domainheaders.New(pool),
WafRepo: wafsvc.New(pool),
SetupStore: setup.NewStore(setup.DefaultDir),
SPOEConfigPath: filepath.Join(configgen.EtcEdgeguard, "haproxy", "coraza-spoe.cfg"),
}
}
@@ -116,6 +121,17 @@ func (g *Generator) Render(ctx context.Context) error {
if err := configgen.AtomicWrite(out, buf.Bytes(), 0o644); err != nil {
return fmt.Errorf("haproxy: write: %w", err)
}
// Write SPOE config whenever WAF is enabled; remove it when disabled
// so HAProxy doesn't fail on a missing backend reference.
if view.WAFEnabled {
spoeOut := g.SPOEConfigPath
if spoeOut == "" {
spoeOut = filepath.Join(configgen.EtcEdgeguard, "haproxy", "coraza-spoe.cfg")
}
if err := configgen.AtomicWrite(spoeOut, []byte(spoeCfg), 0o644); err != nil {
return fmt.Errorf("haproxy: write spoe config: %w", err)
}
}
if g.SkipReload {
return nil
}
@@ -125,6 +141,29 @@ func (g *Generator) Render(ctx context.Context) error {
return nil
}
// spoeCfg is the static SPOE configuration for edgeguard-waf.
// HAProxy 3.x format: [<engine-name>] section + spoe-agent / spoe-message
// (no square brackets around spoe-agent/spoe-message keywords).
// spoeCfg uses `option continue-on-error` so that HAProxy never blocks
// a request when the SPOE agent is slow or unavailable. Without this,
// a timeout during CRS engine initialization would block all traffic,
// including domains without WAF configured.
const spoeCfg = `# Generated by edgeguard-api. DO NOT EDIT.
[edgeguard-waf]
spoe-agent edgeguard-waf-agent
messages edgeguard-waf-req
option var-prefix waf
option continue-on-error
timeout hello 100ms
timeout idle 30s
timeout processing 1s
use-backend spoe-edgeguard-waf
spoe-message edgeguard-waf-req
args src=src method=method uri=url ver=req.ver headers=req.hdrs host=req.hdr(host)
event on-frontend-http-request
`
// View is what the template consumes. Routes per domain are pre-
// joined here so the template can stay declarative; Servers leben pro
// BackendView, damit das Template einen `backend …`-Block mit den N
@@ -148,6 +187,11 @@ type View struct {
// IPv6Enabled: wenn true fügt das Template zusätzliche
// bind-Direktiven für [::]:80, [::]:443 und [::]:3443 hinzu.
IPv6Enabled bool
// WAFEnabled: wenn true wird der SPOE-Filter für edgeguard-waf
// in public_https eingebunden und das spoe-Backend gerendert.
// Wird gesetzt sobald mindestens eine Domain WAF enabled hat.
WAFEnabled bool
}
type DomainView struct {
@@ -289,6 +333,14 @@ func (g *Generator) loadView(ctx context.Context) (*View, error) {
}
}
v := &View{Domains: domViews, Backends: activeBackends, HTTPDomains: httpDomains}
// Check whether any domain has WAF enabled.
if g.WafRepo != nil {
if wafEnabled, err := g.WafRepo.ListEnabled(ctx); err == nil {
v.WAFEnabled = len(wafEnabled) > 0
}
}
if g.SetupStore != nil {
if st, err := g.SetupStore.Load(); err == nil && st != nil {
v.GlobalMaintenance = st.MaintenanceMode

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

View File

@@ -2,8 +2,6 @@ global_defs {
router_id {{ .RouterID }}
script_user root
enable_script_security
vrrp_garp_interval 0
vrrp_gna_interval 0
}
vrrp_script chk_edgeguard {
@@ -13,7 +11,23 @@ vrrp_script chk_edgeguard {
fall 3
rise 2
}
{{ if .GWCheckIP }}
vrrp_script chk_gateway {
script "/usr/lib/edgeguard/keepalived-gw-check.sh {{ .GWCheckIP }}"
interval 5
weight -110
fall 2
rise 2
}
{{ end }}
{{ if .HBInterface }}
vrrp_sync_group VG_1 {
group {
VI_1
VI_HB
}
}
{{ end }}
vrrp_instance VI_1 {
state {{ .State }}
interface {{ .Interface }}
@@ -29,12 +43,30 @@ vrrp_instance VI_1 {
auth_pass {{ .AuthPass }}
}
virtual_ipaddress {
{{ .VIP }}
}
{{ range .VIPs }} {{ .Address }}/{{ .Prefix }} dev {{ .Device }}
{{ end }} }
track_script {
chk_edgeguard
}
{{ if .GWCheckIP }} chk_gateway
{{ end }} }
notify_master "/usr/lib/edgeguard/keepalived-master.sh"
notify_backup "/usr/lib/edgeguard/keepalived-backup.sh"
notify_fault "/usr/lib/edgeguard/keepalived-backup.sh"
}
{{ if .HBInterface }}
vrrp_instance VI_HB {
state {{ .State }}
interface {{ .HBInterface }}
virtual_router_id {{ .HBRouterID }}
priority {{ .Priority }}
advert_int 1
{{ if .HBSrcIP }} unicast_src_ip {{ .HBSrcIP }}
unicast_peer {
{{ .HBPeerIP }}
}
{{ end }} authentication {
auth_type PASS
auth_pass {{ .AuthPass }}
}
}
{{ end }}

View File

@@ -14,6 +14,7 @@ import (
"fmt"
"os"
"os/exec"
"strings"
"text/template"
"github.com/jackc/pgx/v5/pgxpool"
@@ -29,16 +30,30 @@ var cfgTpl string
var tpl = template.Must(template.New("keepalived").Parse(cfgTpl))
// VIPEntry ist eine einzelne VIP-Adresse die keepalived verwaltet.
type VIPEntry struct {
Address string // z.B. 89.163.205.100
Prefix int // z.B. 24
Device string // z.B. eth0
}
// View ist der Template-Kontext.
type View struct {
State string // MASTER | BACKUP
Interface string
RouterID int
Priority int // MASTER=200, BACKUP=100
SrcIP string // eigene Public-IP (für unicast_src_ip)
PeerIP string // Peer-Public-IP (für unicast_peer)
AuthPass string
VIP string
State string // MASTER | BACKUP
Interface string // Interface für VRRP-Advertisements (VI_1)
RouterID int
Priority int // MASTER=200, BACKUP=100
SrcIP string // eigene Public-IP (unicast_src_ip)
PeerIP string // Peer-Public-IP (unicast_peer)
AuthPass string
VIPs []VIPEntry // alle is_vip=true Einträge aus ip_addresses
// Dual-path VRRP (Split-Brain-Schutz, Migration 0033)
HBInterface string
HBSrcIP string
HBPeerIP string
HBRouterID int
// GW-Tracking
GWCheckIP string
}
type generator struct {
@@ -53,15 +68,15 @@ func New(pool *pgxpool.Pool, localID string) configgen.Generator {
func (g *generator) Name() string { return "keepalived" }
func (g *generator) Render(ctx context.Context) error {
cs, local, peer, err := g.loadData(ctx)
cs, vips, local, peer, err := g.loadData(ctx)
if err != nil {
return fmt.Errorf("keepalived: load: %w", err)
}
if cs.VIPAddress == nil || *cs.VIPAddress == "" {
// Kein VIP konfiguriert → keepalived.conf nicht schreiben.
if len(vips) == 0 {
// Keine VIPs konfiguriert → keepalived.conf nicht schreiben.
return nil
}
v := g.buildView(cs, local, peer)
v := g.buildView(cs, vips, local, peer)
var buf bytes.Buffer
if err := tpl.Execute(&buf, v); err != nil {
return fmt.Errorf("keepalived: template: %w", err)
@@ -75,23 +90,47 @@ func (g *generator) Render(ctx context.Context) error {
return nil
}
func (g *generator) loadData(ctx context.Context) (*models.ClusterSettings, *models.HANode, *models.HANode, error) {
func (g *generator) loadData(ctx context.Context) (*models.ClusterSettings, []VIPEntry, *models.HANode, *models.HANode, error) {
var cs models.ClusterSettings
row := g.pool.QueryRow(ctx, `SELECT id, vip_address, vip_interface, vip_auth_pass, vrrp_router_id FROM cluster_settings WHERE id = 1`)
if err := row.Scan(&cs.ID, &cs.VIPAddress, &cs.VIPInterface, &cs.VIPAuthPass, &cs.VRRPRouterID); err != nil {
return nil, nil, nil, fmt.Errorf("cluster_settings: %w", err)
row := g.pool.QueryRow(ctx, `
SELECT id, vip_address, vip_interface, vip_auth_pass, vrrp_router_id,
hb_interface, hb_src_ip, hb_peer_ip, hb_router_id, gw_check_ip
FROM cluster_settings WHERE id = 1`)
if err := row.Scan(&cs.ID, &cs.VIPAddress, &cs.VIPInterface, &cs.VIPAuthPass, &cs.VRRPRouterID,
&cs.HBInterface, &cs.HBSrcIP, &cs.HBPeerIP, &cs.HBRouterID, &cs.GWCheckIP); err != nil {
return nil, nil, nil, nil, fmt.Errorf("cluster_settings: %w", err)
}
rows, err := g.pool.Query(ctx, `SELECT id, fqdn, role, pg_role, public_ip, status FROM ha_nodes ORDER BY joined_at`)
// Alle VIPs aus ip_addresses (is_vip=true, active=true) inkl. Interface-Name.
vipRows, err := g.pool.Query(ctx, `
SELECT ia.address, ia.prefix, ni.name
FROM ip_addresses ia
JOIN network_interfaces ni ON ia.interface_id = ni.id
WHERE ia.is_vip = true AND ia.active = true
ORDER BY ni.name, ia.address`)
if err != nil {
return nil, nil, nil, fmt.Errorf("ha_nodes: %w", err)
return nil, nil, nil, nil, fmt.Errorf("ip_addresses: %w", err)
}
defer rows.Close()
defer vipRows.Close()
var vips []VIPEntry
for vipRows.Next() {
var v VIPEntry
if err := vipRows.Scan(&v.Address, &v.Prefix, &v.Device); err != nil {
continue
}
vips = append(vips, v)
}
nodeRows, err := g.pool.Query(ctx, `SELECT id, fqdn, role, pg_role, public_ip, status FROM ha_nodes ORDER BY joined_at`)
if err != nil {
return nil, nil, nil, nil, fmt.Errorf("ha_nodes: %w", err)
}
defer nodeRows.Close()
var local, peer *models.HANode
for rows.Next() {
for nodeRows.Next() {
n := &models.HANode{}
if err := rows.Scan(&n.ID, &n.FQDN, &n.Role, &n.PGRole, &n.PublicIP, &n.Status); err != nil {
if err := nodeRows.Scan(&n.ID, &n.FQDN, &n.Role, &n.PGRole, &n.PublicIP, &n.Status); err != nil {
continue
}
if n.ID == g.localID {
@@ -101,17 +140,22 @@ func (g *generator) loadData(ctx context.Context) (*models.ClusterSettings, *mod
}
}
if local == nil {
return nil, nil, nil, fmt.Errorf("local node %s not in ha_nodes", g.localID)
return nil, nil, nil, nil, fmt.Errorf("local node %s not in ha_nodes", g.localID)
}
return &cs, local, peer, nil
return &cs, vips, local, peer, nil
}
func (g *generator) buildView(cs *models.ClusterSettings, local, peer *models.HANode) View {
func (g *generator) buildView(cs *models.ClusterSettings, vips []VIPEntry, local, peer *models.HANode) View {
v := View{
RouterID: cs.VRRPRouterID,
VIP: deref(cs.VIPAddress),
Interface: deref(cs.VIPInterface),
AuthPass: deref(cs.VIPAuthPass),
RouterID: cs.VRRPRouterID,
VIPs: vips,
Interface: deref(cs.VIPInterface),
AuthPass: deref(cs.VIPAuthPass),
HBInterface: deref(cs.HBInterface),
HBSrcIP: deref(cs.HBSrcIP),
HBPeerIP: deref(cs.HBPeerIP),
HBRouterID: cs.HBRouterID,
GWCheckIP: deref(cs.GWCheckIP),
}
if v.Interface == "" {
v.Interface = "eth0"
@@ -119,9 +163,17 @@ func (g *generator) buildView(cs *models.ClusterSettings, local, peer *models.HA
if v.AuthPass == "" {
v.AuthPass = "edgeguard"
}
if v.HBRouterID == 0 {
v.HBRouterID = 52
}
// Primary-Node bekommt höhere Priorität und startet als MASTER.
if local.PGRole == "primary" || local.Role == "primary" {
// pg_role=standby ist das härtere Signal — ein Standby-Node ist niemals
// MASTER, auch wenn role='primary' noch aus dem Join-Prozess stammt.
// Reihenfolge: standby → BACKUP; sonst primary-Check.
if local.PGRole == "standby" {
v.State = "BACKUP"
v.Priority = 100
} else if local.PGRole == "primary" || local.Role == "primary" {
v.State = "MASTER"
v.Priority = 200
} else {
@@ -143,7 +195,11 @@ func reloadKeepalived() error {
// keepalived läuft noch nicht — erster Render beim Start.
return nil
}
return exec.Command("systemctl", "reload-or-restart", "keepalived").Run()
cmd := exec.Command("sudo", "-n", "/usr/bin/systemctl", "reload-or-restart", "keepalived.service")
if out, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("sudo systemctl reload-or-restart keepalived.service: %w (output: %s)", err, strings.TrimSpace(string(out)))
}
return nil
}
func deref(s *string) string {

View File

@@ -4,12 +4,19 @@ import "time"
// ClusterSettings ist die Singleton-Tabelle (id=1) für VIP/VRRP-
// und Replikations-Konfiguration. Angelegt in Migration 0029.
// hb_* = zweite VRRP-Instanz für Split-Brain-Schutz (0033).
// gw_check_ip = Gateway-IP für vrrp_script chk_gateway (0033).
type ClusterSettings struct {
ID int `gorm:"column:id;primaryKey" json:"id"`
VIPAddress *string `gorm:"column:vip_address" json:"vip_address,omitempty"`
VIPInterface *string `gorm:"column:vip_interface" json:"vip_interface,omitempty"`
VIPAuthPass *string `gorm:"column:vip_auth_pass" json:"vip_auth_pass,omitempty"`
VRRPRouterID int `gorm:"column:vrrp_router_id" json:"vrrp_router_id"`
HBInterface *string `gorm:"column:hb_interface" json:"hb_interface,omitempty"`
HBSrcIP *string `gorm:"column:hb_src_ip" json:"hb_src_ip,omitempty"`
HBPeerIP *string `gorm:"column:hb_peer_ip" json:"hb_peer_ip,omitempty"`
HBRouterID int `gorm:"column:hb_router_id" json:"hb_router_id"`
GWCheckIP *string `gorm:"column:gw_check_ip" json:"gw_check_ip,omitempty"`
CreatedAt time.Time `gorm:"column:created_at" json:"created_at"`
UpdatedAt time.Time `gorm:"column:updated_at" json:"updated_at"`
}

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

@@ -40,16 +40,20 @@ func (DNSRecord) TableName() string { return "dns_records" }
// Optionen. Default kommt aus der Migration (alle Werte sinnvoll
// für die typische LAN-Resolver-Rolle).
type DNSSettings struct {
ID int64 `gorm:"primaryKey" json:"id"`
ListenAddresses string `gorm:"column:listen_addresses" json:"listen_addresses"`
ListenPort int `gorm:"column:listen_port" json:"listen_port"`
UpstreamForwards string `gorm:"column:upstream_forwards" json:"upstream_forwards"`
AccessACL string `gorm:"column:access_acl" json:"access_acl"`
DNSSEC bool `gorm:"column:dnssec" json:"dnssec"`
QNameMinimisation bool `gorm:"column:qname_minimisation" json:"qname_minimisation"`
CacheMinTTL int `gorm:"column:cache_min_ttl" json:"cache_min_ttl"`
CacheMaxTTL int `gorm:"column:cache_max_ttl" json:"cache_max_ttl"`
UpdatedAt time.Time `gorm:"column:updated_at" json:"updated_at"`
ID int64 `gorm:"primaryKey" json:"id"`
ListenAddresses string `gorm:"column:listen_addresses" json:"listen_addresses"`
ListenPort int `gorm:"column:listen_port" json:"listen_port"`
UpstreamForwards string `gorm:"column:upstream_forwards" json:"upstream_forwards"`
AccessACL string `gorm:"column:access_acl" json:"access_acl"`
DNSSEC bool `gorm:"column:dnssec" json:"dnssec"`
QNameMinimisation bool `gorm:"column:qname_minimisation" json:"qname_minimisation"`
CacheMinTTL int `gorm:"column:cache_min_ttl" json:"cache_min_ttl"`
CacheMaxTTL int `gorm:"column:cache_max_ttl" json:"cache_max_ttl"`
Prefetch bool `gorm:"column:prefetch" json:"prefetch"`
ServeExpired bool `gorm:"column:serve_expired" json:"serve_expired"`
MsgCacheSizeMB int `gorm:"column:msg_cache_size_mb" json:"msg_cache_size_mb"`
RRSetCacheSizeMB int `gorm:"column:rrset_cache_size_mb" json:"rrset_cache_size_mb"`
UpdatedAt time.Time `gorm:"column:updated_at" json:"updated_at"`
}
func (DNSSettings) TableName() string { return "dns_settings" }

View File

@@ -33,6 +33,8 @@ type FirewallNATRule struct {
TargetPortEnd *int `gorm:"column:target_port_end" json:"target_port_end,omitempty"`
Comment *string `gorm:"column:comment" json:"comment,omitempty"`
Note *string `gorm:"column:note" json:"note,omitempty"`
Labels []string `gorm:"column:labels" json:"labels"`
CreatedAt time.Time `gorm:"column:created_at" json:"created_at"`
UpdatedAt time.Time `gorm:"column:updated_at" json:"updated_at"`
}

View File

@@ -36,6 +36,8 @@ type FirewallRule struct {
Log bool `gorm:"column:log" json:"log"`
Comment *string `gorm:"column:comment" json:"comment,omitempty"`
Note *string `gorm:"column:note" json:"note,omitempty"`
Labels []string `gorm:"column:labels;serializer:json" json:"labels"`
CreatedAt time.Time `gorm:"column:created_at" json:"created_at"`
UpdatedAt time.Time `gorm:"column:updated_at" json:"updated_at"`
}

View File

@@ -0,0 +1,19 @@
package models
import "time"
type ForwardProxySettings struct {
ID int `gorm:"primaryKey" json:"id"`
ListenAddresses string `gorm:"column:listen_addresses" json:"listen_addresses"`
ListenPort int `gorm:"column:listen_port" json:"listen_port"`
CacheMemMB int `gorm:"column:cache_mem_mb" json:"cache_mem_mb"`
CacheDirMB int `gorm:"column:cache_dir_mb" json:"cache_dir_mb"`
MaxObjSizeMB int `gorm:"column:max_obj_size_mb" json:"max_obj_size_mb"`
ConnectTimeout int `gorm:"column:connect_timeout" json:"connect_timeout"`
ReadTimeout int `gorm:"column:read_timeout" json:"read_timeout"`
RequestTimeout int `gorm:"column:request_timeout" json:"request_timeout"`
CreatedAt time.Time `gorm:"column:created_at" json:"created_at"`
UpdatedAt time.Time `gorm:"column:updated_at" json:"updated_at"`
}
func (ForwardProxySettings) TableName() string { return "forward_proxy_settings" }

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

20
internal/models/waf.go Normal file
View File

@@ -0,0 +1,20 @@
package models
import "time"
// WafConfig holds the per-domain WAF policy.
// Default on creation: enabled=false, mode=detection, paranoia_level=1.
type WafConfig struct {
ID int64 `gorm:"primaryKey" json:"id"`
DomainID int64 `gorm:"column:domain_id;uniqueIndex" json:"domain_id"`
Enabled bool `gorm:"column:enabled" json:"enabled"`
Mode string `gorm:"column:mode" json:"mode"` // "detection" | "blocking"
ParanoiaLevel int `gorm:"column:paranoia_level" json:"paranoia_level"` // 14
RuleExclusions []string `gorm:"column:rule_exclusions;type:text[]" json:"rule_exclusions"`
ExclusionNotes map[string]string `gorm:"column:exclusion_notes;type:jsonb" json:"exclusion_notes"` // rule_id → note
TrustedProxies []string `gorm:"column:trusted_proxies;type:text[]" json:"trusted_proxies"`
CustomRules string `gorm:"column:custom_rules" json:"custom_rules"`
UpdatedAt time.Time `gorm:"column:updated_at" json:"updated_at"`
}
func (WafConfig) TableName() string { return "waf_configs" }

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

@@ -204,12 +204,16 @@ func (r *Repo) DeleteRecord(ctx context.Context, id int64) error {
func (r *Repo) GetSettings(ctx context.Context) (*models.DNSSettings, error) {
row := r.Pool.QueryRow(ctx, `
SELECT id, listen_addresses, listen_port, upstream_forwards, access_acl,
dnssec, qname_minimisation, cache_min_ttl, cache_max_ttl, updated_at
dnssec, qname_minimisation, cache_min_ttl, cache_max_ttl,
prefetch, serve_expired, msg_cache_size_mb, rrset_cache_size_mb,
updated_at
FROM dns_settings WHERE id=1`)
var s models.DNSSettings
if err := row.Scan(&s.ID, &s.ListenAddresses, &s.ListenPort, &s.UpstreamForwards,
&s.AccessACL, &s.DNSSEC, &s.QNameMinimisation,
&s.CacheMinTTL, &s.CacheMaxTTL, &s.UpdatedAt); err != nil {
&s.CacheMinTTL, &s.CacheMaxTTL,
&s.Prefetch, &s.ServeExpired, &s.MsgCacheSizeMB, &s.RRSetCacheSizeMB,
&s.UpdatedAt); err != nil {
return nil, err
}
return &s, nil
@@ -220,16 +224,22 @@ func (r *Repo) UpdateSettings(ctx context.Context, s models.DNSSettings) (*model
UPDATE dns_settings SET
listen_addresses=$1, listen_port=$2, upstream_forwards=$3, access_acl=$4,
dnssec=$5, qname_minimisation=$6, cache_min_ttl=$7, cache_max_ttl=$8,
prefetch=$9, serve_expired=$10, msg_cache_size_mb=$11, rrset_cache_size_mb=$12,
updated_at=NOW()
WHERE id=1
RETURNING id, listen_addresses, listen_port, upstream_forwards, access_acl,
dnssec, qname_minimisation, cache_min_ttl, cache_max_ttl, updated_at`,
dnssec, qname_minimisation, cache_min_ttl, cache_max_ttl,
prefetch, serve_expired, msg_cache_size_mb, rrset_cache_size_mb,
updated_at`,
s.ListenAddresses, s.ListenPort, s.UpstreamForwards, s.AccessACL,
s.DNSSEC, s.QNameMinimisation, s.CacheMinTTL, s.CacheMaxTTL)
s.DNSSEC, s.QNameMinimisation, s.CacheMinTTL, s.CacheMaxTTL,
s.Prefetch, s.ServeExpired, s.MsgCacheSizeMB, s.RRSetCacheSizeMB)
var out models.DNSSettings
if err := row.Scan(&out.ID, &out.ListenAddresses, &out.ListenPort, &out.UpstreamForwards,
&out.AccessACL, &out.DNSSEC, &out.QNameMinimisation,
&out.CacheMinTTL, &out.CacheMaxTTL, &out.UpdatedAt); err != nil {
&out.CacheMinTTL, &out.CacheMaxTTL,
&out.Prefetch, &out.ServeExpired, &out.MsgCacheSizeMB, &out.RRSetCacheSizeMB,
&out.UpdatedAt); err != nil {
return nil, err
}
return &out, nil

View File

@@ -23,7 +23,7 @@ SELECT id, name, priority, enabled, kind,
in_zone, out_zone, proto,
match_src_cidr, match_dst_cidr, match_dport_start, match_dport_end,
target_addr, target_port_start, target_port_end,
comment, created_at, updated_at
comment, note, labels, created_at, updated_at
FROM firewall_nat_rules
`
@@ -57,52 +57,58 @@ func (r *NATRulesRepo) Get(ctx context.Context, id int64) (*models.FirewallNATRu
}
func (r *NATRulesRepo) Create(ctx context.Context, x models.FirewallNATRule) (*models.FirewallNATRule, error) {
if x.Labels == nil {
x.Labels = []string{}
}
row := r.Pool.QueryRow(ctx, `
INSERT INTO firewall_nat_rules (
name, priority, enabled, kind,
in_zone, out_zone, proto,
match_src_cidr, match_dst_cidr, match_dport_start, match_dport_end,
target_addr, target_port_start, target_port_end,
comment
comment, note, labels
) VALUES (
$1, $2, $3, $4,
$5, $6, $7,
$8, $9, $10, $11,
$12, $13, $14,
$15
$15, $16, $17
)
RETURNING id, name, priority, enabled, kind,
in_zone, out_zone, proto,
match_src_cidr, match_dst_cidr, match_dport_start, match_dport_end,
target_addr, target_port_start, target_port_end,
comment, created_at, updated_at`,
comment, note, labels, created_at, updated_at`,
x.Name, x.Priority, x.Enabled, x.Kind,
x.InZone, x.OutZone, x.Proto,
x.MatchSrcCIDR, x.MatchDstCIDR, x.MatchDPortStart, x.MatchDPortEnd,
x.TargetAddr, x.TargetPortStart, x.TargetPortEnd,
x.Comment)
x.Comment, x.Note, x.Labels)
return scanNATRule(row)
}
func (r *NATRulesRepo) Update(ctx context.Context, id int64, x models.FirewallNATRule) (*models.FirewallNATRule, error) {
if x.Labels == nil {
x.Labels = []string{}
}
row := r.Pool.QueryRow(ctx, `
UPDATE firewall_nat_rules SET
name = $1, priority = $2, enabled = $3, kind = $4,
in_zone = $5, out_zone = $6, proto = $7,
match_src_cidr = $8, match_dst_cidr = $9, match_dport_start = $10, match_dport_end = $11,
target_addr = $12, target_port_start = $13, target_port_end = $14,
comment = $15, updated_at = NOW()
WHERE id = $16
comment = $15, note = $16, labels = $17, updated_at = NOW()
WHERE id = $18
RETURNING id, name, priority, enabled, kind,
in_zone, out_zone, proto,
match_src_cidr, match_dst_cidr, match_dport_start, match_dport_end,
target_addr, target_port_start, target_port_end,
comment, created_at, updated_at`,
comment, note, labels, created_at, updated_at`,
x.Name, x.Priority, x.Enabled, x.Kind,
x.InZone, x.OutZone, x.Proto,
x.MatchSrcCIDR, x.MatchDstCIDR, x.MatchDPortStart, x.MatchDPortEnd,
x.TargetAddr, x.TargetPortStart, x.TargetPortEnd,
x.Comment, id)
x.Comment, x.Note, x.Labels, id)
out, err := scanNATRule(row)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
@@ -124,6 +130,37 @@ func (r *NATRulesRepo) Delete(ctx context.Context, id int64) error {
return nil
}
// PatchNote updates only the note field of a NAT rule.
func (r *NATRulesRepo) PatchNote(ctx context.Context, id int64, note string) error {
var n *string
if note != "" {
n = &note
}
tag, err := r.Pool.Exec(ctx, `UPDATE firewall_nat_rules SET note = $1, updated_at = NOW() WHERE id = $2`, n, id)
if err != nil {
return err
}
if tag.RowsAffected() == 0 {
return ErrNATRuleNotFound
}
return nil
}
// PatchLabels replaces the labels array of a NAT rule.
func (r *NATRulesRepo) PatchLabels(ctx context.Context, id int64, labels []string) error {
if labels == nil {
labels = []string{}
}
tag, err := r.Pool.Exec(ctx, `UPDATE firewall_nat_rules SET labels = $1, updated_at = NOW() WHERE id = $2`, labels, id)
if err != nil {
return err
}
if tag.RowsAffected() == 0 {
return ErrNATRuleNotFound
}
return nil
}
func scanNATRule(row interface{ Scan(...any) error }) (*models.FirewallNATRule, error) {
var x models.FirewallNATRule
if err := row.Scan(
@@ -131,9 +168,12 @@ func scanNATRule(row interface{ Scan(...any) error }) (*models.FirewallNATRule,
&x.InZone, &x.OutZone, &x.Proto,
&x.MatchSrcCIDR, &x.MatchDstCIDR, &x.MatchDPortStart, &x.MatchDPortEnd,
&x.TargetAddr, &x.TargetPortStart, &x.TargetPortEnd,
&x.Comment, &x.CreatedAt, &x.UpdatedAt,
&x.Comment, &x.Note, &x.Labels, &x.CreatedAt, &x.UpdatedAt,
); err != nil {
return nil, err
}
if x.Labels == nil {
x.Labels = []string{}
}
return &x, nil
}

View File

@@ -23,7 +23,7 @@ SELECT id, name, priority, enabled, action,
src_zone, src_address_object_id, src_address_group_id, src_cidr,
dst_zone, dst_address_object_id, dst_address_group_id, dst_cidr,
service_object_id, service_group_id,
log, comment, created_at, updated_at
log, comment, note, labels, created_at, updated_at
FROM firewall_rules
`
@@ -57,52 +57,58 @@ func (r *RulesRepo) Get(ctx context.Context, id int64) (*models.FirewallRule, er
}
func (r *RulesRepo) Create(ctx context.Context, x models.FirewallRule) (*models.FirewallRule, error) {
if x.Labels == nil {
x.Labels = []string{}
}
row := r.Pool.QueryRow(ctx, `
INSERT INTO firewall_rules (
name, priority, enabled, action,
src_zone, src_address_object_id, src_address_group_id, src_cidr,
dst_zone, dst_address_object_id, dst_address_group_id, dst_cidr,
service_object_id, service_group_id,
log, comment
log, comment, note, labels
) VALUES (
$1, $2, $3, $4,
$5, $6, $7, $8,
$9, $10, $11, $12,
$13, $14,
$15, $16
$15, $16, $17, $18
)
RETURNING id, name, priority, enabled, action,
src_zone, src_address_object_id, src_address_group_id, src_cidr,
dst_zone, dst_address_object_id, dst_address_group_id, dst_cidr,
service_object_id, service_group_id,
log, comment, created_at, updated_at`,
log, comment, note, labels, created_at, updated_at`,
x.Name, x.Priority, x.Enabled, x.Action,
x.SrcZone, x.SrcAddressObjectID, x.SrcAddressGroupID, x.SrcCIDR,
x.DstZone, x.DstAddressObjectID, x.DstAddressGroupID, x.DstCIDR,
x.ServiceObjectID, x.ServiceGroupID,
x.Log, x.Comment)
x.Log, x.Comment, x.Note, x.Labels)
return scanRule(row)
}
func (r *RulesRepo) Update(ctx context.Context, id int64, x models.FirewallRule) (*models.FirewallRule, error) {
if x.Labels == nil {
x.Labels = []string{}
}
row := r.Pool.QueryRow(ctx, `
UPDATE firewall_rules SET
name = $1, priority = $2, enabled = $3, action = $4,
src_zone = $5, src_address_object_id = $6, src_address_group_id = $7, src_cidr = $8,
dst_zone = $9, dst_address_object_id = $10, dst_address_group_id = $11, dst_cidr = $12,
service_object_id = $13, service_group_id = $14,
log = $15, comment = $16, updated_at = NOW()
WHERE id = $17
log = $15, comment = $16, note = $17, labels = $18, updated_at = NOW()
WHERE id = $19
RETURNING id, name, priority, enabled, action,
src_zone, src_address_object_id, src_address_group_id, src_cidr,
dst_zone, dst_address_object_id, dst_address_group_id, dst_cidr,
service_object_id, service_group_id,
log, comment, created_at, updated_at`,
log, comment, note, labels, created_at, updated_at`,
x.Name, x.Priority, x.Enabled, x.Action,
x.SrcZone, x.SrcAddressObjectID, x.SrcAddressGroupID, x.SrcCIDR,
x.DstZone, x.DstAddressObjectID, x.DstAddressGroupID, x.DstCIDR,
x.ServiceObjectID, x.ServiceGroupID,
x.Log, x.Comment, id)
x.Log, x.Comment, x.Note, x.Labels, id)
out, err := scanRule(row)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
@@ -124,6 +130,37 @@ func (r *RulesRepo) Delete(ctx context.Context, id int64) error {
return nil
}
// PatchNote updates only the note field of a rule.
func (r *RulesRepo) PatchNote(ctx context.Context, id int64, note string) error {
var n *string
if note != "" {
n = &note
}
tag, err := r.Pool.Exec(ctx, `UPDATE firewall_rules SET note = $1, updated_at = NOW() WHERE id = $2`, n, id)
if err != nil {
return err
}
if tag.RowsAffected() == 0 {
return ErrRuleNotFound
}
return nil
}
// PatchLabels replaces the labels array of a rule.
func (r *RulesRepo) PatchLabels(ctx context.Context, id int64, labels []string) error {
if labels == nil {
labels = []string{}
}
tag, err := r.Pool.Exec(ctx, `UPDATE firewall_rules SET labels = $1, updated_at = NOW() WHERE id = $2`, labels, id)
if err != nil {
return err
}
if tag.RowsAffected() == 0 {
return ErrRuleNotFound
}
return nil
}
func scanRule(row interface{ Scan(...any) error }) (*models.FirewallRule, error) {
var x models.FirewallRule
if err := row.Scan(
@@ -131,9 +168,12 @@ func scanRule(row interface{ Scan(...any) error }) (*models.FirewallRule, error)
&x.SrcZone, &x.SrcAddressObjectID, &x.SrcAddressGroupID, &x.SrcCIDR,
&x.DstZone, &x.DstAddressObjectID, &x.DstAddressGroupID, &x.DstCIDR,
&x.ServiceObjectID, &x.ServiceGroupID,
&x.Log, &x.Comment, &x.CreatedAt, &x.UpdatedAt,
&x.Log, &x.Comment, &x.Note, &x.Labels, &x.CreatedAt, &x.UpdatedAt,
); err != nil {
return nil, err
}
if x.Labels == nil {
x.Labels = []string{}
}
return &x, nil
}

View File

@@ -1,6 +1,6 @@
// Package forwardproxy provides CRUD against the forward_proxy_acls
// table. Renderer in internal/squid consumes the same rows to emit
// /etc/edgeguard/squid/squid.conf.
// table and settings in forward_proxy_settings. Renderer in internal/squid
// consumes both tables to emit /etc/edgeguard/squid/squid.conf.
package forwardproxy
import (
@@ -97,6 +97,52 @@ func (r *Repo) Delete(ctx context.Context, id int64) error {
return nil
}
// Settings returns the singleton forward_proxy_settings row.
func (r *Repo) GetSettings(ctx context.Context) (*models.ForwardProxySettings, error) {
var s models.ForwardProxySettings
if err := r.Pool.QueryRow(ctx, `
SELECT id, listen_addresses, listen_port,
cache_mem_mb, cache_dir_mb, max_obj_size_mb,
connect_timeout, read_timeout, request_timeout,
created_at, updated_at
FROM forward_proxy_settings WHERE id=1`).Scan(
&s.ID, &s.ListenAddresses, &s.ListenPort,
&s.CacheMemMB, &s.CacheDirMB, &s.MaxObjSizeMB,
&s.ConnectTimeout, &s.ReadTimeout, &s.RequestTimeout,
&s.CreatedAt, &s.UpdatedAt,
); err != nil {
return nil, err
}
return &s, nil
}
func (r *Repo) UpdateSettings(ctx context.Context, s models.ForwardProxySettings) (*models.ForwardProxySettings, error) {
var out models.ForwardProxySettings
if err := r.Pool.QueryRow(ctx, `
UPDATE forward_proxy_settings SET
listen_addresses=$1, listen_port=$2,
cache_mem_mb=$3, cache_dir_mb=$4, max_obj_size_mb=$5,
connect_timeout=$6, read_timeout=$7, request_timeout=$8,
updated_at=NOW()
WHERE id=1
RETURNING id, listen_addresses, listen_port,
cache_mem_mb, cache_dir_mb, max_obj_size_mb,
connect_timeout, read_timeout, request_timeout,
created_at, updated_at`,
s.ListenAddresses, s.ListenPort,
s.CacheMemMB, s.CacheDirMB, s.MaxObjSizeMB,
s.ConnectTimeout, s.ReadTimeout, s.RequestTimeout,
).Scan(
&out.ID, &out.ListenAddresses, &out.ListenPort,
&out.CacheMemMB, &out.CacheDirMB, &out.MaxObjSizeMB,
&out.ConnectTimeout, &out.ReadTimeout, &out.RequestTimeout,
&out.CreatedAt, &out.UpdatedAt,
); err != nil {
return nil, err
}
return &out, nil
}
func scan(row interface{ Scan(...any) error }) (*models.ForwardProxyACL, error) {
var a models.ForwardProxyACL
if err := row.Scan(

View File

@@ -0,0 +1,105 @@
package ipaddresses
import (
"bytes"
"context"
"fmt"
"os/exec"
"strings"
"git.netcell-it.de/projekte/edgeguard-native/internal/configgen"
)
// ConfPath wird von edgeguard-apply-ipaddresses gelesen.
const ConfPath = "/etc/edgeguard/ip-addresses.conf"
type Generator struct {
Repo *Repo
}
func NewGenerator(repo *Repo) *Generator { return &Generator{Repo: repo} }
// Render schreibt /etc/edgeguard/ip-addresses.conf (Format: dev|addr/prefix)
// und triggert das apply-Skript via sudo.
func (g *Generator) Render(ctx context.Context) error {
return g.render(ctx, false)
}
// RenderSecondary wie Render, aber schließt Ethernet-Interface-IPs aus.
// Auf einem Secondary-Node werden eth0-IPs (Public-IP + VIP) von
// cloud-init bzw. Keepalived verwaltet — edgeguard soll sie nicht
// überschreiben oder entfernen.
func (g *Generator) RenderSecondary(ctx context.Context) error {
return g.render(ctx, true)
}
func (g *Generator) render(ctx context.Context, excludeEthernet bool) error {
type addrRow struct {
dev string
addr string
prefix int
}
q := `
SELECT ni.name, ia.address, ia.prefix
FROM ip_addresses ia
JOIN network_interfaces ni ON ni.id = ia.interface_id
WHERE ia.active = true`
if excludeEthernet {
q += `
AND ni.type != 'ethernet'`
}
q += `
ORDER BY ni.name, ia.address`
rows, err := g.Repo.Pool.Query(ctx, q)
if err != nil {
return fmt.Errorf("query: %w", err)
}
defer rows.Close()
var entries []addrRow
for rows.Next() {
var r addrRow
if err := rows.Scan(&r.dev, &r.addr, &r.prefix); err != nil {
return fmt.Errorf("scan: %w", err)
}
entries = append(entries, r)
}
if err := rows.Err(); err != nil {
return err
}
var buf bytes.Buffer
buf.WriteString("# Generated by edgeguard-api — DO NOT EDIT.\n")
buf.WriteString("# Read by edgeguard-apply-ipaddresses. Format: dev|address/prefix\n")
for _, e := range entries {
fmt.Fprintf(&buf, "%s|%s/%d\n",
sanitize(e.dev), sanitize(e.addr), e.prefix)
}
if err := configgen.AtomicWrite(ConfPath, buf.Bytes(), 0o644); err != nil {
return fmt.Errorf("write %s: %w", ConfPath, err)
}
if err := applyIPAddresses(); err != nil {
return fmt.Errorf("apply: %w", err)
}
return nil
}
func applyIPAddresses() error {
cmd := exec.Command("sudo", "-n", "/usr/bin/systemctl",
"restart", "edgeguard-ipaddresses.service")
out, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("systemctl restart edgeguard-ipaddresses.service: %s: %w",
strings.TrimSpace(string(out)), err)
}
return nil
}
func sanitize(s string) string {
s = strings.ReplaceAll(s, "|", "")
s = strings.ReplaceAll(s, "\n", "")
return strings.TrimSpace(s)
}

View File

@@ -0,0 +1,103 @@
package networkifs
import (
"bytes"
"context"
"encoding/json"
"fmt"
"os/exec"
"strings"
"git.netcell-it.de/projekte/edgeguard-native/internal/configgen"
)
// ConfPath is read by edgeguard-apply-interfaces.
const ConfPath = "/etc/edgeguard/interfaces.conf"
type Generator struct {
Repo *Repo
}
func NewGenerator(repo *Repo) *Generator { return &Generator{Repo: repo} }
// Render writes /etc/edgeguard/interfaces.conf (format: type|name|parent|vlan_id|mtu|members)
// for VLAN/bridge/bond interfaces and triggers edgeguard-interfaces.service.
// Ethernet and WireGuard interfaces are managed by the OS / wg-quick and are excluded.
func (g *Generator) Render(ctx context.Context) error {
rows, err := g.Repo.Pool.Query(ctx, `
SELECT type, name,
COALESCE(parent, ''),
COALESCE(vlan_id::text, ''),
COALESCE(mtu::text, ''),
members
FROM network_interfaces
WHERE active = true
AND type IN ('vlan', 'bridge', 'bond')
AND (type = 'vlan' OR jsonb_array_length(members) > 0)
ORDER BY type, name`,
)
if err != nil {
return fmt.Errorf("query: %w", err)
}
defer rows.Close()
type ifRow struct {
typ string
name string
parent string
vlanID string
mtu string
members []string
}
var entries []ifRow
for rows.Next() {
var r ifRow
var membersRaw []byte
if err := rows.Scan(&r.typ, &r.name, &r.parent, &r.vlanID, &r.mtu, &membersRaw); err != nil {
return fmt.Errorf("scan: %w", err)
}
if len(membersRaw) > 0 {
_ = json.Unmarshal(membersRaw, &r.members)
}
entries = append(entries, r)
}
if err := rows.Err(); err != nil {
return err
}
var buf bytes.Buffer
buf.WriteString("# Generated by edgeguard-api — DO NOT EDIT.\n")
buf.WriteString("# Read by edgeguard-apply-interfaces. Format: type|name|parent|vlan_id|mtu|members\n")
for _, e := range entries {
fmt.Fprintf(&buf, "%s|%s|%s|%s|%s|%s\n",
sanitizeIf(e.typ), sanitizeIf(e.name), sanitizeIf(e.parent),
sanitizeIf(e.vlanID), sanitizeIf(e.mtu),
sanitizeIf(strings.Join(e.members, ",")))
}
if err := configgen.AtomicWrite(ConfPath, buf.Bytes(), 0o644); err != nil {
return fmt.Errorf("write %s: %w", ConfPath, err)
}
if err := applyInterfaces(); err != nil {
return fmt.Errorf("apply: %w", err)
}
return nil
}
func applyInterfaces() error {
cmd := exec.Command("sudo", "-n", "/usr/bin/systemctl",
"restart", "edgeguard-interfaces.service")
out, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("systemctl restart edgeguard-interfaces.service: %s: %w",
strings.TrimSpace(string(out)), err)
}
return nil
}
func sanitizeIf(s string) string {
s = strings.ReplaceAll(s, "|", "")
s = strings.ReplaceAll(s, "\n", "")
return strings.TrimSpace(s)
}

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,9 +118,20 @@ func (s *Signer) IssueWithRole(actor, role string) (string, *Token, error) {
return encoded, &t, nil
}
// 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)
}
// Issue is IssueWithRole with empty role.
func (s *Signer) Issue(actor string) (string, *Token, error) {
return s.IssueWithRole(actor, "")
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) {
return s.issue(actor, role, ttl)
}
// Verify checks a token. Returns ErrInvalidToken or ErrExpiredToken.
@@ -165,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

@@ -12,6 +12,7 @@ import (
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/pquerna/otp/totp"
"golang.org/x/crypto/bcrypt"
)
@@ -27,22 +28,30 @@ type User struct {
Email string `json:"email"`
Role string `json:"role"`
Active bool `json:"active"`
TOTPEnabled bool `json:"totp_enabled"`
LastLoginAt *time.Time `json:"last_login_at"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// AuthInfo is returned by FindForAuth — contains credentials needed during login.
type AuthInfo struct {
User
PasswordHash string
TOTPSecret *string
}
type Repo struct {
pool *pgxpool.Pool
}
func New(pool *pgxpool.Pool) *Repo { return &Repo{pool: pool} }
const selectCols = `id, email, role, active, last_login_at, created_at, updated_at`
const selectCols = `id, email, role, active, totp_enabled, last_login_at, created_at, updated_at`
func scan(row pgx.Row) (User, error) {
var u User
err := row.Scan(&u.ID, &u.Email, &u.Role, &u.Active,
err := row.Scan(&u.ID, &u.Email, &u.Role, &u.Active, &u.TOTPEnabled,
&u.LastLoginAt, &u.CreatedAt, &u.UpdatedAt)
return u, err
}
@@ -71,7 +80,7 @@ func (r *Repo) FindByEmail(ctx context.Context, email string) (User, string, err
var hash string
err := r.pool.QueryRow(ctx,
`SELECT `+selectCols+`, password_hash FROM users WHERE lower(email)=lower($1)`,
email).Scan(&u.ID, &u.Email, &u.Role, &u.Active,
email).Scan(&u.ID, &u.Email, &u.Role, &u.Active, &u.TOTPEnabled,
&u.LastLoginAt, &u.CreatedAt, &u.UpdatedAt, &hash)
if errors.Is(err, pgx.ErrNoRows) {
return u, "", ErrNotFound
@@ -79,6 +88,70 @@ func (r *Repo) FindByEmail(ctx context.Context, email string) (User, string, err
return u, hash, err
}
// FindForAuth returns full auth credentials including TOTP secret. ErrNotFound if absent.
func (r *Repo) FindForAuth(ctx context.Context, email string) (*AuthInfo, error) {
var a AuthInfo
err := r.pool.QueryRow(ctx,
`SELECT `+selectCols+`, password_hash, totp_secret FROM users WHERE lower(email)=lower($1)`,
email).Scan(&a.ID, &a.Email, &a.Role, &a.Active, &a.TOTPEnabled,
&a.LastLoginAt, &a.CreatedAt, &a.UpdatedAt, &a.PasswordHash, &a.TOTPSecret)
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNotFound
}
return &a, err
}
// GenerateTOTPSecret creates a new TOTP secret for the given email and returns
// the secret + the otpauth:// provisioning URI (for QR code rendering in the UI).
// The secret is NOT saved yet — call ConfirmTOTP after the user verifies the code.
func GenerateTOTPSecret(email string) (secret, uri string, err error) {
key, err := totp.Generate(totp.GenerateOpts{
Issuer: "EdgeGuard",
AccountName: email,
})
if err != nil {
return "", "", err
}
return key.Secret(), key.URL(), nil
}
// ConfirmTOTP verifies the given TOTP code against the (not-yet-saved) secret
// and, on success, persists it and enables TOTP for the user.
func (r *Repo) ConfirmTOTP(ctx context.Context, userID int64, secret, code string) error {
if !totp.Validate(code, secret) {
return errors.New("invalid_totp_code")
}
tag, err := r.pool.Exec(ctx,
`UPDATE users SET totp_secret=$1, totp_enabled=true, updated_at=NOW() WHERE id=$2`,
secret, userID)
if err != nil {
return err
}
if tag.RowsAffected() == 0 {
return ErrNotFound
}
return nil
}
// DisableTOTP clears the TOTP secret and disables 2FA for the given user.
func (r *Repo) DisableTOTP(ctx context.Context, userID int64) error {
tag, err := r.pool.Exec(ctx,
`UPDATE users SET totp_secret=NULL, totp_enabled=false, updated_at=NOW() WHERE id=$1`,
userID)
if err != nil {
return err
}
if tag.RowsAffected() == 0 {
return ErrNotFound
}
return nil
}
// VerifyTOTP checks a live TOTP code against the stored secret.
func VerifyTOTP(secret, code string) bool {
return totp.Validate(code, secret)
}
func (r *Repo) Count(ctx context.Context) (int, error) {
var n int
err := r.pool.QueryRow(ctx, `SELECT COUNT(*) FROM users`).Scan(&n)
@@ -167,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

@@ -0,0 +1,227 @@
// Package waf implements CRUD for per-domain WAF policies (waf_configs).
package waf
import (
"context"
"errors"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"git.netcell-it.de/projekte/edgeguard-native/internal/models"
)
var ErrNotFound = errors.New("waf config not found")
type Repo struct {
Pool *pgxpool.Pool
}
func New(pool *pgxpool.Pool) *Repo { return &Repo{Pool: pool} }
const baseSelect = `
SELECT id, domain_id, enabled, mode, paranoia_level,
rule_exclusions, exclusion_notes, trusted_proxies, custom_rules, updated_at
FROM waf_configs
`
func scan(row pgx.Row) (*models.WafConfig, error) {
var c models.WafConfig
err := row.Scan(
&c.ID, &c.DomainID, &c.Enabled, &c.Mode, &c.ParanoiaLevel,
&c.RuleExclusions, &c.ExclusionNotes, &c.TrustedProxies, &c.CustomRules, &c.UpdatedAt,
)
if err != nil {
return nil, err
}
if c.ExclusionNotes == nil {
c.ExclusionNotes = map[string]string{}
}
return &c, nil
}
// List returns all WAF configs ordered by domain_id.
func (r *Repo) List(ctx context.Context) ([]models.WafConfig, error) {
rows, err := r.Pool.Query(ctx, baseSelect+" ORDER BY domain_id ASC")
if err != nil {
return nil, err
}
defer rows.Close()
out := make([]models.WafConfig, 0, 16)
for rows.Next() {
c, err := scan(rows)
if err != nil {
return nil, err
}
out = append(out, *c)
}
return out, rows.Err()
}
// GetByDomain returns the WAF config for a domain, or ErrNotFound.
func (r *Repo) GetByDomain(ctx context.Context, domainID int64) (*models.WafConfig, error) {
row := r.Pool.QueryRow(ctx, baseSelect+" WHERE domain_id = $1", domainID)
c, err := scan(row)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNotFound
}
return nil, err
}
return c, nil
}
// Upsert inserts or updates the WAF config for a domain.
// Returns the resulting row.
func (r *Repo) Upsert(ctx context.Context, c models.WafConfig) (*models.WafConfig, error) {
c.UpdatedAt = time.Now()
if c.ExclusionNotes == nil {
c.ExclusionNotes = map[string]string{}
}
row := r.Pool.QueryRow(ctx, `
INSERT INTO waf_configs
(domain_id, enabled, mode, paranoia_level,
rule_exclusions, exclusion_notes, trusted_proxies, custom_rules, updated_at)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)
ON CONFLICT (domain_id) DO UPDATE SET
enabled = EXCLUDED.enabled,
mode = EXCLUDED.mode,
paranoia_level = EXCLUDED.paranoia_level,
rule_exclusions = EXCLUDED.rule_exclusions,
exclusion_notes = EXCLUDED.exclusion_notes,
trusted_proxies = EXCLUDED.trusted_proxies,
custom_rules = EXCLUDED.custom_rules,
updated_at = EXCLUDED.updated_at
RETURNING id, domain_id, enabled, mode, paranoia_level,
rule_exclusions, exclusion_notes, trusted_proxies, custom_rules, updated_at
`,
c.DomainID, c.Enabled, c.Mode, c.ParanoiaLevel,
c.RuleExclusions, c.ExclusionNotes, c.TrustedProxies, c.CustomRules, c.UpdatedAt,
)
return scan(row)
}
// ListEnabled returns only configs with enabled=true (used by the WAF agent).
func (r *Repo) ListEnabled(ctx context.Context) ([]models.WafConfig, error) {
rows, err := r.Pool.Query(ctx, baseSelect+" WHERE enabled = true ORDER BY domain_id ASC")
if err != nil {
return nil, err
}
defer rows.Close()
out := make([]models.WafConfig, 0, 8)
for rows.Next() {
c, err := scan(rows)
if err != nil {
return nil, err
}
out = append(out, *c)
}
return out, rows.Err()
}
// WafAlert mirrors the waf_alerts DB row.
type WafAlert struct {
ID int64 `json:"id"`
DomainID *int64 `json:"domain_id,omitempty"`
Hostname string `json:"hostname"`
ClientIP string `json:"client_ip"`
Method string `json:"method"`
URI string `json:"uri"`
RuleID int `json:"rule_id"`
RuleMsg string `json:"rule_msg"`
Severity string `json:"severity"`
Action string `json:"action"`
CreatedAt time.Time `json:"created_at"`
}
// ListAlerts returns recent WAF alerts, optionally filtered by domain_id.
func (r *Repo) ListAlerts(ctx context.Context, domainID *int64, limit int) ([]WafAlert, error) {
if limit <= 0 || limit > 1000 {
limit = 200
}
var rows interface{ Next() bool; Scan(...any) error; Close(); Err() error }
var err error
if domainID != nil {
rows2, e := r.Pool.Query(ctx, `
SELECT id, domain_id, hostname, client_ip, method, uri,
rule_id, rule_msg, severity, action, created_at
FROM waf_alerts
WHERE domain_id = $1
ORDER BY created_at DESC LIMIT $2
`, *domainID, limit)
rows, err = rows2, e
} else {
rows2, e := r.Pool.Query(ctx, `
SELECT id, domain_id, hostname, client_ip, method, uri,
rule_id, rule_msg, severity, action, created_at
FROM waf_alerts
ORDER BY created_at DESC LIMIT $1
`, limit)
rows, err = rows2, e
}
if err != nil {
return nil, err
}
defer rows.Close()
out := make([]WafAlert, 0, limit)
for rows.Next() {
var a WafAlert
if err := rows.Scan(
&a.ID, &a.DomainID, &a.Hostname, &a.ClientIP, &a.Method, &a.URI,
&a.RuleID, &a.RuleMsg, &a.Severity, &a.Action, &a.CreatedAt,
); err != nil {
return nil, err
}
out = append(out, a)
}
return out, rows.Err()
}
// PurgeAlerts removes alerts older than the given number of days.
func (r *Repo) PurgeAlerts(ctx context.Context, olderThanDays int) error {
_, err := r.Pool.Exec(ctx,
`DELETE FROM waf_alerts WHERE created_at < NOW() - ($1 || ' days')::interval`,
olderThanDays,
)
return err
}
// DomainConfigPair combines a domain hostname with its WAF config.
type DomainConfigPair struct {
Hostname string
Config models.WafConfig
}
// ListAllWithDomain returns all WAF configs joined with their domain name.
// Used by the WAF agent to build the hostname→engine mapping.
func (r *Repo) ListAllWithDomain(ctx context.Context) ([]DomainConfigPair, error) {
rows, err := r.Pool.Query(ctx, `
SELECT d.name,
w.id, w.domain_id, w.enabled, w.mode, w.paranoia_level,
w.rule_exclusions, w.trusted_proxies, w.custom_rules, w.updated_at
FROM waf_configs w
JOIN domains d ON d.id = w.domain_id
WHERE d.active = true
ORDER BY d.name ASC
`)
if err != nil {
return nil, err
}
defer rows.Close()
out := make([]DomainConfigPair, 0, 16)
for rows.Next() {
var p DomainConfigPair
var c models.WafConfig
if err := rows.Scan(
&p.Hostname,
&c.ID, &c.DomainID, &c.Enabled, &c.Mode, &c.ParanoiaLevel,
&c.RuleExclusions, &c.TrustedProxies, &c.CustomRules, &c.UpdatedAt,
); err != nil {
return nil, err
}
p.Config = c
out = append(out, p)
}
return out, rows.Err()
}

View File

@@ -2,12 +2,14 @@
# Source: internal/squid/squid.go (template: squid.cfg.tpl).
# Re-generate via `edgeguard-ctl render-config --only=squid`.
http_port {{.ListenPort}}
{{range .ListenAddrs -}}
{{if .Addr}}http_port {{.Addr}}:{{.Port}}
{{else}}http_port {{.Port}}
{{end}}{{- end}}
# Standard cache directory + small in-memory cache. Forward proxy
# isn't a CDN — we keep cache modest to avoid disk pressure.
cache_dir ufs /var/spool/squid 100 16 256
cache_mem 64 MB
cache_dir ufs /var/spool/squid {{.CacheDirMB}} 16 256
cache_mem {{.CacheMemMB}} MB
maximum_object_size {{.MaxObjSizeMB}} MB
# Logging — combined access log, rotated by logrotate.
access_log /var/log/squid/access.log squid
@@ -56,7 +58,9 @@ http_access allow localhost
http_access allow localnet
http_access deny all
# Hostnames + visible name — operator can override via squid.conf
# drop-in if needed.
connect_timeout {{.ConnectTimeout}} seconds
read_timeout {{.ReadTimeout}} seconds
request_timeout {{.RequestTimeout}} seconds
visible_hostname edgeguard-proxy
forwarded_for on

View File

@@ -11,6 +11,7 @@ import (
"fmt"
"os"
"path/filepath"
"strings"
"text/template"
"github.com/jackc/pgx/v5/pgxpool"
@@ -21,8 +22,8 @@ import (
)
const (
confPath = "/etc/edgeguard/squid/squid.conf"
listenPort = 3128
confPath = "/etc/edgeguard/squid/squid.conf"
defaultListenPort = 3128
)
//go:embed squid.cfg.tpl
@@ -30,9 +31,20 @@ var cfgTpl string
var tpl = template.Must(template.New("squid").Parse(cfgTpl))
type ListenAddr struct {
Addr string // empty = all interfaces
Port int
}
type View struct {
ListenPort int
ACLs []models.ForwardProxyACL
ListenAddrs []ListenAddr
ACLs []models.ForwardProxyACL
CacheMemMB int
CacheDirMB int
MaxObjSizeMB int
ConnectTimeout int
ReadTimeout int
RequestTimeout int
}
type Generator struct {
@@ -52,7 +64,45 @@ func (g *Generator) renderBuf(ctx context.Context) (bytes.Buffer, error) {
if err != nil {
return bytes.Buffer{}, fmt.Errorf("list acls: %w", err)
}
view := View{ListenPort: listenPort, ACLs: acls}
// Read all settings — fall back to defaults if table not migrated yet.
s := models.ForwardProxySettings{
ListenPort: defaultListenPort,
CacheMemMB: 64,
CacheDirMB: 100,
MaxObjSizeMB: 4,
ConnectTimeout: 60,
ReadTimeout: 300,
RequestTimeout: 300,
}
_ = g.Pool.QueryRow(ctx, `
SELECT listen_addresses, listen_port,
cache_mem_mb, cache_dir_mb, max_obj_size_mb,
connect_timeout, read_timeout, request_timeout
FROM forward_proxy_settings WHERE id=1`).Scan(
&s.ListenAddresses, &s.ListenPort,
&s.CacheMemMB, &s.CacheDirMB, &s.MaxObjSizeMB,
&s.ConnectTimeout, &s.ReadTimeout, &s.RequestTimeout,
)
var listenAddrs []ListenAddr
for _, raw := range splitCSV(s.ListenAddresses) {
listenAddrs = append(listenAddrs, ListenAddr{Addr: raw, Port: s.ListenPort})
}
if len(listenAddrs) == 0 {
listenAddrs = []ListenAddr{{Addr: "", Port: s.ListenPort}}
}
view := View{
ListenAddrs: listenAddrs,
ACLs: acls,
CacheMemMB: s.CacheMemMB,
CacheDirMB: s.CacheDirMB,
MaxObjSizeMB: s.MaxObjSizeMB,
ConnectTimeout: s.ConnectTimeout,
ReadTimeout: s.ReadTimeout,
RequestTimeout: s.RequestTimeout,
}
var body bytes.Buffer
if err := tpl.Execute(&body, view); err != nil {
return bytes.Buffer{}, fmt.Errorf("template: %w", err)
@@ -60,6 +110,17 @@ func (g *Generator) renderBuf(ctx context.Context) (bytes.Buffer, error) {
return body, nil
}
func splitCSV(s string) []string {
var out []string
for _, p := range strings.Split(s, ",") {
p = strings.TrimSpace(p)
if p != "" {
out = append(out, p)
}
}
return out
}
func (g *Generator) RenderToString(ctx context.Context) (string, error) {
buf, err := g.renderBuf(ctx)
if err != nil {

View File

@@ -31,8 +31,10 @@ server:
do-tcp: yes
cache-min-ttl: {{.Settings.CacheMinTTL}}
cache-max-ttl: {{.Settings.CacheMaxTTL}}
msg-cache-size: 64m
rrset-cache-size: 128m
msg-cache-size: {{.Settings.MsgCacheSizeMB}}m
rrset-cache-size: {{.Settings.RRSetCacheSizeMB}}m
prefetch: {{if .Settings.Prefetch}}yes{{else}}no{{end}}
serve-expired: {{if .Settings.ServeExpired}}yes{{else}}no{{end}}
num-threads: 2
# Hardening

120
internal/waf/alerts.go Normal file
View File

@@ -0,0 +1,120 @@
package waf
import (
"context"
"log/slog"
"sync"
"sync/atomic"
"time"
"github.com/jackc/pgx/v5/pgxpool"
)
// Alert represents a single WAF rule match that was logged.
type Alert struct {
ID int64 `json:"id"`
DomainID *int64 `json:"domain_id,omitempty"`
Hostname string `json:"hostname"`
ClientIP string `json:"client_ip"`
Method string `json:"method"`
URI string `json:"uri"`
RuleID int `json:"rule_id"`
RuleMsg string `json:"rule_msg"`
Severity string `json:"severity"`
Action string `json:"action"` // "detected" | "blocked"
CreatedAt time.Time `json:"created_at"`
}
// 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
stop chan struct{}
done chan struct{}
closeOnce sync.Once
closed atomic.Bool
}
// NewAlertWriter creates an AlertWriter and starts its background goroutine.
// bufSize is the number of unwritten alerts that can queue before drops.
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 (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:
slog.Warn("waf: alert channel full — dropping alert", "host", a.Hostname, "rule", a.RuleID)
}
}
// 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() {
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
}
}
}
}
}
func (aw *AlertWriter) write(a Alert) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
// Resolve domain_id from hostname (best-effort).
var domainID *int64
var id int64
if err := aw.pool.QueryRow(ctx,
`SELECT id FROM domains WHERE name = $1 AND active = true LIMIT 1`,
a.Hostname,
).Scan(&id); err == nil {
domainID = &id
}
if _, err := aw.pool.Exec(ctx, `
INSERT INTO waf_alerts
(domain_id, hostname, client_ip, method, uri,
rule_id, rule_msg, severity, action)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)
`, domainID, a.Hostname, a.ClientIP, a.Method, a.URI,
a.RuleID, a.RuleMsg, a.Severity, a.Action,
); err != nil {
slog.Warn("waf: write alert to db failed", "error", err)
}
}

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

106
internal/waf/engine.go Normal file
View File

@@ -0,0 +1,106 @@
// Package waf implements the per-domain WAF engine for EdgeGuard.
// It wraps Coraza v3 (OWASP Core Rule Set) and exposes a simple
// hostname-keyed engine manager that the SPOE agent uses.
package waf
import (
"fmt"
"os"
"path/filepath"
"strings"
"github.com/corazawaf/coraza/v3"
"git.netcell-it.de/projekte/edgeguard-native/internal/models"
)
const (
DefaultCRSDir = "/usr/share/edgeguard/waf/crs"
DefaultSPOEAddr = "127.0.0.1:9000"
)
// BuildEngine creates a Coraza WAF instance for the given domain config.
// crsDir is the path to the OWASP CRS directory (may be empty — engine
// works without CRS, using only the basic Coraza core rules).
func BuildEngine(cfg models.WafConfig, crsDir string) (coraza.WAF, error) {
directives := buildDirectives(cfg, crsDir)
wafCfg := coraza.NewWAFConfig().
WithRequestBodyAccess().
WithDirectives(directives)
return coraza.NewWAF(wafCfg)
}
// buildDirectives assembles the SecLang directives for a domain config.
func buildDirectives(cfg models.WafConfig, crsDir string) string {
var sb strings.Builder
sb.WriteString("SecRequestBodyAccess On\n")
sb.WriteString("SecResponseBodyAccess Off\n")
sb.WriteString("SecRequestBodyLimit 13107200\n") // 12.5 MB
sb.WriteString("SecRequestBodyInMemoryLimit 131072\n") // 128 KB
sb.WriteString(fmt.Sprintf("SecRuleEngine %s\n", ruleEngineMode(cfg.Mode)))
if crsDir != "" && crsAvailable(crsDir) {
// Paranoia level MUST be set before CRS rules are included.
pl := cfg.ParanoiaLevel
if pl < 1 || pl > 4 {
pl = 1
}
sb.WriteString(fmt.Sprintf(
"SecAction \"id:900000,phase:1,nolog,pass,t:none,setvar:tx.paranoia_level=%d\"\n", pl,
))
setupConf := filepath.Join(crsDir, "crs-setup.conf")
if _, err := os.Stat(setupConf); err == nil {
sb.WriteString(fmt.Sprintf("Include %s\n", setupConf))
}
rulesGlob := filepath.Join(crsDir, "rules", "*.conf")
sb.WriteString(fmt.Sprintf("Include %s\n", rulesGlob))
}
// Rule exclusions (applied after CRS load so they override CRS).
for _, id := range cfg.RuleExclusions {
id = strings.TrimSpace(id)
if id != "" {
sb.WriteString(fmt.Sprintf("SecRuleRemoveById %s\n", id))
}
}
// 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) != "" {
sb.WriteString(cfg.CustomRules)
sb.WriteString("\n")
}
return sb.String()
}
func ruleEngineMode(mode string) string {
switch mode {
case "blocking":
return "On"
default: // "detection"
return "DetectionOnly"
}
}
// crsAvailable returns true when the CRS rules directory exists and
// contains at least one .conf file.
func crsAvailable(crsDir string) bool {
rulesDir := filepath.Join(crsDir, "rules")
entries, err := os.ReadDir(rulesDir)
if err != nil {
return false
}
for _, e := range entries {
if strings.HasSuffix(e.Name(), ".conf") {
return true
}
}
return false
}

127
internal/waf/manager.go Normal file
View File

@@ -0,0 +1,127 @@
package waf
import (
"fmt"
"log/slog"
"net"
"sync"
"github.com/corazawaf/coraza/v3"
"git.netcell-it.de/projekte/edgeguard-native/internal/models"
)
// DomainEngine bundles a Coraza WAF with its operating mode.
type DomainEngine struct {
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
// rebuilt only when their configuration changes (UpdatedAt differs).
// All public methods are safe for concurrent use.
type Manager struct {
mu sync.RWMutex
engines map[string]*DomainEngine // hostname → engine (nil entry = disabled)
configKeys map[string]configKey // hostname → last-seen config fingerprint
crsDir string
}
// configKey identifies a specific WAF config snapshot so we only
// rebuild the engine when something actually changed.
type configKey struct {
enabled bool
mode string
paranoiaLevel int
updatedAt int64 // unix nano
}
// NewManager creates an empty Manager with the given CRS directory.
func NewManager(crsDir string) *Manager {
if crsDir == "" {
crsDir = DefaultCRSDir
}
return &Manager{
engines: make(map[string]*DomainEngine),
configKeys: make(map[string]configKey),
crsDir: crsDir,
}
}
// DomainConfig pairs a domain hostname with its WAF policy.
type DomainConfig struct {
Hostname string
Config models.WafConfig
}
// Reload refreshes engines from the given list, rebuilding only when
// the config has actually changed since the last call.
func (m *Manager) Reload(domains []DomainConfig) error {
m.mu.RLock()
prevEngines := m.engines
prevKeys := m.configKeys
m.mu.RUnlock()
newEngines := make(map[string]*DomainEngine, len(domains))
newKeys := make(map[string]configKey, len(domains))
for _, dc := range domains {
ck := configKey{
enabled: dc.Config.Enabled,
mode: dc.Config.Mode,
paranoiaLevel: dc.Config.ParanoiaLevel,
updatedAt: dc.Config.UpdatedAt.UnixNano(),
}
newKeys[dc.Hostname] = ck
if !dc.Config.Enabled {
newEngines[dc.Hostname] = nil
continue
}
// Reuse existing engine if config hasn't changed.
if prev, ok := prevKeys[dc.Hostname]; ok && prev == ck {
if existing := prevEngines[dc.Hostname]; existing != nil {
newEngines[dc.Hostname] = existing
continue
}
}
waf, err := BuildEngine(dc.Config, m.crsDir)
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, TrustedProxies: dc.Config.TrustedProxies}
slog.Info("waf: engine (re)loaded",
"host", dc.Hostname,
"mode", dc.Config.Mode,
"paranoia_level", dc.Config.ParanoiaLevel,
"crs", crsAvailable(m.crsDir),
)
}
m.mu.Lock()
m.engines = newEngines
m.configKeys = newKeys
m.mu.Unlock()
return nil
}
// GetForHost returns the DomainEngine for the given hostname, or
// (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").
// 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]
m.mu.RUnlock()
if !ok || de == nil {
return nil, false
}
return de, true
}

243
internal/waf/spoe.go Normal file
View File

@@ -0,0 +1,243 @@
package waf
import (
"context"
"log/slog"
"net"
"net/http"
"strings"
"github.com/corazawaf/coraza/v3/types"
"github.com/dropmorepackets/haproxy-go/pkg/encoding"
"github.com/dropmorepackets/haproxy-go/spop"
)
// SPOEAgent wraps the haproxy-go SPOE server and dispatches each
// inspected request to the appropriate per-domain Coraza engine.
type SPOEAgent struct {
Manager *Manager
AlertWriter *AlertWriter
Addr string
}
// ListenAndServe starts the SPOE agent. Blocks until ctx is cancelled.
func (a *SPOEAgent) ListenAndServe(ctx context.Context) error {
agent := spop.Agent{
Addr: a.Addr,
Handler: spop.HandlerFunc(a.handle),
BaseContext: ctx,
}
return agent.ListenAndServe()
}
// handle is called by the haproxy-go SPOE library for every NOTIFY
// frame HAProxy sends. It extracts the request data, runs Coraza,
// and optionally sets a txn.waf.status variable to trigger a deny ACL.
func (a *SPOEAgent) handle(ctx context.Context, w *encoding.ActionWriter, m *encoding.Message) {
var (
clientIP string
method string
uri string // full request URI (path + optional ?query)
httpVer string
host string
rawHdrs string
)
// Iterate over the key-value pairs HAProxy sent with this message.
entry := encoding.AcquireKVEntry()
defer encoding.ReleaseKVEntry(entry)
for m.KV.Next(entry) {
switch {
case entry.NameEquals("src"):
addr := entry.ValueAddr()
if addr.IsValid() {
clientIP = addr.String()
}
case entry.NameEquals("method"):
method = string(entry.ValueBytes())
case entry.NameEquals("uri"):
uri = string(entry.ValueBytes())
case entry.NameEquals("ver"):
httpVer = string(entry.ValueBytes())
case entry.NameEquals("host"):
host = string(entry.ValueBytes())
case entry.NameEquals("headers"):
rawHdrs = string(entry.ValueBytes())
}
entry.Reset()
}
if host == "" {
return
}
de, ok := a.Manager.GetForHost(host)
if !ok {
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()
if err := tx.Close(); err != nil {
slog.Warn("waf: tx.Close", "error", err)
}
}()
// Feed connection metadata.
if clientIP != "" {
tx.ProcessConnection(clientIP, 0, "", 0)
}
if uri == "" {
uri = "/"
}
if httpVer == "" {
httpVer = "HTTP/1.1"
}
tx.ProcessURI(uri, method, httpVer)
// Feed Host header first (required by many CRS rules).
tx.AddRequestHeader("Host", host)
// Parse and feed all raw headers.
parseHeaders(rawHdrs, func(name, val string) {
if !strings.EqualFold(name, "host") { // already added above
tx.AddRequestHeader(name, val)
}
})
// Evaluate request headers.
interruption := tx.ProcessRequestHeaders()
// Log all matched rules (detection + blocking).
for _, mr := range tx.MatchedRules() {
a.sendAlert(host, clientIP, method, uri, mr, interruption != nil)
}
if interruption != nil {
status := interruption.Status
if status == 0 {
status = http.StatusForbidden
}
slog.Info("waf: request blocked",
"host", host, "method", method, "uri", uri,
"client", clientIP, "status", status, "rule", interruption.RuleID,
)
if de.Mode == "blocking" {
if err := w.SetInt64(encoding.VarScopeTransaction, "status", int64(status)); err != nil {
slog.Warn("waf: SetInt64 status", "error", err)
}
}
}
}
// sendAlert enqueues a WAF alert for async DB write.
// Control-flow rules (pass+nolog with empty message) are skipped —
// they are CRS paranoia-level skip-markers, not real detections.
func (a *SPOEAgent) sendAlert(host, clientIP, method, uri string, mr types.MatchedRule, blocked bool) {
if a.AlertWriter == nil {
return
}
ruleID := mr.Rule().ID()
// Skip CRS setup/initialization rules (900xxx909xxx) — they fire on
// every request as part of CRS init and are not security events.
// Real detection rules start at 910xxx (IP reputation) and above.
if ruleID > 0 && ruleID < 910000 {
return
}
// Skip control-flow rules with no message (PL-skip markers).
if mr.Message() == "" {
return
}
action := "detected"
if blocked && mr.Disruptive() {
action = "blocked"
}
a.AlertWriter.Send(Alert{
Hostname: host,
ClientIP: clientIP,
Method: method,
URI: uri,
RuleID: mr.Rule().ID(),
RuleMsg: mr.Message(),
Severity: mr.Rule().Severity().String(),
Action: action,
})
}
// 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)) {
for _, line := range strings.Split(raw, "\n") {
line = strings.TrimRight(line, "\r")
if line == "" {
continue
}
idx := strings.IndexByte(line, ':')
if idx <= 0 {
continue
}
name := strings.TrimSpace(line[:idx])
val := strings.TrimSpace(line[idx+1:])
if name != "" {
fn(name, val)
}
}
}

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

@@ -34,6 +34,21 @@ func stopWGQuick(iface string) error {
return nil
}
func enableWGQuick(iface string) error {
cmd := exec.Command("sudo", "-n", "/usr/bin/systemctl", "enable", "wg-quick@"+iface+".service")
if out, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("systemctl enable wg-quick@%s: %w: %s", iface, err, string(out))
}
return nil
}
func disableWGQuick(iface string) error {
cmd := exec.Command("sudo", "-n", "/usr/bin/systemctl", "disable", "wg-quick@"+iface+".service")
// Ignore failures — unit may already be disabled.
_ = cmd.Run()
return nil
}
// symlinkWGQuickConf creates (or atomically replaces) the symlink
// /etc/wireguard/<iface>.conf → target via sudo. /etc/wireguard/ is
// owned root:root 700 so the edgeguard user cannot write to it directly;

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,7 +153,10 @@ func (g *Generator) Render(ctx context.Context) error {
continue
}
_ = os.Remove(filepath.Join(ConfDir, e.Name()))
_ = stopWGQuick(ifaceName)
if !g.SkipReload {
_ = stopWGQuick(ifaceName)
_ = disableWGQuick(ifaceName)
}
}
}
return nil
@@ -227,20 +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)
}
if existing, err := os.ReadFile(path); err == nil && bytes.Equal(existing, body.Bytes()) {
_ = enableWGQuick(ifc.Name)
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,5 +1,6 @@
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'
import deDE from 'antd/locale/de_DE'
import enUS from 'antd/locale/en_US'
@@ -25,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'))
@@ -37,6 +40,8 @@ const AlertsPage = lazy(() => import('./pages/Alerts'))
const LicensePage = lazy(() => import('./pages/License'))
const SettingsPage = lazy(() => import('./pages/Settings'))
const UsersPage = lazy(() => import('./pages/Users'))
const CrowdSecPage = lazy(() => import('./pages/CrowdSec'))
const WAFPage = lazy(() => import('./pages/WAF'))
const queryClient = new QueryClient({
defaultOptions: {
@@ -62,15 +67,51 @@ const antdTheme = {
colorTextSecondary: '#64748B',
controlHeight: 34,
},
components: {
Tabs: {
itemColor: '#334155',
itemHoverColor: '#0F172A',
itemSelectedColor: '#0EA5E9',
inkBarColor: '#0EA5E9',
cardBg: '#F1F5F9',
titleFontSize: 13,
},
},
}
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 }) {
@@ -99,6 +140,7 @@ export default function App() {
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<SetupGate>
<LocationKeyBoundary>
<Suspense fallback={<div className="loader-center"><Spin size="large" /></div>}>
<Routes>
<Route path="/setup" element={<SetupPage onComplete={(u: SessionUser) => useAuthStore.getState().set(u)} />} />
@@ -120,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 />} />
@@ -131,14 +175,24 @@ export default function App() {
<Route path="/license" element={<LicensePage />} />
<Route path="/users" element={<UsersPage />} />
<Route path="/settings" element={<SettingsPage />} />
<Route path="/crowdsec" element={<CrowdSecPage />} />
<Route path="/waf" element={<WAFPage />} />
</Route>
<Route path="*" element={<Navigate to="/dashboard" replace />} />
</Routes>
</Suspense>
</LocationKeyBoundary>
</SetupGate>
</BrowserRouter>
</QueryClientProvider>
</ConfigProvider>
)
}
// Resets the ErrorBoundary on every route change so a render error on
// one page never permanently blocks navigation to another page.
function LocationKeyBoundary({ children }: { children: ReactNode }) {
const { pathname } = useLocation()
return <ErrorBoundary key={pathname}>{children}</ErrorBoundary>
}

View File

@@ -1,35 +1,65 @@
import { Component, type ErrorInfo, type ReactNode } from 'react'
// Top-level ErrorBoundary. Catches throws aus dem React-Tree (inkl.
// Lazy-Chunk-Loadfehler, die auf flakigem Mobilfunk häufig sind) und
// rendert eine sichtbare Fehlerseite statt #root leer zu lassen.
// Ohne diese Boundary endet jeder Render-Throw als „blank page".
//
// Wir loggen den Fehler in die Browser-Console (für Remote-Debug via
// Safari-Inspector/Chrome-Remote) und zeigen dem Operator die
// Fehlermeldung wörtlich — kein Translation-Layer, weil i18n selbst
// schon kaputt sein kann.
import { isStaleChunkError, reloadForStaleChunkOnce } from '../lib/staleChunkReload'
interface State { error: Error | null }
// Top-level ErrorBoundary. Catches throws aus dem React-Tree (inkl.
// Lazy-Chunk-Loadfehler nach einem Deploy) und rendert eine sichtbare
// Fehlerseite statt #root leer zu lassen. Ohne diese Boundary endet
// jeder Render-Throw als „blank page".
//
// Stale-Chunk-Fehler (alter Tab referenziert nicht mehr existierende
// gehashte Chunks nach einem Deploy) werden automatisch per einmaligem
// Reload behoben — der Operator sieht dann nur kurz „Aktualisiere…".
// Erst wenn auch der Reload nicht hilft (giveUp) zeigen wir die manuelle
// Fehlerkarte. Andere Fehler werden wörtlich angezeigt — kein
// Translation-Layer, weil i18n selbst kaputt sein kann.
interface State { error: Error | null; giveUp: boolean }
export default class ErrorBoundary extends Component<{ children: ReactNode }, State> {
state: State = { error: null }
state: State = { error: null, giveUp: false }
static getDerivedStateFromError(error: Error): State {
static getDerivedStateFromError(error: Error): Partial<State> {
return { error }
}
componentDidCatch(error: Error, info: ErrorInfo) {
// eslint-disable-next-line no-console
console.error('[ErrorBoundary]', error, info.componentStack)
// Stale-Chunk → einmalig neu laden. Schlägt der Loop-Schutz an
// (Reload half nicht), auf die manuelle Karte zurückfallen.
if (isStaleChunkError(error) && !reloadForStaleChunkOnce()) {
this.setState({ giveUp: true })
}
}
reset = () => { this.setState({ error: null }) }
reset = () => { this.setState({ error: null, giveUp: false }) }
render() {
const err = this.state.error
if (!err) return this.props.children
const isChunkErr = /Loading chunk|Failed to fetch dynamically imported module|Importing a module script failed/i.test(err.message)
const isChunkErr = isStaleChunkError(err)
// Auto-Reload läuft (Chunk-Fehler, Loop-Schutz noch nicht erreicht):
// neutralen Lade-Hinweis zeigen statt der Fehlerkarte.
if (isChunkErr && !this.state.giveUp) {
return (
<div style={{
minHeight: '100vh',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: 24,
background: '#F8FAFC',
color: '#64748B',
fontSize: 14,
fontFamily: "-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif",
}}>
Aktualisiere EdgeGuard
</div>
)
}
return (
<div style={{
minHeight: '100vh',
@@ -54,7 +84,7 @@ export default class ErrorBoundary extends Component<{ children: ReactNode }, St
</div>
<div style={{ fontSize: 13, color: '#64748B', marginBottom: 16 }}>
{isChunkErr
? 'Ein Teil der App konnte nicht aus dem Netz geladen werden. Das passiert häufig bei wechselndem Mobilfunk-Empfang. Versuche es mit einem Reload.'
? 'Ein Teil der App konnte nicht geladen werden — auch ein automatischer Reload hat nicht geholfen. Bitte lade die Seite manuell neu (ggf. mit Strg+F5), oder prüfe die Verbindung zum Server.'
: 'Beim Initialisieren der Oberfläche ist ein Fehler aufgetreten.'}
</div>
<pre style={{

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',

Some files were not shown because too many files have changed in this diff Show More