128 Commits

Author SHA1 Message Date
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
Debian
eaa6a04234 feat(cluster): PG Logical Replication setup + Master-Key-Sync + Firewall-Ports
cluster-init-replication:
- listen_addresses = '*' damit Cluster-Peers PG auf :5432 erreichen können
- max_replication_slots = 20 / max_wal_senders = 10 (verhindert Slot-Erschöpfung
  bei initaler Tabellen-Synchronisation mit vielen gleichzeitigen Sync-Workern)
- pg-replication-secret: Ownership an edgeguard-User (API-Lesezugriff)
- detectPGConfig() statt hardcoded PG 16 (System läuft PG 17)

cluster-setup-standby:
- syncMasterKey(): holt /var/lib/edgeguard/.master_key via mTLS vom Primary —
  ohne identischen Master-Key können replizierte WireGuard-Keys nicht entschlüsselt werden
- render-config: sudo -u edgeguard statt als root (DB-Zugriff)

nftables Template:
- Port 5432 (PG) + 6379 (KeyDB) für Cluster-Peers (@peer_ipv4/@peer_ipv6) freigegeben

handlers/cluster.go:
- GET /agent/cluster/master-key: gibt .master_key via mTLS zurück (hex-kodiert)

v1.2.15

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-30 12:56:32 +02:00
Debian
884f84d3f1 fix(rolling-update): stable done-detection, mobile banner layout, stale-state protection
- FinishRollingUpdateIfPending() auf API-Startup: transitiert
  updating-primary → done damit der UI-Flow nach Restart abschließt
- RollingUpdateStatus: setzt done nach Auslieferung auf idle zurück
  (verhindert Stale-done bei Page-Reload)
- wasRollingActiveRef: reagiert auf done nur wenn rolling in DIESER
  Session aktiv war — kein sofortiger Reload bei Stale-State
- UI-Fallback für updating-primary: poll auf /system/health version-flip
- Cluster-Erkennung via /cluster/status; Rolling-Update-Button nur im Cluster
- Update-Banner-Button nicht mehr gequetscht (flex-shrink:0 + nowrap)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-30 12:56:20 +02:00
Debian
bc6db1fc2b feat(cluster): Rolling Update — Secondary-first upgrade orchestration (v1.2.3)
POST /cluster/rolling-update startet den gestaffelten Upgrade-Prozess:
1. Secondary via mTLS /agent/cluster/trigger-update anstoßen
2. /agent/cluster/version pollen bis Secondary Version-Flip zeigt (max 10 min)
3. Primary self-upgrade via systemd-run (identisch zu /system/upgrade)

State wird in /var/lib/edgeguard/rolling-update-state.json persistiert:
Phasen: updating-secondary → waiting-secondary → updating-primary.
"done" wird nicht geschrieben — Prozess stirbt beim Upgrade. UI erkennt
Abschluss via /system/health version-flip (analog Single-Node-Upgrade).

UI: UpdateBanner erkennt Cluster-Modus (/cluster/status mode="cluster")
und tauscht den "Install now"-Button gegen "Rolling Update (Cluster)" aus.
Multi-Step-Modal zeigt die drei Phasen; ab updating-primary wechselt der
Client auf /system/health polling.

Aggregator.PostPeer: neuer einzel-POST-Helper für mTLS-trigger-update.
WithVersion(): ClusterHandler bekommt Binary-Version für /agent/cluster/version.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 23:40:49 +02:00
Debian
25c7cd0cb5 feat(cluster): PG Logical Replication + VIP/Keepalived + config_hash sync (v1.2.1–1.2.2)
- PG Logical Replication: edgeguard_shared PUBLICATION auf Primary,
  edgeguard_sub SUBSCRIPTION auf Secondary. Nur geteilte Config-Tabellen
  werden repliziert; node-eigene Daten (network_interfaces, ip_addresses,
  static_routes, cluster_settings, dns_settings, ntp_settings) bleiben
  lokal — OPNsense-Muster.
- cluster-init-replication: Erstellt PUBLICATION, Rolle + pg_hba-Einträge
  (logical + replication), WAL-Level auf logical.
- cluster-setup-standby: Erstellt SUBSCRIPTION (copy_data=true), pollt
  pg_subscription_rel bis alle Tabellen sync = 'r', rendert dann Configs.
- promote: manueller Failover via pg_promote() + touch recovery.signal.
- VIP/Keepalived: cluster_settings-Tabelle (vip_address, vip_interface,
  vrrp_router_id), /cluster/vip-settings API, Keepalived-Config-Generator
  mit VRRP + check_script + notify-Skripten in /usr/lib/edgeguard/scripts/.
- config_hash sync: Secondary pusht alle 5 Min seinen Hash via mTLS an
  Primary (PushSelfToPrimary). Heartbeat schreibt nur LOCAL, daher ohne
  aktiven Push wäre Primary-Sicht des Secondary-Hash stale gewesen.
- runSecondaryConfigRender: Goroutine auf Secondary rendert HAProxy+nftables
  neu wenn config_hash sich ändert (Logical-Replication-Nachzügler).
- confighash: node-spezifische Tabellen aus hashSpec entfernt.
- postinst: Keepalived-Skripte installieren, sudoers für keepalived.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 23:40:37 +02:00
Debian
c1a4ccff8f fix(cluster): Placeholder zuerst löschen vor UpsertSelf (UNIQUE-fqdn-Bug)
ha_nodes hat UNIQUE(fqdn). UpsertSelf nutzt ON CONFLICT(id) — schlägt
bei fqdn-Konflikten fehl. Wenn Placeholder und echter Node die gleiche
FQDN haben, schlug der INSERT der echten Row mit "duplicate key on
ha_nodes_fqdn_unique" fehl.

Root cause für "joining" stuck forever: der Peer blieb ewig als
Placeholder weil AgentRegisterPeer + reconcileJoiningPeers die Echte-
ID-Row nie erfolgreich einfügen konnten.

Fix: DeletePlaceholdersByFQDN vor UpsertSelf in beiden Code-Paths.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 19:45:02 +02:00
Debian
ffb579879d feat(cluster): pull-basierte Peer-Registrierung als autoRegister-Fallback
Wenn autoRegister (Push utm-2→utm-1) fehlschlägt, bleibt der Peer
dauerhaft im 'joining'-Status. Neuer Self-Healing-Mechanismus:

- GET /agent/cluster/identity auf dem Agent-Listener: gibt die eigene
  ha_nodes-Row zurück (echte Node-ID, FQDN, Version)
- ClusterHandler.Status() startet für jeden 'joining'/'pending'-Peer
  einen Background-Reconcile via Aggregator: holt Identity, upsertet
  mit echter ID (public_ip aus Placeholder übernommen für nftables),
  löscht Placeholder
- autoRegister-Fehler werden jetzt als Warn-Log sichtbar statt silent
  verworfen

Damit reicht ein Cluster-Page-Aufruf nach Update beider Nodes um den
Status von 'joining' auf 'online' zu bringen.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 19:06:19 +02:00
Debian
6eb6d6d6a4 fix(jointoken): initialize HMAC secret on service startup
Consume() called loadSecret() which failed with "no such file" when
cluster-join-secret didn't exist yet. New() now calls ensureSecret()
so the file is always created before any Consume() attempt.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 18:44:00 +02:00
Debian
357113e7be fix(cluster): Alle Placeholder-Rows bei autoRegister bereinigen
AgentRegisterPeer löschte bisher nur den neuen prenode-{fqdn}-Placeholder.
Alte pre-{timestamp}-Rows (aus Versionen vor 1.1.158) blieben stehen und
zeigten dauerhaft status=joining.

Fix: DeletePlaceholdersByFQDN löscht ALLE ha_nodes-Rows mit gleicher FQDN
außer der echten Node-ID — unabhängig vom ID-Format.

Auch preRegisterByFQDN nutzt jetzt das stabile prenode-{fqdn}-Format.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 18:33:50 +02:00
Debian
8d83de6b0f feat(cluster): Fix "joining" status + cross-node auth federation
1. preRegisterJoiner now runs SYNCHRONOUSLY before IssueCert responds,
   so nftables @peer_ipv4 is updated before the joiner calls autoRegister.
   Previously it was a goroutine → race → autoRegister failed → "joining"
   forever.

2. Stable node ID for pre-registered placeholder (prenode-{fqdn}) instead
   of time-based ID — re-joins are now idempotent.

3. AgentRegisterPeer sets status="online" immediately (peer proved it is
   online by connecting via mTLS) and deletes the prenode-{fqdn} placeholder.

4. autoRegister retries 3× with 2s delay in case of transient nftables lag.

5. Auth federation: cluster nodes forward failed logins to the primary via
   mTLS /agent/auth/check so users can log in on any node with primary
   credentials (no PG replication needed).
   - SystemHandler.AgentAuthCheck: new endpoint on :8443
   - AuthHandler.checkWithPrimary: mTLS call to primary when local auth fails
   - AuthHandler.WithClusterTLS: inject cluster TLS store
   - startAgentListener now uses the wired systemHdl with Users repo

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 18:04:34 +02:00
Debian
6bb1c5c6d3 fix(aggregator): Envelope aus Peer-Responses entpacken vor Weiterleitung
callPeer speicherte den rohen HTTP-Response-Body ({"data": {...}, "error":
null}) in PeerResult.Data. Der lokale Pfad serialisiert dagegen die Struct
direkt ohne Envelope. Im UI führte das auf Peer-Nodes zu
`r.data.load_avg_1 = undefined` → TypeError: Cannot read properties of
undefined (reading 'toFixed') → Cluster-Seite nicht ladbar.

Fix: Envelope-Feld `data` extrahieren; fallback auf rohen Body wenn kein
gültiger Envelope.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 17:52:25 +02:00
Debian
de28523708 feat(cluster): startup peer-sync + stable primary-ID in ha_nodes
On API restart, cluster nodes now re-register their primary in the local
ha_nodes and reload nftables so @peer_ipv4 is correct after a package
update or reboot without requiring a re-join.

Also fixes duplicate ha_nodes rows: preRegisterPrimary previously used
time.Now().UnixNano() as node ID, creating a fresh row each call.
Now uses a deterministic ID derived from the FQDN so repeated upserts
are idempotent.

