30 Commits

Author SHA1 Message Date
noroot
3215da8a84 chore(release): v1.3.26 stable 2026-09-11 09:36:14 +02:00
noroot
6f69697705 fix(deps): golang.org/x/crypto v0.56.0 — zwei SSH-DoS-CVEs
GO-2026-6354/6355 (deadlocked channel → DoS) sind über
remote.Service.Test → ssh.Dial im SFTP-Backup-Pfad erreichbar, der
govulncheck-Gate hat das Release entsprechend blockiert. Nicht durch
eine Code-Änderung ausgelöst — die CVEs wurden seit v1.3.25 publiziert.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 09:34:13 +02:00
noroot
33cfc1a90d fix(fwlog): Live-Log war seit Tagen still tot + Seitengröße wirkungslos
Zwei unabhängige Bugs hinter "Live-Log zeigt Einträge, aber nichts Neues":

1) ulogd2 stirbt beim nächtlichen Logrotate. Der postinst nahm an, ulogd
   laufe als root — die Debian-Unit startet aber `ulogd --daemon --uid ulog`.
   Beim Start öffnet ulogd die JSONL noch als root und schreibt danach über
   den offenen fd weiter, egal wem sie gehört. Nachts schickt das Distro-
   Profil /etc/logrotate.d/ulogd2 ein SIGHUP; das Reopen läuft dann als
   `ulog` und scheitert an root:edgeguard 0640 ("can't open JSON log file:
   Permission denied"). ulogd wertet das als fatal und beendet sich mit
   Exit-Code 0 — Restart=on-failure hätte also nicht gegriffen, und ohne
   Restart= blieb der Dienst tot (auf utm-1 5 Tage unbemerkt). Die API
   servierte derweil weiter ihren In-Memory-Ring von vor der Rotation,
   deshalb sah die UI Einträge, aber nie neue.
   Fix: Owner ulog (Schreiber) : edgeguard (Leser), logrotate `create`
   passend, plus Drop-in Restart=always als Selbstheilung.

2) Seitengröße liess sich nicht umstellen. Die Tabellen übergaben ein
   literales `pagination={{ pageSize: N }}`. antd merged via
   extendsObject(innerPagination, paginationObj) — der Prop überschreibt
   bei jedem Render den State, den der Size-Changer gerade gesetzt hat.
   Bei einem Live-Log rendert das im Sekundentakt, der Klick auf 20/100
   war also sofort wieder weg. Fix: defaultPageSize (unkontrolliert).
   Betraf ausser dem Live-Log auch Logs, Backups-History, Routes,
   Alerts und CrowdSec.

Ausserdem: `t` aus den WS-Effect-Deps genommen. i18n wechselt dessen
Identität bei Store-Updates, was den Effect neu laufen liess — inklusive
setEntries([]), d.h. der Live-Puffer leerte sich ohne erkennbaren Grund.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 09:32:43 +02:00
noroot
3e05c7fe49 chore(release): v1.3.25 stable 2026-09-02 16:49:18 +02:00
noroot
924540d7a9 fix(build): management-ui als eigene Go-Modul-Grenze
golangci-lint schlug beim Stable-Release-Rebuild neu fehl:
management-ui/node_modules/flatted/golang/pkg/flatted/flatted.go (eine
rohe .go-Datei ohne eigenes go.mod, Teil des npm-Pakets "flatted") hat
sich durch den bun-statt-npm-Rebuild inhaltlich geändert und wurde vom
govet-Sublinter neu bemängelt — geriet nur ins Gate weil sie ohne
Modul-Grenze automatisch unter `./...` des Root-Moduls fällt.

management-ui/go.mod als reine Grenze (kein eigenständiges Buildable-
Modul) stoppt `go vet/build/test ./...` davor, überhaupt dort
hineinzulaufen — robuster als ein golangci-lint-Pfad-Exclude, weil es
auch die plain go vet/build/test-Schritte in release-check erfasst,
nicht nur golangci-lint.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-02 16:48:36 +02:00
noroot
561816d79d fix(release): bun statt npm-Fallback für Release-Builds erzwingen
make ui fällt ohne bun still auf npm install zurück — löst Dependencies
gegen package.json neu auf statt gegen das gepinnte management-ui/
bun.lock und hinterlässt eine dazu inkonsistente package-lock.json.
Der gelockerte Dirty-Check (--untracked-files=no, letzter Commit)
ignoriert genau dieses Artefakt, weil es zusammen mit anderer fremder
unversionierter Arbeit im Repo liegt — beide Fixes zusammen hätten
also einen Release mit abweichender Dependency-Auflösung klaglos
durchgelassen.

release.sh bricht jetzt hart ab wenn bun fehlt, statt sich auf den
Fallback zu verlassen — Release-Builds (Testing UND Stable) müssen
reproduzierbar aus dem gepinnten Lockfile bauen.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-02 16:46:12 +02:00
noroot
cab78eb3d1 chore(release): v1.3.24 stable 2026-09-02 16:37:41 +02:00
noroot
f61f82d36f fix(postinst): main→stable-Migration erkennt auch alte netcell-edgeguard.list
utm-1 (Produktion) läuft noch mit dem Dateinamen aus einer noch älteren
Installer-Generation (vor dem Rename auf edgeguard.list) — install.sh
räumt den nur bei einem FRISCHEN Install auf, nie bei einem Upgrade.
Ohne diesen Fix hätte die main→stable-Migration aus dem letzten Commit
auf genau diesem Node nie gegriffen. Erst auf edgeguard.list
konsolidieren, dann main→stable wie gehabt.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-02 16:37:00 +02:00
noroot
7aa2a907d5 chore(release): v1.3.23 stable 2026-09-02 16:32:56 +02:00
noroot
99df6f731d fix(release): Dirty-Check ignoriert unversionierte Dateien
git status --porcelain ohne --untracked-files=no blockierte den
Stable-Release wegen fremder, noch nicht committeter Arbeit in
unversionierten Verzeichnissen (deploy/angie, internal/angie,
internal/proxy, migrations) — die gehen ein Release nichts an,
nur uncommittete Änderungen an bereits versionierten Dateien sollen
blocken.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-02 16:32:13 +02:00
noroot
bab82f8d5b feat(update): Testing/Stable-Update-Kanäle wie enconf (Suite=trixie, Komponente=Kanal)
Installer (EDGEGUARD_CHANNEL), Kanal-Lesen/-Schreiben ohne DB-State
(sources.list ist Quelle der Wahrheit), Cluster-Endpoints für Kanalwechsel
mit mTLS-Peer-Propagation + Drift-Erkennung, --allow-downgrades für
testing→stable-Downgrades über den bestehenden sicheren Rolling-Update-
Flow, Settings-UI mit Bestätigung, neues scripts/release.sh (Testing-Push
datumsbasiert YYYY.MM.DD.NN, Stable-Promotion mit Verify-Gate + Git-Tag),
publish.sh/cleanup-old.sh kanalfähig mit Stable-Tag-Schutz.

Migriert Bestandsnodes automatisch von der alten "main"-Komponente auf
"stable" (postinst, idempotent) — ohne das würden vor diesem Release
installierte Nodes stillschweigend keine Updates mehr sehen, sobald
main nicht mehr bespielt wird.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-02 16:31:52 +02:00
noroot
7ff6575790 fix(net): martian-source Log-Spam auf VRRP-Backup-Node — v1.3.22
log_martians global aus (rp_filter-Drop bleibt aktiv): utm-2 sah als
Backup dauerhaft Broadcast/Multicast für Gateway-Adressen, die es
nicht besitzt, und flutete dmesg damit (>100k Zeilen/Woche). Security-
Logging läuft ohnehin über nftables-NFLOG/ulogd2 + CrowdSec, nicht dmesg.

Nebenbei: go.mod-Toolchain auf 1.26.6 (offene Stdlib-CVEs in 1.26.4,
govulncheck-Gate schlug fehl) und Makefile-ui-Target braucht
--include=dev für den npm-Fallback, sonst bricht der UI-Build bei
gesetztem NODE_ENV=production ab.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-30 17:54:49 +02:00
Debian
51e5fe83d9 feat(crowdsec): http-crawl-non_statics per Default in Simulation — verhindert FP-Bans legitimer App-Nutzer — v1.3.21
http-crawl-non_statics ist bei modernen SPAs/Apps strukturell FP-anfällig: ein
Seiten-Load/Sync feuert 40+ distinkte /api/-URLs, der Leaky-Bucket (capacity=40,
leak ~2/s) läuft in Sekunden über → False-Positive-Ban legitimer Nutzer/Kunden
(mehrere Kunden meldeten das; Incident 2026-08-18 bannte die Admin-Telekom-IP).

postinst setzt das Scenario jetzt per Default in SIMULATION (alarmiert weiter,
bannt aber nicht). Echte Angriffe (ssh-bf, http-cve-*, backdoors, CVE-2021-41773)
bleiben scharf. Marker-geschützt (/var/lib/edgeguard/.crowdsec-crawl-sim-applied)
→ nur bei Erst-Install; ein späteres manuelles `cscli simulation disable` des
Operators wird bei Updates NICHT überschrieben. Überlebt damit auch Node-Neuaufbau.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-18 14:20:54 +02:00
Debian
b70db4ccf0 fix(scheduler): ACME-Renewal nur auf VIP-Master — Standby-403 verursachte Cert-Drift — v1.3.20
Der Scheduler fuhr runRenewer (ACME) ungated auf beiden Nodes. ACME-HTTP-01-
Challenges laufen aber auf :80 der VIP → nur der VIP-Master kann sie bestehen.
Der BACKUP-Node scheiterte immer mit 403 (invalid authorization) und setzte
tls_certs.status lokal auf "error" → Divergenz zur replizierten Row
(Primary=active) → Config-Drift-Banner + Log-Noise. runRenewer jetzt hinter
nodeHoldsVIP() gegated (Start + 6h-Tick); runCertExpiryCheck bleibt ungated
(read-only). Die Cert-Row/PEM repliziert ohnehin vom Master auf den Standby.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-07 08:35:03 +02:00
Debian
f1f7df74f7 feat(crowdsec): UI-Schalter „vertrauenswürdiges Admin-Panel" → gerenderte CrowdSec-Whitelist — v1.3.19
Pro-Domain-Flag crowdsec_trusted (Migration 0048). Ist es gesetzt, rendert der
neue crowdsec-whitelist-Generator den Hostname host-genau in
/etc/crowdsec/parsers/s02-enrich/edgeguard-admin-hosts-whitelist.yaml
(evt.Parsed.http_host) und reloadet crowdsec. Löst das Problem, dass Admin-SPAs
(viele /api/-Requests pro Aktion) http-crawl-non_statics triggern und die
Admin-IP bannen — jetzt im Frontend steuerbar statt manueller Node-Datei.

- Generator internal/crowdsec/whitelist.go (configgen.Generator, no-op ohne
  CrowdSec), registriert in edgeguard-ctl render-config + in den Domains-Reloader
  komponiert (Domain-Edit → Whitelist re-render). Aus der replizierten DB
  gerendert → überlebt Node-Neuaufbau (Ersatz für die manuelle Node-Datei).
- postinst: sudoers reload crowdsec + edgeguard-owned Whitelist-Datei anlegen
  (dir root-owned → Generator überschreibt nur die vorab-chownte Datei) +
  Initial-Render.
