44 Commits

Author SHA1 Message Date
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
109 changed files with 11171 additions and 920 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.162
1.2.86

View File

@@ -40,6 +40,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"
@@ -58,9 +59,10 @@ import (
"git.netcell-it.de/projekte/edgeguard-native/internal/services/tlscerts"
wgsvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/wireguard"
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.162"
var version = "1.2.35"
func main() {
addr := os.Getenv("EDGEGUARD_API_ADDR")
@@ -175,6 +177,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 +235,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)
@@ -324,7 +346,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
@@ -360,6 +384,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
@@ -457,6 +483,10 @@ func main() {
// schon erledigt.
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}
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
@@ -560,10 +590,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)
})
}
@@ -680,6 +718,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

@@ -0,0 +1,573 @@
package main
import (
"crypto/rand"
"crypto/tls"
"crypto/x509"
"encoding/json"
"flag"
"fmt"
"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
"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,
)
if err := psqlDBExec("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 := fmt.Sprintf("https://%s:%d/agent/cluster/pg-replication-info", host, agentPort)
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 := fmt.Sprintf("https://%s:%d/agent/cluster/master-key", host, agentPort)
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
}
// 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.162"
var version = "1.2.15"
const usage = `edgeguard-ctl — EdgeGuard CLI
@@ -25,23 +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=)
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.
Without iface arguments: imports all .conf files.
With iface args: imports only the named interfaces.
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)
`
@@ -69,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

@@ -12,6 +12,8 @@ import (
"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"
@@ -64,7 +66,16 @@ func cmdRenderConfig(args []string) int {
fw.SkipReload = true
}
// 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}
if ka != nil {
gens = append(gens, ka)
}
results, runErr := configorch.Run(ctx, gens, only)
fmt.Print(configorch.Summarise(results))

View File

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

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

25
go.mod
View File

@@ -3,23 +3,30 @@ module git.netcell-it.de/projekte/edgeguard-native
go 1.26.0
require (
github.com/corazawaf/coraza/v3 v3.7.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
)
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 +35,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 +84,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
)

60
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,23 @@ 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/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 +52,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 +63,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 +75,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 +100,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 +122,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 +134,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 +151,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=
@@ -155,9 +192,10 @@ golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs=
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 +217,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

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

@@ -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,36 @@ 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"},
// network_interfaces + ip_addresses werden seit 0030 repliziert —
// VLAN/Bridge/Bond-Definitionen und Gateway-IPs müssen auf dem Secondary
// für Failover bereitstehen. Ethernet-IPs werden im Secondary-Renderer
// herausgefiltert (eth0 = cloud-init / Keepalived).
//
// ip_addresses.interface_id ist ein node-lokaler Autoincrement-PK, der
// zwischen zwei unabhängigen DBs divergiert (utm-1: eth0=6, utm-2: eth0=1).
// Wir hashen daher semantisch: address + prefix + flags + interface_name
// statt interface_id — sonst False-Positive-Drift auf logisch identischen Nodes.
{Name: "network_interfaces"},
{Name: "ip_addresses", CustomSQL: `
SELECT COALESCE(md5(string_agg(rh, '|' ORDER BY rh)), '')
FROM (
SELECT md5(jsonb_build_object(
'address', ia.address,
'prefix', ia.prefix,
'is_vip', ia.is_vip,
'active', ia.active,
'vip_priority', ia.vip_priority,
'description', ia.description,
'iface', ni.name
)::text) AS rh
FROM ip_addresses ia
JOIN network_interfaces ni ON ia.interface_id = ni.id
) sub`},
// static_routes, dns_settings, ntp_settings bleiben node-spezifisch.
}
// hashSQL rendert die SHA-Input-SQL für eine Tabelle.
@@ -100,22 +125,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

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

View File

@@ -0,0 +1,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,17 @@
-- +goose Up
-- +goose StatementBegin
-- network_interfaces und ip_addresses werden in die Cluster-Replikation
-- aufgenommen. Das ALTER PUBLICATION erfordert den Superuser (postgres),
-- daher läuft es im postinst via `sudo -u postgres psql`, nicht hier.
-- Diese Migration dient nur als Versions-Marker für goose.
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

@@ -363,11 +363,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.

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,6 +50,11 @@ 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

View File

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

View File