PrimaryFQDN is now persisted in setup.json during CompleteAsNode so the
startup sync knows which primary to contact.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 17:45:26 +02:00
Debian
8ac066d99a fix(cluster): Primary bidirektional in ha_nodes eintragen nach Join
Nach erfolgreichem cluster-join trägt der Joining-Node (utm-2) den Primary
per DNS-Lookup in seine lokale ha_nodes ein und lädt nftables neu.
Damit ist @peer_ipv4 auf utm-2 sofort mit utm-1's IP befüllt und Port 8443
ist bidirektional offen — der Aggregator auf utm-1 kann utm-2 auf :8443
abklappern.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 17:36:06 +02:00
Debian
5ace250879 feat(cluster): FQDN-First Token-Flow — Node vor dem Join vorregistrieren
Cluster-Seite: Schritt 2 fragt jetzt den FQDN des neuen Knotens bevor
der Token generiert wird. Der Knoten wird sofort in ha_nodes (status=pending)
eingetragen. Nach Token-Generierung wird direkt die Setup-Wizard-URL des
neuen Knotens angezeigt (https://<fqdn>:3443/setup).

Backend: POST /cluster/join-tokens nimmt jetzt node_fqdn entgegen,
pre-registriert via preRegisterByFQDN(). Die IP wird beim issue-cert
nachgetragen (preRegisterJoiner).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 17:29:58 +02:00
Debian
fd69081336 fix(cluster): Joiner-IP sofort in @peer_ipv4 — Henne-Ei-Problem gelöst
IssueCert registriert den beitretenden Node jetzt direkt in ha_nodes
(public_ip = ClientIP, status='joining') und triggert den Firewall-Reload,
bevor die Response zurückgeht. Damit ist Port 8443 schon offen wenn der
Node im nächsten Schritt auto-register via mTLS versucht.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 17:25:38 +02:00
Debian
6a5aff69d9 fix(setup): CompleteAsNode idempotent — konvertiert Standalone zu Cluster-Node
Wenn setup bereits abgeschlossen war (z.B. zuvor als Standalone eingerichtet),
wird der Node jetzt ohne Fehler auf is_cluster_node=true umgestellt statt
mit "setup already completed" zu fehlschlagen.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 17:21:17 +02:00
Debian
2e84268f61 fix(setup): TLS-Prüfungs-Checkbox aus Cluster-Join-GUI entfernt
Sicherheit des Joins kommt vom HMAC-Token, nicht von TLS-Cert-Trust.
Insecure=true wird jetzt immer im Handler gesetzt — GUI-Nutzer müssen
das nicht kennen oder konfigurieren.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 17:18:33 +02:00
Debian
60e2aec5d9 fix(setup): Force-Flag für cluster-join — Bootstrap-Cert wird automatisch ersetzt
Der API-Boot erstellt für Single-Node-Betrieb automatisch ein selbst-signiertes
peer-Cert. Beim Join über die GUI wurde das als Fehler gemeldet.

clusterjoin.Request.Force=true im Setup-Handler überspringt die HasPeer-Prüfung;
CLI (edgeguard-ctl cluster-join) bleibt vorsichtig (Force=false, manuelles rm nötig).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 17:14:34 +02:00
Debian
afe2c951b9 feat(setup): Cluster-Join komplett in GUI — kein CLI mehr nötig
POST /setup/join-cluster macht alles was bisher edgeguard-ctl cluster-join
tat: CSR generieren, Certs vom Primary holen, schreiben, auto-registrieren,
Setup als Cluster-Node markieren.

Setup-Wizard Node-Modus fragt jetzt direkt Primary-FQDN + Join-Token ab.
Nach Submit: Erfolgsmeldung + einziger verbleibender Schritt (systemctl restart).

Neue interne Bibliothek: internal/services/clusterjoin — wird von Handler
und CLI (edgeguard-ctl cluster-join) gleichermaßen genutzt, keine Duplizierung.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 17:08:49 +02:00
Debian
7e5a7a83c0 feat(setup): Cluster-Node-Modus im Setup-Wizard + Install-Script-Port-Fix
Setup-Wizard zeigt jetzt beim ersten Aufruf eine Modusauswahl:
- "Neuinstallation" → bisheriger Flow (Admin-Account + FQDN + ACME)
- "Cluster-Knoten beitreten" → nur FQDN + ACME, kein Admin-Account;
  nach dem Submit werden die cluster-join-Befehle direkt angezeigt

Backend: NodeRequest + Store.CompleteAsNode() + POST /setup/complete-node
State: is_cluster_node Flag; login via PG-Replikation vom Primary

Install-Script: Setup-URL zeigt jetzt korrekt :3443/setup statt /setup

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 16:07:19 +02:00
Debian
a0b3bccbc6 fix(cluster): ha_nodes INET-Spalten → TEXT — pgx-Scan-Fehler OID 869
pgx kann INET-Typen (OID 869) nicht direkt in *string scannen.
Migration 0028 konvertiert public_ip, internal_ip, mgmt_ip auf TEXT
(USING ip::TEXT erhält bestehende Werte). Basis-Migrationen 0002 + 0020
auf TEXT umgestellt damit frische Installs keine INET anlegen.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 15:41:20 +02:00
Debian
33f6fef009 feat(cluster): Seite komplett neu — Blank-Page-Fix + Node-Join-Wizard
Seite war komplett leer wenn die Cluster-API einen Fehler zurückgab
(if(!data) return null). Jetzt: sichtbarer Error-Banner mit Retry,
keine leere Seite mehr.

Neuer 4-Schritt-Wizard "Zweiten Node hinzufügen" immer sichtbar:
  1. Installer-Oneliner (copyable)
  2. Join-Token generieren (Button → POST /cluster/join-tokens)
  3. cluster-join-Befehl inkl. Token inline auf der Seite (kein Modal)
  4. systemctl restart edgeguard-api

Token + CA-Fingerprint + Befehl erscheinen direkt in Schritt 3
nach Token-Generierung — kein separates Modal mehr nötig.
common.retry i18n-Key ergänzt.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 15:17:12 +02:00
Debian
31d3485a2f feat(dns): Validierung für DNS-Settings — upstream IPs, ACL-CIDRs, TTL-Kreuzcheck
Backend: validateSettings() prüft vor dem Reload-Trigger ob upstream_forwards
gültige IPs (inkl. @port), access_acl gültige IPs/CIDRs und listen_addresses
gültige IPs sind; cache_max_ttl ≥ cache_min_ttl.
Frontend: Pattern-Validatoren auf upstream_forwards + access_acl; TTL-
Kreuzvalidierung mit dependencies.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 12:52:28 +02:00
Debian
0cbc781d4f feat(domains): cert error Tooltip mit last_error im Domain-Detail
Abgelaufene und fehlerhafte Zertifikate zeigen jetzt beim Hover den
Fehlertext (last_error) aus der DB. TLSCertLite um last_error erweitert.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 09:25:33 +02:00
Debian
bf09fd2cef fix(scheduler): Cert-Renewal-Alerts pro Domain statt aggregierter Zählung (v1.1.143)
- certrenewer.Result.FailedDomains []string — jeder fehlgeschlagene Domain-Name
  wird erfasst (Issue/Parse/Write-Fehler)
- runRenewer: pro Domain eigener Alert + eigener Dedupe-Key statt einem
  shared "cert.renew_failed"-Key; Alert-Message nennt jetzt den Domain-Namen
  und gibt Hinweis auf ACME/DNS-Debugging

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 07:38:57 +02:00
Debian
7dd76d4b58 fix(backends): Backend-Löschen verweigern wenn Routing-Regeln darauf zeigen (v1.1.142)
- Service: Pre-Check auf routing_rules.backend_id → ErrInUse vor DELETE
- Handler: ErrInUse → HTTP 409 Conflict statt 500 (response.Conflict helper)
- UI: Delete-Button deaktiviert + Tooltip "Von N Regeln verwendet" wenn Referenzen existieren

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 07:15:16 +02:00
Debian
40414d472b fix(routing+backends): Pfad-Präfix-Validierung + Warnung bei leerem Backend-Pool (v1.1.141)
- RoutingRules: path_prefix Pattern-Validator (muss mit / beginnen) + destroyOnHidden
- Backends: Alert-Banner wenn aktives Backend 0 Server hat (→ HAProxy 503)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 21:25:55 +02:00
Debian
9dee904169 fix(backends): Adress-Validierung für Backend-Server + Quick-Backend (v1.1.140)
Pattern-Validator lehnt offensichtlich ungültige Adressen (Sonderzeichen,
Leerzeichen) schon im Frontend ab, bevor sie HAProxy-Config-Generierung
zerstören können. Gilt für Backend-Server-Modal und Domain-Quick-Backend.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 21:13:24 +02:00
Debian
d89569017d feat(firewall): Regel duplizieren + CIDR-Validierung (v1.1.139)
- Duplicate-Button (CopyOutlined): klont Regel mit priority+1, disabled=true, Name "(copy)"
- CIDR-Felder: Pattern-Validator für IPv4/IPv6-CIDR-Notation
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 20:36:25 +02:00
Debian
dc2c1b7471 fix(settings): Bestätigungsdialog vor Service-Restart + status-dot CSS (v1.1.138)
- Popconfirm vor jedem Restart-Button — kein versehentlicher Neustart mehr
- status-dot inline styles → .status-dot.online/.offline CSS-Klassen

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 17:14:44 +02:00
Debian
2a9310e947 fix(firewall): NAT-Regeln nach Priorität sortieren + Port-Range-Validierung (v1.1.137)
- dataSource: data→sortedNAT — Up/Down-Pfeile zeigten auf falschen Nachbarn
- Form.Item dependencies: dportEnd/targetPortEnd validiert ≥ Start-Port
- Modal destroyOnHidden: Formular-State nach Schließen zurückgesetzt

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 16:51:07 +02:00
Debian
76b5a4586c feat(firewall): Enterprise-UI-Redesign — KPI-Strip, Zone-Badges, Filter-Bar (v1.1.136)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 16:39:57 +02:00
Debian
fa86b14635 fix(layout): Header-Titel für SSL, DNS, NTP, WireGuard, ForwardProxy
PAGE_TITLES in AppLayout fehlten 5 Einträge — diese Seiten zeigten
statt dem Seitennamen nur den generischen App-Titel im Header.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 16:20:02 +02:00
Debian
abde59f9b4 fix(firewall): SystemRules-Panel zeigt UDP/443 (HTTP/3 QUIC) Anti-Lockout
Die Systemregeln-Dokumentation listete nur TCP/443, aber das nftables-
Template enthält auch udp dport 443 für QUIC. Ohne den UDP-Eintrag wirkte
das Panel inkorrekt und erschwerte Firewall-Troubleshooting.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 15:47:12 +02:00
Debian
0a31a253b3 fix(routing-rules): Tabelle zeigt Regeln in Priority-Reihenfolge
DataTable nutzte `rules` (API-Reihenfolge), sortedRules nur für
die Up/Down-Index-Berechnung — Move-Pfeile und Tabellenreihenfolge
waren dadurch inkonsistent. Fix: DataTable bekommt sortedRules.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 15:32:47 +02:00
Debian
e2b8560c65 fix(audit): log system.upgrade trigger
Upgrade war die einzige system-Mutation ohne Audit-Eintrag. Alle
anderen (maintenance, service-restart, haproxy-reload, IPv6, render-
configs, auto-update) loggen bereits. Jetzt wird system.upgrade mit
actor + unit-Name geloggt.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 15:23:30 +02:00
Debian
22146c422d fix(auth): audit-log login success + failure attempts
Login handler hatte h.Audit injiziert (mit Kommentar "login-success/fail
ins audit_log fließen") aber nie aufgerufen. Jetzt werden geloggt:
  - auth.login.failed (reason: invalid_credentials | account_disabled)
  - auth.login.success (mit role + remote IP)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 15:14:13 +02:00
Debian
2a76a6599b fix(license): Rejection-Reason anzeigen + Placeholder NETC- korrigieren
Wenn der License-Server valid:false zurückgibt, wird payload.reason
jetzt als roter Alert angezeigt — bisher war der Grund nicht sichtbar
und der Operator sah nur "Abgelaufen" ohne Erklärung.

Formular-Placeholder NMG-XXXX → NETC-XXXX (mail-gateway copy-paste).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 15:06:48 +02:00
Debian
6a9019058a fix(license): Produktname + mail-gateway-Feature-Keys entfernen
Trial-Result hatte Product="NetCell MailGuard" und Feature-Keys
(reporting, eu_portal, digest, whitelabel, rest_api_write) die aus
dem mail-gateway copy-pastet wurden und für EdgeGuard bedeutungslos
sind. Bereinigt: Product="NetCell EdgeGuard", Features={} (leer bis
EdgeGuard-eigene Gates definiert werden), Limits-Struktur bleibt.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 15:03:16 +02:00
Debian
a6ef320c57 fix(audit): fehlende Audit-Logs für DNS-Zone/Record-Delete + NTP-Pool-Delete
DeleteZone, DeleteRecord und NTPPool.DeletePool hatten keine Audit-
Einträge. Alle anderen Mutations derselben Handler loggen korrekt —
nur Delete war vergessen worden. Jetzt konsistent:
  dns.zone.delete, dns.record.delete, ntp.pool.delete

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 14:57:35 +02:00
Debian
1c39a0e533 fix(alerts+diag): quickToggle SMTP-Datenverlust + Diagnostics Viewer-Gate
Alerts: quickToggle schickte den Channel durch buildPayload(), das SMTP-
Settings aus einem flachen ChannelFormValues rekonstruiert. Bei Email-
Channels wurde damit jedes aktiv/inaktiv-Toggle zum SMTP-Config-Wipe,
weil die Flat-Fields (smtp_host, smtp_port, from, ...) undefined waren.
Fix: raw Channel-Objekt mit geändertem active-Flag direkt PUT-ten.

testFire-Button fehlte disabled={isViewer} + Tooltip.

Diagnostics: kein isViewer-Check, Viewer sahen funktionierende Run-
Buttons und bekamen 403 ohne Erklärung. Fix: isViewer aus Auth-Store,
disabled-Prop in ToolCard, alle 5 Cards + Enter-Handler gegattet.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 14:44:58 +02:00
Debian
d87068535d fix(backends): disabled={isViewer} fehlt am Server-Active-Switch in Detail.tsx
Der ServerPanel in Backends/Detail.tsx hat den isViewer-Prop korrekt
erhalten, ihn aber nicht auf den Active-Switch angewendet. Viewer
konnten dadurch den Server-Status im Backend-Detail direkt umschalten
(backends/index.tsx hatte den Switch korrekt gesperrt, Detail.tsx nicht).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 14:15:02 +02:00
Debian
7c50e28f55 fix(auth): ChangePassword unterstützt jetzt DB-User — nicht nur Setup-Admin
Vorher hat ChangePassword das Passwort ausschließlich gegen den
setup-store-Admin geprüft und dort gespeichert. User die via
User-Management angelegt wurden, bekamen immer 401.

Fix: DB-Lookup via Users.FindByEmail, Verify + SetPassword im DB-Store.
Setup-Store-Admin bleibt synchron wenn die E-Mail übereinstimmt.
Fallback auf setup-store bleibt erhalten für Legacy-Installs (pre-DB).

Bonus: %m-Formatverb-Fehler im Scheduler-Alert-Text behoben.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 13:52:48 +02:00
Debian
74d3f25d21 fix(users): Delete-Button für eigenes Konto deaktivieren
ActionButtons für die eigene User-Zeile bekommt deleteDisabled=true +
deleteDisabledReason=users.cannotDeleteSelf. Backend schützt gegen
Selbst-Löschung bereits (400 cannot delete own account); UI zeigte
aber den aktiven Delete-Button und ließ Operator ins Leere klicken.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 13:42:36 +02:00
Debian
44f184d505 fix(users): viewer-Rolle — Add/Edit/Delete/Set-PW/Active-Toggle deaktivieren
Users-Seite fehlte `isViewer` komplett: Viewer sahen alle Mutations-
Controls aktiv und bekamen erst nach dem Klick einen Backend-403.
Jetzt: „Benutzer hinzufügen"-Button + KeyOutlined-Set-PW-Button +
Active-Switch tragen `disabled={isViewer}`, ActionButtons-Pair liest
isViewer schon intern aus dem Auth-Store.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 10:27:12 +02:00
Debian
192384c448 fix(routing-rules): backend selector showed (undefined:undefined) instead of scheme
The Backend interface declared address/port fields that don't exist on the
Backend model (those belong to BackendServer). The table column and the
backend select dropdown both rendered "name (undefined:undefined)". Fixed
by replacing phantom fields with scheme, matching the same fix applied to
Domains pages in v1.1.121.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 09:38:35 +02:00
Debian
22528a54a9 fix(domains): backend column showed (undefined:undefined) instead of scheme
BackendLite interfaces in Domains list and Detail declared address/port
fields that don't exist on the Backend model (those live on BackendServer).
Every backend tag rendered as "name (undefined:undefined)". Fixed by
replacing the phantom fields with scheme, so tags show e.g. "my-app (https)".

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 21:12:06 +02:00
Debian
36eea71c65 fix(backends): safeID server name matching + routing priority swap
Backend Detail page: HAProxy stat rows use safeID(name) as server token
(spaces/dots → '_') but the UI matched on the raw DB name, so servers
with non-alphanumeric names never showed live status. Added matching
safeID helper in TypeScript (mirrors haproxy.go implementation).

Routing Rules: commit priority up/down swap buttons (developed in a
previous session, were left uncommitted).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 20:51:34 +02:00
Debian
f378771dca feat(dashboard): SSL card expired/expiring split + HAProxy frontend labels
Expired certs now show a red error alert ("renew immediately") instead
of being silently folded into the orange "expiring soon" warning.
HAProxy frontend technical names (public_http, public_https, mgmt_https)
now display as human-readable labels (HTTP In, HTTPS In, Management).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 18:51:59 +02:00
Debian
123ee6e34f fix(haproxy-stats): filter internal backends from stats response
api_backend (management API) and rl_* (rate-limit stick-tables) were
appearing in the dashboard's backend health card alongside customer
backends. Skip these internal HAProxy proxy entries in the stats parser.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 18:48:04 +02:00
Debian
40a1b63c02 fix(ntp+backends): /system/services response is {services:[]} not a bare array
Both NTP and Backends pages cast r.data.data directly to Array<…> but
the handler wraps the list as gin.H{"services": out}. The wrong cast
returned an object to .find()/.filter(), crashing with "s.find is not
a function" and breaking the NTP page entirely. Firewall/ForwardProxy/
DNS/Settings pages already used the correct {services:…} shape. v1.1.117.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 17:14:23 +02:00
Debian
7f4606ba16 feat(dashboard): show HAProxy check_status code on DOWN servers
When a backend server is DOWN, the HAProxy card now shows the check_status
code (L4CON, L4TOUT, L7STS, L6CON, …) as a volcano-colored tag next to
the DOWN badge. Hovering shows a plain-English explanation of the failure
reason so operators can diagnose without leaving the dashboard. v1.1.116.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 17:08:34 +02:00
Debian
1c60affd27 fix(scheduler): same fieldAt i<=0 bug as haproxy_stats.go safeAt
fieldAt() in the scheduler used i<=0 instead of i<0, causing pxname
(column 0 in HAProxy CSV) to always return "". The HasPrefix("",
"eg_backend_") check then filtered every row → byBackend was always
empty → backend.down alert never fired since the check was introduced.
v1.1.115.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 13:31:56 +02:00
Debian
7c86656c36 feat(dashboard): resolve eg_backend_<id> → friendly name in HAProxy card
The HAProxy stats card now shows the configured backend name (e.g.
"My Backend/web1") instead of the internal HAProxy name
("eg_backend_1/web1"). Also surfaces the socket error message when
HAProxy is unreachable (⚠ HAProxy socket: <error>) instead of the
generic "no backends" empty state — operators immediately see whether
the box is unconfigured or the socket is down. v1.1.114.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 07:59:53 +02:00
Debian
67c5180168 fix(haproxy-stats): add stats socket to template + fix safeAt index-0 bug
Two root causes for Dashboard backends not showing:
1. haproxy.cfg.tpl had no stats socket directive → /run/haproxy/admin.sock
   never created → handler got "connection refused" → empty array returned.
   Fixed: added stats socket /run/haproxy/admin.sock mode 0660 level admin.
2. safeAt() used i<=0 instead of i<0 → pxname (column 0) always returned ""
   → all backend names were empty in the API response.
After upgrade: HAProxy reload (triggered by any domain/backend save, or
edgeguard-ctl render-config) will create the socket. v1.1.113.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 07:36:31 +02:00
Debian
da554e9e82 feat(alerts): severity + kind filter selects on Events tab
Operators can now narrow the history table by severity (INFO/WARNING/
ERROR/CRITICAL) and by alert kind (dynamically derived from loaded
events, searchable). Filtering is client-side — no extra API calls.
v1.1.112.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 20:07:50 +02:00
Debian
326f79cf00 feat(dashboard): WG server tunnels show peer summary instead of per-peer rows
Server tunnels with many peers previously flooded the WG card. Now
server mode shows "X/Y peers online · ▼rx ▲tx" (3-min handshake
threshold) while client tunnels retain full per-peer detail.
Adds i18n keys dashboard.wgCard.peersOnline (EN/DE). v1.1.111.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 20:01:49 +02:00
Debian
4406f9ac5b feat(scheduler): WireGuard-Client-Tunnel-Down-Alert + dedupe-Korrekturen — v1.1.110
- runWGClientTunnelCheck() prüft alle aktiven Client-Tunnels (mode='client')
  alle 5 Min; feuert Error-Alert wenn kein Handshake seit >5 Min oder
  noch nie (12h dedupe pro Tunnel-Name)
- Dedupe-Angaben in alerts.scopeDesc korrigiert: war "1h" für mem/conntrack/ntp,
  tatsächlich 12h (shared alertDedupe) — beide Sprachen bereinigt
- wg.tunnel.down-Trigger in beiden i18n-Dateien dokumentiert

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 19:16:03 +02:00
Debian
feae18772c feat(scheduler): NTP-Sync-Alert — warnt wenn chrony keine Zeitquelle hat — v1.1.109
- runNTPSyncCheck() läuft alle 10 Minuten: ruft chronyc tracking auf,
  prüft ob Stratum 0 / ≥16 oder Reference ID 00000000 — feuert
  Warning mit Fix-Hints (1h dedupe)
- Initial-Check absichtlich NICHT beim Boot, da chrony nach dem Start
  einige Sekunden zur ersten Synchronisation braucht
- ntp.unsync-Trigger in beiden i18n-Dateien dokumentiert

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 17:40:16 +02:00
Debian
b9dfab6664 feat(scheduler): Conntrack-Alert — silent-drop Warnung bevor Tabelle voll läuft — v1.1.108
- runConntrackCheck() liest /proc/sys/net/netfilter/nf_conntrack_count +
  nf_conntrack_max, feuert Warning bei ≥80% und Critical bei ≥90%
  (2-Min-Takt, 1h dedupe pro Severity-Key)
- Bei 100% Auslastung dropped nftables alle neuen Verbindungen lautlos;
  der Alert kommt damit rechtzeitig bevor das passiert
- conntrack.high-Trigger in beiden i18n-Dateien dokumentiert

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 06:21:16 +02:00
Debian
ac6c580318 feat(scheduler+ntp): Memory-Alerts + NTP-Status freq/rms — v1.1.107
- Scheduler: runMemoryCheck() liest /proc/meminfo, feuert Warning bei ≥85% und Critical bei ≥95% RAM-Auslastung (5-Min-Intervall, 1h dedupe pro Severity-Key)
- NTP-Status-Karte: freq_ppm (±ppm mit Warnung bei >100) und rms_offset_ms werden jetzt angezeigt — Felder waren im Backend schon vorhanden, aber nie gerendert
- Alerts scopeDesc: mem.high-Trigger in beiden i18n-Dateien dokumentiert

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 05:54:57 +02:00
Debian
99b4225e64 feat(alerts): backend.down trigger — HAProxy socket check every 2 min, 12h dedupe
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 17:30:49 +02:00
Debian
711c4c7eb1 feat(fwd-proxy): Squid cache stats card — GET /forward-proxy/stats + squidclient mgr:counters
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 16:47:31 +02:00
Debian
f29d74bad7 feat(fwd-proxy): move-up/down buttons for ACL priority ordering
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 13:36:22 +02:00
Debian
f6615fd27d feat(dns): Resolver stats tab + NAT rule move buttons
- GET /dns/stats via unbound-control stats_noreset: total queries,
  cache hits/miss, hit-rate progress bar, recursive replies, prefetch,
  rate-limited, unwanted, RRset/msg cache memory
- DNS page: new "Resolver stats" tab with 30s auto-refresh
- NAT rules: ↑↓ move buttons (same pattern as firewall rules 1.1.102)

v1.1.103

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 13:20:07 +02:00
Debian
844f9ddc83 feat(firewall): Move-up/down buttons for rule priority reordering
Adds ↑↓ icon buttons in the firewall rules table. Clicking swaps
the rule's priority value with its sorted neighbor via two sequential
PUTs. First rule's ↑ and last rule's ↓ are disabled. Viewer role
cannot reorder.

v1.1.102

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 12:07:10 +02:00
Debian
4629679ba9 feat(ntp): Live peer status tab (chronyc sources)
Adds GET /ntp/sources endpoint (runs chronyc sources, parses tabular
output) and a new "Peer status" tab in the NTP page showing all
configured peers with mode, state badge, stratum, poll interval,
reach register, last-rx and offset/error sample.

v1.1.101

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 11:57:04 +02:00
Debian
57b9cd89b2 feat(settings): Copy + Download buttons for config preview
v1.1.100

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 10:48:45 +02:00
Debian
757b58f607 feat(ux): Hostname in Sidebar + CPU-Anzahl neben Load Average
- Sidebar: Hostname unter der Versionsnummer (aus /system/health);
  10px, gedimmt — beim Verwalten mehrerer Boxen sofort erkennbar
- /system/resources: neues Feld num_cpus (via runtime.NumCPU)
- Dashboard Resources-Card: Load-Average-Label zeigt "(N CPU)" —
  1.80 auf 1 CPU ist saturiert, auf 4 CPUs ist es harmlos

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 09:49:15 +02:00
Debian
c99b0cef4c feat(system): health-Endpoint liefert jetzt hostname + OS + kernel
- /system/health gibt zusätzlich hostname, os (PRETTY_NAME aus
  /etc/os-release) und kernel (/proc/version, ohne Build-Details) zurück
- Settings-Seite: SystemHealth-Interface erweitert, System-Info-Card
  zeigt Hostname, Betriebssystem und Kernel-Version an (conditional,
  nur wenn vorhanden)
- Nützlich beim Verwalten mehrerer Boxen — ohne SSH sofort sehen
  welches System gerade geöffnet ist

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 06:43:56 +02:00
Debian
cfb0e9ed01 feat(config-preview): chrony + wireguard in Config-Preview verfügbar
- chrony.RenderToString(): rendert /etc/chrony/conf.d/edgeguard.conf
  ohne Datei-Write oder Service-Reload
- wireguard.RenderToString(): kombiniert alle aktiven Interface-Configs;
  PrivateKey + PresharedKey werden als <redacted> ausgegeben (sicher für
  UI-Anzeige)
- main.go: beide in WithConfigPreviewers eingetragen
- Settings UI: chrony + wireguard im Generator-Dropdown

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 06:28:48 +02:00
Debian
73619c17f8 fix(system): render-configs rendert jetzt ALLE Dienste, nicht nur HAProxy
- SystemHandler.ExtraReloaders: neues Feld für nftables, wireguard,
  squid, unbound, chrony Reloader
- WithAllReloaders(): Wire-Methode; main.go bindet alle Reloader nach
  Initialisierung ein
- RenderConfigs: läuft alle Reloader durch, sammelt Fehler, antwortet
  mit {ok, rendered[], errors{}} — partiell OK wird als warning gemeldet
- UI: Settings-Button-Label "Re-render all configs"; partial-error
  Toast zeigt welche Dienste fehlschlugen
- Timeout: 30s statt 10s (alle Dienste brauchen mehr Zeit)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 22:29:00 +02:00
Debian
8d7a43bc8c feat(wireguard): selektiver .conf-Import — Auswahl einzelner Interfaces per Modal
- GET /wireguard/importable listet alle .conf-Dateien in /etc/wireguard/
  mit already_in-Flag (bereits in DB)
- POST /wireguard/import nimmt optionale Names-Liste; ohne Namen → alles
- edgeguard-ctl wg-import [iface…] importiert nur die genannten Interfaces
- UI: Checkbox-Modal mit "Alle neuen auswählen" + Einzelauswahl;
  bereits importierte Interfaces disabled + grüner Tag

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 22:09:37 +02:00
Debian
ac068bc9dd feat(dns+ntp): DNS-Cache-Flush + NTP-Force-Sync — operative Aktionen (1.1.94)
Backend: POST /dns/flush-cache (unbound-control flush_zone .)
         POST /ntp/force-sync  (chronyc makestep)
Beide werden im Audit-Log festgehalten.
UI: Schaltflächen in DNS-Settings und NTP-Settings neben Save,
    mit Tooltip-Beschreibung + i18n (de+en).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 16:52:20 +02:00
Debian
0856c2fc10 feat(rbac): RBAC-Audit abschliessen — Viewer-Sperre für alle verbleibenden Seiten (1.1.93)
Domains, Backends (inkl. ServerPanel), Backups/History und Alerts:
isViewer-Check + disabled Switches, Tooltip-wrapped Add-Buttons,
bedingte Delete/Restore-Render (Viewer → disabled Tooltip statt Popconfirm).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 16:39:24 +02:00
Debian
d3a3b93c1b feat(rbac): Viewer-Rolle auf alle verbleibenden CRUD-Seiten durchsetzen — v1.1.92
DNS, NTP, ForwardProxy, RoutingRules, Networks (Interfaces + Routes),
IPAddresses, WireGuard (Servers + Clients + Peers), Backups/RemoteTargets, SSL:
Add-Buttons, quickToggle-Switches und Settings-Save-Buttons für Viewer deaktiviert.
ActionButtons-Komponente schützt Edit/Delete bereits zentral; hier wurden nur
die verbleibenden Mutationspunkte (Add, Switch, Submit) nachgezogen.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 13:56:24 +02:00
Debian
d4267e2003 feat(rbac): Viewer-Rolle auf alle Firewall-CRUD-Seiten durchsetzen — v1.1.91
AddressObjects, AddressGroups, Services, ServiceGroups, Rules, NATRules, Zones:
Add/Edit-Buttons disabled + Tooltip, Delete bedingt gerendert (viewer vs. Popconfirm).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 13:20:54 +02:00
Debian
8b0e8a8ab4 fix(rbac): Viewer-Rolle in Settings + Cluster — operative Buttons sperren
Settings: HAProxy-Reload, Render-Configs, Backup-Now, Service-Restart,
E-Mail-Save, Maintenance-Toggle, Backup-/Audit-Retention, Auto-Update-
und IPv6-Toggle disabled für Viewer (Passwort-Änderung bleibt aktiv).
Cluster: Join-Token-Generation, Peer-Removal, mTLS-Cert-Renew disabled.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 13:14:51 +02:00
180 changed files with 21609 additions and 2280 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.1.89
1.2.94

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"
@@ -40,6 +42,7 @@ import (
"git.netcell-it.de/projekte/edgeguard-native/internal/aggregator"
"git.netcell-it.de/projekte/edgeguard-native/internal/cluster/clustertls"
"git.netcell-it.de/projekte/edgeguard-native/internal/cluster/jointoken"
"git.netcell-it.de/projekte/edgeguard-native/internal/services/clusterjoin"
aptsvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/apt"
"git.netcell-it.de/projekte/edgeguard-native/internal/services/domainheaders"
"git.netcell-it.de/projekte/edgeguard-native/internal/services/domains"
@@ -57,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.1.89"
var version = "1.2.35"
func main() {
addr := os.Getenv("EDGEGUARD_API_ADDR")
@@ -111,7 +118,7 @@ func main() {
requireAuth := handlers.RequireAuth(signer)
setupHdl := handlers.NewSetupHandler(setupStore)
setupHdl := handlers.NewSetupHandler(setupStore).WithVersion(version)
setupHdl.Register(v1)
// systemHdl exists früh damit sowohl der frühe (DB-pool nicht
@@ -175,6 +182,20 @@ func main() {
go runClusterHeartbeat(context.Background(), pool, nodeID, version)
}
// Secondary: push config_hash to primary every 5 min so the primary's
// ha_nodes reflects actual state. Without this, the primary retains the
// stale hash written at join-time and the drift banner never clears.
// st.IsClusterNode + PrimaryFQDN are only set on joined secondary nodes.
if nodeID != "" && st != nil && st.IsClusterNode && st.PrimaryFQDN != "" {
if primaryURL, normErr := clusterjoin.NormalizePrimaryURL(st.PrimaryFQDN); normErr == nil {
go runPrimaryPush(context.Background(), pool, nodeID, st.FQDN, version, primaryURL)
} else {
slog.Warn("cluster: cannot normalize primary URL for push", "primary", st.PrimaryFQDN, "error", normErr)
}
// runSecondaryConfigRender wird weiter unten gestartet sobald
// clusterAggregator verfügbar ist (braucht mTLS-Client für Cert-Sync).
}
// Phase 3.3: Cluster-CA + Peer-Cert. Founder-Pfad — auf einem
// frisch installierten Single-Node generieren wir die CA und
// signieren uns selbst, damit der Agent-Listener auf :8443
@@ -219,6 +240,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)
@@ -268,14 +295,23 @@ func main() {
systemHdl.WithAudit(auditRepo, nodeID)
systemHdl.WithDB(pool)
systemHdl.WithConfigPreviewers(map[string]func(context.Context) (string, error){
"haproxy": haproxy.New(pool).RenderToString,
"nftables": firewallrender.New(pool).RenderToString,
"squid": squidrender.New(pool).RenderToString,
"unbound": unboundrender.New(pool).RenderToString,
"haproxy": haproxy.New(pool).RenderToString,
"nftables": firewallrender.New(pool).RenderToString,
"squid": squidrender.New(pool).RenderToString,
"unbound": unboundrender.New(pool).RenderToString,
"chrony": chronyrender.New(pool).RenderToString,
"wireguard": wgrender.New(pool, secretsBox).RenderToString,
})
setupHdl.WithAudit(auditRepo, nodeID)
setupHdl.WithClusterSupport(clusterStore, func(ctx context.Context) error {
return firewallrender.New(pool).Render(ctx)
})
// Cluster-Node-Startup: Primary in lokalen ha_nodes eintragen damit
// nftables @peer_ipv4 korrekt ist — auch ohne erneuten Join.
go setupHdl.StartupPeerSync()
usersRepo := usersvc.New(pool)
authHdl.WithAudit(auditRepo, nodeID).WithUsers(usersRepo)
authHdl.WithAudit(auditRepo, nodeID).WithUsers(usersRepo).WithClusterTLS(clusterTLSStore)
systemHdl.WithUsers(usersRepo)
haproxyReloader := func(ctx context.Context) error {
return haproxy.New(pool).Render(ctx)
@@ -285,6 +321,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)
@@ -315,7 +359,9 @@ func main() {
clusterHdl := handlers.NewClusterHandler(clusterStore, nodeID).
WithAggregator(clusterAggregator).
WithJoinFlow(clusterTLSStore, joinTokens).
WithPeerReloader(peerReloader)
WithPeerReloader(peerReloader).
WithAudit(auditRepo, nodeID).
WithVersion(version)
clusterHdl.Register(authed)
// /cluster/issue-cert läuft PUBLIC — joining Peer hat noch
// keine Session/Cert. Token + Nonce-Tracking ist die einzige
@@ -351,6 +397,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
@@ -401,6 +449,30 @@ 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,
"kea": keaReloader,
"freeradius": radiusReloader,
})
// License — node-local key store + DB-mirror of last verify
// result. Real verify runs against license.netcell-it.com via
// internal/license; the scheduler triggers daily re-verify.
@@ -436,7 +508,11 @@ func main() {
// Listener wird nur gestartet wenn Cert-Material vorhanden ist;
// auf einer frisch installierten Box hat die Init-Phase oben das
// schon erledigt.
startAgentListener(version, agentHdl)
startAgentListener(version, agentHdl, systemHdl)
// Nach einem Upgrade-Neustart: wenn die State-Datei "updating-primary"
// enthält, sind wir gerade neu gestartet → Update abgeschlossen → "done".
handlers.FinishRollingUpdateIfPending()
log.Printf("edgeguard-api %s listening on %s", version, addr)
srv := &http.Server{Addr: addr, Handler: r}
@@ -450,7 +526,7 @@ func main() {
// RegisterAgent — health + resources). Fehler im Cert-Load = no-op
// + log; Fehler beim Listen.Serve loggen wir aber lassen die API
// weiterlaufen.
func startAgentListener(version string, clusterHdl *handlers.ClusterHandler) {
func startAgentListener(version string, clusterHdl *handlers.ClusterHandler, sysHdl *handlers.SystemHandler) {
store := clustertls.New("")
serverTLS, err := store.ServerTLSConfig()
if err != nil {
@@ -470,7 +546,13 @@ func startAgentListener(version string, clusterHdl *handlers.ClusterHandler) {
// hier implizit aus dem Binary (Peer-Roundtrip ist immer same-major).
// Aggregator-Aufrufer sehen /agent/... direkt.
root := r.Group("")
handlers.NewSystemHandler(version).RegisterAgent(root)
// Nutze den gewiredeten systemHdl (mit Users + Setup) damit
// AgentAuthCheck Credentials gegen die echte DB prüfen kann.
if sysHdl != nil {
sysHdl.RegisterAgent(root)
} else {
handlers.NewSystemHandler(version).RegisterAgent(root)
}
if clusterHdl != nil {
// Phase 3.5: /agent/cluster/peers (Auto-Register).
clusterHdl.RegisterAgent(root)
@@ -535,10 +617,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)
})
}
@@ -655,6 +745,116 @@ func runClusterHeartbeat(ctx context.Context, pool *pgxpoolPool, localID, versio
}
}
// runSecondaryConfigRender läuft auf Secondary-Nodes und re-rendert alle
// 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.
//
// 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, 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
}
lastHash = hash
slog.Info("cluster: secondary config changed via replication, re-rendering", "hash", hash)
// HAProxy
if err := haproxy.New(pool).Render(rCtx); err != nil {
slog.Warn("cluster: secondary haproxy render failed", "error", err)
}
// nftables
if err := firewallrender.New(pool).Render(rCtx); err != nil {
slog.Warn("cluster: secondary nftables render failed", "error", err)
}
// 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 {
case <-ctx.Done():
return
case <-time.After(30 * time.Second):
render()
}
for {
select {
case <-ctx.Done():
return
case <-t.C:
render()
}
}
}
// 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.
func runPrimaryPush(ctx context.Context, pool *pgxpoolPool, nodeID, fqdn, version, primaryURL string) {
const tick = 5 * time.Minute
t := time.NewTicker(tick)
defer t.Stop()
push := func() {
pCtx, cancel := context.WithTimeout(ctx, 15*time.Second)
defer cancel()
hash, _ := cluster.ComputeConfigHash(pCtx, pool)
if err := clusterjoin.PushSelfToPrimary(primaryURL, "", nodeID, fqdn, version, hash); err != nil {
slog.Warn("cluster: push-to-primary failed", "error", err)
} else {
slog.Debug("cluster: config_hash pushed to primary", "hash", hash)
}
}
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

@@ -1,43 +1,14 @@
package main
import (
"bytes"
"crypto/tls"
"crypto/x509"
"encoding/json"
"errors"
"flag"
"fmt"
"io"
"net"
"net/http"
"net/url"
"os"
"strings"
"time"
"git.netcell-it.de/projekte/edgeguard-native/internal/cluster/clustertls"
"git.netcell-it.de/projekte/edgeguard-native/internal/services/clusterjoin"
)
// cmdClusterJoin: provisioniert auf diesem Node das Cluster-Cert-
// Material durch einen Aufruf an /api/v1/cluster/issue-cert beim
// Primary.
//
// Usage:
// edgeguard-ctl cluster-join <primary-fqdn-or-url> --token <token>
// [--insecure]
// [--cn <fqdn>]
//
// --insecure: TLS-Verify überspringen (für Bootstrap wenn der
// Primary mit self-signed Cert läuft und die CA noch
// nicht woanders verteilt ist — der Cert-Issue-Flow
// selbst läuft über HMAC-Token, nicht über TLS-Trust).
// --cn: Subject-CN für unseren CSR. Default: os.Hostname().
//
// Output: schreibt ca.crt + peer.{crt,key} nach /var/lib/edgeguard/
// cluster-tls/. Falls Cert-Material schon vorhanden, abort mit
// hint auf manuellen rm — wir wollen nicht aus Versehen einen
// laufenden Cluster-Node von seiner identity bringen.
func cmdClusterJoin(args []string) int {
fs := flag.NewFlagSet("cluster-join", flag.ContinueOnError)
tokenFlag := fs.String("token", "", "cluster join token (eg-join-v1.…)")
@@ -52,93 +23,32 @@ func cmdClusterJoin(args []string) int {
fmt.Fprintln(os.Stderr, "usage: edgeguard-ctl cluster-join <primary-fqdn-or-url> --token <…>")
return 2
}
primary := fs.Arg(0)
if *tokenFlag == "" {
fmt.Fprintln(os.Stderr, "edgeguard-ctl cluster-join: --token required")
return 2
}
store := clustertls.New(*clusterTLSDir)
if store.HasPeer() {
fmt.Fprintf(os.Stderr,
"edgeguard-ctl cluster-join: peer cert already present under %s — "+
"refuse to overwrite. Run 'rm -rf %s' first if this is intentional.\n",
*clusterTLSDir, *clusterTLSDir)
return 1
}
commonName := *cn
if commonName == "" {
h, _ := os.Hostname()
commonName = h
}
if commonName == "" {
commonName = "edgeguard-node"
}
endpoint, err := normalizePrimaryURL(primary)
if err != nil {
if err := clusterjoin.Join(clusterjoin.Request{
PrimaryFQDN: fs.Arg(0),
Token: *tokenFlag,
CommonName: commonName,
Insecure: *insecure,
TLSDir: *clusterTLSDir,
Version: version,
}); err != nil {
fmt.Fprintf(os.Stderr, "edgeguard-ctl cluster-join: %v\n", err)
return 1
}
// SAN: gleicher CN + Hostname. IPs hängen wir an wenn das Host-
// Argument eine IP war, damit der lokale Agent-Listener auch
// gegen IP gechecked werden kann.
dnsNames := []string{commonName}
var ips []net.IP
if ip := net.ParseIP(commonName); ip != nil {
ips = append(ips, ip)
// Wenn CN eine IP ist, lassen wir DNSNames leer — RFC 6125
// erlaubt nicht beides als-ob-DNS.
dnsNames = nil
}
keyPEM, csrPEM, err := clustertls.NewPeerKeyAndCSR(commonName, dnsNames, ips)
if err != nil {
fmt.Fprintf(os.Stderr, "edgeguard-ctl cluster-join: gen CSR: %v\n", err)
return 1
}
caCertPEM, peerCertPEM, err := postIssueCert(endpoint, *tokenFlag, csrPEM, *insecure)
if err != nil {
fmt.Fprintf(os.Stderr, "edgeguard-ctl cluster-join: issue-cert: %v\n", err)
return 1
}
if err := os.MkdirAll(*clusterTLSDir, 0o700); err != nil {
fmt.Fprintf(os.Stderr, "edgeguard-ctl cluster-join: mkdir %s: %v\n", *clusterTLSDir, err)
return 1
}
// Schreiben in stabiler Reihenfolge: erst CA (wird vom Peer-Cert-
// Verify gebraucht), dann peer.{crt,key}.
for _, w := range []struct {
name string
mode os.FileMode
data string
}{
{"ca.crt", 0o644, caCertPEM},
{"peer.crt", 0o644, peerCertPEM},
{"peer.key", 0o600, keyPEM},
} {
path := *clusterTLSDir + "/" + w.name
if err := os.WriteFile(path, []byte(w.data), w.mode); err != nil {
fmt.Fprintf(os.Stderr, "edgeguard-ctl cluster-join: write %s: %v\n", path, err)
return 1
}
}
// Phase 3.5: Auto-Register beim Primary. Nutzt das frisch erhaltene
// Peer-Cert via mTLS, damit der Primary uns in ha_nodes mit
// status='joining' anlegt + sein peer_ipv4-Set updated.
if err := autoRegister(endpoint, *clusterTLSDir, commonName); err != nil {
fmt.Fprintf(os.Stderr,
"edgeguard-ctl cluster-join: auto-register failed (Cert-Material liegt aber schon — kannst manuell nachholen): %v\n", err)
// Wir geben hier NICHT-NULL zurück — der Cert-Issue war ja
// erfolgreich. Der Operator kann manuell registrieren oder
// es funktioniert beim Service-Start (Phase 3.2 Heartbeat).
}
primary, _ := clusterjoin.NormalizePrimaryURL(fs.Arg(0))
fmt.Printf("Cluster-Join erfolgreich.\n")
fmt.Printf(" Primary: %s\n", endpoint)
fmt.Printf(" Primary: %s\n", primary)
fmt.Printf(" CN: %s\n", commonName)
fmt.Printf(" Files: %s/{ca.crt,peer.crt,peer.key}\n", *clusterTLSDir)
fmt.Printf("\nNächste Schritte:\n")
@@ -147,150 +57,3 @@ func cmdClusterJoin(args []string) int {
fmt.Printf(" 3) PG-Basebackup + KeyDB-Replica-Setup folgt mit Phase 3.5 (manuell bis dahin)\n")
return 0
}
// autoRegister: POST mTLS an <primary-host>:8443/agent/cluster/peers.
// Note: der mTLS-Agent-Port :8443 ist anders als der Public-Port
// (3443). Wir leiten den Host aus der primary-URL ab und ersetzen
// den Port.
func autoRegister(primary, tlsDir, commonName string) error {
// Primary-URL parse + Port-Override
u, err := url.Parse(primary)
if err != nil {
return err
}
u.Host = u.Hostname() + ":8443"
u.Path = "/agent/cluster/peers"
// Local node-id + body bauen. node-id liegt in /var/lib/edgeguard/
// node-id (vom Heartbeat-Subsystem persistiert); wir lesen direkt
// statt cluster.EnsureNodeID() um den DB-Abhängigkeit-Pfad nicht
// zu öffnen.
nodeID, _ := os.ReadFile("/var/lib/edgeguard/node-id")
hostname, _ := os.Hostname()
body, _ := json.Marshal(map[string]string{
"id": strings.TrimSpace(string(nodeID)),
"name": hostname,
"fqdn": commonName,
"api_url": "https://" + commonName + ":3443",
"version": version,
})
// mTLS-Client mit gerade frisch geschriebenem Material.
pair, err := tls.LoadX509KeyPair(tlsDir+"/peer.crt", tlsDir+"/peer.key")
if err != nil {
return fmt.Errorf("load peer cert: %w", err)
}
caPEM, err := os.ReadFile(tlsDir + "/ca.crt")
if err != nil {
return fmt.Errorf("read ca: %w", err)
}
pool := x509.NewCertPool()
if !pool.AppendCertsFromPEM(caPEM) {
return errors.New("invalid ca.crt")
}
tr := &http.Transport{
TLSClientConfig: &tls.Config{
Certificates: []tls.Certificate{pair},
RootCAs: pool,
MinVersion: tls.VersionTLS13,
// Hostname-Verify: wir checken gegen den CN/SAN des
// Primary-Cert. Wenn der Primary-Cert das nicht hat
// (Self-Signed for IP only), kann der join trotzdem
// erfolgreich sein wenn das CA-Cert validiert.
ServerName: u.Hostname(),
},
TLSHandshakeTimeout: 5 * time.Second,
ResponseHeaderTimeout: 10 * time.Second,
}
client := &http.Client{Transport: tr, Timeout: 30 * time.Second}
req, err := http.NewRequest(http.MethodPost, u.String(), bytes.NewReader(body))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
resp, err := client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(raw)))
}
return nil
}
// normalizePrimaryURL: nimmt "fqdn", "host:port" oder "https://host:port"
// und liefert immer "https://host:port" zurück. Default-Port 3443 (das
// ist der Mgmt-UI-Listener; /cluster/issue-cert läuft dort).
func normalizePrimaryURL(in string) (string, error) {
in = strings.TrimSpace(in)
if in == "" {
return "", errors.New("empty primary fqdn/url")
}
if !strings.HasPrefix(in, "http://") && !strings.HasPrefix(in, "https://") {
in = "https://" + in
}
u, err := url.Parse(in)
if err != nil {
return "", err
}
if u.Hostname() == "" {
return "", errors.New("primary URL has no host")
}
if u.Port() == "" {
u.Host = u.Hostname() + ":3443"
}
u.Path = ""
u.RawQuery = ""
u.Fragment = ""
return u.String(), nil
}
// postIssueCert: POSTet {token, csr} an <primary>/api/v1/cluster/issue-cert.
// `insecure` skippt TLS-Verify damit der Bootstrap auch wenn der Primary
// mit self-signed Cert hört durchgeht — die Sicherheit hängt am HMAC-
// gesigneten Token, nicht am TLS-Layer.
func postIssueCert(primary, token, csr string, insecure bool) (caCert, peerCert string, err error) {
body, _ := json.Marshal(map[string]string{"token": token, "csr": csr})
req, err := http.NewRequest(http.MethodPost,
primary+"/api/v1/cluster/issue-cert", bytes.NewReader(body))
if err != nil {
return "", "", err
}
req.Header.Set("Content-Type", "application/json")
tr := &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: insecure, MinVersion: tls.VersionTLS12},
TLSHandshakeTimeout: 5 * time.Second,
ResponseHeaderTimeout: 10 * time.Second,
}
client := &http.Client{Transport: tr, Timeout: 30 * time.Second}
resp, err := client.Do(req)
if err != nil {
return "", "", err
}
defer resp.Body.Close()
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if resp.StatusCode != http.StatusOK {
return "", "", fmt.Errorf("HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(raw)))
}
var env struct {
Data struct {
CACert string `json:"ca_cert"`
PeerCert string `json:"peer_cert"`
} `json:"data"`
Error string `json:"error"`
}
if err := json.Unmarshal(raw, &env); err != nil {
return "", "", fmt.Errorf("decode response: %w", err)
}
if env.Error != "" {
return "", "", fmt.Errorf("server: %s", env.Error)
}
if env.Data.CACert == "" || env.Data.PeerCert == "" {
return "", "", errors.New("response missing ca_cert or peer_cert")
}
return env.Data.CACert, env.Data.PeerCert, nil
}

View File

@@ -0,0 +1,589 @@
package main
import (
"crypto/rand"
"crypto/tls"
"crypto/x509"
"encoding/json"
"flag"
"fmt"
"net"
"net/http"
"os"
"os/exec"
"os/user"
"path/filepath"
"strconv"
"strings"
"time"
"git.netcell-it.de/projekte/edgeguard-native/internal/cluster/clustertls"
)
const (
egReplSecret = "/var/lib/edgeguard/pg-replication-secret"
egReplUser = "edgeguard_replicator"
egPubName = "edgeguard_shared"
egSubName = "edgeguard_sub"
)
// pgConfig hält die zur Laufzeit erkannten PG-Pfade.
type pgConfig struct {
Version string // z.B. "17"
Cluster string // z.B. "main"
DataDir string // /var/lib/postgresql/17/main
HBAPath string // /etc/postgresql/17/main/pg_hba.conf
ConfD string // /etc/postgresql/17/main/conf.d
}
// detectPGConfig ermittelt Version, Cluster und Pfade aus der laufenden
// PG-Instanz via SHOW hba_file / SHOW data_directory. Damit ist der Code
// unabhängig von der PG-Hauptversion (16, 17, …).
func detectPGConfig() (pgConfig, error) {
hbaRaw, err := psqlRun([]string{"-tA", "-c", "SHOW hba_file;"})
if err != nil {
return pgConfig{}, fmt.Errorf("cannot detect pg hba_file: %w", err)
}
hbaPath := strings.TrimSpace(string(hbaRaw))
dataRaw, err := psqlRun([]string{"-tA", "-c", "SHOW data_directory;"})
if err != nil {
return pgConfig{}, fmt.Errorf("cannot detect pg data_directory: %w", err)
}
dataDir := strings.TrimSpace(string(dataRaw))
// hbaPath: /etc/postgresql/<version>/<cluster>/pg_hba.conf
parts := strings.Split(filepath.ToSlash(hbaPath), "/")
if len(parts) < 6 {
return pgConfig{}, fmt.Errorf("unexpected hba_file path: %s", hbaPath)
}
version := parts[3]
cluster := parts[4]
confD := filepath.Join("/etc/postgresql", version, cluster, "conf.d")
return pgConfig{
Version: version,
Cluster: cluster,
DataDir: dataDir,
HBAPath: hbaPath,
ConfD: confD,
}, nil
}
// localOnlyTables listet alle Tabellen die nicht in die Replikations-
// Publication aufgenommen werden. Alles andere wird automatisch repliziert.
var localOnlyTables = []string{
"ha_nodes", // Node-Identität, Status
"network_interfaces", // Eigene Interfaces (eth0, eth1 …)
"ip_addresses", // Eigene IP-Adressen (unterschiedlich pro Node!)
"static_routes", // Node-spezifisches Routing
"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
"alert_events", // Lokale Laufzeit-Events
"backups", // Backup-Historie ist per-Node
"goose_db_version", // Migration-Tracking, internes Tool-State
}
// cmdClusterInitReplication richtet PG auf dieser Node als Logical-Replication-
// Primary ein. Idempotent — kann gefahrlos mehrfach laufen.
//
// Ablauf:
// 1. edgeguard_replicator-Rolle anlegen/aktualisieren
// 2. Passwort → /var/lib/edgeguard/pg-replication-secret
// 3. conf.d/edgeguard-replication.conf mit wal_level=logical schreiben
// 4. pg_hba.conf für Replikations-Verbindungen aktualisieren
// 5. SELECT-Grants auf alle geteilten Tabellen
// 6. PUBLICATION erstellen (alle Tabellen außer localOnlyTables)
// 7. PG reload
func cmdClusterInitReplication(args []string) int {
fs := flag.NewFlagSet("cluster-init-replication", flag.ContinueOnError)
fs.SetOutput(os.Stderr)
if err := fs.Parse(args); err != nil {
return 2
}
pg, err := detectPGConfig()
if err != nil {
fmt.Fprintln(os.Stderr, "cluster-init-replication: PG-Erkennung:", err)
return 1
}
fmt.Printf("→ PostgreSQL %s/%s erkannt\n", pg.Version, pg.Cluster)
// 1. Passwort generieren
pass, err := generatePassword(32)
if err != nil {
fmt.Fprintln(os.Stderr, "cluster-init-replication: generate password:", err)
return 1
}
// 2. edgeguard_replicator-Rolle anlegen/updaten
roleSQL := fmt.Sprintf(`DO $$
BEGIN
IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = '%s') THEN
CREATE ROLE %s REPLICATION LOGIN PASSWORD '%s';
ELSE
ALTER ROLE %s PASSWORD '%s';
END IF;
END
$$`, egReplUser, egReplUser, pass, egReplUser, pass)
if err := psqlExec(roleSQL); err != nil {
fmt.Fprintln(os.Stderr, "cluster-init-replication: create replication role:", err)
return 1
}
fmt.Printf("✓ Replication-Rolle %q angelegt/aktualisiert\n", egReplUser)
// 3. Passwort speichern
if err := os.MkdirAll(filepath.Dir(egReplSecret), 0o750); err != nil {
fmt.Fprintln(os.Stderr, "cluster-init-replication: mkdir:", err)
return 1
}
if err := os.WriteFile(egReplSecret, []byte(pass), 0o600); err != nil {
fmt.Fprintln(os.Stderr, "cluster-init-replication: write secret:", err)
return 1
}
// Ownership an edgeguard-api-User übergeben damit die API lesen kann
if u, err := user.Lookup("edgeguard"); err == nil {
uid, _ := strconv.Atoi(u.Uid)
gid, _ := strconv.Atoi(u.Gid)
_ = os.Chown(egReplSecret, uid, gid)
}
fmt.Printf("✓ Replication-Secret gespeichert: %s\n", egReplSecret)
// 4. conf.d/edgeguard-replication.conf schreiben
// wal_level=logical ist eine Obermenge von replica — unterstützt
// sowohl Logical Replication als auch ggfs. physisches WAL-Archiving.
if err := os.MkdirAll(pg.ConfD, 0o755); err != nil {
fmt.Fprintln(os.Stderr, "cluster-init-replication: conf.d mkdir:", err)
return 1
}
replConf := `# EdgeGuard Logical Replication — automatisch generiert
# Nicht manuell bearbeiten; wird von edgeguard-ctl cluster-init-replication verwaltet.
wal_level = logical
max_wal_senders = 10
max_replication_slots = 20
max_logical_replication_workers = 4
wal_keep_size = 512MB
# Lausche auf localhost + alle konfigurierten Interfaces damit Cluster-Peers
# sich verbinden können. '*' ist sicher weil pg_hba.conf den Zugriff auf
# bekannte Replikations-User beschränkt.
listen_addresses = '*'
`
confPath := filepath.Join(pg.ConfD, "edgeguard-replication.conf")
if err := os.WriteFile(confPath, []byte(replConf), 0o644); err != nil {
fmt.Fprintln(os.Stderr, "cluster-init-replication: write postgresql conf:", err)
return 1
}
fmt.Printf("✓ %s geschrieben (wal_level=logical)\n", confPath)
// 5. pg_hba.conf aktualisieren
if err := ensureHBAReplication(pg.HBAPath); err != nil {
fmt.Fprintln(os.Stderr, "cluster-init-replication: pg_hba.conf:", err)
return 1
}
fmt.Printf("✓ %s aktualisiert\n", pg.HBAPath)
// 6. PG reload (damit wal_level + pg_hba aktiv werden)
if out, err := exec.Command("pg_ctlcluster", pg.Version, pg.Cluster, "reload").CombinedOutput(); err != nil {
fmt.Fprintf(os.Stderr, "cluster-init-replication: pg reload failed: %v\n%s\n", err, out)
return 1
}
fmt.Printf("✓ PostgreSQL %s/%s neu geladen\n", pg.Version, pg.Cluster)
// 7. SELECT-Grants: edgeguard_replicator muss alle zu replizierenden
// Tabellen lesen können. DEFAULT PRIVILEGES sichert zukünftige Tabellen.
grantSQL := fmt.Sprintf(`
GRANT SELECT ON ALL TABLES IN SCHEMA public TO %s;
ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT ON TABLES TO %s;
`, egReplUser, egReplUser)
if err := psqlDBExec("edgeguard", grantSQL); err != nil {
fmt.Fprintln(os.Stderr, "cluster-init-replication: grant SELECT:", err)
return 1
}
fmt.Printf("✓ SELECT auf alle Tabellen für %q gewährt\n", egReplUser)
// 8. PUBLICATION erstellen — alle public-Tabellen außer localOnlyTables.
// Idempotent: DROP IF EXISTS + CREATE.
if err := createPublication(); err != nil {
fmt.Fprintln(os.Stderr, "cluster-init-replication: create publication:", err)
return 1
}
fmt.Printf("✓ PUBLICATION %q erstellt\n", egPubName)
fmt.Println()
fmt.Println("Nächste Schritte:")
fmt.Println(" 1) Auf dem Secondary: edgeguard-ctl cluster-setup-standby <primary-ip>")
fmt.Println(" 2) Cluster-Settings (VIP) auf BEIDEN Nodes separat konfigurieren")
fmt.Println(" → Settings → Cluster → VIP/Keepalived")
return 0
}
// createPublication baut die PUBLICATION dynamisch aus allen Tabellen
// im public-Schema minus localOnlyTables. Idempotent: löscht eine
// bestehende Publication gleichen Namens zuerst.
func createPublication() error {
// Alle Tabellen im public-Schema ermitteln
listSQL := `SELECT tablename FROM pg_tables WHERE schemaname = 'public' ORDER BY tablename`
out, err := psqlDBRun("edgeguard", []string{"-tA", "-c", listSQL})
if err != nil {
return fmt.Errorf("list tables: %w", err)
}
excluded := make(map[string]bool)
for _, t := range localOnlyTables {
excluded[t] = true
}
var tables []string
for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
t := strings.TrimSpace(line)
if t == "" || excluded[t] {
continue
}
tables = append(tables, t)
}
if len(tables) == 0 {
return fmt.Errorf("keine Tabellen für Publication gefunden")
}
dropSQL := fmt.Sprintf("DROP PUBLICATION IF EXISTS %s;", egPubName)
if err := psqlDBExec("edgeguard", dropSQL); err != nil {
return fmt.Errorf("drop old publication: %w", err)
}
createSQL := fmt.Sprintf("CREATE PUBLICATION %s FOR TABLE %s;",
egPubName, strings.Join(tables, ", "))
if err := psqlDBExec("edgeguard", createSQL); err != nil {
return fmt.Errorf("create publication: %w", err)
}
return nil
}
// ensureHBAReplication fügt Einträge für die Replikations-Verbindung
// in pg_hba.conf ein. Für Logical Replication brauchen wir einen
// normalen "host edgeguard"-Eintrag (nicht "host replication").
// Idempotent via Marker-Kommentar.
func ensureHBAReplication(hbaPath string) error {
data, err := os.ReadFile(hbaPath)
if err != nil {
return fmt.Errorf("read: %w", err)
}
const marker = "# EdgeGuard replication"
if strings.Contains(string(data), marker) {
return nil
}
entry := fmt.Sprintf(`
%s
host edgeguard %s 0.0.0.0/0 scram-sha-256
host edgeguard %s ::/0 scram-sha-256
host replication %s 0.0.0.0/0 scram-sha-256
host replication %s ::/0 scram-sha-256
`, marker, egReplUser, egReplUser, egReplUser, egReplUser)
f, err := os.OpenFile(hbaPath, os.O_APPEND|os.O_WRONLY, 0o640)
if err != nil {
return fmt.Errorf("open: %w", err)
}
defer f.Close()
_, err = f.WriteString(entry)
return err
}
// cmdClusterSetupStandby richtet diesen Node als Logical-Replication-
// Subscriber ein. Der Secondary behält seine eigene beschreibbare PG-
// Instanz — nur die geteilten Tabellen werden vom Primary repliziert.
// Node-spezifische Tabellen (Interfaces, IPs, Routen, VIP-Settings …)
// bleiben lokal und werden NICHT überschrieben. Analog zu OPNsense's
// HA-Sync: Interface-IPs und Hostname bleiben immer per-Node konfiguriert.
//
// Voraussetzungen:
// - cluster-join erfolgreich (TLS-Certs in /var/lib/edgeguard/cluster-tls/)
// - Primary hat cluster-init-replication ausgeführt
// - Dieser Node hat edgeguard-api schon gelaufen (Migrations ausgeführt)
//
// Ablauf:
// 1. Replication-Credentials via mTLS vom Primary holen
// 2. Bestehende Subscription löschen (idempotent)
// 3. SUBSCRIPTION auf Primary erstellen (copy_data=true → Initialkopiierung)
// 4. Warten bis Initialkopiierung abgeschlossen
// 5. render-config ausführen damit Service-Configs den neuen Stand reflektieren
func cmdClusterSetupStandby(args []string) int {
fs := flag.NewFlagSet("cluster-setup-standby", flag.ContinueOnError)
agentPort := fs.Int("agent-port", 8443, "mTLS agent port on primary")
tlsDir := fs.String("tls-dir", clustertls.DefaultDir, "Verzeichnis mit ca.crt + peer.{crt,key}")
fs.SetOutput(os.Stderr)
if err := fs.Parse(args); err != nil {
return 2
}
if fs.NArg() < 1 {
fmt.Fprintln(os.Stderr, "usage: edgeguard-ctl cluster-setup-standby <primary-ip-or-host>")
return 2
}
primaryHost := fs.Arg(0)
// 1. Replication-Credentials vom Primary holen
creds, err := fetchReplicationCreds(primaryHost, *agentPort, *tlsDir)
if err != nil {
fmt.Fprintf(os.Stderr, "cluster-setup-standby: replication-creds: %v\n", err)
return 1
}
fmt.Printf("✓ Replication-Credentials von %s:%d erhalten\n", primaryHost, *agentPort)
// 2. Bestehende Subscription löschen (idempotent)
dropSQL := fmt.Sprintf(`
DO $$ BEGIN
IF EXISTS (SELECT FROM pg_subscription WHERE subname = '%s') THEN
ALTER SUBSCRIPTION %s DISABLE;
ALTER SUBSCRIPTION %s SET (slot_name = NONE);
DROP SUBSCRIPTION %s;
END IF;
END $$;`, egSubName, egSubName, egSubName, egSubName)
if err := psqlDBExec("edgeguard", dropSQL); err != nil {
// Nicht fatal — wenn PG noch keine Subscription kennt ist das OK
fmt.Printf(" → keine bestehende Subscription gefunden (ok)\n")
} else {
fmt.Println("✓ Bestehende Subscription entfernt")
}
// 3. SUBSCRIPTION erstellen
// sslmode=require: Verbindung zwischen Cluster-Nodes soll immer verschlüsselt sein.
// copy_data=true: Initialkopiierung aller geteilten Tabellen vom Primary.
connStr := fmt.Sprintf(
"host=%s port=%d user=%s password=%s dbname=edgeguard sslmode=require",
creds.Host, creds.Port, creds.User, creds.Password,
)
createSQL := fmt.Sprintf(
"CREATE SUBSCRIPTION %s CONNECTION '%s' PUBLICATION %s WITH (copy_data = true, enabled = true);",
egSubName, connStr, egPubName,
)
// 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
}
fmt.Printf("✓ SUBSCRIPTION %q erstellt — Initialkopiierung läuft\n", egSubName)
// 4. Warten bis Initialkopiierung abgeschlossen
fmt.Print("→ Warte auf Initialkopiierung")
deadline := time.Now().Add(5 * time.Minute)
for time.Now().Before(deadline) {
pendingSQL := fmt.Sprintf(`
SELECT COUNT(*) FROM pg_subscription_rel
WHERE srsubid = (SELECT oid FROM pg_subscription WHERE subname = '%s')
AND srsubstate != 'r';`, egSubName)
out, err := psqlDBRun("edgeguard", []string{"-tA", "-c", pendingSQL})
if err == nil && strings.TrimSpace(string(out)) == "0" {
break
}
fmt.Print(".")
time.Sleep(3 * time.Second)
}
fmt.Println()
// Finale Prüfung
checkSQL := fmt.Sprintf(`
SELECT COUNT(*) FROM pg_subscription_rel
WHERE srsubid = (SELECT oid FROM pg_subscription WHERE subname = '%s')
AND srsubstate != 'r';`, egSubName)
if out, err := psqlDBRun("edgeguard", []string{"-tA", "-c", checkSQL}); err == nil {
if n := strings.TrimSpace(string(out)); n != "0" {
fmt.Fprintf(os.Stderr,
"cluster-setup-standby: %s Tabellen noch nicht synchronisiert — prüfe PG-Logs\n", n)
fmt.Println(" → Subscription läuft trotzdem weiter im Hintergrund")
} else {
fmt.Println("✓ Alle geteilten Tabellen synchronisiert")
}
}
// 5. Master-Key vom Primary holen — für WireGuard-Key-Entschlüsselung
fmt.Println("→ Secrets Master-Key vom Primary synchronisieren...")
if err := syncMasterKey(primaryHost, *agentPort, *tlsDir); err != nil {
fmt.Fprintf(os.Stderr, "cluster-setup-standby: master-key: %v (WireGuard-Keys können nicht entschlüsselt werden)\n", err)
} else {
fmt.Println("✓ Master-Key synchronisiert")
}
// 6. render-config ausführen — muss als edgeguard-User laufen (DB-Zugriff)
fmt.Println("→ Service-Configs neu rendern...")
if out, err := exec.Command("sudo", "-u", "edgeguard", "edgeguard-ctl", "render-config").CombinedOutput(); err != nil {
fmt.Fprintf(os.Stderr, "cluster-setup-standby: render-config: %v\n%s\n", err, out)
fmt.Println(" → Manuell nachholen: sudo -u edgeguard edgeguard-ctl render-config")
} else {
fmt.Print(string(out))
fmt.Println("✓ Service-Configs aktualisiert")
}
fmt.Println()
fmt.Println("✓ Logical Replication eingerichtet.")
fmt.Println()
fmt.Println("Was repliziert wird (automatisch, in Echtzeit):")
fmt.Println(" Domains, Backends, Firewall-Rules, WireGuard, DNS-Zones,")
fmt.Println(" TLS-Certs, Users, Forward-Proxy, NTP-Pools, ...")
fmt.Println()
fmt.Println("Was NICHT repliziert wird (bleibt pro Node konfiguriert):")
fmt.Println(" Netzwerk-Interfaces, IP-Adressen, Routen,")
fmt.Println(" Cluster-Settings (VIP-Interface!), DNS/NTP-Listen-Adressen")
fmt.Println()
fmt.Println("Nächste Schritte:")
fmt.Println(" 1) sudo systemctl restart edgeguard-api")
fmt.Println(" 2) VIP/Keepalived auf BEIDEN Nodes separat konfigurieren:")
fmt.Println(" Settings → Cluster → VIP/Keepalived")
fmt.Println(" 3) Bei Failover: edgeguard-ctl promote (auf dem Secondary)")
return 0
}
// pgReplicationCreds sind die Credentials die der Primary via mTLS zurückgibt.
type pgReplicationCreds struct {
Host string `json:"host"`
Port int `json:"port"`
User string `json:"user"`
Password string `json:"password"`
}
// fetchReplicationCreds ruft GET /agent/cluster/pg-replication-info via mTLS ab.
func fetchReplicationCreds(host string, agentPort int, tlsDir string) (*pgReplicationCreds, error) {
caPath := filepath.Join(tlsDir, "ca.crt")
certPath := filepath.Join(tlsDir, "peer.crt")
keyPath := filepath.Join(tlsDir, "peer.key")
caCert, err := os.ReadFile(caPath)
if err != nil {
return nil, fmt.Errorf("read ca.crt: %w", err)
}
pool := x509.NewCertPool()
pool.AppendCertsFromPEM(caCert)
cert, err := tls.LoadX509KeyPair(certPath, keyPath)
if err != nil {
return nil, fmt.Errorf("load peer cert: %w", err)
}
client := &http.Client{
Timeout: 15 * time.Second,
Transport: &http.Transport{
TLSClientConfig: &tls.Config{
RootCAs: pool,
Certificates: []tls.Certificate{cert},
},
},
}
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)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("GET %s: HTTP %d", url, resp.StatusCode)
}
var result struct {
Data pgReplicationCreds `json:"data"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, fmt.Errorf("decode response: %w", err)
}
return &result.Data, nil
}
// syncMasterKey holt den Secrets-Master-Key vom Primary via mTLS und schreibt
// ihn nach /var/lib/edgeguard/.master_key. Dadurch können replizierte
// verschlüsselte WireGuard-Keys und PSKs auf dem Secondary entschlüsselt werden.
func syncMasterKey(host string, agentPort int, tlsDir string) error {
caPath := filepath.Join(tlsDir, "ca.crt")
certPath := filepath.Join(tlsDir, "peer.crt")
keyPath := filepath.Join(tlsDir, "peer.key")
caCert, err := os.ReadFile(caPath)
if err != nil {
return fmt.Errorf("read ca.crt: %w", err)
}
rootPool := x509.NewCertPool()
rootPool.AppendCertsFromPEM(caCert)
cert, err := tls.LoadX509KeyPair(certPath, keyPath)
if err != nil {
return fmt.Errorf("load peer cert: %w", err)
}
client := &http.Client{
Timeout: 15 * time.Second,
Transport: &http.Transport{
TLSClientConfig: &tls.Config{
RootCAs: rootPool,
Certificates: []tls.Certificate{cert},
},
},
}
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)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("GET %s: HTTP %d", url, resp.StatusCode)
}
var result struct {
Data struct {
KeyHex string `json:"key_hex"`
} `json:"data"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return fmt.Errorf("decode response: %w", err)
}
key := make([]byte, 32)
if _, err := fmt.Sscanf(result.Data.KeyHex, "%x", &key); err != nil {
return fmt.Errorf("decode key_hex: %w", err)
}
const masterKeyPath = "/var/lib/edgeguard/.master_key"
if err := os.WriteFile(masterKeyPath, key, 0o600); err != nil {
return fmt.Errorf("write master key: %w", err)
}
if u, err := user.Lookup("edgeguard"); err == nil {
uid, _ := strconv.Atoi(u.Uid)
gid, _ := strconv.Atoi(u.Gid)
_ = os.Chown(masterKeyPath, uid, gid)
}
return nil
}
// generatePassword erzeugt ein kryptographisch sicheres Passwort.
func generatePassword(n int) (string, error) {
const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
buf := make([]byte, n)
if _, err := rand.Read(buf); err != nil {
return "", err
}
for i, b := range buf {
buf[i] = charset[int(b)%len(charset)]
}
return string(buf), nil
}
// psqlDBExec führt SQL in der angegebenen Datenbank als postgres-Superuser aus.
func psqlDBExec(db, sql string) error {
_, err := psqlDBRun(db, []string{"-v", "ON_ERROR_STOP=1", "-c", sql})
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}
return psqlRun(append(baseArgs, args...))
}