- UI: Switch „Vertrauenswürdiges Admin-Panel" im Domain-Detail.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-06 22:23:30 +02:00
Debian
de153dc13a fix(waf): GET-Requests ohne Body wurden von allen Phase-2-Regeln nie geprüft — v1.3.18
Schwerwiegende WAF-Lücke: der SPOE-Agent rief tx.ProcessRequestBody() nur bei
len(body)>0 auf. In Coraza wird die GESAMTE Phase 2 (SQLi 942xxx, XSS 941xxx,
LFI/RCE — alle prüfen ARGS aus dem Query-String) aber erst von
ProcessRequestBody() ausgewertet. Damit lief jeder GET-Request ohne Body
komplett ungeprüft an den Injection-Regeln vorbei — der häufigste
Web-Angriffsvektor (?id=1' OR 1=1, ?x=<script>) war blind. Nur Requests MIT
Body (POST/PUT/PROPFIND-XML) wurden inspiziert.

Fix: ProcessRequestBody() läuft jetzt IMMER (mit/ohne Body). Regressionstest
TestPhase2RequiresProcessRequestBody nagelt die Semantik fest.

Alle 9 aktiven WAF-Domains sind in Detection-Modus → der Fix erzeugt nur mehr
(echte) Alerts, blockt nichts.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-04 10:00:17 +02:00
Debian
5c268425c1 feat(waf): benutzerdefinierte App-Profile + Fix: CRS-Plugins wurden im Agent nie geladen — v1.3.17
Neu: eigene WAF-App-Profile (benannte, wiederverwendbare Rule-ID-Ausnahme-
Bündel) — zentrale Bibliothek im UI (eigener Tab), pro Domain zuweisbar,
Built-in-OWASP-Plugins bleiben read-only + als Vorlage klonbar. Nur reine
Rule-IDs/Ranges (keine SecLang-Ausführung, injektionssicher).
- Migration 0047: Tabelle waf_app_profiles (repliziert via reconcile) +
  waf_configs.app_profiles.
- Service/Handler: CRUD (/waf/profiles), Built-ins geschützt (builtin=false-Gate).
- Agent-Loader: app_profiles → in effektive rule_exclusions gemerged; ihr
  updated_at hebt das effektive updated_at der Domain → Engine-Rebuild bei
  Profil-Edit.
- UI: Profile-Tab (Liste/Editor mit durchsuchbaren Rule-IDs) + Multi-Select im
  Domain-Drawer.

FIX (wichtig): ListAllWithDomain — der EINZIGE Loader des laufenden WAF-Agents —
selektierte crs_plugins nie. Dadurch war cfg.CRSPlugins im Agent immer leer und
KEIN Built-in-CRS-Plugin (Nextcloud/WordPress/Drupal) wurde je in die Engine
inkludiert. Jetzt geladen (+ app_profiles). Die per-Domain-Plugin-Wahl wirkt
damit erstmals tatsächlich.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-03 15:51:39 +02:00
Debian
0846eaa05b feat(ha): VIP-Preempt zurück zum PG-Primary — mit gehärtetem Health-Gate — v1.3.16
Preempt-Rückkehr (preempt_delay 120) wieder aktiv: der bevorzugte Node
(PG-Primary, Prio 200) holt die VIP nach Erholung zurück. Der Incident
2026-08-03 (halb-kaputter Node riss die VIP an sich) wird verhindert, weil
keepalived-check.sh jetzt zusätzlich fordert:
  - haproxy-Prozess aktiv
  - :443 gebunden (bedient wirklich Traffic)
Ein nicht-bedienender Node geht damit in FAULT und kann NICHT (mehr) preempten.

Außerdem: CrowdSec-Management-Whitelist (Backend api_backend) fest ins postinst
gebacken (Admin-SPA-Traffic wird nie mehr als http-crawl gebannt, IP-unabhängig,
Incident-Root-Fix) + Altlast netcell-mgmt-whitelist.yaml wird aufgeräumt.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-03 13:39:15 +02:00
Debian
d395e3ea68 fix(keepalived): REVERT preempt_delay → nopreempt (Prod-Ausfall 2026-08-03) — v1.3.15
v1.3.9 hatte auf dem PG-Primary (Prio 200) nopreempt durch preempt_delay ersetzt,
damit die VIP zum Primary heimwandert. Das reaktivierte GENAU den Fehlermodus,
den nopreempt verhindert: der Prio-200-Node holt die VIP zurück, sobald
keepalived ihn für gesund hält — aber die Track-Scripts können "gesund" melden,
während der Dienst kaputt ist. Am 2026-08-03 entriss so ein halb-kaputtes utm-1
dem funktionierenden utm-2 die VIP (Log: "Master received advert from .6 with
higher priority 200 → Entering BACKUP") und hielt sie fest → Ausfall, bis utm-1
hart abgeschaltet wurde.

Zurück auf nopreempt (beide Nodes, wie vor v1.3.9). VIP-Affinität zum Primary
erst wieder, wenn der Health-Check "Prozess up aber Dienst kaputt" als FAULT
erkennt. Bis dahin: Stabilität > Affinität.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-03 12:24:16 +02:00
Debian
f0be5be496 feat(ui/waf): Regel-Ausnahmen direkt im Config-Dialog + durchsuchbarer Select — v1.3.14
Feedback: in der Regel-Ausnahmen-Sektion konnte man Ausnahmen nur SEHEN, nicht
hinzufügen (nur Hinweis auf den Alarme-Tab), und die Rule-ID war Freitext.

Jetzt: durchsuchbarer Select (aus CRS_RULES, filtert nach ID UND Beschreibung —
z.B. "941" oder "XSS") + optionale Notiz + Hinzufügen-Button direkt im Dialog.
Speichert sofort (wie der Entfernen-Button). Der Dropdown deckt alle 331
message-behafteten CRS-Regeln ab = genau die, die je in einem Alarm auftauchen.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-03 11:41:49 +02:00
Debian
235b5c3b9a fix(waf/packaging): CRS-Plugin-Download probiert main + master, curl -f — v1.3.13
WordPress-Plugin nutzt den Branch `master` (nicht `main`), und `curl -sL` ohne
-f wertete den 404 als Erfolg → WordPress-Plugin wurde still übersprungen. Fix:
beide Branches probieren, curl -f (harter HTTP-Fehler), Tarball-Extraktion als
Erfolgskriterium.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-03 10:36:51 +02:00
Debian
37f729381d feat(waf): CRS-App-Exclusion-Plugins (Nextcloud/WordPress/Drupal) pro Domain — v1.3.12
Statt manueller SecRuleRemoveById-IDs kann man pro Domain offizielle OWASP-CRS-
Exclusion-Plugins aktivieren — pfad-genaue, upstream-gepflegte App-Ausnahmen.

- Migration 0046: waf_configs.crs_plugins text[].
- Engine (engine.go): je gewähltem Plugin werden config/before VOR den CRS-Rules
  und after DANACH inkludiert (exakt nach OWASP-CRS-Plugin-Spec); nur die für
  DIESE Domain gewählten, nur wenn die Datei existiert. Whitelist KnownCRSPlugins.
- Handler: crs_plugins im Upsert-Body + Whitelist-Validierung (Include-Pfad-
  Injection-Schutz).
- Packaging (postinst): lädt die Plugins (coreruleset/<name>-plugin) nach
  <crs>/plugins/ — self-healing auf jedem configure, nur fehlende.
- UI: Multi-Select „App-Profile (CRS-Plugins)" im WAF-Config-Drawer.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-03 10:28:03 +02:00
Debian
088910ee19 fix(scheduler): WG-Client-Tunnel-Check nutzt korrekten Tabellennamen — v1.3.11
runWGClientTunnelCheck fragte `wg_interfaces` ab — die Tabelle heißt überall
sonst `wireguard_interfaces`. Der Query-Fehler wurde verschluckt (if err return),
sodass der Check bei JEDEM 5-min-Lauf still no-opte (WG-Client-Tunnel-Monitoring
faktisch tot) und PostgreSQL alle 5 min `relation "wg_interfaces" does not exist`
ins Log schrieb (auf beiden Nodes). Fix: korrekter Tabellenname.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-02 20:58:22 +02:00
Debian
09e6e0c4f7 fix(keepalived): notify_master ermittelt PG-Rolle über Publication statt Datei — v1.3.10
keepalived-master.sh loggte beim VIP-Übernehmen "PG-Rolle ist noch 'standby'",
weil es /var/lib/edgeguard/pg_role las — die schreibt nur `promote`, ein via
cluster-init-replication eingerichteter Primary hat sie nie → falsches Label.
Jetzt zuverlässig über pg_publication (nur der Primary trägt edgeguard_shared,
Konvention wie cluster_repair.go): Primary → "bereits PG-Primary (kein promote
nötig)", sonst → "standby, edgeguard-ctl promote". Rein kosmetisch/Logging.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-31 19:06:05 +02:00
Debian
18ee243a44 feat(keepalived): VIP wandert zum PG-Primary zurück (preempt_delay) — v1.3.9
Bisher trugen beide VRRP-Instanzen `nopreempt` → ein erholter Primary holte die
VIP NICHT zurück; nach einem Deploy-/VM-Blip blieb sie auf dem Standby kleben
(genau die Situation: VIP auf utm-2 obwohl utm-1 der PG-Primary ist).

Jetzt: der bevorzugte Node (PG-Primary, Prio 200) rendert `preempt_delay 120`
statt nopreempt → er holt die VIP nach 120s STABILER Erholung heim. Der Standby
(Prio 100) behält nopreempt (reißt die VIP nie an sich → Split-Brain-Schutz).
Der 120s-Delay + gw-check + Heartbeat-Sync-Group verhindern Flap-Back bei kurzen
Hicks. State bleibt immer BACKUP.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-31 17:56:53 +02:00
Debian
4856779db8 fix(ui): Standby-Node zeigt keinen "alle Backends down"-Fehlalarm mehr — v1.3.8
Der Dashboard-Down-Backends-Alarm liest die LIVE-HAProxy-Stats des lokalen
Nodes. Auf dem keepalived-BACKUP-Node erreicht die lokale HAProxy die Backend-
Subnetze nicht (VLAN-Gateway-VIPs liegen beim Master) → alle Backends L4-down.
v1.3.7 stoppte nur den alert_events-Spam (scheduler), nicht die Anzeige.

Jetzt: ist der Node BACKUP (vip_status.vrrp_state), wird der rote "N Backends
down"-Alarm durch einen ruhigen Info-Hinweis ersetzt ("Standby-Node — Backend-
Health lokal nicht aussagekräftig, Master bedient den Traffic"). Auf MASTER/
UNKNOWN bleibt der echte Alarm.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-31 17:44:23 +02:00
Debian
8e4759ccc6 feat(cluster): Replikations-Reconcile + Backend-Down nur auf VIP-Master — v1.3.7
- fix(scheduler): backend.down-Check läuft nur noch wenn dieser Node den VIP
  hält (nodeHoldsVIP). Ein keepalived-BACKUP-Node hat KEINE VLAN-IP → erreicht
  die Backend-Subnetze nicht → sah bisher ALLE Backends L4-down und feuerte
  Dauer-Fehlalarme (Hauptquelle des alert_events-Spams). Master sieht die
  echten States.
- feat(ctl): `cluster-reconcile-replication` — bringt Publication/Grants/
  Subscription idempotent in den Soll-Zustand (Publisher: fehlende Shared-
  Tables ADD, node-lokale DROP, GRANT SELECT für Replikator; Subscriber:
  neue Tabellen leeren + REFRESH). Läuft im postinst nach migrate.
- fix(packaging): postinst re-added network_interfaces/ip_addresses bei JEDEM
  Upgrade in die Publication (alter fester Block) → ersetzt durch den Reconcile.
  DAS war die Wiederkehr-Ursache.
- fix(replication): waf_alerts → localOnlyTables (Event-Daten, node-lokal wie
  alert_events).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-31 17:07:21 +02:00
Debian
96290253c8 feat(waf): Request-Body-Inspektion — Coraza sieht jetzt POST/PUT-Payloads — v1.3.6
Bisher inspizierte die WAF nur URL/Querystring + Header (SPOE sendete keinen
Body, ProcessRequestBody wurde nie aufgerufen) → blind für POST/PUT-Payloads
(Form-SQLi, JSON-Injection, Uploads). Jetzt:

- haproxy.cfg.tpl: `option http-buffer-request` im public_https-Frontend, NUR
  wenn WAF aktiv (.WAFEnabled) — kein RAM-pro-Connection-Overhead sonst.
- spoeCfg (haproxy.go): SPOE-Message sendet `body=req.body` an den Agent.
- spoe.go: Body einsammeln → tx.WriteRequestBody + tx.ProcessRequestBody nach
  der Header-Phase (vor MatchedRules-Log, damit Body-Treffer geloggt werden);
  Interruption blockt in blocking-Mode.

Coraza-Engine war schon bereit (SecRequestBodyAccess On + Limits, engine.go).
Puffer bis tune.bufsize (~16KB); größere Bodies zur Prüfung gekappt.
Render-Test: http-buffer-request nur bei WAF + vor dem SPOE-Filter; body=req.body.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-31 12:27:41 +02:00
Debian
dca40761a4 feat: CrowdSec journald-Acquisition + Alarm-Quittieren/Löschen — v1.3.5
- fix(crowdsec/packaging): postinst setzt die HAProxy-Acquisition deterministisch
  auf die journald-Unit (haproxy.service) statt der cscli-setup-Datei-Default
  (/var/log/haproxy.log existiert nicht → CrowdSec las nichts → HTTP/CVE-Szenarien
  liefen leer). Self-healing auf jedem configure; admin-Custom bleibt unangetastet.
- feat(alerts): Alarme (alert_events) bulk quittieren + löschen. Migration 0045
  (acknowledged_at + Teil-Index). Dashboard-Karte zählt nur noch OFFENE (open=true)
  → Quittieren lässt die "Aktuelle Alerts"-Meldung verschwinden, History bleibt.
  Events-Tab: Row-Selection, Quittieren/Löschen (Auswahl) + "Alle quittieren",
  Status-Spalte (offen/quittiert). Audit-geloggt.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-31 10:58:27 +02:00
Debian
0ac91a7c59 feat: per-Backend timeout server + Alerts-Deeplink + Retention + Cert-Prune — v1.3.4
- feat(backends): per-Backend `server_timeout_seconds` (nullable, Default 60s).
  Rendert `timeout server <N>s` im HAProxy-Backend-Block — für langsam
  antwortende Upstreams (KI-/Inferenz-Server mit gepufferter Antwort).
  Migration 0044 (+CHECK 1..86400), Model/Repo/Template/UI + Render-Test.
- fix(ui): Dashboard-Alert-Karte verlinkt auf /alerts?tab=events; Alerts-Seite
  respektiert ?tab= (Deeplink landete bisher auf leerem Channels-Tab).
- feat(scheduler): alert_events-Retention (90d) im täglichen Cleanup-Tick —
  Schutz vor unbounded growth der node-lokalen Health-Event-History.
- fix(cluster): Cert-Sync prunt jetzt lokale .pem die der Primary nicht mehr
  hat (Waisen gelöschter Domains); schützt _default.pem + eigenen Node-Cert.
- fix(security): x/text v0.37→v0.39 (GO-2026-5970, Infinite-Loop; via ACME+goose
  aktiv aufgerufen — govulncheck-Release-Gate).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-27 15:59:55 +02:00
67 changed files with 3071 additions and 228 deletions

View File

@@ -104,7 +104,7 @@ ui:
@echo " -> management-ui (vite build, version $(VERSION))"
@cd management-ui && \
if [ -x "$$(command -v bun)" ]; then bun install --silent && bun run build; \
else npm install --silent && npm run build; fi
else npm install --include=dev --silent && npm run build; fi
deb-amd64: release-check build-linux-amd64 ui
@./scripts/apt-repo/build-package.sh amd64 $(VERSION)
@@ -114,17 +114,18 @@ deb-arm64: release-check build-linux-arm64 ui
deb: deb-amd64 deb-arm64
GITEA_DEB_URL := https://git.netcell-it.de/api/packages/projekte/debian/pool/trixie/main/upload
# Direktes `make publish` bleibt als Handnotbremse erhalten, veröffentlicht
# aber immer nach stable — für Testing-Releases + das Stable-Promotion-
# Gate (verify_channel_debs, Version-Bump, Git-Tag) scripts/release.sh nutzen.
publish-amd64: deb-amd64
@./scripts/apt-repo/publish.sh $(VERSION) amd64
@./scripts/apt-repo/publish.sh $(VERSION) amd64 stable
@echo " -> cleanup-old (keep last $${KEEP:-10})"
@./scripts/apt-repo/cleanup-old.sh
@./scripts/apt-repo/cleanup-old.sh stable
publish-arm64: deb-arm64
@./scripts/apt-repo/publish.sh $(VERSION) arm64
@./scripts/apt-repo/publish.sh $(VERSION) arm64 stable
@echo " -> cleanup-old (keep last $${KEEP:-10})"
@./scripts/apt-repo/cleanup-old.sh
@./scripts/apt-repo/cleanup-old.sh stable
publish: publish-amd64 publish-arm64

View File

@@ -1 +1 @@
1.3.3
1.3.26

View File

@@ -6,6 +6,7 @@ package main
import (
"context"
"crypto/rand"
"errors"
"log"
"log/slog"
"net/http"
@@ -22,6 +23,7 @@ import (
"git.netcell-it.de/projekte/edgeguard-native/internal/cluster"
"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/crowdsec"
"git.netcell-it.de/projekte/edgeguard-native/internal/database"
firewallrender "git.netcell-it.de/projekte/edgeguard-native/internal/firewall"
radiusrender "git.netcell-it.de/projekte/edgeguard-native/internal/freeradius"
@@ -307,6 +309,7 @@ func main() {
"unbound": unboundrender.New(pool).RenderToString,
"chrony": chronyrender.New(pool).RenderToString,
"wireguard": wgrender.New(pool, secretsBox).RenderToString,
"crowdsec-whitelist": crowdsec.NewWhitelistGenerator(pool).RenderToString,
})
setupHdl.WithAudit(auditRepo, nodeID)
setupHdl.WithClusterSupport(clusterStore, func(ctx context.Context) error {
@@ -323,6 +326,14 @@ func main() {
return haproxy.New(pool).Render(ctx)
}
// Domain-Mutationen rendern zusätzlich die CrowdSec-Admin-Whitelist neu
// (Flag crowdsec_trusted → host-genaue Ausnahme). No-op ohne CrowdSec.
// Beide laufen unabhängig; Fehler werden zusammengefasst (nur geloggt).
crowdsecWL := crowdsec.NewWhitelistGenerator(pool)
domainsReloader := func(ctx context.Context) error {
return errors.Join(haproxy.New(pool).Render(ctx), crowdsecWL.Render(ctx))
}
authed := v1.Group("")
authed.Use(requireAuth, handlers.RequireAdminForMutations())
setupHdl.RegisterAuthed(authed)
@@ -335,7 +346,7 @@ func main() {
WithAudit(auditRepo, nodeID)
oidcHdl.RegisterPublic(v1)
oidcHdl.RegisterAdmin(authed)
handlers.NewDomainsHandler(domainsRepo, routingRepo, domainHeadersRepo, auditRepo, nodeID, haproxyReloader).Register(authed)
handlers.NewDomainsHandler(domainsRepo, routingRepo, domainHeadersRepo, auditRepo, nodeID, domainsReloader).Register(authed)
handlers.NewBackendsHandler(backendsRepo, auditRepo, nodeID, haproxyReloader).Register(authed)
handlers.NewBackendServersHandler(backendServersRepo, auditRepo, nodeID, haproxyReloader).Register(authed)
handlers.NewRoutingRulesHandler(routingRepo, auditRepo, nodeID, haproxyReloader).Register(authed)

View File

@@ -0,0 +1,230 @@
package main
import (
"fmt"
"os"
"sort"
"strings"
)
// cmdClusterReconcileReplication bringt Publication, Grants und Subscription
// idempotent in den Soll-Zustand. Verhindert die zwei Fehlermodi, die sich
// beim Nachrüsten von Features zeigen:
// - Eine `FOR TABLE`-Publication nimmt später per Migration hinzugekommene
// Shared-Tables NICHT automatisch auf → sie replizieren nie (Standby
// läuft nach Failover ohne WAF/OIDC/DHCP/RADIUS-Config).
// - Der GRANT SELECT für den Replikations-User ist ein Snapshot bei Setup;
// neue Tabellen fehlen → tablesync hängt in `d` (Permission).
//
// Rollen-Selbsterkennung (idempotent, läuft im postinst nach migrate):
//
// Publisher (hat Publication):
// - GRANT SELECT auf ALLE Tabellen (+ DEFAULT PRIVILEGES) für den
// Replikations-User.
// - Publication-Mitgliedschaft angleichen: fehlende Shared-Tables ADD,
// fälschlich enthaltene node-lokale (localOnlyTables) DROP.
// Subscriber (hat Subscription):
// - Frisch zu synchronisierende Shared-Tables lokal TRUNCATE (Primary =
// Source of Truth; verhindert Duplicate-Key beim Initial-COPY einer
// per Migration seed-befüllten Singleton-Tabelle), dann REFRESH.
// Single-Node (weder noch): nichts zu tun.
//
// Best-effort: Fehler werden geloggt, brechen aber ein Paket-Upgrade nie ab.
func cmdClusterReconcileReplication(_ []string) int {
hasPub := psqlDBBool("edgeguard",
fmt.Sprintf("SELECT EXISTS(SELECT 1 FROM pg_publication WHERE pubname='%s')", egPubName))
hasSub := psqlDBBool("edgeguard",
fmt.Sprintf("SELECT EXISTS(SELECT 1 FROM pg_subscription WHERE subname='%s')", egSubName))
switch {
case hasPub:
reconcilePublisher()
case hasSub:
reconcileSubscriber()
default:
// Standalone-Node — keine Replikation eingerichtet.
}
return 0
}
// reconcilePublisher gleicht Grants + Publication-Mitgliedschaft an.
func reconcilePublisher() {
// 1. Grants IMMER neu setzen (idempotent, deckt neue Tabellen ab).
grantSQL := fmt.Sprintf(
"GRANT SELECT ON ALL TABLES IN SCHEMA public TO %s;\n"+
"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, "reconcile: GRANT SELECT fehlgeschlagen:", err)
} else {
fmt.Printf("✓ reconcile: SELECT-Grants für %q aktualisiert\n", egReplUser)
}
// 2. Publication-Mitgliedschaft angleichen.
desired, err := desiredSharedTables()
if err != nil {
fmt.Fprintln(os.Stderr, "reconcile: Tabellen-Liste:", err)
return
}
current, err := publicationTables()
if err != nil {
fmt.Fprintln(os.Stderr, "reconcile: Publication-Liste:", err)
return
}
desiredSet := toSet(desired)
currentSet := toSet(current)
var toAdd, toDrop []string
for _, t := range desired {
if !currentSet[t] {
toAdd = append(toAdd, t)
}
}
for _, t := range current {
if !desiredSet[t] {
toDrop = append(toDrop, t) // node-lokale, die fälschlich drin sind
}
}
sort.Strings(toAdd)
sort.Strings(toDrop)
if len(toAdd) > 0 {
if err := psqlDBExec("edgeguard", fmt.Sprintf(
"ALTER PUBLICATION %s ADD TABLE %s;", egPubName, strings.Join(toAdd, ", "))); err != nil {
fmt.Fprintln(os.Stderr, "reconcile: ADD TABLE fehlgeschlagen:", err)
} else {
fmt.Printf("✓ reconcile: %d Tabelle(n) zur Publication hinzugefügt: %s\n",
len(toAdd), strings.Join(toAdd, ", "))
}
}
if len(toDrop) > 0 {
if err := psqlDBExec("edgeguard", fmt.Sprintf(
"ALTER PUBLICATION %s DROP TABLE %s;", egPubName, strings.Join(toDrop, ", "))); err != nil {
fmt.Fprintln(os.Stderr, "reconcile: DROP TABLE fehlgeschlagen:", err)
} else {
fmt.Printf("✓ reconcile: %d node-lokale Tabelle(n) aus Publication entfernt: %s\n",
len(toDrop), strings.Join(toDrop, ", "))
}
}
if len(toAdd) == 0 && len(toDrop) == 0 {
fmt.Println("✓ reconcile: Publication bereits im Soll-Zustand")
}
}
// reconcileSubscriber zieht neu publizierte Tabellen nach: erst lokal leeren
// (Primary = Source of Truth, verhindert Duplicate-Key beim Initial-COPY),
// dann REFRESH PUBLICATION. Bereits synchronisierte Tabellen bleiben unberührt.
func reconcileSubscriber() {
desired, err := desiredSharedTables()
if err != nil {
fmt.Fprintln(os.Stderr, "reconcile: Tabellen-Liste:", err)
return
}
synced, err := subscriptionRelTables()
if err != nil {
fmt.Fprintln(os.Stderr, "reconcile: subscription_rel-Liste:", err)
return
}
syncedSet := toSet(synced)
var fresh []string
for _, t := range desired {
if !syncedSet[t] {
fresh = append(fresh, t)
}
}
sort.Strings(fresh)
if len(fresh) > 0 {
// Nur frisch zu synchronisierende Shared-Tables leeren — nie eine
// bereits replizierte oder node-lokale Tabelle.
if err := psqlDBExec("edgeguard",
fmt.Sprintf("TRUNCATE %s;", strings.Join(fresh, ", "))); err != nil {
fmt.Fprintln(os.Stderr, "reconcile: TRUNCATE (neue Tabellen) fehlgeschlagen:", err)
} else {
fmt.Printf("✓ reconcile: %d neue Tabelle(n) für Initial-Sync geleert: %s\n",
len(fresh), strings.Join(fresh, ", "))
}
}
// REFRESH ist NICHT transaktionssicher → einzelnes Statement, autocommit.
if err := psqlDBExec("edgeguard",
fmt.Sprintf("ALTER SUBSCRIPTION %s REFRESH PUBLICATION;", egSubName)); err != nil {
fmt.Fprintln(os.Stderr, "reconcile: REFRESH PUBLICATION fehlgeschlagen:", err)
} else {
fmt.Printf("✓ reconcile: Subscription %q refresht\n", egSubName)
}
}
// desiredSharedTables = alle public-Tabellen minus localOnlyTables.
func desiredSharedTables() ([]string, error) {
out, err := psqlDBRun("edgeguard", []string{"-tA", "-c",
`SELECT tablename FROM pg_tables WHERE schemaname='public'`})
if err != nil {
return nil, fmt.Errorf("list tables: %w", err)
}
return filterSharedTables(splitLines(string(out))), nil
}
// filterSharedTables entfernt localOnlyTables aus der Tabellenliste. Pure
// Funktion — unit-testbar.
func filterSharedTables(all []string) []string {
excluded := toSet(localOnlyTables)
var out []string
for _, t := range all {
if t != "" && !excluded[t] {
out = append(out, t)
}
}
sort.Strings(out)
return out
}
// publicationTables listet die aktuell in edgeguard_shared publizierten Tabellen.
func publicationTables() ([]string, error) {
out, err := psqlDBRun("edgeguard", []string{"-tA", "-c",
fmt.Sprintf("SELECT tablename FROM pg_publication_tables WHERE pubname='%s'", egPubName)})
if err != nil {
return nil, err
}
return splitLines(string(out)), nil
}
// subscriptionRelTables listet die Tabellen, die die Subscription bereits kennt.
func subscriptionRelTables() ([]string, error) {
out, err := psqlDBRun("edgeguard", []string{"-tA", "-c",
fmt.Sprintf(`SELECT c.relname FROM pg_subscription_rel r
JOIN pg_class c ON c.oid = r.srrelid
JOIN pg_subscription s ON s.oid = r.srsubid
WHERE s.subname = '%s'`, egSubName)})
if err != nil {
return nil, err
}
return splitLines(string(out)), nil
}
func psqlDBBool(db, sql string) bool {
out, err := psqlDBRun(db, []string{"-tA", "-c", sql})
if err != nil {
return false
}
return strings.TrimSpace(string(out)) == "t"
}
func splitLines(s string) []string {
var out []string
for _, l := range strings.Split(strings.TrimSpace(s), "\n") {
if l = strings.TrimSpace(l); l != "" {
out = append(out, l)
}
}
return out
}
func toSet(items []string) map[string]bool {
m := make(map[string]bool, len(items))
for _, it := range items {
m[it] = true
}
return m
}

View File

@@ -0,0 +1,27 @@
package main
import (
"reflect"
"testing"
)
func TestFilterSharedTables(t *testing.T) {
all := []string{
"backends", "domains", "waf_configs", "oidc_settings",
"ip_addresses", "network_interfaces", "alert_events", "waf_alerts",
"ha_nodes", "goose_db_version", "radius_users", "",
}
got := filterSharedTables(all)
want := []string{"backends", "domains", "oidc_settings", "radius_users", "waf_configs"}
if !reflect.DeepEqual(got, want) {
t.Errorf("filterSharedTables()\n got=%v\nwant=%v", got, want)
}
// node-lokale müssen raus sein (inkl. der frisch node-lokal gemachten).
for _, local := range []string{"ip_addresses", "network_interfaces", "alert_events", "waf_alerts", "ha_nodes", "goose_db_version"} {
for _, g := range got {
if g == local {
t.Errorf("localOnly-Tabelle %q darf NICHT in shared-Liste sein", local)
}
}
}
}

View File

@@ -87,6 +87,7 @@ var localOnlyTables = []string{
"join_tokens_used", // Token-Tracking nur auf Primary relevant
"audit_log", // Lokales Audit-Protokoll
"alert_events", // Lokale Laufzeit-Events
"waf_alerts", // Lokale WAF-Detection-Events (wie alert_events)
"backups", // Backup-Historie ist per-Node
"goose_db_version", // Migration-Tracking, internes Tool-State
}

View File

@@ -74,6 +74,8 @@ func main() {
os.Exit(cmdClusterInitReplication(os.Args[2:]))
case "cluster-setup-standby":
os.Exit(cmdClusterSetupStandby(os.Args[2:]))
case "cluster-reconcile-replication":
os.Exit(cmdClusterReconcileReplication(os.Args[2:]))
case "promote":
os.Exit(cmdPromote(os.Args[2:]))
case "cluster-leave", "dump-config":

View File

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

View File

@@ -104,6 +104,12 @@ const (
auditCleanupInterval = 24 * time.Hour
auditRetentionDays = 90
// alertRetentionDays — alert_events wächst sonst unbegrenzt (node-lokale
// Health-Events: backend.down, mem.high, cert.expiring …). Läuft im
// selben täglichen Tick wie der Audit-Cleanup. Fester Default, kein
// Setup-Override (Events sind reine Diagnose-History).
alertRetentionDays = 90
// backendDownCheckInterval — alle 2 Minuten HAProxy-Stats lesen und
// prüfen ob ein Backend komplett ausgefallen ist (alle Server DOWN).
// Dedupe 12h pro Backend → kein Alert-Spam. Frischer Alert wenn das
@@ -185,7 +191,8 @@ func main() {
auditRepo := audit.New(pool)
alertDedupe := newDedupe(12 * time.Hour)
if renewer != nil {
// ACME nur auf dem VIP-Master (siehe Tick-Kommentar unten).
if renewer != nil && nodeHoldsVIP(ctx, pool) {
runRenewer(ctx, renewer, alertSvc, alertDedupe)
}
runLicenseVerify(ctx, licClient, licKeyStore, licRepo, nodeID, alertSvc, alertDedupe)
@@ -252,7 +259,13 @@ func main() {
for {
select {
case <-renewTick.C:
if renewer != nil {
// ACME-HTTP-01-Challenges laufen auf :80 der VIP → nur der
// VIP-Master kann sie bestehen. Ein BACKUP-Node scheitert IMMER
// mit 403 (invalid authorization) und setzt tls_certs.status lokal
// auf "error" → Divergenz zur replizierten Row (Primary=active) →
// Config-Drift-Banner + Log-Noise. Renewal daher nur am VIP-Master;
// die Cert-Row/PEM repliziert von dort ohnehin auf den Standby.
if renewer != nil && nodeHoldsVIP(ctx, pool) {
runRenewer(ctx, renewer, alertSvc, alertDedupe)
}
runCertExpiryCheck(ctx, tlsRepo, alertSvc, alertDedupe)
@@ -272,6 +285,7 @@ func main() {
runDiskCheck(ctx, alertSvc, alertDedupe)
case <-auditTick.C:
runAuditCleanup(ctx, auditRepo, setupStore)
runAlertCleanup(ctx, alertSvc)
case <-backendDownTick.C:
runBackendDownCheck(ctx, pool, alertSvc, alertDedupe)
case <-memTick.C:
@@ -317,6 +331,29 @@ func runAuditCleanup(ctx context.Context, r *audit.Repo, setupStore *setup.Store
}
}
// runAlertCleanup löscht alert_events älter als alertRetentionDays.
// Schutz vor unbounded growth — auf einer aktiven Box feuern backend.down/
// mem.high/cert.expiring über Monate tausende Rows (die Tabelle ist
// node-lokal, wird also nirgends sonst abgeräumt). Best-effort: Fehler
// werden nur geloggt.
func runAlertCleanup(ctx context.Context, a *alerts.Service) {
if a == nil {
return
}
cctx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
n, err := a.Cleanup(cctx, alertRetentionDays)
if err != nil {
slog.Warn("scheduler: alert cleanup failed",
"keep_days", alertRetentionDays, "error", err)
return
}
if n > 0 {
slog.Info("scheduler: alert cleanup",
"deleted", n, "keep_days", alertRetentionDays)
}
}
// runDiskCheck prüft die Belegung von / via statfs. Fire-Schwellen:
// - >= 90% → Critical (error). Box ist akut gefährdet — beim
// nächsten Backup-Run oder größeren apt-Update droht "no space
@@ -592,7 +629,7 @@ func runWGClientTunnelCheck(ctx context.Context, pool *pgxpool.Pool, a *alerts.S
// Alle aktiven Client-Interfaces aus DB laden.
type wgIface struct{ name string }
rows, err := pool.Query(ctx,
`SELECT name FROM wg_interfaces WHERE mode = 'client' AND active = true ORDER BY name`)
`SELECT name FROM wireguard_interfaces WHERE mode = 'client' AND active = true ORDER BY name`)
if err != nil {
return
}
@@ -663,6 +700,56 @@ func runWGClientTunnelCheck(ctx context.Context, pool *pgxpool.Pool, a *alerts.S
var egBackendRE = regexp.MustCompile(`^eg_backend_(\d+)$`)
// nodeHoldsVIP meldet true, wenn dieser Node aktuell mindestens eine
// is_vip-Adresse lokal trägt — also der keepalived-MASTER ist. Nur der
// Master hält die VLAN-Gateway-VIPs und erreicht damit die Backend-
// Subnetze; ein BACKUP-Node hat KEINE VLAN-IP und sieht deshalb JEDES
// Backend als L4-down. Spiegelt SystemHandler.VIPStatus (net.Interfaces,
// kein Shell-out).
func nodeHoldsVIP(ctx context.Context, pool *pgxpool.Pool) bool {
if pool == nil {
return false
}
rows, err := pool.Query(ctx,
`SELECT address FROM ip_addresses WHERE is_vip = true AND active = true`)
if err != nil {
return false
}
defer rows.Close()
var vips []string
for rows.Next() {
var addr string
if err := rows.Scan(&addr); err == nil {
vips = append(vips, addr)
}
}
if len(vips) == 0 {
return false
}
local := make(map[string]bool)
ifaces, err := net.Interfaces()
if err != nil {
return false
}
for _, ifc := range ifaces {
addrs, err := ifc.Addrs()
if err != nil {
continue
}
for _, a := range addrs {
if ipnet, ok := a.(*net.IPNet); ok {
local[ipnet.IP.String()] = true
}
}
}
for _, v := range vips {
if local[v] {
return true
}
}
return false
}
// runBackendDownCheck liest HAProxy-Stats via Admin-Socket und feuert
// einen Error-Alert für jedes Backend bei dem alle Server DOWN sind
// (und mind. einer einen echten Health-Check hat). Dedupe 12h pro Backend.
@@ -670,6 +757,14 @@ func runBackendDownCheck(ctx context.Context, pool *pgxpool.Pool, a *alerts.Serv
if a == nil || d == nil {
return
}
// Nur auf dem VIP-Master prüfen. Ein BACKUP-Node hält die VLAN-
// Gateway-VIPs nicht und kann die Backend-Subnetze gar nicht erreichen
// → jeder Health-Check läuft L4TOUT → Dauer-"backend.down"-Fehlalarm
// (Hauptquelle des alert_events-Spams). Der Master bedient den Traffic
// und sieht die echten Backend-States.
if !nodeHoldsVIP(ctx, pool) {
return
}
dialer := net.Dialer{Timeout: 2 * time.Second}
conn, err := dialer.DialContext(ctx, "unix", "/run/haproxy/admin.sock")
if err != nil {

16
go.mod
View File

@@ -2,6 +2,8 @@ module git.netcell-it.de/projekte/edgeguard-native
go 1.26.4
toolchain go1.26.6
require (
github.com/corazawaf/coraza/v3 v3.7.0
github.com/coreos/go-oidc/v3 v3.18.0
@@ -16,7 +18,7 @@ require (
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.52.0
golang.org/x/crypto v0.56.0
golang.org/x/oauth2 v0.36.0
)
@@ -78,12 +80,12 @@ require (
go.uber.org/multierr v1.11.0 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/arch v0.8.0 // indirect
golang.org/x/mod v0.35.0 // indirect
golang.org/x/net v0.55.0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.45.0 // indirect
golang.org/x/text v0.37.0 // indirect
golang.org/x/tools v0.44.0 // indirect
golang.org/x/mod v0.38.0 // indirect
golang.org/x/net v0.57.0 // indirect
golang.org/x/sync v0.22.0 // indirect
golang.org/x/sys v0.47.0 // indirect
golang.org/x/text v0.41.0 // indirect
golang.org/x/tools v0.48.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

32
go.sum
View File

@@ -186,24 +186,24 @@ go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc=
golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988=
golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc=
golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM=
golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU=
golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
golang.org/x/crypto v0.56.0 h1:GUh5Ii4J5jtcseSMiRqr1jXCNHoxjeV9Fmekc2oLy6Y=
golang.org/x/crypto v0.56.0/go.mod h1:OMW5y6CY9l38uPLmxU6l6pwcXp1obtLo3e6gT7gQR2I=
golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk=
golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40=
golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE=
golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU=
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
golang.org/x/sys v0.45.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=
golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI=
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=
golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w=
golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE=
golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=

View File

@@ -0,0 +1,107 @@
package crowdsec
import (
"bytes"
"context"
"fmt"
"os"
"strings"
"github.com/jackc/pgx/v5/pgxpool"
"git.netcell-it.de/projekte/edgeguard-native/internal/configgen"
)
// WhitelistPath ist die aus dem Domain-Flag crowdsec_trusted gerenderte
// CrowdSec-Parser-Whitelist. s02-enrich läuft vor den Scenarios, sodass
// whitelisted Events gar nicht erst in http-crawl-non_statics o. Ä. zählen.
const WhitelistPath = "/etc/crowdsec/parsers/s02-enrich/edgeguard-admin-hosts-whitelist.yaml"
// WhitelistGenerator rendert eine host-genaue CrowdSec-Whitelist aus allen
// Domains mit crowdsec_trusted=true. Vertrauenswürdige Admin-Panels (SPAs, die
// pro Aktion viele /api/-Requests feuern) würden sonst das Scenario
// http-crawl-non_statics auslösen und die Admin-IP bannen. No-op, wenn CrowdSec auf diesem Node nicht
// installiert ist (managed-wenn-installiert).
type WhitelistGenerator struct {
pool *pgxpool.Pool
SkipReload bool
}
func NewWhitelistGenerator(pool *pgxpool.Pool) *WhitelistGenerator {
return &WhitelistGenerator{pool: pool}
}
func (g *WhitelistGenerator) Name() string { return "crowdsec-whitelist" }
func (g *WhitelistGenerator) Render(ctx context.Context) error {
// Managed-wenn-installiert: ohne CrowdSec kein Whitelist-File.
if !IsInstalled() {
return nil
}
hosts, err := g.trustedHosts(ctx)
if err != nil {
return fmt.Errorf("crowdsec-whitelist: query: %w", err)
}
// Direktes Schreiben (kein tmp+rename): /etc/crowdsec/parsers/... ist
// root-owned, edgeguard darf nur die eine (postinst-chownte) Datei
// überschreiben — analog chrony/unbound.
if err := os.WriteFile(WhitelistPath, renderWhitelist(hosts), 0o644); err != nil {
return fmt.Errorf("crowdsec-whitelist: write %s: %w", WhitelistPath, err)
}
if g.SkipReload {
return nil
}
return configgen.ReloadService("crowdsec")
}
// RenderToString gibt die gerenderte Whitelist zurück (Config-Preview), ohne zu
// schreiben oder zu reloaden.
func (g *WhitelistGenerator) RenderToString(ctx context.Context) (string, error) {
hosts, err := g.trustedHosts(ctx)
if err != nil {
return "", err
}
return string(renderWhitelist(hosts)), nil
}
func (g *WhitelistGenerator) trustedHosts(ctx context.Context) ([]string, error) {
rows, err := g.pool.Query(ctx,
`SELECT name FROM domains WHERE crowdsec_trusted = true AND active = true ORDER BY name`)
if err != nil {
return nil, err
}
defer rows.Close()
var hosts []string
for rows.Next() {
var n string
if err := rows.Scan(&n); err != nil {
return nil, err
}
hosts = append(hosts, n)
}
return hosts, rows.Err()
}
// renderWhitelist baut die CrowdSec-Parser-Whitelist-YAML. Ohne vertrauens-
// würdige Hosts bleibt die Ausdrucksliste leer → `in []` matcht nie → es wird
// nichts whitelisted (Datei bleibt gültig). Pure Funktion (testbar).
func renderWhitelist(hosts []string) []byte {
quoted := make([]string, 0, len(hosts))
for _, h := range hosts {
h = strings.TrimSpace(h)
if h == "" {
continue
}
// Einfachquote + eingebettete Quotes verdoppeln (expr-String-Literal).
quoted = append(quoted, "'"+strings.ReplaceAll(h, "'", "''")+"'")
}
var b bytes.Buffer
b.WriteString("# Generated by edgeguard-api from domains.crowdsec_trusted. DO NOT EDIT.\n")
b.WriteString("name: edgeguard/admin-hosts-whitelist\n")
b.WriteString("description: Trusted admin panels (SPA fires many /api/ requests) exempted from CrowdSec - not a crawl.\n")
b.WriteString("whitelist:\n")
b.WriteString(" reason: edgeguard trusted admin host (SPA, not crawl/probing)\n")
b.WriteString(" expression:\n")
fmt.Fprintf(&b, " - \"evt.Parsed.http_host in [%s]\"\n", strings.Join(quoted, ", "))
return b.Bytes()
}

View File

@@ -0,0 +1,35 @@
package crowdsec
import (
"strings"
"testing"
)
func TestRenderWhitelist(t *testing.T) {
t.Run("hosts werden host-genau eingetragen", func(t *testing.T) {
out := string(renderWhitelist([]string{"control.netcell-it.de", "admin.example.com"}))
if !strings.Contains(out, "evt.Parsed.http_host in ['control.netcell-it.de', 'admin.example.com']") {
t.Fatalf("erwartete host-Liste fehlt:\n%s", out)
}
if !strings.Contains(out, "name: edgeguard/admin-hosts-whitelist") {
t.Fatalf("Parser-Name fehlt:\n%s", out)
}
})
t.Run("leere Liste → in [] (matcht nie, Datei gültig)", func(t *testing.T) {
out := string(renderWhitelist(nil))
if !strings.Contains(out, "evt.Parsed.http_host in []") {
t.Fatalf("erwarte leeres in []:\n%s", out)
}
})
t.Run("leere/whitespace-Hosts werden gefiltert, Quotes escaped", func(t *testing.T) {
out := string(renderWhitelist([]string{" ", "a'b.de", ""}))
if !strings.Contains(out, "'a''b.de'") {
t.Fatalf("Quote-Escaping falsch:\n%s", out)
}
if strings.Contains(out, "'', ") || strings.Contains(out, "[''") {
t.Fatalf("leere Hosts nicht gefiltert:\n%s", out)
}
})
}

View File

@@ -0,0 +1,27 @@
-- +goose Up
-- +goose StatementBegin
-- Per-Backend `timeout server` (Sekunden). NULL = defaults-Timeout (60s,
-- siehe haproxy.cfg.tpl). Gedacht für Upstreams die LANGE für die Antwort
-- brauchen und dabei NICHT streamen — z. B. KI-/Inferenz-Server, die eine
-- gepufferte Antwort erst nach Minuten schicken. Ohne Override kappt der
-- 60s-defaults-Timeout diese Requests.
--
-- Bewusst NULL-per-default: Backends ohne Langläufer-Workload behalten den
-- kurzen Timeout (Connection-Hygiene / Slowloris-Schutz, vgl. v1.3.2).
-- Der Renderer setzt `timeout server <N>s` NUR wenn ein Wert gesetzt ist.
--
-- CHECK 1..86400: mind. 1s, max. 24h — verhindert 0/negativ (würde HAProxy-
-- Config sprengen bzw. „unendlich" bedeuten) und absurd hohe Werte.
ALTER TABLE backends
ADD COLUMN IF NOT EXISTS server_timeout_seconds INTEGER
CONSTRAINT backends_server_timeout_range
CHECK (server_timeout_seconds IS NULL
OR (server_timeout_seconds BETWEEN 1 AND 86400));
-- +goose StatementEnd
-- +goose Down
-- +goose StatementBegin
ALTER TABLE backends DROP COLUMN IF EXISTS server_timeout_seconds;
-- +goose StatementEnd

View File

@@ -0,0 +1,22 @@
-- +goose Up
-- +goose StatementBegin
-- Quittieren von Alarmen. acknowledged_at = NULL → offen (zählt im Dashboard).
-- Gesetzt → quittiert (bleibt als History sichtbar, zählt aber nicht mehr auf
-- der Startseiten-Karte "Aktuelle Alerts"). alert_events ist node-lokal
-- (localOnlyTables) → kein Replikations-Effekt.
ALTER TABLE alert_events
ADD COLUMN IF NOT EXISTS acknowledged_at TIMESTAMPTZ;
-- Teil-Index für den Dashboard-Query (nur offene, newest-first).
CREATE INDEX IF NOT EXISTS idx_alert_events_open
ON alert_events (fired_at DESC)
WHERE acknowledged_at IS NULL;
-- +goose StatementEnd
-- +goose Down
-- +goose StatementBegin
DROP INDEX IF EXISTS idx_alert_events_open;
ALTER TABLE alert_events DROP COLUMN IF EXISTS acknowledged_at;
-- +goose StatementEnd

View File

@@ -0,0 +1,19 @@
-- +goose Up
-- +goose StatementBegin
-- CRS-App-Exclusion-Plugins pro Domain (OWASP-CRS-Plugin-System). Liste von
-- Plugin-Namen (z. B. 'nextcloud','wordpress','drupal'). Der WAF-Renderer
-- inkludiert je gewähltem Plugin dessen config/before/after-Dateien aus
-- <crsDir>/plugins/ an den korrekten Punkten (config+before VOR den CRS-Rules,
-- after DANACH) → pfad-genaue, upstream-gepflegte App-Ausnahmen statt manueller
-- SecRuleRemoveById-IDs. waf_configs ist repliziert; der Renderer läuft pro
-- Node lokal, daher kein Cross-Node-Effekt außer der Config selbst.
ALTER TABLE waf_configs
ADD COLUMN IF NOT EXISTS crs_plugins TEXT[] NOT NULL DEFAULT '{}';
-- +goose StatementEnd
-- +goose Down
-- +goose StatementBegin
ALTER TABLE waf_configs DROP COLUMN IF EXISTS crs_plugins;
-- +goose StatementEnd

View File

@@ -0,0 +1,34 @@
-- +goose Up
-- +goose StatementBegin
-- Benutzerdefinierte WAF-App-Profile: benannte, wiederverwendbare Bündel von
-- CRS-Rule-Exclusions (reine Rule-IDs/Ranges — keine SecLang-Ausführung, sicher).
-- Wirken wie die eingebauten OWASP-Plugins, sind aber im UI erstellbar/editierbar
-- und werden pro Domain zugewiesen (waf_configs.app_profiles). Die Auflösung in
-- effektive SecRuleRemoveById-Zeilen passiert im WAF-Agent (ListAllWithDomain).
--
-- Repliziert (Config, kein node-lokaler Zustand) → vom cluster-reconcile
-- automatisch in edgeguard_shared aufgenommen (nicht in localOnlyTables).
CREATE TABLE IF NOT EXISTS waf_app_profiles (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
description TEXT NOT NULL DEFAULT '',
rule_exclusions TEXT[] NOT NULL DEFAULT '{}',
builtin BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Zuweisung Profil→Domain: Liste von Profil-Namen je waf_config. Beim Bauen der
-- Engine werden ihre rule_exclusions in die effektiven Ausnahmen der Domain
-- gemischt (zusätzlich zu den domain-eigenen rule_exclusions).
ALTER TABLE waf_configs
ADD COLUMN IF NOT EXISTS app_profiles TEXT[] NOT NULL DEFAULT '{}';
-- +goose StatementEnd
-- +goose Down
-- +goose StatementBegin
ALTER TABLE waf_configs DROP COLUMN IF EXISTS app_profiles;
DROP TABLE IF EXISTS waf_app_profiles;
-- +goose StatementEnd

View File

@@ -0,0 +1,18 @@
-- +goose Up
-- +goose StatementBegin
-- Pro-Domain-Flag: vertrauenswuerdiges Admin-Panel → von CrowdSec ausnehmen.
-- Admin-SPAs feuern viele /api/-Requests pro Aktion und triggern sonst das
-- Scenario http-crawl-non_statics (False-Positive-Ban der Admin-IP, die oft
-- dynamisch ist). Der crowdsec-Whitelist-Renderer schreibt aus allen Domains
-- mit crowdsec_trusted=true eine host-genaue CrowdSec-Parser-Whitelist
-- (evt.Parsed.http_host). Repliziert (Config, node-lokal gerendert) → ueberlebt
-- auch einen Node-Neuaufbau, weil aus der DB gerendert.
ALTER TABLE domains ADD COLUMN IF NOT EXISTS crowdsec_trusted BOOLEAN NOT NULL DEFAULT false;
-- +goose StatementEnd
-- +goose Down
-- +goose StatementBegin
ALTER TABLE domains DROP COLUMN IF EXISTS crowdsec_trusted;
-- +goose StatementEnd

View File

@@ -18,7 +18,10 @@ import (
// PUT /api/v1/alerts/channels/:id
// DELETE /api/v1/alerts/channels/:id
// POST /api/v1/alerts/test — Test-Event in alle aktiven Channels
// GET /api/v1/alerts/events?limit=N — History
// GET /api/v1/alerts/events?limit=N&open=true — History (open=nur offene)
// POST /api/v1/alerts/events/acknowledge — Bulk-Quittieren {ids:[…]}
// POST /api/v1/alerts/events/acknowledge-all — alle offenen quittieren
// POST /api/v1/alerts/events/delete — Bulk-Löschen {ids:[…]}
type AlertsHandler struct {
Service *alerts.Service
Audit *audit.Repo
@@ -37,6 +40,9 @@ func (h *AlertsHandler) Register(rg *gin.RouterGroup) {
g.DELETE("/channels/:id", h.DeleteChannel)
g.POST("/test", h.TestFire)
g.GET("/events", h.ListEvents)
g.POST("/events/acknowledge", h.AcknowledgeEvents)
g.POST("/events/acknowledge-all", h.AcknowledgeAllEvents)
g.POST("/events/delete", h.DeleteEvents)
}
func (h *AlertsHandler) ListChannels(c *gin.Context) {
@@ -125,10 +131,64 @@ func (h *AlertsHandler) ListEvents(c *gin.Context) {
limit = n
}
}
out, err := h.Service.ListEvents(c.Request.Context(), limit)
// ?open=true → nur offene (nicht quittierte) Events. Nutzt die
// Dashboard-Karte, damit Quittieren die Meldung verschwinden lässt.
openOnly := c.Query("open") == "true"
out, err := h.Service.ListEvents(c.Request.Context(), limit, openOnly)
if err != nil {
response.Internal(c, err)
return
}
response.OK(c, gin.H{"events": out})
}
// eventIDsRequest ist der Body für Bulk-Quittieren/-Löschen.
type eventIDsRequest struct {
IDs []int64 `json:"ids"`
}
// AcknowledgeEvents quittiert die übergebenen Event-IDs.
func (h *AlertsHandler) AcknowledgeEvents(c *gin.Context) {
var req eventIDsRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, err)
return
}
n, err := h.Service.Acknowledge(c.Request.Context(), req.IDs)
if err != nil {
response.Internal(c, err)
return
}
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "alert.events.acknowledge",
strconv.Itoa(len(req.IDs)), gin.H{"ids": req.IDs, "acknowledged": n}, h.NodeID)
response.OK(c, gin.H{"acknowledged": n})
}
// AcknowledgeAllEvents quittiert alle offenen Events.
func (h *AlertsHandler) AcknowledgeAllEvents(c *gin.Context) {
n, err := h.Service.AcknowledgeAll(c.Request.Context())
if err != nil {
response.Internal(c, err)
return
}
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "alert.events.acknowledge_all",
"all", gin.H{"acknowledged": n}, h.NodeID)
response.OK(c, gin.H{"acknowledged": n})
}
// DeleteEvents löscht die übergebenen Event-IDs endgültig.
func (h *AlertsHandler) DeleteEvents(c *gin.Context) {
var req eventIDsRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, err)
return
}
n, err := h.Service.DeleteEvents(c.Request.Context(), req.IDs)
if err != nil {
response.Internal(c, err)
return
}
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "alert.events.delete",
strconv.Itoa(len(req.IDs)), gin.H{"ids": req.IDs, "deleted": n}, h.NodeID)
response.OK(c, gin.H{"deleted": n})
}

View File

@@ -21,6 +21,7 @@ import (
"git.netcell-it.de/projekte/edgeguard-native/internal/cluster/jointoken"
"git.netcell-it.de/projekte/edgeguard-native/internal/handlers/response"
"git.netcell-it.de/projekte/edgeguard-native/internal/models"
aptsvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/apt"
"git.netcell-it.de/projekte/edgeguard-native/internal/services/audit"
)
@@ -92,6 +93,8 @@ func (h *ClusterHandler) Register(rg *gin.RouterGroup) {
g.GET("/repair-replication/status", h.RepairReplicationStatus)
g.GET("/vip-status", h.VIPStatus)
g.POST("/vip-test", h.VIPTest)
g.GET("/update-channel", h.UpdateChannel)
g.POST("/update-channel", h.SetUpdateChannel)
if h.TLSStore != nil {
g.GET("/cert-status", h.CertStatus)
g.POST("/renew-self", h.RenewSelf)
@@ -247,6 +250,8 @@ func (h *ClusterHandler) RegisterAgent(rg *gin.RouterGroup) {
g.GET("/master-key", h.AgentMasterKey)
g.GET("/version", h.AgentVersion)
g.POST("/trigger-update", h.AgentTriggerUpdate)
g.POST("/set-channel", h.AgentSetChannel)
g.GET("/channel", h.AgentChannel)
g.GET("/active-ips", h.AgentActiveIPs)
g.POST("/vip-cmd", h.AgentVIPCmd)
g.GET("/tls-certs", h.AgentTLSCerts)
@@ -758,7 +763,10 @@ retry_apt() {
while [ $attempt -lt $max ]; do
attempt=$((attempt + 1))
apt-get update -qq || true
if apt-get install -y -qq -o Dpkg::Options::=--force-confold \
# --allow-downgrades: nur relevant nach testing→stable-Kanalwechsel
# (Testing-Versionen sortieren datumsbasiert höher als Stable-Semver).
# No-Op im Normalfall, da die Candidate sonst immer >= installed ist.
if apt-get install -y -qq --allow-downgrades -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
@@ -789,6 +797,131 @@ rm -f /var/lib/edgeguard/upgrade.sh
c.JSON(http.StatusAccepted, gin.H{"status": "upgrading"})
}
// ── Update-Kanal (stable/testing) ──────────────────────────────────────
//
// Kanal-Modell wie enconf (Suite=Codename, Komponente=Kanal, siehe
// internal/services/apt.Channel/SetChannel) — an EdgeGuards fixes
// Primary/Standby-Paar angepasst statt generischer Server-Flotte: der
// Kanal wird auf beiden Nodes synchron gehalten (wie config_hash),
// kein Node-Override. Reines Umschreiben der sources.list + `apt-get
// update` ist risikofrei (kein Service-Restart, keine VIP-Auswirkung)
// — das eigentliche Downgrade/Upgrade auf die neue Kanal-Version läuft
// danach ganz normal über den bestehenden (sicheren, Standby-zuerst)
// Rolling-Update-Flow, der --allow-downgrades jetzt mit unterstützt.
type updateChannelResponse struct {
Channel string `json:"channel"`
PeerChannel string `json:"peer_channel,omitempty"`
PeerReached bool `json:"peer_reached"`
PeerDrifted bool `json:"peer_drifted"`
}
// UpdateChannel liefert den lokalen Kanal + (falls Cluster) den Kanal
// des Peers zur Drift-Erkennung — analog zum config_hash-Vergleich.
func (h *ClusterHandler) UpdateChannel(c *gin.Context) {
resp := updateChannelResponse{Channel: aptsvc.Channel()}
peer := h.peerNode(c.Request.Context())
if peer != nil && h.Aggregator != nil {
results := h.Aggregator.FanOut(c.Request.Context(), []models.HANode{*peer}, "/agent/cluster/channel", h.LocalID)
if len(results) > 0 && results[0].OK {
var body struct {
Channel string `json:"channel"`
}
if json.Unmarshal(results[0].Data, &body) == nil {
resp.PeerReached = true
resp.PeerChannel = body.Channel
resp.PeerDrifted = body.Channel != resp.Channel
}
}
}
response.OK(c, resp)
}
// SetUpdateChannel setzt den Kanal lokal und — falls ein Peer existiert
// — synchron auch auf dem Peer via mTLS. Löst KEIN Paket-Update aus;
// das übernimmt der Admin danach ganz normal über den Update-Banner /
// Rolling-Update, der die neue Candidate-Version dann bereits sieht.
func (h *ClusterHandler) SetUpdateChannel(c *gin.Context) {
var req struct {
Channel string `json:"channel"`
}
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, err)
return
}
if req.Channel != "stable" && req.Channel != "testing" {
response.BadRequest(c, fmt.Errorf("channel must be 'stable' or 'testing'"))
return
}
if err := aptsvc.SetChannel(c.Request.Context(), req.Channel); err != nil {
response.Internal(c, err)
return
}
resp := updateChannelResponse{Channel: req.Channel}
if peer := h.peerNode(c.Request.Context()); peer != nil && h.Aggregator != nil {
body, _ := json.Marshal(req)
result := h.Aggregator.PostPeerWithBody(c.Request.Context(), *peer, "/agent/cluster/set-channel", body)
resp.PeerReached = result.OK
if !result.OK {
slog.Warn("cluster: set-channel on peer failed", "peer", peer.FQDN, "error", result.Err)
}
}
if h.Audit != nil {
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "system.update_channel.set",
"", gin.H{"channel": req.Channel}, h.NodeID)
}
response.OK(c, resp)
}
// AgentChannel: mTLS-Peer-Read des lokalen Kanals (für Drift-Anzeige).
func (h *ClusterHandler) AgentChannel(c *gin.Context) {
response.OK(c, gin.H{"channel": aptsvc.Channel()})
}
// AgentSetChannel: mTLS-Peer-Write — wird vom Primary aufgerufen um den
// Kanal auf diesem (Standby-)Node synchron zu setzen.
func (h *ClusterHandler) AgentSetChannel(c *gin.Context) {
var req struct {
Channel string `json:"channel"`
}
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, err)
return
}
if req.Channel != "stable" && req.Channel != "testing" {
response.BadRequest(c, fmt.Errorf("channel must be 'stable' or 'testing'"))
return
}
if err := aptsvc.SetChannel(c.Request.Context(), req.Channel); err != nil {
response.Internal(c, err)
return
}
slog.Info("cluster: update channel set on this node by primary mTLS call",
"channel", req.Channel, "client", c.ClientIP())
response.OK(c, gin.H{"channel": req.Channel})
}
// peerNode liefert die einzige andere ha_nodes-Row (best-effort, nil
// wenn Standalone oder Store fehlt) — gleiches Muster wie in
// RollingUpdate für die Secondary-Ermittlung.
func (h *ClusterHandler) peerNode(ctx context.Context) *models.HANode {
if h.Store == nil {
return nil
}
nodes, err := h.Store.List(ctx)
if err != nil {
return nil
}
for i := range nodes {
if nodes[i].ID != h.LocalID {
return &nodes[i]
}
}
return nil
}
var errInvalidJoinRequest = simpleError("missing token or csr")
type simpleError string

View File

@@ -109,6 +109,40 @@ func SyncTLSCertsFromPrimary(ctx context.Context, pool *pgxpool.Pool, agg *aggre
slog.Info("cert-sync: updated", "file", name)
}
// Prune: lokale .pem entfernen, die der Primary NICHT (mehr) hat.
// Ohne diesen Schritt bleiben Zertifikate gelöschter Domains auf dem
// Secondary als Waisen liegen — der Sync oben ist write-only, „nicht
// mitgeschickt" ≠ „gelöscht". Geschützt bleiben:
// _default.pem — Self-Signed-Fallback
// <lokaler-FQDN>.pem — eigener Node-Cert (steht NICHT im Primary-Payload)
// Nur prunen wenn der Payload nicht leer ist — Schutz gegen ein
// versehentliches Leerräumen bei unvollständiger Primary-Antwort.
if len(payload.Certs) > 0 {
protected := map[string]bool{"_default.pem": true}
var localFQDN string
if err := pool.QueryRow(ctx,
`SELECT fqdn FROM ha_nodes WHERE id = $1`, localID).Scan(&localFQDN); err == nil && localFQDN != "" {
protected[localFQDN+".pem"] = true
}
if entries, err := os.ReadDir(tlsCertDir); err == nil {
for _, e := range entries {
name := e.Name()
if e.IsDir() || !strings.HasSuffix(name, ".pem") || protected[name] {
continue
}
if _, ok := payload.Certs[name]; ok {
continue // vom Primary gepflegt — behalten
}
if err := os.Remove(filepath.Join(tlsCertDir, name)); err != nil {
slog.Warn("cert-sync: prune failed", "file", name, "error", err)
continue
}
changed = true
slog.Info("cert-sync: pruned orphan", "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)

View File

@@ -273,7 +273,10 @@ retry_apt() {
while [ $attempt -lt $max ]; do
attempt=$((attempt + 1))
apt-get update -qq || true
if apt-get install -y -qq -o Dpkg::Options::=--force-confold \
# --allow-downgrades: nur relevant nach testing→stable-Kanalwechsel
# (Testing-Versionen sortieren datumsbasiert höher als Stable-Semver).
# No-Op im Normalfall, da die Candidate sonst immer >= installed ist.
if apt-get install -y -qq --allow-downgrades -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

View File

@@ -940,7 +940,10 @@ retry_apt() {
attempt=$((attempt + 1))
echo "[upgrade] attempt $attempt/$max: apt-get update + install"
apt-get update -qq || true
if apt-get install -y -qq -o Dpkg::Options::=--force-confold \
# --allow-downgrades: nur relevant nach testing→stable-Kanalwechsel
# (Testing-Versionen sortieren datumsbasiert höher als Stable-Semver).
# No-Op im Normalfall, da die Candidate sonst immer >= installed ist.
if apt-get install -y -qq --allow-downgrades -o Dpkg::Options::=--force-confold \
edgeguard-api edgeguard-ui edgeguard; then
return 0
fi

View File

@@ -16,6 +16,7 @@ import (
"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"
intwaf "git.netcell-it.de/projekte/edgeguard-native/internal/waf"
)
// wafRuleIDRe erlaubt nur einzelne CRS-Rule-IDs oder Ranges ("942100" /
@@ -45,6 +46,11 @@ func (h *WafHandler) Register(rg *gin.RouterGroup) {
g.PUT("/configs/:domain_id", h.Upsert)
g.GET("/alerts", h.ListAlerts)
g.DELETE("/alerts", h.PurgeAlerts)
// Benutzerdefinierte App-Profile (wiederverwendbare Rule-ID-Bündel).
g.GET("/profiles", h.ListProfiles)
g.POST("/profiles", h.CreateProfile)
g.PUT("/profiles/:id", h.UpdateProfile)
g.DELETE("/profiles/:id", h.DeleteProfile)
}
// List returns all WAF configs.
@@ -80,13 +86,15 @@ func (h *WafHandler) Get(c *gin.Context) {
// 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"`
Enabled bool `json:"enabled"`
Mode string `json:"mode"`
ParanoiaLevel int `json:"paranoia_level"`
RuleExclusions []string `json:"rule_exclusions"`
CRSPlugins []string `json:"crs_plugins"`
AppProfiles []string `json:"app_profiles"`
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.
@@ -110,9 +118,32 @@ func (h *WafHandler) Upsert(c *gin.Context) {
if body.RuleExclusions == nil {
body.RuleExclusions = []string{}
}
if body.CRSPlugins == nil {
body.CRSPlugins = []string{}
}
if body.TrustedProxies == nil {
body.TrustedProxies = []string{}
}
if body.AppProfiles == nil {
body.AppProfiles = []string{}
}
// App-Profile: nur trimmen/leere raus. Unbekannte Namen sind harmlos (der
// Agent-Resolver ignoriert sie defensiv), aber wir speichern keinen Müll.
cleanProfiles := make([]string, 0, len(body.AppProfiles))
for _, p := range body.AppProfiles {
if p = strings.TrimSpace(p); p != "" {
cleanProfiles = append(cleanProfiles, p)
}
}
body.AppProfiles = cleanProfiles
// CRS-Plugins müssen aus der bekannten Whitelist stammen — sie werden zu
// Include-Pfaden, ein unbekannter Name wäre Pfad-Injection.
for _, p := range body.CRSPlugins {
if _, ok := intwaf.KnownCRSPlugins[strings.TrimSpace(p)]; !ok {
response.BadRequest(c, errors.New("unbekanntes CRS-Plugin: "+p))
return
}
}
if body.ExclusionNotes == nil {
body.ExclusionNotes = map[string]string{}
@@ -144,6 +175,8 @@ func (h *WafHandler) Upsert(c *gin.Context) {
Mode: body.Mode,
ParanoiaLevel: body.ParanoiaLevel,
RuleExclusions: body.RuleExclusions,
CRSPlugins: body.CRSPlugins,
AppProfiles: body.AppProfiles,
ExclusionNotes: body.ExclusionNotes,
TrustedProxies: body.TrustedProxies,
CustomRules: body.CustomRules,
@@ -211,6 +244,119 @@ func (h *WafHandler) PurgeAlerts(c *gin.Context) {
response.OK(c, gin.H{"ok": true, "days": days})
}
// wafProfileNameRe: erlaubte Zeichen für App-Profil-Namen (der Name wird pro
// Domain in waf_configs.app_profiles referenziert; kein SecLang-Kontext, aber
// sauber begrenzen).
var wafProfileNameRe = regexp.MustCompile(`^[A-Za-z0-9 ._-]{1,60}$`)
// profileBody ist das akzeptierte JSON für Create/Update eines App-Profils.
type profileBody struct {
Name string `json:"name"`
Description string `json:"description"`
RuleExclusions []string `json:"rule_exclusions"`
}
// validateProfileBody normalisiert und prüft den Request-Body. Gibt eine
// Fehlermeldung zurück (nil = ok) und mutiert body (trim, nil→[]).
func validateProfileBody(body *profileBody) error {
body.Name = strings.TrimSpace(body.Name)
if !wafProfileNameRe.MatchString(body.Name) {
return errors.New("ungültiger Profil-Name (160 Zeichen: Buchstaben, Ziffern, Leer, . _ -)")
}
body.Description = strings.TrimSpace(body.Description)
if body.RuleExclusions == nil {
body.RuleExclusions = []string{}
}
for i, ex := range body.RuleExclusions {
ex = strings.TrimSpace(ex)
if !wafRuleIDRe.MatchString(ex) {
return errors.New("ungültige Rule-Exclusion (nur IDs/Ranges erlaubt): " + ex)
}
body.RuleExclusions[i] = ex
}
return nil
}
// ListProfiles returns all WAF app profiles (built-in first).
func (h *WafHandler) ListProfiles(c *gin.Context) {
profiles, err := h.Repo.ListProfiles(c.Request.Context())
if err != nil {
response.Internal(c, err)
return
}
response.OK(c, gin.H{"profiles": profiles})
}
// CreateProfile creates a new user-defined app profile.
func (h *WafHandler) CreateProfile(c *gin.Context) {
var body profileBody
if err := c.ShouldBindJSON(&body); err != nil {
response.BadRequest(c, err)
return
}
if err := validateProfileBody(&body); err != nil {
response.BadRequest(c, err)
return
}
p, err := h.Repo.CreateProfile(c.Request.Context(), body.Name, body.Description, body.RuleExclusions)
if err != nil {
response.Internal(c, err)
return
}
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "waf.profile.create", body.Name,
gin.H{"exclusions": len(body.RuleExclusions)}, h.NodeID)
c.JSON(http.StatusOK, gin.H{"profile": p})
}
// UpdateProfile updates a user-defined app profile (built-ins are read-only).
func (h *WafHandler) UpdateProfile(c *gin.Context) {
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
response.BadRequest(c, errors.New("invalid id"))
return
}
var body profileBody
if err := c.ShouldBindJSON(&body); err != nil {
response.BadRequest(c, err)
return
}
if err := validateProfileBody(&body); err != nil {
response.BadRequest(c, err)
return
}
p, err := h.Repo.UpdateProfile(c.Request.Context(), id, body.Name, body.Description, body.RuleExclusions)
if err != nil {
if errors.Is(err, wafsvc.ErrProfileNotFound) {
response.BadRequest(c, errors.New("kein Profil gefunden oder read-only (built-in)"))
return
}
response.Internal(c, err)
return
}
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "waf.profile.update", body.Name,
gin.H{"exclusions": len(body.RuleExclusions)}, h.NodeID)
c.JSON(http.StatusOK, gin.H{"profile": p})
}
// DeleteProfile removes a user-defined app profile (built-ins are protected).
func (h *WafHandler) DeleteProfile(c *gin.Context) {
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
response.BadRequest(c, errors.New("invalid id"))
return
}
if err := h.Repo.DeleteProfile(c.Request.Context(), id); err != nil {
if errors.Is(err, wafsvc.ErrProfileNotFound) {
response.BadRequest(c, errors.New("kein Profil gefunden oder read-only (built-in)"))
return
}
response.Internal(c, err)
return
}
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "waf.profile.delete", strconv.FormatInt(id, 10), nil, h.NodeID)
response.OK(c, gin.H{"ok": true})
}
// defaultConfig returns a sensible disabled default for a domain
// that has no WAF config row yet.
func defaultConfig(domainID int64) models.WafConfig {
@@ -220,6 +366,8 @@ func defaultConfig(domainID int64) models.WafConfig {
Mode: "detection",
ParanoiaLevel: 1,
RuleExclusions: []string{},
CRSPlugins: []string{},
AppProfiles: []string{},
ExclusionNotes: map[string]string{},
TrustedProxies: []string{},
CustomRules: "",

View File

@@ -81,6 +81,13 @@ frontend public_https
bind quic6@:443 ssl crt /etc/edgeguard/tls/ alpn h3
{{- end}}
{{- if .WAFEnabled}}
# WAF: Request-Body puffern, damit edgeguard-waf den Body inspizieren
# kann (POST/PUT-Payloads: Form-SQLi, JSON-Injection, Datei-Uploads).
# Bewusst NUR wenn mind. eine Domain WAF nutzt (.WAFEnabled) — sonst
# kein RAM-pro-Connection-Overhead (vgl. Kommentar am Body-Size-Cap).
# Puffer bis tune.bufsize (~16KB); größere Bodies werden zur Inspektion
# gekappt — typische Injection-Payloads sind klein.
option http-buffer-request
# 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
@@ -226,6 +233,11 @@ backend eg_backend_{{$b.ID}}
{{- if $b.WebSocket}}
timeout tunnel 1h
{{- end}}
{{- if $b.ServerTimeoutSeconds}}
# Override des defaults-`timeout server 60s` für langsame Upstreams
# (z. B. KI-Server mit gepufferter Antwort). Wert per Backend gepflegt.
timeout server {{$b.ServerTimeoutSeconds}}s
{{- end}}
{{- if $b.HealthCheckPath}}
option httpchk
http-check send meth GET uri {{$b.HealthCheckPath}}

View File

@@ -160,7 +160,7 @@ spoe-agent edgeguard-waf-agent
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)
args src=src method=method uri=url ver=req.ver headers=req.hdrs host=req.hdr(host) body=req.body
event on-frontend-http-request
`

View File

@@ -554,6 +554,75 @@ func TestRender_WebSocketEmitsTunnelTimeout(t *testing.T) {
}
}
func TestRender_ServerTimeoutOverride(t *testing.T) {
tmo := 300
v := View{
Backends: []BackendView{
{
Backend: models.Backend{ID: 11, Name: "ai", Scheme: "http",
LBAlgorithm: "roundrobin", ServerTimeoutSeconds: &tmo, Active: true},
Servers: []models.BackendServer{
{BackendID: 11, Name: "ai-1", Address: "10.0.5.30", Port: 8000, Weight: 100, Active: true},
},
},
{
Backend: models.Backend{ID: 12, Name: "web", Scheme: "http",
LBAlgorithm: "roundrobin", Active: true},
Servers: []models.BackendServer{
{BackendID: 12, Name: "web-1", Address: "10.0.5.31", Port: 8080, Weight: 100, Active: true},
},
},
},
}
out := renderView(t, v)
idxAI := strings.Index(out, "backend eg_backend_11")
idxWeb := strings.Index(out, "backend eg_backend_12")
if idxAI < 0 || idxWeb < 0 {
t.Fatalf("backend sections missing in output:\n%s", out)
}
aiBlock := out[idxAI:idxWeb]
webBlock := out[idxWeb:]
// ai (nil-Override gesetzt) soll `timeout server 300s` bekommen …
if !strings.Contains(aiBlock, "timeout server 300s") {
t.Errorf("ai-Block sollte `timeout server 300s` enthalten:\n%s", aiBlock)
}
// … web (kein Override) soll KEINE eigene timeout-server-Zeile bekommen.
if strings.Contains(webBlock, "timeout server") {
t.Errorf("web-Block soll KEIN eigenes `timeout server` enthalten:\n%s", webBlock)
}
}
func TestRender_WAFBuffersRequestBody(t *testing.T) {
// Ohne WAF: kein Body-Buffering (kein RAM-Overhead).
off := renderView(t, View{WAFEnabled: false})
if strings.Contains(off, "option http-buffer-request") {
t.Errorf("ohne WAF darf kein `option http-buffer-request` gerendert werden:\n%s", off)
}
if strings.Contains(off, "filter spoe") {
t.Errorf("ohne WAF darf kein SPOE-Filter gerendert werden")
}
// Mit WAF: Body-Buffering VOR dem SPOE-Filter, damit req.body verfügbar ist.
on := renderView(t, View{WAFEnabled: true})
idxBuf := strings.Index(on, "option http-buffer-request")
idxFilter := strings.Index(on, "filter spoe engine edgeguard-waf")
if idxBuf < 0 {
t.Fatalf("mit WAF muss `option http-buffer-request` gerendert werden:\n%s", on)
}
if idxFilter < 0 {
t.Fatalf("mit WAF muss der SPOE-Filter gerendert werden")
}
if idxBuf > idxFilter {
t.Errorf("`option http-buffer-request` muss VOR dem SPOE-Filter stehen (buf=%d filter=%d)", idxBuf, idxFilter)
}
// Die SPOE-Message muss den Body an den Agent schicken, sonst kann
// Coraza ihn nicht inspizieren.
if !strings.Contains(spoeCfg, "body=req.body") {
t.Errorf("spoeCfg muss `body=req.body` an den WAF-Agent senden:\n%s", spoeCfg)
}
}
func TestRender_MultiServerPool(t *testing.T) {
v := View{
Backends: []BackendView{

View File

@@ -45,7 +45,7 @@ vrrp_instance VI_1 {
virtual_router_id {{ .RouterID }}
priority {{ .Priority }}
advert_int 2
nopreempt
{{ if .PreemptDelay }}preempt_delay {{ .PreemptDelay }}{{ else }}nopreempt{{ end }}
{{ if .SrcIP }} unicast_src_ip {{ .SrcIP }}
unicast_peer {
{{ .PeerIP }}
@@ -72,7 +72,7 @@ vrrp_instance VI_HB {
virtual_router_id {{ .HBRouterID }}
priority {{ .Priority }}
advert_int 2
nopreempt
{{ if .PreemptDelay }}preempt_delay {{ .PreemptDelay }}{{ else }}nopreempt{{ end }}
{{ if .HBSrcIP }} unicast_src_ip {{ .HBSrcIP }}
unicast_peer {
{{ .HBPeerIP }}

View File

@@ -54,8 +54,19 @@ type View struct {
HBRouterID int
// GW-Tracking
GWCheckIP string
// PreemptDelay > 0 → Node holt die VIP nach Erholung zurück (nach N Sekunden
// Stabilität). 0 → nopreempt (bleibt Backup). Siehe buildView.
PreemptDelay int
}
// preemptDelaySeconds: der bevorzugte Node (höhere Priorität = PG-Primary) holt
// die VIP erst nach dieser Wartezeit zurück — lange genug, dass ein frisch
// gebooteter/deployter Node erst wirklich bereit ist (Boot + Service-Start),
// bevor er überhaupt preempten darf. Zusammen mit dem gehärteten Health-Check
// (haproxy aktiv + :443 gebunden, keepalived-check.sh) verhindert das den
// Incident 2026-08-03 (halb-kaputter Node riss die VIP an sich).
const preemptDelaySeconds = 120
type generator struct {
pool *pgxpool.Pool
localID string
@@ -167,14 +178,21 @@ func (g *generator) buildView(cs *models.ClusterSettings, vips []VIPEntry, local
v.HBRouterID = 52
}
// State IMMER BACKUP: das Template setzt `nopreempt`, und nopreempt wirkt
// in keepalived NUR, wenn die Instanz im BACKUP-Zustand startet (bei state
// MASTER wird nopreempt ignoriert). Die Priorität entscheidet weiterhin die
// Initial-Election (primary=200 gewinnt), aber ein erholter Node reißt die
// VIP NICHT mehr zurück → kein Flap-Back / Split-Brain. Deckt sich mit der
// "kein Auto-Promote"-Philosophie: Promotion bleibt manuell.
// pg_role=standby ist das härtere Signal (Standby ist nie bevorzugter Node).
// State IMMER BACKUP; die Priorität entscheidet, welcher Node die VIP
// bevorzugt hält (PG-Primary=200 > Standby=100). Mit preempt_delay holt
// der bevorzugte Node die VIP nach Erholung zurück (VIP-Affinität zum
// PG-Primary), aber erst nach preemptDelaySeconds Stabilität.
//
// Incident 2026-08-03 & Fix: eine frühere preempt_delay-Variante ließ den
// Prio-200-Node die VIP zurückholen, sobald der Health-Check ihn für
// „gesund" hielt — der prüfte aber NUR die edgeguard-api, nicht ob der Node
// wirklich Traffic bedient. Ein halb-kaputter Primary (api up, haproxy/Netz
// down) riss so die VIP an sich → Ausfall. Preempt ist wieder aktiv, WEIL
// keepalived-check.sh jetzt zusätzlich haproxy-aktiv + :443-gebunden fordert:
// ein nicht-bedienender Node geht in FAULT und kann NICHT preempten.
// Promotion/PG-Failover bleibt manuell (edgeguard-ctl promote).
v.State = "BACKUP"
v.PreemptDelay = preemptDelaySeconds
if local.PGRole == "standby" {
v.Priority = 100
} else if local.PGRole == "primary" || local.Role == "primary" {

View File

@@ -17,32 +17,52 @@ func render(t *testing.T, v View) string {
return buf.String()
}
// Split-Brain-Schutz: jede vrrp_instance MUSS `nopreempt` tragen, sonst reißt
// ein erholter Node die VIP zurück → Flapping. nopreempt wirkt nur bei state
// BACKUP — also muss auch der bevorzugte Node BACKUP starten.
func testView() View {
return View{
State: "BACKUP", Interface: "eth0", RouterID: 51, Priority: 200,
SrcIP: "89.163.205.6", PeerIP: "89.163.205.8", AuthPass: "edgeguard",
VIPs: []VIPEntry{{Address: "89.163.205.100", Prefix: 24, Device: "eth0"}},
HBInterface: "ens19", HBSrcIP: "169.254.0.1", HBPeerIP: "169.254.0.2", HBRouterID: 52,
GWCheckIP: "89.163.205.1",
GWCheckIP: "89.163.205.1", PreemptDelay: 120,
}
}
func TestTemplateNopreemptOnBothInstances(t *testing.T) {
// VIP-Affinität zum PG-Primary (Incident-2026-08-03-Fix): mit PreemptDelay
// tragen BEIDE Instanzen `preempt_delay N` statt nopreempt, damit der
// bevorzugte Node die VIP nach Erholung zurückholt — aber erst nach N Sekunden
// Stabilität. Kein Node darf `state MASTER` starten (sonst kein sauberes
// Election). Preempt ist nur sicher, WEIL keepalived-check.sh haproxy-Bereit-
// schaft (aktiv + :443) mitprüft (siehe dortiger Kommentar).
func TestTemplatePreemptDelayOnBothInstances(t *testing.T) {
out := render(t, testView())
if n := strings.Count(out, "nopreempt"); n != 2 {
t.Fatalf("erwarte nopreempt in VI_1 UND VI_HB (2×), gefunden: %d\n%s", n, out)
if c := strings.Count(out, "preempt_delay 120"); c != 2 {
t.Fatalf("erwarte preempt_delay 120 in VI_1 UND VI_HB (2×), gefunden: %d\n%s", c, out)
}
if strings.Contains(out, "nopreempt") {
t.Fatalf("bei PreemptDelay>0 darf KEIN nopreempt gerendert werden:\n%s", out)
}
if strings.Contains(out, "state MASTER") {
t.Fatalf("kein Node darf state MASTER starten (nopreempt würde ignoriert):\n%s", out)
t.Fatalf("kein Node darf state MASTER starten:\n%s", out)
}
if c := strings.Count(out, "state BACKUP"); c != 2 {
t.Fatalf("erwarte state BACKUP in beiden Instanzen, gefunden: %d", c)
}
}
// Ohne PreemptDelay (==0) fällt das Template auf nopreempt zurück (Node bleibt
// Backup, keine VIP-Rückkehr) — der sichere Default, falls Preempt je aus soll.
func TestTemplateFallsBackToNopreempt(t *testing.T) {
v := testView()
v.PreemptDelay = 0
out := render(t, v)
if c := strings.Count(out, "nopreempt"); c != 2 {
t.Fatalf("erwarte nopreempt in beiden Instanzen (2×) bei PreemptDelay=0, gefunden: %d\n%s", c, out)
}
if strings.Contains(out, "preempt_delay") {
t.Fatalf("bei PreemptDelay=0 darf KEIN preempt_delay gerendert werden:\n%s", out)
}
}
// GARP muss forciert + periodic aufgefrischt werden, sonst altert die
// VIP-MAC am Upstream-Switch und die Failover-IP wird unerreichbar.
func TestTemplateGARPRefresh(t *testing.T) {
@@ -103,10 +123,13 @@ func TestBuildViewStateAlwaysBackup(t *testing.T) {
local := &models.HANode{ID: "n1", PGRole: c.pgRole, Role: c.role, PublicIP: &pub}
v := g.buildView(cs, nil, local, nil)
if v.State != "BACKUP" {
t.Errorf("pg_role=%q role=%q: State=%q, erwarte immer BACKUP (nopreempt)", c.pgRole, c.role, v.State)
t.Errorf("pg_role=%q role=%q: State=%q, erwarte immer BACKUP", c.pgRole, c.role, v.State)
}
if v.Priority != c.wantPrio {
t.Errorf("pg_role=%q role=%q: Priority=%d, erwarte %d", c.pgRole, c.role, v.Priority, c.wantPrio)
}
if v.PreemptDelay != 120 {
t.Errorf("pg_role=%q role=%q: PreemptDelay=%d, erwarte 120", c.pgRole, c.role, v.PreemptDelay)
}
}
}

View File

@@ -12,6 +12,10 @@ type Backend struct {
LBAlgorithm string `gorm:"column:lb_algorithm" json:"lb_algorithm"`
WebSocket bool `gorm:"column:websocket" json:"websocket"`
ForceHTTP1 bool `gorm:"column:force_http1" json:"force_http1"`
// ServerTimeoutSeconds überschreibt `timeout server` für dieses
// Backend (Sekunden). nil = defaults-Timeout (60s). Für langsam
// antwortende Upstreams (KI-/Inferenz-Server ohne Streaming).
ServerTimeoutSeconds *int `gorm:"column:server_timeout_seconds" json:"server_timeout_seconds,omitempty"`
Active bool `gorm:"column:active" json:"active"`
CreatedAt time.Time `gorm:"column:created_at" json:"created_at"`
UpdatedAt time.Time `gorm:"column:updated_at" json:"updated_at"`

View File

@@ -20,6 +20,9 @@ type Domain struct {
DisableH3 bool `gorm:"column:disable_h3" json:"disable_h3"`
Notes *string `gorm:"column:notes" json:"notes,omitempty"`
RedirectTo string `gorm:"column:redirect_to" json:"redirect_to"` // ""=aus; sonst 301-Ziel-URL (Domain→Domain)
// CrowdSecTrusted: vertrauenswürdiges Admin-Panel → dessen Hostname wird in
// die CrowdSec-Whitelist gerendert (Admin-SPA-Traffic ist kein Crawl).
CrowdSecTrusted bool `gorm:"column:crowdsec_trusted" json:"crowdsec_trusted"`
CreatedAt time.Time `gorm:"column:created_at" json:"created_at"`
UpdatedAt time.Time `gorm:"column:updated_at" json:"updated_at"`
}

View File

@@ -11,6 +11,14 @@ type WafConfig struct {
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"`
// CRSPlugins: aktivierte OWASP-CRS-App-Exclusion-Plugins (z. B.
// "nextcloud","wordpress"). Der Renderer inkludiert je Plugin dessen
// config/before/after-Dateien aus <crsDir>/plugins/.
CRSPlugins []string `gorm:"column:crs_plugins;type:text[]" json:"crs_plugins"`
// AppProfiles: zugewiesene benutzerdefinierte WAF-App-Profile (Namen aus
// waf_app_profiles). Ihre rule_exclusions werden im Agent in die effektiven
// Ausnahmen dieser Domain gemischt.
AppProfiles []string `gorm:"column:app_profiles;type:text[]" json:"app_profiles"`
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"`
@@ -18,3 +26,18 @@ type WafConfig struct {
}
func (WafConfig) TableName() string { return "waf_configs" }
// WafAppProfile ist ein benanntes, wiederverwendbares Bündel von CRS-Rule-
// Exclusions (reine Rule-IDs/Ranges). Built-in-Profile (builtin=true) sind
// read-only; benutzerdefinierte sind im UI editierbar und pro Domain zuweisbar.
type WafAppProfile struct {
ID int64 `gorm:"primaryKey" json:"id"`
Name string `gorm:"column:name;uniqueIndex" json:"name"`
Description string `gorm:"column:description" json:"description"`
RuleExclusions []string `gorm:"column:rule_exclusions;type:text[]" json:"rule_exclusions"`
Builtin bool `gorm:"column:builtin" json:"builtin"`
CreatedAt time.Time `gorm:"column:created_at" json:"created_at"`
UpdatedAt time.Time `gorm:"column:updated_at" json:"updated_at"`
}
func (WafAppProfile) TableName() string { return "waf_app_profiles" }

View File

@@ -62,13 +62,14 @@ type EmailSettings struct {
// Event ist eine Row in alert_events.
type Event struct {
ID int64 `json:"id"`
Kind string `json:"kind"`
Severity Severity `json:"severity"`
Subject string `json:"subject"`
Message string `json:"message"`
SentTo json.RawMessage `json:"sent_to"`
FiredAt time.Time `json:"fired_at"`
ID int64 `json:"id"`
Kind string `json:"kind"`
Severity Severity `json:"severity"`
Subject string `json:"subject"`
Message string `json:"message"`
SentTo json.RawMessage `json:"sent_to"`
FiredAt time.Time `json:"fired_at"`
AcknowledgedAt *time.Time `json:"acknowledged_at,omitempty"`
}
// SendResult pro Channel — landet als JSON-Array in sent_to.
@@ -170,14 +171,19 @@ func (s *Service) DeleteChannel(ctx context.Context, id int64) error {
return nil
}
// ListEvents liefert die letzten N Events newest-first.
func (s *Service) ListEvents(ctx context.Context, limit int) ([]Event, error) {
// ListEvents liefert die letzten N Events newest-first. Wenn openOnly
// gesetzt ist, werden nur noch offene (nicht quittierte) Events geliefert —
// das nutzt die Dashboard-Karte, damit Quittieren die Meldung verschwinden
// lässt.
func (s *Service) ListEvents(ctx context.Context, limit int, openOnly bool) ([]Event, error) {
if limit <= 0 || limit > 500 {
limit = 100
}
rows, err := s.Pool.Query(ctx, `
SELECT id, kind, severity, subject, message, sent_to, fired_at
FROM alert_events ORDER BY fired_at DESC, id DESC LIMIT $1`, limit)
SELECT id, kind, severity, subject, message, sent_to, fired_at, acknowledged_at
FROM alert_events
WHERE ($2::bool = false OR acknowledged_at IS NULL)
ORDER BY fired_at DESC, id DESC LIMIT $1`, limit, openOnly)
if err != nil {
return nil, err
}
@@ -186,7 +192,7 @@ FROM alert_events ORDER BY fired_at DESC, id DESC LIMIT $1`, limit)
for rows.Next() {
var e Event
if err := rows.Scan(&e.ID, &e.Kind, &e.Severity, &e.Subject,
&e.Message, &e.SentTo, &e.FiredAt); err != nil {
&e.Message, &e.SentTo, &e.FiredAt, &e.AcknowledgedAt); err != nil {
return nil, err
}
out = append(out, e)
@@ -194,6 +200,64 @@ FROM alert_events ORDER BY fired_at DESC, id DESC LIMIT $1`, limit)
return out, rows.Err()
}
// Acknowledge quittiert die angegebenen Events (setzt acknowledged_at=NOW()
// bei noch offenen). Liefert die Anzahl geänderter Rows.
func (s *Service) Acknowledge(ctx context.Context, ids []int64) (int64, error) {
if len(ids) == 0 {
return 0, nil
}
tag, err := s.Pool.Exec(ctx,
`UPDATE alert_events SET acknowledged_at = NOW()
WHERE id = ANY($1) AND acknowledged_at IS NULL`, ids)
if err != nil {
return 0, err
}
return tag.RowsAffected(), nil
}
// AcknowledgeAll quittiert alle offenen Events — Backing für den
// "Alle quittieren"-Button.
func (s *Service) AcknowledgeAll(ctx context.Context) (int64, error) {
tag, err := s.Pool.Exec(ctx,
`UPDATE alert_events SET acknowledged_at = NOW() WHERE acknowledged_at IS NULL`)
if err != nil {
return 0, err
}
return tag.RowsAffected(), nil
}
// DeleteEvents löscht die angegebenen Events endgültig. Liefert die Anzahl
// gelöschter Rows.
func (s *Service) DeleteEvents(ctx context.Context, ids []int64) (int64, error) {
if len(ids) == 0 {
return 0, nil
}
tag, err := s.Pool.Exec(ctx,
`DELETE FROM alert_events WHERE id = ANY($1)`, ids)
if err != nil {
return 0, err
}
return tag.RowsAffected(), nil
}
// Cleanup löscht alert_events älter als keepDays und liefert die Anzahl
// gelöschter Rows. make_interval(days => $1) nimmt $1 sauber als int —
// der frühere ($1 || ' days')::interval-Ansatz erzwang text und scheiterte
// unter pgx mit einem Encode-Fehler (vgl. waf PurgeAlerts, v1.3.3).
func (s *Service) Cleanup(ctx context.Context, keepDays int) (int64, error) {
if keepDays <= 0 {
return 0, nil
}
tag, err := s.Pool.Exec(ctx,
`DELETE FROM alert_events WHERE fired_at < NOW() - make_interval(days => $1)`,
keepDays,
)
if err != nil {
return 0, err
}
return tag.RowsAffected(), nil
}
// Fire dispatch'ed einen Event an alle aktiven Channels und persistiert
// das Ergebnis. Non-fatal — Send-Failures werden im sent_to-JSON
// dokumentiert, der Event selbst landet in jedem Fall in der History.

View File

@@ -237,3 +237,61 @@ func AutoUpdateEnabled() bool {
_, err := os.Stat(AutoUpdateConfPath)
return err == nil
}
// ── Update-Kanal (stable/testing) ──────────────────────────────────────
//
// Kanal-Modell 1:1 von enconf übernommen: Suite = OS-Codename (trixie),
// Komponente = Kanal. Kein eigenes Config-File — die sources.list-Zeile
// selbst ist die einzige Quelle der Wahrheit (siehe scripts/install.sh
// setup_repo(), das dieselbe Zeile beim Erstinstall schreibt).
// SourcesListPath: vom Installer angelegte apt-Quelle. Root-owned wie
// AutoUpdateConfPath — Schreibzugriff nur via sudo tee (Sudoers-Pin im
// postinst).
const SourcesListPath = "/etc/apt/sources.list.d/edgeguard.list"
const sourcesListTemplate = "deb [signed-by=/etc/apt/keyrings/nmg.asc] " +
"https://git.netcell-it.de/api/packages/projekte/debian trixie %s\n"
// Channel liest den aktuell konfigurierten Update-Kanal aus dem letzten
// Feld der deb-Zeile. Default "stable" wenn die Datei fehlt oder das
// letzte Feld kein bekannter Kanal ist (Fail-safe — nie stillschweigend
// "testing" annehmen).
func Channel() string {
data, err := os.ReadFile(SourcesListPath)
if err != nil {
return "stable"
}
for _, raw := range strings.Split(string(data), "\n") {
line := strings.TrimSpace(raw)
if !strings.HasPrefix(line, "deb ") {
continue
}
fields := strings.Fields(line)
if len(fields) == 0 {
continue
}
switch fields[len(fields)-1] {
case "stable", "testing":
return fields[len(fields)-1]
}
}
return "stable"
}
// SetChannel schreibt die sources.list-Zeile mit dem neuen Kanal und
// refresht den apt-Cache sofort — sonst zeigt der Update-Banner bis zum
// nächsten 5-min-Throttle-Fenster noch den alten Kanal-Stand.
func SetChannel(ctx context.Context, channel string) error {
if channel != "stable" && channel != "testing" {
return fmt.Errorf("apt: unknown channel %q (expected stable|testing)", channel)
}
body := fmt.Sprintf(sourcesListTemplate, channel)
cmd := exec.Command("sudo", "-n", "/usr/bin/tee", SourcesListPath)
cmd.Stdin = strings.NewReader(body)
if out, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("sudo tee %s: %w: %s", SourcesListPath, err, strings.TrimSpace(string(out)))
}
RefreshNow(ctx)
return nil
}

View File

@@ -26,8 +26,8 @@ type Repo struct {
func New(pool *pgxpool.Pool) *Repo { return &Repo{Pool: pool} }
const baseSelect = `
SELECT id, name, scheme, health_check_path, lb_algorithm, websocket, force_http1, active,
created_at, updated_at
SELECT id, name, scheme, health_check_path, lb_algorithm, websocket, force_http1,
server_timeout_seconds, active, created_at, updated_at
FROM backends
`
@@ -65,11 +65,13 @@ func (r *Repo) Create(ctx context.Context, b models.Backend) (*models.Backend, e
b.LBAlgorithm = "roundrobin"
}
row := r.Pool.QueryRow(ctx, `
INSERT INTO backends (name, scheme, health_check_path, lb_algorithm, websocket, force_http1, active)
VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING id, name, scheme, health_check_path, lb_algorithm, websocket, force_http1, active,
created_at, updated_at`,
b.Name, b.Scheme, b.HealthCheckPath, b.LBAlgorithm, b.WebSocket, b.ForceHTTP1, b.Active)
INSERT INTO backends (name, scheme, health_check_path, lb_algorithm, websocket, force_http1,
server_timeout_seconds, active)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING id, name, scheme, health_check_path, lb_algorithm, websocket, force_http1,
server_timeout_seconds, active, created_at, updated_at`,
b.Name, b.Scheme, b.HealthCheckPath, b.LBAlgorithm, b.WebSocket, b.ForceHTTP1,
b.ServerTimeoutSeconds, b.Active)
return scanBackend(row)
}
@@ -85,12 +87,14 @@ UPDATE backends SET
lb_algorithm = $4,
websocket = $5,
force_http1 = $6,
active = $7,
server_timeout_seconds = $7,
active = $8,
updated_at = NOW()
WHERE id = $8
RETURNING id, name, scheme, health_check_path, lb_algorithm, websocket, force_http1, active,
created_at, updated_at`,
b.Name, b.Scheme, b.HealthCheckPath, b.LBAlgorithm, b.WebSocket, b.ForceHTTP1, b.Active, id)
WHERE id = $9
RETURNING id, name, scheme, health_check_path, lb_algorithm, websocket, force_http1,
server_timeout_seconds, active, created_at, updated_at`,
b.Name, b.Scheme, b.HealthCheckPath, b.LBAlgorithm, b.WebSocket, b.ForceHTTP1,
b.ServerTimeoutSeconds, b.Active, id)
out, err := scanBackend(row)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
@@ -125,7 +129,8 @@ func scanBackend(row interface{ Scan(...any) error }) (*models.Backend, error) {
var b models.Backend
if err := row.Scan(
&b.ID, &b.Name, &b.Scheme,
&b.HealthCheckPath, &b.LBAlgorithm, &b.WebSocket, &b.ForceHTTP1, &b.Active,
&b.HealthCheckPath, &b.LBAlgorithm, &b.WebSocket, &b.ForceHTTP1,
&b.ServerTimeoutSeconds, &b.Active,
&b.CreatedAt, &b.UpdatedAt,
); err != nil {
return nil, err

View File

@@ -24,7 +24,7 @@ SELECT id, name, active, primary_backend_id, http_to_https,
hsts_enabled, hsts_max_age, hsts_subdomains, hsts_preload,
maintenance_mode, maintenance_message, www_redirect,
rate_limit_rps, max_body_kb, disable_h3,
notes, redirect_to, created_at, updated_at
notes, redirect_to, crowdsec_trusted, created_at, updated_at
FROM domains
`
@@ -65,17 +65,19 @@ func (r *Repo) Create(ctx context.Context, d models.Domain) (*models.Domain, err
INSERT INTO domains (name, active, primary_backend_id, http_to_https,
hsts_enabled, hsts_max_age, hsts_subdomains, hsts_preload,
maintenance_mode, maintenance_message, www_redirect,
rate_limit_rps, max_body_kb, disable_h3, notes, redirect_to)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16)
rate_limit_rps, max_body_kb, disable_h3, notes, redirect_to,
crowdsec_trusted)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17)
RETURNING id, name, active, primary_backend_id, http_to_https,
hsts_enabled, hsts_max_age, hsts_subdomains, hsts_preload,
maintenance_mode, maintenance_message, www_redirect,
rate_limit_rps, max_body_kb, disable_h3,
notes, redirect_to, created_at, updated_at`,
notes, redirect_to, crowdsec_trusted, created_at, updated_at`,
d.Name, d.Active, d.PrimaryBackendID, d.HTTPToHTTPS,
d.HSTSEnabled, d.HSTSMaxAge, d.HSTSSubdomains, d.HSTSPreload,
d.MaintenanceMode, d.MaintenanceMessage, d.WWWRedirect,
d.RateLimitRPS, d.MaxBodyKB, d.DisableH3, d.Notes, d.RedirectTo)
d.RateLimitRPS, d.MaxBodyKB, d.DisableH3, d.Notes, d.RedirectTo,
d.CrowdSecTrusted)
return scanDomain(row)
}
@@ -101,17 +103,19 @@ UPDATE domains SET
disable_h3 = $14,
notes = $15,
redirect_to = $16,
crowdsec_trusted = $17,
updated_at = NOW()
WHERE id = $17
WHERE id = $18
RETURNING id, name, active, primary_backend_id, http_to_https,
hsts_enabled, hsts_max_age, hsts_subdomains, hsts_preload,
maintenance_mode, maintenance_message, www_redirect,
rate_limit_rps, max_body_kb, disable_h3,
notes, redirect_to, created_at, updated_at`,
notes, redirect_to, crowdsec_trusted, created_at, updated_at`,
d.Name, d.Active, d.PrimaryBackendID, d.HTTPToHTTPS,
d.HSTSEnabled, d.HSTSMaxAge, d.HSTSSubdomains, d.HSTSPreload,
d.MaintenanceMode, d.MaintenanceMessage, d.WWWRedirect,
d.RateLimitRPS, d.MaxBodyKB, d.DisableH3, d.Notes, d.RedirectTo, id)
d.RateLimitRPS, d.MaxBodyKB, d.DisableH3, d.Notes, d.RedirectTo,
d.CrowdSecTrusted, id)
out, err := scanDomain(row)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
@@ -140,7 +144,7 @@ func scanDomain(row interface{ Scan(...any) error }) (*models.Domain, error) {
&d.HSTSEnabled, &d.HSTSMaxAge, &d.HSTSSubdomains, &d.HSTSPreload,
&d.MaintenanceMode, &d.MaintenanceMessage, &d.WWWRedirect,
&d.RateLimitRPS, &d.MaxBodyKB, &d.DisableH3,
&d.Notes, &d.RedirectTo, &d.CreatedAt, &d.UpdatedAt,
&d.Notes, &d.RedirectTo, &d.CrowdSecTrusted, &d.CreatedAt, &d.UpdatedAt,
); err != nil {
return nil, err
}

View File

@@ -0,0 +1,124 @@
package waf
import (
"context"
"errors"
"time"
"github.com/jackc/pgx/v5"
"git.netcell-it.de/projekte/edgeguard-native/internal/models"
)
// ErrProfileNotFound wird von Get/Update/Delete zurückgegeben, wenn kein Profil
// mit der ID existiert.
var ErrProfileNotFound = errors.New("waf app profile not found")
const profileSelect = `
SELECT id, name, description, rule_exclusions, builtin, created_at, updated_at
FROM waf_app_profiles
`
func scanProfile(row pgx.Row) (*models.WafAppProfile, error) {
var p models.WafAppProfile
if err := row.Scan(&p.ID, &p.Name, &p.Description, &p.RuleExclusions, &p.Builtin, &p.CreatedAt, &p.UpdatedAt); err != nil {
return nil, err
}
return &p, nil
}
// ListProfiles gibt alle App-Profile zurück (Built-in zuerst, dann alphabetisch).
func (r *Repo) ListProfiles(ctx context.Context) ([]models.WafAppProfile, error) {
rows, err := r.Pool.Query(ctx, profileSelect+" ORDER BY builtin DESC, name ASC")
if err != nil {
return nil, err
}
defer rows.Close()
out := make([]models.WafAppProfile, 0, 16)
for rows.Next() {
p, err := scanProfile(rows)
if err != nil {
return nil, err
}
out = append(out, *p)
}
return out, rows.Err()
}
// profilesByName lädt alle Profile in eine Name→Profil-Map (für die Auflösung
// im Agent-Loader).
func (r *Repo) profilesByName(ctx context.Context) (map[string]models.WafAppProfile, error) {
list, err := r.ListProfiles(ctx)
if err != nil {
return nil, err
}
m := make(map[string]models.WafAppProfile, len(list))
for _, p := range list {
m[p.Name] = p
}
return m, nil
}
// GetProfile gibt ein Profil per ID zurück, oder ErrProfileNotFound.
func (r *Repo) GetProfile(ctx context.Context, id int64) (*models.WafAppProfile, error) {
row := r.Pool.QueryRow(ctx, profileSelect+" WHERE id = $1", id)
p, err := scanProfile(row)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrProfileNotFound
}
return nil, err
}
return p, nil
}
// CreateProfile legt ein neues benutzerdefiniertes Profil an (builtin immer
// false — Built-ins werden nicht über die API erzeugt).
func (r *Repo) CreateProfile(ctx context.Context, name, description string, exclusions []string) (*models.WafAppProfile, error) {
if exclusions == nil {
exclusions = []string{}
}
now := time.Now()
row := r.Pool.QueryRow(ctx, `
INSERT INTO waf_app_profiles (name, description, rule_exclusions, builtin, created_at, updated_at)
VALUES ($1,$2,$3,false,$4,$4)
RETURNING id, name, description, rule_exclusions, builtin, created_at, updated_at
`, name, description, exclusions, now)
return scanProfile(row)
}
// UpdateProfile ändert Name/Beschreibung/Ausnahmen eines Profils. Built-in-
// Profile sind read-only (WHERE builtin = false) → ErrProfileNotFound, wenn
// das Profil fehlt ODER built-in ist.
func (r *Repo) UpdateProfile(ctx context.Context, id int64, name, description string, exclusions []string) (*models.WafAppProfile, error) {
if exclusions == nil {
exclusions = []string{}
}
row := r.Pool.QueryRow(ctx, `
UPDATE waf_app_profiles
SET name = $2, description = $3, rule_exclusions = $4, updated_at = $5
WHERE id = $1 AND builtin = false
RETURNING id, name, description, rule_exclusions, builtin, created_at, updated_at
`, id, name, description, exclusions, time.Now())
p, err := scanProfile(row)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrProfileNotFound
}
return nil, err
}
return p, nil
}
// DeleteProfile entfernt ein benutzerdefiniertes Profil. Built-in-Profile sind
// geschützt. Gibt ErrProfileNotFound zurück, wenn nichts gelöscht wurde.
func (r *Repo) DeleteProfile(ctx context.Context, id int64) error {
tag, err := r.Pool.Exec(ctx, `DELETE FROM waf_app_profiles WHERE id = $1 AND builtin = false`, id)
if err != nil {
return err
}
if tag.RowsAffected() == 0 {
return ErrProfileNotFound
}
return nil
}

View File

@@ -0,0 +1,48 @@
package waf
import (
"testing"
"time"
"git.netcell-it.de/projekte/edgeguard-native/internal/models"
)
func TestMergeProfileExclusions(t *testing.T) {
base := time.Date(2026, 8, 3, 10, 0, 0, 0, time.UTC)
newer := base.Add(1 * time.Hour)
profiles := map[string]models.WafAppProfile{
"nc": {Name: "nc", RuleExclusions: []string{"942100", "920420"}, UpdatedAt: newer},
"wp": {Name: "wp", RuleExclusions: []string{"942100", "941100"}, UpdatedAt: base},
}
t.Run("keine Profile → unverändert", func(t *testing.T) {
got, ts := mergeProfileExclusions([]string{"1000"}, base, nil, profiles)
if len(got) != 1 || got[0] != "1000" || !ts.Equal(base) {
t.Fatalf("got=%v ts=%v", got, ts)
}
})
t.Run("union dedupliziert, Reihenfolge stabil", func(t *testing.T) {
got, ts := mergeProfileExclusions([]string{"942100", "900001"}, base, []string{"nc", "wp"}, profiles)
want := []string{"942100", "900001", "920420", "941100"} // 942100 nicht doppelt
if len(got) != len(want) {
t.Fatalf("got=%v want=%v", got, want)
}
for i := range want {
if got[i] != want[i] {
t.Fatalf("got=%v want=%v", got, want)
}
}
// effektives updated_at = max(base, nc.newer) = newer
if !ts.Equal(newer) {
t.Fatalf("ts=%v want=%v (Profil-Edit muss Rebuild ausloesen)", ts, newer)
}
})
t.Run("unbekanntes Profil defensiv ignoriert", func(t *testing.T) {
got, ts := mergeProfileExclusions([]string{"1000"}, base, []string{"gibtsnicht"}, profiles)
if len(got) != 1 || got[0] != "1000" || !ts.Equal(base) {
t.Fatalf("got=%v ts=%v", got, ts)
}
})
}

View File

@@ -22,7 +22,7 @@ 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
rule_exclusions, crs_plugins, app_profiles, exclusion_notes, trusted_proxies, custom_rules, updated_at
FROM waf_configs
`
@@ -30,7 +30,7 @@ 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,
&c.RuleExclusions, &c.CRSPlugins, &c.AppProfiles, &c.ExclusionNotes, &c.TrustedProxies, &c.CustomRules, &c.UpdatedAt,
)
if err != nil {
return nil, err
@@ -82,22 +82,24 @@ func (r *Repo) Upsert(ctx context.Context, c models.WafConfig) (*models.WafConfi
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)
rule_exclusions, crs_plugins, app_profiles, exclusion_notes, trusted_proxies, custom_rules, updated_at)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11)
ON CONFLICT (domain_id) DO UPDATE SET
enabled = EXCLUDED.enabled,
mode = EXCLUDED.mode,
paranoia_level = EXCLUDED.paranoia_level,
rule_exclusions = EXCLUDED.rule_exclusions,
crs_plugins = EXCLUDED.crs_plugins,
app_profiles = EXCLUDED.app_profiles,
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
rule_exclusions, crs_plugins, app_profiles, 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,
c.RuleExclusions, c.CRSPlugins, c.AppProfiles, c.ExclusionNotes, c.TrustedProxies, c.CustomRules, c.UpdatedAt,
)
return scan(row)
}
@@ -200,11 +202,23 @@ type DomainConfigPair struct {
// ListAllWithDomain returns all WAF configs joined with their domain name.
// Used by the WAF agent to build the hostname→engine mapping.
//
// Wichtig: crs_plugins UND app_profiles werden hier geladen — früher fehlte
// crs_plugins, dadurch waren die gewählten Built-in-CRS-Plugins im laufenden
// Agent nie aktiv. app_profiles (benutzerdefinierte Rule-ID-Bündel) werden hier
// in die effektiven rule_exclusions der Domain gemischt und ihr updated_at
// fließt in das effektive updated_at ein — so baut der Manager die Engine neu,
// sobald ein Profil bearbeitet wird (der Rebuild-Trigger hängt an updated_at).
func (r *Repo) ListAllWithDomain(ctx context.Context) ([]DomainConfigPair, error) {
profiles, err := r.profilesByName(ctx)
if err != nil {
return nil, err
}
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
w.rule_exclusions, w.crs_plugins, w.app_profiles,
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
@@ -221,12 +235,49 @@ func (r *Repo) ListAllWithDomain(ctx context.Context) ([]DomainConfigPair, error
if err := rows.Scan(
&p.Hostname,
&c.ID, &c.DomainID, &c.Enabled, &c.Mode, &c.ParanoiaLevel,
&c.RuleExclusions, &c.TrustedProxies, &c.CustomRules, &c.UpdatedAt,
&c.RuleExclusions, &c.CRSPlugins, &c.AppProfiles,
&c.TrustedProxies, &c.CustomRules, &c.UpdatedAt,
); err != nil {
return nil, err
}
c.RuleExclusions, c.UpdatedAt = mergeProfileExclusions(c.RuleExclusions, c.UpdatedAt, c.AppProfiles, profiles)
p.Config = c
out = append(out, p)
}
return out, rows.Err()
}
// mergeProfileExclusions vereint die domain-eigenen Ausnahmen mit denen aller
// zugewiesenen App-Profile (dedupliziert, stabile Reihenfolge) und hebt das
// effektive updated_at auf das Maximum aus Config + zugewiesenen Profilen an.
// Pure Funktion (leicht testbar, keine DB).
func mergeProfileExclusions(own []string, updatedAt time.Time, assigned []string, profiles map[string]models.WafAppProfile) ([]string, time.Time) {
if len(assigned) == 0 {
return own, updatedAt
}
seen := make(map[string]struct{}, len(own))
merged := make([]string, 0, len(own))
for _, id := range own {
if _, ok := seen[id]; !ok {
seen[id] = struct{}{}
merged = append(merged, id)
}
}
effUpdated := updatedAt
for _, name := range assigned {
prof, ok := profiles[name]
if !ok {
continue // unbekanntes/gelöschtes Profil defensiv ignorieren
}
if prof.UpdatedAt.After(effUpdated) {
effUpdated = prof.UpdatedAt
}
for _, id := range prof.RuleExclusions {
if _, ok := seen[id]; !ok {
seen[id] = struct{}{}
merged = append(merged, id)
}
}
}
return merged, effUpdated
}

View File

@@ -48,12 +48,23 @@ func buildDirectives(cfg models.WafConfig, crsDir string) string {
pl = 1
}
fmt.Fprintf(&sb, "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 {
fmt.Fprintf(&sb, "Include %s\n", setupConf)
includeIfExists(&sb, filepath.Join(crsDir, "crs-setup.conf"))
// CRS-App-Exclusion-Plugins: config + before laufen VOR den CRS-Rules
// (setzen Enable-Vars + pfad-scoped ctl:ruleRemoveById), after DANACH —
// exakt nach OWASP-CRS-Plugin-Spec. Es werden NUR die für DIESE Domain
// gewählten Plugins inkludiert (per-Domain, nicht global).
plugins := resolveCRSPlugins(cfg.CRSPlugins)
for _, prefix := range plugins {
includeIfExists(&sb, filepath.Join(crsDir, "plugins", prefix+"-config.conf"))
includeIfExists(&sb, filepath.Join(crsDir, "plugins", prefix+"-before.conf"))
}
// rules/*.conf ist ein Glob (kein Stat) — crsAvailable() hat oben bereits
// bestätigt, dass mind. eine .conf existiert.
fmt.Fprintf(&sb, "Include %s\n", filepath.Join(crsDir, "rules", "*.conf"))
for _, prefix := range plugins {
includeIfExists(&sb, filepath.Join(crsDir, "plugins", prefix+"-after.conf"))
}
rulesGlob := filepath.Join(crsDir, "rules", "*.conf")
fmt.Fprintf(&sb, "Include %s\n", rulesGlob)
}
// Rule exclusions (applied after CRS load so they override CRS).
@@ -78,6 +89,35 @@ func buildDirectives(cfg models.WafConfig, crsDir string) string {
return sb.String()
}
// KnownCRSPlugins mappt den kurzen Plugin-Namen (gespeichert in
// waf_configs.crs_plugins, im UI gewählt) auf sein Datei-Prefix in
// <crsDir>/plugins/. Nur diese werden paketiert (postinst) und akzeptiert.
var KnownCRSPlugins = map[string]string{
"nextcloud": "nextcloud-rule-exclusions",
"wordpress": "wordpress-rule-exclusions",
"drupal": "drupal-rule-exclusions",
}
// resolveCRSPlugins mappt gewählte Plugin-Namen auf ihre Datei-Prefixe und
// filtert unbekannte/leere raus — defensiv, nie ungültige Includes rendern.
func resolveCRSPlugins(names []string) []string {
out := make([]string, 0, len(names))
for _, n := range names {
if prefix, ok := KnownCRSPlugins[strings.TrimSpace(n)]; ok {
out = append(out, prefix)
}
}
return out
}
// includeIfExists rendert eine Include-Zeile nur, wenn die Datei existiert — so
// bricht ein gewähltes-aber-nicht-installiertes Plugin die Config nicht.
func includeIfExists(sb *strings.Builder, path string) {
if _, err := os.Stat(path); err == nil {
fmt.Fprintf(sb, "Include %s\n", path)
}
}
func ruleEngineMode(mode string) string {
switch mode {
case "blocking":

View File

@@ -0,0 +1,60 @@
package waf
import (
"os"
"path/filepath"
"strings"
"testing"
"git.netcell-it.de/projekte/edgeguard-native/internal/models"
)
// mustWrite legt eine Datei (inkl. Verzeichnis) an.
func mustWrite(t *testing.T, p, content string) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(p, []byte(content), 0o644); err != nil {
t.Fatal(err)
}
}
func TestBuildDirectives_CRSPluginIncludeOrder(t *testing.T) {
crs := t.TempDir()
mustWrite(t, filepath.Join(crs, "crs-setup.conf"), "# setup\n")
mustWrite(t, filepath.Join(crs, "rules", "REQUEST-942.conf"), "# rules\n")
mustWrite(t, filepath.Join(crs, "plugins", "nextcloud-rule-exclusions-config.conf"), "# nc config\n")
mustWrite(t, filepath.Join(crs, "plugins", "nextcloud-rule-exclusions-before.conf"), "# nc before\n")
cfg := models.WafConfig{Mode: "blocking", ParanoiaLevel: 1, CRSPlugins: []string{"nextcloud", "unknown-x"}}
out := buildDirectives(cfg, crs)
iSetup := strings.Index(out, "crs-setup.conf")
iCfg := strings.Index(out, "nextcloud-rule-exclusions-config.conf")
iBefore := strings.Index(out, "nextcloud-rule-exclusions-before.conf")
iRules := strings.Index(out, filepath.Join("rules", "*.conf"))
if iSetup < 0 || iCfg < 0 || iBefore < 0 || iRules < 0 {
t.Fatalf("erwartete Includes fehlen:\n%s", out)
}
// config + before MÜSSEN vor den CRS-Rules stehen (Plugin-Spec).
if iSetup >= iCfg || iCfg >= iBefore || iBefore >= iRules {
t.Errorf("falsche Include-Reihenfolge (setup=%d cfg=%d before=%d rules=%d):\n%s", iSetup, iCfg, iBefore, iRules, out)
}
// Unbekanntes Plugin darf NICHT inkludiert werden (Whitelist).
if strings.Contains(out, "unknown-x") {
t.Errorf("unbekanntes Plugin wurde inkludiert:\n%s", out)
}
// Nicht existente after.conf → keine Include-Zeile.
if strings.Contains(out, "nextcloud-rule-exclusions-after.conf") {
t.Errorf("nicht existente after.conf wurde inkludiert:\n%s", out)
}
}
func TestResolveCRSPlugins(t *testing.T) {
got := resolveCRSPlugins([]string{"nextcloud", " wordpress ", "bogus", ""})
want := "nextcloud-rule-exclusions,wordpress-rule-exclusions"
if strings.Join(got, ",") != want {
t.Errorf("resolveCRSPlugins=%v want %q", got, want)
}
}

View File

@@ -41,6 +41,7 @@ func (a *SPOEAgent) handle(ctx context.Context, w *encoding.ActionWriter, m *enc
httpVer string
host string
rawHdrs string
body []byte // gepufferter Request-Body (via HAProxy option http-buffer-request)
)
// Iterate over the key-value pairs HAProxy sent with this message.
@@ -63,6 +64,12 @@ func (a *SPOEAgent) handle(ctx context.Context, w *encoding.ActionWriter, m *enc
host = string(entry.ValueBytes())
case entry.NameEquals("headers"):
rawHdrs = string(entry.ValueBytes())
case entry.NameEquals("body"):
// Kopieren: entry wird nach Reset() wiederverwendet, der
// zugrundeliegende Puffer darf nicht referenziert bleiben.
if b := entry.ValueBytes(); len(b) > 0 {
body = append([]byte(nil), b...)
}
}
entry.Reset()
}
@@ -119,6 +126,34 @@ func (a *SPOEAgent) handle(ctx context.Context, w *encoding.ActionWriter, m *enc
// Evaluate request headers.
interruption := tx.ProcessRequestHeaders()
// Request-Body inspizieren (POST/PUT-Payloads: Form-SQLi, JSON-Injection,
// Uploads). Nur wenn die Header-Phase noch nicht geblockt hat. HAProxy
// liefert den Body via `option http-buffer-request` (bis tune.bufsize) —
// größere Bodies werden zur Prüfung gekappt. Content-Type kam bereits
// über die Header, sodass Coraza urlencoded/multipart/json korrekt parst.
if interruption == nil {
if len(body) > 0 {
if it, _, err := tx.WriteRequestBody(body); err != nil {
slog.Warn("waf: WriteRequestBody", "error", err)
} else if it != nil {
interruption = it
}
}
// ProcessRequestBody MUSS immer laufen — auch ohne Body. In Coraza wird
// die GESAMTE Phase 2 (SQLi 942xxx, XSS 941xxx, die den Query-String/ARGS
// prüfen) erst hier ausgewertet. Wurde das an len(body)>0 gekoppelt,
// blieben GET-Requests ohne Body von allen Phase-2-Regeln ungeprüft →
// Query-String-Angriffe (?id=1' OR 1=1, ?x=<script>) liefen komplett
// durch. Der häufigste Web-Angriffsvektor war damit blind.
if interruption == nil {
if it, err := tx.ProcessRequestBody(); err != nil {
slog.Warn("waf: ProcessRequestBody", "error", err)
} else if it != nil {
interruption = it
}
}
}
// Log all matched rules (detection + blocking).
for _, mr := range tx.MatchedRules() {
a.sendAlert(host, clientIP, method, uri, mr, interruption != nil)

View File

@@ -0,0 +1,50 @@
package waf
import (
"testing"
"github.com/corazawaf/coraza/v3"
)
// TestPhase2RequiresProcessRequestBody nagelt die Coraza-Semantik fest, die der
// SPOE-Bug verletzt hatte: Phase-2-Regeln (SQLi 942xxx, XSS 941xxx — sie prüfen
// ARGS aus dem Query-String) werden ERST von ProcessRequestBody() ausgewertet.
// Koppelt man ProcessRequestBody an len(body)>0, bleiben GET-Requests ohne Body
// von der gesamten Phase 2 ungeprüft. Dieser Test schlägt fehl, sollte jemand
// den Aufruf je wieder body-abhängig machen.
func TestPhase2RequiresProcessRequestBody(t *testing.T) {
waf, err := coraza.NewWAF(coraza.NewWAFConfig().WithDirectives(
"SecRuleEngine DetectionOnly\n" +
"SecRule ARGS \"@rx attackpattern\" \"id:1234,phase:2,log,msg:'phase2-arg'\"\n"))
if err != nil {
t.Fatalf("NewWAF: %v", err)
}
tx := waf.NewTransaction()
defer func() { tx.ProcessLogging(); _ = tx.Close() }()
tx.ProcessConnection("1.2.3.4", 0, "", 0)
tx.ProcessURI("/?x=attackpattern", "GET", "HTTP/1.1") // GET, kein Body
tx.AddRequestHeader("Host", "test")
tx.ProcessRequestHeaders()
// Vor ProcessRequestBody darf die Phase-2-Regel noch NICHT gefeuert haben.
if n := len(tx.MatchedRules()); n != 0 {
t.Fatalf("vor ProcessRequestBody: %d Matches, erwarte 0", n)
}
if _, err := tx.ProcessRequestBody(); err != nil {
t.Fatalf("ProcessRequestBody: %v", err)
}
// Jetzt MUSS die Phase-2-Regel gegen den Query-Arg gefeuert haben.
found := false
for _, mr := range tx.MatchedRules() {
if mr.Rule().ID() == 1234 {
found = true
}
}
if !found {
t.Fatal("Phase-2-Regel feuerte auch nach ProcessRequestBody nicht — " +
"GET-Query-Args würden ungeprüft bleiben (SPOE-Bug)")
}
}

8
management-ui/go.mod Normal file
View File

@@ -0,0 +1,8 @@
// Modul-Grenze, kein eigenständiges Go-Modul mit Code. Verhindert dass
// `go vet/build/test ./...` im Root-Modul in management-ui/node_modules/
// hineinläuft — npm-Pakete (z.B. flatted) shippen dort teils rohe .go-
// Dateien ohne eigenes go.mod, die sich bei jedem bun/npm install ändern
// können und sonst ungefragt in EdgeGuards Go-Quality-Gates landen.
module git.netcell-it.de/projekte/edgeguard-native/management-ui
go 1.26

View File

@@ -454,6 +454,8 @@
"summaryCritical": "{{critical}} kritisch"
},
"downBackendsAlert": "{{count}} Backend(s) komplett ausgefallen — kein Server UP",
"backupNodeBackends": "Standby-Node — Backend-Health lokal nicht aussagekräftig",
"backupNodeBackendsDesc": "Dieser Node ist gerade keepalived-BACKUP und hält die VLAN-Gateway-VIPs nicht, kann die Backend-Subnetze also nicht erreichen — die lokalen Health-Checks laufen deshalb alle auf Timeout. Kein Ausfall: der Master-Node bedient den Traffic. Nach einem Failover (VIP übernimmt dieser Node) werden die Backends hier UP.",
"maintenanceAlert": "{{count}} Domain(s) im Wartungs-Modus",
"onboardingTitle": "Willkommen bei EdgeGuard",
"onboardingIntro": "Frische Box — hier die nächsten Schritte um Customer-Traffic zu routen:",
@@ -565,7 +567,9 @@
"routingRulesHint": "Pfad-Präfix → Backend-Zuordnungen für diese Domain. Niedrigste Prioritätszahl gewinnt; nicht gematchte Anfragen gehen an das Primary-Backend.",
"routingRulesEmpty": "Keine Routing-Regeln — alle Anfragen gehen an das Primary-Backend.",
"backendUp": "Backend UP",
"backendDown": "Backend DOWN"
"backendDown": "Backend DOWN",
"crowdsecTrusted": "Vertrauenswürdiges Admin-Panel (CrowdSec-Ausnahme)",
"crowdsecTrustedHint": "Für Admin-Oberflächen (SPA), die beim Bedienen viele /api/-Requests feuern. Nimmt diesen Host von der CrowdSec-Crawl-Erkennung aus, damit du nicht fälschlich gebannt wirst. Die WAF schützt die Domain weiterhin."
},
"backends": {
"title": "Backends",
@@ -592,6 +596,8 @@
"websocketHint": "An: erlaubt langlebige WebSocket-/Long-Poll-Verbindungen (z. B. Proxmox-Console, SSH-WS, AsyncAPI) — Tunnel-Idle 1h statt 60s. Aus: strikte HTTP-Timeouts.",
"forceHttp1": "HTTP/1.1 erzwingen",
"forceHttp1Hint": "Deaktiviert HTTP/2 (h2) auf der Backend-Verbindung — HAProxy handelt nur noch HTTP/1.1 aus. Nötig für Backends die kein h2 unterstützen (z. B. ältere nginx-Konfigurationen ohne h2-Modul, Legacy-Apps).",
"serverTimeout": "Antwort-Timeout (timeout server)",
"serverTimeoutHint": "Wie lange HAProxy auf die Antwort dieses Backends wartet, bevor es abbricht. Leer = Default (60s). Höher setzen für langsame Upstreams die NICHT streamen (z. B. KI-/Inferenz-Server mit gepufferter Antwort). Achtung: gilt als Inaktivitäts-Timeout — streamende Backends (SSE/chunked) brauchen das meist nicht.",
"servers": "Server",
"noServers": "kein Server",
"noServersWarning": "Ein oder mehrere aktive Backends haben keine Server konfiguriert.",
@@ -919,6 +925,17 @@
"autoUpdateHint": "Whitelist umfasst nur edgeguard, edgeguard-api, edgeguard-ui. Andere Pakete bleiben unter manueller Kontrolle. Verlangt unattended-upgrades (Distro-Standard auf Trixie). Conf-File: /etc/apt/apt.conf.d/52edgeguard-auto-updates.",
"autoUpdateToggled": "Auto-Update-Einstellung gespeichert.",
"autoUpdateFailed": "Auto-Update-Toggle fehlgeschlagen",
"updateChannelCardTitle": "Update-Kanal",
"updateChannelStable": "Stable",
"updateChannelTesting": "Testing",
"updateChannelApply": "Anwenden",
"updateChannelConfirmTitle": "Update-Kanal wechseln?",
"updateChannelSwitchTestingWarn": "Testing kann instabile Zwischenstände enthalten. Beide Nodes werden umgestellt.",
"updateChannelSwitchStableWarn": "Wechsel zurück nach Stable kann ein Downgrade auf beiden Nodes auslösen (Testing-Versionen sind neuer datiert).",
"updateChannelSaved": "Update-Kanal gespeichert (beide Nodes).",
"updateChannelFailed": "Update-Kanal-Wechsel fehlgeschlagen",
"updateChannelDrift": "Kanal-Drift zum Peer-Node erkannt (Peer: {{peer}}) — beim nächsten Wechsel wird synchronisiert.",
"updateChannelHint": "Testing zieht datumsbasierte Zwischenversionen aus dem Testing-Repo, Stable die kuratierten Releases. Kanal gilt für beide HA-Nodes synchron.",
"ipv6CardTitle": "IPv6",
"ipv6On": "Aktiviert — HAProxy bindet zusätzlich zu IPv4 auf [::]:80, [::]:443 und [::]:3443.",
"ipv6Off": "Deaktiviert — HAProxy lauscht nur auf IPv4.",
@@ -1503,6 +1520,12 @@
"emptyEventsDesc": "Triggers (Cert-Expiry, Backup-Fail, Cluster-Drift, License-Invalid, etc.) haben noch keinen Event gefeuert. Wenn sie feuern, landen sie hier und werden an die konfigurierten Channels zugestellt.",
"noChannels": "kein Channel aktiv",
"confirmDelete": "Channel {{name}} wirklich löschen?",
"acknowledge": "Quittieren",
"acknowledgeAll": "Alle quittieren",
"acknowledged": "Quittiert",
"acked": "quittiert",
"open": "offen",
"confirmDeleteEvents": "{{n}} Event(s) endgültig löschen?",
"kindWebhook": "Webhook (Slack/Discord/Teams/HTTP-Endpoint)",
"kindEmail": "E-Mail (SMTP)",
"smtp": {
@@ -1523,7 +1546,8 @@
"time": "Zeit",
"severity": "Severity",
"subject": "Betreff",
"delivered": "Gesendet"
"delivered": "Gesendet",
"status": "Status"
}
},
"remotes": {
@@ -1869,7 +1893,11 @@
"deleteFailed": "Löschen fehlgeschlagen",
"secretSet": "Gespeichert — leer lassen, um es unverändert zu lassen.",
"secretUnset": "Noch nichts gespeichert.",
"tabs": { "settings": "Einstellungen", "clients": "Clients (NAS)", "users": "Benutzer" },
"tabs": {
"settings": "Einstellungen",
"clients": "Clients (NAS)",
"users": "Benutzer"
},
"settings": {
"enabled": "RADIUS auf dieser Node aktiv",
"listen": "Listen-Adressen"
@@ -1923,6 +1951,9 @@
"enabled": "Aktiviert",
"mode": "Modus",
"paranoia": "Paranoia-Level",
"crsPlugins": "App-Profile (CRS-Plugins)",
"crsPluginsHint": "Offizielle OWASP-CRS-Exclusion-Plugins für bekannte Apps — deaktivieren automatisch die typischen False-Positive-Regeln pfad-genau (z.B. Nextcloud-WebDAV, WordPress-Editor). Sauberer als manuelle Regel-IDs.",
"crsPluginsPlaceholder": "App-Profile wählen (optional)",
"exclusions": "Regel-Ausnahmen",
"exclusionsHint": "Kommagetrennte Regel-IDs die deaktiviert werden (z.B. 920350, 941130).",
"trustedProxies": "Vertrauenswürdige Proxys",
@@ -1933,11 +1964,19 @@
"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."
"exclusionsAddHint": "Regel oben suchen und hinzufügen — oder im Alarme-Tab per \"Als Ausnahme\" auf einem Alarm.",
"exclusionAddPlaceholder": "Regel suchen (ID oder Beschreibung)…",
"exclusionAddNote": "Notiz (optional)",
"exclusionAddNotFound": "Keine Regel gefunden",
"exclusionAddBtn": "Hinzufügen",
"appProfiles": "Eigene App-Profile",
"appProfilesHint": "Wiederverwendbare, selbst gepflegte Profile (Regel-ID-Bündel) — zentral unter „App-Profile“ anlegen und hier pro Domain zuweisen. Ergänzt die OWASP-Plugins oben.",
"appProfilesPlaceholder": "Eigene Profile wählen (optional)"
},
"tabs": {
"domains": "Domains",
"alerts": "Alarme"
"alerts": "Alarme",
"profiles": "App-Profile"
},
"alerts": {
"total": "Einträge",
@@ -1966,6 +2005,39 @@
"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"
},
"profiles": {
"intro": "Wiederverwendbare Ausnahme-Profile (Bündel von CRS-Regel-IDs). Einmal anlegen, pro Domain zuweisen. Die eingebauten OWASP-Plugins sind read-only und werden pro Domain gewählt.",
"new": "Neues Profil",
"empty": "Noch keine eigenen Profile. Lege eins an, um Regel-Ausnahmen wiederzuverwenden.",
"builtinInfo": "Eingebaute OWASP-CRS-Plugins (dateibasiert, gepflegt). Read-only — pro Domain im WAF-Drawer wählbar. „Als Vorlage“ erstellt daraus ein leeres eigenes Profil zum Befüllen.",
"typeBuiltin": "OWASP",
"typeCustom": "Eigen",
"col": {
"name": "Name",
"type": "Typ",
"description": "Beschreibung",
"rules": "Ausnahmen"
},
"edit": "Bearbeiten",
"delete": "Löschen",
"clone": "Klonen",
"asTemplate": "Als Vorlage",
"deleteConfirm": "Profil wirklich löschen? Zuweisungen an Domains verlieren dann diese Ausnahmen.",
"createTitle": "Neues App-Profil",
"editTitle": "App-Profil bearbeiten",
"name": "Name",
"namePlaceholder": "z.B. Meine WebApp",
"description": "Beschreibung",
"descriptionPlaceholder": "Wofür ist dieses Profil? (optional)",
"rules": "Regel-Ausnahmen",
"rulesHint": "CRS-Regel-IDs die für zugewiesene Domains deaktiviert werden. Durchsuchbar nach ID oder Beschreibung.",
"rulesPlaceholder": "Regel-IDs suchen und hinzufügen…",
"saved": "Profil gespeichert.",
"saveFailed": "Profil konnte nicht gespeichert werden.",
"deleted": "Profil gelöscht.",
"deleteFailed": "Profil konnte nicht gelöscht werden.",
"cloneSuffix": "Kopie"
}
}
}
}

View File

@@ -454,6 +454,8 @@
"summaryCritical": "{{critical}} critical"
},
"downBackendsAlert": "{{count}} backend(s) completely down — no server UP",
"backupNodeBackends": "Standby node — local backend health is not meaningful",
"backupNodeBackendsDesc": "This node is currently the keepalived BACKUP and does not hold the VLAN gateway VIPs, so it cannot reach the backend subnets — local health checks all time out. This is not an outage: the master node serves the traffic. After a failover (this node takes over the VIP), the backends will show UP here.",
"maintenanceAlert": "{{count}} domain(s) in maintenance mode",
"onboardingTitle": "Welcome to EdgeGuard",
"onboardingIntro": "Fresh box — here are the next steps to route customer traffic:",
@@ -565,7 +567,9 @@
"routingRulesHint": "Path-prefix → backend mappings for this domain. Lowest priority number wins; unmatched requests go to the primary backend.",
"routingRulesEmpty": "No routing rules — all requests go to the primary backend.",
"backendUp": "backend UP",
"backendDown": "backend DOWN"
"backendDown": "backend DOWN",
"crowdsecTrusted": "Trusted admin panel (CrowdSec exemption)",
"crowdsecTrustedHint": "For admin UIs (SPAs) that fire many /api/ requests while you use them. Exempts this host from CrowdSec crawl detection so you don't get falsely banned. The WAF still protects the domain."
},
"backends": {
"title": "Backends",
@@ -592,6 +596,8 @@
"websocketHint": "On: allow long-lived WebSocket / long-poll connections (Proxmox console, SSH-over-WS, AsyncAPI) — tunnel idle 1h instead of 60s. Off: strict HTTP timeouts.",
"forceHttp1": "Force HTTP/1.1",
"forceHttp1Hint": "Disables HTTP/2 (h2) on the backend connection — HAProxy negotiates HTTP/1.1 only. Required for backends that don't support h2 (e.g. older nginx configs without the h2 module, legacy apps).",
"serverTimeout": "Response timeout (timeout server)",
"serverTimeoutHint": "How long HAProxy waits for this backend's response before aborting. Empty = default (60s). Raise it for slow upstreams that do NOT stream (e.g. AI/inference servers with a buffered response). Note: this is an inactivity timeout — streaming backends (SSE/chunked) usually don't need it.",
"servers": "Servers",
"noServers": "no server",
"noServersWarning": "One or more active backends have no servers configured.",
@@ -919,6 +925,17 @@
"autoUpdateHint": "Whitelist covers edgeguard, edgeguard-api, edgeguard-ui only. Other packages stay under manual control. Requires unattended-upgrades (Trixie distro default). Conf file: /etc/apt/apt.conf.d/52edgeguard-auto-updates.",
"autoUpdateToggled": "Auto-update setting saved.",
"autoUpdateFailed": "Auto-update toggle failed",
"updateChannelCardTitle": "Update channel",
"updateChannelStable": "Stable",
"updateChannelTesting": "Testing",
"updateChannelApply": "Apply",
"updateChannelConfirmTitle": "Switch update channel?",
"updateChannelSwitchTestingWarn": "Testing may contain unstable interim builds. Both nodes will be switched.",
"updateChannelSwitchStableWarn": "Switching back to stable may trigger a downgrade on both nodes (testing versions are dated newer).",
"updateChannelSaved": "Update channel saved (both nodes).",
"updateChannelFailed": "Update channel switch failed",
"updateChannelDrift": "Channel drift detected against the peer node (peer: {{peer}}) — will sync on the next switch.",
"updateChannelHint": "Testing pulls date-stamped interim builds from the testing repo, stable pulls curated releases. The channel applies to both HA nodes in sync.",
"ipv6CardTitle": "IPv6",
"ipv6On": "Enabled — HAProxy binds on [::]:80, [::]:443 and [::]:3443 in addition to IPv4.",
"ipv6Off": "Disabled — HAProxy listens on IPv4 only.",
@@ -1503,6 +1520,12 @@
"emptyEventsDesc": "Triggers (cert expiry, backup failure, cluster drift, license invalid, etc.) haven't fired any events yet. When they do, they land here and get delivered to the configured channels.",
"noChannels": "no active channel",
"confirmDelete": "Really delete channel {{name}}?",
"acknowledge": "Acknowledge",
"acknowledgeAll": "Acknowledge all",
"acknowledged": "Acknowledged",
"acked": "acknowledged",
"open": "open",
"confirmDeleteEvents": "Permanently delete {{n}} event(s)?",
"kindWebhook": "Webhook (Slack/Discord/Teams/HTTP endpoint)",
"kindEmail": "Email (SMTP)",
"smtp": {
@@ -1523,7 +1546,8 @@
"time": "Time",
"severity": "Severity",
"subject": "Subject",
"delivered": "Delivered"
"delivered": "Delivered",
"status": "Status"
}
},
"remotes": {
@@ -1869,7 +1893,11 @@
"deleteFailed": "Delete failed",
"secretSet": "Stored — leave empty to keep unchanged.",
"secretUnset": "Nothing stored yet.",
"tabs": { "settings": "Settings", "clients": "Clients (NAS)", "users": "Users" },
"tabs": {
"settings": "Settings",
"clients": "Clients (NAS)",
"users": "Users"
},
"settings": {
"enabled": "RADIUS active on this node",
"listen": "Listen addresses"
@@ -1923,6 +1951,9 @@
"enabled": "Enabled",
"mode": "Mode",
"paranoia": "Paranoia Level",
"crsPlugins": "App profiles (CRS plugins)",
"crsPluginsHint": "Official OWASP CRS exclusion plugins for well-known apps — automatically disable the typical false-positive rules in a path-scoped way (e.g. Nextcloud WebDAV, WordPress editor). Cleaner than manual rule IDs.",
"crsPluginsPlaceholder": "Select app profiles (optional)",
"exclusions": "Rule Exclusions",
"exclusionsHint": "Comma-separated rule IDs to disable (e.g. 920350, 941130).",
"trustedProxies": "Trusted Proxies",
@@ -1933,11 +1964,19 @@
"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."
"exclusionsAddHint": "Search for a rule above and add it — or via the Alerts tab with \"Add exception\" on an alert.",
"exclusionAddPlaceholder": "Search rule (ID or description)…",
"exclusionAddNote": "Note (optional)",
"exclusionAddNotFound": "No rule found",
"exclusionAddBtn": "Add",
"appProfiles": "Custom App Profiles",
"appProfilesHint": "Reusable, self-maintained profiles (bundles of rule IDs) — create them centrally under “App Profiles” and assign them here per domain. Complements the OWASP plugins above.",
"appProfilesPlaceholder": "Select custom profiles (optional)"
},
"tabs": {
"domains": "Domains",
"alerts": "Alerts"
"alerts": "Alerts",
"profiles": "App Profiles"
},
"alerts": {
"total": "entries",
@@ -1966,6 +2005,39 @@
"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"
},
"profiles": {
"intro": "Reusable exclusion profiles (bundles of CRS rule IDs). Create once, assign per domain. The built-in OWASP plugins are read-only and selected per domain.",
"new": "New Profile",
"empty": "No custom profiles yet. Create one to reuse rule exclusions.",
"builtinInfo": "Built-in OWASP CRS plugins (file-based, maintained). Read-only — selectable per domain in the WAF drawer. “Use as template” creates an empty custom profile from it to fill in.",
"typeBuiltin": "OWASP",
"typeCustom": "Custom",
"col": {
"name": "Name",
"type": "Type",
"description": "Description",
"rules": "Exclusions"
},
"edit": "Edit",
"delete": "Delete",
"clone": "Clone",
"asTemplate": "Use as template",
"deleteConfirm": "Really delete this profile? Domains using it will lose these exclusions.",
"createTitle": "New App Profile",
"editTitle": "Edit App Profile",
"name": "Name",
"namePlaceholder": "e.g. My WebApp",
"description": "Description",
"descriptionPlaceholder": "What is this profile for? (optional)",
"rules": "Rule Exclusions",
"rulesHint": "CRS rule IDs disabled for assigned domains. Searchable by ID or description.",
"rulesPlaceholder": "Search and add rule IDs…",
"saved": "Profile saved.",
"saveFailed": "Could not save profile.",
"deleted": "Profile deleted.",
"deleteFailed": "Could not delete profile.",
"cloneSuffix": "Copy"
}
}
}
}

View File

@@ -8,6 +8,7 @@ import {
} from '@ant-design/icons'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import { useSearchParams } from 'react-router-dom'
import dayjs from 'dayjs'
import apiClient, { isEnvelope } from '../../api/client'
@@ -41,6 +42,7 @@ interface AlertEvent {
message: string
sent_to: SendResult[]
fired_at: string
acknowledged_at?: string | null
}
interface ChannelFormValues {
@@ -69,6 +71,14 @@ export default function AlertsPage() {
const qc = useQueryClient()
const isViewer = useAuthStore((s) => s.user?.role) === 'viewer'
// Tab über ?tab= steuerbar, damit der Dashboard-Deeplink
// (/alerts?tab=events) direkt auf der Event-History landet statt auf
// dem Channels-Tab. Default bleibt 'channels' für den Direktaufruf.
const [searchParams, setSearchParams] = useSearchParams()
const activeTab = searchParams.get('tab') === 'events' ? 'events' : 'channels'
const setActiveTab = (key: string) =>
setSearchParams(key === 'events' ? { tab: 'events' } : {}, { replace: true })
const channels = useQuery({
queryKey: ['alerts', 'channels'],
queryFn: async () => {
@@ -90,6 +100,29 @@ export default function AlertsPage() {
const [form] = Form.useForm<ChannelFormValues>()
const [filterSev, setFilterSev] = useState<string | undefined>()
const [filterKind, setFilterKind] = useState<string | undefined>()
const [selectedIds, setSelectedIds] = useState<number[]>([])
// Nach Quittieren/Löschen sowohl die Event-Liste als auch die Dashboard-
// Karte (queryKey ['alerts','events','recent']) invalidieren — Prefix-Match.
const refreshEvents = () => {
setSelectedIds([])
void qc.invalidateQueries({ queryKey: ['alerts', 'events'] })
}
const ackMut = useMutation({
mutationFn: (ids: number[]) => apiClient.post('/alerts/events/acknowledge', { ids }),
onSuccess: () => { message.success(t('alerts.acknowledged')); refreshEvents() },
onError: (e: Error) => message.error(e.message),
})
const ackAllMut = useMutation({
mutationFn: () => apiClient.post('/alerts/events/acknowledge-all'),
onSuccess: () => { message.success(t('alerts.acknowledged')); refreshEvents() },
onError: (e: Error) => message.error(e.message),
})
const delEventsMut = useMutation({
mutationFn: (ids: number[]) => apiClient.post('/alerts/events/delete', { ids }),
onSuccess: () => { message.success(t('common.delete')); refreshEvents() },
onError: (e: Error) => message.error(e.message),
})
const kindOptions = useMemo(() => {
const kinds = [...new Set((events.data ?? []).map(e => e.kind))].sort()
@@ -267,6 +300,14 @@ export default function AlertsPage() {
)
},
},
{
title: t('alerts.col.status'), key: 'ack', width: 110,
render: (_, r: AlertEvent) => r.acknowledged_at
? <Tooltip title={dayjs(r.acknowledged_at).format('YYYY-MM-DD HH:mm')}>
<Tag color="green">{t('alerts.acked')}</Tag>
</Tooltip>
: <Tag color="gold">{t('alerts.open')}</Tag>,
},
]
const kind = Form.useWatch('kind', form)
@@ -304,7 +345,7 @@ export default function AlertsPage() {
description={t('alerts.scopeDesc')}
/>
<Tabs items={[
<Tabs activeKey={activeTab} onChange={setActiveTab} items={[
{
key: 'channels',
label: t('alerts.tabs.channels'),
@@ -342,33 +383,62 @@ export default function AlertsPage() {
label: t('alerts.tabs.events'),
children: (
<Card size="small">
<Space className="mb-8">
<Select
allowClear
placeholder={t('alerts.col.severity')}
style={{ width: 150 }}
value={filterSev}
onChange={setFilterSev}
options={[
{ value: 'info', label: <Tag color="blue">INFO</Tag> },
{ value: 'warning', label: <Tag color="orange">WARNING</Tag> },
{ value: 'error', label: <Tag color="red">ERROR</Tag> },
{ value: 'critical', label: <Tag color="magenta">CRITICAL</Tag> },
]}
/>
<Select
allowClear
showSearch
placeholder={t('alerts.col.kind')}
style={{ width: 220 }}
value={filterKind}
onChange={setFilterKind}
options={kindOptions}
/>
</Space>
<div style={{ display: 'flex', justifyContent: 'space-between', flexWrap: 'wrap', gap: 8 }} className="mb-8">
<Space>
<Select
allowClear
placeholder={t('alerts.col.severity')}
style={{ width: 150 }}
value={filterSev}
onChange={setFilterSev}
options={[
{ value: 'info', label: <Tag color="blue">INFO</Tag> },
{ value: 'warning', label: <Tag color="orange">WARNING</Tag> },
{ value: 'error', label: <Tag color="red">ERROR</Tag> },
{ value: 'critical', label: <Tag color="magenta">CRITICAL</Tag> },
]}
/>
<Select
allowClear
showSearch
placeholder={t('alerts.col.kind')}
style={{ width: 220 }}
value={filterKind}
onChange={setFilterKind}
options={kindOptions}
/>
</Space>
<Space>
<Tooltip title={isViewer ? t('auth.viewerBadge') : undefined}>
<Button size="small" disabled={isViewer || selectedIds.length === 0}
loading={ackMut.isPending} onClick={() => ackMut.mutate(selectedIds)}>
{t('alerts.acknowledge')}{selectedIds.length > 0 ? ` (${selectedIds.length})` : ''}
</Button>
</Tooltip>
<Popconfirm title={t('alerts.confirmDeleteEvents', { n: selectedIds.length })}
onConfirm={() => delEventsMut.mutate(selectedIds)}
disabled={isViewer || selectedIds.length === 0}>
<Button size="small" danger disabled={isViewer || selectedIds.length === 0}
loading={delEventsMut.isPending}>
{t('common.delete')}{selectedIds.length > 0 ? ` (${selectedIds.length})` : ''}
</Button>
</Popconfirm>
<Tooltip title={isViewer ? t('auth.viewerBadge') : undefined}>
<Button size="small" type="primary" ghost disabled={isViewer}
loading={ackAllMut.isPending} onClick={() => ackAllMut.mutate()}>
{t('alerts.acknowledgeAll')}
</Button>
</Tooltip>
</Space>
</div>
<Table size="small" rowKey="id" loading={events.isFetching}
dataSource={filteredEvents} columns={evColumns}
pagination={{ pageSize: 25 }}
rowSelection={{
selectedRowKeys: selectedIds,
onChange: (keys) => setSelectedIds(keys as number[]),
}}
rowClassName={(r) => (r.acknowledged_at ? 'eg-row-muted' : '')}
pagination={{ defaultPageSize: 25 }}
locale={{ emptyText: (
<EmptyState
icon={<BellOutlined />}

View File

@@ -20,13 +20,17 @@ interface Backend {
id: number; name: string; scheme: string
health_check_path?: string | null
lb_algorithm: 'roundrobin' | 'leastconn' | 'source'
websocket: boolean; force_http1: boolean; active: boolean
websocket: boolean; force_http1: boolean
server_timeout_seconds?: number | null
active: boolean
}
interface BackendFormValues {
name: string; scheme: 'http' | 'https'
health_check_path?: string
lb_algorithm: 'roundrobin' | 'leastconn' | 'source'
websocket: boolean; force_http1: boolean; active: boolean
websocket: boolean; force_http1: boolean
server_timeout_seconds?: number | null
active: boolean
domain_ids?: number[]
}
interface BackendServer {
@@ -174,6 +178,7 @@ export default function BackendDetailPage() {
lb_algorithm: backend.lb_algorithm,
websocket: backend.websocket,
force_http1: backend.force_http1,
server_timeout_seconds: backend.server_timeout_seconds ?? undefined,
active: backend.active,
domain_ids: attached.map(d => d.id),
}}
@@ -205,6 +210,11 @@ export default function BackendDetailPage() {
extra={t('backends.forceHttp1Hint')}>
<Switch />
</Form.Item>
<Form.Item label={t('backends.serverTimeout')} name="server_timeout_seconds"
extra={t('backends.serverTimeoutHint')}>
<InputNumber min={1} max={86400} step={30}
style={{ width: '100%' }} addonAfter="s" placeholder="60 (default)" />
</Form.Item>
<Form.Item label={t('backends.active')} name="active" valuePropName="checked">
<Switch />
</Form.Item>

View File

@@ -26,6 +26,7 @@ interface Backend {
lb_algorithm: 'roundrobin' | 'leastconn' | 'source'
websocket: boolean
force_http1: boolean
server_timeout_seconds?: number | null
active: boolean
created_at: string
updated_at: string
@@ -266,6 +267,7 @@ export default function BackendsPage() {
<Space size={4}>
<Tag>{v}</Tag>
{row.websocket && <Tag color="cyan">WS</Tag>}
{row.server_timeout_seconds ? <Tag color="gold">{row.server_timeout_seconds}s</Tag> : null}
</Space>
),
},

View File

@@ -350,7 +350,7 @@ export default function HistoryTab() {
loading={list.isFetching}
dataSource={list.data ?? []}
columns={columns}
pagination={{ pageSize: 25, showSizeChanger: true, pageSizeOptions: [25, 50, 100] }}
pagination={{ defaultPageSize: 25, showSizeChanger: true, pageSizeOptions: [25, 50, 100] }}
locale={{ emptyText: t('backups.empty') }}
/>

View File

@@ -225,7 +225,7 @@ function DecisionsTab() {
loading={isLoading}
dataSource={decisions ?? []}
columns={columns}
pagination={{ pageSize: 20 }}
pagination={{ defaultPageSize: 20 }}
/>
<Modal
title={t('cs.decision.banModal')}
@@ -321,7 +321,7 @@ function AlertsTab() {
loading={isLoading}
dataSource={alerts ?? []}
columns={columns}
pagination={{ pageSize: 20 }}
pagination={{ defaultPageSize: 20 }}
/>
</div>
)
@@ -379,7 +379,7 @@ function BouncersTab() {
loading={isLoading}
dataSource={bouncers ?? []}
columns={columns}
pagination={{ pageSize: 20 }}
pagination={{ defaultPageSize: 20 }}
/>
</div>
)
@@ -436,7 +436,7 @@ function MachinesTab() {
loading={isLoading}
dataSource={machines ?? []}
columns={columns}
pagination={{ pageSize: 20 }}
pagination={{ defaultPageSize: 20 }}
/>
</div>
)
@@ -515,7 +515,7 @@ function CollectionsTab() {
loading={isLoading}
dataSource={collections ?? []}
columns={columns}
pagination={{ pageSize: 50 }}
pagination={{ defaultPageSize: 50 }}
/>
</div>
)

View File

@@ -201,7 +201,7 @@ export default function DashboardPage() {
})
const recentAlerts = useQuery({
queryKey: ['alerts', 'events', 'recent'],
queryFn: () => fetchList<AlertEvent>('/alerts/events?limit=10', 'events'),
queryFn: () => fetchList<AlertEvent>('/alerts/events?limit=10&open=true', 'events'),
refetchInterval: 60_000,
})
const services = useQuery({
@@ -303,6 +303,13 @@ export default function DashboardPage() {
return down
})()
// Auf dem keepalived-BACKUP-Node erreicht die lokale HAProxy die Backend-
// Subnetze nicht (die VLAN-Gateway-VIPs liegen beim Master) → sie sieht
// ALLE Backends als down. Das ist strukturell erwartet, kein Ausfall:
// der Master bedient den Traffic. Deshalb den roten Down-Alarm auf dem
// Standby durch einen ruhigen Hinweis ersetzen.
const isBackup = vipStatus.data?.vrrp_state === 'BACKUP'
const alerts = recentAlerts.data ?? []
const nCritical = alerts.filter(e => e.severity === 'critical' || e.severity === 'error').length
const nWarning = alerts.filter(e => e.severity === 'warning').length
@@ -361,18 +368,25 @@ export default function DashboardPage() {
</span>
</Space>
}
action={<Link to="/alerts" style={{ fontSize: 12 }}>{t('dashboard.alertsCard.viewAll')} </Link>}
action={<Link to="/alerts?tab=events" style={{ fontSize: 12 }}>{t('dashboard.alertsCard.viewAll')} </Link>}
/>
)}
{/* ─ Operational alerts ─────────────────────────────── */}
{downBackends.length > 0 && (
{downBackends.length > 0 && !isBackup && (
<Alert
type="error" showIcon className="mb-12"
message={t('dashboard.downBackendsAlert', { count: downBackends.length })}
description={<Space wrap size={4}>{downBackends.map(n => <Link key={n} to="/backends">{n}</Link>)}</Space>}
/>
)}
{downBackends.length > 0 && isBackup && (
<Alert
type="info" showIcon className="mb-12"
message={t('dashboard.backupNodeBackends')}
description={t('dashboard.backupNodeBackendsDesc')}
/>
)}
{maintenanceDomains.length > 0 && (
<Alert
type="warning" showIcon className="mb-12"

View File

@@ -30,6 +30,7 @@ interface Domain {
redirect_to: string
rate_limit_rps: number; max_body_kb: number
disable_h3: boolean
crowdsec_trusted: boolean
notes?: string | null
}
@@ -44,6 +45,7 @@ interface DomainFormValues {
redirect_to: string
rate_limit_rps: number; max_body_kb: number
disable_h3: boolean
crowdsec_trusted: boolean
notes?: string
}
@@ -278,6 +280,7 @@ export default function DomainDetailPage() {
rate_limit_rps: domain.rate_limit_rps ?? 0,
max_body_kb: domain.max_body_kb ?? 0,
disable_h3: domain.disable_h3 ?? false,
crowdsec_trusted: domain.crowdsec_trusted ?? false,
notes: domain.notes ?? '',
}}
onFinish={(v) => update.mutate(v)}
@@ -367,6 +370,11 @@ export default function DomainDetailPage() {
<Switch />
</Form.Item>
<Form.Item label={t('domains.crowdsecTrusted')} name="crowdsec_trusted" valuePropName="checked"
extra={t('domains.crowdsecTrustedHint')}>
<Switch />
</Form.Item>
<Form.Item label={t('domains.notes')} name="notes">
<Input.TextArea rows={2} />
</Form.Item>

View File

@@ -129,6 +129,12 @@ export default function FirewallLivePage() {
pausedRef.current = paused
const pendingDuringPauseRef = useRef<Entry[]>([])
const wsRef = useRef<WebSocket | null>(null)
// tRef statt `t` in den WS-Effect-Deps: `t` wechselt bei jedem
// i18n-Store-Update die Identität. Stünde es in den Deps, würde der
// Effect neu laufen — inkl. `setEntries([])` und WS-Reconnect, d. h.
// der Live-Puffer wäre ohne erkennbaren Grund plötzlich leer.
const tRef = useRef(t)
useEffect(() => { tRef.current = t }, [t])
useEffect(() => {
const t = setTimeout(() => setAppliedFilters(filters), 300)
@@ -187,7 +193,7 @@ export default function FirewallLivePage() {
if (!cancelled && active) scheduleReconnect()
}
ws.onerror = () => {
setError(t('fwlog.connError'))
setError(tRef.current('fwlog.connError'))
}
}
@@ -204,7 +210,7 @@ export default function FirewallLivePage() {
if (reconnectTimer) clearTimeout(reconnectTimer)
if (wsRef.current) { wsRef.current.close(); wsRef.current = null }
}
}, [active, query, t])
}, [active, query])
// Resume: gebufferte Events in die Tabelle mergen.
useEffect(() => {
@@ -412,7 +418,7 @@ export default function FirewallLivePage() {
size="small"
dataSource={[...entries].reverse()}
columns={columns}
pagination={{ pageSize: 50, showSizeChanger: true, pageSizeOptions: [25, 50, 100, 200] }}
pagination={{ defaultPageSize: 50, showSizeChanger: true, pageSizeOptions: [25, 50, 100, 200] }}
locale={{ emptyText: connected ? t('fwlog.empty') : t('fwlog.connecting') }}
/>
</>

View File

@@ -293,7 +293,7 @@ export default function LogsPage() {
loading={logsQuery.isFetching}
dataSource={entries}
columns={columns}
pagination={{ pageSize: 50, showSizeChanger: true, pageSizeOptions: [25, 50, 100, 200] }}
pagination={{ defaultPageSize: 50, showSizeChanger: true, pageSizeOptions: [25, 50, 100, 200] }}
/>
{entries.length === 0 && !logsQuery.isFetching && (

View File

@@ -228,7 +228,7 @@ export default function RoutesTab() {
size="small"
dataSource={live.data ?? []}
columns={liveColumns}
pagination={{ pageSize: 25, showSizeChanger: true, pageSizeOptions: [25, 50, 100] }}
pagination={{ defaultPageSize: 25, showSizeChanger: true, pageSizeOptions: [25, 50, 100] }}
locale={{ emptyText: t('routes.liveEmpty') }}
style={{ marginTop: 12 }}
/>

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 { ApartmentOutlined, CloudDownloadOutlined, CloudSyncOutlined, CodeOutlined, CopyOutlined, DatabaseOutlined, DownloadOutlined, ExclamationCircleOutlined, FileSearchOutlined, GlobalOutlined, LockOutlined, MailOutlined, ReloadOutlined, SettingOutlined, StopOutlined, ToolOutlined } from '@ant-design/icons'
import { ApartmentOutlined, BranchesOutlined, 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'
@@ -342,6 +342,30 @@ export default function SettingsPage() {
},
})
const [channelDraft, setChannelDraft] = useState<string | null>(null)
const { data: updateChannel } = useQuery({
queryKey: ['cluster', 'update-channel'],
queryFn: async () => {
const r = await apiClient.get('/cluster/update-channel')
return isEnvelope(r.data)
? (r.data.data as { channel: string; peer_channel?: string; peer_reached: boolean; peer_drifted: boolean })
: { channel: 'stable', peer_reached: false, peer_drifted: false }
},
})
const setUpdateChannel = useMutation({
mutationFn: async (channel: string) => {
const r = await apiClient.post('/cluster/update-channel', { channel })
return r.data
},
onSuccess: () => {
msg.success(t('settings.updateChannelSaved'))
void qc.invalidateQueries({ queryKey: ['cluster', 'update-channel'] })
void qc.invalidateQueries({ queryKey: ['system', 'package-versions'] })
},
onError: (e: Error) => msg.error(t('settings.updateChannelFailed') + ': ' + e.message),
onSettled: () => setChannelDraft(null),
})
const { data: ipv6 } = useQuery({
queryKey: ['system', 'ipv6'],
queryFn: async () => {
@@ -765,6 +789,55 @@ export default function SettingsPage() {
</Space>
</Card>
<Card
title={<><BranchesOutlined /> {t('settings.updateChannelCardTitle')}</>}
className="mb-12"
size="small"
>
<Space direction="vertical" size={8} style={{ width: '100%' }}>
<Space>
<Select
value={channelDraft ?? updateChannel?.channel ?? 'stable'}
style={{ width: 160 }}
disabled={isViewer}
options={[
{ value: 'stable', label: t('settings.updateChannelStable') },
{ value: 'testing', label: t('settings.updateChannelTesting') },
]}
onChange={setChannelDraft}
/>
{channelDraft && channelDraft !== (updateChannel?.channel ?? 'stable') && (
<Popconfirm
title={t('settings.updateChannelConfirmTitle')}
description={
channelDraft === 'testing'
? t('settings.updateChannelSwitchTestingWarn')
: t('settings.updateChannelSwitchStableWarn')
}
okText={t('settings.updateChannelApply')}
cancelText={t('common.cancel')}
onConfirm={() => setUpdateChannel.mutate(channelDraft)}
onCancel={() => setChannelDraft(null)}
>
<Button size="small" type="primary" loading={setUpdateChannel.isPending}>
{t('settings.updateChannelApply')}
</Button>
</Popconfirm>
)}
</Space>
{updateChannel?.peer_drifted && (
<Alert
type="warning"
showIcon
message={t('settings.updateChannelDrift', { peer: updateChannel?.peer_channel ?? '?' })}
/>
)}
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{t('settings.updateChannelHint')}
</Typography.Text>
</Space>
</Card>
<Card
title={<><GlobalOutlined /> {t('settings.ipv6CardTitle')}</>}
className="mb-12"

View File

@@ -1,11 +1,12 @@
import { useState } from 'react'
import { useEffect, useMemo, useState } from 'react'
import {
Alert, Button, Card, Col, Drawer, Form, Input, Modal, Popconfirm, Row,
Select, Space, Switch, Tabs, Tag, Tooltip, Typography, message,
} from 'antd'
import {
CheckCircleOutlined, CloseCircleOutlined, DeleteOutlined,
SafetyCertificateOutlined, SettingOutlined, WarningOutlined,
CheckCircleOutlined, CloseCircleOutlined, CopyOutlined, DeleteOutlined,
EditOutlined, PlusOutlined, SafetyCertificateOutlined, SettingOutlined,
WarningOutlined,
} from '@ant-design/icons'
import { useTranslation } from 'react-i18next'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
@@ -14,7 +15,7 @@ import apiClient, { isEnvelope } from '../../api/client'
import PageHeader from '../../components/PageHeader'
import DataTable from '../../components/DataTable'
import { useAuthStore } from '../../stores/auth'
import { getRuleDescription } from './crsRules'
import { CRS_RULES, getRuleDescription } from './crsRules'
const { Text } = Typography
@@ -33,11 +34,30 @@ interface WafConfig {
mode: 'detection' | 'blocking'
paranoia_level: number
rule_exclusions: string[]
crs_plugins: string[]
app_profiles: string[]
exclusion_notes: Record<string, string>
trusted_proxies: string[]
custom_rules: string
}
interface WafProfile {
id: number
name: string
description: string
rule_exclusions: string[]
builtin: boolean
created_at?: string
updated_at?: string
}
// CRS-App-Exclusion-Plugins — muss zur Backend-Whitelist (KnownCRSPlugins) passen.
const CRS_PLUGIN_OPTIONS = [
{ value: 'nextcloud', label: 'Nextcloud' },
{ value: 'wordpress', label: 'WordPress' },
{ value: 'drupal', label: 'Drupal' },
]
// ---------- API helpers -----------------------------------------------------
async function fetchDomains(): Promise<Domain[]> {
@@ -65,16 +85,26 @@ function defaultConfig(domainId: number): WafConfig {
mode: 'detection',
paranoia_level: 1,
rule_exclusions: [],
crs_plugins: [],
app_profiles: [],
exclusion_notes: {},
trusted_proxies: [],
custom_rules: '',
}
}
async function fetchProfiles(): Promise<WafProfile[]> {
const r = await apiClient.get('/waf/profiles')
if (!isEnvelope(r.data)) return []
return (r.data.data as { profiles?: WafProfile[] }).profiles ?? []
}
interface WafFormValues {
enabled: boolean
mode: 'detection' | 'blocking'
paranoia_level: number
crs_plugins: string[]
app_profiles: string[]
trusted_proxies_str: string
custom_rules: string
}
@@ -92,6 +122,14 @@ function ConfigDrawer({ domainName, domainId, onClose }: ConfigDrawerProps) {
const qc = useQueryClient()
const isViewer = useAuthStore((s) => s.user?.role) === 'viewer'
const [form] = Form.useForm<WafFormValues>()
const [addRuleId, setAddRuleId] = useState<string>('')
const [addNote, setAddNote] = useState<string>('')
// Durchsuchbare Optionen aus der CRS-Regel-Liste (ID — Beschreibung).
// filterOption unten matcht sowohl ID als auch Beschreibung.
const ruleOptions = useMemo(
() => Object.entries(CRS_RULES).map(([id, desc]) => ({ value: id, label: `${id}${desc}` })),
[],
)
const { data: cfg, isLoading } = useQuery({
queryKey: ['waf', 'config', domainId],
@@ -99,6 +137,13 @@ function ConfigDrawer({ domainName, domainId, onClose }: ConfigDrawerProps) {
enabled: domainId !== null,
})
// Eigene App-Profile (nur benutzerdefinierte, nicht built-in) als Optionen.
const { data: profiles } = useQuery({ queryKey: ['waf', 'profiles'], queryFn: fetchProfiles })
const profileOptions = useMemo(
() => (profiles ?? []).filter(p => !p.builtin).map(p => ({ value: p.name, label: p.name })),
[profiles],
)
const save = useMutation({
mutationFn: (values: WafConfig) =>
apiClient.put(`/waf/configs/${domainId}`, values),
@@ -136,6 +181,7 @@ function ConfigDrawer({ domainName, domainId, onClose }: ConfigDrawerProps) {
layout="vertical"
initialValues={{
...cfg,
app_profiles: cfg.app_profiles ?? [],
trusted_proxies_str: (cfg.trusted_proxies ?? []).join(', '),
}}
onFinish={(vals) => {
@@ -147,6 +193,8 @@ function ConfigDrawer({ domainName, domainId, onClose }: ConfigDrawerProps) {
mode: vals.mode,
paranoia_level: vals.paranoia_level,
rule_exclusions: cfg?.rule_exclusions ?? [],
crs_plugins: vals.crs_plugins ?? [],
app_profiles: vals.app_profiles ?? [],
exclusion_notes: cfg?.exclusion_notes ?? {},
trusted_proxies: proxies,
custom_rules: vals.custom_rules ?? '',
@@ -190,6 +238,35 @@ function ConfigDrawer({ domainName, domainId, onClose }: ConfigDrawerProps) {
</Select>
</Form.Item>
<Form.Item
label={t('waf.config.crsPlugins')}
name="crs_plugins"
help={t('waf.config.crsPluginsHint')}
>
<Select
mode="multiple"
allowClear
disabled={isViewer}
placeholder={t('waf.config.crsPluginsPlaceholder')}
options={CRS_PLUGIN_OPTIONS}
/>
</Form.Item>
<Form.Item
label={t('waf.config.appProfiles')}
name="app_profiles"
help={t('waf.config.appProfilesHint')}
>
<Select
mode="multiple"
allowClear
disabled={isViewer}
placeholder={t('waf.config.appProfilesPlaceholder')}
options={profileOptions}
notFoundContent={t('waf.profiles.empty')}
/>
</Form.Item>
{/* Exclusions list — shows existing exclusions with notes + remove button */}
<Form.Item label={t('waf.config.exclusions')}>
{(cfg?.rule_exclusions ?? []).length === 0 ? (
@@ -224,6 +301,8 @@ function ConfigDrawer({ domainName, domainId, onClose }: ConfigDrawerProps) {
mode: cfg?.mode ?? 'detection',
paranoia_level: cfg?.paranoia_level ?? 1,
rule_exclusions: newExclusions,
crs_plugins: cfg?.crs_plugins ?? [],
app_profiles: cfg?.app_profiles ?? [],
exclusion_notes: newNotes,
trusted_proxies: cfg?.trusted_proxies ?? [],
custom_rules: cfg?.custom_rules ?? '',
@@ -235,9 +314,59 @@ function ConfigDrawer({ domainName, domainId, onClose }: ConfigDrawerProps) {
))}
</Space>
)}
<div style={{ marginTop: 8 }}>
<Text type="secondary" style={{ fontSize: 11 }}>{t('waf.config.exclusionsAddHint')}</Text>
</div>
{!isViewer && (
<div style={{ marginTop: 10 }}>
<div style={{ display: 'flex', gap: 8 }}>
<Select
showSearch
value={addRuleId || undefined}
placeholder={t('waf.config.exclusionAddPlaceholder')}
style={{ flex: 1, minWidth: 0 }}
options={ruleOptions}
optionFilterProp="label"
notFoundContent={t('waf.config.exclusionAddNotFound')}
onChange={(v) => setAddRuleId(v)}
/>
<Input
placeholder={t('waf.config.exclusionAddNote')}
value={addNote}
onChange={(e) => setAddNote(e.target.value)}
style={{ width: 150 }}
/>
<Button
type="primary"
icon={<PlusOutlined />}
loading={save.isPending}
disabled={!addRuleId}
onClick={() => {
if (!addRuleId) return
const existing = cfg?.rule_exclusions ?? []
if (existing.includes(addRuleId)) { setAddRuleId(''); setAddNote(''); return }
const newNotes = { ...(cfg?.exclusion_notes ?? {}) }
if (addNote.trim()) newNotes[addRuleId] = addNote.trim()
save.mutate({
domain_id: domainId!,
enabled: cfg?.enabled ?? false,
mode: cfg?.mode ?? 'detection',
paranoia_level: cfg?.paranoia_level ?? 1,
rule_exclusions: [...existing, addRuleId],
crs_plugins: cfg?.crs_plugins ?? [],
app_profiles: cfg?.app_profiles ?? [],
exclusion_notes: newNotes,
trusted_proxies: cfg?.trusted_proxies ?? [],
custom_rules: cfg?.custom_rules ?? '',
})
setAddRuleId(''); setAddNote('')
}}
>
{t('waf.config.exclusionAddBtn')}
</Button>
</div>
<Text type="secondary" style={{ fontSize: 11, display: 'block', marginTop: 6 }}>
{t('waf.config.exclusionsAddHint')}
</Text>
</div>
)}
</Form.Item>
<Form.Item
@@ -479,6 +608,220 @@ function AlertsTab({ domainId, configMap }: { domainId?: number; configMap: Map<
)
}
// ---------- Profiles tab ----------------------------------------------------
interface ProfileFormValues {
name: string
description: string
rule_exclusions: string[]
}
function ProfileEditor({ profile, onClose }: { profile: WafProfile | null; onClose: () => void }) {
const { t } = useTranslation()
const qc = useQueryClient()
const [form] = Form.useForm<ProfileFormValues>()
const isCreate = profile !== null && profile.id === 0
const ruleOptions = useMemo(
() => Object.entries(CRS_RULES).map(([id, desc]) => ({ value: id, label: `${id}${desc}` })),
[],
)
// Formular bei jedem Öffnen/Wechsel neu befüllen.
useEffect(() => {
if (profile) {
form.setFieldsValue({
name: profile.name,
description: profile.description,
rule_exclusions: profile.rule_exclusions ?? [],
})
}
}, [profile, form])
const save = useMutation({
mutationFn: (vals: ProfileFormValues) =>
isCreate
? apiClient.post('/waf/profiles', vals)
: apiClient.put(`/waf/profiles/${profile!.id}`, vals),
onSuccess: () => {
message.success(t('waf.profiles.saved'))
void qc.invalidateQueries({ queryKey: ['waf'] })
onClose()
},
onError: () => message.error(t('waf.profiles.saveFailed')),
})
return (
<Drawer
title={isCreate ? t('waf.profiles.createTitle') : t('waf.profiles.editTitle')}
open={profile !== null}
onClose={onClose}
width={520}
footer={
<Space style={{ justifyContent: 'flex-end', width: '100%' }}>
<Button onClick={onClose}>{t('common.cancel')}</Button>
<Button type="primary" loading={save.isPending} onClick={() => form.submit()}>
{t('common.save')}
</Button>
</Space>
}
>
<Form
form={form}
layout="vertical"
onFinish={(vals) => save.mutate({
name: vals.name,
description: vals.description ?? '',
rule_exclusions: vals.rule_exclusions ?? [],
})}
>
<Form.Item
label={t('waf.profiles.name')}
name="name"
rules={[{ required: true, max: 60 }]}
>
<Input placeholder={t('waf.profiles.namePlaceholder')} />
</Form.Item>
<Form.Item label={t('waf.profiles.description')} name="description">
<Input placeholder={t('waf.profiles.descriptionPlaceholder')} />
</Form.Item>
<Form.Item
label={t('waf.profiles.rules')}
name="rule_exclusions"
help={t('waf.profiles.rulesHint')}
>
<Select
mode="multiple"
showSearch
allowClear
placeholder={t('waf.profiles.rulesPlaceholder')}
options={ruleOptions}
optionFilterProp="label"
/>
</Form.Item>
</Form>
</Drawer>
)
}
function ProfilesTab() {
const { t } = useTranslation()
const qc = useQueryClient()
const isViewer = useAuthStore((s) => s.user?.role) === 'viewer'
const [editing, setEditing] = useState<WafProfile | null>(null)
const { data: profiles, isLoading } = useQuery({ queryKey: ['waf', 'profiles'], queryFn: fetchProfiles })
const custom = (profiles ?? []).filter(p => !p.builtin)
const del = useMutation({
mutationFn: (id: number) => apiClient.delete(`/waf/profiles/${id}`),
onSuccess: () => {
message.success(t('waf.profiles.deleted'))
void qc.invalidateQueries({ queryKey: ['waf'] })
},
onError: () => message.error(t('waf.profiles.deleteFailed')),
})
// openCreate(seed) öffnet den Editor im Create-Modus (id=0) mit Startwerten.
const openCreate = (seed?: Partial<WafProfile>) => setEditing({
id: 0,
name: seed?.name ?? '',
description: seed?.description ?? '',
rule_exclusions: seed?.rule_exclusions ?? [],
builtin: false,
})
const columns = [
{
title: t('waf.profiles.col.name'),
dataIndex: 'name',
key: 'name',
render: (v: string) => <Text strong style={{ fontSize: 13 }}>{v}</Text>,
},
{
title: t('waf.profiles.col.description'),
dataIndex: 'description',
key: 'description',
ellipsis: true,
render: (v: string) => <Text type="secondary" style={{ fontSize: 12 }}>{v || '—'}</Text>,
},
{
title: t('waf.profiles.col.rules'),
key: 'rules',
width: 90,
render: (_: unknown, row: WafProfile) => <Tag>{(row.rule_exclusions ?? []).length}</Tag>,
},
{
title: '',
key: 'actions',
width: 210,
render: (_: unknown, row: WafProfile) => (
<Space size={4}>
<Button size="small" icon={<EditOutlined />} disabled={isViewer} onClick={() => setEditing(row)}>
{t('waf.profiles.edit')}
</Button>
<Tooltip title={t('waf.profiles.clone')}>
<Button
size="small"
icon={<CopyOutlined />}
disabled={isViewer}
onClick={() => openCreate({
name: `${row.name} ${t('waf.profiles.cloneSuffix')}`,
description: row.description,
rule_exclusions: row.rule_exclusions,
})}
/>
</Tooltip>
<Popconfirm title={t('waf.profiles.deleteConfirm')} onConfirm={() => del.mutate(row.id)} disabled={isViewer}>
<Button size="small" danger icon={<DeleteOutlined />} disabled={isViewer} />
</Popconfirm>
</Space>
),
},
]
return (
<div className="mt-2">
<Alert
type="info"
showIcon
className="mb-16"
message={t('waf.profiles.intro')}
description={
<div style={{ marginTop: 8 }}>
<Text style={{ fontSize: 12 }}>{t('waf.profiles.builtinInfo')}</Text>
<div style={{ marginTop: 8, display: 'flex', gap: 12, flexWrap: 'wrap', alignItems: 'center' }}>
{CRS_PLUGIN_OPTIONS.map(p => (
<Space key={p.value} size={2}>
<Tag icon={<SafetyCertificateOutlined />}>{p.label}</Tag>
{!isViewer && (
<Button size="small" type="link" onClick={() => openCreate({ name: `${p.value}-custom` })}>
{t('waf.profiles.asTemplate')}
</Button>
)}
</Space>
))}
</div>
</div>
}
/>
<div className="flex-between mb-12">
<Text type="secondary" style={{ fontSize: 12 }}>
{custom.length} {t('waf.profiles.typeCustom')}
</Text>
<Button type="primary" icon={<PlusOutlined />} disabled={isViewer} onClick={() => openCreate()}>
{t('waf.profiles.new')}
</Button>
</div>
{custom.length === 0 && !isLoading ? (
<Alert type="info" showIcon message={t('waf.profiles.empty')} />
) : (
<DataTable rowKey="id" loading={isLoading} dataSource={custom} columns={columns} />
)}
<ProfileEditor profile={editing} onClose={() => setEditing(null)} />
</div>
)
}
// ---------- Page ------------------------------------------------------------
export default function WAFPage() {
@@ -652,6 +995,11 @@ export default function WAFPage() {
</>
),
},
{
key: 'profiles',
label: t('waf.tabs.profiles'),
children: <ProfilesTab />,
},
{
key: 'alerts',
label: t('waf.tabs.alerts'),

View File

@@ -1160,6 +1160,9 @@ h1, h2, h3, h4, h5, h6 {
.threat-tile.threat-tile--warn { opacity: 1; border-left: 3px solid #F59E0B; }
.threat-tile.threat-tile--crit { opacity: 1; border-left: 3px solid #DC2626; }
.threat-tile.threat-tile--idle { opacity: 0.55; border-left: 3px solid transparent; }
/* Quittierte Alert-Events (Alerts → Events-Tab) werden abgedimmt. */
tr.eg-row-muted > td { opacity: 0.5; }
.threat-tile-value--hit { color: #EF4444; }
.threat-tile-value--warn { color: #F59E0B; }
.threat-tile-value--crit { color: #DC2626; }

View File

@@ -152,6 +152,9 @@ edgeguard ALL=(root) NOPASSWD: /usr/bin/apt-get update
# NICHT beliebig in /etc/apt/apt.conf.d schreiben darf.
edgeguard ALL=(root) NOPASSWD: /usr/bin/tee /etc/apt/apt.conf.d/52edgeguard-auto-updates
edgeguard ALL=(root) NOPASSWD: /bin/rm -f /etc/apt/apt.conf.d/52edgeguard-auto-updates
# Update-Kanal-Switch (Settings → Update-Kanal) schreibt exakt diese
# sources.list-Zeile. Gleiches Restrict-Pattern wie oben.
edgeguard ALL=(root) NOPASSWD: /usr/bin/tee /etc/apt/sources.list.d/edgeguard.list
# Backup-Pfad: pg_dump als postgres-User. Whitelist exakt mit
# --clean --if-exists --no-owner --no-acl + dem festen DB-Namen.
edgeguard ALL=(postgres) NOPASSWD: /usr/bin/pg_dump --clean --if-exists --no-owner --no-acl edgeguard
@@ -203,7 +206,9 @@ edgeguard ALL=(root) NOPASSWD: /usr/bin/systemctl stop edgeguard-waf.service
edgeguard ALL=(root) NOPASSWD: /usr/bin/systemctl enable edgeguard-waf.service
edgeguard ALL=(root) NOPASSWD: /usr/bin/systemctl disable edgeguard-waf.service
edgeguard ALL=(root) NOPASSWD: /usr/bin/systemctl restart edgeguard-waf.service
# CrowdSec service toggle (start/stop/enable/disable)
# CrowdSec service toggle (start/stop/enable/disable) + reload (Whitelist-Render)
edgeguard ALL=(root) NOPASSWD: /usr/bin/systemctl reload crowdsec.service
edgeguard ALL=(root) NOPASSWD: /bin/systemctl reload crowdsec.service
edgeguard ALL=(root) NOPASSWD: /usr/bin/systemctl start crowdsec.service
edgeguard ALL=(root) NOPASSWD: /usr/bin/systemctl stop crowdsec.service
edgeguard ALL=(root) NOPASSWD: /usr/bin/systemctl enable crowdsec.service
@@ -292,6 +297,28 @@ SUDOERS
chmod 0440 /etc/sudoers.d/edgeguard
# ── Migration: alte "main"-Komponente → "stable" (Kanal-Modell) ──
# Vor diesem Release schrieb der Installer immer Komponente "main"
# in die apt-Quelle. Ab jetzt publiziert publish.sh/release.sh nur
# noch nach stable/testing — ohne diese Migration würden
# Bestandsnodes stumm keine neuen Updates mehr sehen (main bleibt
# auf dem letzten main-Stand stehen). Idempotent.
#
# netcell-edgeguard.list: NOCH ältere Installer-Generation vor dem
# Rename auf edgeguard.list (install.sh räumt sie nur bei einem
# FRISCHEN Install auf, nie bei einem Upgrade — auf Bestandsnodes,
# die nie neu installiert wurden, ist sie ggf. noch die aktive
# Datei). Erst konsolidieren, dann main→stable.
EG_SOURCES_LIST=/etc/apt/sources.list.d/edgeguard.list
EG_LEGACY_LIST=/etc/apt/sources.list.d/netcell-edgeguard.list
if [ -f "$EG_LEGACY_LIST" ] && [ ! -f "$EG_SOURCES_LIST" ]; then
mv "$EG_LEGACY_LIST" "$EG_SOURCES_LIST"
fi
if [ -f "$EG_SOURCES_LIST" ] && grep -q ' trixie main$' "$EG_SOURCES_LIST"; then
sed -i 's/ trixie main$/ trixie stable/' "$EG_SOURCES_LIST"
apt-get update -qq || true
fi
# ── Sysctl-Profil für Edge-Gateway (NAT + HAProxy + Forwarding) ──
# Voraussetzung für NAT/DNAT/Masquerade + sinnvolle Defaults
# für eine high-throughput Forwarding-Box. Edit nicht von Hand
@@ -355,7 +382,14 @@ net.core.default_qdisc = fq
net.ipv4.tcp_syncookies = 1
net.ipv4.icmp_echo_ignore_broadcasts = 1
net.ipv4.icmp_ignore_bogus_error_responses = 1
net.ipv4.conf.all.log_martians = 1
# log_martians AUS (nicht der rp_filter-Drop selbst!): Ein VRRP-Backup-Node
# sieht auf geteilten L2-VLANs dauerhaft Broadcast/Multicast für Gateway-/
# VIP-Adressen, die er im Backup-Zustand nicht besitzt — rp_filter=2 verwirft
# das korrekt, aber log_martians=1 flutet dmesg/journal damit (>100k Zeilen/
# Woche beobachtet). Kernel-OR-Semantik: conf.all=1 überschreibt jeden
# Interface-spezifischen Wert, daher nur hier zentral abschaltbar. Security-
# Sichtbarkeit läuft ohnehin über nftables-NFLOG/ulogd2 + CrowdSec, nicht dmesg.
net.ipv4.conf.all.log_martians = 0
kernel.kptr_restrict = 2
kernel.dmesg_restrict = 1
@@ -376,9 +410,25 @@ SYSCTL
if [ ! -f /var/log/edgeguard/firewall.jsonl ]; then
: > /var/log/edgeguard/firewall.jsonl
fi
# ulogd2 läuft als root (eigener Daemon); File muss von ihm
# schreibbar UND von edgeguard-API lesbar sein.
chown root:"$EG_USER" /var/log/edgeguard/firewall.jsonl
# WICHTIG (Incident 2026-09-06): ulogd2 läuft NICHT als root
# die Debian-Unit startet `ulogd --daemon --uid ulog`, der Daemon
# dropped also nach dem Öffnen seiner Files auf den User `ulog`.
# Solange er läuft schreibt er über den offenen fd weiter, egal
# wem die Datei gehört. Aber: das Distro-Profil
# /etc/logrotate.d/ulogd2 schickt nachts SIGHUP an ulogd, damit
# es seine Logfiles neu öffnet — und DAS passiert als `ulog`.
# Gehörte die Datei root:edgeguard 0640, scheitert das Reopen mit
# "can't open JSON log file: Permission denied", ulogd wertet das
# als fatal und BEENDET sich. Ohne Restart= blieb der Dienst dann
# tagelang still tot (5 Tage unbemerkt) → Firewall-Live-Log zeigte
# nur noch den alten In-Memory-Ring der API, nie neue Events.
# Daher: Owner = ulog (Schreiber), Gruppe = edgeguard (Leser).
if getent passwd ulog >/dev/null 2>&1; then
chown ulog:"$EG_USER" /var/log/edgeguard/firewall.jsonl
else
# ulogd2 (noch) nicht installiert — Fallback wie bisher.
chown root:"$EG_USER" /var/log/edgeguard/firewall.jsonl
fi
chmod 0640 /var/log/edgeguard/firewall.jsonl
cat > /etc/ulogd.conf <<'ULOGD'
@@ -426,7 +476,9 @@ ULOGD
compress
delaycompress
copytruncate
create 0640 root edgeguard
# Owner muss ulog sein — siehe Kommentar oben: ulogd re-öffnet die
# Datei bei SIGHUP als `ulog` und stirbt sonst an EACCES.
create 0640 ulog edgeguard
}
LOGROTATE
chmod 0644 /etc/logrotate.d/edgeguard-firewall
@@ -435,6 +487,18 @@ LOGROTATE
# da ist (Dependency-Konflikt o.ä.), nur warnen — die Firewall
# läuft auch ohne Logger.
if systemctl list-unit-files ulogd2.service >/dev/null 2>&1; then
# Restart=always als Selbstheilung: ulogd beendet sich bei
# einem fehlgeschlagenen Reopen mit Exit-Code 0 (also KEIN
# Restart=on-failure — das würde nicht greifen). Ohne das
# bleibt Firewall-Logging nach einem Rotate-Hiccup still tot.
install -d -m 0755 /etc/systemd/system/ulogd2.service.d
cat > /etc/systemd/system/ulogd2.service.d/edgeguard-restart.conf <<'ULOGDUNIT'
# Managed by edgeguard — re-installation overwrites this file.
[Service]
Restart=always
RestartSec=5s
ULOGDUNIT
systemctl daemon-reload
systemctl enable ulogd2.service >/dev/null 2>&1 || true
systemctl restart ulogd2.service || \
echo "postinst: ulogd2.service restart failed (firewall logs disabled until fixed)" >&2
@@ -780,24 +844,20 @@ VIPCMD
exit 1
fi
# ALTER PUBLICATION erfordert den PG-Superuser (edgeguard ist nicht
# Owner der Publication). Idempotent — No-Op auf Secondary-Nodes
# (wo die Publication nicht existiert) und wenn die Tabellen schon
# drin sind.
sudo -u postgres psql edgeguard <<'EOSQL' 2>/dev/null || true
DO $$
BEGIN
IF EXISTS (SELECT 1 FROM pg_publication WHERE pubname = 'edgeguard_shared') THEN
IF NOT EXISTS (
SELECT 1 FROM pg_publication_tables
WHERE pubname = 'edgeguard_shared' AND tablename = 'network_interfaces'
) THEN
ALTER PUBLICATION edgeguard_shared ADD TABLE network_interfaces, ip_addresses;
END IF;
END IF;
END;
$$;
EOSQL
# ── Replikation reconcilen (Publication / Grants / Subscription) ──
# Ersetzt die frühere feste ADD-TABLE-Logik (die network_interfaces/
# ip_addresses bei JEDEM Upgrade fälschlich wieder in die Publication
# zog — beide sind node-lokal). edgeguard-ctl erkennt die Rolle selbst
# und bringt idempotent den Soll-Zustand:
# Publisher : fehlende Shared-Tables ADD, node-lokale (localOnlyTables)
# DROP, GRANT SELECT für den Replikations-User (deckt per
# Migration neu hinzugekommene Tabellen ab — sonst hängt
# deren tablesync in 'd').
# Subscriber: neue Tabellen leeren + REFRESH PUBLICATION.
# Standalone: No-Op.
# Läuft als root → buildPsqlCmd nutzt `sudo -u postgres psql` (Superuser).
# Best-effort: ein Reconcile-Fehler darf das Upgrade nie abbrechen.
/usr/bin/edgeguard-ctl cluster-reconcile-replication 2>&1 || true
# ── CrowdSec IDS installation ─────────────────────────────────────────
# Install CrowdSec if not present. We use the official CrowdSec APT repo.
@@ -823,6 +883,83 @@ EOSQL
fi
fi
# ── CrowdSec HAProxy-Acquisition auf journald zeigen ──────────
# EdgeGuards HAProxy loggt nach journald (log /dev/log local0/1) —
# es gibt KEINE /var/log/haproxy.log. `cscli setup` rät aber auf eine
# datei-basierte Quelle → CrowdSec liest nichts → die HTTP-/CVE-
# Szenarien laufen leer (WAF-artiger Web-Schutz tot). Wir setzen die
# Acquisition deterministisch auf die haproxy.service-Journal-Unit
# (analog zur sshd-Quelle). Läuft auf JEDEM configure (self-healing
# auch für Bestandsinstalls). Admin-Custom bleibt unangetastet: nur
# überschreiben, wenn Datei fehlt / noch die kaputte Datei-Default
# (/var/log/haproxy.log) enthält / von uns stammt.
if command -v cscli >/dev/null 2>&1; then
CS_HAPROXY_ACQUIS="/etc/crowdsec/acquis.d/setup.haproxy.yaml"
if [ ! -f "$CS_HAPROXY_ACQUIS" ] \
|| grep -q '/var/log/haproxy.log' "$CS_HAPROXY_ACQUIS" 2>/dev/null \
|| grep -q 'Managed by EdgeGuard' "$CS_HAPROXY_ACQUIS" 2>/dev/null; then
install -d -m 0755 /etc/crowdsec/acquis.d
cat > "$CS_HAPROXY_ACQUIS" <<'ACQUIS'
# Managed by EdgeGuard. HAProxy loggt nach journald (log /dev/log local0/1),
# es gibt keine /var/log/haproxy.log. journalctl-Quelle analog zur sshd-Acquis.
source: journalctl
journalctl_filter:
- "_SYSTEMD_UNIT=haproxy.service"
labels:
type: haproxy
ACQUIS
# SIGHUP-Reload lädt die Acquisition neu; Restart als Fallback.
systemctl reload crowdsec 2>/dev/null \
|| systemctl restart crowdsec 2>/dev/null || true
fi
# CrowdSec-Whitelist: eigenen Management-Traffic (Backend
# api_backend = ausschließlich edgeguard-api: Admin-UI + REST-API +
# ACME) von ALLEN Scenarios ausnehmen. Die Admin-SPA feuert beim
# Laden viele /api/-Requests — normal, wurde aber als http-crawl
# gebannt (Incident 2026-08-03, Admin-IP gesperrt). IP-unabhängig.
install -d -m 0755 /etc/crowdsec/parsers/s02-enrich
# Altlast aus dem manuellen Live-Fix (2026-08-03) entfernen — sonst
# lägen zwei inhaltsgleiche Whitelist-Dateien nebeneinander.
rm -f /etc/crowdsec/parsers/s02-enrich/netcell-mgmt-whitelist.yaml
cat > /etc/crowdsec/parsers/s02-enrich/edgeguard-mgmt-whitelist.yaml <<'WL'
name: edgeguard/mgmt-ui-whitelist
description: Management-UI/REST-API/ACME (HAProxy-Backend api_backend) von CrowdSec ausnehmen - Admin-SPA-Traffic ist kein Angriff.
whitelist:
reason: edgeguard management-ui / api (backend api_backend)
expression:
- "evt.Parsed.backend_name == 'api_backend'"
WL
systemctl reload crowdsec 2>/dev/null \
|| systemctl restart crowdsec 2>/dev/null || true
# Admin-Hosts-Whitelist: wird vom crowdsec-whitelist-Generator aus
# domains.crowdsec_trusted gerendert (UI-Schalter "vertrauenswuerdiges
# Admin-Panel"). Das Verzeichnis ist root-owned → der Generator (laeuft
# als edgeguard) kann nur eine vorab-chownte Datei ueberschreiben, daher
# hier leer + edgeguard-owned anlegen, dann aus der DB befuellen.
install -o "$EG_USER" -g "$EG_USER" -m 0644 /dev/null \
/etc/crowdsec/parsers/s02-enrich/edgeguard-admin-hosts-whitelist.yaml
sudo -n -u "$EG_USER" /usr/bin/edgeguard-ctl render-config --only=crowdsec-whitelist 2>/dev/null || true
# CrowdSec-Simulations-Default: http-crawl-non_statics ist bei modernen
# SPAs/Apps strukturell FP-anfaellig — ein Seiten-Load/Sync feuert 40+
# distinkte /api/-URLs, der Leaky-Bucket (capacity=40, leak ~2/s) laeuft
# in Sekunden ueber → False-Positive-Ban legitimer Nutzer/Kunden. Daher
# per Default in SIMULATION: alarmiert weiter, bannt aber nicht. Echte
# Angriffe (ssh-bf, http-cve-*, backdoors, CVE-2021-41773 …) bleiben
# scharf. Nur bei ERST-Install setzen (Marker) → ein spaeteres manuelles
# 'cscli simulation disable …' des Operators wird bei Updates NICHT
# wieder ueberschrieben.
EG_CROWDSEC_SIM_MARKER=/var/lib/edgeguard/.crowdsec-crawl-sim-applied
if [ ! -f "$EG_CROWDSEC_SIM_MARKER" ]; then
cscli simulation enable crowdsecurity/http-crawl-non_statics 2>/dev/null || true
install -d -m 0755 /var/lib/edgeguard
: > "$EG_CROWDSEC_SIM_MARKER"
systemctl reload crowdsec 2>/dev/null || systemctl restart crowdsec 2>/dev/null || true
fi
fi
# ── Render initial service configs ───────────────────────────
# Writes /etc/edgeguard/haproxy/haproxy.cfg + nftables.d/
# ruleset.nft from the (just-migrated, empty) PG state.
@@ -910,6 +1047,43 @@ KEEPALIVEDDROPIN
rm -rf "${CRS_TMP}"
fi
# ── CRS App-Exclusion-Plugins (Nextcloud / WordPress / Drupal) ──────
# Offizielle OWASP-CRS-Plugins (separate Repos coreruleset/<name>-plugin).
# Ihre plugins/*.conf landen in <crs>/plugins/; der WAF-Renderer bindet
# je Domain die GEWÄHLTEN ein (waf_configs.crs_plugins). Läuft auf JEDEM
# configure (self-healing für Bestandsinstalls), aber nur wenn das
# jeweilige Plugin noch fehlt — admin-Anpassungen bleiben unangetastet.
if [ -d "$WAF_CRS_DIR/rules" ]; then
install -d -m 0755 "$WAF_CRS_DIR/plugins"
for plugin in nextcloud-rule-exclusions wordpress-rule-exclusions drupal-rule-exclusions; do
if [ -f "$WAF_CRS_DIR/plugins/${plugin}-before.conf" ] \
|| [ -f "$WAF_CRS_DIR/plugins/${plugin}-config.conf" ]; then
continue
fi
P_TMP="$(mktemp -d)"
P_OK=""
# Plugin-Repos nutzen teils 'main', teils 'master' als Default-
# Branch. curl -f (statt still einen 404 als Erfolg zu werten).
for P_BR in main master; do
if curl -fsSL --max-time 45 \
"https://github.com/coreruleset/${plugin}-plugin/archive/refs/heads/${P_BR}.tar.gz" \
-o "${P_TMP}/p.tgz" 2>/dev/null \
&& tar xzf "${P_TMP}/p.tgz" -C "${P_TMP}" 2>/dev/null; then
P_OK=1; break
fi
done
if [ -n "$P_OK" ] && ls "${P_TMP}"/*/plugins/${plugin}-*.conf >/dev/null 2>&1; then
install -m 0644 "${P_TMP}"/*/plugins/${plugin}-*.conf \
"$WAF_CRS_DIR/plugins/" 2>/dev/null \
&& echo "postinst: CRS-Plugin ${plugin} installiert"
else
echo "postinst: CRS-Plugin ${plugin} nicht installiert (Download/Layout)" >&2
fi
rm -rf "${P_TMP}"
done
chown -R "$EG_USER":"$EG_USER" "$WAF_CRS_DIR/plugins" 2>/dev/null || true
fi
# ── systemd: pick up new units + restart haproxy with our cfg
systemctl daemon-reload
systemctl restart haproxy.service || true

View File

@@ -1,6 +1,30 @@
#!/bin/bash
# Keepalived health check: edgeguard-api erreichbar?
# Weight -50 → BACKUP gewinnt wenn Primary-API nicht antwortet.
# Keepalived-Health-Check (chk_edgeguard).
#
# WICHTIG (Incident 2026-08-03): Dieser Check entscheidet mit, ob ein Node die
# VIP übernehmen bzw. per Preempt zurückholen darf. Er darf NUR dann "gesund"
# (exit 0) melden, wenn der Node Traffic WIRKLICH bedienen kann — nicht nur
# "edgeguard-api-Prozess up". Sonst reißt ein frisch gebooteter / mitten im
# Deploy befindlicher Node (haproxy noch nicht bereit) die VIP an sich, obwohl
# er nichts bedient → Cluster-Ausfall, bis der Node hart abgeschaltet wird.
#
# ALLE Bedingungen müssen erfüllt sein, sonst exit 1 → Instanz geht in FAULT →
# kein (Re-)Preempt, der gesunde Peer behält/bekommt die VIP.
# 1) edgeguard-api antwortet auf den Health-Endpoint (Unix-Socket oder Loopback)?
curl -sf --max-time 2 --unix-socket /run/edgeguard/api.sock \
http://localhost/api/v1/system/health > /dev/null 2>&1 \
|| curl -sf --max-time 2 http://127.0.0.1:9443/api/v1/system/health > /dev/null 2>&1
|| curl -sf --max-time 2 http://127.0.0.1:9443/api/v1/system/health > /dev/null 2>&1 \
|| exit 1
# 2) haproxy-Prozess aktiv? Während Boot/Restart kurz false → Node bleibt in dem
# Fenster Backup (kann nicht preempten).
systemctl is-active --quiet haproxy 2>/dev/null || exit 1
# 3) haproxy hört wirklich auf dem oeffentlichen TLS-Port :443 (bindet = bedient)?
# Faengt "Prozess up, aber Config kaputt / Port nicht gebunden" ab. :443 ist
# architektur-bedingt immer gebunden (Public-TLS + ACME-Webroot + Mgmt-FQDN-
# Fallback), also ein verlaessliches Ready-Signal.
ss -H -ltn 2>/dev/null | awk '{print $4}' | grep -Eq ':443$' || exit 1
exit 0

View File

@@ -4,8 +4,20 @@
# KEIN Auto-Promote — Split-Brain-Schutz durch manuelle Promotion.
# Admin muss "edgeguard-ctl promote" ausführen wenn PG-Failover gewünscht.
logger -t keepalived -p daemon.warning \
"MASTER: VIP übernommen — PG-Rolle ist noch '$(cat /var/lib/edgeguard/pg_role 2>/dev/null || echo standby)'. Für PG-Failover: edgeguard-ctl promote"
# PG-Rolle ZUVERLÄSSIG über die Publication ermitteln — nicht über die evtl.
# veraltete/fehlende Datei /var/lib/edgeguard/pg_role (die schrieb nur `promote`;
# ein via cluster-init-replication eingerichteter Primary hat sie nie → Log sagte
# fälschlich "standby"). Nur der Primary trägt die Publication edgeguard_shared
# (Konvention, vgl. cluster_repair.go). Best-effort: bei psql-Fehler neutral.
if sudo -u postgres psql -d edgeguard -tAc \
"SELECT EXISTS(SELECT 1 FROM pg_publication WHERE pubname='edgeguard_shared')" \
2>/dev/null | grep -q '^t$'; then
logger -t keepalived -p daemon.warning \
"MASTER: VIP übernommen — dieser Node ist bereits PG-Primary (kein promote nötig)."
else
logger -t keepalived -p daemon.warning \
"MASTER: VIP übernommen — PG-Rolle ist 'standby'. Für PG-Failover: edgeguard-ctl promote"
fi
# ── Upstream-ARP/Routing für die Failover-VIP(s) aktualisieren ──
# Manche Hoster lernen die neue MAC einer Failover-IP NICHT zuverlässig über

View File

@@ -1,27 +1,49 @@
#!/bin/bash
# Cleanup-old: löscht alle EdgeGuard-Versionen im Gitea Package
# Registry außer den letzten N (default 10).
# Registry außer den letzten N (default 10) — pro Kanal (Komponente).
#
# Wird vom Makefile direkt nach erfolgreichem Upload aufgerufen. Reihen-
# folge ist wichtig: ERST der neue Build hochladen, DANN die ältesten
# wegschmeißen — sonst riskieren wir bei Cleanup-vor-Upload eine Lücke.
# Wird vom Makefile/release.sh direkt nach erfolgreichem Upload
# aufgerufen. Reihenfolge ist wichtig: ERST der neue Build hochladen,
# DANN die ältesten wegschmeißen — sonst riskieren wir bei Cleanup-vor-
# Upload eine Lücke.
#
# Stable-Schutz: Versionen, die als Git-Tag `v<version>` im Repo stehen,
# werden im stable-Kanal NIE gelöscht (analog enconf STABLE_PROTECT) —
# ein Kunde, der genau diese Version installiert hat, muss sie über
# apt-get weiterhin ziehen können.
#
# Voraussetzungen:
# - ~/.gitea-token mit write-Package-Scope
# - jq + curl
#
# Aufruf:
# ./cleanup-old.sh # KEEP=10 (default)
# KEEP=20 ./cleanup-old.sh # mehr behalten
# ./cleanup-old.sh # Kanal stable, KEEP=10 (default)
# ./cleanup-old.sh testing # Kanal testing
# KEEP=20 ./cleanup-old.sh stable # mehr behalten
set -euo pipefail
KEEP="${KEEP:-10}"
OWNER="projekte"
DIST="trixie"
COMPONENT="main"
COMPONENT="${1:-stable}"
case "$COMPONENT" in stable|testing) ;; *) echo "cleanup-old: unknown channel '$COMPONENT' (expected stable or testing)" >&2; exit 2 ;; esac
BASE="https://git.netcell-it.de"
# Geschützte Versionen (nur relevant im stable-Kanal): jeder vorhandene
# Git-Tag `v*` im Repo-Root. Leer wenn nicht im Repo ausgeführt oder im
# testing-Kanal (dort gibt es keine Tags/keinen Schutz).
PROTECTED_VERSIONS=""
if [ "$COMPONENT" = "stable" ]; then
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
PROTECTED_VERSIONS="$(git -C "$REPO_ROOT" tag --list 'v*' 2>/dev/null | sed 's/^v//' || true)"
fi
is_protected() {
local v="$1"
[ -z "$PROTECTED_VERSIONS" ] && return 1
printf '%s\n' "$PROTECTED_VERSIONS" | grep -qx "$v"
}
if [ -z "${GITEA_TOKEN:-}" ]; then
if [ -r "$HOME/.gitea-token" ]; then
GITEA_TOKEN="$(tr -d '\n' < "$HOME/.gitea-token")"
@@ -37,6 +59,7 @@ TOK="$GITEA_TOKEN"
PKGS=(
"edgeguard:all"
"edgeguard-api:amd64"
"edgeguard-api:arm64"
"edgeguard-ui:all"
)
@@ -44,27 +67,37 @@ cleanup_pkg() {
local pkg="$1"
local arch="$2"
# Versionen sammeln. Gitea Package-API liefert flache Liste:
# /api/v1/packages/{owner}?type=debian&q={name}
# Versionen sammeln. Gitea Package-API liefert flache Liste über ALLE
# Kanäle hinweg: /api/v1/packages/{owner}?type=debian&q={name}
# Wir filtern client-seitig auf name (exact-match — Gitea-Query ist
# leider substring-fuzzy) und sort -V (semver) absteigend.
# leider substring-fuzzy) UND auf das Versionsformat des aktuellen
# Kanals — stable=Semver (X.Y.Z), testing=datumsbasiert (YYYY.MM.DD.NN).
# Die beiden Formate überlappen nie, das hält testing- und stable-
# Versionen sauber getrennt, obwohl Gitea (Name,Version) global dedupliziert.
local raw
raw="$(curl -fsS -H "Authorization: token $TOK" \
"$BASE/api/v1/packages/$OWNER?type=debian&q=$pkg&limit=1000")"
local ver_pattern
if [ "$COMPONENT" = "stable" ]; then
ver_pattern='^[0-9]+\.[0-9]+\.[0-9]+$'
else
ver_pattern='^[0-9]{4}\.[0-9]{2}\.[0-9]{2}\.[0-9]+$'
fi
local versions
versions="$(printf '%s' "$raw" | jq -r --arg n "$pkg" \
'.[] | select(.name==$n) | .version' | sort -V -r | awk '!seen[$0]++')"
'.[] | select(.name==$n) | .version' | grep -E "$ver_pattern" | sort -V -r | awk '!seen[$0]++')"
if [ -z "$versions" ]; then
echo " $pkg ($arch): no versions found, skip"
echo " $pkg ($arch): no $COMPONENT versions found, skip"
return 0
fi
local count
count="$(printf '%s\n' "$versions" | wc -l)"
if [ "$count" -le "$KEEP" ]; then
echo " $pkg ($arch): $count versions, ≤ keep=$KEEP, nothing to delete"
echo " $pkg ($arch): $count $COMPONENT versions, ≤ keep=$KEEP, nothing to delete"
return 0
fi
@@ -72,10 +105,14 @@ cleanup_pkg() {
to_delete="$(printf '%s\n' "$versions" | tail -n +$((KEEP+1)))"
local kept_count=$((count - $(printf '%s\n' "$to_delete" | wc -l)))
echo " $pkg ($arch): $count versions total, keeping $kept_count newest, deleting $(printf '%s\n' "$to_delete" | wc -l)"
echo " $pkg ($arch): $count $COMPONENT versions total, keeping $kept_count newest, considering $(printf '%s\n' "$to_delete" | wc -l) for deletion"
while IFS= read -r v; do
[ -z "$v" ] && continue
if is_protected "$v"; then
echo " skip $pkg $v $arch (protected — git tag v$v exists)"
continue
fi
# DELETE-Endpoint für Debian-Pakete:
# /api/packages/{owner}/debian/pool/{dist}/{comp}/{name}/{version}/{arch}
local url="$BASE/api/packages/$OWNER/debian/pool/$DIST/$COMPONENT/$pkg/$v/$arch"

View File

@@ -11,16 +11,22 @@
# Befund 2026-05-17 nach Gitea-Disk-Full-Vorfall.
#
# Aufruf:
# ./publish.sh <version> <arch>
# arch = amd64 | arm64. arm64 publiziert NUR edgeguard-api (das einzige
# arch-spezifische Paket); amd64 publiziert alle drei.
# ./publish.sh <version> <arch> [channel]
# arch = amd64 | arm64. arm64 publiziert NUR edgeguard-api (das einzige
# arch-spezifische Paket); amd64 publiziert alle drei.
# channel = stable | testing (Default stable). Kanal-Modell wie enconf:
# Suite=trixie fest, Komponente=Kanal. Wird von scripts/release.sh
# gesetzt — direkter Aufruf (z.B. aus altem `make publish`) bleibt
# ohne 3. Arg unverändert auf stable.
set -euo pipefail
VERSION="${1:?usage: publish.sh <version> <arch>}"
ARCH="${2:?usage: publish.sh <version> <arch>}"
VERSION="${1:?usage: publish.sh <version> <arch> [channel]}"
ARCH="${2:?usage: publish.sh <version> <arch> [channel]}"
CHANNEL="${3:-stable}"
case "$CHANNEL" in stable|testing) ;; *) echo "publish: unknown channel '$CHANNEL' (expected stable or testing)" >&2; exit 2 ;; esac
BASE="https://git.netcell-it.de/api/packages/projekte/debian/pool/trixie/main/upload"
BASE="https://git.netcell-it.de/api/packages/projekte/debian/pool/trixie/${CHANNEL}/upload"
if [ -z "${GITEA_TOKEN:-}" ]; then
if [ -r "$HOME/.gitea-token" ]; then
@@ -88,4 +94,4 @@ case "$ARCH" in
;;
esac
echo "publish: ok ($VERSION/$ARCH)"
echo "publish: ok ($VERSION/$ARCH/$CHANNEL)"

View File

@@ -4,6 +4,9 @@
#
# curl -fsSL https://get.netcell-edgeguard.de | sudo bash
#
# Kanal wählen (Default: stable):
# curl -fsSL https://get.netcell-edgeguard.de | EDGEGUARD_CHANNEL=testing sudo -E bash
#
# Supported: Debian 13 (Trixie), amd64 + arm64.
# ══════════════════════════════════════════════════════════════════════════════
@@ -111,12 +114,14 @@ setup_repo() {
curl -fsSL "https://git.netcell-it.de/api/packages/projekte/debian/repository.key" \
-o /etc/apt/keyrings/nmg.asc
fi
echo "deb [signed-by=/etc/apt/keyrings/nmg.asc] https://git.netcell-it.de/api/packages/projekte/debian trixie main" \
# Kanal-Modell (wie enconf): Suite = OS-Codename, Komponente = Kanal.
# Default stable; EDGEGUARD_CHANNEL=testing für Testing-Kanal.
echo "deb [signed-by=/etc/apt/keyrings/nmg.asc] https://git.netcell-it.de/api/packages/projekte/debian trixie ${EDGEGUARD_CHANNEL:-stable}" \
> /etc/apt/sources.list.d/edgeguard.list
apt-get update -qq
}
step "Set up EdgeGuard apt repository" setup_repo
step "Set up EdgeGuard apt repository (${EDGEGUARD_CHANNEL:-stable})" setup_repo
AVAILABLE=$(LC_ALL=C apt-cache policy edgeguard 2>/dev/null | awk '/Candidate:/ {print $2; exit}' || true)
if [ -n "$AVAILABLE" ] && [ "$AVAILABLE" != "(none)" ]; then

193
scripts/release.sh Executable file
View File

@@ -0,0 +1,193 @@
#!/usr/bin/env bash
# EdgeGuard — Release erstellen (Testing-Push oder Stable-Promotion)
#
# Verwendung:
# ./scripts/release.sh # Testing-Push: baut den aktuellen
# # Stand, Version = datumsbasiert
# # YYYY.MM.DD.NN, Upload → testing
# ./scripts/release.sh stable # Stable-Promotion: baut den
# # aktuellen Stand unter der
# # nächsten Patch-Version (VERSION-
# # Datei +1), Upload → stable,
# # VERSION-Commit + Git-Tag v<version>
# ./scripts/release.sh stable 1.4.0 # Stable mit expliziter Version
#
# Kanal-Modell 1:1 von enconf (netcell-webpanel) übernommen — Suite=trixie
# fest, Komponente=Kanal (stable/testing). An EdgeGuard angepasst:
# - Gates laufen über die bestehende Go-Quality-Baseline (`make deb` ruft
# `release-check` + `management-ui`-tsc automatisch auf) statt eigener
# Preflight-Schritte.
# - Kein Docs-/Changelog-/Checksum-/Marketing-Site-Deploy — dafür
# existiert bei EdgeGuard keine Infrastruktur (siehe CLAUDE.md).
# - Testing-Versionen sind datumsbasiert (wie enconf) DAMIT sie (a) bei
# apt immer über jeder Stable-Semver-Version sortieren und (b) nie mit
# einer Stable-Versionsnummer kollidieren — Gitea dedupliziert
# (Name,Version) global über alle Kanäle hinweg.
#
# Voraussetzung: GITEA_TOKEN (env oder ~/.gitea-token) mit Package-
# Upload/Delete/Query-Scope.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
cd "$REPO_DIR"
OWNER="projekte"
GITEA_URL="https://git.netcell-it.de"
KEEP="${KEEP:-10}"
log() { echo "[release] $*"; }
warn() { echo "$*" >&2; }
abort() { echo "🛑 ABBRUCH: $*" >&2; exit 1; }
if [ -z "${GITEA_TOKEN:-}" ]; then
if [ -r "$HOME/.gitea-token" ]; then
GITEA_TOKEN="$(tr -d '\n' < "$HOME/.gitea-token")"
else
abort "GITEA_TOKEN fehlt (env oder ~/.gitea-token)"
fi
fi
export GITEA_TOKEN
# `make ui` fällt bei fehlendem bun auf `npm install` zurück (Convenience
# für schnelle lokale Builds) — das löst Dependencies gegen package.json
# neu auf statt gegen das gepinnte management-ui/bun.lock, und hinterlässt
# eine package-lock.json die nichts mit dem committeten Lockfile zu tun
# hat. Für einen Release (Testing ODER Stable) ist das nicht akzeptabel —
# beide Kanäle müssen reproduzierbar aus dem gepinnten Lockfile bauen.
command -v bun >/dev/null 2>&1 || \
abort "bun fehlt — Release-Builds müssen aus management-ui/bun.lock bauen, nicht aus dem npm-Fallback. Installieren: https://bun.sh/install"
# ─── Testing-Version: datumsbasiert YYYY.MM.DD.NN ──────────────────────
# NN = laufende Nummer für den Tag, ermittelt aus den bereits im Registry
# vorhandenen Versionen des Meta-Pakets (edgeguard) mit dem heutigen
# Datumspräfix. Erster Release des Tages -> .01.
next_testing_version() {
local today
today="$(date -u +%Y.%m.%d)"
local raw
raw="$(curl -fsS -H "Authorization: token $GITEA_TOKEN" \
"$GITEA_URL/api/v1/packages/$OWNER?type=debian&q=edgeguard&limit=1000" 2>/dev/null || echo '[]')"
local last_n
last_n="$(printf '%s' "$raw" | jq -r --arg n "edgeguard" --arg pfx "${today}." \
'.[] | select(.name==$n) | .version | select(startswith($pfx))' \
| sed "s/^${today}\.//" | sort -n | tail -1)"
printf '%s.%02d' "$today" "$(( ${last_n:-0} + 1 ))"
}
# ─── Stable-Version: Patch-Bump der VERSION-Datei (Default) ────────────
bump_patch() {
local major minor patch
IFS='.' read -r major minor patch <<< "$1"
echo "${major}.${minor}.$((patch + 1))"
}
# ─── Alte Version im Ziel-Kanal ersetzen — Upload derselben Nummer würde
# sonst mit 409 den alten Inhalt behalten (Gitea dedupliziert (Name,
# Version,Arch,Kanal-Pool) — best-effort, 404 ist der Normalfall). ──────
delete_before_upload() {
local channel="$1" version="$2" name="$3" arch="$4"
local url="$GITEA_URL/api/packages/$OWNER/debian/pool/trixie/$channel/$name/$version/$arch"
curl -sS -o /dev/null -w '' -X DELETE -H "Authorization: token $GITEA_TOKEN" "$url" || true
}
replace_in_channel() {
local channel="$1" version="$2"
delete_before_upload "$channel" "$version" edgeguard-api amd64
delete_before_upload "$channel" "$version" edgeguard-api arm64
delete_before_upload "$channel" "$version" edgeguard-ui all
delete_before_upload "$channel" "$version" edgeguard all
}
# ─── verify_channel_debs — Health-Gate wie enconf verify_stable_debs ───
# Holt den Packages-Index eines Kanals und prüft für JEDE dort
# referenzierte .deb, ob sie unter ihrer Pool-URL wirklich mit HTTP 200
# abrufbar ist (nicht nur ob der Index existiert — Gitea regeneriert den
# Index nicht immer zuverlässig nach einem Delete).
verify_channel_debs() {
local channel="$1"
log "verify_channel_debs($channel): jede indexierte .deb muss abrufbar sein..."
local files f code bad=0 checked=0
files="$(curl -sk -H "Authorization: token $GITEA_TOKEN" \
"$GITEA_URL/api/packages/$OWNER/debian/dists/trixie/$channel/binary-amd64/Packages" 2>/dev/null \
| awk '/^Filename:/{print $2}')"
if [ -z "$files" ]; then
warn "$channel: Packages-Index leer/nicht erreichbar — übersprungen"
return 0
fi
while IFS= read -r f; do
[ -z "$f" ] && continue
checked=$((checked + 1))
code="$(curl -sk -o /dev/null -w '%{http_code}' \
-H "Authorization: token $GITEA_TOKEN" "$GITEA_URL/api/packages/$OWNER/debian/$f")"
if [ "$code" != "200" ]; then
warn "$channel: $f -> HTTP $code"
bad=$((bad + 1))
fi
done <<< "$files"
[ "$bad" -eq 0 ] || abort "$channel: $bad von $checked .deb(s) im Index nicht abrufbar — Registry inkonsistent."
log "$channel: $checked .deb(s) verifiziert, alle abrufbar."
}
# ─── Testing-Push ───────────────────────────────────────────────────────
testing_push() {
local new_version
new_version="$(next_testing_version)"
log "[testing 1/3] Gates + Build $new_version (make deb — release-check, amd64+arm64+ui)..."
make VERSION="$new_version" deb
log "[testing 2/3] Alte Version im testing-Kanal ersetzen + hochladen..."
replace_in_channel testing "$new_version"
./scripts/apt-repo/publish.sh "$new_version" amd64 testing
./scripts/apt-repo/publish.sh "$new_version" arm64 testing
log "[testing 3/3] Cleanup (keep last $KEEP) + Verify..."
KEEP="$KEEP" ./scripts/apt-repo/cleanup-old.sh testing
verify_channel_debs testing
log "✅ Testing-Release $new_version veröffentlicht."
}
# ─── Stable-Promotion ───────────────────────────────────────────────────
promote_stable() {
local explicit_version="${1:-}"
local current_version new_version
current_version="$(cat "$REPO_DIR/VERSION")"
new_version="${explicit_version:-$(bump_patch "$current_version")}"
git -C "$REPO_DIR" rev-parse "v$new_version" >/dev/null 2>&1 && \
abort "Git-Tag v$new_version existiert bereits."
# --untracked-files=no: nur versionierte Änderungen blocken. Das Repo
# kann fremde, noch nicht committete Arbeit in eigenen Verzeichnissen
# liegen haben (untracked) — die geht ein Stable-Release nichts an.
[ -n "$(git -C "$REPO_DIR" status --porcelain --untracked-files=no)" ] && \
abort "Uncommittete Änderungen an versionierten Dateien — commit/stash erst, dann Stable-Release."
log "[stable 1/4] Gates + Build $new_version (make deb — release-check, amd64+arm64+ui)..."
make VERSION="$new_version" deb
log "[stable 2/4] Alte Version im stable-Kanal ersetzen + hochladen..."
replace_in_channel stable "$new_version"
./scripts/apt-repo/publish.sh "$new_version" amd64 stable
./scripts/apt-repo/publish.sh "$new_version" arm64 stable
log "[stable 3/4] VERSION-Datei setzen + Commit + Git-Tag..."
printf '%s' "$new_version" > "$REPO_DIR/VERSION"
git -C "$REPO_DIR" add VERSION
git -C "$REPO_DIR" commit -m "chore(release): v$new_version stable"
git -C "$REPO_DIR" tag "v$new_version"
log "[stable 4/4] Cleanup (keep last $KEEP) + Verify..."
KEEP="$KEEP" ./scripts/apt-repo/cleanup-old.sh stable
verify_channel_debs stable
log "✅ Stable-Release v$new_version fertig."
log " Push nicht vergessen: git push origin main --tags"
}
case "${1:-}" in
stable) promote_stable "${2:-}" ;;
"") testing_push ;;
*) abort "unbekanntes Argument '$1' (erwartet: 'stable' oder kein Argument)" ;;
esac