@@ -7,6 +7,9 @@ import (
"encoding/pem"
"fmt"
"log/slog"
"net/http"
"os"
"os/exec"
"strings"
"time"
@@ -18,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
@@ -31,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.
@@ -40,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 {
@@ -61,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)
@@ -115,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
@@ -139,6 +243,15 @@ 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
@@ -161,6 +274,59 @@ func (h *ClusterHandler) AgentIdentity(c *gin.Context) {
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
// Firewall + ggfs. andere Configs regeneriert (damit peer_ipv4-Set
// frisch ist). Wird vom main.go gesetzt.
@@ -172,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 {
@@ -482,6 +654,14 @@ func (h *ClusterHandler) preRegisterJoiner(parent context.Context, clientIP, csr
slog.Info("cluster: joiner pre-registered, firewall updated", "fqdn", fqdn, "ip", clientIP)
}
// ptrStr dereferences a *string safely for comparison; nil → "".
func ptrStr(s *string) string {
if s == nil {
return ""
}
return *s
}
// cnFromCSR extracts the Subject Common Name from a PEM-encoded CSR.
// Returns empty string on any parse error.
func cnFromCSR(csrPEM string) string {
@@ -555,6 +735,60 @@ func (h *ClusterHandler) reconcileJoiningPeers(placeholders []models.HANode) {
}
}
// 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
@@ -623,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.
@@ -693,23 +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,344 @@
package handlers
import (
"context"
"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).
//
// Der Resync MUSS auf dem Standby/Subscriber laufen (nur der hat eine
// Subscription). Operatoren erreichen die UI aber über die VIP, die immer
// auf den Primary zeigt. Deshalb:
//
// - Auf dem Primary geklickt → Dispatch via mTLS an den Standby
// (POST /agent/cluster/repair-replication), der dort lokal läuft.
// - Auf dem Standby direkt geklickt → läuft lokal.
//
// Die eigentliche Arbeit läuft — analog zum Rolling-Update — in einer
// transienten systemd-Unit, die das bereits getestete
// `edgeguard-ctl cluster-setup-standby <primary>` ausführt.
const (
repairUnitName = "edgeguard-repair-replication.service"
repairScriptPath = "/var/lib/edgeguard/repair-replication.sh"
repairAgentPath = "/agent/cluster/repair-replication"
)
// validRepairHost erlaubt nur IPv4/IPv6/Hostnamen — der Wert landet in
// einem Bash-Script das als root läuft, also strikt validieren (defense
// in depth, auch wenn er aus ha_nodes stammt).
var validRepairHost = regexp.MustCompile(`^[A-Za-z0-9._:-]{1,253}$`)
// RepairReplication ist der UI-Endpoint. Läuft der lokale Node als
// Primary, wird der Resync an den Standby-Peer delegiert; auf dem Standby
// selbst läuft er lokal.
func (h *ClusterHandler) RepairReplication(c *gin.Context) {
if h.Store == nil {
response.Internal(c, errors.New("cluster store unavailable"))
return
}
all, err := h.Store.List(c.Request.Context())
if err != nil {
response.Internal(c, err)
return
}
local := findNode(all, h.LocalID)
standby := findByPGRole(all, "standby")
// Primary → an den Standby delegieren.
if local != nil && local.PGRole == "primary" {
if h.Aggregator == nil {
response.BadRequest(c, errors.New("kein mTLS-Aggregator verfügbar — Resync nicht delegierbar"))
return
}
if standby == nil {
response.BadRequest(c, errors.New("kein Standby-Node gefunden, an den der Resync delegiert werden könnte"))
return
}
res := h.Aggregator.PostPeer(c.Request.Context(), *standby, repairAgentPath)
if !res.OK {
response.Internal(c, fmt.Errorf("Resync auf %s anstoßen: %s", standby.FQDN, res.Err))
return
}
slog.Info("cluster: replication repair delegated to standby", "standby", standby.FQDN)
if h.Audit != nil {
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "cluster.repair-replication",
standby.FQDN, gin.H{"target": "standby", "standby": standby.FQDN}, h.NodeID)
}
response.Accepted(c, gin.H{"dispatched": true, "target": "standby", "standby_fqdn": standby.FQDN})
return
}
// Standby (oder Direktzugriff) → lokal ausführen.
host, err := h.runLocalRepair(c.Request.Context(), all)
if err != nil {
response.BadRequest(c, err)
return
}
if h.Audit != nil {
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "cluster.repair-replication",
host, gin.H{"target": "local", "primary": host}, h.NodeID)
}
response.Accepted(c, gin.H{"dispatched": true, "target": "local", "primary": host})
}
// AgentRepairReplication wird vom Primary via mTLS auf dem Standby
// aufgerufen und startet dort den lokalen Resync.
func (h *ClusterHandler) AgentRepairReplication(c *gin.Context) {
if h.Store == nil {
response.Internal(c, errors.New("cluster store unavailable"))
return
}
all, err := h.Store.List(c.Request.Context())
if err != nil {
response.Internal(c, err)
return
}
host, err := h.runLocalRepair(c.Request.Context(), all)
if err != nil {
response.BadRequest(c, err)
return
}
slog.Info("cluster: replication repair triggered by peer", "primary", host, "node", h.LocalID)
if h.Audit != nil {
_ = h.Audit.Log(c.Request.Context(), "cluster-peer", "cluster.repair-replication",
host, gin.H{"target": "local", "primary": host, "via": "agent"}, h.NodeID)
}
response.Accepted(c, gin.H{"dispatched": true, "primary": host})
}
// runLocalRepair startet den Resync auf DIESEM Node. Verweigert auf dem
// Primary (kein Subscriber). Gibt den ermittelten Primary-Host zurück.
func (h *ClusterHandler) runLocalRepair(_ context.Context, all []models.HANode) (string, error) {
local := findNode(all, h.LocalID)
primary := findByPGRole(all, "primary")
if local != nil && local.PGRole == "primary" {
return "", errors.New("dieser Node ist der PostgreSQL-Primary — Resync läuft nur auf einem Standby/Subscriber")
}
if primary == nil {
return "", errors.New("kein PostgreSQL-Primary im Cluster gefunden — Resync-Quelle unbekannt")
}
if primary.ID == h.LocalID {
return "", errors.New("der lokale Node ist als Primary markiert — Resync nicht möglich")
}
host := pickPrimaryHost(primary)
if host == "" {
return "", errors.New("Primary hat keine erreichbare IP/FQDN in ha_nodes")
}
if !validRepairHost.MatchString(host) {
return "", fmt.Errorf("ungültige Primary-Adresse: %q", host)
}
if 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
`, host, 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", host, "node", h.LocalID)
return host, 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 Standby-Peer geholt (dort läuft der Job); sonst lokal.
func (h *ClusterHandler) RepairReplicationStatus(c *gin.Context) {
if h.Store != nil {
if all, err := h.Store.List(c.Request.Context()); err == nil {
local := findNode(all, h.LocalID)
standby := findByPGRole(all, "standby")
if local != nil && local.PGRole == "primary" && h.Aggregator != nil && standby != nil {
results := h.Aggregator.FanOut(c.Request.Context(),
[]models.HANode{*standby}, repairAgentPath+"/status", h.LocalID)
if len(results) == 1 && results[0].OK && len(results[0].Data) > 0 {
c.Data(200, "application/json", wrapEnvelope(results[0].Data))
return
}
// Peer nicht erreichbar → idle zurückgeben statt Fehler,
// damit das UI-Polling nicht hart abbricht.
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 / findByPGRole: kleine Helfer über die ha_nodes-Liste.
func findNode(nodes []models.HANode, id string) *models.HANode {
for i := range nodes {
if nodes[i].ID == id {
return &nodes[i]
}
}
return nil
}
func findByPGRole(nodes []models.HANode, role string) *models.HANode {
for i := range nodes {
if nodes[i].PGRole == role {
return &nodes[i]
}
}
return nil
}
// pickPrimaryHost wählt die beste erreichbare Adresse des Primary:
// 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 {
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})
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -118,10 +118,12 @@ func (h *SystemHandler) Register(rg *gin.RouterGroup) {
g.POST("/haproxy-reload", h.HAProxyReload)
g.POST("/render-configs", h.RenderConfigs)
g.POST("/service-restart", h.ServiceRestart)
g.POST("/service-toggle", h.ServiceToggle)
g.GET("/upgrade-status", h.UpgradeStatus)
g.GET("/ipv6", h.IPv6)
g.POST("/ipv6", h.SetIPv6)
g.GET("/config-preview", h.ConfigPreview)
g.GET("/vip-status", h.VIPStatus)
}
// RegisterAgent mountet die read-only System-Endpoints auf der mTLS-
@@ -188,10 +190,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 {
@@ -630,6 +636,53 @@ func (h *SystemHandler) ServiceRestart(c *gin.Context) {
response.OK(c, gin.H{"ok": true, "service": svc})
}
// toggleAllowlist defines which services may be started/stopped via the UI.
var toggleAllowlist = map[string]bool{
"crowdsec": true,
"crowdsec-firewall-bouncer": true,
"edgeguard-waf": true,
"squid": true,
"unbound": true,
}
// ServiceToggle starts or stops (and enables/disables) a service.
// Body: {"service": "crowdsec", "enabled": true}
func (h *SystemHandler) ServiceToggle(c *gin.Context) {
var req struct {
Service string `json:"service" binding:"required"`
Enabled bool `json:"enabled"`
}
if err := c.ShouldBindJSON(&req); err != nil {
response.Err(c, http.StatusBadRequest, simpleErr("service and enabled required"))
return
}
svc := strings.TrimSpace(req.Service)
if !toggleAllowlist[svc] {
response.Err(c, http.StatusBadRequest, simpleErr("service not in toggle allowlist: "+svc))
return
}
unit := svc + ".service"
action := "stop"
sysdAction := "disable"
if req.Enabled {
action = "start"
sysdAction = "enable"
}
if out, err := exec.Command("sudo", "-n", "/usr/bin/systemctl", sysdAction, unit).CombinedOutput(); err != nil {
response.Err(c, http.StatusInternalServerError, simpleErr(strings.TrimSpace(string(out))+": "+err.Error()))
return
}
if out, err := exec.Command("sudo", "-n", "/usr/bin/systemctl", action, unit).CombinedOutput(); err != nil {
response.Err(c, http.StatusInternalServerError, simpleErr(strings.TrimSpace(string(out))+": "+err.Error()))
return
}
if h.Audit != nil {
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "system.service_toggle",
svc, gin.H{"service": svc, "enabled": req.Enabled}, h.NodeID)
}
response.OK(c, gin.H{"ok": true, "service": svc, "enabled": req.Enabled})
}
// RenderConfigs erzwingt ein Re-Render aller Service-Configs aus dem
// aktuellen DB-State. Läuft haproxy + alle ExtraReloaders (nftables,
// wireguard, squid, unbound, chrony) durch. Fehler werden gesammelt
@@ -1077,6 +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})
}

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

@@ -0,0 +1,199 @@
package handlers
import (
"context"
"errors"
"log/slog"
"net/http"
"strconv"
"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"
)
// 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{}
}
cfg := models.WafConfig{
DomainID: domainID,
Enabled: body.Enabled,
Mode: body.Mode,
ParanoiaLevel: body.ParanoiaLevel,
RuleExclusions: body.RuleExclusions,
ExclusionNotes: body.ExclusionNotes,
TrustedProxies: body.TrustedProxies,
CustomRules: body.CustomRules,
}
result, err := h.Repo.Upsert(c.Request.Context(), cfg)
if err != nil {
response.Internal(c, err)
return
}
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "waf.config.upsert",
strconv.FormatInt(domainID, 10),
gin.H{"enabled": body.Enabled, "mode": body.Mode, "paranoia_level": body.ParanoiaLevel},
h.NodeID)
// Reload HAProxy so the SPOE filter is added/removed based on
// whether any domain now has WAF enabled.
if h.Reloader != nil {
go func() {
if err := h.Reloader(context.Background()); err != nil {
slog.Warn("waf: haproxy reload after config change failed", "error", err)
}
}()
}
c.JSON(http.StatusOK, gin.H{"config": result})
}
// ListAlerts returns recent WAF alerts. Optional: ?domain_id=X&limit=N
func (h *WafHandler) ListAlerts(c *gin.Context) {
var domainID *int64
if v := c.Query("domain_id"); v != "" {
id, err := strconv.ParseInt(v, 10, 64)
if err != nil {
response.BadRequest(c, errors.New("invalid domain_id"))
return
}
domainID = &id
}
limit := 200
if v := c.Query("limit"); v != "" {
if n, err := strconv.Atoi(v); err == nil && n > 0 {
limit = n
}
}
alerts, err := h.Repo.ListAlerts(c.Request.Context(), domainID, limit)
if err != nil {
response.Internal(c, err)
return
}
response.OK(c, gin.H{"alerts": alerts})
}
// PurgeAlerts deletes old WAF alerts. Optional: ?days=N (default 30)
func (h *WafHandler) PurgeAlerts(c *gin.Context) {
days := 30
if v := c.Query("days"); v != "" {
if n, err := strconv.Atoi(v); err == nil && n > 0 {
days = n
}
}
if err := h.Repo.PurgeAlerts(c.Request.Context(), days); err != nil {
response.Internal(c, err)
return
}
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "waf.alerts.purge",
"", gin.H{"days": days}, h.NodeID)
response.OK(c, gin.H{"ok": true, "days": days})
}
// defaultConfig returns a sensible disabled default for a domain
// that has no WAF config row yet.
func defaultConfig(domainID int64) models.WafConfig {
return models.WafConfig{
DomainID: domainID,
Enabled: false,
Mode: "detection",
ParanoiaLevel: 1,
RuleExclusions: []string{},
ExclusionNotes: map[string]string{},
TrustedProxies: []string{},
CustomRules: "",
}
}

View File

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

View File

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

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

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

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

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

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

View File

@@ -128,7 +128,7 @@ func Join(req Request) error {
// synchronous on the primary side.
var autoRegErr error
for i := 0; i < 3; i++ {
if err := autoRegister(primary, tlsDir, req.CommonName, req.Version, req.NodeID); err == nil {
if err := autoRegister(primary, tlsDir, req.CommonName, req.Version, req.NodeID, ""); err == nil {
autoRegErr = nil
break
} else {
@@ -217,7 +217,18 @@ func issueCert(primary, token, csr string, insecure bool) (caCert, peerCert stri
return env.Data.CACert, env.Data.PeerCert, nil
}
func autoRegister(primary, tlsDir, commonName, version, nodeID string) error {
// 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
@@ -231,11 +242,12 @@ func autoRegister(primary, tlsDir, commonName, version, nodeID string) error {
}
hostname, _ := os.Hostname()
body, _ := json.Marshal(map[string]string{
"id": nodeID,
"name": hostname,
"fqdn": commonName,
"api_url": "https://" + commonName + ":3443",
"version": version,
"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")

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -122,6 +122,15 @@ func (s *Signer) Issue(actor string) (string, *Token, error) {
return s.IssueWithRole(actor, "")
}
// IssueWithRoleTTL issues a token with a custom TTL (overrides s.TTL for this call).
func (s *Signer) IssueWithRoleTTL(actor, role string, ttl time.Duration) (string, *Token, error) {
orig := s.TTL
s.TTL = ttl
raw, tok, err := s.IssueWithRole(actor, role)
s.TTL = orig
return raw, tok, err
}
// Verify checks a token. Returns ErrInvalidToken or ErrExpiredToken.
func (s *Signer) Verify(raw string) (*Token, error) {
if raw == "" {

View File

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

View File

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

View File

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

View File

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

View File

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

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

@@ -0,0 +1,84 @@
package waf
import (
"context"
"log/slog"
"time"
"github.com/jackc/pgx/v5/pgxpool"
)
// Alert represents a single WAF rule match that was logged.
type Alert struct {
ID int64 `json:"id"`
DomainID *int64 `json:"domain_id,omitempty"`
Hostname string `json:"hostname"`
ClientIP string `json:"client_ip"`
Method string `json:"method"`
URI string `json:"uri"`
RuleID int `json:"rule_id"`
RuleMsg string `json:"rule_msg"`
Severity string `json:"severity"`
Action string `json:"action"` // "detected" | "blocked"
CreatedAt time.Time `json:"created_at"`
}
// AlertWriter accepts Alert values via a buffered channel and writes
// them to PostgreSQL asynchronously so SPOE handling stays low-latency.
type AlertWriter struct {
pool *pgxpool.Pool
ch chan Alert
}
// NewAlertWriter creates an AlertWriter and starts its background goroutine.
// bufSize is the number of unwritten alerts that can queue before drops.
func NewAlertWriter(pool *pgxpool.Pool, bufSize int) *AlertWriter {
aw := &AlertWriter{
pool: pool,
ch: make(chan Alert, bufSize),
}
go aw.run()
return aw
}
// Send enqueues an alert. Drops silently if the channel is full to
// avoid slowing down SPOE request handling.
func (aw *AlertWriter) Send(a Alert) {
select {
case aw.ch <- a:
default:
slog.Warn("waf: alert channel full — dropping alert", "host", a.Hostname, "rule", a.RuleID)
}
}
func (aw *AlertWriter) run() {
for a := range aw.ch {
aw.write(a)
}
}
func (aw *AlertWriter) write(a Alert) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
// Resolve domain_id from hostname (best-effort).
var domainID *int64
var id int64
if err := aw.pool.QueryRow(ctx,
`SELECT id FROM domains WHERE name = $1 AND active = true LIMIT 1`,
a.Hostname,
).Scan(&id); err == nil {
domainID = &id
}
if _, err := aw.pool.Exec(ctx, `
INSERT INTO waf_alerts
(domain_id, hostname, client_ip, method, uri,
rule_id, rule_msg, severity, action)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)
`, domainID, a.Hostname, a.ClientIP, a.Method, a.URI,
a.RuleID, a.RuleMsg, a.Severity, a.Action,
); err != nil {
slog.Warn("waf: write alert to db failed", "error", err)
}
}

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

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

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

@@ -0,0 +1,157 @@
package waf
import (
"fmt"
"log/slog"
"sync"
"github.com/corazawaf/coraza/v3"
"git.netcell-it.de/projekte/edgeguard-native/internal/models"
)
// DomainEngine bundles a Coraza WAF with its operating mode.
type DomainEngine struct {
WAF coraza.WAF
Mode string // "detection" | "blocking"
}
// Manager holds per-domain Coraza engine instances. Engines are
// rebuilt only when their configuration changes (UpdatedAt differs).
// All public methods are safe for concurrent use.
type Manager struct {
mu sync.RWMutex
engines map[string]*DomainEngine // hostname → engine (nil entry = disabled)
configKeys map[string]configKey // hostname → last-seen config fingerprint
crsDir string
}
// configKey identifies a specific WAF config snapshot so we only
// rebuild the engine when something actually changed.
type configKey struct {
enabled bool
mode string
paranoiaLevel int
updatedAt int64 // unix nano
}
// NewManager creates an empty Manager with the given CRS directory.
func NewManager(crsDir string) *Manager {
if crsDir == "" {
crsDir = DefaultCRSDir
}
return &Manager{
engines: make(map[string]*DomainEngine),
configKeys: make(map[string]configKey),
crsDir: crsDir,
}
}
// DomainConfig pairs a domain hostname with its WAF policy.
type DomainConfig struct {
Hostname string
Config models.WafConfig
}
// Reload refreshes engines from the given list, rebuilding only when
// the config has actually changed since the last call.
func (m *Manager) Reload(domains []DomainConfig) error {
m.mu.RLock()
prevEngines := m.engines
prevKeys := m.configKeys
m.mu.RUnlock()
newEngines := make(map[string]*DomainEngine, len(domains))
newKeys := make(map[string]configKey, len(domains))
for _, dc := range domains {
ck := configKey{
enabled: dc.Config.Enabled,
mode: dc.Config.Mode,
paranoiaLevel: dc.Config.ParanoiaLevel,
updatedAt: dc.Config.UpdatedAt.UnixNano(),
}
newKeys[dc.Hostname] = ck
if !dc.Config.Enabled {
newEngines[dc.Hostname] = nil
continue
}
// Reuse existing engine if config hasn't changed.
if prev, ok := prevKeys[dc.Hostname]; ok && prev == ck {
if existing := prevEngines[dc.Hostname]; existing != nil {
newEngines[dc.Hostname] = existing
continue
}
}
waf, err := BuildEngine(dc.Config, m.crsDir)
if err != nil {
return fmt.Errorf("waf: build engine for %s: %w", dc.Hostname, err)
}
newEngines[dc.Hostname] = &DomainEngine{WAF: waf, Mode: dc.Config.Mode}
slog.Info("waf: engine (re)loaded",
"host", dc.Hostname,
"mode", dc.Config.Mode,
"paranoia_level", dc.Config.ParanoiaLevel,
"crs", crsAvailable(m.crsDir),
)
}
m.mu.Lock()
m.engines = newEngines
m.configKeys = newKeys
m.mu.Unlock()
return nil
}
// GetForHost returns the DomainEngine for the given hostname, or
// (nil, false) when the domain has no WAF or WAF is disabled.
func (m *Manager) GetForHost(host string) (*DomainEngine, bool) {
// Strip port if present (e.g. "example.com:443" → "example.com").
if i := lastColon(host); i >= 0 {
host = host[:i]
}
m.mu.RLock()
de, ok := m.engines[host]
m.mu.RUnlock()
if !ok || de == nil {
return nil, false
}
return de, true
}
// lastColon returns the index of the last ':' in s that looks like a
// port separator (after the final ']' for IPv6), or -1.
func lastColon(s string) int {
// IPv6 addresses in brackets: "[::1]:443"
if len(s) > 0 && s[0] == '[' {
if rb := lastByte(s, ']'); rb >= 0 && rb < len(s)-1 && s[rb+1] == ':' {
return rb + 1
}
return -1
}
// Plain host — only strip port if there's exactly one colon.
count := 0
idx := -1
for i, c := range s {
if c == ':' {
count++
idx = i
}
}
if count == 1 {
return idx
}
return -1
}
func lastByte(s string, b byte) int {
for i := len(s) - 1; i >= 0; i-- {
if s[i] == b {
return i
}
}
return -1
}

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

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

View File

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

View File

@@ -152,6 +152,7 @@ func (g *Generator) Render(ctx context.Context) error {
}
_ = os.Remove(filepath.Join(ConfDir, e.Name()))
_ = stopWGQuick(ifaceName)
_ = disableWGQuick(ifaceName)
}
}
return nil
@@ -235,6 +236,7 @@ func (g *Generator) renderIface(ctx context.Context, ifc models.WireguardInterfa
if err := symlinkWGQuickConf(ifc.Name, path); err != nil {
return fmt.Errorf("symlink: %w", err)
}
_ = enableWGQuick(ifc.Name)
if existing, err := os.ReadFile(path); err == nil && bytes.Equal(existing, body.Bytes()) {
return startWGQuick(ifc.Name)
}

View File

@@ -1,5 +1,6 @@
import { Suspense, lazy, useEffect, type ReactNode } from 'react'
import { BrowserRouter, Navigate, Route, Routes, useLocation } from 'react-router-dom'
import ErrorBoundary from './components/ErrorBoundary'
import { ConfigProvider, Spin } from 'antd'
import deDE from 'antd/locale/de_DE'
import enUS from 'antd/locale/en_US'
@@ -37,6 +38,8 @@ const AlertsPage = lazy(() => import('./pages/Alerts'))
const LicensePage = lazy(() => import('./pages/License'))
const SettingsPage = lazy(() => import('./pages/Settings'))
const UsersPage = lazy(() => import('./pages/Users'))
const CrowdSecPage = lazy(() => import('./pages/CrowdSec'))
const WAFPage = lazy(() => import('./pages/WAF'))
const queryClient = new QueryClient({
defaultOptions: {
@@ -62,6 +65,16 @@ const antdTheme = {
colorTextSecondary: '#64748B',
controlHeight: 34,
},
components: {
Tabs: {
itemColor: '#334155',
itemHoverColor: '#0F172A',
itemSelectedColor: '#0EA5E9',
inkBarColor: '#0EA5E9',
cardBg: '#F1F5F9',
titleFontSize: 13,
},
},
}
function RequireAuth({ children }: { children: ReactNode }) {
@@ -99,6 +112,7 @@ export default function App() {
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<SetupGate>
<LocationKeyBoundary>
<Suspense fallback={<div className="loader-center"><Spin size="large" /></div>}>
<Routes>
<Route path="/setup" element={<SetupPage onComplete={(u: SessionUser) => useAuthStore.getState().set(u)} />} />
@@ -131,14 +145,24 @@ export default function App() {
<Route path="/license" element={<LicensePage />} />
<Route path="/users" element={<UsersPage />} />
<Route path="/settings" element={<SettingsPage />} />
<Route path="/crowdsec" element={<CrowdSecPage />} />
<Route path="/waf" element={<WAFPage />} />
</Route>
<Route path="*" element={<Navigate to="/dashboard" replace />} />
</Routes>
</Suspense>
</LocationKeyBoundary>
</SetupGate>
</BrowserRouter>
</QueryClientProvider>
</ConfigProvider>
)
}
// Resets the ErrorBoundary on every route change so a render error on
// one page never permanently blocks navigation to another page.
function LocationKeyBoundary({ children }: { children: ReactNode }) {
const { pathname } = useLocation()
return <ErrorBoundary key={pathname}>{children}</ErrorBoundary>
}

View File

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

View File

@@ -19,6 +19,7 @@ import {
FireOutlined,
GlobalOutlined,
NodeIndexOutlined,
RadarChartOutlined,
SafetyCertificateOutlined,
SettingOutlined,
TeamOutlined,
@@ -76,6 +77,8 @@ const NAV: NavSection[] = [
{ path: '/firewall/live', labelKey: 'nav.firewallLive', icon: <EyeOutlined />, child: true },
{ path: '/vpn/wireguard', labelKey: 'nav.wireguard', icon: <ThunderboltOutlined /> },
{ path: '/forward-proxy', labelKey: 'nav.forwardProxy', icon: <CloudServerOutlined /> },
{ path: '/crowdsec', labelKey: 'nav.crowdsec', icon: <RadarChartOutlined /> },
{ path: '/waf', labelKey: 'nav.waf', icon: <SafetyCertificateOutlined /> },
],
},
{
@@ -130,9 +133,9 @@ export default function Sidebar({ isOpen, onClose }: SidebarProps) {
<span className="sidebar-logo-text">{t('app.title')}</span>
</div>
{NAV.map((section) => (
{NAV.map((section, idx) => (
<div key={section.labelKey}>
<div className="sidebar-section">
<div className={`sidebar-section${idx > 0 ? ' sidebar-section--bordered' : ''}`}>
<div className="sidebar-section-label">{t(section.labelKey)}</div>
</div>
<ul className="sidebar-menu">

View File

@@ -1,5 +1,5 @@
import { Alert, Button, Popconfirm, Space, Tooltip, message } from 'antd'
import { CloudDownloadOutlined, ReloadOutlined, RocketOutlined } from '@ant-design/icons'
import { Alert, Button, Popconfirm, Tooltip, message } from 'antd'
import { CloudDownloadOutlined, ReloadOutlined, RocketOutlined, ClusterOutlined } from '@ant-design/icons'
import { useQuery } from '@tanstack/react-query'
import { useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
@@ -20,6 +20,19 @@ interface SystemHealth { status: string; version: string }
interface PendingUpdate { pkg: string; installed: string; available: string }
interface ClusterStatus {
mode: string // "single-node" | "cluster"
peers: Array<{ id: string; fqdn: string }>
}
interface RollingUpdateState {
phase: string // idle | updating-secondary | waiting-secondary | updating-primary | failed
secondary_fqdn: string
secondary_id: string
error?: string
updated_at: string
}
// allUpdates parsed das flache map-Format ({pkg_installed,pkg_available})
// das /system/package-versions zurückliefert. Eines davon ist meist
// das meta-Paket "edgeguard" → die "Ziel-Version".
@@ -53,6 +66,46 @@ export default function UpdateBanner({ compact = false }: UpdateBannerProps = {}
gcTime: 0,
})
const clusterStatus = useQuery({
queryKey: ['cluster', 'status-update-banner'],
queryFn: async () => {
try {
const r = await apiClient.get('/cluster/status')
return isEnvelope(r.data) ? (r.data.data as ClusterStatus) : null
} catch {
return null
}
},
refetchInterval: 60_000,
staleTime: 30_000,
})
const rollingStatus = useQuery({
queryKey: ['cluster', 'rolling-update-status'],
queryFn: async () => {
try {
const r = await apiClient.get('/cluster/rolling-update/status')
return isEnvelope(r.data) ? (r.data.data as RollingUpdateState) : null
} catch {
return null
}
},
refetchInterval: 5_000,
staleTime: 0,
gcTime: 0,
})
const isCluster = clusterStatus.data?.mode === 'cluster'
const rollingPhase = rollingStatus.data?.phase ?? 'idle'
const rollingActive = rollingPhase !== 'idle' && rollingPhase !== 'failed' && rollingPhase !== 'done'
const secondaryFQDN = rollingStatus.data?.secondary_fqdn ?? ''
// Verhindert dass ein stale "done" aus einer vorherigen Session sofort
// einen Reload auslöst. Nur wenn rollingActive in DIESER Session true
// war, reagieren wir auf "done".
const wasRollingActiveRef = useRef(false)
// Normal single-node upgrade state
const [upgrading, setUpgrading] = useState(false)
const [upgradeElapsed, setUpgradeElapsed] = useState(0)
const [forceChecking, setForceChecking] = useState(false)
@@ -61,11 +114,62 @@ export default function UpdateBanner({ compact = false }: UpdateBannerProps = {}
const installedRef = useRef<string>('')
const targetRef = useRef<string>('')
// Rolling update elapsed counter
const [rollingElapsed, setRollingElapsed] = useState(0)
const rollingTickRef = useRef<ReturnType<typeof setInterval> | null>(null)
useEffect(() => () => {
if (upgradePollRef.current) clearInterval(upgradePollRef.current)
if (upgradeTickRef.current) clearInterval(upgradeTickRef.current)
if (rollingTickRef.current) clearInterval(rollingTickRef.current)
}, [])
// Start rolling elapsed timer when rolling becomes active
useEffect(() => {
if (rollingActive) {
wasRollingActiveRef.current = true
if (!rollingTickRef.current) {
setRollingElapsed(0)
rollingTickRef.current = setInterval(() => setRollingElapsed(e => e + 1), 1000)
}
} else if (!rollingActive && rollingTickRef.current) {
clearInterval(rollingTickRef.current)
rollingTickRef.current = null
}
}, [rollingActive])
// "done": nur reagieren wenn wir in DIESER Session rollingActive gesehen
// haben — sonst würde ein stale "done" sofort einen Reload auslösen.
useEffect(() => {
if (rollingPhase === 'done' && wasRollingActiveRef.current) {
msg.success(t('update.success', { version: targetRef.current || '…' }))
setTimeout(() => window.location.reload(), 1500)
}
}, [rollingPhase, msg, t])
// Fallback: wenn "updating-primary" und die API noch antwortet (Primary
// schon neu gestartet bevor das UI die Phase gesehen hat), poll auf "done".
useEffect(() => {
if (rollingPhase === 'updating-primary') {
let sawDown = false
const poll = setInterval(async () => {
try {
const res = await apiClient.get('/system/health')
const newV = isEnvelope(res.data) ? (res.data.data as SystemHealth).version : ''
if (sawDown && newV) {
clearInterval(poll)
void rollingStatus.refetch()
}
} catch {
sawDown = true
}
}, 3000)
// Safety: nach 2 Min einfach reload
const safety = setTimeout(() => { clearInterval(poll); window.location.reload() }, 120_000)
return () => { clearInterval(poll); clearTimeout(safety) }
}
}, [rollingPhase, rollingStatus])
const data = pkgVersions.data ?? {}
const updates = allUpdates(data)
const updateAvailable = updates.length > 0
@@ -76,14 +180,8 @@ export default function UpdateBanner({ compact = false }: UpdateBannerProps = {}
const forceCheck = async () => {
setForceChecking(true)
try {
// ?force=1: bypassed den Server-seitigen 5-min-Throttle für
// apt-get update. Ohne den Force-Hint würde der Endpoint
// einfach den letzten Cache zurückliefern (max. 5 min alt) und
// der Button fühlt sich kaputt an. Pattern aus mail-gateway.
const r = await apiClient.get('/system/package-versions?force=1')
const fresh = (isEnvelope(r.data) ? (r.data.data as PackageVersions) : {})
// useQuery-Cache mit dem frischen Wert füttern damit der Banner
// sofort umschaltet, ohne auf die nächste 30s-Welle zu warten.
void pkgVersions.refetch()
const found = allUpdates(fresh).length > 0
msg[found ? 'success' : 'info'](
@@ -105,8 +203,6 @@ export default function UpdateBanner({ compact = false }: UpdateBannerProps = {}
apiClient.post('/system/upgrade')
.then(() => {
// Poll /healthz (kein Auth, robust auch wenn die API gerade
// restartet und Cookie ihre Session nicht erkennt).
let sawDown = false
upgradePollRef.current = setInterval(async () => {
try {
@@ -121,14 +217,9 @@ export default function UpdateBanner({ compact = false }: UpdateBannerProps = {}
setTimeout(() => window.location.reload(), 1500)
}
} catch {
// Connection refused / 502 → API restartet. Beim nächsten
// erfolgreichen Poll erkennen wir den Version-Flip.
sawDown = true
}
}, 3000)
// Sicherheits-Timeout: nach 2 Min einfach reload — falls der
// Restart länger braucht als erwartet, kommt die UI in jedem
// Fall wieder hoch.
setTimeout(() => {
if (upgradePollRef.current) clearInterval(upgradePollRef.current)
if (upgradeTickRef.current) clearInterval(upgradeTickRef.current)
@@ -144,13 +235,19 @@ export default function UpdateBanner({ compact = false }: UpdateBannerProps = {}
})
}
const startRollingUpdate = () => {
installedRef.current = installedVersion
targetRef.current = targetVersion
apiClient.post('/cluster/rolling-update')
.then(() => {
void rollingStatus.refetch()
})
.catch((e: Error) => {
msg.error(t('update.failed') + ': ' + e.message)
})
}
if (compact) {
// Compact-Variante: Force-Check-Button für "ich will jetzt prüfen",
// wenn aktuell NICHTS ausstehendes da ist. Sobald ein Update
// verfügbar ist, übernimmt der gelbe Full-Mode-Banner (in
// AppLayout) die Sichtbarkeit — wir blenden den Compact-Button
// dann komplett aus, sonst doppelt-doppelt Info (Befund 2026-05-15:
// "die roten Banner können weg, der gelbe Banner reicht").
if (updateAvailable) {
return <>{msgCtx}</>
}
@@ -171,7 +268,7 @@ export default function UpdateBanner({ compact = false }: UpdateBannerProps = {}
)
}
if (!updateAvailable && !upgrading) {
if (!updateAvailable && !upgrading && !rollingActive) {
return <>{msgCtx}</>
}
@@ -179,38 +276,43 @@ export default function UpdateBanner({ compact = false }: UpdateBannerProps = {}
<>
{msgCtx}
{updateAvailable && !upgrading && (
{updateAvailable && !upgrading && !rollingActive && (
<Alert
type="warning"
banner
showIcon
icon={<CloudDownloadOutlined />}
message={t('update.available', { version: targetVersion })}
description={updates.length > 1
? t('update.multiPackageHint', { count: updates.length })
: undefined}
action={
<Space>
<Button
size="small"
icon={<ReloadOutlined />}
loading={forceChecking}
onClick={forceCheck}
>
{t('update.checkNow')}
</Button>
<Popconfirm
title={t('update.confirmTitle')}
description={t('update.confirmDesc', { version: targetVersion })}
okText={t('update.applyNow')}
cancelText={t('common.cancel')}
onConfirm={startUpgrade}
>
<Button size="small" type="primary" icon={<CloudDownloadOutlined />}>
{t('update.applyNow')}
</Button>
</Popconfirm>
</Space>
message={
<div className="update-banner-row">
<span>{t('update.available', { version: targetVersion })}</span>
{isCluster ? (
<Popconfirm
title={t('update.rollingConfirmTitle')}
description={t('update.rollingConfirmDesc', {
secondary: clusterStatus.data?.peers?.[0]?.fqdn ?? 'secondary',
})}
okText={t('update.rollingUpdate')}
cancelText={t('common.cancel')}
onConfirm={startRollingUpdate}
>
<Button size="small" type="primary" icon={<ClusterOutlined />}>
Rolling Update
</Button>
</Popconfirm>
) : (
<Popconfirm
title={t('update.confirmTitle')}
description={t('update.confirmDesc', { version: targetVersion })}
okText={t('update.applyNow')}
cancelText={t('common.cancel')}
onConfirm={startUpgrade}
>
<Button size="small" type="primary" icon={<CloudDownloadOutlined />}>
{t('update.applyNow')}
</Button>
</Popconfirm>
)}
</div>
}
/>
)}
@@ -234,26 +336,10 @@ export default function UpdateBanner({ compact = false }: UpdateBannerProps = {}
</div>
<div className="update-modal__steps">
<Step
done={upgradeElapsed >= 5}
active={upgradeElapsed < 5}
label={t('update.stepDownload')}
/>
<Step
done={upgradeElapsed >= 15}
active={upgradeElapsed >= 5 && upgradeElapsed < 15}
label={t('update.stepInstall')}
/>
<Step
done={upgradeElapsed >= 25}
active={upgradeElapsed >= 15 && upgradeElapsed < 25}
label={t('update.stepRestart')}
/>
<Step
done={false}
active={upgradeElapsed >= 25}
label={t('update.stepVerify')}
/>
<Step done={upgradeElapsed >= 5} active={upgradeElapsed < 5} label={t('update.stepDownload')} />
<Step done={upgradeElapsed >= 15} active={upgradeElapsed >= 5 && upgradeElapsed < 15} label={t('update.stepInstall')} />
<Step done={upgradeElapsed >= 25} active={upgradeElapsed >= 15 && upgradeElapsed < 25} label={t('update.stepRestart')} />
<Step done={false} active={upgradeElapsed >= 25} label={t('update.stepVerify')} />
</div>
<div className="update-modal__timer">{upgradeElapsed}s</div>
@@ -261,6 +347,54 @@ export default function UpdateBanner({ compact = false }: UpdateBannerProps = {}
</div>
</div>
)}
{rollingActive && (
<div className="update-modal-overlay">
<div className="update-modal">
<div className="update-modal__orbit">
<div className="update-modal__ring" />
<div className="update-modal__ring update-modal__ring--2" />
<div className="update-modal__dot" />
<div className="update-modal__dot update-modal__dot--2" />
<div className="update-modal__center">
<ClusterOutlined className="update-modal__icon" />
</div>
</div>
<div className="update-modal__title">{t('update.rollingRunning')}</div>
<div className="update-modal__version">
v{installedRef.current || '…'} v{targetRef.current || '…'}
</div>
<div className="update-modal__steps">
<Step
done={rollingPhase === 'waiting-secondary' || rollingPhase === 'updating-primary'}
active={rollingPhase === 'updating-secondary'}
label={t('update.rollingStepSecondary', { fqdn: secondaryFQDN })}
/>
<Step
done={rollingPhase === 'updating-primary'}
active={rollingPhase === 'waiting-secondary'}
label={t('update.rollingStepWaiting')}
/>
<Step
done={false}
active={rollingPhase === 'updating-primary'}
label={t('update.rollingStepPrimary')}
/>
</div>
<div className="update-modal__timer">{rollingElapsed}s</div>
<div className="update-modal__hint">{t('update.waitHint')}</div>
{rollingStatus.data?.error && (
<div className="update-modal__hint" style={{ color: '#ff4d4f' }}>
{rollingStatus.data.error}
</div>
)}
</div>
</div>
)}
</>
)
}

View File

@@ -27,13 +27,15 @@
"alerts": "Alarme",
"license": "Lizenz",
"settings": "Einstellungen",
"crowdsec": "CrowdSec IDS",
"section": {
"overview": "Übersicht",
"routing": "Routing",
"network": "Netzwerk",
"security": "Sicherheit",
"system": "System"
}
},
"waf": "WAF"
},
"fw": {
"title": "Firewall",
@@ -152,7 +154,9 @@
"emptyTitle": "Noch keine eigenen Firewall-Regeln.",
"emptyDesc": "Die System-Regeln oben halten SSH (rate-limited), HTTPS :443 und Mgmt-UI :3443 immer offen (Anti-Lockout). Eigene Regeln für app-spezifische Inbound-Ports oder zonenübergreifende Forwards anlegen.",
"logEnabled": "Logging aktiv — gematchte Pakete werden ins Firewall-Log geschrieben",
"ruleDisabled": "Regel deaktiviert"
"ruleDisabled": "Regel deaktiviert",
"unnamed": "(kein Name)",
"zeroHitHint": "Keine Treffer seit dem letzten Neustart — möglicherweise ungenutzte oder überlagerte Regel"
},
"kpi": {
"policyRules": "Policy-Regeln",
@@ -171,7 +175,10 @@
"allActions": "Alle Aktionen",
"allZones": "Alle Zonen",
"noResults": "Keine Regeln entsprechen dem Filter",
"noResultsHint": "Filter zurücksetzen um alle Regeln zu sehen."
"noResultsHint": "Filter zurücksetzen um alle Regeln zu sehen.",
"groupView": "Nach Zone gruppieren",
"flatView": "Flache Liste",
"rules": "Regeln"
},
"nat": {
"name": "Name",
@@ -281,7 +288,12 @@
"loggedInAs": "Angemeldet als",
"forgotPassword": "Passwort vergessen?",
"viewerBadge": "Nur lesen",
"viewerHint": "Dieser Account hat die Rolle Betrachter — Änderungen sind gesperrt. Ein Admin kann die Rolle anpassen."
"viewerHint": "Dieser Account hat die Rolle Betrachter — Änderungen sind gesperrt. Ein Admin kann die Rolle anpassen.",
"totp": {
"prompt": "Bitte gib den 6-stelligen Code aus deiner Authenticator-App ein.",
"verify": "Code bestätigen",
"invalidCode": "Ungültiger Code"
}
},
"reset": {
"title": "Admin-Passwort zurücksetzen",
@@ -392,6 +404,17 @@
"backends": "Backends",
"attached": "{{count}}/{{total}} Domains haben einen Primary-Backend"
},
"vipCard": {
"title": "VIP / VRRP",
"noVips": "Keine VIPs konfiguriert",
"keepalivedInactive": "keepalived läuft nicht",
"state": {
"MASTER": "MASTER",
"BACKUP": "BACKUP",
"FAULT": "FAULT",
"UNKNOWN": "Unbekannt"
}
},
"systemCard": {
"title": "System",
"version": "Version",
@@ -399,9 +422,16 @@
"ifaces": "Interfaces",
"wg": "WireGuard"
},
"networkServicesCard": {
"title": "Netzwerk-Dienste",
"configure": "Konfigurieren"
},
"alertsCard": {
"title": "Aktuelle Alerts",
"viewAll": "Alle anzeigen"
"viewAll": "Alle anzeigen",
"summary": "{{critical}} kritisch · {{warning}} Warnung",
"summaryWarning": "{{warning}} Warnung",
"summaryCritical": "{{critical}} kritisch"
},
"downBackendsAlert": "{{count}} Backend(s) komplett ausgefallen — kein Server UP",
"maintenanceAlert": "{{count}} Domain(s) im Wartungs-Modus",
@@ -624,11 +654,22 @@
"tokenLabel": "Token",
"caFingerprintLabel": "CA-Fingerabdruck",
"driftBanner": "Config-Drift erkannt",
"driftBannerDesc": "Ein oder mehrere Peers haben einen anderen Config-Hash als dieser Node. Entweder stehen noch Änderungen in der Outbox, oder auf einem Peer wurde direkt in der DB editiert. Warte bis die Outbox leer ist oder starte Diagnostics.",
"driftBannerDesc": "Ein oder mehrere Peers haben einen anderen Config-Hash als dieser Node. Kurz nach einer Änderung ist das normal (die Replikation hinkt nach) und verschwindet von selbst. Bleibt der Drift bestehen, ist die Replikation gestört oder es wurde direkt in die DB eines Peers geschrieben.",
"repair": {
"button": "Resync erzwingen",
"noStandbyHint": "Kein Standby-Node gefunden, an den der Resync delegiert werden könnte.",
"confirmTitle": "Replikation reparieren?",
"confirmDesc": "Baut die Replikations-Subscription auf dem Standby-Node neu auf und kopiert alle geteilten Config-Tabellen frisch vom Primary. Vom Primary aus geklickt wird der Resync per mTLS an den Standby delegiert. Lokale Direkt-Edits am Standby werden dabei überschrieben (Primary = Source of Truth).",
"confirmOk": "Resync starten",
"started": "Resync angestoßen — läuft im Hintergrund auf dem Standby.",
"ok": "Replikation repariert — Config wieder synchron.",
"failed": "Resync fehlgeschlagen"
},
"col": {
"node": "Knoten",
"status": "Status",
"role": "Rolle",
"pgRole": "PG-Rolle",
"apiUrl": "API-URL",
"configHash": "Config-Hash",
"version": "Version",
@@ -641,6 +682,41 @@
"uptime": "Uptime",
"fetchMs": "Fetch"
},
"pgRole": {
"standalone": "standalone",
"primary": "primary",
"standby": "standby"
},
"vipCard": {
"title": "Hochverfügbarkeit (VIP / Keepalived)",
"vipAddress": "VIP-Adresse",
"vipAddressHelp": "Virtuelle IP-Adresse die zwischen Nodes wandert (z.B. 89.163.205.10)",
"vipInterface": "Netzwerk-Interface",
"vipInterfaceHelp": "Interface auf dem die VIP gebunden wird (z.B. eth0)",
"vipAuthPass": "VRRP Auth-Passwort",
"vipAuthPassHelp": "Max. 8 Zeichen — Keepalived-Limit. Gleich auf allen Nodes.",
"vrrpRouterId": "VRRP Router-ID",
"vrrpRouterIdHelp": "Muss im Subnetz eindeutig sein (1255). Standard: 51.",
"saveBtn": "Speichern & Keepalived neu konfigurieren",
"saved": "VIP-Einstellungen gespeichert",
"saveFailed": "Speichern fehlgeschlagen",
"hintTitle": "Nächste Schritte nach dem Speichern",
"hintPrimary": "Auf dem Primary: edgeguard-ctl cluster-init-replication",
"hintStandby": "Auf dem Secondary: edgeguard-ctl cluster-setup-standby <primary-ip>",
"hintKeepalived": "Keepalived auf beiden Nodes: sudo systemctl enable --now keepalived",
"hintFailover": "Bei Failover: edgeguard-ctl promote (auf dem Secondary)",
"splitBrainSection": "Split-Brain-Schutz (Dual-Path VRRP + Gateway-Tracking)",
"hbInterface": "Heartbeat-Interface",
"hbInterfaceHelp": "Zweites Interface für die VI_HB-Instanz — VRRP-Advertisements laufen hier unabhängig von VI_1. Leer lassen um zu deaktivieren.",
"hbSrcIp": "Heartbeat-Quell-IP",
"hbSrcIpHelp": "Eigene IP auf dem Heartbeat-Interface (unicast_src_ip für VI_HB).",
"hbPeerIp": "Heartbeat-Peer-IP",
"hbPeerIpHelp": "Peer-IP auf dem Heartbeat-Interface (unicast_peer für VI_HB).",
"hbRouterId": "Heartbeat Router-ID",
"hbRouterIdHelp": "VRRP virtual_router_id für VI_HB — muss sich von der Haupt-Router-ID unterscheiden. Standard: 52.",
"gwCheckIp": "Gateway-Check-IP",
"gwCheckIpHelp": "Upstream-Gateway-IP die alle 5 s angepingt wird. Nicht erreichbar → Priorität sinkt um 110 → Failover wird ausgelöst. Leer lassen um zu deaktivieren."
},
"loadTitle": "Per-Node Resources (mTLS-Aggregator)",
"loadEmpty": "Keine Node-Resources verfügbar — Agent-Listener nicht erreichbar?",
"certCardTitle": "Cluster-TLS-Zertifikate",
@@ -674,7 +750,27 @@
"step3SetupDesc": "Setup-Wizard auf dem neuen Knoten öffnen (https://<node-fqdn>:3443/setup), \"Vorhandenem Cluster beitreten\" wählen, Primary-FQDN ({{primaryFqdn}}) eingeben und den Token oben einfügen.",
"generateNewToken": "Neuen Token generieren",
"setupWizardHint": "Setup-Wizard auf dem neuen Knoten öffnen",
"newNodeFqdnLabel": "FQDN des neuen Knotens"
"newNodeFqdnLabel": "FQDN des neuen Knotens",
"vipTest": {
"cardTitle": "VIP-Schwenk Test",
"cardDesc": "Verschiebt einen VIP temporär auf den Secondary um zu prüfen ob die Dienste korrekt antworten. Keepalived ist nicht beteiligt — reiner ip addr add/del Test.",
"colAddress": "VIP-Adresse",
"colInterface": "Interface",
"colActiveOn": "Aktiv auf",
"swingBtn": "→ Secondary",
"restoreBtn": "← Primary",
"swingOk": "VIP erfolgreich auf Secondary geschwenkt",
"restoreOk": "VIP zurück auf Primary",
"swingFailed": "VIP-Schwenk fehlgeschlagen",
"restoreFailed": "VIP-Rückschwenk fehlgeschlagen",
"noVips": "Keine VIPs konfiguriert (ip_addresses mit is_vip=true)",
"steps": "Schritte",
"stepOk": "OK",
"stepFail": "Fehler",
"confirmSwing": "{{addr}} auf Secondary verschieben?",
"confirmRestore": "{{addr}} zurück auf Primary?",
"unknown": "unbekannt"
}
},
"ssl": {
"title": "SSL-Zertifikate",
@@ -841,7 +937,14 @@
"stepDownload": "Pakete laden",
"stepInstall": "Installation",
"stepRestart": "Service-Restart",
"stepVerify": "Verifizierung"
"stepVerify": "Verifizierung",
"rollingUpdate": "Rolling Update (Cluster)",
"rollingConfirmTitle": "Rolling Update starten?",
"rollingConfirmDesc": "Der Secondary-Node ({{secondary}}) wird zuerst aktualisiert, danach dieser Primary. Kein Ausfall für den Proxied-Traffic während der Secondary-Phase.",
"rollingRunning": "Rolling Update läuft…",
"rollingStepSecondary": "Secondary aktualisieren ({{fqdn}})",
"rollingStepWaiting": "Warte auf Neustart des Secondary",
"rollingStepPrimary": "Primary aktualisieren (dieser Node)"
},
"wg": {
"title": "WireGuard",
@@ -1094,7 +1197,16 @@
"flushCacheFailed": "Cache-Flush fehlgeschlagen",
"upstreamForwardsInvalid": "Jeder Forwarder muss eine gültige IP sein (z.B. 1.1.1.1 oder 9.9.9.9)",
"accessACLInvalid": "Jeder Eintrag muss eine gültige IP oder CIDR sein (z.B. 10.0.0.0/8)",
"cacheTTLError": "Cache-Max-TTL muss ≥ Cache-Min-TTL sein"
"cacheTTLError": "Cache-Max-TTL muss ≥ Cache-Min-TTL sein",
"cacheSection": "Cache",
"prefetch": "Häufige Records vorausladen",
"prefetchExtra": "Häufig abgefragte Records werden vor Ablauf der TTL neu aufgelöst — reduziert Latenz für bekannte Namen.",
"serveExpired": "Abgelaufene Records ausliefern",
"serveExpiredExtra": "Stale Cache-Einträge zurückgeben wenn Upstream-Resolver nicht erreichbar ist. Reduziert SERVFAIL bei Ausfällen.",
"msgCacheSizeMB": "Message-Cache (MB)",
"msgCacheSizeMBExtra": "RAM für DNS-Antwort-Cache (msg-cache-size). Standard 64 MB.",
"rrsetCacheSizeMB": "RRset-Cache (MB)",
"rrsetCacheSizeMBExtra": "RAM für Resource-Record-Cache (rrset-cache-size). Sollte ~2x Message-Cache sein. Standard 128 MB."
}
},
"fwd": {
@@ -1148,6 +1260,28 @@
"dstdom_regex": "dstdom_regex — Domain-Regex",
"srcdom_regex": "srcdom_regex — Quell-Domain-Regex",
"browser": "browser — User-Agent-Regex"
},
"settings": {
"title": "Proxy-Einstellungen",
"listenAddresses": "Listen-Adressen",
"listenAddressesExtra": "Komma-getrennte IPs auf denen Squid lauscht (z.B. 10.0.5.1, 10.0.20.1). Leer = alle Interfaces.",
"listenPort": "Port",
"listenPortExtra": "Standard: 3128.",
"saveFailed": "Einstellungen konnten nicht gespeichert werden.",
"cacheSection": "Cache",
"cacheMemMB": "RAM-Cache (MB)",
"cacheMemMBExtra": "RAM den Squid für Caching nutzt (cache_mem). Standard 64 MB.",
"cacheDirMB": "Disk-Cache (MB)",
"cacheDirMBExtra": "Speicherplatz für den UFS-Cache. Standard 100 MB.",
"maxObjSizeMB": "Max. Objektgröße (MB)",
"maxObjSizeMBExtra": "Größtes Objekt das Squid cached. Größere Objekte werden direkt durchgeleitet. Standard 4 MB.",
"timeoutSection": "Timeouts",
"connectTimeout": "Verbindungs-Timeout (s)",
"connectTimeoutExtra": "Sekunden, die Squid beim Verbindungsaufbau zum Upstream wartet.",
"readTimeout": "Lese-Timeout (s)",
"readTimeoutExtra": "Sekunden zwischen aufeinanderfolgenden Lesevorgängen vom Upstream.",
"requestTimeout": "Anfrage-Timeout (s)",
"requestTimeoutExtra": "Maximale Zeit für einen vollständigen Request/Response-Zyklus."
}
},
"common": {
@@ -1173,6 +1307,8 @@
"retry": "Erneut versuchen",
"close": "Schließen",
"refresh": "Aktualisieren",
"back": "Zurück",
"next": "Weiter",
"up": "UP",
"down": "DOWN",
"relTime": {
@@ -1510,7 +1646,24 @@
"errorEmailTaken": "Diese E-Mail-Adresse wird bereits verwendet.",
"cannotDeleteSelf": "Das eigene Konto kann nicht gelöscht werden.",
"you": "Ich",
"never": "Nie"
"never": "Nie",
"totp": {
"on": "2FA",
"off": "",
"setup": "2FA einrichten",
"manage": "2FA verwalten",
"disable": "2FA deaktivieren",
"disableFor": "2FA für {{email}} deaktivieren",
"enabled": "2FA wurde aktiviert",
"disabled": "2FA wurde deaktiviert",
"setupTitle": "Zwei-Faktor-Authentifizierung einrichten",
"manageTitle": "Zwei-Faktor-Authentifizierung",
"scanHint": "Scanne den QR-Code mit Google Authenticator, Authy oder einer kompatiblen App.",
"enterCode": "Gib den 6-stelligen Code aus deiner Authenticator-App ein:",
"confirm": "Bestätigen & aktivieren",
"alreadyEnabled": "2FA ist für diesen Account aktiv.",
"disableHint": "Klicke auf 'Deaktivieren' um 2FA für diesen Account zu entfernen."
}
},
"audit": {
"title": "Audit-Log",
@@ -1540,5 +1693,158 @@
"next": "Weiter",
"showing": "Zeile {{from}}{{to}}"
}
},
"cs": {
"title": "CrowdSec IDS",
"intro": "Kollaborative Intrusion-Detection: Log-basierte Erkennung + Community-Blocklisten via nftables-Bouncer.",
"notInstalled": "CrowdSec ist nicht installiert. Bitte das Paket crowdsec + crowdsec-firewall-bouncer-nftables installieren.",
"status": {
"agent": "Agent",
"bouncer": "Bouncer",
"decisions": "Aktive Sperren",
"alerts": "Alarme",
"running": "Aktiv",
"stopped": "Gestoppt",
"notInstalled": "Nicht installiert"
},
"tabs": {
"decisions": "Entscheidungen",
"alerts": "Alarme",
"bouncers": "Bouncers",
"machines": "Maschinen",
"collections": "Collections"
},
"decision": {
"ip": "IP-Adresse",
"reason": "Grund",
"origin": "Herkunft",
"duration": "Dauer",
"country": "Land",
"as": "AS-Name",
"type": "Typ",
"unban": "Entsperren",
"banModal": "Manuell sperren",
"banBtn": "IP sperren",
"confirmUnban": "IP {{ip}} wirklich entsperren?",
"addSuccess": "IP {{ip}} wurde gesperrt.",
"deleteSuccess": "Sperre aufgehoben."
},
"alert": {
"id": "ID",
"scenario": "Szenario",
"events": "Events",
"sourceIP": "Quell-IP",
"country": "Land",
"start": "Beginn",
"stop": "Ende",
"delete": "Verwerfen",
"confirmDelete": "Alarm #{{id}} wirklich verwerfen?"
},
"bouncer": {
"name": "Name",
"ip": "IP",
"validKey": "Key gültig",
"version": "Version",
"lastPull": "Letzter Pull",
"type": "Typ",
"delete": "Entfernen",
"confirmDelete": "Bouncer {{name}} wirklich entfernen?"
},
"machine": {
"id": "Machine-ID",
"created": "Angelegt",
"lastPush": "Letzter Push",
"validated": "Validiert",
"version": "Version",
"delete": "Entfernen",
"confirmDelete": "Maschine {{id}} wirklich entfernen?"
},
"collection": {
"name": "Collection",
"status": "Status",
"version": "Version",
"author": "Author",
"install": "Installieren",
"remove": "Entfernen",
"enabled": "Installiert",
"disabled": "Nicht installiert",
"confirmRemove": "Collection {{name}} wirklich entfernen?"
}
},
"waf": {
"title": "Web Application Firewall",
"intro": "Domänen-spezifische HTTP-Request-Inspektion via Coraza/OWASP CRS. Standard: für alle Domains deaktiviert.",
"configure": "Konfigurieren",
"toggleFailed": "WAF-Status konnte nicht geändert werden.",
"defaultOffHint": "WAF ist standardmäßig für alle Domains deaktiviert. Unten pro Domain aktivieren und konfigurieren.",
"col": {
"domain": "Domain",
"status": "WAF",
"mode": "Modus",
"paranoia": "Paranoia"
},
"stat": {
"protected": "Geschützt",
"blocking": "Blocking",
"detection": "Nur Erkennung"
},
"mode": {
"detection": "Erkennung",
"blocking": "Blocking"
},
"pl": {
"1": "Basis (empfohlen)",
"2": "Standard",
"3": "Erweitert",
"4": "Maximum (kann Traffic brechen)"
},
"config": {
"enabled": "Aktiviert",
"mode": "Modus",
"paranoia": "Paranoia-Level",
"exclusions": "Regel-Ausnahmen",
"exclusionsHint": "Kommagetrennte Regel-IDs die deaktiviert werden (z.B. 920350, 941130).",
"trustedProxies": "Vertrauenswürdige Proxys",
"trustedProxiesHint": "IPs/CIDRs die die WAF-Inspektion umgehen (z.B. interne Load Balancer).",
"customRules": "Eigene SecRules",
"customRulesHint": "Rohe SecRule-Direktiven die nach dem CRS eingefügt werden. Können CRS-Regeln überschreiben.",
"defaultHint": "Standard: Nur-Erkennung, Paranoia-Level 1. Erst auf Blocking wechseln, nachdem Alerts geprüft wurden.",
"saveFailed": "WAF-Konfiguration konnte nicht gespeichert werden.",
"noExclusions": "Noch keine Regelausnahmen.",
"noNote": "Keine Notiz",
"exclusionsAddHint": "Ausnahmen über den Alarme-Tab hinzufügen — \"Als Ausnahme\" auf einem Alarm klicken."
},
"tabs": {
"domains": "Domains",
"alerts": "Alarme"
},
"alerts": {
"total": "Einträge",
"empty": "Noch keine WAF-Alarme. Regelübereinstimmungen erscheinen hier.",
"purge30d": "> 30 Tage löschen",
"purgeConfirm": "Alle Alarme älter als 30 Tage löschen?",
"purged": "Alarme gelöscht.",
"blocked": "Geblockt",
"detected": "Erkannt",
"col": {
"time": "Zeit",
"action": "Aktion",
"hostname": "Domain",
"clientIp": "Client-IP",
"method": "Methode",
"uri": "URI",
"ruleId": "Regel-ID",
"severity": "Schwere",
"msg": "Meldung"
},
"addException": "Als Ausnahme",
"exceptionAdded": "Regel als Ausnahme für diese Domain hinzugefügt.",
"exceptionFailed": "Ausnahme konnte nicht gespeichert werden.",
"noDomain": "Domain nicht gefunden — Ausnahme manuell konfigurieren.",
"exceptionModalTitle": "Ausnahme für Regel {{rule}} hinzufügen",
"exceptionModalHint": "Optional: Begründung warum diese Regel ein False Positive für diese Domain ist.",
"exceptionNotePlaceholder": "z.B. Unsere API verwendet nicht-standardisierte Header die diese Regel auslösen.",
"alreadyExcluded": "Bereits Ausnahme"
}
}
}
}

View File

@@ -27,13 +27,15 @@
"alerts": "Alerts",
"license": "License",
"settings": "Settings",
"crowdsec": "CrowdSec IDS",
"section": {
"overview": "Overview",
"routing": "Routing",
"network": "Network",
"security": "Security",
"system": "System"
}
},
"waf": "WAF"
},
"fw": {
"title": "Firewall",
@@ -112,7 +114,7 @@
"rule": {
"name": "Name",
"priority": "Priority",
"enabled": "Enabled",
"enabled": "Active",
"log": "Log",
"action": "Action",
"src": "Source",
@@ -152,7 +154,9 @@
"emptyTitle": "No custom firewall rules yet.",
"emptyDesc": "The system rules above keep SSH (rate-limited), HTTPS :443 and the mgmt UI :3443 open (anti-lockout). Add custom rules for app-specific inbound ports or cross-zone forwards.",
"logEnabled": "Logging active — matched packets are written to the firewall log",
"ruleDisabled": "Rule disabled"
"ruleDisabled": "Rule disabled",
"unnamed": "(unnamed)",
"zeroHitHint": "No hits since last restart — possibly unused or shadowed rule"
},
"kpi": {
"policyRules": "Policy Rules",
@@ -171,7 +175,10 @@
"allActions": "All actions",
"allZones": "All zones",
"noResults": "No rules match the filter",
"noResultsHint": "Clear the filter to see all rules."
"noResultsHint": "Clear the filter to see all rules.",
"groupView": "Group by zone",
"flatView": "Flat list",
"rules": "rules"
},
"nat": {
"name": "Name",
@@ -281,7 +288,12 @@
"loggedInAs": "Signed in as",
"forgotPassword": "Forgot your password?",
"viewerBadge": "Read-only",
"viewerHint": "Your account has viewer role — all changes are blocked. Contact an admin to change your role."
"viewerHint": "Your account has viewer role — all changes are blocked. Contact an admin to change your role.",
"totp": {
"prompt": "Enter the 6-digit code from your authenticator app.",
"verify": "Verify code",
"invalidCode": "Invalid code"
}
},
"reset": {
"title": "Reset admin password",
@@ -392,6 +404,17 @@
"backends": "Backends",
"attached": "{{count}}/{{total}} domains have a primary backend"
},
"vipCard": {
"title": "VIP / VRRP",
"noVips": "No VIPs configured",
"keepalivedInactive": "keepalived not running",
"state": {
"MASTER": "MASTER",
"BACKUP": "BACKUP",
"FAULT": "FAULT",
"UNKNOWN": "Unknown"
}
},
"systemCard": {
"title": "System",
"version": "Version",
@@ -399,9 +422,16 @@
"ifaces": "Interfaces",
"wg": "WireGuard"
},
"networkServicesCard": {
"title": "Network services",
"configure": "Configure"
},
"alertsCard": {
"title": "Recent alerts",
"viewAll": "View all"
"title": "Active alerts",
"viewAll": "View all",
"summary": "{{critical}} critical · {{warning}} warning",
"summaryWarning": "{{warning}} warning",
"summaryCritical": "{{critical}} critical"
},
"downBackendsAlert": "{{count}} backend(s) completely down — no server UP",
"maintenanceAlert": "{{count}} domain(s) in maintenance mode",
@@ -624,11 +654,22 @@
"tokenLabel": "Token",
"caFingerprintLabel": "CA fingerprint",
"driftBanner": "Config drift detected",
"driftBannerDesc": "One or more peers have a different config hash than this node. Either changes are still in the outbox or a peer was edited directly in the DB. Wait for the outbox to drain or run diagnostics.",
"driftBannerDesc": "One or more peers have a different config hash than this node. Right after a change this is normal (replication is catching up) and clears on its own. If the drift persists, replication is broken or a peer's DB was edited directly.",
"repair": {
"button": "Force resync",
"noStandbyHint": "No standby node found to delegate the resync to.",
"confirmTitle": "Repair replication?",
"confirmDesc": "Rebuilds the replication subscription on the standby node and re-copies all shared config tables from the primary. When clicked on the primary, the resync is delegated to the standby via mTLS. Local direct edits on the standby will be overwritten (primary = source of truth).",
"confirmOk": "Start resync",
"started": "Resync dispatched — running in the background on the standby.",
"ok": "Replication repaired — config in sync again.",
"failed": "Resync failed"
},
"col": {
"node": "Node",
"status": "Status",
"role": "Role",
"pgRole": "PG role",
"apiUrl": "API URL",
"configHash": "Config hash",
"version": "Version",
@@ -641,6 +682,41 @@
"uptime": "Uptime",
"fetchMs": "Fetch"
},
"pgRole": {
"standalone": "standalone",
"primary": "primary",
"standby": "standby"
},
"vipCard": {
"title": "High Availability (VIP / Keepalived)",
"vipAddress": "VIP address",
"vipAddressHelp": "Virtual IP address that moves between nodes (e.g. 89.163.205.10)",
"vipInterface": "Network interface",
"vipInterfaceHelp": "Interface to bind the VIP on (e.g. eth0)",
"vipAuthPass": "VRRP auth password",
"vipAuthPassHelp": "Max. 8 characters — Keepalived limit. Same on all nodes.",
"vrrpRouterId": "VRRP router ID",
"vrrpRouterIdHelp": "Must be unique in the subnet (1255). Default: 51.",
"saveBtn": "Save & reconfigure Keepalived",
"saved": "VIP settings saved",
"saveFailed": "Failed to save",
"hintTitle": "Next steps after saving",
"hintPrimary": "On primary: edgeguard-ctl cluster-init-replication",
"hintStandby": "On secondary: edgeguard-ctl cluster-setup-standby <primary-ip>",
"hintKeepalived": "Keepalived on both nodes: sudo systemctl enable --now keepalived",
"hintFailover": "On failover: edgeguard-ctl promote (on the secondary node)",
"splitBrainSection": "Split-brain protection (dual-path VRRP + gateway tracking)",
"hbInterface": "Heartbeat interface",
"hbInterfaceHelp": "Second interface for VI_HB instance — VRRP advertisements run here independently of VI_1. Leave empty to disable.",
"hbSrcIp": "Heartbeat source IP",
"hbSrcIpHelp": "Own IP on the heartbeat interface (unicast_src_ip for VI_HB).",
"hbPeerIp": "Heartbeat peer IP",
"hbPeerIpHelp": "Peer IP on the heartbeat interface (unicast_peer for VI_HB).",
"hbRouterId": "Heartbeat router ID",
"hbRouterIdHelp": "VRRP virtual_router_id for VI_HB — must differ from main Router ID. Default: 52.",
"gwCheckIp": "Gateway check IP",
"gwCheckIpHelp": "Upstream gateway IP to ping every 5 s. If unreachable: priority drops by 110 → failover triggers. Leave empty to disable."
},
"loadTitle": "Per-node resources (mTLS aggregator)",
"loadEmpty": "No node resources available — agent listener unreachable?",
"certCardTitle": "Cluster TLS certificates",
@@ -674,7 +750,27 @@
"step3SetupDesc": "Open the setup wizard on the new node (https://<node-fqdn>:3443/setup), choose \"Join existing cluster\", enter the primary FQDN ({{primaryFqdn}}) and paste the token above.",
"generateNewToken": "Generate new token",
"setupWizardHint": "Open the setup wizard on the new node",
"newNodeFqdnLabel": "New node FQDN"
"newNodeFqdnLabel": "New node FQDN",
"vipTest": {
"cardTitle": "VIP failover test",
"cardDesc": "Temporarily move a VIP to the secondary to test that services respond correctly. Keepalived is not involved — this is a raw ip addr add/del test.",
"colAddress": "VIP address",
"colInterface": "Interface",
"colActiveOn": "Active on",
"swingBtn": "→ Secondary",
"restoreBtn": "← Primary",
"swingOk": "VIP successfully moved to secondary",
"restoreOk": "VIP restored to primary",
"swingFailed": "VIP swing failed",
"restoreFailed": "VIP restore failed",
"noVips": "No VIPs configured (ip_addresses with is_vip=true)",
"steps": "Steps",
"stepOk": "OK",
"stepFail": "Failed",
"confirmSwing": "Move {{addr}} to secondary?",
"confirmRestore": "Restore {{addr}} to primary?",
"unknown": "unknown"
}
},
"ssl": {
"title": "SSL certificates",
@@ -841,7 +937,14 @@
"stepDownload": "Download packages",
"stepInstall": "Install",
"stepRestart": "Service restart",
"stepVerify": "Verification"
"stepVerify": "Verification",
"rollingUpdate": "Rolling Update (Cluster)",
"rollingConfirmTitle": "Start Rolling Update?",
"rollingConfirmDesc": "The secondary node ({{secondary}}) is updated first, then this primary. No downtime for proxied traffic during the secondary phase.",
"rollingRunning": "Rolling update in progress…",
"rollingStepSecondary": "Updating secondary ({{fqdn}})",
"rollingStepWaiting": "Waiting for secondary restart",
"rollingStepPrimary": "Updating primary (this node)"
},
"wg": {
"title": "WireGuard",
@@ -1094,7 +1197,16 @@
"flushCacheFailed": "Flush failed",
"upstreamForwardsInvalid": "Each forwarder must be a valid IP (e.g. 1.1.1.1 or 9.9.9.9)",
"accessACLInvalid": "Each entry must be a valid IP or CIDR (e.g. 10.0.0.0/8 or 192.168.1.0/24)",
"cacheTTLError": "Cache max-TTL must be ≥ cache min-TTL"
"cacheTTLError": "Cache max-TTL must be ≥ cache min-TTL",
"cacheSection": "Cache",
"prefetch": "Prefetch popular records",
"prefetchExtra": "Re-fetch records before TTL expires if queried frequently — reduces latency for hot names.",
"serveExpired": "Serve expired records",
"serveExpiredExtra": "Return stale cache entries when upstream resolvers are unreachable. Reduces SERVFAIL during outages.",
"msgCacheSizeMB": "Message cache (MB)",
"msgCacheSizeMBExtra": "RAM for DNS response cache (msg-cache-size). Default 64 MB.",
"rrsetCacheSizeMB": "RRset cache (MB)",
"rrsetCacheSizeMBExtra": "RAM for resource-record cache (rrset-cache-size). Should be ~2x message cache. Default 128 MB."
}
},
"fwd": {
@@ -1148,6 +1260,28 @@
"dstdom_regex": "dstdom_regex — destination domain regex",
"srcdom_regex": "srcdom_regex — source domain regex",
"browser": "browser — User-Agent regex"
},
"settings": {
"title": "Proxy settings",
"listenAddresses": "Listen addresses",
"listenAddressesExtra": "Comma-separated IPs Squid listens on (e.g. 10.0.5.1, 10.0.20.1). Leave empty to listen on all interfaces.",
"listenPort": "Port",
"listenPortExtra": "Default: 3128.",
"saveFailed": "Settings could not be saved.",
"cacheSection": "Cache",
"cacheMemMB": "In-memory cache (MB)",
"cacheMemMBExtra": "RAM used by Squid for caching (cache_mem). Default 64 MB.",
"cacheDirMB": "Disk cache (MB)",
"cacheDirMBExtra": "Disk space for the UFS cache. Default 100 MB.",
"maxObjSizeMB": "Max. object size (MB)",
"maxObjSizeMBExtra": "Largest object Squid will cache. Objects above this are fetched fresh. Default 4 MB.",
"timeoutSection": "Timeouts",
"connectTimeout": "Connect timeout (s)",
"connectTimeoutExtra": "Seconds to wait when opening a connection to the upstream server.",
"readTimeout": "Read timeout (s)",
"readTimeoutExtra": "Seconds Squid waits between consecutive reads from the upstream.",
"requestTimeout": "Request timeout (s)",
"requestTimeoutExtra": "Maximum time for a complete request/response cycle."
}
},
"common": {
@@ -1173,6 +1307,8 @@
"retry": "Retry",
"close": "Close",
"refresh": "Refresh",
"back": "Back",
"next": "Next",
"up": "UP",
"down": "DOWN",
"relTime": {
@@ -1510,7 +1646,24 @@
"errorEmailTaken": "This email address is already in use.",
"cannotDeleteSelf": "You cannot delete your own account.",
"you": "You",
"never": "Never"
"never": "Never",
"totp": {
"on": "2FA",
"off": "",
"setup": "Set up 2FA",
"manage": "Manage 2FA",
"disable": "Disable 2FA",
"disableFor": "Disable 2FA for {{email}}",
"enabled": "2FA has been enabled",
"disabled": "2FA has been disabled",
"setupTitle": "Set up two-factor authentication",
"manageTitle": "Two-factor authentication",
"scanHint": "Scan the QR code with Google Authenticator, Authy, or any compatible app.",
"enterCode": "Enter the 6-digit code from your authenticator app:",
"confirm": "Confirm & activate",
"alreadyEnabled": "2FA is active for this account.",
"disableHint": "Click 'Disable 2FA' to remove two-factor authentication from this account."
}
},
"audit": {
"title": "Audit log",
@@ -1540,5 +1693,158 @@
"next": "Next",
"showing": "Row {{from}}{{to}}"
}
},
"cs": {
"title": "CrowdSec IDS",
"intro": "Collaborative intrusion detection: log-based detection + community blocklists via nftables bouncer.",
"notInstalled": "CrowdSec is not installed. Please install the crowdsec + crowdsec-firewall-bouncer-nftables packages.",
"status": {
"agent": "Agent",
"bouncer": "Bouncer",
"decisions": "Active bans",
"alerts": "Alerts",
"running": "Active",
"stopped": "Stopped",
"notInstalled": "Not installed"
},
"tabs": {
"decisions": "Decisions",
"alerts": "Alerts",
"bouncers": "Bouncers",
"machines": "Machines",
"collections": "Collections"
},
"decision": {
"ip": "IP address",
"reason": "Reason",
"origin": "Origin",
"duration": "Duration",
"country": "Country",
"as": "AS name",
"type": "Type",
"unban": "Unban",
"banModal": "Manual ban",
"banBtn": "Ban IP",
"confirmUnban": "Really unban IP {{ip}}?",
"addSuccess": "IP {{ip}} has been banned.",
"deleteSuccess": "Ban removed."
},
"alert": {
"id": "ID",
"scenario": "Scenario",
"events": "Events",
"sourceIP": "Source IP",
"country": "Country",
"start": "Start",
"stop": "End",
"delete": "Dismiss",
"confirmDelete": "Really dismiss alert #{{id}}?"
},
"bouncer": {
"name": "Name",
"ip": "IP",
"validKey": "Key valid",
"version": "Version",
"lastPull": "Last pull",
"type": "Type",
"delete": "Remove",
"confirmDelete": "Really remove bouncer {{name}}?"
},
"machine": {
"id": "Machine ID",
"created": "Created",
"lastPush": "Last push",
"validated": "Validated",
"version": "Version",
"delete": "Remove",
"confirmDelete": "Really remove machine {{id}}?"
},
"collection": {
"name": "Collection",
"status": "Status",
"version": "Version",
"author": "Author",
"install": "Install",
"remove": "Remove",
"enabled": "Installed",
"disabled": "Not installed",
"confirmRemove": "Really remove collection {{name}}?"
}
},
"waf": {
"title": "Web Application Firewall",
"intro": "Per-domain HTTP request inspection via Coraza/OWASP CRS. Default: off for all domains.",
"configure": "Configure",
"toggleFailed": "Could not change WAF state.",
"defaultOffHint": "WAF is disabled by default for all domains. Enable and configure per domain below.",
"col": {
"domain": "Domain",
"status": "WAF",
"mode": "Mode",
"paranoia": "Paranoia"
},
"stat": {
"protected": "Protected",
"blocking": "Blocking",
"detection": "Detection only"
},
"mode": {
"detection": "Detection",
"blocking": "Blocking"
},
"pl": {
"1": "Basic (recommended)",
"2": "Standard",
"3": "Advanced",
"4": "Maximum (may break traffic)"
},
"config": {
"enabled": "Enabled",
"mode": "Mode",
"paranoia": "Paranoia Level",
"exclusions": "Rule Exclusions",
"exclusionsHint": "Comma-separated rule IDs to disable (e.g. 920350, 941130).",
"trustedProxies": "Trusted Proxies",
"trustedProxiesHint": "IPs/CIDRs that bypass WAF inspection (e.g. internal load balancers).",
"customRules": "Custom SecRules",
"customRulesHint": "Raw SecRule directives appended after the CRS. Applied last, can override CRS rules.",
"defaultHint": "Default: Detection-Only, Paranoia Level 1. Switch to Blocking only after reviewing alerts.",
"saveFailed": "Failed to save WAF configuration.",
"noExclusions": "No rule exclusions yet.",
"noNote": "No note",
"exclusionsAddHint": "Add exceptions via the Alerts tab — click \"Add exception\" on an alert."
},
"tabs": {
"domains": "Domains",
"alerts": "Alerts"
},
"alerts": {
"total": "entries",
"empty": "No WAF alerts yet. Rules matched will appear here.",
"purge30d": "Purge > 30 days",
"purgeConfirm": "Delete all alerts older than 30 days?",
"purged": "Alerts purged.",
"blocked": "Blocked",
"detected": "Detected",
"col": {
"time": "Time",
"action": "Action",
"hostname": "Domain",
"clientIp": "Client IP",
"method": "Method",
"uri": "URI",
"ruleId": "Rule ID",
"severity": "Severity",
"msg": "Message"
},
"addException": "Add exception",
"exceptionAdded": "Rule added as exception for this domain.",
"exceptionFailed": "Failed to add exception.",
"noDomain": "Domain not found — configure exception manually.",
"exceptionModalTitle": "Add exception for rule {{rule}}",
"exceptionModalHint": "Optional: describe why this rule is a false positive for this domain.",
"exceptionNotePlaceholder": "e.g. Our custom API uses non-standard headers that trigger this rule.",
"alreadyExcluded": "Already excluded"
}
}
}
}

View File

@@ -0,0 +1,44 @@
// Behandelt "stale chunk"-Fehler: Ein Tab, der noch den Build von gestern
// fährt, referenziert gehashte Chunk-Dateien (z.B. Cluster-BOSNsJEn.js).
// Nach einem Deploy existieren diese Hashes nicht mehr → der Lazy-Import
// läuft auf 404 ("Failed to fetch dynamically imported module"). Ein
// voller Reload holt frisches index.html mit den neuen Hashes und behebt
// das. Wir machen diesen Reload automatisch — aber nur einmal pro
// Cooldown-Fenster, damit es keine Endlosschleife gibt wenn der Server
// wirklich nicht erreichbar ist.
const RELOAD_FLAG = 'eg:stale-chunk-reload-at'
const COOLDOWN_MS = 15_000
// isStaleChunkError erkennt die Lazy-Import-/Preload-Fehler quer über
// Browser (Chrome/Safari/Firefox formulieren sie unterschiedlich).
export function isStaleChunkError(err: unknown): boolean {
const msg = err instanceof Error ? err.message : String(err ?? '')
return /Loading chunk|Failed to fetch dynamically imported module|Importing a module script failed|error loading dynamically imported module|'text\/html' is not a valid JavaScript MIME type/i.test(msg)
}
// reloadForStaleChunkOnce lädt die Seite genau einmal neu. Gibt false
// zurück, wenn innerhalb des Cooldowns bereits neu geladen wurde — dann
// soll der Aufrufer auf eine manuelle Fehler-UI zurückfallen (der Reload
// hat das Problem offensichtlich nicht gelöst, z.B. Server down).
export function reloadForStaleChunkOnce(): boolean {
let last = 0
try { last = Number(sessionStorage.getItem(RELOAD_FLAG) || 0) } catch { /* private mode */ }
const now = Date.now()
if (last && now - last < COOLDOWN_MS) return false
try { sessionStorage.setItem(RELOAD_FLAG, String(now)) } catch { /* ignore */ }
window.location.reload()
return true
}
// installStaleChunkReload registriert einen globalen Listener für Vites
// preloadError-Event (gefeuert wenn ein dynamisch importiertes Modul
// nicht geladen werden kann). preventDefault verhindert das erneute
// Werfen durch Vite; danach laden wir einmalig neu. Fängt Fälle ab, die
// nicht im React-Render-Pfad landen (z.B. Modul-Preload).
export function installStaleChunkReload(): void {
window.addEventListener('vite:preloadError', (e) => {
e.preventDefault()
reloadForStaleChunkOnce()
})
}

View File

@@ -1,12 +1,17 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { ensureStorageSchema } from './lib/storageSchema'
import { installStaleChunkReload } from './lib/staleChunkReload'
// Vor allen anderen Imports die Storage prüfen — i18n und auth-store
// lesen beim Modul-Init aus Storage, also muss der Cleanup davor
// passieren wenn die Schema-Version nicht stimmt.
ensureStorageSchema()
// Stale-Chunk-Reload global registrieren: fängt Lazy-Import-Fehler nach
// einem Deploy ab und lädt einmalig neu, statt eine Fehlerseite zu zeigen.
installStaleChunkReload()
import './styles/enterprise.css'
import './i18n'
import App from './App.tsx'

View File

@@ -1,6 +1,6 @@
import { Alert, Button, Card, Descriptions, Input, Popconfirm, Space, Spin, Table, Tag, Tooltip, Typography, message } from 'antd'
import { Alert, Button, Card, Descriptions, Input, List, Popconfirm, Space, Spin, Table, Tag, Tooltip, Typography, message } from 'antd'
import type { ColumnsType } from 'antd/es/table'
import { ApartmentOutlined, CopyOutlined, DeleteOutlined, KeyOutlined, ReloadOutlined } from '@ant-design/icons'
import { ApartmentOutlined, CopyOutlined, DeleteOutlined, KeyOutlined, ReloadOutlined, SwapOutlined } from '@ant-design/icons'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
@@ -22,6 +22,7 @@ interface HANode {
internal_ip?: string | null
mgmt_ip?: string | null
role: string
pg_role: 'standalone' | 'primary' | 'standby'
version?: string | null
config_hash?: string | null
status: 'online' | 'offline' | 'joining' | 'leaving' | 'unknown'
@@ -39,6 +40,16 @@ interface ClusterStatus {
updated_at: string
}
interface RepairStatus {
phase: 'idle' | 'running' | 'success' | 'failed'
state: string
result: string
exit_code: number
started_at?: string
finished_at?: string
log: string[]
}
interface NodeResources {
load_avg_1: number
load_avg_5: number
@@ -86,6 +97,28 @@ interface CertStatus {
peer?: CertInfo
}
interface VIPInfo {
id: number
address: string
prefix: number
device: string
}
interface VIPStatusEntry {
vip: VIPInfo
active_on: string[]
}
interface VIPTestStep {
step: string
ok: boolean
message?: string
}
interface VIPTestResult {
steps: VIPTestStep[]
}
function statusTag(s: HANode['status'], t: (k: string) => string) {
switch (s) {
case 'online': return <Tag color="green">{t('cluster.status.online')}</Tag>
@@ -271,8 +304,103 @@ export default function ClusterPage() {
onError: () => message.error(t('cluster.joinTokenFailed')),
})
const isClusterMode = data?.mode === 'cluster'
const vipStatusQuery = useQuery({
queryKey: ['cluster', 'vip-status'],
queryFn: async () => {
const r = await apiClient.get('/cluster/vip-status')
const payload = isEnvelope(r.data) ? (r.data.data as { vips?: VIPStatusEntry[] }) : null
return payload?.vips ?? []
},
enabled: isClusterMode,
refetchInterval: 30_000,
retry: 1,
})
const [vipTestResult, setVipTestResult] = useState<{ id: number; steps: VIPTestStep[] } | null>(null)
const vipSwing = useMutation({
mutationFn: async ({ id, action }: { id: number; action: 'to_secondary' | 'restore' }) => {
const r = await apiClient.post('/cluster/vip-test', { ip_address_id: id, action })
return isEnvelope(r.data) ? (r.data.data as VIPTestResult) : null
},
onSuccess: (result, { id, action }) => {
if (result) setVipTestResult({ id, steps: result.steps })
const allOk = result?.steps.every(s => s.ok) ?? false
if (allOk) {
const key = action === 'to_secondary' ? 'cluster.vipTest.swingOk' : 'cluster.vipTest.restoreOk'
void message.success(t(key))
} else {
const key = action === 'to_secondary' ? 'cluster.vipTest.swingFailed' : 'cluster.vipTest.restoreFailed'
void message.error(t(key))
}
void vipStatusQuery.refetch()
},
onError: (e: Error) => void message.error(e.message),
})
// ── Replication-Repair ("Resync erzwingen") ──────────────────
const [repairing, setRepairing] = useState(false)
const repairStatusQuery = useQuery({
queryKey: ['cluster', 'repair-status'],
queryFn: async () => {
const r = await apiClient.get('/cluster/repair-replication/status')
return isEnvelope(r.data) ? (r.data.data as RepairStatus) : null
},
enabled: repairing,
refetchInterval: 3_000,
retry: 1,
})
useEffect(() => {
if (!repairing) return
const st = repairStatusQuery.data
// Job meldet Fehler → abbrechen mit letzter Log-Zeile.
if (st?.phase === 'failed') {
setRepairing(false)
const tail = st.log?.slice(-1)[0] ?? ''
void message.error(t('cluster.repair.failed') + (tail ? ': ' + tail : ''))
return
}
// Erfolg = Job meldet success ODER der Drift ist verschwunden. Letzteres
// ist das verlässliche Signal, da die transiente systemd-Unit (--collect)
// nach Erfolg verschwindet und "success" so verpasst werden kann.
if (st?.phase === 'success' || data?.drift_found === false) {
setRepairing(false)
void message.success(t('cluster.repair.ok'))
void qc.invalidateQueries({ queryKey: ['cluster'] })
return
}
// Cluster-Status frisch halten, damit drift_found zeitnah umspringt.
void qc.invalidateQueries({ queryKey: ['cluster', 'status'] })
}, [repairing, repairStatusQuery.data, data?.drift_found, qc, t])
const repairReplication = useMutation({
mutationFn: async () => {
const r = await apiClient.post('/cluster/repair-replication')
return r.data
},
onSuccess: () => {
setRepairing(true)
void message.info(t('cluster.repair.started'))
void repairStatusQuery.refetch()
},
onError: (e: Error) => void message.error(t('cluster.repair.failed') + ': ' + e.message),
})
const primaryFqdn = data?.local_node?.fqdn ?? window.location.hostname
// Repair-Button: sichtbar bei Drift, für Admins, wenn ein Resync-Ziel
// existiert — auf dem Standby (lokal) oder auf dem Primary (delegiert
// an den Standby-Peer).
const localRole = data?.local_node?.pg_role
const canRepair = !isViewer
&& !!data?.drift_found
&& (localRole === 'standby'
|| (localRole === 'primary' && (data?.peers?.some(p => p.pg_role === 'standby') ?? false)))
const peerColumns: ColumnsType<HANode> = [
{
title: t('cluster.col.node'), key: 'node',
@@ -291,6 +419,13 @@ export default function ClusterPage() {
title: t('cluster.col.role'), dataIndex: 'role', width: 110,
render: (v: string) => <Tag color={v === 'primary' ? 'gold' : 'default'}>{v}</Tag>,
},
{
title: t('cluster.col.pgRole'), dataIndex: 'pg_role', width: 110,
render: (v?: string) => {
if (!v || v === 'standalone') return <Tag>{t('cluster.pgRole.standalone')}</Tag>
return <Tag color={v === 'primary' ? 'blue' : 'cyan'}>{t(`cluster.pgRole.${v}`)}</Tag>
},
},
{
title: t('cluster.col.version'), dataIndex: 'version', width: 100,
render: (v?: string | null) => v ? <Tag>{v}</Tag> : <Text type="secondary"></Text>,
@@ -402,7 +537,35 @@ export default function ClusterPage() {
banner
className="mb-16"
message={t('cluster.driftBanner')}
description={t('cluster.driftBannerDesc')}
description={
<>
<Paragraph style={{ marginBottom: 8 }}>{t('cluster.driftBannerDesc')}</Paragraph>
{data.local_node?.pg_role === 'primary'
&& !(data.peers?.some(p => p.pg_role === 'standby'))
&& <Text type="secondary">{t('cluster.repair.noStandbyHint')}</Text>}
</>
}
action={
canRepair ? (
<Popconfirm
title={t('cluster.repair.confirmTitle')}
description={t('cluster.repair.confirmDesc')}
okText={t('cluster.repair.confirmOk')}
cancelText={t('common.cancel')}
okButtonProps={{ danger: true }}
onConfirm={() => repairReplication.mutate()}
>
<Button
danger
size="small"
icon={<ReloadOutlined />}
loading={repairing || repairReplication.isPending}
>
{t('cluster.repair.button')}
</Button>
</Popconfirm>
) : undefined
}
/>
)}
@@ -525,6 +688,13 @@ export default function ClusterPage() {
{data.local_node.role}
</Tag>
</Descriptions.Item>
<Descriptions.Item label={t('cluster.col.pgRole')}>
{(() => {
const v = data.local_node.pg_role
if (!v || v === 'standalone') return <Tag>{t('cluster.pgRole.standalone')}</Tag>
return <Tag color={v === 'primary' ? 'blue' : 'cyan'}>{t(`cluster.pgRole.${v}`)}</Tag>
})()}
</Descriptions.Item>
<Descriptions.Item label={t('cluster.col.version')}>
{data.local_node.version ? <Tag>{data.local_node.version}</Tag> : '—'}
</Descriptions.Item>
@@ -626,6 +796,134 @@ export default function ClusterPage() {
</Card>
)}
{/* ── VIP-Schwenk Test ─────────────────────────────────── */}
{isClusterMode && (
<Card
size="small"
title={<Space><SwapOutlined />{t('cluster.vipTest.cardTitle')}</Space>}
className="mb-16"
extra={
<Button size="small" icon={<ReloadOutlined />} onClick={() => void vipStatusQuery.refetch()}>
{t('common.refresh')}
</Button>
}
>
<Alert
type="info"
showIcon
message={t('cluster.vipTest.cardDesc')}
className="mb-12"
/>
{vipStatusQuery.isLoading ? (
<Spin />
) : (vipStatusQuery.data?.length ?? 0) === 0 ? (
<Text type="secondary">{t('cluster.vipTest.noVips')}</Text>
) : (
<Table<VIPStatusEntry>
size="small"
rowKey={r => String(r.vip.id)}
dataSource={vipStatusQuery.data ?? []}
pagination={false}
expandable={{
expandedRowRender: r => {
const res = vipTestResult?.id === r.vip.id ? vipTestResult : null
if (!res) return null
return (
<List
size="small"
dataSource={res.steps}
renderItem={s => (
<List.Item>
<Space>
<Tag color={s.ok ? 'green' : 'red'}>{s.ok ? t('cluster.vipTest.stepOk') : t('cluster.vipTest.stepFail')}</Tag>
<Text style={{ fontFamily: 'monospace', fontSize: 12 }}>{s.step}</Text>
{s.message && <Text type="danger" style={{ fontSize: 12 }}>{s.message}</Text>}
</Space>
</List.Item>
)}
/>
)
},
rowExpandable: r => vipTestResult?.id === r.vip.id,
}}
columns={[
{
title: t('cluster.vipTest.colAddress'),
key: 'address',
render: (_, r) => (
<Text style={{ fontFamily: 'monospace' }}>{r.vip.address}/{r.vip.prefix}</Text>
),
},
{
title: t('cluster.vipTest.colInterface'),
key: 'device',
width: 120,
render: (_, r) => <Tag>{r.vip.device}</Tag>,
},
{
title: t('cluster.vipTest.colActiveOn'),
key: 'activeOn',
render: (_, r) => {
if (!r.active_on || r.active_on.length === 0) {
return <Tag color="red">{t('cluster.vipTest.unknown')}</Tag>
}
return (
<Space size={4}>
{r.active_on.map(fqdn => <Tag key={fqdn} color="green">{fqdn}</Tag>)}
</Space>
)
},
},
{
title: t('common.actions'),
key: 'actions',
width: 200,
render: (_, r) => {
const localFqdn = data?.local_node?.fqdn
const onLocal = r.active_on?.includes(localFqdn ?? '') ?? false
const onPeer = r.active_on?.some(f => f !== localFqdn) ?? false
const loading = vipSwing.isPending && (vipSwing.variables as { id: number })?.id === r.vip.id
return (
<Space size={4}>
{!isViewer && !onPeer && (
<Popconfirm
title={t('cluster.vipTest.confirmSwing', { addr: r.vip.address })}
okText={t('common.yes')}
cancelText={t('common.no')}
onConfirm={() => vipSwing.mutate({ id: r.vip.id, action: 'to_secondary' })}
>
<Button size="small" loading={loading && onLocal}>
{t('cluster.vipTest.swingBtn')}
</Button>
</Popconfirm>
)}
{!isViewer && onPeer && (
<Popconfirm
title={t('cluster.vipTest.confirmRestore', { addr: r.vip.address })}
okText={t('common.yes')}
cancelText={t('common.no')}
onConfirm={() => vipSwing.mutate({ id: r.vip.id, action: 'restore' })}
>
<Button size="small" type="primary" loading={loading}>
{t('cluster.vipTest.restoreBtn')}
</Button>
</Popconfirm>
)}
{isViewer && (
<Tooltip title={t('auth.viewerBadge')}>
<Button size="small" disabled>{t('cluster.vipTest.swingBtn')}</Button>
</Tooltip>
)}
</Space>
)
},
},
]}
/>
)}
</Card>
)}
{/* ── Per-Node Resources ────────────────────────────────── */}
<Card
size="small"

View File

@@ -0,0 +1,567 @@
import { useState } from 'react'
import {
Alert,
Button,
Form,
Input,
Modal,
Popconfirm,
Select,
Space,
Switch,
Tag,
Tabs,
Table,
} from 'antd'
import {
CheckCircleOutlined,
CloseCircleOutlined,
DeleteOutlined,
DownloadOutlined,
RadarChartOutlined,
StopOutlined,
} from '@ant-design/icons'
import { useTranslation } from 'react-i18next'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useAuthStore } from '../../stores/auth'
import apiClient, { isEnvelope } from '../../api/client'
import PageHeader from '../../components/PageHeader'
import type {
AddDecisionBody,
Alert as CSAlert,
Bouncer,
CrowdSecStatus,
Decision,
HubItem,
Machine,
} from './types'
// ---------- API helpers -----------------------------------------------------
async function fetchStatus(): Promise<CrowdSecStatus> {
const r = await apiClient.get('/crowdsec/status')
if (isEnvelope(r.data)) return r.data.data as CrowdSecStatus
return r.data as CrowdSecStatus
}
async function fetchDecisions(): Promise<Decision[]> {
const r = await apiClient.get('/crowdsec/decisions')
if (!isEnvelope(r.data)) return []
return (r.data.data as { decisions: Decision[] }).decisions ?? []
}
async function fetchAlerts(): Promise<CSAlert[]> {
const r = await apiClient.get('/crowdsec/alerts')
if (!isEnvelope(r.data)) return []
return (r.data.data as { alerts: CSAlert[] }).alerts ?? []
}
async function fetchBouncers(): Promise<Bouncer[]> {
const r = await apiClient.get('/crowdsec/bouncers')
if (!isEnvelope(r.data)) return []
return (r.data.data as { bouncers: Bouncer[] }).bouncers ?? []
}
async function fetchMachines(): Promise<Machine[]> {
const r = await apiClient.get('/crowdsec/machines')
if (!isEnvelope(r.data)) return []
return (r.data.data as { machines: Machine[] }).machines ?? []
}
async function fetchCollections(): Promise<HubItem[]> {
const r = await apiClient.get('/crowdsec/collections')
if (!isEnvelope(r.data)) return []
return (r.data.data as { collections: HubItem[] }).collections ?? []
}
async function toggleService(service: string, enabled: boolean): Promise<void> {
await apiClient.post('/system/service-toggle', { service, enabled })
}
// ---------- Status strip ----------------------------------------------------
function StatusStrip({ status, onToggle }: { status: CrowdSecStatus | undefined; onToggle: () => void }) {
const { t } = useTranslation()
const isViewer = useAuthStore((s) => s.user?.role) === 'viewer'
const toggleAgent = useMutation({
mutationFn: (enabled: boolean) => toggleService('crowdsec', enabled),
onSuccess: onToggle,
})
const toggleBouncer = useMutation({
mutationFn: (enabled: boolean) => toggleService('crowdsec-firewall-bouncer', enabled),
onSuccess: onToggle,
})
return (
<div className="fw-kpi-strip">
<div className="fw-kpi-card">
<div className="fw-kpi-label">{t('cs.status.agent')}</div>
<div className="fw-kpi-value">
<Space size={8}>
<Tag
icon={status?.agent_running ? <CheckCircleOutlined /> : <CloseCircleOutlined />}
color={status?.agent_running ? 'green' : 'red'}
>
{status?.agent_running ? t('cs.status.running') : t('cs.status.stopped')}
</Tag>
<Switch
size="small"
checked={status?.agent_running ?? false}
disabled={isViewer || !status?.installed}
loading={toggleAgent.isPending}
onChange={(checked) => toggleAgent.mutate(checked)}
/>
</Space>
</div>
{status?.version && (
<div className="fw-kpi-sub">{status.version}</div>
)}
</div>
<div className="fw-kpi-card">
<div className="fw-kpi-label">{t('cs.status.bouncer')}</div>
<div className="fw-kpi-value">
<Space size={8}>
<Tag
icon={status?.bouncer_running ? <CheckCircleOutlined /> : <CloseCircleOutlined />}
color={status?.bouncer_running ? 'green' : 'red'}
>
{status?.bouncer_running ? t('cs.status.running') : t('cs.status.stopped')}
</Tag>
<Switch
size="small"
checked={status?.bouncer_running ?? false}
disabled={isViewer || !status?.installed}
loading={toggleBouncer.isPending}
onChange={(checked) => toggleBouncer.mutate(checked)}
/>
</Space>
</div>
</div>
<div className="fw-kpi-card">
<div className="fw-kpi-label">{t('cs.status.decisions')}</div>
<div className="fw-kpi-value">{status?.decision_count ?? ''}</div>
<div className="fw-kpi-sub">{t('cs.tabs.decisions')}</div>
</div>
<div className="fw-kpi-card">
<div className="fw-kpi-label">{t('cs.status.alerts')}</div>
<div className="fw-kpi-value">{status?.alert_count ?? ''}</div>
<div className="fw-kpi-sub">{t('cs.tabs.alerts')}</div>
</div>
</div>
)
}
// ---------- Decisions tab ---------------------------------------------------
function DecisionsTab() {
const { t } = useTranslation()
const queryClient = useQueryClient()
const [banModalOpen, setBanModalOpen] = useState(false)
const [form] = Form.useForm<AddDecisionBody>()
const { data: decisions, isLoading } = useQuery({
queryKey: ['crowdsec', 'decisions'],
queryFn: fetchDecisions,
refetchInterval: 30_000,
})
const unban = useMutation({
mutationFn: (id: number) => apiClient.delete(`/crowdsec/decisions?id=${id}`),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['crowdsec'] }),
})
const addDecision = useMutation({
mutationFn: (body: AddDecisionBody) => apiClient.post('/crowdsec/decisions', body),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['crowdsec'] })
setBanModalOpen(false)
form.resetFields()
},
})
const columns = [
{ title: t('cs.decision.ip'), dataIndex: 'value', key: 'value' },
{ title: t('cs.decision.reason'), dataIndex: 'reason', key: 'reason' },
{ title: t('cs.decision.origin'), dataIndex: 'origin', key: 'origin' },
{ title: t('cs.decision.duration'), dataIndex: 'duration', key: 'duration' },
{ title: t('cs.decision.type'), dataIndex: 'type', key: 'type',
render: (v: string) => <Tag color={v === 'ban' ? 'red' : 'orange'}>{v}</Tag> },
{ title: t('cs.decision.country'), dataIndex: 'country', key: 'country' },
{ title: t('cs.decision.as'), dataIndex: 'as', key: 'as' },
{
title: '',
key: 'actions',
render: (_: unknown, row: Decision) => (
<Popconfirm
title={t('cs.decision.confirmUnban', { ip: row.value })}
onConfirm={() => unban.mutate(row.id)}
okText={t('cs.decision.unban')}
okButtonProps={{ danger: true }}
>
<Button size="small" danger icon={<StopOutlined />}>
{t('cs.decision.unban')}
</Button>
</Popconfirm>
),
},
]
return (
<div className="mt-2">
<div className="mb-2">
<Button
type="primary"
icon={<StopOutlined />}
onClick={() => setBanModalOpen(true)}
>
{t('cs.decision.banBtn')}
</Button>
</div>
<Table
size="small"
rowKey="id"
loading={isLoading}
dataSource={decisions ?? []}
columns={columns}
pagination={{ pageSize: 20 }}
/>
<Modal
title={t('cs.decision.banModal')}
open={banModalOpen}
onCancel={() => { setBanModalOpen(false); form.resetFields() }}
onOk={() => form.submit()}
okButtonProps={{ danger: true, loading: addDecision.isPending }}
okText={t('cs.decision.banBtn')}
>
<Form
form={form}
layout="vertical"
initialValues={{ duration: '24h', reason: 'manual ban', type: 'ban' }}
onFinish={(vals) => addDecision.mutate(vals)}
>
<Form.Item name="ip" label={t('cs.decision.ip')} rules={[{ required: true }]}>
<Input placeholder="1.2.3.4" />
</Form.Item>
<Form.Item name="duration" label={t('cs.decision.duration')} rules={[{ required: true }]}>
<Select>
<Select.Option value="1h">1h</Select.Option>
<Select.Option value="12h">12h</Select.Option>
<Select.Option value="24h">24h</Select.Option>
<Select.Option value="168h">7d</Select.Option>
<Select.Option value="720h">30d</Select.Option>
<Select.Option value="8760h">permanent (1y)</Select.Option>
</Select>
</Form.Item>
<Form.Item name="reason" label={t('cs.decision.reason')}>
<Input />
</Form.Item>
<Form.Item name="type" label={t('cs.decision.type')}>
<Select>
<Select.Option value="ban">ban</Select.Option>
<Select.Option value="captcha">captcha</Select.Option>
</Select>
</Form.Item>
</Form>
</Modal>
</div>
)
}
// ---------- Alerts tab ------------------------------------------------------
function AlertsTab() {
const { t } = useTranslation()
const queryClient = useQueryClient()
const { data: alerts, isLoading } = useQuery({
queryKey: ['crowdsec', 'alerts'],
queryFn: fetchAlerts,
refetchInterval: 30_000,
})
const deleteAlert = useMutation({
mutationFn: (id: number) => apiClient.delete(`/crowdsec/alerts/${id}`),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['crowdsec'] }),
})
const columns = [
{ title: t('cs.alert.id'), dataIndex: 'id', key: 'id', width: 70 },
{ title: t('cs.alert.scenario'), dataIndex: 'scenario', key: 'scenario' },
{ title: t('cs.alert.events'), dataIndex: 'events_count', key: 'events_count', width: 80 },
{ title: t('cs.alert.sourceIP'), key: 'sourceIP',
render: (_: unknown, row: CSAlert) => row.source?.ip ?? row.source?.value ?? '' },
{ title: t('cs.alert.country'), key: 'country',
render: (_: unknown, row: CSAlert) => row.source?.cn ?? '' },
{ title: t('cs.alert.start'), dataIndex: 'start_at', key: 'start_at' },
{ title: t('cs.alert.stop'), dataIndex: 'stop_at', key: 'stop_at' },
{
title: '',
key: 'actions',
render: (_: unknown, row: CSAlert) => (
<Popconfirm
title={t('cs.alert.confirmDelete', { id: row.id })}
onConfirm={() => deleteAlert.mutate(row.id)}
okButtonProps={{ danger: true }}
>
<Button size="small" danger icon={<DeleteOutlined />}>
{t('cs.alert.delete')}
</Button>
</Popconfirm>
),
},
]
return (
<div className="mt-2">
<Table
size="small"
rowKey="id"
loading={isLoading}
dataSource={alerts ?? []}
columns={columns}
pagination={{ pageSize: 20 }}
/>
</div>
)
}
// ---------- Bouncers tab ----------------------------------------------------
function BouncersTab() {
const { t } = useTranslation()
const queryClient = useQueryClient()
const { data: bouncers, isLoading } = useQuery({
queryKey: ['crowdsec', 'bouncers'],
queryFn: fetchBouncers,
refetchInterval: 30_000,
})
const deleteBouncer = useMutation({
mutationFn: (name: string) => apiClient.delete(`/crowdsec/bouncers/${encodeURIComponent(name)}`),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['crowdsec'] }),
})
const columns = [
{ title: t('cs.bouncer.name'), dataIndex: 'name', key: 'name' },
{ title: t('cs.bouncer.ip'), dataIndex: 'ip_address', key: 'ip_address' },
{ title: t('cs.bouncer.validKey'), dataIndex: 'revoked', key: 'revoked',
render: (v: boolean) => !v
? <Tag color="green"><CheckCircleOutlined /> OK</Tag>
: <Tag color="red"><CloseCircleOutlined /> revoked</Tag> },
{ title: t('cs.bouncer.version'), dataIndex: 'version', key: 'version' },
{ title: t('cs.bouncer.type'), dataIndex: 'type', key: 'type' },
{ title: t('cs.bouncer.lastPull'), dataIndex: 'last_pull', key: 'last_pull' },
{
title: '',
key: 'actions',
render: (_: unknown, row: Bouncer) => (
<Popconfirm
title={t('cs.bouncer.confirmDelete', { name: row.name })}
onConfirm={() => deleteBouncer.mutate(row.name)}
okButtonProps={{ danger: true }}
>
<Button size="small" danger icon={<DeleteOutlined />}>
{t('cs.bouncer.delete')}
</Button>
</Popconfirm>
),
},
]
return (
<div className="mt-2">
<Table
size="small"
rowKey="name"
loading={isLoading}
dataSource={bouncers ?? []}
columns={columns}
pagination={{ pageSize: 20 }}
/>
</div>
)
}
// ---------- Machines tab ----------------------------------------------------
function MachinesTab() {
const { t } = useTranslation()
const queryClient = useQueryClient()
const { data: machines, isLoading } = useQuery({
queryKey: ['crowdsec', 'machines'],
queryFn: fetchMachines,
refetchInterval: 30_000,
})
const deleteMachine = useMutation({
mutationFn: (id: string) => apiClient.delete(`/crowdsec/machines/${encodeURIComponent(id)}`),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['crowdsec'] }),
})
const columns = [
{ title: t('cs.machine.id'), dataIndex: 'machineId', key: 'machineId' },
{ title: t('cs.machine.created'), dataIndex: 'created_at', key: 'created_at' },
{ title: t('cs.machine.lastPush'), dataIndex: 'last_push', key: 'last_push' },
{ title: t('cs.machine.validated'), dataIndex: 'isValidated', key: 'isValidated',
render: (v: boolean) => v
? <Tag color="green">ja</Tag>
: <Tag color="orange">nein</Tag> },
{ title: t('cs.machine.version'), dataIndex: 'version', key: 'version' },
{
title: '',
key: 'actions',
render: (_: unknown, row: Machine) => (
<Popconfirm
title={t('cs.machine.confirmDelete', { id: row.machineId })}
onConfirm={() => deleteMachine.mutate(row.machineId)}
okButtonProps={{ danger: true }}
>
<Button size="small" danger icon={<DeleteOutlined />}>
{t('cs.machine.delete')}
</Button>
</Popconfirm>
),
},
]
return (
<div className="mt-2">
<Table
size="small"
rowKey="machineId"
loading={isLoading}
dataSource={machines ?? []}
columns={columns}
pagination={{ pageSize: 20 }}
/>
</div>
)
}
// ---------- Collections tab -------------------------------------------------
function CollectionsTab() {
const { t } = useTranslation()
const queryClient = useQueryClient()
const { data: collections, isLoading } = useQuery({
queryKey: ['crowdsec', 'collections'],
queryFn: fetchCollections,
refetchInterval: 60_000,
})
const installCollection = useMutation({
mutationFn: (name: string) => apiClient.post(`/crowdsec/collections/${encodeURIComponent(name)}/install`),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['crowdsec'] }),
})
const removeCollection = useMutation({
mutationFn: (name: string) => apiClient.delete(`/crowdsec/collections/${encodeURIComponent(name)}`),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['crowdsec'] }),
})
const isEnabled = (status: string) =>
status === 'enabled' || status === 'downloaded'
const columns = [
{ title: t('cs.collection.name'), dataIndex: 'name', key: 'name' },
{ title: t('cs.collection.status'), dataIndex: 'status', key: 'status',
render: (v: string) => (
<Tag color={isEnabled(v) ? 'green' : 'default'}>
{isEnabled(v) ? t('cs.collection.enabled') : t('cs.collection.disabled')}
</Tag>
) },
{ title: t('cs.collection.version'), dataIndex: 'local_version', key: 'local_version' },
{ title: t('cs.collection.author'), dataIndex: 'author', key: 'author' },
{
title: '',
key: 'actions',
render: (_: unknown, row: HubItem) => (
<Space size={4}>
{!isEnabled(row.status) ? (
<Button
size="small"
icon={<DownloadOutlined />}
loading={installCollection.isPending}
onClick={() => installCollection.mutate(row.name)}
>
{t('cs.collection.install')}
</Button>
) : (
<Popconfirm
title={t('cs.collection.confirmRemove', { name: row.name })}
onConfirm={() => removeCollection.mutate(row.name)}
okButtonProps={{ danger: true }}
>
<Button size="small" danger icon={<DeleteOutlined />}>
{t('cs.collection.remove')}
</Button>
</Popconfirm>
)}
</Space>
),
},
]
return (
<div className="mt-2">
<Table
size="small"
rowKey="name"
loading={isLoading}
dataSource={collections ?? []}
columns={columns}
pagination={{ pageSize: 50 }}
/>
</div>
)
}
// ---------- Page ------------------------------------------------------------
export default function CrowdSecPage() {
const { t } = useTranslation()
const queryClient = useQueryClient()
const { data: status } = useQuery({
queryKey: ['crowdsec', 'status'],
queryFn: fetchStatus,
refetchInterval: 10_000,
})
const invalidateStatus = () => {
void queryClient.invalidateQueries({ queryKey: ['crowdsec', 'status'] })
}
const tabs = [
{ key: 'decisions', label: t('cs.tabs.decisions'), children: <DecisionsTab /> },
{ key: 'alerts', label: t('cs.tabs.alerts'), children: <AlertsTab /> },
{ key: 'bouncers', label: t('cs.tabs.bouncers'), children: <BouncersTab /> },
{ key: 'machines', label: t('cs.tabs.machines'), children: <MachinesTab /> },
{ key: 'collections', label: t('cs.tabs.collections'), children: <CollectionsTab /> },
]
return (
<div>
<PageHeader
icon={<RadarChartOutlined />}
title={t('cs.title')}
subtitle={t('cs.intro')}
/>
{status && !status.installed && (
<Alert
type="warning"
showIcon
message={t('cs.notInstalled')}
className="mb-2"
/>
)}
<StatusStrip status={status} onToggle={invalidateStatus} />
<Tabs items={tabs} defaultActiveKey="decisions" type="card" />
</div>
)
}

View File

@@ -0,0 +1,79 @@
export interface Decision {
id: number
origin: string
type: string
scope: string
value: string
duration: string
reason: string
country?: string
as?: string
}
export interface AlertSource {
ip: string
cn?: string
as_name?: string
range?: string
scope?: string
value?: string
}
export interface Alert {
id: number
scenario: string
events_count: number
source: AlertSource
start_at: string
stop_at: string
decisions?: Decision[]
}
export interface Bouncer {
name: string
ip_address?: string
revoked: boolean
last_pull?: string
type?: string
version?: string
created_at: string
auth_type?: string
}
export interface Machine {
machineId: string
created_at: string
updated_at: string
last_push?: string
isValidated: boolean
version?: string
status?: string
}
export interface HubItem {
name: string
description?: string
status: string
local_version?: string
local_path?: string
author?: string
type?: string
}
export interface CrowdSecStatus {
installed: boolean
agent_running: boolean
bouncer_running: boolean
version?: string
decision_count: number
alert_count: number
bouncer_count: number
machine_count: number
}
export interface AddDecisionBody {
ip: string
duration: string
reason: string
type: string
}

View File

@@ -1,5 +1,5 @@
import { useState } from 'react'
import { Alert, Button, Card, Col, Drawer, Form, Input, InputNumber, Modal, Progress, Row, Select, Space, Statistic, Switch, Tabs, Tag, Tooltip, Typography, message } from 'antd'
import { Alert, Button, Card, Col, Divider, Drawer, Form, Input, InputNumber, Modal, Progress, Row, Select, Space, Statistic, Switch, Tabs, Tag, Tooltip, Typography, message } from 'antd'
import type { ColumnsType } from 'antd/es/table'
import { BarChartOutlined, CheckCircleOutlined, ClearOutlined, CloseCircleOutlined, GlobalOutlined, NodeIndexOutlined, PlusOutlined, ReloadOutlined, SettingOutlined } from '@ant-design/icons'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
@@ -43,6 +43,10 @@ interface Settings {
qname_minimisation: boolean
cache_min_ttl: number
cache_max_ttl: number
prefetch: boolean
serve_expired: boolean
msg_cache_size_mb: number
rrset_cache_size_mb: number
}
const RECORD_TYPES = ['A', 'AAAA', 'CNAME', 'TXT', 'MX', 'SRV', 'NS', 'PTR', 'CAA']
@@ -464,6 +468,7 @@ function SettingsTab() {
for (const i of sys ?? []) {
if (i.ifname === 'lo') continue
for (const a of i.addr_info ?? []) {
if (a.local.startsWith('fe80:')) continue
ipOptions.push({
value: a.local,
label: `${a.local}${i.ifname} (${a.family === 'inet' ? 'IPv4' : 'IPv6'})`,
@@ -477,7 +482,20 @@ function SettingsTab() {
.split(',')
.map(s => s.trim())
.filter(Boolean),
} : undefined
} : {
listen_addresses: [],
listen_port: 53,
upstream_forwards: '1.1.1.1, 9.9.9.9',
access_acl: '10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16',
dnssec: false,
qname_minimisation: true,
cache_min_ttl: 60,
cache_max_ttl: 86400,
prefetch: false,
serve_expired: false,
msg_cache_size_mb: 64,
rrset_cache_size_mb: 128,
}
const save = useMutation({
mutationFn: async (v: SettingsForm) => {
@@ -566,9 +584,19 @@ function SettingsTab() {
<Form.Item label={t('dns.settings.qnameMin')} name="qname_minimisation" valuePropName="checked">
<Switch />
</Form.Item>
<Space>
<Form.Item label={t('dns.settings.prefetch')} name="prefetch" valuePropName="checked"
extra={t('dns.settings.prefetchExtra')}>
<Switch />
</Form.Item>
<Form.Item label={t('dns.settings.serveExpired')} name="serve_expired" valuePropName="checked"
extra={t('dns.settings.serveExpiredExtra')}>
<Switch />
</Form.Item>
<Divider plain>{t('dns.settings.cacheSection')}</Divider>
<Space wrap>
<Form.Item label={t('dns.settings.cacheMin')} name="cache_min_ttl" dependencies={['cache_max_ttl']}>
<InputNumber min={0} style={{ width: 120 }} />
<InputNumber min={0} style={{ width: 130 }} addonAfter="s" />
</Form.Item>
<Form.Item
label={t('dns.settings.cacheMax')}
@@ -586,7 +614,15 @@ function SettingsTab() {
}),
]}
>
<InputNumber min={60} style={{ width: 120 }} />
<InputNumber min={60} style={{ width: 130 }} addonAfter="s" />
</Form.Item>
<Form.Item label={t('dns.settings.msgCacheSizeMB')} name="msg_cache_size_mb"
extra={t('dns.settings.msgCacheSizeMBExtra')}>
<InputNumber min={8} max={4096} style={{ width: 130 }} addonAfter="MB" />
</Form.Item>
<Form.Item label={t('dns.settings.rrsetCacheSizeMB')} name="rrset_cache_size_mb"
extra={t('dns.settings.rrsetCacheSizeMBExtra')}>
<InputNumber min={16} max={8192} style={{ width: 130 }} addonAfter="MB" />
</Form.Item>
</Space>
<Form.Item>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,145 @@
import { useEffect, useRef, useState } from 'react'
import { Input, Tag, Tooltip } from 'antd'
import { MessageOutlined, PlusOutlined } from '@ant-design/icons'
// ── InlineNote ────────────────────────────────────────────────────────
// Click the grey icon to add a note, click the gold tag to edit it.
// Saves on Enter/blur, discards on Escape. No modal needed.
interface InlineNoteProps {
value?: string | null
disabled?: boolean
onSave: (note: string) => void
addTitle?: string
editTitle?: string
}
export function InlineNote({ value, disabled, onSave, addTitle = 'Notiz hinzufügen', editTitle = 'Klicken zum Bearbeiten' }: InlineNoteProps) {
const [editing, setEditing] = useState(false)
const [text, setText] = useState(value ?? '')
const inputRef = useRef<HTMLInputElement>(null)
const originalRef = useRef(value ?? '')
useEffect(() => { setText(value ?? ''); originalRef.current = value ?? '' }, [value])
useEffect(() => { if (editing) inputRef.current?.focus() }, [editing])
const save = () => {
setEditing(false)
const trimmed = text.trim()
if (trimmed !== originalRef.current) onSave(trimmed)
}
if (editing) {
return (
<Input
ref={inputRef as never}
size="small"
value={text}
onChange={e => setText(e.target.value)}
onPressEnter={save}
onBlur={save}
onKeyDown={e => { if (e.key === 'Escape') { setText(originalRef.current); setEditing(false) } }}
placeholder="Notiz…"
style={{ fontSize: 11, maxWidth: 260 }}
allowClear
suffix={<span style={{ fontSize: 9, color: '#94A3B8' }}>Enter </span>}
onClick={e => e.stopPropagation()}
/>
)
}
if (value) {
return (
<Tooltip title={disabled ? undefined : editTitle}>
<Tag
color="gold"
style={{ fontSize: 10, cursor: disabled ? 'default' : 'pointer', maxWidth: 240, overflow: 'hidden', textOverflow: 'ellipsis', margin: 0 }}
onClick={e => { if (disabled) return; e.stopPropagation(); setEditing(true) }}
>
<MessageOutlined style={{ marginRight: 3 }} />{value}
</Tag>
</Tooltip>
)
}
if (disabled) return null
return (
<Tooltip title={addTitle}>
<MessageOutlined
style={{ color: '#CBD5E1', cursor: 'pointer', fontSize: 12 }}
onClick={e => { e.stopPropagation(); setEditing(true) }}
/>
</Tooltip>
)
}
// ── InlineLabels ──────────────────────────────────────────────────────
// Shows labels as closable geekblue tags. A small "+" button appends a
// new label. Saves each change immediately via onSave callback.
interface InlineLabelsProps {
labels: string[]
disabled?: boolean
onSave: (labels: string[]) => void
}
export function InlineLabels({ labels, disabled, onSave }: InlineLabelsProps) {
const [adding, setAdding] = useState(false)
const [inputVal, setInputVal] = useState('')
const inputRef = useRef<HTMLInputElement>(null)
useEffect(() => { if (adding) inputRef.current?.focus() }, [adding])
const addLabel = () => {
const v = inputVal.trim()
setAdding(false); setInputVal('')
if (v && !labels.includes(v)) onSave([...labels, v])
}
const removeLabel = (label: string) => {
onSave(labels.filter(l => l !== label))
}
return (
<span style={{ display: 'inline-flex', flexWrap: 'wrap', gap: 2, alignItems: 'center' }}>
{labels.map(l => (
<Tag
key={l}
color="geekblue"
closable={!disabled}
onClose={e => { e.preventDefault(); removeLabel(l) }}
style={{ fontSize: 10, margin: 0 }}
onClick={e => e.stopPropagation()}
>
{l}
</Tag>
))}
{!disabled && (
adding ? (
<Input
ref={inputRef as never}
size="small"
value={inputVal}
onChange={e => setInputVal(e.target.value)}
onPressEnter={addLabel}
onBlur={addLabel}
onKeyDown={e => { if (e.key === 'Escape') { setAdding(false); setInputVal('') } }}
placeholder="Label…"
style={{ fontSize: 11, width: 90 }}
onClick={e => e.stopPropagation()}
/>
) : (
<Tooltip title="Label hinzufügen">
<Tag
style={{ fontSize: 10, cursor: 'pointer', borderStyle: 'dashed', margin: 0, color: '#64748B', borderColor: '#CBD5E1', background: 'transparent' }}
onClick={e => { e.stopPropagation(); setAdding(true) }}
>
<PlusOutlined style={{ fontSize: 9 }} />
</Tag>
</Tooltip>
)
)}
</span>
)
}

View File

@@ -1,9 +1,12 @@
import { useMemo, useState } from 'react'
import { Button, Form, Input, InputNumber, Modal, Popconfirm, Select, Space, Switch, Tag, Tooltip, message } from 'antd'
import { Button, Form, Input, InputNumber, Modal, Popconfirm, Select, Space, Switch, Tag, Tooltip, Typography, message } from 'antd'
import type { ColumnsType } from 'antd/es/table'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import { ArrowDownOutlined, ArrowUpOutlined, BranchesOutlined } from '@ant-design/icons'
import { ArrowDownOutlined, ArrowUpOutlined, BranchesOutlined, CopyOutlined, DeleteOutlined, EditOutlined } from '@ant-design/icons'
import { InlineNote, InlineLabels } from './InlineEditors'
const { Text } = Typography
import DataTable from '../../components/DataTable'
import EmptyState from '../../components/EmptyState'
@@ -86,6 +89,39 @@ export default function NATRulesTab() {
onSuccess: () => { void qc.invalidateQueries({ queryKey: ['fw', 'nat'] }) },
onError: (e: Error) => message.error(e.message),
})
const patchNote = useMutation({
mutationFn: async ({ id, note }: { id: number; note: string }) => {
await apiClient.patch(`/firewall/nat-rules/${id}`, { note })
},
onSuccess: () => { void qc.invalidateQueries({ queryKey: ['fw', 'nat'] }) },
onError: (e: Error) => message.error(e.message),
})
const patchLabels = useMutation({
mutationFn: async ({ id, labels }: { id: number; labels: string[] }) => {
await apiClient.patch(`/firewall/nat-rules/${id}`, { labels })
},
onSuccess: () => { void qc.invalidateQueries({ queryKey: ['fw', 'nat'] }) },
onError: (e: Error) => message.error(e.message),
})
const duplicate = useMutation({
mutationFn: async (r: NATRule) => {
const { id: _id, created_at: _ca, updated_at: _ua, ...rest } = r as NATRule & { created_at?: unknown; updated_at?: unknown }
await apiClient.post('/firewall/nat-rules', {
...rest,
name: r.name ? `${r.name} (copy)` : undefined,
priority: r.priority + 1,
enabled: false,
})
},
onSuccess: () => {
message.success(t('fw.rule.duplicated'))
void qc.invalidateQueries({ queryKey: ['fw', 'nat'] })
},
onError: (e: Error) => message.error(e.message),
})
const quickToggle = useMutation({
mutationFn: async ({ id, row, checked }: { id: number; row: NATRule; checked: boolean }) => {
await apiClient.put(`/firewall/nat-rules/${id}`, { ...row, enabled: checked })
@@ -114,25 +150,83 @@ export default function NATRulesTab() {
return <code>{r.target_addr}{r.target_port_start ? `:${r.target_port_start}${r.target_port_end !== r.target_port_start ? `-${r.target_port_end}` : ''}` : ''}</code>
}
const openEdit = (row: NATRule) => {
setEditing(row)
form.setFieldsValue({
name: row.name ?? undefined,
priority: row.priority, enabled: row.enabled, kind: row.kind,
in_zone: row.in_zone ?? undefined, out_zone: row.out_zone ?? undefined,
proto: row.proto ?? undefined,
match_src_cidr: row.match_src_cidr ?? undefined,
match_dst_cidr: row.match_dst_cidr ?? undefined,
match_dport_start: row.match_dport_start ?? undefined,
match_dport_end: row.match_dport_end ?? undefined,
target_addr: row.target_addr ?? undefined,
target_port_start: row.target_port_start ?? undefined,
target_port_end: row.target_port_end ?? undefined,
comment: row.comment ?? undefined,
})
}
const columns: ColumnsType<NATRule> = [
{ title: '#', dataIndex: 'priority', key: 'priority', width: 70 },
{ title: t('fw.nat.kind'), dataIndex: 'kind', key: 'kind', render: (k: NATRule['kind']) => <Tag color={KIND_COLORS[k]}>{k.toUpperCase()}</Tag> },
{
title: '', key: 'dot', width: 28,
render: (_, row) => (
<Tooltip title={row.enabled ? t('fw.rule.enabled') : t('fw.rule.ruleDisabled')}>
<span className={`fw-rule-dot fw-rule-dot--${row.enabled ? 'on' : 'off'}`} />
</Tooltip>
),
},
{
title: '#', dataIndex: 'priority', key: 'priority', width: 52,
render: (v: number) => (
<Text style={{ fontFamily: 'monospace', fontSize: 12, color: '#475569' }}>{v}</Text>
),
},
{
title: t('fw.nat.kind'), dataIndex: 'kind', key: 'kind', width: 110,
render: (k: NATRule['kind']) => <Tag color={KIND_COLORS[k]}>{k.toUpperCase()}</Tag>,
},
{
title: t('fw.nat.name'), key: 'name',
render: (_, r) => (
<div>
<div style={{ display: 'flex', alignItems: 'center', gap: 4, flexWrap: 'wrap' }}>
{r.name
? <span className="fw-rule-name" style={{ fontWeight: 500, fontSize: 12, color: '#0F172A' }}>{r.name}</span>
: <span className="fw-rule-name" style={{ fontSize: 12, color: '#94A3B8', fontStyle: 'italic' }}>{t('fw.rule.unnamed')}</span>
}
<InlineLabels
labels={r.labels ?? []}
disabled={isViewer}
onSave={labels => patchLabels.mutate({ id: r.id, labels })}
/>
<InlineNote
value={r.note}
disabled={isViewer}
onSave={note => patchNote.mutate({ id: r.id, note })}
/>
</div>
{r.comment && <div style={{ fontSize: 11, color: '#64748B', marginTop: 2 }}>{r.comment}</div>}
</div>
),
},
{
title: t('fw.nat.match'), key: 'match',
render: (_, r) => (
<Space size={4}>
{r.in_zone && <Tag>in:{r.in_zone}</Tag>}
{r.out_zone && <Tag>out:{r.out_zone}</Tag>}
{r.proto && <Tag>{r.proto}</Tag>}
{r.match_src_cidr && <code>src={r.match_src_cidr}</code>}
{r.match_dst_cidr && <code>dst={r.match_dst_cidr}</code>}
{r.match_dport_start && <code>dport={r.match_dport_start}{r.match_dport_end !== r.match_dport_start ? `-${r.match_dport_end}` : ''}</code>}
<Space size={4} wrap>
{r.in_zone && <Tag style={{ fontFamily: 'monospace', fontSize: 11 }}>in:{r.in_zone}</Tag>}
{r.out_zone && <Tag style={{ fontFamily: 'monospace', fontSize: 11 }}>out:{r.out_zone}</Tag>}
{r.proto && <Tag style={{ fontSize: 11 }}>{r.proto}</Tag>}
{r.match_src_cidr && <Text style={{ fontFamily: 'monospace', fontSize: 11 }}>src={r.match_src_cidr}</Text>}
{r.match_dst_cidr && <Text style={{ fontFamily: 'monospace', fontSize: 11 }}>dst={r.match_dst_cidr}</Text>}
{r.match_dport_start && <Text style={{ fontFamily: 'monospace', fontSize: 11 }}>:{r.match_dport_start}{r.match_dport_end && r.match_dport_end !== r.match_dport_start ? `-${r.match_dport_end}` : ''}</Text>}
</Space>
),
},
{ title: t('fw.nat.target'), key: 'target', render: (_, r) => renderTarget(r) },
{ title: t('fw.nat.target'), key: 'target', width: 200, render: (_, r) => renderTarget(r) },
{
title: t('fw.nat.enabled'), dataIndex: 'enabled', key: 'enabled', width: 80,
title: t('fw.nat.enabled'), dataIndex: 'enabled', key: 'enabled', width: 68,
render: (v: boolean, row: NATRule) => (
<Switch
size="small"
@@ -144,19 +238,19 @@ export default function NATRulesTab() {
),
},
{
title: '', key: 'move', width: 64,
title: '', key: 'move', width: 52,
render: (_, row) => {
const idx = sortedNAT.findIndex(r => r.id === row.id)
const swapping = swapNAT.isPending
return (
<Space size={2}>
<Space size={1} className="fw-row-actions">
<Tooltip title={t('fw.rule.moveUp')}>
<Button size="small" icon={<ArrowUpOutlined />}
<Button type="text" size="small" icon={<ArrowUpOutlined />}
disabled={isViewer || idx <= 0 || swapping}
onClick={() => swapNAT.mutate({ a: row, b: sortedNAT[idx - 1] })} />
</Tooltip>
<Tooltip title={t('fw.rule.moveDown')}>
<Button size="small" icon={<ArrowDownOutlined />}
<Button type="text" size="small" icon={<ArrowDownOutlined />}
disabled={isViewer || idx >= sortedNAT.length - 1 || swapping}
onClick={() => swapNAT.mutate({ a: row, b: sortedNAT[idx + 1] })} />
</Tooltip>
@@ -165,35 +259,27 @@ export default function NATRulesTab() {
},
},
{
title: t('common.edit'), key: 'actions',
title: '', key: 'actions', width: 88,
render: (_, row) => (
<Space>
<Tooltip title={isViewer ? t('auth.viewerBadge') : undefined}>
<Button size="small" disabled={isViewer} onClick={() => {
setEditing(row)
form.setFieldsValue({
name: row.name ?? undefined,
priority: row.priority, enabled: row.enabled, kind: row.kind,
in_zone: row.in_zone ?? undefined, out_zone: row.out_zone ?? undefined,
proto: row.proto ?? undefined,
match_src_cidr: row.match_src_cidr ?? undefined,
match_dst_cidr: row.match_dst_cidr ?? undefined,
match_dport_start: row.match_dport_start ?? undefined,
match_dport_end: row.match_dport_end ?? undefined,
target_addr: row.target_addr ?? undefined,
target_port_start: row.target_port_start ?? undefined,
target_port_end: row.target_port_end ?? undefined,
comment: row.comment ?? undefined,
})
}}>{t('common.edit')}</Button>
<Space size={0} className="fw-row-actions">
<Tooltip title={isViewer ? t('auth.viewerBadge') : t('common.edit')}>
<Button type="text" size="small" icon={<EditOutlined />}
disabled={isViewer}
onClick={() => openEdit(row)} />
</Tooltip>
<Tooltip title={isViewer ? t('auth.viewerBadge') : t('fw.rule.duplicate')}>
<Button type="text" size="small" icon={<CopyOutlined />}
disabled={isViewer}
loading={duplicate.isPending && duplicate.variables?.id === row.id}
onClick={() => duplicate.mutate(row)} />
</Tooltip>
{isViewer ? (
<Tooltip title={t('auth.viewerBadge')}>
<Button size="small" danger disabled>{t('common.delete')}</Button>
<Button type="text" size="small" icon={<DeleteOutlined />} danger disabled />
</Tooltip>
) : (
<Popconfirm title={t('fw.nat.deleteConfirm')} onConfirm={() => del.mutate(row.id)}>
<Button size="small" danger>{t('common.delete')}</Button>
<Button type="text" size="small" icon={<DeleteOutlined />} danger />
</Popconfirm>
)}
</Space>
@@ -218,6 +304,7 @@ export default function NATRulesTab() {
loading={isLoading}
dataSource={sortedNAT}
columns={columns}
rowClassName={(row: NATRule) => !row.enabled ? 'fw-rule-row--disabled' : ''}
emptyContent={
<EmptyState
icon={<BranchesOutlined />}

View File

@@ -7,8 +7,10 @@ import type { ColumnsType } from 'antd/es/table'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import {
ArrowDownOutlined, ArrowUpOutlined, CopyOutlined, EyeOutlined, FireOutlined, PlusOutlined,
AppstoreOutlined, ArrowDownOutlined, ArrowUpOutlined, CopyOutlined, DeleteOutlined, EditOutlined,
EyeOutlined, FireOutlined, PlusOutlined, UnorderedListOutlined, WarningOutlined,
} from '@ant-design/icons'
import { InlineNote, InlineLabels } from './InlineEditors'
const { Text } = Typography
@@ -159,10 +161,27 @@ export default function RulesTab() {
if (cidr) return cidr
return 'any'
}
// Auto-generates a human-readable one-liner like "LAN/any → WAN/10.0.0.0/24 · HTTPS"
const autoDescription = (r: FwRule): string => {
const src = renderAddrCompact(r.src_address_object_id, r.src_address_group_id, r.src_cidr)
const dst = renderAddrCompact(r.dst_address_object_id, r.dst_address_group_id, r.dst_cidr)
const svc = r.service_object_id ? svLabel(r.service_object_id)
: r.service_group_id ? sgLabel(r.service_group_id)
: 'any'
return `${r.src_zone}/${src}${r.dst_zone}/${dst} · ${svc}`
}
const renderService = (objID?: number | null, grpID?: number | null) => {
if (objID) return <Tag style={{ fontFamily: 'monospace', fontSize: 11 }}>{svLabel(objID)}</Tag>
if (grpID) return <Tag color="purple" style={{ fontFamily: 'monospace', fontSize: 11 }}> {sgLabel(grpID)}</Tag>
return <Tag style={{ fontSize: 11, color: '#94A3B8' }}>any</Tag>
return <span className="fw-addr-any">any</span>
}
const renderAddr = (objID?: number | null, grpID?: number | null, cidr?: string | null) => {
const label = renderAddrCompact(objID, grpID, cidr)
if (label === 'any') return <span className="fw-addr-any">any</span>
return <Text style={{ fontSize: 11, color: '#475569', fontFamily: 'monospace' }}>{label}</Text>
}
// ── Filter state ─────────────────────────────────────────────
@@ -170,6 +189,7 @@ export default function RulesTab() {
const [filterAction, setFilterAction] = useState<string>('')
const [filterZone, setFilterZone] = useState<string>('')
const [groupByZone, setGroupByZone] = useState(true)
const [editing, setEditing] = useState<FwRule | null>(null)
const [creating, setCreating] = useState(false)
const [form] = Form.useForm<FormValues>()
@@ -193,6 +213,25 @@ export default function RulesTab() {
return r
}, [sortedRules, searchText, filterAction, filterZone])
const groupedSections = useMemo(() => {
const map = new Map<string, { srcZone: string; dstZone: string; rules: FwRule[] }>()
for (const r of filteredRules) {
const key = `${r.src_zone}${r.dst_zone}`
if (!map.has(key)) map.set(key, { srcZone: r.src_zone, dstZone: r.dst_zone, rules: [] })
map.get(key)!.rules.push(r)
}
return Array.from(map.entries()).map(([key, v]) => ({ key, ...v }))
}, [filteredRules])
const rowClassFn = (row: FwRule) => {
const c = counterByID.get(row.id)
const zeroHit = row.enabled && (!c || c.packets === 0)
return [
!row.enabled ? 'fw-rule-row--disabled' : '',
zeroHit ? 'fw-rule-row--zero-hit' : '',
].filter(Boolean).join(' ')
}
const create = useMutation({
mutationFn: async (v: FormValues) => { await apiClient.post('/firewall/rules', buildPayload(v)) },
onSuccess: () => {
@@ -233,6 +272,22 @@ export default function RulesTab() {
onError: (e: Error) => message.error(e.message),
})
const patchNote = useMutation({
mutationFn: async ({ id, note }: { id: number; note: string }) => {
await apiClient.patch(`/firewall/rules/${id}`, { note })
},
onSuccess: () => { void qc.invalidateQueries({ queryKey: ['fw', 'rules'] }) },
onError: (e: Error) => message.error(e.message),
})
const patchLabels = useMutation({
mutationFn: async ({ id, labels }: { id: number; labels: string[] }) => {
await apiClient.patch(`/firewall/rules/${id}`, { labels })
},
onSuccess: () => { void qc.invalidateQueries({ queryKey: ['fw', 'rules'] }) },
onError: (e: Error) => message.error(e.message),
})
const duplicate = useMutation({
mutationFn: async (r: FwRule) => {
const { id: _id, created_at: _ca, updated_at: _ua, ...rest } = r as FwRule & { created_at?: unknown; updated_at?: unknown }
@@ -294,11 +349,7 @@ export default function RulesTab() {
render: (_, r) => (
<div style={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
<ZoneBadge zone={r.src_zone} />
{(r.src_address_object_id || r.src_address_group_id || r.src_cidr) && (
<Text style={{ fontSize: 11, color: '#475569', fontFamily: 'monospace' }}>
{renderAddrCompact(r.src_address_object_id, r.src_address_group_id, r.src_cidr)}
</Text>
)}
{renderAddr(r.src_address_object_id, r.src_address_group_id, r.src_cidr)}
</div>
),
},
@@ -311,11 +362,7 @@ export default function RulesTab() {
render: (_, r) => (
<div style={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
<ZoneBadge zone={r.dst_zone} />
{(r.dst_address_object_id || r.dst_address_group_id || r.dst_cidr) && (
<Text style={{ fontSize: 11, color: '#475569', fontFamily: 'monospace' }}>
{renderAddrCompact(r.dst_address_object_id, r.dst_address_group_id, r.dst_cidr)}
</Text>
)}
{renderAddr(r.dst_address_object_id, r.dst_address_group_id, r.dst_cidr)}
</div>
),
},
@@ -327,11 +374,26 @@ export default function RulesTab() {
title: t('fw.rule.name'), key: 'name', ellipsis: true,
render: (_, r) => (
<div>
{r.name && <div style={{ fontWeight: 500, fontSize: 12, color: '#0F172A' }}>{r.name}</div>}
{r.comment && (
<div style={{ fontSize: 11, color: '#94A3B8', marginTop: 1 }}>{r.comment}</div>
)}
{!r.name && !r.comment && <Text type="secondary" style={{ fontSize: 11 }}></Text>}
<div style={{ display: 'flex', alignItems: 'center', gap: 4, flexWrap: 'wrap' }}>
{r.name
? <span className="fw-rule-name" style={{ fontWeight: 500, fontSize: 12, color: '#0F172A' }}>{r.name}</span>
: <span className="fw-rule-name" style={{ fontSize: 12, color: '#94A3B8', fontStyle: 'italic' }}>{t('fw.rule.unnamed')}</span>
}
<InlineLabels
labels={r.labels ?? []}
disabled={isViewer}
onSave={labels => patchLabels.mutate({ id: r.id, labels })}
/>
<InlineNote
value={r.note}
disabled={isViewer}
onSave={note => patchNote.mutate({ id: r.id, note })}
/>
</div>
{r.comment
? <div style={{ fontSize: 11, color: '#64748B', marginTop: 2 }}>{r.comment}</div>
: <div className="fw-rule-desc">{autoDescription(r)}</div>
}
</div>
),
},
@@ -339,7 +401,13 @@ export default function RulesTab() {
title: t('fw.rule.hits'), key: 'hits', width: 80, align: 'right' as const,
render: (_, r) => {
const c = counterByID.get(r.id)
if (!c || c.packets === 0) return <Text type="secondary" style={{ fontSize: 11 }}></Text>
if (!c || c.packets === 0) return (
<Tooltip title={r.enabled ? t('fw.rule.zeroHitHint') : undefined}>
<span style={{ fontSize: 11, color: r.enabled ? '#FAAD14' : '#CBD5E1' }}>
{r.enabled ? <><WarningOutlined style={{ fontSize: 10, marginRight: 2 }} />0</> : '—'}
</span>
</Tooltip>
)
return (
<Tooltip title={`${c.packets.toLocaleString()} pkts · ${fmtBytes(c.bytes)}`}>
<Text style={{ fontSize: 11, fontVariantNumeric: 'tabular-nums', color: '#0EA5E9' }}>
@@ -372,19 +440,19 @@ export default function RulesTab() {
),
},
{
title: '', key: 'move', width: 60,
title: '', key: 'move', width: 52,
render: (_, row) => {
const idx = sortedRules.findIndex(r => r.id === row.id)
const swapping = swap.isPending
return (
<Space size={2}>
<Space size={1} className="fw-row-actions">
<Tooltip title={t('fw.rule.moveUp')}>
<Button size="small" icon={<ArrowUpOutlined />}
<Button type="text" size="small" icon={<ArrowUpOutlined />}
disabled={isViewer || idx <= 0 || swapping}
onClick={() => swap.mutate({ a: row, b: sortedRules[idx - 1] })} />
</Tooltip>
<Tooltip title={t('fw.rule.moveDown')}>
<Button size="small" icon={<ArrowDownOutlined />}
<Button type="text" size="small" icon={<ArrowDownOutlined />}
disabled={isViewer || idx >= sortedRules.length - 1 || swapping}
onClick={() => swap.mutate({ a: row, b: sortedRules[idx + 1] })} />
</Tooltip>
@@ -393,30 +461,27 @@ export default function RulesTab() {
},
},
{
title: '', key: 'actions', width: 120,
title: '', key: 'actions', width: 88,
render: (_, row) => (
<Space size={4}>
<Space size={0} className="fw-row-actions">
<Tooltip title={isViewer ? t('auth.viewerBadge') : t('common.edit')}>
<Button size="small" disabled={isViewer} onClick={() => editFromRow(row)}>
{t('common.edit')}
</Button>
<Button type="text" size="small" icon={<EditOutlined />}
disabled={isViewer}
onClick={() => editFromRow(row)} />
</Tooltip>
<Tooltip title={isViewer ? t('auth.viewerBadge') : t('fw.rule.duplicate')}>
<Button
size="small"
icon={<CopyOutlined />}
<Button type="text" size="small" icon={<CopyOutlined />}
disabled={isViewer}
loading={duplicate.isPending && duplicate.variables?.id === row.id}
onClick={() => duplicate.mutate(row)}
/>
onClick={() => duplicate.mutate(row)} />
</Tooltip>
{isViewer ? (
<Tooltip title={t('auth.viewerBadge')}>
<Button size="small" danger disabled>{t('common.delete')}</Button>
<Button type="text" size="small" icon={<DeleteOutlined />} danger disabled />
</Tooltip>
) : (
<Popconfirm title={t('fw.rule.deleteConfirm')} onConfirm={() => del.mutate(row.id)}>
<Button size="small" danger>{t('common.delete')}</Button>
<Button type="text" size="small" icon={<DeleteOutlined />} danger />
</Popconfirm>
)}
</Space>
@@ -473,6 +538,14 @@ export default function RulesTab() {
</Text>
)}
<div className="fw-filter-bar-right">
<Tooltip title={groupByZone ? t('fw.filter.flatView') : t('fw.filter.groupView')}>
<Button
type={groupByZone ? 'default' : 'text'}
size="small"
icon={groupByZone ? <AppstoreOutlined /> : <UnorderedListOutlined />}
onClick={() => setGroupByZone(v => !v)}
/>
</Tooltip>
<Tooltip title={isViewer ? t('auth.viewerBadge') : undefined}>
<Button type="primary" icon={<PlusOutlined />} disabled={isViewer} onClick={openCreate}>
{t('fw.rule.add')}
@@ -481,13 +554,8 @@ export default function RulesTab() {
</div>
</div>
<DataTable
rowKey="id"
loading={isLoading}
dataSource={filteredRules}
columns={columns}
rowClassName={(row: FwRule) => row.enabled ? '' : 'fw-rule-row--disabled'}
emptyContent={
{groupByZone ? (
groupedSections.length === 0 ? (
<EmptyState
icon={<FireOutlined />}
title={activeFilters ? t('fw.filter.noResults') : t('fw.rule.emptyTitle')}
@@ -502,8 +570,52 @@ export default function RulesTab() {
) : undefined
}
/>
}
/>
) : (
groupedSections.map(({ key, srcZone, dstZone, rules: groupRules }) => (
<div key={key} className="fw-zone-section">
<div className="fw-zone-section-header">
<ZoneBadge zone={srcZone} />
<span className="fw-zone-section-arrow"></span>
<ZoneBadge zone={dstZone} />
<span className="fw-zone-section-count">
{groupRules.length} {t('fw.filter.rules')}
</span>
</div>
<DataTable
rowKey="id"
loading={isLoading}
dataSource={groupRules}
columns={columns}
rowClassName={rowClassFn}
/>
</div>
))
)
) : (
<DataTable
rowKey="id"
loading={isLoading}
dataSource={filteredRules}
columns={columns}
rowClassName={rowClassFn}
emptyContent={
<EmptyState
icon={<FireOutlined />}
title={activeFilters ? t('fw.filter.noResults') : t('fw.rule.emptyTitle')}
description={activeFilters ? t('fw.filter.noResultsHint') : t('fw.rule.emptyDesc')}
action={
!activeFilters ? (
<Tooltip title={isViewer ? t('auth.viewerBadge') : undefined}>
<Button type="primary" icon={<PlusOutlined />} disabled={isViewer} onClick={openCreate}>
{t('fw.rule.add')}
</Button>
</Tooltip>
) : undefined
}
/>
}
/>
)}
<Modal
title={editing ? t('fw.rule.edit') : t('fw.rule.add')}

View File

@@ -125,10 +125,7 @@ export default function FirewallPage() {
)}
/>
<FirewallKPIStrip nftablesActive={nftables?.active} />
<Tabs
items={tabs}
defaultActiveKey="rules"
/>
<Tabs items={tabs} defaultActiveKey="rules" type="card" />
</div>
)
}

View File

@@ -69,6 +69,8 @@ export interface FwRule {
service_group_id?: number | null
log: boolean
comment?: string | null
note?: string | null
labels: string[]
}
export interface NATRule {
@@ -88,6 +90,8 @@ export interface NATRule {
target_port_start?: number | null
target_port_end?: number | null
comment?: string | null
note?: string | null
labels: string[]
}
// Fallback list — used only while /firewall/zones hasn't loaded

View File

@@ -1,6 +1,6 @@
import { useMemo, useState } from 'react'
import {
Alert, Button, Card, Col, Form, Input, InputNumber, Modal, Progress, Row, Select, Space, Statistic, Switch, Tag, Tooltip, Typography, message,
Alert, Button, Card, Col, Form, Input, InputNumber, Modal, Progress, Row, Select, Space, Statistic, Switch, Tag, Tooltip, Typography, message, Divider,
} from 'antd'
import type { ColumnsType } from 'antd/es/table'
import { ArrowDownOutlined, ArrowUpOutlined, BarChartOutlined, CheckCircleOutlined, CloseCircleOutlined, CloudServerOutlined, PlusOutlined, ReloadOutlined } from '@ant-design/icons'
@@ -31,6 +31,18 @@ interface ACL {
interface ServiceStatus { label: string; unit: string; active: boolean; state: string }
interface ProxySettings {
id: number
listen_addresses: string
listen_port: number
cache_mem_mb: number
cache_dir_mb: number
max_obj_size_mb: number
connect_timeout: number
read_timeout: number
request_timeout: number
}
interface FormValues {
name: string
acl_type: string
@@ -98,6 +110,27 @@ export default function ForwardProxyPage() {
refetchInterval: 30_000,
})
const { data: settings, isLoading: settingsLoading } = useQuery({
queryKey: ['fwd-proxy', 'settings'],
queryFn: async () => {
const r = await apiClient.get('/forward-proxy/settings')
if (!isEnvelope(r.data)) return null
return r.data.data as ProxySettings
},
})
const [settingsForm] = Form.useForm<ProxySettings>()
const saveSettings = useMutation({
mutationFn: async (v: ProxySettings) => {
await apiClient.put('/forward-proxy/settings', v)
},
onSuccess: () => {
message.success(t('common.save'))
void qc.invalidateQueries({ queryKey: ['fwd-proxy', 'settings'] })
},
onError: () => message.error(t('fwd.settings.saveFailed')),
})
const [editing, setEditing] = useState<ACL | null>(null)
const [creating, setCreating] = useState(false)
const [form] = Form.useForm<FormValues>()
@@ -284,6 +317,97 @@ export default function ForwardProxyPage() {
</Card>
)}
<Card
size="small"
className="mb-12"
title={t('fwd.settings.title')}
loading={settingsLoading}
>
<Form
form={settingsForm}
layout="vertical"
initialValues={settings ?? {
listen_addresses: '', listen_port: 3128,
cache_mem_mb: 64, cache_dir_mb: 100, max_obj_size_mb: 4,
connect_timeout: 60, read_timeout: 300, request_timeout: 300,
}}
key={settings?.id ?? 'loading'}
onFinish={(v) => saveSettings.mutate(v)}
>
<Row gutter={16}>
<Col xs={24} md={16}>
<Form.Item
label={t('fwd.settings.listenAddresses')}
name="listen_addresses"
extra={t('fwd.settings.listenAddressesExtra')}
>
<Input placeholder="10.0.5.1, 10.0.20.1" disabled={isViewer} />
</Form.Item>
</Col>
<Col xs={24} md={8}>
<Form.Item
label={t('fwd.settings.listenPort')}
name="listen_port"
extra={t('fwd.settings.listenPortExtra')}
>
<InputNumber min={1} max={65535} style={{ width: '100%' }} disabled={isViewer} />
</Form.Item>
</Col>
</Row>
<Divider plain>{t('fwd.settings.cacheSection')}</Divider>
<Row gutter={16}>
<Col xs={24} sm={8}>
<Form.Item label={t('fwd.settings.cacheMemMB')} name="cache_mem_mb" extra={t('fwd.settings.cacheMemMBExtra')}>
<InputNumber min={16} max={8192} style={{ width: '100%' }} addonAfter="MB" disabled={isViewer} />
</Form.Item>
</Col>
<Col xs={24} sm={8}>
<Form.Item label={t('fwd.settings.cacheDirMB')} name="cache_dir_mb" extra={t('fwd.settings.cacheDirMBExtra')}>
<InputNumber min={100} max={102400} style={{ width: '100%' }} addonAfter="MB" disabled={isViewer} />
</Form.Item>
</Col>
<Col xs={24} sm={8}>
<Form.Item label={t('fwd.settings.maxObjSizeMB')} name="max_obj_size_mb" extra={t('fwd.settings.maxObjSizeMBExtra')}>
<InputNumber min={1} max={1024} style={{ width: '100%' }} addonAfter="MB" disabled={isViewer} />
</Form.Item>
</Col>
</Row>
<Divider plain>{t('fwd.settings.timeoutSection')}</Divider>
<Row gutter={16}>
<Col xs={24} sm={8}>
<Form.Item label={t('fwd.settings.connectTimeout')} name="connect_timeout" extra={t('fwd.settings.connectTimeoutExtra')}>
<InputNumber min={5} max={3600} style={{ width: '100%' }} addonAfter="s" disabled={isViewer} />
</Form.Item>
</Col>
<Col xs={24} sm={8}>
<Form.Item label={t('fwd.settings.readTimeout')} name="read_timeout" extra={t('fwd.settings.readTimeoutExtra')}>
<InputNumber min={30} max={3600} style={{ width: '100%' }} addonAfter="s" disabled={isViewer} />
</Form.Item>
</Col>
<Col xs={24} sm={8}>
<Form.Item label={t('fwd.settings.requestTimeout')} name="request_timeout" extra={t('fwd.settings.requestTimeoutExtra')}>
<InputNumber min={30} max={3600} style={{ width: '100%' }} addonAfter="s" disabled={isViewer} />
</Form.Item>
</Col>
</Row>
<Tooltip title={isViewer ? t('auth.viewerBadge') : undefined}>
<Button
type="primary"
htmlType="submit"
loading={saveSettings.isPending}
disabled={isViewer}
>
{t('common.save')}
</Button>
</Tooltip>
</Form>
</Card>
<Divider />
<DataTable
rowKey="id"
loading={isLoading}

View File

@@ -193,26 +193,6 @@ export default function IPAddressesPage() {
subtitle={t('ips.intro')}
/>
<Card title={t('ips.systemDiscovered')} size="small" className="mb-12">
{(sysAddrs ?? []).length === 0
? <Typography.Text type="secondary"></Typography.Text>
: (
<DataTable
size="small"
rowKey={(r) => `${r.ifname}-${r.address}`}
dataSource={sysAddrs ?? []}
columns={[
{ title: t('ips.interface'), dataIndex: 'ifname', key: 'ifname', render: (s: string) => <code>{s}</code> },
{ title: t('ips.address'), key: 'addr', render: (_, row: SystemAddress) => <code>{row.address}/{row.prefix}</code> },
{ title: t('ips.family'), dataIndex: 'family', key: 'family', render: (f: string) => <Tag>{f === 'inet' ? 'IPv4' : 'IPv6'}</Tag> },
]}
/>
)
}
</Card>
<Typography.Title level={5} style={{ marginTop: 8 }}>{t('ips.managedTitle')}</Typography.Title>
<DataTable
rowKey="id"
loading={isLoading}
@@ -240,6 +220,24 @@ export default function IPAddressesPage() {
/>
}
/>
<Card title={t('ips.systemDiscovered')} size="small" className="mt-12">
{(sysAddrs ?? []).length === 0
? <Typography.Text type="secondary"></Typography.Text>
: (
<DataTable
size="small"
rowKey={(r) => `${r.ifname}-${r.address}`}
dataSource={sysAddrs ?? []}
columns={[
{ title: t('ips.interface'), dataIndex: 'ifname', key: 'ifname', render: (s: string) => <code>{s}</code> },
{ title: t('ips.address'), key: 'addr', render: (_, row: SystemAddress) => <code>{row.address}/{row.prefix}</code> },
{ title: t('ips.family'), dataIndex: 'family', key: 'family', render: (f: string) => <Tag>{f === 'inet' ? 'IPv4' : 'IPv6'}</Tag> },
]}
/>
)
}
</Card>
<Modal
title={editing ? t('ips.editAddress') : t('ips.addAddress')}
open={editing !== null || creating}

View File

@@ -1,4 +1,6 @@
import { useState } from 'react'
import { Button, Card, Form, Input, message, Typography } from 'antd'
import { KeyOutlined } from '@ant-design/icons'
import { Link, useNavigate } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
@@ -17,20 +19,24 @@ interface LoginValues {
export default function LoginPage({ onLogin }: Props) {
const { t } = useTranslation()
const navigate = useNavigate()
const [totpRequired, setTotpRequired] = useState(false)
const [totpCode, setTotpCode] = useState('')
const [verifying, setVerifying] = useState(false)
const onFinish = async (vals: LoginValues) => {
try {
const r = await apiClient.post('/auth/login', vals)
if (isEnvelope(r.data)) {
const u = r.data.data as SessionUser
onLogin(u)
navigate('/dashboard', { replace: true })
if (!isEnvelope(r.data)) return
const d = r.data.data as { totp_required?: boolean } & SessionUser
if (d.totp_required) {
setTotpRequired(true)
return
}
onLogin(d)
navigate('/dashboard', { replace: true })
} catch (e: unknown) {
const err = e as { message?: string; status?: number }
if (err.status === 503) {
// setup-mode → drop to wizard
navigate('/setup', { replace: true })
return
}
@@ -38,36 +44,73 @@ export default function LoginPage({ onLogin }: Props) {
}
}
const onTOTPVerify = async () => {
if (!totpCode || totpCode.length < 6) return
setVerifying(true)
try {
const r = await apiClient.post('/auth/totp-verify', { code: totpCode })
if (isEnvelope(r.data)) {
onLogin(r.data.data as SessionUser)
navigate('/dashboard', { replace: true })
}
} catch (e: unknown) {
const err = e as { message?: string }
message.error(err.message ?? t('auth.totp.invalidCode'))
setTotpCode('')
} finally {
setVerifying(false)
}
}
return (
<div style={{ display: 'flex', minHeight: '100vh', alignItems: 'center', justifyContent: 'center', background: '#f0f2f5' }}>
<Card style={{ width: 400 }}>
<Typography.Title level={3} style={{ textAlign: 'center', marginBottom: 24 }}>
{t('app.title')}
</Typography.Title>
<Form layout="vertical" onFinish={onFinish}>
<Form.Item
label={t('auth.email')}
name="email"
rules={[{ required: true, type: 'email' }]}
>
<Input autoComplete="email" autoFocus />
</Form.Item>
<Form.Item
label={t('auth.password')}
name="password"
rules={[{ required: true }]}
>
<Input.Password autoComplete="current-password" />
</Form.Item>
<Form.Item>
<Button type="primary" htmlType="submit" block>
{t('auth.login')}
{!totpRequired ? (
<Form layout="vertical" onFinish={onFinish}>
<Form.Item label={t('auth.email')} name="email" rules={[{ required: true, type: 'email' }]}>
<Input autoComplete="email" autoFocus />
</Form.Item>
<Form.Item label={t('auth.password')} name="password" rules={[{ required: true }]}>
<Input.Password autoComplete="current-password" />
</Form.Item>
<Form.Item>
<Button type="primary" htmlType="submit" block>{t('auth.login')}</Button>
</Form.Item>
</Form>
) : (
<div>
<Typography.Paragraph style={{ textAlign: 'center' }}>
<KeyOutlined style={{ fontSize: 32, color: '#1677ff', marginBottom: 8 }} /><br />
{t('auth.totp.prompt')}
</Typography.Paragraph>
<Input
size="large"
maxLength={6}
placeholder="000000"
value={totpCode}
onChange={e => setTotpCode(e.target.value.replace(/\D/g, ''))}
onPressEnter={onTOTPVerify}
style={{ textAlign: 'center', letterSpacing: 8, fontSize: 20, marginBottom: 16 }}
autoFocus
/>
<Button type="primary" block loading={verifying} onClick={onTOTPVerify}>
{t('auth.totp.verify')}
</Button>
</Form.Item>
</Form>
<div style={{ textAlign: 'center', fontSize: 12 }}>
<Link to="/reset-password">{t('auth.forgotPassword')}</Link>
</div>
<Button type="link" block style={{ marginTop: 8 }} onClick={() => { setTotpRequired(false); setTotpCode('') }}>
{t('common.back')}
</Button>
</div>
)}
{!totpRequired && (
<div style={{ textAlign: 'center', fontSize: 12 }}>
<Link to="/reset-password">{t('auth.forgotPassword')}</Link>
</div>
)}
</Card>
</div>
)

View File

@@ -381,6 +381,7 @@ function SettingsTab() {
for (const i of sys ?? []) {
if (i.ifname === 'lo') continue
for (const a of i.addr_info ?? []) {
if (a.local.startsWith('fe80:')) continue
ipOptions.push({
value: a.local,
label: `${a.local}${i.ifname} (${a.family === 'inet' ? 'IPv4' : 'IPv6'})`,

View File

@@ -1,5 +1,5 @@
import { Alert, Button, Card, Descriptions, Form, Input, InputNumber, Popconfirm, Select, Space, Spin, Switch, Tooltip, Typography, message } from 'antd'
import { CloudDownloadOutlined, CloudSyncOutlined, CodeOutlined, CopyOutlined, DatabaseOutlined, DownloadOutlined, ExclamationCircleOutlined, FileSearchOutlined, GlobalOutlined, LockOutlined, MailOutlined, ReloadOutlined, SettingOutlined, StopOutlined, ToolOutlined } from '@ant-design/icons'
import { ApartmentOutlined, CloudDownloadOutlined, CloudSyncOutlined, CodeOutlined, CopyOutlined, DatabaseOutlined, DownloadOutlined, ExclamationCircleOutlined, FileSearchOutlined, GlobalOutlined, LockOutlined, MailOutlined, ReloadOutlined, SettingOutlined, StopOutlined, ToolOutlined } from '@ant-design/icons'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
@@ -34,12 +34,25 @@ interface ChangePasswordValues {
confirm_password: string
}
interface VIPSettingsValues {
vip_address?: string
vip_interface?: string
vip_auth_pass?: string
vrrp_router_id?: number
hb_interface?: string
hb_src_ip?: string
hb_peer_ip?: string
hb_router_id?: number
gw_check_ip?: string
}
export default function SettingsPage() {
const { t } = useTranslation()
const qc = useQueryClient()
const isViewer = useAuthStore((s) => s.user?.role) === 'viewer'
const [msg, msgCtx] = message.useMessage()
const [pwForm] = Form.useForm<ChangePasswordValues>()
const [vipForm] = Form.useForm<VIPSettingsValues>()
const { data: setupStatus, isLoading: loadingSetup } = useQuery({
queryKey: ['setup', 'status'],
@@ -59,6 +72,27 @@ export default function SettingsPage() {
},
})
const { data: vipSettings } = useQuery({
queryKey: ['cluster', 'vip-settings'],
queryFn: async () => {
const r = await apiClient.get('/cluster/vip-settings')
return isEnvelope(r.data) ? r.data.data as VIPSettingsValues : null
},
})
useEffect(() => {
if (vipSettings) vipForm.setFieldsValue(vipSettings)
}, [vipSettings, vipForm])
const updateVIP = useMutation({
mutationFn: async (v: VIPSettingsValues) => apiClient.put('/cluster/vip-settings', v),
onSuccess: () => {
msg.success(t('cluster.vipCard.saved'))
void qc.invalidateQueries({ queryKey: ['cluster', 'vip-settings'] })
},
onError: (e: Error) => msg.error(t('cluster.vipCard.saveFailed') + ': ' + e.message),
})
const [emailForm] = Form.useForm<ContactEmailValues>()
const updateEmails = useMutation({
mutationFn: async (v: ContactEmailValues) => {
@@ -768,6 +802,83 @@ export default function SettingsPage() {
</Space>
</Card>
<Card
title={<><ApartmentOutlined /> {t('cluster.vipCard.title')}</>}
size="small"
className="mb-12"
>
<Alert
type="info"
showIcon
className="mb-12"
message={t('cluster.vipCard.hintTitle')}
description={
<ul style={{ margin: '4px 0', paddingLeft: 18 }}>
<li><Typography.Text code>{t('cluster.vipCard.hintPrimary')}</Typography.Text></li>
<li><Typography.Text code>{t('cluster.vipCard.hintStandby')}</Typography.Text></li>
<li><Typography.Text code>{t('cluster.vipCard.hintKeepalived')}</Typography.Text></li>
<li><Typography.Text code>{t('cluster.vipCard.hintFailover')}</Typography.Text></li>
</ul>
}
/>
<Form<VIPSettingsValues>
form={vipForm}
layout="vertical"
onFinish={(v) => updateVIP.mutate(v)}
initialValues={{ vrrp_router_id: 51 }}
>
<Form.Item label={t('cluster.vipCard.vipAddress')} name="vip_address"
extra={t('cluster.vipCard.vipAddressHelp')}>
<Input placeholder="89.163.205.10" disabled={isViewer} />
</Form.Item>
<Form.Item label={t('cluster.vipCard.vipInterface')} name="vip_interface"
extra={t('cluster.vipCard.vipInterfaceHelp')}>
<Input placeholder="eth0" disabled={isViewer} />
</Form.Item>
<Form.Item label={t('cluster.vipCard.vipAuthPass')} name="vip_auth_pass"
extra={t('cluster.vipCard.vipAuthPassHelp')}
rules={[{ max: 8, message: 'Max. 8 Zeichen (Keepalived-Limit)' }]}>
<Input.Password placeholder="max 8 chars" disabled={isViewer} />
</Form.Item>
<Form.Item label={t('cluster.vipCard.vrrpRouterId')} name="vrrp_router_id"
extra={t('cluster.vipCard.vrrpRouterIdHelp')}>
<InputNumber min={1} max={255} disabled={isViewer} />
</Form.Item>
<Typography.Text strong style={{ display: 'block', marginBottom: 12, marginTop: 8 }}>
{t('cluster.vipCard.splitBrainSection')}
</Typography.Text>
<Form.Item label={t('cluster.vipCard.hbInterface')} name="hb_interface"
extra={t('cluster.vipCard.hbInterfaceHelp')}>
<Input placeholder="eth1" disabled={isViewer} />
</Form.Item>
<Form.Item label={t('cluster.vipCard.hbSrcIp')} name="hb_src_ip"
extra={t('cluster.vipCard.hbSrcIpHelp')}>
<Input placeholder="192.168.1.1" disabled={isViewer} />
</Form.Item>
<Form.Item label={t('cluster.vipCard.hbPeerIp')} name="hb_peer_ip"
extra={t('cluster.vipCard.hbPeerIpHelp')}>
<Input placeholder="192.168.1.2" disabled={isViewer} />
</Form.Item>
<Form.Item label={t('cluster.vipCard.hbRouterId')} name="hb_router_id"
extra={t('cluster.vipCard.hbRouterIdHelp')}>
<InputNumber min={1} max={255} disabled={isViewer} />
</Form.Item>
<Form.Item label={t('cluster.vipCard.gwCheckIp')} name="gw_check_ip"
extra={t('cluster.vipCard.gwCheckIpHelp')}>
<Input placeholder="89.163.205.1" disabled={isViewer} />
</Form.Item>
{!isViewer && (
<Form.Item>
<Button type="primary" htmlType="submit" loading={updateVIP.isPending}>
{t('cluster.vipCard.saveBtn')}
</Button>
</Form.Item>
)}
</Form>
</Card>
<Card title={<><LockOutlined /> {t('settings.passwordCardTitle')}</>} size="small">
<Form<ChangePasswordValues>
form={pwForm}

View File

@@ -1,9 +1,9 @@
import { useState } from 'react'
import {
Button, Form, Input, Modal, Select, Space, Switch, Tag, Tooltip, Typography, message,
Button, Form, Input, Modal, QRCode, Select, Space, Switch, Tag, Tooltip, Typography, message,
} from 'antd'
import type { ColumnsType } from 'antd/es/table'
import { KeyOutlined, PlusOutlined, TeamOutlined } from '@ant-design/icons'
import { KeyOutlined, LockOutlined, PlusOutlined, SafetyCertificateOutlined, TeamOutlined } from '@ant-design/icons'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
@@ -21,6 +21,7 @@ interface User {
email: string
role: string
active: boolean
totp_enabled: boolean
last_login_at: string | null
created_at: string
}
@@ -45,9 +46,14 @@ export default function UsersPage() {
const { data: users, isLoading } = useQuery({ queryKey: ['users'], queryFn: listUsers })
const [creating, setCreating] = useState(false)
const [editing, setEditing] = useState<User | null>(null)
const [pwTarget, setPwTarget] = useState<User | null>(null)
const [creating, setCreating] = useState(false)
const [editing, setEditing] = useState<User | null>(null)
const [pwTarget, setPwTarget] = useState<User | null>(null)
const [totpTarget, setTotpTarget] = useState<User | null>(null)
const [totpStep, setTotpStep] = useState(0)
const [totpSecret, setTotpSecret] = useState('')
const [totpUri, setTotpUri] = useState('')
const [totpCode, setTotpCode] = useState('')
const [createForm] = Form.useForm<CreateValues>()
const [editForm] = Form.useForm<EditValues>()
const [pwForm] = Form.useForm<PwValues>()
@@ -80,6 +86,44 @@ export default function UsersPage() {
onSuccess: invalidate,
onError: (e: Error) => message.error(e.message),
})
const disableTOTPMut = useMutation({
mutationFn: (id: number) => apiClient.delete(`/users/${id}/totp`),
onSuccess: () => { message.success(t('users.totp.disabled')); invalidate() },
onError: (e: Error) => message.error(e.message),
})
const openTOTPSetup = async (row: User) => {
setTotpTarget(row)
setTotpStep(0)
setTotpCode('')
if (row.email === me?.actor) {
// own account — generate secret via self-service endpoint
try {
const r = await apiClient.post('/auth/totp/setup')
if (isEnvelope(r.data)) {
const d = r.data.data as { secret: string; uri: string }
setTotpSecret(d.secret)
setTotpUri(d.uri)
}
} catch (e: unknown) {
message.error((e as Error).message)
setTotpTarget(null)
}
}
}
const confirmTOTP = async () => {
if (!totpTarget) return
try {
await apiClient.post('/auth/totp/confirm', { secret: totpSecret, code: totpCode })
message.success(t('users.totp.enabled'))
setTotpTarget(null)
invalidate()
} catch (e: unknown) {
message.error((e as Error).message ?? t('auth.totp.invalidCode'))
setTotpCode('')
}
}
const roleOptions = [
{ value: 'admin', label: t('users.roleAdmin') },
@@ -104,6 +148,12 @@ export default function UsersPage() {
</Tag>
),
},
{
title: '2FA', key: 'totp', width: 70,
render: (_, row) => row.totp_enabled
? <Tag color="green" icon={<SafetyCertificateOutlined />}>{t('users.totp.on')}</Tag>
: <Tag color="default">{t('users.totp.off')}</Tag>,
},
{
title: t('users.active'), dataIndex: 'active', key: 'active', width: 80,
render: (v: boolean, row: User) => (
@@ -127,7 +177,7 @@ export default function UsersPage() {
: <Text type="secondary" style={{ fontSize: 12 }}>{t('users.never')}</Text>,
},
{
title: t('common.actions'), key: 'actions', width: 120,
title: t('common.actions'), key: 'actions', width: 160,
render: (_, row) => (
<Space size={4}>
<Tooltip title={isViewer ? t('auth.viewerBadge') : t('users.setPassword')}>
@@ -135,6 +185,22 @@ export default function UsersPage() {
disabled={isViewer}
onClick={() => { setPwTarget(row); pwForm.resetFields() }} />
</Tooltip>
{/* 2FA button: setup for own account, disable for others */}
{row.email === me?.actor ? (
<Tooltip title={row.totp_enabled ? t('users.totp.manage') : t('users.totp.setup')}>
<Button type="text" size="small"
icon={<LockOutlined style={{ color: row.totp_enabled ? '#52c41a' : undefined }} />}
onClick={() => void openTOTPSetup(row)} />
</Tooltip>
) : (
row.totp_enabled && !isViewer && (
<Tooltip title={t('users.totp.disableFor', { email: row.email })}>
<Button type="text" size="small" danger icon={<LockOutlined />}
loading={disableTOTPMut.isPending && disableTOTPMut.variables === row.id}
onClick={() => disableTOTPMut.mutate(row.id)} />
</Tooltip>
)
)}
<ActionButtons
onEdit={() => {
setEditing(row)
@@ -150,13 +216,11 @@ export default function UsersPage() {
},
]
const isSelfTOTP = totpTarget?.email === me?.actor
return (
<div>
<PageHeader
icon={<TeamOutlined />}
title={t('users.title')}
subtitle={t('users.intro')}
/>
<PageHeader icon={<TeamOutlined />} title={t('users.title')} subtitle={t('users.intro')} />
<DataTable
rowKey="id"
@@ -195,16 +259,10 @@ export default function UsersPage() {
/>
{/* Create modal */}
<Modal
title={t('users.addUser')}
open={creating}
<Modal title={t('users.addUser')} open={creating}
onCancel={() => { setCreating(false); createForm.resetFields() }}
onOk={() => void createForm.submit()}
confirmLoading={createMut.isPending}
destroyOnHidden
>
<Form form={createForm} layout="vertical"
onFinish={(v) => createMut.mutate(v)}>
onOk={() => void createForm.submit()} confirmLoading={createMut.isPending} destroyOnHidden>
<Form form={createForm} layout="vertical" onFinish={(v) => createMut.mutate(v)}>
<Form.Item label={t('users.email')} name="email" rules={[{ required: true, type: 'email' }]}>
<Input autoFocus autoComplete="off" />
</Form.Item>
@@ -223,14 +281,9 @@ export default function UsersPage() {
</Modal>
{/* Edit modal */}
<Modal
title={t('users.editUser')}
open={editing !== null}
<Modal title={t('users.editUser')} open={editing !== null}
onCancel={() => { setEditing(null); editForm.resetFields() }}
onOk={() => void editForm.submit()}
confirmLoading={updateMut.isPending}
destroyOnHidden
>
onOk={() => void editForm.submit()} confirmLoading={updateMut.isPending} destroyOnHidden>
<Form form={editForm} layout="vertical"
onFinish={(v) => editing && updateMut.mutate({ id: editing.id, v })}>
<Form.Item label={t('users.email')} name="email" rules={[{ required: true, type: 'email' }]}>
@@ -246,14 +299,10 @@ export default function UsersPage() {
</Modal>
{/* Set password modal */}
<Modal
title={`${t('users.setPasswordTitle')}${pwTarget?.email ?? ''}`}
<Modal title={`${t('users.setPasswordTitle')}${pwTarget?.email ?? ''}`}
open={pwTarget !== null}
onCancel={() => { setPwTarget(null); pwForm.resetFields() }}
onOk={() => void pwForm.submit()}
confirmLoading={pwMut.isPending}
destroyOnHidden
>
onOk={() => void pwForm.submit()} confirmLoading={pwMut.isPending} destroyOnHidden>
<Form form={pwForm} layout="vertical"
onFinish={(v) => pwTarget && pwMut.mutate({ id: pwTarget.id, v })}>
<Form.Item label={t('users.newPassword')} name="password"
@@ -263,6 +312,61 @@ export default function UsersPage() {
</Form.Item>
</Form>
</Modal>
{/* TOTP setup modal (own account only) */}
<Modal
title={isSelfTOTP ? t('users.totp.setupTitle') : t('users.totp.manageTitle')}
open={totpTarget !== null}
onCancel={() => setTotpTarget(null)}
footer={totpStep === 1
? [
<Button key="back" onClick={() => setTotpStep(0)}>{t('common.back')}</Button>,
<Button key="confirm" type="primary" onClick={() => void confirmTOTP()}>{t('users.totp.confirm')}</Button>,
]
: [
<Button key="cancel" onClick={() => setTotpTarget(null)}>{t('common.cancel')}</Button>,
totpTarget?.totp_enabled
? <Button key="disable" danger onClick={() => { if (totpTarget) { disableTOTPMut.mutate(totpTarget.id); setTotpTarget(null) } }}>{t('users.totp.disable')}</Button>
: <Button key="next" type="primary" onClick={() => setTotpStep(1)}>{t('common.next')}</Button>,
]
}
destroyOnHidden
>
{totpStep === 0 && (
<div style={{ textAlign: 'center' }}>
{totpTarget?.totp_enabled ? (
<>
<SafetyCertificateOutlined style={{ fontSize: 48, color: '#52c41a', marginBottom: 16 }} />
<Typography.Paragraph>{t('users.totp.alreadyEnabled')}</Typography.Paragraph>
<Typography.Paragraph type="secondary">{t('users.totp.disableHint')}</Typography.Paragraph>
</>
) : (
<>
<Typography.Paragraph>{t('users.totp.scanHint')}</Typography.Paragraph>
{totpUri && <QRCode value={totpUri} size={200} style={{ margin: '0 auto 16px' }} />}
<Typography.Paragraph type="secondary" copyable={{ text: totpSecret }} style={{ fontFamily: 'monospace', fontSize: 13 }}>
{totpSecret}
</Typography.Paragraph>
</>
)}
</div>
)}
{totpStep === 1 && (
<div style={{ textAlign: 'center' }}>
<Typography.Paragraph>{t('users.totp.enterCode')}</Typography.Paragraph>
<Input
size="large"
maxLength={6}
placeholder="000000"
value={totpCode}
onChange={e => setTotpCode(e.target.value.replace(/\D/g, ''))}
onPressEnter={() => void confirmTOTP()}
style={{ textAlign: 'center', letterSpacing: 8, fontSize: 20, width: 200 }}
autoFocus
/>
</div>
)}
</Modal>
</div>
)
}

View File

@@ -0,0 +1,341 @@
/**
* OWASP CRS v4.7.0 — rule descriptions (auto-generated from installed CRS).
* Update by running the extraction script when upgrading CRS.
*/
export const CRS_RULES: Record<number, string> = {
901001: "ModSecurity CRS is deployed without configuration! Please copy the crs-setup.conf.example template to crs-setup.conf,...",
901100: "Enabling body inspection",
901350: "Enabling forced body inspection for ASCII content",
901400: "Sampling: Disable the rule engine based on sampling_percentage <pct> and random number <rnd>",
901500: "Detection paranoia level configured is lower than the paranoia level itself. This is illegal. Blocking request. Aborting",
911011: "Method is not allowed by policy",
913011: "Found User-Agent associated with security scanner",
920011: "Invalid HTTP Request Line",
920013: "Range: Too many fields (6 or more)",
920015: "Invalid character in request (outside of printable chars below ascii 127)",
920017: "Range: Too many fields for pdf request (6 or more)",
920120: "Attempted multipart/form-data bypass",
920121: "Attempted multipart/form-data bypass",
920160: "Content-Length HTTP header is not numeric",
920170: "GET or HEAD Request with Body Content",
920171: "GET or HEAD Request with Transfer-Encoding",
920180: "POST without Content-Length or Transfer-Encoding headers",
920181: "Content-Length and Transfer-Encoding headers present",
920190: "Range: Invalid Last Byte Value",
920201: "Range: Too many fields for pdf request (63 or more)",
920210: "Multiple/Conflicting Connection Header Data Found",
920220: "URL Encoding Abuse Attack Attempt",
920221: "URL Encoding Abuse Attack Attempt",
920230: "Multiple URL Encoding Detected",
920240: "URL Encoding Abuse Attack Attempt",
920250: "UTF8 Encoding Abuse Attack Attempt",
920260: "Unicode Full/Half Width Abuse Attack Attempt",
920270: "Invalid character in request (null character)",
920271: "Invalid character in request (non printable characters)",
920273: "Invalid character in request (outside of very strict set)",
920274: "Invalid character in request headers (outside of very strict set)",
920275: "Invalid character in request headers (outside of very strict set)",
920280: "Request Missing a Host Header",
920290: "Empty Host Header",
920300: "Request Missing an Accept Header",
920310: "Request Has an Empty Accept Header",
920311: "Request Has an Empty Accept Header",
920320: "Missing User Agent Header",
920330: "Empty User Agent Header",
920340: "Request Containing Content, but Missing Content-Type header",
920341: "Request Containing Content Requires Content-Type header",
920350: "Host header is a numeric IP address",
920360: "Argument name too long",
920370: "Argument value too long",
920380: "Too many arguments in request",
920390: "Total arguments size exceeded",
920400: "Uploaded file size too large",
920410: "Total uploaded files size too large",
920420: "Request content type is not allowed by policy",
920430: "HTTP protocol version is not allowed by policy",
920440: "URL file extension is restricted by policy",
920450: "HTTP header is restricted by policy (<matched>)",
920451: "HTTP header is restricted by policy (<matched>)",
920460: "Abnormal character escapes in request",
920470: "Illegal Content-Type header",
920480: "Request content type charset is not allowed by policy",
920490: "Request header x-up-devcap-post-charset detected in combination with suspicious prefix",
920500: "Attempt to access a backup or working file",
920510: "Invalid Cache-Control request header",
920520: "Accept-Encoding header exceeded sensible length",
920521: "Illegal Accept-Encoding header",
920530: "Multiple charsets detected in content type header",
920540: "Possible Unicode character bypass detected",
920600: "Illegal Accept header: charset parameter",
920610: "Raw (unencoded) fragment in request URI",
920620: "Multiple Content-Type Request Headers",
921011: "HTTP Request Smuggling Attack",
921013: "HTTP Header Injection Attack via payload (CR/LF detected)",
921015: "HTTP Range Header detected",
921017: "HTTP Parameter Pollution possible via array notation",
921120: "HTTP Response Splitting Attack",
921130: "HTTP Response Splitting Attack",
921140: "HTTP Header Injection Attack via headers",
921150: "HTTP Header Injection Attack via payload (CR/LF detected)",
921160: "HTTP Header Injection Attack via payload (CR/LF and header-name detected)",
921170: "HTTP Parameter Pollution (<var>)",
921190: "HTTP Splitting (CR/LF in request filename detected)",
921200: "LDAP Injection Attack",
921210: "HTTP Parameter Pollution after detecting bogus char after parameter array",
921240: "mod_proxy attack attempt detected",
921421: "Content-Type header: Dangerous content type outside the mime type declaration",
921422: "Content-Type header: Dangerous content type outside the mime type declaration",
922100: "Multipart content type global _charset_ definition is not allowed by policy",
922110: "Illegal MIME Multipart Header content-type: charset parameter",
922120: "Content-Transfer-Encoding was deprecated by rfc7578 in 2015 and should not be used",
922130: "Multipart header contains characters outside of valid range",
930011: "Path Traversal Attack (/../) or (/.../)",
930013: "OS File Access Attempt in REQUEST_HEADERS",
930110: "Path Traversal Attack (/../) or (/.../)",
930120: "OS File Access Attempt",
930130: "Restricted File Access Attempt",
931011: "Possible Remote File Inclusion (RFI) Attack: URL Parameter using IP Address",
931013: "Possible Remote File Inclusion (RFI) Attack: Off-Domain Reference/Link",
931110: "Possible Remote File Inclusion (RFI) Attack: Common RFI Vulnerable Parameter Name used w/URL Payload",
931120: "Possible Remote File Inclusion (RFI) Attack: URL Payload Used w/Trailing Question Mark Character (?)",
931131: "Possible Remote File Inclusion (RFI) Attack: Off-Domain Reference/Link",
932011: "Remote Command Execution: Unix Command Injection (2-3 chars)",
932013: "Remote Command Execution: Unix Command Injection",
932015: "Remote Command Execution: Unix Command Injection",
932120: "Remote Command Execution: Windows PowerShell Command Found",
932125: "Remote Command Execution: Windows Powershell Alias Command Injection",
932130: "Remote Command Execution: Unix Shell Expression Found",
932131: "Remote Command Execution: Unix Shell Expression Found",
932140: "Remote Command Execution: Windows FOR/IF Command Found",
932160: "Remote Command Execution: Unix Shell Code Found",
932161: "Remote Command Execution: Unix Shell Code Found in REQUEST_HEADERS",
932170: "Remote Command Execution: Shellshock (CVE-2014-6271)",
932171: "Remote Command Execution: Shellshock (CVE-2014-6271)",
932175: "Remote Command Execution: Unix shell alias invocation",
932180: "Restricted File Upload Attempt",
932190: "Remote Command Execution: Wildcard bypass technique attempt",
932200: "RCE Bypass Technique",
932205: "RCE Bypass Technique",
932206: "RCE Bypass Technique",
932210: "Remote Command Execution: SQLite System Command Execution",
932220: "Remote Command Execution: Unix Command Injection with pipe",
932235: "Remote Command Execution: Unix Command Injection (command without evasion)",
932236: "Remote Command Execution: Unix Command Injection (command without evasion)",
932237: "Remote Command Execution: Unix Shell Code Found in REQUEST_HEADERS",
932238: "Remote Command Execution: Unix Shell Code Found in REQUEST_HEADERS",
932239: "Remote Command Execution: Unix Command Injection found in user-agent or referer header",
932240: "Remote Command Execution: Unix Command Injection evasion attempt detected",
932250: "Remote Command Execution: Direct Unix Command Execution",
932260: "Remote Command Execution: Direct Unix Command Execution",
932270: "Remote Command Execution: Unix Shell Expression Found",
932300: "Remote Command Execution: SMTP Command Execution",
932301: "Remote Command Execution: SMTP Command Execution",
932310: "Remote Command Execution: IMAP Command Execution",
932311: "Remote Command Execution: IMAP Command Execution",
932320: "Remote Command Execution: POP3 Command Execution",
932321: "Remote Command Execution: POP3 Command Execution",
932330: "Remote Command Execution: Unix shell history invocation",
932331: "Remote Command Execution: Unix shell history invocation",
932370: "Remote Command Execution: Windows Command Injection",
932380: "Remote Command Execution: Windows Command Injection",
933011: "PHP Injection Attack: PHP Open Tag Found",
933013: "PHP Injection Attack: Medium-Risk PHP Function Name Found",
933015: "PHP Injection Attack: Variables Found",
933110: "PHP Injection Attack: PHP Script File Upload Found",
933111: "PHP Injection Attack: PHP Script File Upload Found",
933120: "PHP Injection Attack: Configuration Directive Found",
933130: "PHP Injection Attack: Variables Found",
933140: "PHP Injection Attack: I/O Stream Found",
933150: "PHP Injection Attack: High-Risk PHP Function Name Found",
933160: "PHP Injection Attack: High-Risk PHP Function Call Found",
933161: "PHP Injection Attack: Low-Value PHP Function Call Found",
933170: "PHP Injection Attack: Serialized Object Injection",
933180: "PHP Injection Attack: Variable Function Call Found",
933190: "PHP Injection Attack: PHP Closing Tag Found",
933200: "PHP Injection Attack: Wrapper scheme detected",
933210: "PHP Injection Attack: Variable Function Call Found",
933211: "PHP Injection Attack: Variable Function Call Found",
934011: "Node.js Injection Attack 1/2",
934013: "Node.js Injection Attack 2/2",
934110: "Possible Server Side Request Forgery (SSRF) Attack: Cloud provider metadata URL in Parameter",
934120: "Possible Server Side Request Forgery (SSRF) Attack: URL Parameter using IP Address",
934130: "JavaScript Prototype Pollution",
934140: "Perl Injection Attack",
934150: "Ruby Injection Attack",
934160: "Node.js DoS attack",
934170: "PHP data scheme attack",
941011: "XSS Attack Detected via libinjection",
941013: "XSS Attack Detected via libinjection",
941110: "XSS Filter - Category 1: Script Tag Vector",
941120: "XSS Filter - Category 2: Event Handler Vector",
941130: "XSS Filter - Category 3: Attribute Vector",
941140: "XSS Filter - Category 4: Javascript URI Vector",
941150: "XSS Filter - Category 5: Disallowed HTML Attributes",
941160: "NoScript XSS InjectionChecker: HTML Injection",
941170: "NoScript XSS InjectionChecker: Attribute Injection",
941180: "Node-Validator Deny List Keywords",
941181: "Node-Validator Deny List Keywords",
941190: "IE XSS Filters - Attack Detected",
941200: "IE XSS Filters - Attack Detected",
941210: "IE XSS Filters - Attack Detected",
941220: "IE XSS Filters - Attack Detected",
941230: "IE XSS Filters - Attack Detected",
941240: "IE XSS Filters - Attack Detected",
941250: "IE XSS Filters - Attack Detected",
941260: "IE XSS Filters - Attack Detected",
941270: "IE XSS Filters - Attack Detected",
941280: "IE XSS Filters - Attack Detected",
941290: "IE XSS Filters - Attack Detected",
941300: "IE XSS Filters - Attack Detected",
941310: "US-ASCII Malformed Encoding XSS Filter - Attack Detected",
941320: "Possible XSS Attack Detected - HTML Tag Handler",
941330: "IE XSS Filters - Attack Detected",
941340: "IE XSS Filters - Attack Detected",
941350: "UTF-7 Encoding IE XSS - Attack Detected",
941360: "JSFuck / Hieroglyphy obfuscation detected",
941370: "JavaScript global variable found",
941380: "AngularJS client side template injection detected",
941390: "Javascript method detected",
941400: "XSS JavaScript function without parentheses",
942011: "SQL Injection Attack Detected via libinjection",
942013: "SQL Injection Attack: SQL Operator Detected",
942015: "Detects HAVING injections",
942017: "Restricted SQL Character Anomaly Detection (cookies): # of special characters exceeded (3)",
942101: "SQL Injection Attack Detected via libinjection",
942130: "SQL Injection Attack: SQL Boolean-based attack detected",
942131: "SQL Injection Attack: SQL Boolean-based attack detected",
942140: "SQL Injection Attack: Common DB Names Detected",
942150: "SQL Injection Attack: SQL function name detected",
942151: "SQL Injection Attack: SQL function name detected",
942152: "SQL Injection Attack: SQL function name detected",
942160: "Detects blind sqli tests using sleep() or benchmark()",
942170: "Detects SQL benchmark and sleep injection attempts including conditional queries",
942180: "Detects basic SQL authentication bypass attempts 1/3",
942190: "Detects MSSQL code execution and information gathering attempts",
942200: "Detects MySQL comment-/space-obfuscated injections and backtick termination",
942210: "Detects chained SQL injection attempts 1/2",
942220: "Looking for integer overflow attacks, these are taken from skipfish, except 2.2.2250738585072011e-308 is the 'magic...",
942230: "Detects conditional SQL injection attempts",
942240: "Detects MySQL charset switch and MSSQL DoS attempts",
942250: "Detects MATCH AGAINST, MERGE and EXECUTE IMMEDIATE injections",
942260: "Detects basic SQL authentication bypass attempts 2/3",
942270: "Looking for basic sql injection. Common attack string for mysql, oracle and others",
942280: "Detects Postgres pg_sleep injection, waitfor delay attacks and database shutdown attempts",
942290: "Finds basic MongoDB SQL injection attempts",
942300: "Detects MySQL comments, conditions and ch(a)r injections",
942310: "Detects chained SQL injection attempts 2/2",
942320: "Detects MySQL and PostgreSQL stored procedure/function injections",
942321: "Detects MySQL and PostgreSQL stored procedure/function injections",
942330: "Detects classic SQL injection probings 1/3",
942340: "Detects basic SQL authentication bypass attempts 3/3",
942350: "Detects MySQL UDF injection and other data/structure manipulation attempts",
942360: "Detects concatenated basic SQL injection and SQLLFI attempts",
942361: "Detects basic SQL injection based on keyword alter or union",
942362: "Detects concatenated basic SQL injection and SQLLFI attempts",
942370: "Detects classic SQL injection probings 2/3",
942380: "SQL Injection Attack",
942390: "SQL Injection Attack",
942400: "SQL Injection Attack",
942410: "SQL Injection Attack",
942420: "Restricted SQL Character Anomaly Detection (cookies): # of special characters exceeded (8)",
942430: "Restricted SQL Character Anomaly Detection (args): # of special characters exceeded (12)",
942431: "Restricted SQL Character Anomaly Detection (args): # of special characters exceeded (6)",
942432: "Restricted SQL Character Anomaly Detection (args): # of special characters exceeded (2)",
942441: "SQL Comment Sequence Detected",
942450: "SQL Hex Encoding Identified",
942460: "Meta-Character Anomaly Detection Alert - Repetitive Non-Word Characters",
942470: "SQL Injection Attack",
942480: "SQL Injection Attack",
942490: "Detects classic SQL injection probings 3/3",
942500: "MySQL in-line comment detected",
942510: "SQLi bypass attempt by ticks or backticks detected",
942511: "SQLi bypass attempt by ticks detected",
942520: "Detects basic SQL authentication bypass attempts 4.0/4",
942521: "Detects basic SQL authentication bypass attempts 4.1/4",
942522: "Detects basic SQL authentication bypass attempts 4.1/4",
942530: "SQLi query termination detected",
942540: "SQL Authentication bypass (split query)",
942550: "JSON-Based SQL Injection",
942560: "MySQL Scientific Notation payload detected",
943011: "Possible Session Fixation Attack: Setting Cookie Values in HTML",
943110: "Possible Session Fixation Attack: SessionID Parameter Name with Off-Domain Referer",
943120: "Possible Session Fixation Attack: SessionID Parameter Name with No Referer",
944011: "Remote Command Execution: Suspicious Java class detected",
944013: "Potential Remote Command Execution: Log4j / Log4shell",
944015: "Base64 encoded string matched suspicious keyword",
944017: "Potential Remote Command Execution: Log4j / Log4shell",
944110: "Remote Command Execution: Java process spawn (CVE-2017-9805)",
944120: "Remote Command Execution: Java serialization (CVE-2015-4852)",
944130: "Suspicious Java class detected",
944140: "Java Injection Attack: Java Script File Upload Found",
944150: "Potential Remote Command Execution: Log4j / Log4shell",
944200: "Magic bytes Detected, probable java serialization in use",
944210: "Magic bytes Detected Base64 Encoded, probable java serialization in use",
944240: "Remote Command Execution: Java serialization (CVE-2015-4852)",
944250: "Remote Command Execution: Suspicious Java method detected",
944260: "Remote Command Execution: Malicious class-loading payload",
949052: "Inbound Anomaly Score Exceeded in phase 1 (Total Score: %{TX.BLOCKING_INBOUND_ANOMALY_SCORE})",
949110: "Inbound Anomaly Score Exceeded (Total Score: %{TX.BLOCKING_INBOUND_ANOMALY_SCORE})",
950010: "Directory Listing",
950013: "The Application Returned a 500-Level Status Code",
950140: "CGI source code leakage",
951010: "Microsoft Access SQL Information Leakage",
951120: "Oracle SQL Information Leakage",
951130: "DB2 SQL Information Leakage",
951140: "EMC SQL Information Leakage",
951150: "firebird SQL Information Leakage",
951160: "Frontbase SQL Information Leakage",
951170: "hsqldb SQL Information Leakage",
951180: "informix SQL Information Leakage",
951190: "ingres SQL Information Leakage",
951200: "interbase SQL Information Leakage",
951210: "maxDB SQL Information Leakage",
951220: "mssql SQL Information Leakage",
951230: "mysql SQL Information Leakage",
951240: "postgres SQL Information Leakage",
951250: "sqlite SQL Information Leakage",
951260: "Sybase SQL Information Leakage",
952010: "Java Source Code Leakage",
952110: "Java Errors",
953010: "PHP Information Leakage",
953013: "PHP Information Leakage",
953110: "PHP source code leakage",
953120: "PHP source code leakage",
954010: "Disclosure of IIS install location",
954110: "Application Availability Error",
954120: "IIS Information Leakage",
954130: "IIS Information Leakage",
955010: "Web shell detected",
955013: "webadmin.php file manager",
955110: "r57 web shell",
955120: "WSO web shell",
955130: "b4tm4n web shell",
955140: "Mini Shell web shell",
955150: "Ashiyane web shell",
955160: "Symlink_Sa web shell",
955170: "CasuS web shell",
955180: "GRP WebShell",
955190: "NGHshell web shell",
955200: "SimAttacker web shell",
955210: "Unknown web shell",
955220: "lama web shell",
955230: "lostDC web shell",
955240: "Unknown web shell",
955250: "Unknown web shell",
955260: "Ru24PostWebShell web shell",
955270: "s72 Shell web shell",
955280: "PhpSpy web shell",
955290: "g00nshell web shell",
955300: "PuNkHoLic shell web shell",
955310: "azrail web shell",
955320: "SmEvK_PaThAn Shell web shell",
955330: "Shell I web shell",
955340: "b374k m1n1 web shell",
959052: "Outbound Anomaly Score Exceeded in phase 3 (Total Score: %{tx.blocking_outbound_anomaly_score})",
959100: "Outbound Anomaly Score Exceeded (Total Score: %{tx.blocking_outbound_anomaly_score})",
980099: "Anomaly Scores: Inbound/Outbound anomaly score summary",
}
export function getRuleDescription(ruleId: number): string {
return CRS_RULES[ruleId] ?? `Rule ${ruleId} — see coreruleset.org for details`
}

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