View File

@@ -1,7 +1,6 @@
// Command edgeguard-ctl is the admin CLI for setup, migrations and
// (later) cluster ops. v1 wires migrate + initdb so postinst can
// initialise a fresh node; cluster-* and promote remain stubs until
// Phase 3.
// cluster ops. v1.2 implements PG streaming replication setup,
// VIP/Keepalived config and manual failover (promote).
package main
import (
@@ -11,7 +10,7 @@ import (
"git.netcell-it.de/projekte/edgeguard-native/internal/services/setup"
)
var version = "1.1.89"
var version = "1.2.15"
const usage = `edgeguard-ctl — EdgeGuard CLI
@@ -25,20 +24,25 @@ Commands:
migrate check Validate embedded migrations (no DB connect)
migrate dump [dir] Write embedded SQL files to dir (default: ./migrations)
initdb Create PostgreSQL role + database (idempotent)
render-config Regenerate haproxy / nftables configs from PG (--no-reload, --only=)
wg-import [--path <dir>] Import existing /etc/wireguard/*.conf files into the DB
render-config Regenerate all configs from PG (--no-reload, --only=svc)
Services: haproxy nftables squid wireguard unbound chrony keepalived
wg-import [--path <dir>] [iface…]
Import /etc/wireguard/*.conf files into the DB.
reset-password Generate a one-time token for the /reset-password UI flow
cluster-join <primary> --token <…>
Provision Cluster-TLS material on this node by
exchanging the join-token at the primary's
/api/v1/cluster/issue-cert endpoint. Writes
ca.crt + peer.{crt,key} into /var/lib/edgeguard/
cluster-tls/. PG-Basebackup + KeyDB replica
setup remain manual until Phase 3.5.
cluster-renew-self Re-issue this node's peer.{crt,key} using the
local cluster CA (founder/single-node only).
1-year validity. Restart edgeguard-api after.
promote Promote this node's PG to primary (Phase 3, not yet implemented)
Provision Cluster-TLS material; writes ca.crt + peer.{crt,key}
cluster-init-replication Richtet PG Logical Replication auf dem Primary ein.
Erstellt edgeguard_replicator-Rolle, setzt wal_level=logical,
erstellt PUBLICATION edgeguard_shared (alle geteilten Tabellen).
Auf dem Primary ausführen bevor der Secondary joined.
cluster-setup-standby <ip> Richtet diesen Node als Logical-Replication-Subscriber ein.
Erstellt SUBSCRIPTION gegen den Primary (Initialkopiierung
aller geteilten Tabellen). Node-eigene Daten (Interfaces,
IPs, Routen, VIP-Settings) bleiben unangetastet.
Voraussetzung: cluster-join + cluster-init-replication.
cluster-renew-self Re-issue this node's peer.{crt,key} using the local cluster CA.
promote Promote diesen PG-Standby zum Primary (manueller Failover).
Kein Auto-Promote — Split-Brain-Schutz durch manuelle Entscheidung.
dump-config Print effective config (Phase 3, not yet implemented)
`
@@ -66,7 +70,13 @@ func main() {
os.Exit(cmdClusterJoin(os.Args[2:]))
case "cluster-renew-self":
os.Exit(cmdClusterRenewSelf(os.Args[2:]))
case "cluster-leave", "promote", "dump-config":
case "cluster-init-replication":
os.Exit(cmdClusterInitReplication(os.Args[2:]))
case "cluster-setup-standby":
os.Exit(cmdClusterSetupStandby(os.Args[2:]))
case "promote":
os.Exit(cmdPromote(os.Args[2:]))
case "cluster-leave", "dump-config":
fmt.Fprintf(os.Stderr, "edgeguard-ctl: %q is a Phase-3 stub — not yet implemented\n", os.Args[1])
os.Exit(1)
default:

View File

@@ -0,0 +1,160 @@
package main
import (
"context"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
"git.netcell-it.de/projekte/edgeguard-native/internal/cluster"
"git.netcell-it.de/projekte/edgeguard-native/internal/database"
"git.netcell-it.de/projekte/edgeguard-native/internal/keepalived"
)
// cmdPromote promotes this node's PostgreSQL instance from Hot-Standby
// to Primary. Manual failover — keine automatische Promotion, um Split-Brain
// in 2-Node-Clustern ohne externen Quorum zu verhindern.
//
// Ablauf:
// 1. Prüfen ob standby.signal vorhanden (wir sind wirklich Standby)
// 2. pg_ctlcluster promote → PG wird Primary
// 3. Warten bis pg_is_in_recovery() = false
// 4. ha_nodes.pg_role auf 'primary' setzen
// 5. KeyDB cluster:pg-primary-url auf lokal setzen
// 6. keepalived.conf neu rendern (Primary bekommt Priorität 200)
// 7. keepalived reload
func cmdPromote(args []string) int {
pg, err := detectPGConfig()
if err != nil {
fmt.Fprintln(os.Stderr, "promote: PG-Erkennung:", err)
return 1
}
// 1. Standby-Signal prüfen
signalPath := filepath.Join(pg.DataDir, "standby.signal")
if _, err := os.Stat(signalPath); os.IsNotExist(err) {
fmt.Fprintf(os.Stderr,
"promote: %s nicht gefunden — diese Node ist kein PG-Standby oder wurde bereits promoted.\n",
signalPath)
return 1
}
fmt.Printf("→ Promoting PostgreSQL %s/%s zu Primary...\n", pg.Version, pg.Cluster)
if out, err := exec.Command("pg_ctlcluster", pg.Version, pg.Cluster, "promote").
CombinedOutput(); err != nil {
fmt.Fprintf(os.Stderr, "promote: pg_ctlcluster promote: %v\n%s\n", err, out)
return 1
}
fmt.Println("✓ pg_ctlcluster promote gesendet")
// 2. Warten bis PG wirklich Primary ist (pg_is_in_recovery = false)
fmt.Print("→ Warte auf PG Primary-Mode")
deadline := time.Now().Add(60 * time.Second)
for time.Now().Before(deadline) {
out, err := psqlRun([]string{"-tA", "-c", "SELECT pg_is_in_recovery();"})
if err == nil && strings.TrimSpace(string(out)) == "f" {
break
}
fmt.Print(".")
time.Sleep(2 * time.Second)
}
fmt.Println()
// Nochmal prüfen
out, err := psqlRun([]string{"-tA", "-c", "SELECT pg_is_in_recovery();"})
if err != nil || strings.TrimSpace(string(out)) != "f" {
fmt.Fprintln(os.Stderr, "promote: PG ist nach 60s noch in recovery — prüfe PG-Logs")
return 1
}
fmt.Println("✓ PostgreSQL ist jetzt Primary")
// 3. ha_nodes.pg_role + role aktualisieren
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
pool, err := database.Open(ctx, database.ConnStringFromEnv())
if err != nil {
fmt.Fprintln(os.Stderr, "promote: db connect:", err)
fmt.Println(" → ha_nodes manuell updaten: UPDATE ha_nodes SET pg_role='primary', role='primary' WHERE id='<local-id>';")
} else {
defer pool.Close()
localID, err := loadLocalID()
if err != nil {
fmt.Fprintln(os.Stderr, "promote: local node ID:", err)
} else {
_, err = pool.Exec(ctx, `UPDATE ha_nodes SET pg_role='primary', role='primary', status='online', updated_at=NOW() WHERE id=$1`, localID)
if err != nil {
fmt.Fprintln(os.Stderr, "promote: update ha_nodes:", err)
} else {
fmt.Println("✓ ha_nodes.pg_role = 'primary' gesetzt")
}
}
}
// 4. KeyDB cluster:pg-primary-url updaten
if err := updateKeyDBPrimaryURL(); err != nil {
fmt.Fprintf(os.Stderr, "promote: KeyDB update: %v\n", err)
fmt.Println(" → Manuell: redis-cli SET cluster:pg-primary-url 'postgres://edgeguard@/edgeguard'")
} else {
fmt.Println("✓ KeyDB cluster:pg-primary-url aktualisiert")
}
// 5. Keepalived.conf neu rendern (Primary = Priorität 200)
if pool != nil {
localID, _ := loadLocalID()
kg := keepalived.New(pool, localID)
renderCtx, renderCancel := context.WithTimeout(context.Background(), 10*time.Second)
defer renderCancel()
if err := kg.Render(renderCtx); err != nil {
fmt.Fprintf(os.Stderr, "promote: keepalived render: %v\n", err)
fmt.Println(" → Manuell: edgeguard-ctl render-config --only=keepalived")
} else {
fmt.Println("✓ keepalived.conf neu gerendert (Priority 200)")
}
}
fmt.Println()
fmt.Println("✓ Promotion abgeschlossen. Diese Node ist jetzt der primäre EdgeGuard-Knoten.")
fmt.Println()
fmt.Println("Empfohlene Nachschritte:")
fmt.Println(" 1) sudo systemctl restart edgeguard-api (falls noch nicht laufend)")
fmt.Println(" 2) Alte Primary-Node nach Recovery als neuen Standby einrichten:")
fmt.Println(" edgeguard-ctl cluster-setup-standby <diese-node-ip>")
return 0
}
// loadLocalID liest die Node-ID aus /var/lib/edgeguard/node.conf.
func loadLocalID() (string, error) {
c, err := cluster.LoadLocalConfig("")
if err != nil {
return "", err
}
if c.NodeID == "" {
return "", fmt.Errorf("NODE_ID in node.conf ist leer")
}
return c.NodeID, nil
}
// updateKeyDBPrimaryURL schreibt den lokalen PG-DSN als cluster:pg-primary-url
// in KeyDB, damit alle Nodes im Cluster Writes an diese Node schicken.
func updateKeyDBPrimaryURL() error {
// edgeguard-api nutzt Unix-Socket-Auth, der DSN ist immer lokal.
const localDSN = "postgres://edgeguard@/edgeguard?host=/var/run/postgresql"
out, err := exec.Command("redis-cli",
"-s", "/var/run/keydb/keydb.sock",
"SET", "cluster:pg-primary-url", localDSN,
).CombinedOutput()
if err != nil {
// Fallback: Standard-Port
out2, err2 := exec.Command("redis-cli",
"-p", "6379",
"SET", "cluster:pg-primary-url", localDSN,
).CombinedOutput()
if err2 != nil {
return fmt.Errorf("%v: %s / %v: %s", err, out, err2, out2)
}
}
return nil
}

View File

@@ -8,10 +8,14 @@ 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"
"git.netcell-it.de/projekte/edgeguard-native/internal/haproxy"
"git.netcell-it.de/projekte/edgeguard-native/internal/cluster"
"git.netcell-it.de/projekte/edgeguard-native/internal/keepalived"
"git.netcell-it.de/projekte/edgeguard-native/internal/services/configorch"
"git.netcell-it.de/projekte/edgeguard-native/internal/services/secrets"
"git.netcell-it.de/projekte/edgeguard-native/internal/squid"
@@ -59,12 +63,29 @@ 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
}
gens := []configgen.Generator{hap, fw, sq, wg, ub, cn}
// keepalived: Node-ID aus node.conf für Prioritäts-Berechnung
var ka configgen.Generator
if lc, err := cluster.LoadLocalConfig(""); err == nil && lc.NodeID != "" {
ka = keepalived.New(pool, lc.NodeID)
}
gens := []configgen.Generator{hap, fw, sq, wg, ub, cn, ke, fr}
if ka != nil {
gens = append(gens, ka)
}
results, runErr := configorch.Run(ctx, gens, only)
fmt.Print(configorch.Summarise(results))

View File

@@ -46,7 +46,9 @@ func cmdWGImport(args []string) int {
wireguard.NewPeersRepo(pool),
box,
)
res, err := im.ImportDir(ctx, *path)
// Positional args after flags = specific interface names to import.
names := fs.Args()
res, err := im.ImportSelected(ctx, *path, names)
if err != nil {
fmt.Fprintln(os.Stderr, "wg-import:", err)
return 1

View File

@@ -9,12 +9,17 @@
package main
import (
"bufio"
"context"
"encoding/json"
"fmt"
"log/slog"
"net"
"os"
"os/exec"
"regexp"
"strconv"
"strings"
"syscall"
"time"
@@ -27,6 +32,7 @@ import (
"git.netcell-it.de/projekte/edgeguard-native/internal/services/acme"
"git.netcell-it.de/projekte/edgeguard-native/internal/services/alerts"
"git.netcell-it.de/projekte/edgeguard-native/internal/services/audit"
"git.netcell-it.de/projekte/edgeguard-native/internal/services/backends"
"git.netcell-it.de/projekte/edgeguard-native/internal/services/backup"
backupremote "git.netcell-it.de/projekte/edgeguard-native/internal/services/backup/remote"
"git.netcell-it.de/projekte/edgeguard-native/internal/services/certrenewer"
@@ -35,7 +41,7 @@ import (
"git.netcell-it.de/projekte/edgeguard-native/internal/services/tlscerts"
)
var version = "1.1.89"
var version = "1.2.35"
const (
// renewTickInterval — how often we re-evaluate expiring certs.
@@ -97,6 +103,44 @@ const (
// ist passiert nichts.
auditCleanupInterval = 24 * time.Hour
auditRetentionDays = 90
// backendDownCheckInterval — alle 2 Minuten HAProxy-Stats lesen und
// prüfen ob ein Backend komplett ausgefallen ist (alle Server DOWN).
// Dedupe 12h pro Backend → kein Alert-Spam. Frischer Alert wenn das
// Backend nach 12h immer noch unten ist.
backendDownCheckInterval = 2 * time.Minute
// memCheckInterval — alle 5 Minuten /proc/meminfo lesen. Schwellen
// warning 85%, critical 95%. Dedupe 1h pro Severity damit bei einem
// kurzfristigen Spike nicht jeder Tick feuert.
memCheckInterval = 5 * time.Minute
memWarnPct = 85.0
memCriticalPct = 95.0
// conntrackCheckInterval — alle 2 Minuten /proc/sys/net/netfilter/
// nf_conntrack_count+max lesen. Eine volle conntrack-Tabelle verwirft
// alle neuen Verbindungen ohne jegliche Rückmeldung. 2-Minuten-Takt
// erlaubt früh zu warnen bevor die Tabelle überläuft.
// Schwellen analog Disk: 80% Warning, 90% Critical. Dedupe 1h.
conntrackCheckInterval = 2 * time.Minute
conntrackWarnPct = 80.0
conntrackCriticalPct = 90.0
// ntpSyncCheckInterval — alle 10 Minuten chronyc tracking aufrufen.
// Keine Sync bedeutet: Uhr driftet → TLS-Cert-Prüfung schlägt fehl
// wenn die Abweichung > Toleranz des Gegenstücks (i.d.R. ±1 min),
// JWT-Ablauf inkonsistent, Cluster-Split-Brain möglich. Dedupe 1h
// damit ein kurzer Upstream-Ausfall (Reboot, DHCP-Pause) keinen
// Alert-Regen produziert.
ntpSyncCheckInterval = 10 * time.Minute
// wgTunnelCheckInterval — alle 5 Minuten WireGuard-Client-Tunnels
// auf Aktualität prüfen. Client-Tunnels (mode='client') haben genau
// einen Peer; wenn dessen letzter Handshake älter als wgStaleSec ist,
// ist der Tunnel effektiv tot — Traffic droht lautlos. Dedupe 30min
// pro Tunnel damit schnell wiederhergestellte Tunnels nur einmal feuern.
wgTunnelCheckInterval = 5 * time.Minute
wgStaleSec = int64(5 * 60) // 5 Minuten ohne Handshake = tot
)
func main() {
@@ -180,6 +224,31 @@ func main() {
auditTick := time.NewTicker(auditCleanupInterval)
defer auditTick.Stop()
backendDownTick := time.NewTicker(backendDownCheckInterval)
defer backendDownTick.Stop()
// Initial-Check direkt beim Start — wenn ein Backend seit dem letzten
// Scheduler-Restart down ist, brauchen wir nicht 2 Minuten zu warten.
runBackendDownCheck(ctx, pool, alertSvc, alertDedupe)
memTick := time.NewTicker(memCheckInterval)
defer memTick.Stop()
runMemoryCheck(ctx, alertSvc, alertDedupe)
conntrackTick := time.NewTicker(conntrackCheckInterval)
defer conntrackTick.Stop()
runConntrackCheck(ctx, alertSvc, alertDedupe)
ntpSyncTick := time.NewTicker(ntpSyncCheckInterval)
defer ntpSyncTick.Stop()
// Kein Initial-Check bei Boot: chrony braucht nach dem Start
// einige Sekunden bis zur ersten Synchronisation — ein
// sofortiger Check würde immer feuern.
wgTunnelTick := time.NewTicker(wgTunnelCheckInterval)
defer wgTunnelTick.Stop()
// Kein Initial-Check bei Boot: Tunnels brauchen nach dem Start
// des wg-quick-Dienstes einen Moment für den ersten Handshake.
for {
select {
case <-renewTick.C:
@@ -203,6 +272,16 @@ func main() {
runDiskCheck(ctx, alertSvc, alertDedupe)
case <-auditTick.C:
runAuditCleanup(ctx, auditRepo, setupStore)
case <-backendDownTick.C:
runBackendDownCheck(ctx, pool, alertSvc, alertDedupe)
case <-memTick.C:
runMemoryCheck(ctx, alertSvc, alertDedupe)
case <-conntrackTick.C:
runConntrackCheck(ctx, alertSvc, alertDedupe)
case <-ntpSyncTick.C:
runNTPSyncCheck(ctx, alertSvc, alertDedupe)
case <-wgTunnelTick.C:
runWGClientTunnelCheck(ctx, pool, alertSvc, alertDedupe)
}
}
}
@@ -307,6 +386,395 @@ func runDiskCheck(ctx context.Context, a *alerts.Service, d *dedupe) {
// remaining hat UND eine lokale CA existiert, wird automatisch neu
// signiert. Restart-Hinweis als Info-Alert — wir starten edgeguard-api
// nicht selbst neu, das passiert beim nächsten geplanten Update/Reboot.
// runMemoryCheck liest /proc/meminfo und feuert bei hoher RAM-Belegung.
// Schwellen: warning >= 85%, critical >= 95%. Dedupe 1h pro Severity
// damit kurze Spikes (Backup, apt-Upgrade) keine Alert-Flut erzeugen.
func runMemoryCheck(ctx context.Context, a *alerts.Service, d *dedupe) {
if a == nil || d == nil {
return
}
data, err := os.ReadFile("/proc/meminfo")
if err != nil {
return
}
var memTotal, memAvail int64
for _, line := range strings.Split(string(data), "\n") {
var key string
var val int64
if _, err := fmt.Sscanf(line, "%s %d", &key, &val); err != nil {
continue
}
switch key {
case "MemTotal:":
memTotal = val
case "MemAvailable:":
memAvail = val
}
}
if memTotal <= 0 {
return
}
usedPct := float64(memTotal-memAvail) * 100 / float64(memTotal)
usedGB := float64(memTotal-memAvail) / 1024 / 1024
totalGB := float64(memTotal) / 1024 / 1024
var key, title string
var sev alerts.Severity
switch {
case usedPct >= memCriticalPct:
key = "mem.high.critical"
sev = alerts.SeverityError
title = fmt.Sprintf("RAM kritisch hoch: %.0f%%", usedPct)
case usedPct >= memWarnPct:
key = "mem.high.warning"
sev = alerts.SeverityWarning
title = fmt.Sprintf("RAM-Belegung hoch: %.0f%%", usedPct)
default:
return
}
if !d.shouldFire(key) {
return
}
desc := fmt.Sprintf(
"RAM-Auslastung: %.1f%% — %.1f von %.1f GB belegt.\n\n"+
"Häufige Ursachen:\n"+
" • Unbound-Cache zu groß (rrset-cache-size in /etc/edgeguard/unbound/unbound.conf)\n"+
" • Squid cache_mem zu groß (64 MB default)\n"+
" • PostgreSQL shared_buffers (default ~128 MB)\n"+
" • Prozesse prüfen: ps aux --sort=-%%mem | head -10",
usedPct, usedGB, totalGB)
if _, err := a.Fire(ctx, "mem.high", sev, title, desc); err != nil {
slog.Warn("scheduler: memory-check alert fire failed", "error", err)
}
}
// runConntrackCheck liest die conntrack-Tabellen-Belegung aus /proc und
// feuert bei hoher Auslastung. Eine volle conntrack-Tabelle (100%)
// verwirft alle neuen TCP/UDP-Verbindungen ohne ICMP-Rückmeldung —
// der Operator sieht auf der Gegenstelle nur Timeouts.
//
// Schwellen: 80% Warning, 90% Critical (wie Disk, niedriger als RAM weil
// der Impact sofortig ist). Dedupe 1h pro Severity.
func runConntrackCheck(ctx context.Context, a *alerts.Service, d *dedupe) {
if a == nil || d == nil {
return
}
readInt := func(path string) int64 {
b, err := os.ReadFile(path)
if err != nil {
return 0
}
v, _ := strconv.ParseInt(strings.TrimSpace(string(b)), 10, 64)
return v
}
count := readInt("/proc/sys/net/netfilter/nf_conntrack_count")
max := readInt("/proc/sys/net/netfilter/nf_conntrack_max")
if max <= 0 {
return
}
usedPct := float64(count) * 100 / float64(max)
var key, title string
var sev alerts.Severity
switch {
case usedPct >= conntrackCriticalPct:
key = "conntrack.high.critical"
sev = alerts.SeverityError
title = fmt.Sprintf("Conntrack-Tabelle kritisch voll: %.0f%%", usedPct)
case usedPct >= conntrackWarnPct:
key = "conntrack.high.warning"
sev = alerts.SeverityWarning
title = fmt.Sprintf("Conntrack-Tabelle fast voll: %.0f%%", usedPct)
default:
return
}
if !d.shouldFire(key) {
return
}
desc := fmt.Sprintf(
"Conntrack-Auslastung: %.1f%% — %d von %d Einträgen belegt.\n\n"+
"Wenn die Tabelle auf 100%% steigt, werden alle neuen Verbindungen\n"+
"ohne Fehlermeldung verworfen (Silent Drop).\n\n"+
"Maßnahmen:\n"+
" • Zeitweilige Spikes: nf_conntrack_max erhöhen\n"+
" (sysctl net.netfilter.nf_conntrack_max)\n"+
" • Leaks: conntrack -L | sort | head zeigt häufige Quellen\n"+
" • Timeouts reduzieren (z.B. nf_conntrack_tcp_timeout_established)",
usedPct, count, max)
if _, err := a.Fire(ctx, "conntrack.high", sev, title, desc); err != nil {
slog.Warn("scheduler: conntrack-check alert fire failed", "error", err)
}
}
// runNTPSyncCheck ruft chronyc tracking auf und feuert einen Alert wenn
// chrony keine synchronisierte Zeitquelle hat (Stratum 0 oder ≥ 16).
// Zeitdrift > ~1 Minute führt zu TLS-Handshake-Fehlern, JWT-Ablauf-
// Inkonsistenzen und möglichen Cluster-Problemen. Dedupe 1h.
func runNTPSyncCheck(ctx context.Context, a *alerts.Service, d *dedupe) {
if a == nil || d == nil {
return
}
out, err := exec.Command("chronyc", "tracking").Output()
if err != nil {
// chrony nicht installiert oder nicht gestartet — kein Alert,
// weil wir nicht wissen ob chrony hier überhaupt erwartet wird.
return
}
synced, stratum, ref := parseChronyTrackingForAlert(string(out))
if synced {
return
}
const key = "ntp.unsync"
if !d.shouldFire(key) {
return
}
refStr := ref
if refStr == "" {
refStr = "(keine Referenz)"
}
title := fmt.Sprintf("NTP nicht synchronisiert (Stratum %d)", stratum)
desc := fmt.Sprintf(
"chrony hat keine synchronisierte Zeitquelle.\n"+
"Referenz: %s Stratum: %d\n\n"+
"Mögliche Ursachen:\n"+
" • Upstream-NTP-Server nicht erreichbar (UDP/123 blockiert?)\n"+
" • Pool-DNS-Einträge lösen nicht auf\n"+
" • chrony läuft, braucht aber noch Zeit nach Boot (warten)\n\n"+
"Prüfen: chronyc sources -v — chronyc tracking",
refStr, stratum)
if _, err := a.Fire(ctx, "ntp.unsync", alerts.SeverityWarning, title, desc); err != nil {
slog.Warn("scheduler: ntp-sync-check alert fire failed", "error", err)
}
}
// parseChronyTrackingForAlert ist eine schlanke Variante des NTP-Handler-
// Parsers: liefert nur synced/stratum/reference ohne die vollen Felder.
func parseChronyTrackingForAlert(out string) (synced bool, stratum int, reference string) {
for _, line := range strings.Split(out, "\n") {
line = strings.TrimSpace(line)
key, val, ok := strings.Cut(line, ":")
if !ok {
continue
}
key = strings.TrimSpace(key)
val = strings.TrimSpace(val)
switch key {
case "Reference ID":
if i := strings.Index(val, "("); i >= 0 {
reference = strings.Trim(val[i:], "()")
}
if val != "00000000 ()" {
synced = true
}
case "Stratum":
fmt.Sscanf(val, "%d", &stratum)
if stratum > 0 && stratum < 16 {
synced = true
} else if stratum == 0 || stratum >= 16 {
synced = false
}
}
}
return
}
// runWGClientTunnelCheck prüft alle aktiven WireGuard-Client-Tunnels
// (mode='client') auf Handshake-Aktualität. Ein Client-Tunnel hat genau
// einen Peer; wenn dessen letzter Handshake älter als wgStaleSec oder
// noch nie stattgefunden hat, ist der Tunnel tot — Traffic wird lautlos
// verworfen (kein ICMP Unreachable). Dedupe 30min pro Tunnel damit
// nach einer Selbstheilung nicht alle paar Minuten neu gefeuert wird.
func runWGClientTunnelCheck(ctx context.Context, pool *pgxpool.Pool, a *alerts.Service, d *dedupe) {
if a == nil || d == nil || pool == nil {
return
}
// Alle aktiven Client-Interfaces aus DB laden.
type wgIface struct{ name string }
rows, err := pool.Query(ctx,
`SELECT name FROM wg_interfaces WHERE mode = 'client' AND active = true ORDER BY name`)
if err != nil {
return
}
defer rows.Close()
var ifaces []wgIface
for rows.Next() {
var n string
if err := rows.Scan(&n); err == nil {
ifaces = append(ifaces, wgIface{n})
}
}
rows.Close()
if len(ifaces) == 0 {
return
}
now := time.Now().Unix()
for _, ifc := range ifaces {
out, err := exec.Command("wg", "show", ifc.name, "dump").Output()
if err != nil {
// Interface existiert nicht mehr im Kernel (wg-quick down) —
// das ist selbst schon ein Problem; kein separater Alert hier,
// da systemd-Restart-Policy das abdeckt.
continue
}
lines := strings.Split(strings.TrimSpace(string(out)), "\n")
// Zeile 0 ist die Interface-Zeile (own key / pubkey / port / fwmark).
// Zeile 1 ist die Peer-Zeile: pubkey psk endpoint allowed-ips last-hs rx tx keepalive
if len(lines) < 2 {
continue
}
fields := strings.Fields(lines[1])
if len(fields) < 5 {
continue
}
lastHS, _ := strconv.ParseInt(fields[4], 10, 64)
stale := lastHS == 0 || (now-lastHS) > wgStaleSec
if !stale {
continue
}
key := "wg.tunnel.down." + ifc.name
if !d.shouldFire(key) {
continue
}
var detail string
if lastHS == 0 {
detail = "Noch kein Handshake — Tunnel wurde nie erfolgreich aufgebaut."
} else {
ageMin := (now - lastHS) / 60
detail = fmt.Sprintf("Letzter Handshake: vor %d Minuten.", ageMin)
}
title := fmt.Sprintf("WireGuard-Tunnel %s ausgefallen", ifc.name)
desc := fmt.Sprintf(
"Client-Tunnel %s hat seit >5 Minuten keinen Handshake.\n%s\n\n"+
"Traffic zu den RemoteAllowed-Netzen wird lautlos verworfen.\n\n"+
"Mögliche Ursachen:\n"+
" • Remote-Peer nicht erreichbar (Firewall, Routing)\n"+
" • Remote-Server-Keypair geändert (Public-Key stimmt nicht mehr)\n"+
" • UDP-Port des Peers geblockt\n"+
" • wg-quick-Dienst auf dieser Box gestoppt: systemctl status wg-quick@%s",
ifc.name, detail, ifc.name)
if _, err := a.Fire(ctx, "wg.tunnel.down", alerts.SeverityError, title, desc); err != nil {
slog.Warn("scheduler: wg-tunnel-check alert fire failed", "iface", ifc.name, "error", err)
}
}
}
var egBackendRE = regexp.MustCompile(`^eg_backend_(\d+)$`)
// runBackendDownCheck liest HAProxy-Stats via Admin-Socket und feuert
// einen Error-Alert für jedes Backend bei dem alle Server DOWN sind
// (und mind. einer einen echten Health-Check hat). Dedupe 12h pro Backend.
func runBackendDownCheck(ctx context.Context, pool *pgxpool.Pool, a *alerts.Service, d *dedupe) {
if a == nil || d == nil {
return
}
conn, err := net.DialTimeout("unix", "/run/haproxy/admin.sock", 2*time.Second)
if err != nil {
// HAProxy läuft nicht oder Socket nicht erreichbar — kein Alert,
// das ist der Dienst selbst nicht der Scheduler.
return
}
defer conn.Close()
_ = conn.SetDeadline(time.Now().Add(3 * time.Second))
if _, err := conn.Write([]byte("show stat\n")); err != nil {
return
}
type srvEntry struct{ status string; hasCheck bool }
byBackend := map[string][]srvEntry{}
colIdx := map[string]int{}
scanner := bufio.NewScanner(conn)
scanner.Buffer(make([]byte, 64*1024), 1024*1024)
for scanner.Scan() {
line := scanner.Text()
if line == "" {
continue
}
fields := strings.Split(line, ",")
if strings.HasPrefix(line, "# ") {
fields[0] = strings.TrimPrefix(fields[0], "# ")
for i, name := range fields {
colIdx[name] = i
}
continue
}
px := fieldAt(fields, colIdx["pxname"])
sv := fieldAt(fields, colIdx["svname"])
if !strings.HasPrefix(px, "eg_backend_") || sv == "BACKEND" || sv == "FRONTEND" || sv == "" {
continue
}
status := fieldAt(fields, colIdx["status"])
byBackend[px] = append(byBackend[px], srvEntry{
status: status,
hasCheck: status != "no check",
})
}
if len(byBackend) == 0 {
return
}
// Friendly Backend-Namen aus DB — best-effort, Fehler = anonyme ID.
bkRepo := backends.New(pool)
bklist, _ := bkRepo.List(ctx)
nameOf := func(id int64) string {
for _, b := range bklist {
if b.ID == id {
return b.Name
}
}
return fmt.Sprintf("#%d", id)
}
for haName, servers := range byBackend {
hasRealCheck, allDown := false, true
for _, s := range servers {
if s.hasCheck {
hasRealCheck = true
}
if s.status == "UP" {
allDown = false
break
}
}
if !hasRealCheck || !allDown {
continue
}
m := egBackendRE.FindStringSubmatch(haName)
if m == nil {
continue
}
id, _ := strconv.ParseInt(m[1], 10, 64)
name := nameOf(id)
key := "backend.down." + haName
if !d.shouldFire(key) {
continue
}
msg := fmt.Sprintf(
"Alle Server in Backend \"%s\" sind DOWN — HAProxy liefert 503 für alle Requests zu diesem Backend.\n\n"+
"HAProxy-Backend-Name: %s\n\n"+
"Nächste Schritte:\n"+
" • Dienst auf Backend-Host prüfen (systemctl status / docker ps)\n"+
" • Health-Check-Pfad erreichbar? (curl http://<server>:<port><path>)\n"+
" • Firewall-Regeln zwischen EdgeGuard und Backend-Host prüfen",
name, haName)
if _, err := a.Fire(ctx, "backend.down", alerts.SeverityError,
fmt.Sprintf("Backend DOWN: %s", name), msg); err != nil {
slog.Warn("scheduler: backend-down alert fire failed",
"backend", name, "error", err)
}
}
}
func fieldAt(fields []string, i int) string {
if i < 0 || i >= len(fields) {
return ""
}
return fields[i]
}
func runClusterCertExpiryCheck(ctx context.Context, a *alerts.Service, d *dedupe) {
if a == nil || d == nil {
return
@@ -594,11 +1062,18 @@ func runRenewer(ctx context.Context, r *certrenewer.Service, a *alerts.Service,
slog.Info("scheduler: renewer pass complete",
"checked", res.Checked, "renewed", res.Renewed,
"failed", res.Failed, "skipped", res.Skipped)
if a != nil && res.Failed > 0 && d != nil && d.shouldFire("cert.renew_failed") {
_, _ = a.Fire(ctx, "cert.renew_failed", alerts.SeverityError,
"Cert-Renewal teilweise fehlgeschlagen",
fmt.Sprintf("Renewer-Cycle: %d checked, %d renewed, %d failed, %d skipped",
res.Checked, res.Renewed, res.Failed, res.Skipped))
if a != nil && d != nil {
for _, domain := range res.FailedDomains {
key := "cert.renew_failed:" + domain
if !d.shouldFire(key) {
continue
}
_, _ = a.Fire(ctx, "cert.renew_failed", alerts.SeverityError,
"Cert-Renewal fehlgeschlagen: "+domain,
"Let's Encrypt Erneuerung für "+domain+" ist fehlgeschlagen. "+
"Prüfe ACME-Konfiguration und DNS-Erreichbarkeit. "+
"Nächster Versuch beim nächsten Renewer-Tick (alle 6h).")
}
}
}

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

@@ -0,0 +1,105 @@
// 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)
}
}
// 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

@@ -0,0 +1,40 @@
global_defs {
router_id {{ .RouterID }}
script_user root
enable_script_security
vrrp_garp_interval 0
vrrp_gna_interval 0
}
vrrp_script chk_edgeguard {
script "/usr/lib/edgeguard/keepalived-check.sh"
interval 2
weight -50
fall 3
rise 2
}
vrrp_instance VI_1 {
state {{ .State }}
interface {{ .Interface }}
virtual_router_id {{ .RouterID }}
priority {{ .Priority }}
advert_int 1
{{ if .SrcIP }} unicast_src_ip {{ .SrcIP }}
unicast_peer {
{{ .PeerIP }}
}
{{ end }} authentication {
auth_type PASS
auth_pass {{ .AuthPass }}
}
virtual_ipaddress {
{{ .VIP }}
}
track_script {
chk_edgeguard
}
notify_master "/usr/lib/edgeguard/keepalived-master.sh"
notify_backup "/usr/lib/edgeguard/keepalived-backup.sh"
notify_fault "/usr/lib/edgeguard/keepalived-backup.sh"
}

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

@@ -151,7 +151,19 @@ func (a *Aggregator) callPeer(ctx context.Context, p models.HANode, path string)
return res
}
res.OK = true
res.Data = body
// Agent-Endpoints liefern die Standard-API-Envelope zurück
// ({"data": {...}, "error": null, "message": "ok"}). Wir entpacken
// das `data`-Feld so dass der Aufrufer direkt die Nutzlast bekommt —
// konsistent mit dem Lokal-Pfad (der marshalt die Struct direkt ohne
// Envelope).
var env struct {
Data json.RawMessage `json:"data"`
}
if err := json.Unmarshal(body, &env); err == nil && len(env.Data) > 0 {
res.Data = env.Data
} else {
res.Data = body
}
res.Duration = time.Since(start).Milliseconds()
return res
}
@@ -180,6 +192,80 @@ func agentURL(apiURL string, agentPort int, path string) (string, error) {
return u.String(), nil
}
// PostPeer sendet einen POST-Request an einen einzelnen Peer.
// Wird vom Rolling-Update-Orchestrator genutzt um /agent/cluster/trigger-update
// auf dem Secondary auszulösen.
func (a *Aggregator) PostPeer(ctx context.Context, p models.HANode, path string) 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, nil)
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()
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusAccepted {
res.Err = fmt.Sprintf("HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
res.Duration = time.Since(start).Milliseconds()
return res
}
res.OK = true
res.Duration = time.Since(start).Milliseconds()
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

@@ -49,6 +49,30 @@ func New(pool *pgxpool.Pool) *Generator {
func (g *Generator) Name() string { return "chrony" }
// RenderToString renders the chrony config to a string without writing
// to disk or reloading the service. Used by the config-preview endpoint.
func (g *Generator) RenderToString(ctx context.Context) (string, error) {
settings, err := g.Repo.GetSettings(ctx)
if err != nil {
return "", fmt.Errorf("settings: %w", err)
}
pools, err := g.Repo.ListPools(ctx)
if err != nil {
return "", fmt.Errorf("pools: %w", err)
}
view := View{
Settings: settings,
Pools: pools,
ListenAddresses: filterNonLoopback(splitCSV(settings.ListenAddresses)),
AllowACLs: splitCSV(settings.AllowACL),
}
var body bytes.Buffer
if err := tpl.Execute(&body, view); err != nil {
return "", fmt.Errorf("template: %w", err)
}
return body.String(), nil
}
func (g *Generator) Render(ctx context.Context) error {
settings, err := g.Repo.GetSettings(ctx)
if err != nil {

View File

@@ -35,10 +35,13 @@ import (
// hashTable beschreibt eine Tabelle die in den config-hash einfließt.
type hashTable struct {
Name string
Singleton bool // dns_settings, ntp_settings → eine row, id=1
ExtraExclude []string // Spalten die zusätzlich aus to_jsonb gefiltert werden
SkipUpdatedAt bool // setze true wenn updated_at semantisch relevant ist
Name string
Singleton bool // dns_settings, ntp_settings → eine row, id=1
ExtraExclude []string // Spalten die zusätzlich aus to_jsonb gefiltert werden
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
@@ -49,15 +52,13 @@ var hashSpec = []hashTable{
{Name: "backends"},
{Name: "backend_servers"},
{Name: "routing_rules"},
{Name: "network_interfaces"},
{Name: "ip_addresses"},
{Name: "tls_certs", ExtraExclude: []string{"last_renewed_at", "last_error"}},
{Name: "firewall_zones"},
{Name: "firewall_zones", MigrationDefault: true},
{Name: "firewall_address_objects"},
{Name: "firewall_address_groups"},
{Name: "firewall_services"},
{Name: "firewall_service_groups"},
{Name: "firewall_services", MigrationDefault: true},
{Name: "firewall_service_groups", MigrationDefault: true},
{Name: "firewall_rules"},
{Name: "firewall_nat_rules"},
@@ -67,12 +68,29 @@ var hashSpec = []hashTable{
{Name: "dns_zones"},
{Name: "dns_records"},
{Name: "dns_settings", Singleton: true},
{Name: "ntp_pools"},
{Name: "ntp_settings", Singleton: true},
{Name: "ntp_pools", MigrationDefault: true},
{Name: "static_routes"},
// 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.
@@ -100,22 +118,39 @@ func hashSQL(t hashTable) string {
// ComputeConfigHash gibt den 16-hex-char-Hash über alle Spec-Tabellen
// zurück. Fehlende Tabellen (transienter schema-flux) werden als
// leerer Per-Table-Hash behandelt — kein Abbruch.
//
// Gibt "" zurück wenn alle user-konfigurierbaren Tabellen leer sind
// (Singleton- und MigrationDefault-Tabellen zählen nicht als User-Config).
// Das verhindert False-Positive-Drift-Banner auf frisch gejointen Secondaries.
func ComputeConfigHash(ctx context.Context, pool *pgxpool.Pool) (string, error) {
if pool == nil {
return "", fmt.Errorf("nil pool")
}
h := sha256.New()
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 = ""
}
if s != "" && !t.Singleton && !t.MigrationDefault {
hasUserConfig = true
}
h.Write([]byte(t.Name))
h.Write([]byte{0})
h.Write([]byte(s))
h.Write([]byte{0})
}
if !hasUserConfig {
// Frisch gejoincter Secondary oder komplett leere DB →
// leerer String signalisiert "kein Drift prüfen" im Status-Handler.
return "", nil
}
return hex.EncodeToString(h.Sum(nil))[:16], nil
}

View File

@@ -61,12 +61,19 @@ type Service struct {
}
func New(pool *pgxpool.Pool, getCAFinger func() (string, error)) *Service {
return &Service{
s := &Service{
Pool: pool,
SecretPath: DefaultSecretPath,
GetCAFinger: getCAFinger,
TTL: DefaultTTL,
}
// Ensure the HMAC secret file exists at startup so Consume() never
// fails with "no such file" on the first join attempt.
if _, err := s.ensureSecret(); err != nil {
// Non-fatal: generate won't work either, but we log and continue.
_ = err
}
return s
}
// payload ist das JSON inside-the-token.

View File

@@ -115,6 +115,16 @@ func (s *Store) Delete(ctx context.Context, id string) error {
return nil
}
// DeletePlaceholdersByFQDN removes all ha_nodes rows that share the given
// FQDN but do NOT have the specified keepID. Used to clean up pre-registered
// placeholder rows (both old "pre-{timestamp}" and new "prenode-{fqdn}"
// style) after a real autoRegister arrives via mTLS.
func (s *Store) DeletePlaceholdersByFQDN(ctx context.Context, fqdn, keepID string) error {
_, err := s.Pool.Exec(ctx,
`DELETE FROM ha_nodes WHERE fqdn = $1 AND id != $2`, fqdn, keepID)
return err
}
// EnsureSelfRegistered mints the node-id if needed, builds the row
// from setup.json + os.Hostname + node.conf, and upserts it. Called
// on edgeguard-api boot AFTER the DB pool is reachable.

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

@@ -15,8 +15,8 @@ CREATE TABLE IF NOT EXISTS ha_nodes (
name TEXT NOT NULL,
fqdn TEXT NOT NULL,
api_url TEXT NOT NULL,
public_ip INET,
internal_ip INET,
public_ip TEXT,
internal_ip TEXT,
role TEXT NOT NULL DEFAULT 'peer',
last_seen TIMESTAMPTZ,
joined_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),

View File

@@ -14,7 +14,7 @@
ALTER TABLE ha_nodes
ADD COLUMN IF NOT EXISTS version TEXT,
ADD COLUMN IF NOT EXISTS config_hash TEXT,
ADD COLUMN IF NOT EXISTS mgmt_ip INET,
ADD COLUMN IF NOT EXISTS mgmt_ip TEXT,
ADD COLUMN IF NOT EXISTS status TEXT NOT NULL DEFAULT 'unknown';
ALTER TABLE ha_nodes
@@ -33,7 +33,7 @@ ALTER TABLE ha_nodes
DROP CONSTRAINT IF EXISTS ha_nodes_status_check;
ALTER TABLE ha_nodes
DROP COLUMN IF EXISTS status,
DROP COLUMN IF EXISTS mgmt_ip,
DROP COLUMN IF EXISTS mgmt_ip, -- TEXT
DROP COLUMN IF EXISTS config_hash,
DROP COLUMN IF EXISTS version;
-- +goose StatementEnd

View File

@@ -0,0 +1,20 @@
-- +goose Up
-- +goose StatementBegin
-- pgx scannt INET-Spalten nicht direkt in *string (OID 869-Fehler).
-- Go-Modell speichert IPs als string — TEXT ist hier korrekt.
-- Bestehende Werte bleiben erhalten (USING public_ip::TEXT).
ALTER TABLE ha_nodes
ALTER COLUMN public_ip TYPE TEXT USING public_ip::TEXT,
ALTER COLUMN internal_ip TYPE TEXT USING internal_ip::TEXT,
ALTER COLUMN mgmt_ip TYPE TEXT USING mgmt_ip::TEXT;
-- +goose StatementEnd
-- +goose Down
-- +goose StatementBegin
ALTER TABLE ha_nodes
ALTER COLUMN public_ip TYPE INET USING public_ip::INET,
ALTER COLUMN internal_ip TYPE INET USING internal_ip::INET,
ALTER COLUMN mgmt_ip TYPE INET USING mgmt_ip::INET;
-- +goose StatementEnd

View File

@@ -0,0 +1,36 @@
-- +goose Up
-- +goose StatementBegin
-- pg_role: Rolle dieser Node in der PG-Replikation.
-- "standalone" = kein Streaming-Replication-Setup
-- "primary" = WAL-Sender, repliziert an Standby(s)
-- "standby" = Hot-Standby, liest WAL vom Primary
ALTER TABLE ha_nodes ADD COLUMN IF NOT EXISTS pg_role TEXT NOT NULL DEFAULT 'standalone';
-- cluster_settings: VIP + VRRP-Konfiguration (Singleton, id=1).
-- vip_address = die virtuelle IP-Adresse (z.B. "89.163.205.10")
-- vip_interface = Netzwerk-Interface (z.B. "eth0")
-- vip_auth_pass = VRRP-Authentication-Passwort (max. 8 Zeichen, Keepalived-Limit)
-- vrrp_router_id = VRRP Virtual Router ID (1255, muss im Subnetz eindeutig sein)
CREATE TABLE IF NOT EXISTS cluster_settings (
id INTEGER PRIMARY KEY DEFAULT 1,
vip_address TEXT,
vip_interface TEXT,
vip_auth_pass TEXT,
vrrp_router_id INTEGER NOT NULL DEFAULT 51,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT cluster_settings_singleton CHECK (id = 1)
);
INSERT INTO cluster_settings (id) VALUES (1) ON CONFLICT DO NOTHING;
-- +goose StatementEnd
-- +goose Down
-- +goose StatementBegin
DROP TABLE IF EXISTS cluster_settings;
ALTER TABLE ha_nodes DROP COLUMN IF EXISTS pg_role;
-- +goose StatementEnd

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 {
@@ -49,13 +50,18 @@ table inet edgeguard {
# Cluster-internal: peers reach edgeguard-api over mTLS on :8443
tcp dport 8443 ip saddr @peer_ipv4 accept
tcp dport 8443 ip6 saddr @peer_ipv6 accept
# Cluster-internal: PG Logical Replication (:5432) + KeyDB Active-Active (:6379)
tcp dport 5432 ip saddr @peer_ipv4 accept
tcp dport 5432 ip6 saddr @peer_ipv6 accept
tcp dport 6379 ip saddr @peer_ipv4 accept
tcp dport 6379 ip6 saddr @peer_ipv6 accept
# ── Service-Auto-Rules (DNS/Squid/WG/...) ──
# Aus dem laufenden Service-State abgeleitet — Operator
# 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 ──
@@ -65,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 ───────────────────────────────────────
@@ -95,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-
@@ -122,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}}
}
@@ -146,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

@@ -1,13 +1,19 @@
package handlers
import (
"bytes"
"context"
"encoding/json"
"errors"
"io"
"log/slog"
"net/http"
"strings"
"time"
"github.com/gin-gonic/gin"
"git.netcell-it.de/projekte/edgeguard-native/internal/cluster/clustertls"
"git.netcell-it.de/projekte/edgeguard-native/internal/handlers/response"
"git.netcell-it.de/projekte/edgeguard-native/internal/services/audit"
"git.netcell-it.de/projekte/edgeguard-native/internal/services/session"
@@ -21,11 +27,12 @@ import (
// the account is auto-migrated into the DB (Upsert) so it shows up in
// user management from that point on.
type AuthHandler struct {
Setup *setup.Store
Signer *session.Signer
Audit *audit.Repo
NodeID string
Users *usersvc.Repo // optional — nil on first boot before DB is ready
Setup *setup.Store
Signer *session.Signer
Audit *audit.Repo
NodeID string
Users *usersvc.Repo // optional — nil on first boot before DB is ready
ClusterTLS *clustertls.Store // optional — enables auth federation on cluster nodes
}
func NewAuthHandler(s *setup.Store, sig *session.Signer) *AuthHandler {
@@ -46,15 +53,29 @@ func (h *AuthHandler) WithUsers(u *usersvc.Repo) *AuthHandler {
return h
}
// WithClusterTLS enables auth federation: when local auth fails on a
// cluster node, Login tries the primary via mTLS /agent/auth/check.
func (h *AuthHandler) WithClusterTLS(store *clustertls.Store) *AuthHandler {
h.ClusterTLS = store
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 {
@@ -63,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) {
@@ -86,38 +108,93 @@ 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)
}
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)
}
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)
}
}
// 2. Fallback: setup-store admin (backwards compat for pre-DB installs).
if actor == "" && st.AdminEmail != "" {
if strings.EqualFold(st.AdminEmail, email) && st.VerifyAdminPassword(req.Password) {
actor = st.AdminEmail
role = "admin"
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.
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
role = r
} else {
slog.Debug("auth: primary auth check failed", "primary", st.PrimaryFQDN, "error", err)
}
}
if actor == "" {
if !strings.EqualFold(st.AdminEmail, email) || !st.VerifyAdminPassword(req.Password) {
response.Unauthorized(c, errors.New("invalid_credentials"))
if h.Audit != nil {
_ = h.Audit.Log(c.Request.Context(), email, "auth.login.failed",
email, gin.H{"reason": "invalid_credentials", "remote": remote}, h.NodeID)
}
response.Unauthorized(c, errors.New("invalid_credentials"))
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
}
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)
}
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)
@@ -127,6 +204,10 @@ func (h *AuthHandler) Login(c *gin.Context) {
}
setSessionCookie(c, raw, tok.Exp)
if h.Audit != nil {
_ = h.Audit.Log(c.Request.Context(), actor, "auth.login.success",
actor, gin.H{"role": role, "remote": remote}, h.NodeID)
}
response.OK(c, loginResponse{
Actor: tok.Actor,
Role: tok.Role,
@@ -134,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})
@@ -194,12 +415,49 @@ type changePasswordRequest struct {
// braucht das hier das current_password als Confirmation — verhindert
// dass eine kompromittierte Session den Account übernimmt ohne dass
// das alte Passwort bekannt ist.
//
// Lookup-Reihenfolge: 1) DB users-Tabelle (alle multi-user-Accounts),
// 2) setup-store Admin-Fallback (Legacy / pre-DB). Beim Setup-Admin
// werden beide Stores synchron gehalten.
func (h *AuthHandler) ChangePassword(c *gin.Context) {
var req changePasswordRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, err)
return
}
tok := CurrentToken(c)
if tok == nil {
response.Unauthorized(c, nil)
return
}
// 1. DB-backed user (alle via User-Management erstellten Accounts).
if h.Users != nil {
u, hash, dbErr := h.Users.FindByEmail(c.Request.Context(), tok.Actor)
if dbErr == nil {
if !usersvc.VerifyPassword(hash, req.CurrentPassword) {
response.Unauthorized(c, errors.New("invalid_current_password"))
return
}
if err := h.Users.SetPassword(c.Request.Context(), u.ID, req.NewPassword); err != nil {
response.Internal(c, err)
return
}
// Setup-Store-Admin synchron halten, falls gleiche E-Mail.
if st, _ := h.Setup.Load(); st != nil && strings.EqualFold(st.AdminEmail, tok.Actor) {
_ = h.Setup.SetAdminPassword(req.NewPassword)
}
if h.Audit != nil {
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "auth.password.change",
tok.Actor, gin.H{"actor": actorOf(c)}, h.NodeID)
}
response.OK(c, gin.H{"ok": true})
return
}
}
// 2. Fallback: setup-store Admin (vor DB-Migration oder nicht migriert).
st, err := h.Setup.Load()
if err != nil {
response.Internal(c, err)
@@ -209,10 +467,6 @@ func (h *AuthHandler) ChangePassword(c *gin.Context) {
response.Err(c, http.StatusServiceUnavailable, errors.New("setup_required"))
return
}
// Authorisierte Session ist nicht automatisch der Admin (Phase 4
// admin_users-Tabelle könnte mehrere Rollen haben). v1: aktuell
// nur der eine Admin-User; trotzdem prüfen wir das current_password
// gegen die persistierte Hash.
if !st.VerifyAdminPassword(req.CurrentPassword) {
response.Unauthorized(c, errors.New("invalid_current_password"))
return
@@ -225,13 +479,51 @@ func (h *AuthHandler) ChangePassword(c *gin.Context) {
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "auth.password.change",
st.AdminEmail, gin.H{"actor": actorOf(c)}, h.NodeID)
}
// Neue Session ausstellen — alte Cookie zeigt auf ein Token das
// noch gültig ist; das ist OK für UX (kein erzwungener Logout),
// sicherheitsbewusster: clearSession + force re-login. Wir
// halten's hier ruhig.
response.OK(c, gin.H{"ok": true})
}
// checkWithPrimary verifies credentials against the primary node via mTLS.
// Returns actor+role on success, error on failure.
func (h *AuthHandler) checkWithPrimary(ctx context.Context, primaryFQDN, email, password string) (string, string, error) {
clientTLS, err := h.ClusterTLS.ClientTLSConfig()
if err != nil {
return "", "", err
}
tr := &http.Transport{TLSClientConfig: clientTLS, TLSHandshakeTimeout: 5 * time.Second}
client := &http.Client{Transport: tr, Timeout: 8 * time.Second}
body, _ := json.Marshal(map[string]string{"email": email, "password": password})
reqURL := "https://" + primaryFQDN + ":8443/agent/auth/check"
req, err := http.NewRequestWithContext(ctx, http.MethodPost, reqURL, bytes.NewReader(body))
if err != nil {
return "", "", err
}
req.Header.Set("Content-Type", "application/json")
resp, err := client.Do(req)
if err != nil {
return "", "", err
}
defer resp.Body.Close()
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 64*1024))
if resp.StatusCode != http.StatusOK {
return "", "", errors.New("primary: " + strings.TrimSpace(string(raw)))
}
var env struct {
Data struct {
Actor string `json:"actor"`
Role string `json:"role"`
} `json:"data"`
}
if err := json.Unmarshal(raw, &env); err != nil {
return "", "", err
}
if env.Data.Actor == "" {
return "", "", errors.New("primary returned empty actor")
}
return env.Data.Actor, env.Data.Role, nil
}
func setSessionCookie(c *gin.Context, raw string, expUnix int64) {
maxAge := int(time.Until(time.Unix(expUnix, 0)).Seconds())
if maxAge < 0 {

View File

@@ -117,6 +117,10 @@ func (h *BackendsHandler) Delete(c *gin.Context) {
response.NotFound(c, err)
return
}
if errors.Is(err, backends.ErrInUse) {
response.Conflict(c, err)
return
}
response.Internal(c, err)
return
}

View File

@@ -2,8 +2,15 @@ package handlers
import (
"context"
"crypto/x509"
"encoding/json"
"encoding/pem"
"fmt"
"log/slog"
"net/http"
"os"
"os/exec"
"strings"
"time"
"github.com/gin-gonic/gin"
@@ -14,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
@@ -27,6 +35,7 @@ type ClusterHandler struct {
Store *cluster.Store
LocalID string
Aggregator *aggregator.Aggregator
Version string // laufende Binary-Version, für Rolling-Update-Koordination
// TLSStore + Tokens: optional, gesetzt bei Phase 3.4. Erlauben das
// Generieren von Join-Tokens und das Issue-Cert für joining Peers.
@@ -36,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 {
@@ -57,12 +71,27 @@ 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)
g.GET("/status", h.Status)
g.GET("/system/load", h.SystemLoad)
g.DELETE("/nodes/:id", h.DeleteNode)
g.GET("/vip-settings", h.GetVIPSettings)
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)
@@ -111,6 +140,85 @@ func (h *ClusterHandler) DeleteNode(c *gin.Context) {
response.NoContent(c)
}
// GetVIPSettings liest die cluster_settings-Singleton-Row (VIP/VRRP-Config).
func (h *ClusterHandler) GetVIPSettings(c *gin.Context) {
if h.Store == nil {
response.NotFound(c, simpleError("cluster store not available"))
return
}
var cs vipSettingsRow
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
}
response.OK(c, cs)
}
// UpdateVIPSettings speichert die VIP/VRRP-Konfiguration und triggert
// einen Keepalived-Config-Render. Viewer-Schutz via RequireAdminForMutations-
// Middleware auf der authed-Group — kein Extra-Check nötig.
func (h *ClusterHandler) UpdateVIPSettings(c *gin.Context) {
var req vipSettingsRow
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, err)
return
}
if h.Store == nil {
response.NotFound(c, simpleError("cluster store not available"))
return
}
_, 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,
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.HBInterface), nullIfEmpty(req.HBSrcIP),
nullIfEmpty(req.HBPeerIP), req.HBRouterID, nullIfEmpty(req.GWCheckIP))
if err != nil {
response.Internal(c, err)
return
}
slog.Info("cluster: VIP settings updated", "vip", req.VIPAddress, "actor", actorOf(c))
// Keepalived-Config asynchron neu rendern
if h.PeerReloader != nil {
go func() {
rctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
if err := h.PeerReloader(rctx); err != nil {
slog.Warn("cluster: keepalived render after VIP update failed", "error", err)
}
}()
}
response.NoContent(c)
}
type vipSettingsRow struct {
VIPAddress *string `json:"vip_address"`
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 {
if s == nil || *s == "" {
return nil
}
return s
}
// RegisterPublic mountet die public (unauth) Endpoints — joining Peers
// haben noch keine Session/Cert, deshalb läuft /issue-cert vor der
// requireAuth-Middleware. Aufrufer muss diesen Group auf /api/v1 setzen
@@ -134,6 +242,89 @@ func (h *ClusterHandler) RegisterPublic(rg *gin.RouterGroup) {
func (h *ClusterHandler) RegisterAgent(rg *gin.RouterGroup) {
g := rg.Group("/agent/cluster")
g.POST("/peers", h.AgentRegisterPeer)
g.GET("/identity", h.AgentIdentity)
g.GET("/pg-replication-info", h.AgentPGReplicationInfo)
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
// genutzt um joining-Peers aktiv zu reconcilen wenn autoRegister (Push)
// fehlgeschlagen ist — Pull-Fallback.
func (h *ClusterHandler) AgentIdentity(c *gin.Context) {
if h.Store == nil || h.LocalID == "" {
response.NotFound(c, simpleError("node not registered"))
return
}
node, err := h.Store.Get(c.Request.Context(), h.LocalID)
if err != nil {
if err == cluster.ErrNotFound {
response.NotFound(c, simpleError("local node not in ha_nodes yet"))
return
}
response.Internal(c, err)
return
}
response.OK(c, node)
}
// AgentPGReplicationInfo gibt die Replication-Credentials für pg_basebackup
// zurück. Nur über den mTLS-Agent-Listener erreichbar. Liest das Passwort
// aus /var/lib/edgeguard/pg-replication-secret. Gibt 404 zurück wenn die
// Datei fehlt (cluster-init-replication noch nicht ausgeführt).
func (h *ClusterHandler) AgentPGReplicationInfo(c *gin.Context) {
const secretPath = "/var/lib/edgeguard/pg-replication-secret"
pass, err := readFileString(secretPath)
if err != nil {
response.NotFound(c, simpleError("pg-replication-secret nicht gefunden — cluster-init-replication auf dem Primary ausführen"))
return
}
// Host = eigene Public-IP aus ha_nodes (oder Fallback: FQDN)
host := ""
if h.Store != nil && h.LocalID != "" {
if node, err := h.Store.Get(c.Request.Context(), h.LocalID); err == nil {
if node.PublicIP != nil && *node.PublicIP != "" {
host = *node.PublicIP
}
if host == "" {
host = node.FQDN
}
}
}
response.OK(c, gin.H{
"host": host,
"port": 5432,
"user": "edgeguard_replicator",
"password": strings.TrimSpace(pass),
})
}
// AgentMasterKey gibt den Secrets-Master-Key zurück, damit cluster-setup-standby
// ihn auf dem Secondary synchronisieren kann. Nur über den mTLS-Agent-Listener
// erreichbar. Ohne gemeinsamen Master-Key können replizierte verschlüsselte
// Felder (WireGuard private keys, PSKs) auf dem Secondary nicht entschlüsselt werden.
func (h *ClusterHandler) AgentMasterKey(c *gin.Context) {
const keyPath = "/var/lib/edgeguard/.master_key"
data, err := os.ReadFile(keyPath)
if err != nil {
response.NotFound(c, simpleError("master key nicht gefunden"))
return
}
response.OK(c, gin.H{"key_hex": fmt.Sprintf("%x", data)})
}
func readFileString(path string) (string, error) {
b, err := os.ReadFile(path)
if err != nil {
return "", err
}
return string(b), nil
}
// PeerReloader: optionale Funktion die nach einem Auto-Register
@@ -147,6 +338,12 @@ func (h *ClusterHandler) WithPeerReloader(r PeerReloader) *ClusterHandler {
return h
}
// WithVersion: setzt die laufende Binary-Version für Rolling-Update-Koordination.
func (h *ClusterHandler) WithVersion(v string) *ClusterHandler {
h.Version = v
return h
}
func (h *ClusterHandler) ListNodes(c *gin.Context) {
nodes, err := h.Store.List(c.Request.Context())
if err != nil {
@@ -208,6 +405,23 @@ func (h *ClusterHandler) Status(c *gin.Context) {
if len(out.Peers) > 0 {
out.Mode = "cluster"
}
// Pull-Reconcile für joining-Peers: wenn ein Peer via Aggregator
// erreichbar ist aber noch mit Placeholder-ID in ha_nodes steht,
// holen wir seine echte Identity aktiv ab. Best-effort goroutine —
// blockiert die Status-Response nicht.
if h.Aggregator != nil && h.Store != nil {
var joining []models.HANode
for _, p := range out.Peers {
if p.Status == "joining" || p.Status == "pending" {
joining = append(joining, p)
}
}
if len(joining) > 0 {
go h.reconcileJoiningPeers(joining)
}
}
// Drift-Detection: jeder peer mit anderem config_hash als unser
// lokaler → Banner-Trigger im UI.
if localHash != nil && *localHash != "" {
@@ -289,29 +503,64 @@ func localSystemLoad() any {
// ── Phase 3.4: Cluster-Join Token Flow ────────────────────────────────
// GenerateJoinToken — Admin generiert einen one-shot Bootstrap-Token
// für einen neuen Peer. Token wird NUR EINMAL zurückgegeben; Server
// speichert keinen Klartext, beim Re-Use blockt der nonce-Tracker.
// für einen neuen Peer. Der FQDN des neuen Nodes wird vorab übergeben
// so dass er sofort in ha_nodes vorregistriert und im UI angezeigt werden
// kann. Token wird NUR EINMAL zurückgegeben.
func (h *ClusterHandler) GenerateJoinToken(c *gin.Context) {
var req struct {
NodeFQDN string `json:"node_fqdn"`
}
// Body optional — wenn leer, läuft der Flow ohne Pre-Register.
_ = c.ShouldBindJSON(&req)
token, exp, err := h.Tokens.Generate()
if err != nil {
response.Internal(c, err)
return
}
// Wir liefern auch die primary-fqdn + ca-fingerprint mit, damit
// das UI den Join-Befehl als kompletten curl/CLI-String anzeigen
// kann.
caCert, _, err := h.TLSStore.LoadCA()
caFP := ""
if err == nil && caCert != nil {
caFP = jointoken.CAFingerprint16(caCert.Raw)
}
// Pre-register: wenn ein FQDN übergeben wurde, schon jetzt in
// ha_nodes anlegen (status='pending') + Firewall-Reload. So ist
// der Node bekannt bevor er überhaupt antwortet, und @peer_ipv4
// wird beim issue-cert (wo wir die IP haben) nur noch updaten.
if req.NodeFQDN != "" && h.Store != nil {
go h.preRegisterByFQDN(req.NodeFQDN)
}
response.OK(c, gin.H{
"token": token,
"expires_at": exp.UTC().Format(time.RFC3339),
"ca_fingerprint": caFP,
"node_fqdn": req.NodeFQDN,
})
}
// preRegisterByFQDN legt einen ha_nodes-Eintrag mit status='pending' an
// bevor der Joiner überhaupt die Verbindung aufbaut. Idempotent dank
// ON CONFLICT. Kein Firewall-Reload hier — die IP ist noch unbekannt;
// das erledigt preRegisterJoiner wenn die issue-cert-Anfrage eintrifft.
func (h *ClusterHandler) preRegisterByFQDN(fqdn string) {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
nodeID := fmt.Sprintf("prenode-%s", strings.ReplaceAll(fqdn, ".", "-"))
n := models.HANode{
ID: nodeID,
Name: fqdn,
FQDN: fqdn,
APIURL: "https://" + fqdn + ":3443",
Role: "peer",
Status: "pending",
}
if _, err := h.Store.UpsertSelf(ctx, n); err != nil {
slog.Warn("cluster: pre-register by FQDN failed", "fqdn", fqdn, "error", err)
}
}
// IssueCert — Joining Peer POSTet seinen CSR + den Token. Wir verifizieren
// + konsumieren den Token, signieren den CSR mit unserer Cluster-CA und
// liefern {ca_cert, peer_cert} zurück. PUBLIC Endpoint — keine Session-
@@ -337,8 +586,8 @@ func (h *ClusterHandler) IssueCert(c *gin.Context) {
}
// consumedBy → Remote-IP. Audit-Trail wenn jemand Tokens stiehlt
// und vom falschen Host einlöst.
consumedBy := c.ClientIP()
if _, err := h.Tokens.Consume(c.Request.Context(), req.Token, consumedBy); err != nil {
clientIP := c.ClientIP()
if _, err := h.Tokens.Consume(c.Request.Context(), req.Token, clientIP); err != nil {
response.BadRequest(c, err)
return
}
@@ -353,12 +602,193 @@ func (h *ClusterHandler) IssueCert(c *gin.Context) {
response.Internal(c, err)
return
}
// Pre-register the joining node SYNCHRONOUSLY before returning the
// cert so that nftables @peer_ipv4 already contains the joiner's IP
// by the time they call autoRegister on port 8443. A goroutine here
// caused a race: cert returned → joiner calls autoRegister → nftables
// not updated yet → connection refused → status stays "joining".
if h.Store != nil && h.PeerReloader != nil {
h.preRegisterJoiner(c.Request.Context(), clientIP, req.CSR)
}
response.OK(c, issueCertResponse{
CACert: caPEM,
PeerCert: peerCert,
})
}
// preRegisterJoiner inserts a minimal ha_nodes row for the joining peer
// (using the CSR CN as FQDN and the HTTP client IP as public_ip), then
// triggers a firewall reload so @peer_ipv4 contains the new IP before
// the peer tries to call /agent/cluster/peers on port 8443.
// Uses a stable deterministic ID so re-joins are idempotent.
func (h *ClusterHandler) preRegisterJoiner(parent context.Context, clientIP, csrPEM string) {
ctx, cancel := context.WithTimeout(parent, 10*time.Second)
defer cancel()
fqdn := cnFromCSR(csrPEM)
if fqdn == "" {
fqdn = "joining-" + clientIP
}
nodeID := fmt.Sprintf("prenode-%s", strings.ReplaceAll(fqdn, ".", "-"))
n := models.HANode{
ID: nodeID,
Name: fqdn,
FQDN: fqdn,
APIURL: "https://" + fqdn + ":3443",
Role: "peer",
Status: "joining",
}
n.PublicIP = &clientIP
if _, err := h.Store.UpsertSelf(ctx, n); err != nil {
slog.Warn("cluster: pre-register joiner failed", "fqdn", fqdn, "ip", clientIP, "error", err)
return
}
if err := h.PeerReloader(ctx); err != nil {
slog.Warn("cluster: PeerReloader failed after pre-register", "error", err)
return
}
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 {
block, _ := pem.Decode([]byte(csrPEM))
if block == nil {
return ""
}
csr, err := x509.ParseCertificateRequest(block.Bytes)
if err != nil {
return ""
}
return csr.Subject.CommonName
}
// reconcileJoiningPeers versucht für jeden Peer im Status "joining" oder
// "pending" die echte Node-ID via /agent/cluster/identity zu holen und
// ihn in ha_nodes mit der richtigen ID einzutragen. Self-Healing-
// Fallback wenn autoRegister (Push von joining-Peer zu Primary) wegen
// eines temporären Netzwerkproblems fehlgeschlagen ist.
//
// Die public_ip des Placeholder-Rows wird in der neuen Row übernommen
// damit @peer_ipv4 (nftables) korrekt bleibt.
func (h *ClusterHandler) reconcileJoiningPeers(placeholders []models.HANode) {
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
results := h.Aggregator.FanOut(ctx, placeholders, "/agent/cluster/identity", h.LocalID)
changed := false
for i, res := range results {
if !res.OK || len(res.Data) == 0 {
continue
}
var identity models.HANode
if err := json.Unmarshal(res.Data, &identity); err != nil || identity.ID == "" {
continue
}
placeholder := placeholders[i]
if identity.ID == placeholder.ID {
continue // ID bereits korrekt
}
// Echte ID gefunden — row mit realer ID anlegen, public_ip aus
// dem Placeholder-Row übernehmen damit nftables korrekt bleibt.
n := identity
n.Status = "online"
if n.PublicIP == nil {
n.PublicIP = placeholder.PublicIP
}
if n.InternalIP == nil {
n.InternalIP = placeholder.InternalIP
}
// Placeholder zuerst löschen: ha_nodes hat UNIQUE(fqdn). Ohne
// dieses Delete würde UpsertSelf (ON CONFLICT(id)) mit fqdn-
// unique-Violation scheitern.
_ = h.Store.DeletePlaceholdersByFQDN(ctx, n.FQDN, n.ID)
out, err := h.Store.UpsertSelf(ctx, n)
if err != nil {
slog.Warn("cluster: reconcile joining peer: upsert failed",
"fqdn", n.FQDN, "real_id", n.ID, "error", err)
continue
}
changed = true
slog.Info("cluster: joining peer reconciled via identity pull",
"id", out.ID, "fqdn", out.FQDN, "placeholder_id", placeholder.ID)
}
if changed && h.PeerReloader != nil {
rctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := h.PeerReloader(rctx); err != nil {
slog.Warn("cluster: PeerReloader failed after reconcile", "error", err)
}
}
}
// AgentVersion gibt die laufende Binary-Version zurück. Wird vom Rolling-
// Update-Orchestrator gepollt um zu erkennen wann der Secondary die neue
// Version hat.
func (h *ClusterHandler) AgentVersion(c *gin.Context) {
response.OK(c, gin.H{"version": h.Version})
}
// AgentTriggerUpdate startet den Upgrade-Prozess auf diesem Node via
// systemd-run (detached). Wird vom Primary via mTLS aufgerufen um den
// Secondary zuerst zu aktualisieren (Rolling-Update). Pattern identisch
// zu /system/upgrade — nutzt dieselbe Sudoers-Whitelist aus dem postinst.
func (h *ClusterHandler) AgentTriggerUpdate(c *gin.Context) {
const scriptPath = "/var/lib/edgeguard/upgrade.sh"
const script = `#!/bin/bash
set -e
sleep 2
export DEBIAN_FRONTEND=noninteractive
dpkg --configure -a || true
retry_apt() {
local attempt=0 max=3 wait_for=15
while [ $attempt -lt $max ]; do
attempt=$((attempt + 1))
apt-get update -qq || true
if apt-get install -y -qq -o Dpkg::Options::=--force-confold \
edgeguard-api edgeguard-ui edgeguard; then return 0; fi
[ $attempt -lt $max ] && sleep $wait_for && wait_for=$((wait_for * 2))
done
return 1
}
retry_apt
echo "[upgrade] complete"
rm -f /var/lib/edgeguard/upgrade.sh
`
if err := os.WriteFile(scriptPath, []byte(script), 0o755); err != nil {
response.Internal(c, err)
return
}
const unitName = "edgeguard-upgrade.service"
_ = exec.Command("sudo", "-n", "/usr/bin/systemctl", "reset-failed", unitName).Run()
cmd := exec.Command("sudo", "-n", "/usr/bin/systemd-run",
"--unit="+unitName,
"--description=EdgeGuard self-upgrade",
"--collect",
"bash", scriptPath)
if err := cmd.Run(); err != nil {
slog.Warn("cluster: AgentTriggerUpdate: systemd-run failed", "error", err)
response.Internal(c, err)
return
}
slog.Info("cluster: rolling update triggered on this node by primary mTLS call",
"client", c.ClientIP())
c.JSON(http.StatusAccepted, gin.H{"status": "upgrading"})
}
var errInvalidJoinRequest = simpleError("missing token or csr")
type simpleError string
@@ -427,14 +857,15 @@ func (h *ClusterHandler) RenewSelf(c *gin.Context) {
// die wir wirklich brauchen — sonst kann ein joining Peer beliebige
// ha_nodes-Felder überschreiben.
type registerPeerRequest struct {
ID string `json:"id"` // Joiner's eigene node-id
Name string `json:"name"` // hostname
FQDN string `json:"fqdn"` // sollte mit Client-Cert-CN matchen
APIURL string `json:"api_url"` // https://<fqdn>
PublicIP string `json:"public_ip"` // optional
InternalIP string `json:"internal_ip"` // mTLS-Listener-IP (für peer_ipv4-Set)
MgmtIP string `json:"mgmt_ip"` // optional
Version string `json:"version"`
ID string `json:"id"` // Joiner's eigene node-id
Name string `json:"name"` // hostname
FQDN string `json:"fqdn"` // sollte mit Client-Cert-CN matchen
APIURL string `json:"api_url"` // https://<fqdn>
PublicIP string `json:"public_ip"` // optional
InternalIP string `json:"internal_ip"` // mTLS-Listener-IP (für peer_ipv4-Set)
MgmtIP string `json:"mgmt_ip"` // optional
Version string `json:"version"`
ConfigHash *string `json:"config_hash"` // nil=absent (don't change), ""=no user config
}
// AgentRegisterPeer: vom Joiner nach issue-cert via mTLS aufgerufen.
@@ -479,7 +910,7 @@ func (h *ClusterHandler) AgentRegisterPeer(c *gin.Context) {
FQDN: req.FQDN,
APIURL: req.APIURL,
Role: "peer",
Status: "joining",
Status: "online", // peer IS online — it just connected via mTLS
}
if req.PublicIP != "" {
v := req.PublicIP
@@ -497,17 +928,34 @@ func (h *ClusterHandler) AgentRegisterPeer(c *gin.Context) {
v := req.Version
n.Version = &v
}
if req.ConfigHash != nil {
n.ConfigHash = req.ConfigHash
}
// Placeholder zuerst löschen: ha_nodes hat UNIQUE(fqdn). Der INSERT
// in UpsertSelf verwendet ON CONFLICT(id) — greift NICHT bei fqdn-
// Konflikten. Ohne das Delete würde der INSERT mit "duplicate key on
// 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()

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

@@ -0,0 +1,258 @@
package handlers
import (
"context"
"encoding/json"
"log/slog"
"net/http"
"os"
"os/exec"
"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"
)
const rollingUpdateStateFile = "/var/lib/edgeguard/rolling-update-state.json"
const (
phaseIdle = "idle"
phaseUpdatingSecondary = "updating-secondary"
phaseWaitingSecondary = "waiting-secondary"
phaseUpdatingPrimary = "updating-primary"
phaseDone = "done"
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.
func FinishRollingUpdateIfPending() {
st := readRollingUpdateState()
if st.Phase == phaseUpdatingPrimary {
writeRollingUpdateState(RollingUpdateState{
Phase: phaseDone,
SecondaryID: st.SecondaryID,
SecondaryFQDN: st.SecondaryFQDN,
})
}
}
// RollingUpdateState hält den Fortschritt des Rolling-Updates.
// Persistiert in rollingUpdateStateFile damit der Status über
// einen kurzen API-Neustart hinaus lesbar bleibt.
type RollingUpdateState struct {
Phase string `json:"phase"`
SecondaryID string `json:"secondary_id,omitempty"`
SecondaryFQDN string `json:"secondary_fqdn,omitempty"`
StartedAt time.Time `json:"started_at,omitempty"`
UpdatedAt time.Time `json:"updated_at"`
Error string `json:"error,omitempty"`
}
func readRollingUpdateState() RollingUpdateState {
data, err := os.ReadFile(rollingUpdateStateFile)
if err != nil {
return RollingUpdateState{Phase: phaseIdle, UpdatedAt: time.Now()}
}
var s RollingUpdateState
if err := json.Unmarshal(data, &s); err != nil {
return RollingUpdateState{Phase: phaseIdle, UpdatedAt: time.Now()}
}
return s
}
func writeRollingUpdateState(s RollingUpdateState) {
s.UpdatedAt = time.Now()
data, err := json.Marshal(s)
if err != nil {
slog.Warn("rolling-update: failed to marshal state", "error", err)
return
}
if err := os.WriteFile(rollingUpdateStateFile, data, 0o600); err != nil {
slog.Warn("rolling-update: failed to write state file", "error", err)
}
}
// RollingUpdate startet den Rolling-Update-Prozess:
// 1. Secondary aktualisieren (via mTLS /agent/cluster/trigger-update)
// 2. Warten bis Secondary neue Version meldet
// 3. Primary (dieser Node) aktualisieren (wie /system/upgrade)
//
// Kein Cluster vorhanden → 409 zurück damit der Client auf /system/upgrade
// ausweichen kann. Wenn bereits ein Rolling-Update läuft → aktuellen State.
func (h *ClusterHandler) RollingUpdate(c *gin.Context) {
if h.Aggregator == nil || h.Store == nil {
c.JSON(http.StatusConflict, gin.H{"error": "no cluster — use /system/upgrade"})
return
}
st := readRollingUpdateState()
if st.Phase != phaseIdle && st.Phase != phaseFailed && st.Phase != phaseDone {
response.OK(c, st)
return
}
nodes, err := h.Store.List(c.Request.Context())
if err != nil {
response.Internal(c, err)
return
}
var secondary *models.HANode
for i := range nodes {
if nodes[i].ID != h.LocalID {
secondary = &nodes[i]
break
}
}
if secondary == nil {
c.JSON(http.StatusConflict, gin.H{"error": "no peer node — use /system/upgrade"})
return
}
newState := RollingUpdateState{
Phase: phaseUpdatingSecondary,
SecondaryID: secondary.ID,
SecondaryFQDN: secondary.FQDN,
StartedAt: time.Now(),
}
writeRollingUpdateState(newState)
slog.Info("rolling-update: started", "secondary", secondary.FQDN)
go h.runRollingUpdate(secondary)
c.JSON(http.StatusAccepted, newState)
}
// 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.
func (h *ClusterHandler) RollingUpdateStatus(c *gin.Context) {
st := readRollingUpdateState()
response.OK(c, st)
if st.Phase == phaseDone {
writeRollingUpdateState(RollingUpdateState{Phase: phaseIdle})
}
}
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 {
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: 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)
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
}
}
}
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
}
// 3. Primary (uns selbst) aktualisieren — identisch zu /system/upgrade
writeRollingUpdateState(RollingUpdateState{
Phase: phaseUpdatingPrimary,
SecondaryID: secondary.ID,
SecondaryFQDN: secondary.FQDN,
})
slog.Info("rolling-update: triggering primary self-upgrade")
const scriptPath = "/var/lib/edgeguard/upgrade.sh"
const script = `#!/bin/bash
set -e
sleep 2
export DEBIAN_FRONTEND=noninteractive
dpkg --configure -a || true
retry_apt() {
local attempt=0 max=3 wait_for=15
while [ $attempt -lt $max ]; do
attempt=$((attempt + 1))
apt-get update -qq || true
if apt-get install -y -qq -o Dpkg::Options::=--force-confold \
edgeguard-api edgeguard-ui edgeguard; then return 0; fi
[ $attempt -lt $max ] && sleep $wait_for && wait_for=$((wait_for * 2))
done
return 1
}
retry_apt
echo "[upgrade] complete"
rm -f /var/lib/edgeguard/upgrade.sh
`
if err := os.WriteFile(scriptPath, []byte(script), 0o755); err != nil {
writeRollingUpdateState(RollingUpdateState{
Phase: phaseFailed,
SecondaryID: secondary.ID,
SecondaryFQDN: secondary.FQDN,
Error: "write upgrade script: " + err.Error(),
})
return
}
const unitName = "edgeguard-upgrade.service"
_ = exec.Command("sudo", "-n", "/usr/bin/systemctl", "reset-failed", unitName).Run()
cmd := exec.Command("sudo", "-n", "/usr/bin/systemd-run",
"--unit="+unitName,
"--description=EdgeGuard self-upgrade",
"--collect",
"bash", scriptPath)
if err := cmd.Run(); err != nil {
writeRollingUpdateState(RollingUpdateState{
Phase: phaseFailed,
SecondaryID: secondary.ID,
SecondaryFQDN: secondary.FQDN,
Error: "systemd-run failed: " + err.Error(),
})
slog.Warn("rolling-update: primary systemd-run failed", "error", err)
return
}
// State bleibt "updating-primary" — der Primary restartet gleich.
// UI erkennt Version-Flip via /system/health und schließt den Flow.
slog.Info("rolling-update: primary upgrade dispatched, process will restart")
}

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

@@ -3,7 +3,12 @@ package handlers
import (
"context"
"errors"
"fmt"
"log/slog"
"net"
"os/exec"
"strconv"
"strings"
"github.com/gin-gonic/gin"
@@ -54,6 +59,8 @@ func (h *DNSHandler) Register(rg *gin.RouterGroup) {
g.GET("/settings", h.GetSettings)
g.PUT("/settings", h.UpdateSettings)
g.GET("/stats", h.Stats)
g.POST("/flush-cache", h.FlushCache)
}
// ── Zones ──────────────────────────────────────────────────────
@@ -145,6 +152,8 @@ func (h *DNSHandler) DeleteZone(c *gin.Context) {
response.Internal(c, err)
return
}
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "dns.zone.delete",
strconv.FormatInt(id, 10), gin.H{"id": id}, h.NodeID)
response.NoContent(c)
h.reload(c.Request.Context(), "zone.delete")
}
@@ -256,6 +265,8 @@ func (h *DNSHandler) DeleteRecord(c *gin.Context) {
response.Internal(c, err)
return
}
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "dns.record.delete",
strconv.FormatInt(id, 10), gin.H{"id": id}, h.NodeID)
response.NoContent(c)
h.reload(c.Request.Context(), "record.delete")
}
@@ -277,6 +288,10 @@ func (h *DNSHandler) UpdateSettings(c *gin.Context) {
response.BadRequest(c, err)
return
}
if err := validateSettings(&req); err != nil {
response.BadRequest(c, err)
return
}
out, err := h.Repo.UpdateSettings(c.Request.Context(), req)
if err != nil {
response.Internal(c, err)
@@ -287,8 +302,68 @@ func (h *DNSHandler) UpdateSettings(c *gin.Context) {
h.reload(c.Request.Context(), "settings.update")
}
// FlushCache runs `unbound-control flush_zone .` which discards all
// 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(), "/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)
return
}
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "dns.flush-cache", "unbound", nil, h.NodeID)
response.OK(c, gin.H{"message": "cache flushed", "output": string(out)})
}
// ── Validation ─────────────────────────────────────────────────
// validateSettings checks user-supplied DNS global settings before they
// reach unbound. A malformed upstream IP or CIDR would cause unbound to
// fail on the next reload without any visible error.
func validateSettings(s *models.DNSSettings) error {
if s.ListenPort < 1 || s.ListenPort > 65535 {
return fmt.Errorf("listen_port %d out of range (1-65535)", s.ListenPort)
}
if s.CacheMaxTTL < s.CacheMinTTL {
return fmt.Errorf("cache_max_ttl (%d) must be ≥ cache_min_ttl (%d)", s.CacheMaxTTL, s.CacheMinTTL)
}
for _, raw := range strings.Split(s.UpstreamForwards, ",") {
entry := strings.TrimSpace(raw)
if entry == "" {
continue
}
// strip optional @port suffix (e.g. 1.1.1.1@853)
host, _, _ := strings.Cut(entry, "@")
if net.ParseIP(host) == nil {
return fmt.Errorf("invalid upstream forwarder IP: %q", host)
}
}
for _, raw := range strings.Split(s.AccessACL, ",") {
entry := strings.TrimSpace(raw)
if entry == "" {
continue
}
if strings.Contains(entry, "/") {
if _, _, err := net.ParseCIDR(entry); err != nil {
return fmt.Errorf("invalid access ACL CIDR: %q", entry)
}
} else if net.ParseIP(entry) == nil {
return fmt.Errorf("invalid access ACL IP: %q", entry)
}
}
for _, raw := range strings.Split(s.ListenAddresses, ",") {
addr := strings.TrimSpace(raw)
if addr == "" {
continue
}
if net.ParseIP(addr) == nil {
return fmt.Errorf("invalid listen address: %q", addr)
}
}
return nil
}
func validateZone(z *models.DNSZone) error {
if z.Name == "" {
return errors.New("name required")
@@ -309,6 +384,71 @@ func validateZone(z *models.DNSZone) error {
return nil
}
// Stats liefert Unbound-Resolver-Statistiken via `unbound-control stats_noreset`.
// 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("/usr/sbin/unbound-control", "stats_noreset").Output()
if err != nil {
response.OK(c, gin.H{
"error": "unbound-control nicht verfügbar: " + err.Error(),
})
return
}
response.OK(c, gin.H{"stats": parseUnboundStats(string(out))})
}
type unboundStats struct {
TotalQueries int64 `json:"total_queries"`
CacheHits int64 `json:"cache_hits"`
CacheMiss int64 `json:"cache_miss"`
CacheHitPct float64 `json:"cache_hit_pct"`
RecursiveReplies int64 `json:"recursive_replies"`
Prefetch int64 `json:"prefetch"`
RateLimited int64 `json:"rate_limited"`
RRSetCacheBytes int64 `json:"rrset_cache_bytes"`
MsgCacheBytes int64 `json:"msg_cache_bytes"`
TCPUsage int64 `json:"tcp_usage"`
Unwanted int64 `json:"unwanted"`
}
func parseUnboundStats(out string) unboundStats {
s := unboundStats{}
for _, line := range strings.Split(out, "\n") {
k, v, ok := strings.Cut(line, "=")
if !ok {
continue
}
n, _ := strconv.ParseInt(strings.TrimSpace(v), 10, 64)
switch strings.TrimSpace(k) {
case "total.num.queries":
s.TotalQueries = n
case "total.num.cachehits":
s.CacheHits = n
case "total.num.cachemiss":
s.CacheMiss = n
case "total.num.recursivereplies":
s.RecursiveReplies = n
case "total.num.prefetch":
s.Prefetch = n
case "total.num.queries_ip_ratelimited":
s.RateLimited = n
case "mem.cache.rrset":
s.RRSetCacheBytes = n
case "mem.cache.message":
s.MsgCacheBytes = n
case "total.tcpusage":
s.TCPUsage = n
case "unwanted.queries":
s.Unwanted = n
}
}
if s.TotalQueries > 0 {
s.CacheHitPct = float64(s.CacheHits) / float64(s.TotalQueries) * 100
}
return s
}
func validateRecord(r *models.DNSRecord) error {
if r.Name == "" {
return errors.New("name required")

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

@@ -4,7 +4,9 @@ import (
"context"
"errors"
"log/slog"
"os/exec"
"strconv"
"strings"
"github.com/gin-gonic/gin"
@@ -35,7 +37,12 @@ func (h *ForwardProxyHandler) reload(ctx context.Context, op string) {
}
func (h *ForwardProxyHandler) Register(rg *gin.RouterGroup) {
g := rg.Group("/forward-proxy/acls")
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)
g.POST("", h.Create)
g.GET("/:id", h.Get)
@@ -43,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 {
@@ -135,6 +170,68 @@ func (h *ForwardProxyHandler) Delete(c *gin.Context) {
h.reload(c.Request.Context(), "delete")
}
// Stats liefert Squid-Cache-Statistiken via `squidclient mgr:counters`.
// Die Ausgabe enthält HTTP-Header gefolgt von key = value Zeilen.
func (h *ForwardProxyHandler) Stats(c *gin.Context) {
out, err := exec.Command("squidclient", "-h", "127.0.0.1", "-p", "3128", "mgr:counters").Output()
if err != nil {
response.OK(c, gin.H{
"error": "squidclient nicht verfügbar: " + err.Error(),
})
return
}
response.OK(c, gin.H{"stats": parseSquidCounters(string(out))})
}
type squidStats struct {
ClientRequests int64 `json:"client_requests"`
CacheHits int64 `json:"cache_hits"`
CacheHitPct float64 `json:"cache_hit_pct"`
ClientErrors int64 `json:"client_errors"`
BytesIn int64 `json:"bytes_in"`
BytesOut int64 `json:"bytes_out"`
ServerRequests int64 `json:"server_requests"`
ServerErrors int64 `json:"server_errors"`
}
func parseSquidCounters(out string) squidStats {
// squidclient prefixes an HTTP response header block — skip it.
body := out
if idx := strings.Index(out, "\r\n\r\n"); idx >= 0 {
body = out[idx+4:]
} else if idx := strings.Index(out, "\n\n"); idx >= 0 {
body = out[idx+2:]
}
s := squidStats{}
for _, line := range strings.Split(body, "\n") {
k, v, ok := strings.Cut(line, "=")
if !ok {
continue
}
n, _ := strconv.ParseInt(strings.TrimSpace(v), 10, 64)
switch strings.TrimSpace(k) {
case "client_http.requests":
s.ClientRequests = n
case "client_http.hits":
s.CacheHits = n
case "client_http.errors":
s.ClientErrors = n
case "client_http.kbytes_in":
s.BytesIn = n * 1024
case "client_http.kbytes_out":
s.BytesOut = n * 1024
case "server.all.requests":
s.ServerRequests = n
case "server.all.errors":
s.ServerErrors = n
}
}
if s.ClientRequests > 0 {
s.CacheHitPct = float64(s.CacheHits) / float64(s.ClientRequests) * 100
}
return s
}
// validateACL prüft Name (squid-konform), action, acl_type. Squid
// nimmt viele Typen — wir whitelisten die, die in einem Forward-
// Proxy-Setup üblich sind, damit Tippfehler nicht beim reload

View File

@@ -98,8 +98,10 @@ func (h *HAProxyStatsHandler) Stats(c *gin.Context) {
svname := safeAt(fields, colIdx["svname"])
pxname := safeAt(fields, colIdx["pxname"])
// Skip our internal stats listener and the BACKEND summary row.
if pxname == "internal_stats" || svname == "BACKEND" || svname == "" {
// Skip internal infrastructure rows and the BACKEND summary row.
// api_backend = management API; rl_* = rate-limit stick-tables (no servers).
if pxname == "internal_stats" || pxname == "api_backend" ||
strings.HasPrefix(pxname, "rl_") || svname == "BACKEND" || svname == "" {
continue
}
@@ -135,7 +137,7 @@ func (h *HAProxyStatsHandler) Stats(c *gin.Context) {
}
func safeAt(fields []string, i int) string {
if i <= 0 || i >= len(fields) {
if i < 0 || i >= len(fields) {
return ""
}
return fields[i]

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

View File

@@ -6,6 +6,7 @@ import (
"fmt"
"log/slog"
"os/exec"
"strconv"
"strings"
"github.com/gin-gonic/gin"
@@ -41,6 +42,8 @@ func (h *NTPHandler) Register(rg *gin.RouterGroup) {
g.GET("/settings", h.GetSettings)
g.PUT("/settings", h.UpdateSettings)
g.GET("/status", h.Status)
g.GET("/sources", h.Sources)
g.POST("/force-sync", h.ForceSync)
p := g.Group("/pools")
p.GET("", h.ListPools)
@@ -230,10 +233,95 @@ func (h *NTPHandler) DeletePool(c *gin.Context) {
response.Internal(c, err)
return
}
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "ntp.pool.delete",
strconv.FormatInt(id, 10), gin.H{"id": id}, h.NodeID)
response.NoContent(c)
h.reload(c.Request.Context(), "pool.delete")
}
// ForceSync runs `chronyc makestep` which immediately adjusts the
// system clock to the current NTP reference. Useful after a long
// outage or VM migration where the clock has drifted by more than
// the 1ms default slew threshold.
func (h *NTPHandler) ForceSync(c *gin.Context) {
out, err := exec.Command("chronyc", "makestep").CombinedOutput()
if err != nil {
slog.Error("ntp force-sync failed", "err", err, "out", string(out))
response.Internal(c, err)
return
}
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "ntp.force-sync", "chrony", nil, h.NodeID)
response.OK(c, gin.H{"message": "clock stepped", "output": string(out)})
}
// Sources liefert die aktuellen NTP-Quellen via `chronyc sources`.
// Jede Zeile wird in ein NTPSource-Objekt geparst und als Array zurückgegeben.
func (h *NTPHandler) Sources(c *gin.Context) {
out, err := exec.Command("chronyc", "sources").Output()
if err != nil {
response.OK(c, gin.H{
"sources": []any{},
"error": "chronyc nicht verfügbar: " + err.Error(),
})
return
}
response.OK(c, gin.H{"sources": parseChronymSources(string(out))})
}
type ntpSource struct {
Mode string `json:"mode"`
State string `json:"state"`
Active bool `json:"active"`
Name string `json:"name"`
Stratum int `json:"stratum"`
Poll int `json:"poll"`
Reach string `json:"reach"`
LastRx string `json:"last_rx"`
Sample string `json:"sample"`
}
func parseChronymSources(out string) []ntpSource {
modeMap := map[byte]string{'^': "server", '=': "peer", '#': "local"}
stateMap := map[byte]string{
'*': "synced", '+': "combined", '-': "not_combined",
'?': "unreachable", 'x': "error", '~': "variable",
}
var srcs []ntpSource
for _, line := range strings.Split(out, "\n") {
if len(line) < 2 {
continue
}
mode, ok := modeMap[line[0]]
if !ok {
continue
}
stateChar := line[1]
stateStr, ok := stateMap[stateChar]
if !ok {
stateStr = string(stateChar)
}
fields := strings.Fields(strings.TrimSpace(line[2:]))
if len(fields) < 5 {
continue
}
src := ntpSource{
Mode: mode,
State: stateStr,
Active: stateChar == '*' || stateChar == '+',
Name: fields[0],
Reach: fields[3],
LastRx: fields[4],
}
fmt.Sscanf(fields[1], "%d", &src.Stratum)
fmt.Sscanf(fields[2], "%d", &src.Poll)
if len(fields) >= 6 {
src.Sample = strings.Join(fields[5:], " ")
}
srcs = append(srcs, src)
}
return srcs
}
func validateNTPPool(p *models.NTPPool) error {
if p.Address == "" {
return errors.New("address required")

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

@@ -82,6 +82,10 @@ func Forbidden(c *gin.Context, err error) {
Err(c, http.StatusForbidden, err)
}
func Conflict(c *gin.Context, err error) {
Err(c, http.StatusConflict, err)
}
func Internal(c *gin.Context, err error) {
Err(c, http.StatusInternalServerError, err)
}

View File

@@ -1,10 +1,20 @@
package handlers
import (
"context"
"fmt"
"log/slog"
"net"
"strings"
"time"
"github.com/gin-gonic/gin"
"git.netcell-it.de/projekte/edgeguard-native/internal/cluster"
"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"
"git.netcell-it.de/projekte/edgeguard-native/internal/services/clusterjoin"
"git.netcell-it.de/projekte/edgeguard-native/internal/services/setup"
)
@@ -12,9 +22,12 @@ import (
// are mounted before SetupGate so they remain reachable while the API
// is in setup mode.
type SetupHandler struct {
Store *setup.Store
Audit *audit.Repo
NodeID string
Store *setup.Store
Audit *audit.Repo
NodeID string
Version string
ClusterStore *cluster.Store
PeerReloader PeerReloader
}
func NewSetupHandler(store *setup.Store) *SetupHandler {
@@ -30,10 +43,27 @@ func (h *SetupHandler) WithAudit(a *audit.Repo, nodeID string) *SetupHandler {
return h
}
// WithVersion macht die laufende Version für auto-register verfügbar.
func (h *SetupHandler) WithVersion(v string) *SetupHandler {
h.Version = v
return h
}
// WithClusterSupport erlaubt dem JoinCluster-Handler nach dem Join den
// Primary in der lokalen ha_nodes zu registrieren + nftables neu zu laden,
// damit Port 8443 bidirektional offen ist.
func (h *SetupHandler) WithClusterSupport(store *cluster.Store, reloader PeerReloader) *SetupHandler {
h.ClusterStore = store
h.PeerReloader = reloader
return h
}
func (h *SetupHandler) Register(rg *gin.RouterGroup) {
g := rg.Group("/setup")
g.GET("/status", h.Status)
g.POST("/complete", h.Complete)
g.POST("/complete-node", h.CompleteAsNode)
g.POST("/join-cluster", h.JoinCluster)
}
// RegisterAuthed mountet die Endpoints die nach abgeschlossenem Setup
@@ -111,3 +141,130 @@ func (h *SetupHandler) Complete(c *gin.Context) {
"fqdn": st.FQDN,
})
}
func (h *SetupHandler) CompleteAsNode(c *gin.Context) {
var req setup.NodeRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, err)
return
}
st, err := h.Store.CompleteAsNode(req)
if err != nil {
response.BadRequest(c, err)
return
}
response.OK(c, gin.H{
"completed": st.Completed,
"is_cluster_node": st.IsClusterNode,
"fqdn": st.FQDN,
})
}
// JoinCluster performs the full cluster-join flow from the setup wizard:
// fetches certs from the primary, writes them to disk, then marks setup
// as completed. No CLI required.
func (h *SetupHandler) JoinCluster(c *gin.Context) {
var body struct {
FQDN string `json:"fqdn" binding:"required"`
ACMEEmail string `json:"acme_email" binding:"required,email"`
PrimaryFQDN string `json:"primary_fqdn" binding:"required"`
Token string `json:"token" binding:"required"`
}
if err := c.ShouldBindJSON(&body); err != nil {
response.BadRequest(c, err)
return
}
fqdn := strings.ToLower(strings.TrimSpace(body.FQDN))
if err := clusterjoin.Join(clusterjoin.Request{
PrimaryFQDN: body.PrimaryFQDN,
Token: strings.TrimSpace(body.Token),
CommonName: fqdn,
Insecure: true, // security comes from the HMAC token, not TLS cert trust
Force: true, // bootstrap self-signed cert must be replaced
Version: h.Version,
NodeID: h.NodeID,
}); err != nil {
response.BadRequest(c, err)
return
}
st, err := h.Store.CompleteAsNode(setup.NodeRequest{
FQDN: fqdn,
ACMEEmail: body.ACMEEmail,
PrimaryFQDN: strings.ToLower(strings.TrimSpace(body.PrimaryFQDN)),
})
if err != nil {
response.Internal(c, err)
return
}
// Pre-register the primary in our local ha_nodes so its IP lands in
// @peer_ipv4 and port 8443 is open bidirectionally.
if h.ClusterStore != nil && h.PeerReloader != nil {
go h.preRegisterPrimary(body.PrimaryFQDN)
}
response.OK(c, gin.H{
"completed": st.Completed,
"is_cluster_node": st.IsClusterNode,
"fqdn": st.FQDN,
})
}
// StartupPeerSync is called once after the DB pool and ClusterStore are
// ready. On cluster nodes it re-registers the primary in the local ha_nodes
// and reloads nftables so @peer_ipv4 is correct after a package update or
// reboot — without requiring a new join.
func (h *SetupHandler) StartupPeerSync() {
if h.ClusterStore == nil || h.PeerReloader == nil {
return
}
st, err := h.Store.Load()
if err != nil || st == nil || !st.IsClusterNode || st.PrimaryFQDN == "" {
return
}
h.preRegisterPrimary(st.PrimaryFQDN)
}
// preRegisterPrimary inserts the primary node into the local ha_nodes with
// its resolved IP so nftables @peer_ipv4 allows port 8443 from the primary.
// Uses a stable ID derived from the FQDN so repeated calls are idempotent.
func (h *SetupHandler) preRegisterPrimary(primaryFQDN string) {
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
primaryFQDN = strings.ToLower(strings.TrimSpace(primaryFQDN))
addrs, err := net.DefaultResolver.LookupHost(ctx, primaryFQDN)
if err != nil || len(addrs) == 0 {
slog.Warn("setup: could not resolve primary FQDN for pre-registration",
"fqdn", primaryFQDN, "error", err)
return
}
ip := addrs[0]
// Stable ID so repeated calls (join + startup) don't accumulate rows.
nodeID := fmt.Sprintf("prenode-%s", strings.ReplaceAll(primaryFQDN, ".", "-"))
n := models.HANode{
ID: nodeID,
Name: primaryFQDN,
FQDN: primaryFQDN,
APIURL: "https://" + primaryFQDN + ":3443",
Role: "primary",
Status: "online",
}
n.PublicIP = &ip
if _, err := h.ClusterStore.UpsertSelf(ctx, n); err != nil {
slog.Warn("setup: pre-register primary in ha_nodes failed", "fqdn", primaryFQDN, "error", err)
return
}
if err := h.PeerReloader(ctx); err != nil {
slog.Warn("setup: PeerReloader failed after primary pre-register", "error", err)
return
}
slog.Info("setup: primary pre-registered locally, firewall updated",
"fqdn", primaryFQDN, "ip", ip)
}

View File

@@ -3,11 +3,13 @@ package handlers
import (
"bufio"
stdcontext "context"
"errors"
"log/slog"
"net"
"net/http"
"os"
"os/exec"
"runtime"
"strconv"
"strings"
"syscall"
@@ -21,6 +23,7 @@ import (
aptsvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/apt"
"git.netcell-it.de/projekte/edgeguard-native/internal/services/audit"
"git.netcell-it.de/projekte/edgeguard-native/internal/services/setup"
usersvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/users"
)
// SystemHandler covers /system/health, /system/package-versions,
@@ -40,6 +43,12 @@ type SystemHandler struct {
// in main.go after generators are constructed. Read-only access —
// no writes, no reloads.
ConfigPreviewers map[string]func(stdcontext.Context) (string, error)
// ExtraReloaders: additional service renderers triggered by
// RenderConfigs. Keyed by service name (nftables, wireguard, etc.).
ExtraReloaders map[string]func(stdcontext.Context) error
// Users: optional — wired after DB pool opens. Used by AgentAuthCheck
// so cluster peers can verify credentials against this node's DB.
Users *usersvc.Repo
}
func NewSystemHandler(version string) *SystemHandler {
@@ -75,6 +84,20 @@ func (h *SystemHandler) WithConfigPreviewers(previewers map[string]func(stdconte
return h
}
// WithAllReloaders injectet alle Service-Reloader für RenderConfigs.
// Reihenfolge: haproxy ist bereits in HAProxyReloader; extras sind die
// restlichen Dienste (nftables, wireguard, squid, unbound, chrony).
func (h *SystemHandler) WithAllReloaders(extras map[string]func(stdcontext.Context) error) *SystemHandler {
h.ExtraReloaders = extras
return h
}
// WithUsers injectet das Users-Repo für AgentAuthCheck.
func (h *SystemHandler) WithUsers(u *usersvc.Repo) *SystemHandler {
h.Users = u
return h
}
func (h *SystemHandler) Register(rg *gin.RouterGroup) {
g := rg.Group("/system")
g.GET("/health", h.Health)
@@ -95,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-
@@ -112,8 +137,50 @@ func (h *SystemHandler) RegisterAgent(rg *gin.RouterGroup) {
g := rg.Group("/agent/system")
g.GET("/health", h.Health)
g.GET("/resources", h.Resources)
// Auth-Federation: Cluster-Nodes verifizieren Credentials gegen diesen
// Node via mTLS. Nur über den Agent-Listener (:8443) erreichbar.
rg.POST("/agent/auth/check", h.AgentAuthCheck)
}
// AgentAuthCheck verifies email+password against the local users table
// and setup.json. Called by cluster nodes over mTLS when local auth fails
// so users can log in on any cluster node with the primary's credentials.
func (h *SystemHandler) AgentAuthCheck(c *gin.Context) {
var req struct {
Email string `json:"email"`
Password string `json:"password"`
}
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, err)
return
}
email := strings.TrimSpace(strings.ToLower(req.Email))
if email == "" || req.Password == "" {
response.Unauthorized(c, errInvalidCreds)
return
}
// Check local users table first.
if h.Users != nil {
u, hash, err := h.Users.FindByEmail(c.Request.Context(), email)
if err == nil && u.Active && usersvc.VerifyPassword(hash, req.Password) {
response.OK(c, gin.H{"actor": u.Email, "role": u.Role})
return
}
}
// Fallback: setup.json admin.
if h.Setup != nil {
st, _ := h.Setup.Load()
if st != nil && strings.EqualFold(st.AdminEmail, email) && st.VerifyAdminPassword(req.Password) {
response.OK(c, gin.H{"actor": st.AdminEmail, "role": "admin"})
return
}
}
response.Unauthorized(c, errInvalidCreds)
}
var errInvalidCreds = errors.New("invalid_credentials")
// servicesToCheck is the curated list shown on the dashboard
// service-health-grid. Order matters (UI renders in this sequence).
// Each entry is a (label, systemd-unit) pair — label is what the
@@ -123,10 +190,14 @@ var servicesToCheck = []struct{ Label, Unit string }{
{"edgeguard-scheduler", "edgeguard-scheduler"},
{"haproxy", "haproxy"},
{"nftables", "nftables"},
{"keepalived", "keepalived"},
{"unbound", "unbound"},
{"chrony", "chrony"},
{"squid", "squid"},
{"postgresql", "postgresql"},
{"crowdsec", "crowdsec"},
{"crowdsec-firewall-bouncer", "crowdsec-firewall-bouncer"},
{"edgeguard-waf", "edgeguard-waf"},
}
type serviceStatus struct {
@@ -200,6 +271,7 @@ type resources struct {
LoadAvg1 float64 `json:"load_avg_1"`
LoadAvg5 float64 `json:"load_avg_5"`
LoadAvg15 float64 `json:"load_avg_15"`
NumCPUs int `json:"num_cpus"`
MemTotalKB int64 `json:"mem_total_kb"`
MemAvailKB int64 `json:"mem_avail_kb"`
MemUsedPct float64 `json:"mem_used_pct"`
@@ -223,7 +295,7 @@ func (h *SystemHandler) Resources(c *gin.Context) {
// gin.Context erstellen kann. Best-effort: fehlende Quellen lassen
// die jeweiligen Felder einfach auf 0.
func computeLocalSystemResources() resources {
r := resources{}
r := resources{NumCPUs: runtime.NumCPU()}
if data, err := os.ReadFile("/proc/loadavg"); err == nil {
f := strings.Fields(string(data))
if len(f) >= 3 {
@@ -564,31 +636,94 @@ func (h *SystemHandler) ServiceRestart(c *gin.Context) {
response.OK(c, gin.H{"ok": true, "service": svc})
}
// RenderConfigs erzwingt ein Re-Render aller Service-Configs aus dem
// aktuellen DB-State. Wenn der HAProxyReloader gesetzt ist, läuft der
// (rendert haproxy.cfg + reload). Praktisch wenn ein Operator denkt
// dass die generierte Config nicht mehr mit der DB übereinstimmt
// (Drift, Manual-Edit, etc.).
//
// v1 macht NUR haproxy — weitere Renderer (firewall, dns, ntp, wg,
// squid) sind per Handler an die jeweiligen Mutations-Endpoints
// gekoppelt; für die fehlt aktuell ein generisches "render all".
func (h *SystemHandler) RenderConfigs(c *gin.Context) {
if h.HAProxyReloader == nil {
response.Err(c, http.StatusServiceUnavailable, simpleErr("renderer not wired"))
// 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
}
ctx, cancel := stdcontext.WithTimeout(c.Request.Context(), 10*time.Second)
defer cancel()
if err := h.HAProxyReloader(ctx); err != nil {
response.Internal(c, err)
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.render_configs",
"", gin.H{}, h.NodeID)
_ = 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, "rendered": []string{"haproxy"}})
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
// und als partial-Antwort zurückgegeben — erfolgreich gerenderte
// Dienste stehen in "rendered", fehlgeschlagene in "errors".
func (h *SystemHandler) RenderConfigs(c *gin.Context) {
if h.HAProxyReloader == nil && len(h.ExtraReloaders) == 0 {
response.Err(c, http.StatusServiceUnavailable, simpleErr("renderer not wired"))
return
}
ctx, cancel := stdcontext.WithTimeout(c.Request.Context(), 30*time.Second)
defer cancel()
rendered := []string{}
errs := map[string]string{}
if h.HAProxyReloader != nil {
if err := h.HAProxyReloader(ctx); err != nil {
errs["haproxy"] = err.Error()
} else {
rendered = append(rendered, "haproxy")
}
}
// Defined order so the audit log is deterministic.
order := []string{"nftables", "wireguard", "squid", "unbound", "chrony"}
for _, name := range order {
fn, ok := h.ExtraReloaders[name]
if !ok {
continue
}
if err := fn(ctx); err != nil {
errs[name] = err.Error()
} else {
rendered = append(rendered, name)
}
}
if h.Audit != nil {
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "system.render_configs",
"", gin.H{"rendered": rendered, "errors": errs}, h.NodeID)
}
response.OK(c, gin.H{"ok": len(errs) == 0, "rendered": rendered, "errors": errs})
}
// UpgradeStatus liefert den Status des letzten Self-Upgrade-Versuchs.
@@ -725,10 +860,31 @@ func (h *SystemHandler) ConfigPreview(c *gin.Context) {
}
func (h *SystemHandler) Health(c *gin.Context) {
response.OK(c, gin.H{
resp := gin.H{
"status": "ok",
"version": h.Version,
})
}
if hn, err := os.Hostname(); err == nil {
resp["hostname"] = hn
}
if data, err := os.ReadFile("/proc/version"); err == nil {
// /proc/version: "Linux version 6.x.y (...)" — Kurzform: alles
// bis zum ersten '(' kürzen.
line := strings.TrimSpace(string(data))
if idx := strings.Index(line, " ("); idx > 0 {
line = strings.TrimSpace(line[:idx])
}
resp["kernel"] = line
}
if data, err := os.ReadFile("/etc/os-release"); err == nil {
for _, line := range strings.Split(string(data), "\n") {
if k, v, ok := strings.Cut(line, "="); ok && k == "PRETTY_NAME" {
resp["os"] = strings.Trim(v, `"`)
break
}
}
}
response.OK(c, resp)
}
// PackageVersions reports installed and available versions for the
@@ -831,6 +987,10 @@ rm -f /var/lib/edgeguard/upgrade.sh
_ = fallback.Process.Release()
}
if h.Audit != nil {
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "system.upgrade",
"", gin.H{"unit": unitName}, h.NodeID)
}
c.JSON(http.StatusAccepted, response.Envelope{
Data: gin.H{"status": "upgrading", "unit": unitName},
Error: nil,
@@ -970,6 +1130,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

@@ -83,6 +83,8 @@ func (h *WireguardHandler) Register(rg *gin.RouterGroup) {
// row per (iface, peer) with last_handshake + transfer counters.
// Polled by the UI every 10s; no DB write.
g.GET("/status", h.Status)
g.GET("/importable", h.ListImportable)
g.POST("/import", h.Import)
}
// ── Live wg-show status ─────────────────────────────────────────────
@@ -142,6 +144,45 @@ func (h *WireguardHandler) Status(c *gin.Context) {
response.OK(c, gin.H{"status": rows})
}
// ── Import ────────────────────────────────────────────────────────
const wgImportDir = "/etc/wireguard"
// ListImportable scans /etc/wireguard for *.conf files and reports
// which are new (not yet in the DB) vs. already imported.
func (h *WireguardHandler) ListImportable(c *gin.Context) {
im := wireguard.NewImporter(h.Ifaces, h.Peers, h.Box)
entries, err := im.ListImportable(c.Request.Context(), wgImportDir)
if err != nil {
response.Internal(c, err)
return
}
response.OK(c, gin.H{"entries": entries})
}
// Import imports selected (or all) WireGuard .conf files from
// /etc/wireguard into the DB. Body: { "names": ["wg0","wg1"] }.
// Empty or missing names → import all.
func (h *WireguardHandler) Import(c *gin.Context) {
var req struct {
Names []string `json:"names"`
}
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, err)
return
}
im := wireguard.NewImporter(h.Ifaces, h.Peers, h.Box)
res, err := im.ImportSelected(c.Request.Context(), wgImportDir, req.Names)
if err != nil {
response.Internal(c, err)
return
}
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "wireguard.import",
fmt.Sprintf("%d ifaces", res.IfacesAdded), res, h.NodeID)
h.reload(c.Request.Context(), "import")
response.OK(c, res)
}
// ── Keygen ────────────────────────────────────────────────────────
func (h *WireguardHandler) GenerateKeypair(c *gin.Context) {

View File

@@ -15,6 +15,9 @@ global
# Ohne dieses Flag verweigert HAProxy `bind quic4@…` mit "this SSL
# library does not support the QUIC protocol".
limited-quic
# Runtime-API-Socket für edgeguard-api (show stat, show info, …).
# mode 0660 + group haproxy → edgeguard-user (in haproxy-group) kann lesen.
stats socket /run/haproxy/admin.sock mode 0660 level admin expose-fd listeners
defaults
log global
@@ -77,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.
@@ -88,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
@@ -192,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

@@ -0,0 +1,72 @@
global_defs {
router_id {{ .RouterID }}
script_user root
enable_script_security
}
vrrp_script chk_edgeguard {
script "/usr/lib/edgeguard/keepalived-check.sh"
interval 2
weight -50
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 }}
virtual_router_id {{ .RouterID }}
priority {{ .Priority }}
advert_int 1
{{ if .SrcIP }} unicast_src_ip {{ .SrcIP }}
unicast_peer {
{{ .PeerIP }}
}
{{ end }} authentication {
auth_type PASS
auth_pass {{ .AuthPass }}
}
virtual_ipaddress {
{{ 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

@@ -0,0 +1,210 @@
// Package keepalived rendert /etc/keepalived/keepalived.conf aus
// cluster_settings (VIP/VRRP-Config) und ha_nodes (local vs. peer).
//
// Split-Brain-Strategie: kein Auto-Promote. notify_master loggt nur
// und sendet einen internen Alert. Promotion ist immer manuell via
// "edgeguard-ctl promote" — das ist die einzig sichere Option ohne
// externes Quorum in einem 2-Node-Cluster.
package keepalived
import (
"bytes"
"context"
_ "embed"
"fmt"
"os"
"os/exec"
"strings"
"text/template"
"github.com/jackc/pgx/v5/pgxpool"
"git.netcell-it.de/projekte/edgeguard-native/internal/configgen"
"git.netcell-it.de/projekte/edgeguard-native/internal/models"
)
const ConfPath = "/etc/keepalived/keepalived.conf"
//go:embed keepalived.conf.tpl
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 // 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 {
pool *pgxpool.Pool
localID string
}
func New(pool *pgxpool.Pool, localID string) configgen.Generator {
return &generator{pool: pool, localID: localID}
}
func (g *generator) Name() string { return "keepalived" }
func (g *generator) Render(ctx context.Context) error {
cs, vips, local, peer, err := g.loadData(ctx)
if err != nil {
return fmt.Errorf("keepalived: load: %w", err)
}
if len(vips) == 0 {
// Keine VIPs konfiguriert → keepalived.conf nicht schreiben.
return nil
}
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)
}
if err := configgen.AtomicWrite(ConfPath, buf.Bytes(), 0o640); err != nil {
return fmt.Errorf("keepalived: write: %w", err)
}
if err := reloadKeepalived(); err != nil {
return fmt.Errorf("keepalived: reload: %w", err)
}
return nil
}
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,
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)
}
// 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, nil, fmt.Errorf("ip_addresses: %w", err)
}
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 nodeRows.Next() {
n := &models.HANode{}
if err := nodeRows.Scan(&n.ID, &n.FQDN, &n.Role, &n.PGRole, &n.PublicIP, &n.Status); err != nil {
continue
}
if n.ID == g.localID {
local = n
} else {
peer = n
}
}
if local == nil {
return nil, nil, nil, nil, fmt.Errorf("local node %s not in ha_nodes", g.localID)
}
return &cs, vips, local, peer, nil
}
func (g *generator) buildView(cs *models.ClusterSettings, vips []VIPEntry, local, peer *models.HANode) View {
v := View{
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"
}
if v.AuthPass == "" {
v.AuthPass = "edgeguard"
}
if v.HBRouterID == 0 {
v.HBRouterID = 52
}
// 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 {
v.State = "BACKUP"
v.Priority = 100
}
if local.PublicIP != nil {
v.SrcIP = *local.PublicIP
}
if peer != nil && peer.PublicIP != nil {
v.PeerIP = *peer.PublicIP
}
return v
}
func reloadKeepalived() error {
if _, err := os.Stat("/run/keepalived.pid"); os.IsNotExist(err) {
// keepalived läuft noch nicht — erster Render beim Start.
return nil
}
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 {
if s == nil {
return ""
}
return *s
}

View File

@@ -365,23 +365,16 @@ func (c *Client) Trial() (*Result, error) {
daysLeft := int(remaining.Hours()/24) + 1
expiresAt := info.StartedAt.Add(TrialDuration)
// Trial schaltet alle Pro-Features frei, Limits bleiben 0 (= Skip-
// Enforcement-Konvention). nmg-Backend prüft `lim > 0 && count >= lim`
// und blockiert in Trial-Mode niemanden.
// Trial schaltet alle Features frei, Limits bleiben 0 (= Skip-
// Enforcement-Konvention). Enforcement: `lim > 0 && count >= lim`.
return &Result{
Valid: true,
Type: "trial",
Status: "active",
Product: "NetCell MailGuard",
Product: "NetCell EdgeGuard",
ExpiresAt: &expiresAt,
GracePeriod: daysLeft <= 2,
Features: map[string]bool{
"reporting": true,
"eu_portal": true,
"digest": true,
"whitelabel": true,
"rest_api_write": true,
},
Features: map[string]bool{},
Limits: map[string]int64{
"max_domains": 0, // 0 = Skip-Enforcement (Trial-Konvention)
"max_nodes": 0,

View File

@@ -0,0 +1,24 @@
package models
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"`
}
func (ClusterSettings) TableName() string { return "cluster_settings" }

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

@@ -4,7 +4,7 @@ import "time"
// HANode mirrort eine Row der ha_nodes-Tabelle. Erweitert in Migration
// 0020 um version/config_hash/mgmt_ip/status für Cluster-Phase-3-
// Drift-Detection + Health-State.
// Drift-Detection + Health-State. Migration 0029 fügt PGRole hinzu.
type HANode struct {
ID string `gorm:"column:id;primaryKey" json:"id"`
Name string `gorm:"column:name" json:"name"`
@@ -14,6 +14,7 @@ type HANode struct {
InternalIP *string `gorm:"column:internal_ip;type:inet" json:"internal_ip,omitempty"`
MgmtIP *string `gorm:"column:mgmt_ip;type:inet" json:"mgmt_ip,omitempty"`
Role string `gorm:"column:role" json:"role"`
PGRole string `gorm:"column:pg_role" json:"pg_role"`
Version *string `gorm:"column:version" json:"version,omitempty"`
ConfigHash *string `gorm:"column:config_hash" json:"config_hash,omitempty"`
Status string `gorm:"column:status" json:"status"`

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

@@ -14,7 +14,10 @@ import (
"git.netcell-it.de/projekte/edgeguard-native/internal/models"
)
var ErrNotFound = errors.New("backend not found")
var (
ErrNotFound = errors.New("backend not found")
ErrInUse = errors.New("backend is referenced by one or more routing rules")
)
type Repo struct {
Pool *pgxpool.Pool
@@ -99,6 +102,15 @@ RETURNING id, name, scheme, health_check_path, lb_algorithm, websocket, force_ht
}
func (r *Repo) Delete(ctx context.Context, id int64) error {
var n int
if err := r.Pool.QueryRow(ctx,
`SELECT COUNT(*) FROM routing_rules WHERE backend_id = $1`, id,
).Scan(&n); err != nil {
return err
}
if n > 0 {
return ErrInUse
}
tag, err := r.Pool.Exec(ctx, `DELETE FROM backends WHERE id = $1`, id)
if err != nil {
return err

View File

@@ -62,12 +62,14 @@ func (s *Service) log() *slog.Logger {
}
// Result counts what one Run pass did so the scheduler can log /
// surface it.
// surface it. FailedDomains holds the domain names that failed so
// the scheduler can fire per-domain alerts.
type Result struct {
Checked int
Renewed int
Failed int
Skipped int
Checked int
Renewed int
Failed int
Skipped int
FailedDomains []string
}
// Run scans tls_certs for LE-issued rows that expire within the
@@ -95,6 +97,7 @@ func (s *Service) Run(ctx context.Context) (Result, error) {
certPEM, chainPEM, keyPEM, err := s.Issuer.Issue(row.Domain)
if err != nil {
res.Failed++
res.FailedDomains = append(res.FailedDomains, row.Domain)
s.log().Error("certrenewer: issue failed", "domain", row.Domain, "error", err)
_ = s.Repo.MarkError(ctx, row.Domain, err.Error())
continue
@@ -102,12 +105,14 @@ func (s *Service) Run(ctx context.Context) (Result, error) {
info, err := certstore.Parse(certPEM)
if err != nil {
res.Failed++
res.FailedDomains = append(res.FailedDomains, row.Domain)
_ = s.Repo.MarkError(ctx, row.Domain, "parse: "+err.Error())
continue
}
path, err := certstore.WriteCombined(s.CertDir, row.Domain, certPEM, chainPEM, keyPEM)
if err != nil {
res.Failed++
res.FailedDomains = append(res.FailedDomains, row.Domain)
_ = s.Repo.MarkError(ctx, row.Domain, "write: "+err.Error())
continue
}

View File

@@ -0,0 +1,293 @@
// Package clusterjoin encapsulates the cluster-join flow so it can be
// driven from both the edgeguard-ctl CLI and the setup-wizard HTTP
// handler without duplicating the HTTP + cert logic.
package clusterjoin
import (
"bytes"
"crypto/tls"
"crypto/x509"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"net"
"net/http"
"net/url"
"os"
"strings"
"time"
"git.netcell-it.de/projekte/edgeguard-native/internal/cluster/clustertls"
)
// Request describes what the joining node needs.
type Request struct {
// PrimaryFQDN is the FQDN (or https://…:port URL) of the primary.
// NormalizePrimaryURL() is applied automatically.
PrimaryFQDN string
// Token is the eg-join-v1.… value generated on the primary.
Token string
// CommonName becomes the Subject-CN of the peer cert and the
// lookup key in ha_nodes. Usually the FQDN of this node.
CommonName string
// Insecure skips TLS verify when contacting the primary. Safe
// because security depends on the HMAC-signed token, not TLS trust.
Insecure bool
// Force overwrites existing peer cert material. The CLI keeps this
// false (explicit rm required); the setup-wizard handler sets it
// true because the bootstrap self-signed cert must be replaced.
Force bool
// TLSDir is where ca.crt + peer.{crt,key} are written.
// Zero value → clustertls.DefaultDir.
TLSDir string
// Version + NodeID are reported to the primary during auto-register.
// Both are optional metadata — join succeeds even if empty.
Version string
NodeID string
}
// Join performs the full cluster-join:
// 1. Generates a local Ed25519 key + CSR.
// 2. POSTs to primary /api/v1/cluster/issue-cert to get ca.crt + peer.crt.
// 3. Writes ca.crt, peer.crt, peer.key to TLSDir.
// 4. Calls auto-register on the primary via mTLS (best-effort).
func Join(req Request) error {
if req.PrimaryFQDN == "" {
return errors.New("primary_fqdn required")
}
if req.Token == "" {
return errors.New("token required")
}
if req.CommonName == "" {
h, _ := os.Hostname()
req.CommonName = h
}
if req.CommonName == "" {
req.CommonName = "edgeguard-node"
}
tlsDir := req.TLSDir
if tlsDir == "" {
tlsDir = clustertls.DefaultDir
}
store := clustertls.New(tlsDir)
if store.HasPeer() && !req.Force {
return fmt.Errorf("peer cert already present under %s — remove it first if this is intentional", tlsDir)
}
primary, err := NormalizePrimaryURL(req.PrimaryFQDN)
if err != nil {
return err
}
dnsNames := []string{req.CommonName}
var ips []net.IP
if ip := net.ParseIP(req.CommonName); ip != nil {
ips = append(ips, ip)
dnsNames = nil
}
keyPEM, csrPEM, err := clustertls.NewPeerKeyAndCSR(req.CommonName, dnsNames, ips)
if err != nil {
return fmt.Errorf("generate CSR: %w", err)
}
caCertPEM, peerCertPEM, err := issueCert(primary, req.Token, csrPEM, req.Insecure)
if err != nil {
return fmt.Errorf("issue-cert: %w", err)
}
if err := os.MkdirAll(tlsDir, 0o700); err != nil {
return fmt.Errorf("mkdir %s: %w", tlsDir, err)
}
for _, w := range []struct {
name string
mode os.FileMode
data string
}{
{"ca.crt", 0o644, caCertPEM},
{"peer.crt", 0o644, peerCertPEM},
{"peer.key", 0o600, keyPEM},
} {
path := tlsDir + "/" + w.name
if err := os.WriteFile(path, []byte(w.data), w.mode); err != nil {
return fmt.Errorf("write %s: %w", path, err)
}
}
// Auto-register: retry a few times because the primary's nftables may
// need a moment to reload even though preRegisterJoiner is now
// 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 {
autoRegErr = nil
break
} else {
autoRegErr = err
if i < 2 {
slog.Warn("clusterjoin: autoRegister failed, retrying", "attempt", i+1, "error", err)
time.Sleep(2 * time.Second)
}
}
}
if autoRegErr != nil {
slog.Warn("clusterjoin: autoRegister failed after all retries — primary will reconcile via identity pull",
"error", autoRegErr)
}
// Certs are saved; primary will reconcile via /agent/cluster/identity pull
// even if autoRegister failed. Not returning the error — join is
// structurally complete (certs issued), only the ha_nodes update is pending.
return nil
}
// NormalizePrimaryURL accepts "fqdn", "host:port" or "https://host:port"
// and always returns "https://host:port". Default port is 3443.
func NormalizePrimaryURL(in string) (string, error) {
in = strings.TrimSpace(in)
if in == "" {
return "", errors.New("empty primary fqdn/url")
}
if !strings.HasPrefix(in, "http://") && !strings.HasPrefix(in, "https://") {
in = "https://" + in
}
u, err := url.Parse(in)
if err != nil {
return "", err
}
if u.Hostname() == "" {
return "", errors.New("primary URL has no host")
}
if u.Port() == "" {
u.Host = u.Hostname() + ":3443"
}
u.Path = ""
u.RawQuery = ""
u.Fragment = ""
return u.String(), nil
}
func issueCert(primary, token, csr string, insecure bool) (caCert, peerCert string, err error) {
body, _ := json.Marshal(map[string]string{"token": token, "csr": csr})
req, err := http.NewRequest(http.MethodPost,
primary+"/api/v1/cluster/issue-cert", bytes.NewReader(body))
if err != nil {
return "", "", err
}
req.Header.Set("Content-Type", "application/json")
tr := &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: insecure, MinVersion: tls.VersionTLS12}, //nolint:gosec
TLSHandshakeTimeout: 5 * time.Second,
ResponseHeaderTimeout: 10 * time.Second,
}
client := &http.Client{Transport: tr, Timeout: 30 * time.Second}
resp, err := client.Do(req)
if err != nil {
return "", "", err
}
defer resp.Body.Close()
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if resp.StatusCode != http.StatusOK {
return "", "", fmt.Errorf("HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(raw)))
}
var env struct {
Data struct {
CACert string `json:"ca_cert"`
PeerCert string `json:"peer_cert"`
} `json:"data"`
Error string `json:"error"`
}
if err := json.Unmarshal(raw, &env); err != nil {
return "", "", fmt.Errorf("decode response: %w", err)
}
if env.Error != "" {
return "", "", fmt.Errorf("server: %s", env.Error)
}
if env.Data.CACert == "" || env.Data.PeerCert == "" {
return "", "", errors.New("response missing ca_cert or peer_cert")
}
return env.Data.CACert, env.Data.PeerCert, nil
}
// PushSelfToPrimary sends this node's current identity + configHash to the
// primary via mTLS. Exported for use by the API server's periodic push
// 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 {
if tlsDir == "" {
tlsDir = clustertls.DefaultDir
}
return autoRegister(primaryURL, tlsDir, fqdn, version, nodeID, configHash)
}
func autoRegister(primary, tlsDir, commonName, version, nodeID, configHash string) error {
u, err := url.Parse(primary)
if err != nil {
return err
}
u.Host = u.Hostname() + ":8443"
u.Path = "/agent/cluster/peers"
if nodeID == "" {
raw, _ := os.ReadFile("/var/lib/edgeguard/node-id")
nodeID = strings.TrimSpace(string(raw))
}
hostname, _ := os.Hostname()
body, _ := json.Marshal(map[string]string{
"id": nodeID,
"name": hostname,
"fqdn": commonName,
"api_url": "https://" + commonName + ":3443",
"version": version,
"config_hash": configHash,
})
pair, err := tls.LoadX509KeyPair(tlsDir+"/peer.crt", tlsDir+"/peer.key")
if err != nil {
return fmt.Errorf("load peer cert: %w", err)
}
caPEM, err := os.ReadFile(tlsDir + "/ca.crt")
if err != nil {
return fmt.Errorf("read ca: %w", err)
}
pool := x509.NewCertPool()
if !pool.AppendCertsFromPEM(caPEM) {
return errors.New("invalid ca.crt")
}
tr := &http.Transport{
TLSClientConfig: &tls.Config{
Certificates: []tls.Certificate{pair},
RootCAs: pool,
MinVersion: tls.VersionTLS13,
ServerName: u.Hostname(),
},
TLSHandshakeTimeout: 5 * time.Second,
ResponseHeaderTimeout: 10 * time.Second,
}
client := &http.Client{Transport: tr, Timeout: 30 * time.Second}
httpReq, err := http.NewRequest(http.MethodPost, u.String(), bytes.NewReader(body))
if err != nil {
return err
}
httpReq.Header.Set("Content-Type", "application/json")
resp, err := client.Do(httpReq)
if err != nil {
return err
}
defer resp.Body.Close()
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(raw)))
}
return nil
}

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

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