Auf utm-2 trug die Cluster-Karte an BEIDEN Knoten "DB-Primary" — auf
utm-1 dagegen korrekt "primary" und "peer". Es kann aber nur einen
DB-Primary geben, also war mindestens eine der beiden Ansichten falsch.
Ursache: ha_nodes ist NICHT repliziert (node-lokale Tabelle, jeder Knoten
fuehrt seine eigene Sicht), und in `role` traegt sich jeder Knoten selbst
ein. Ein per Join dazugekommener Knoten behaelt dort den Default
"primary" — utm-2 behauptete in seiner eigenen Ansicht also, es sei
Primary. Der Code weiss das an anderer Stelle bereits:
cluster_repair.go dokumentiert "role/pg_role sind je Node lokal und
unzuverlaessig", und keepalived.go gibt pg_role deshalb seit v1.2.46
absoluten Vorrang vor role.
Die Karte zeigt jetzt pg_role statt role. Das folgt der tatsaechlichen
Replikationsrolle und stimmt auf beiden Seiten ueberein (utm-1=primary,
utm-2=standby). 'standalone'/leer heisst "keine Replikation eingerichtet"
und zeigt bewusst gar keine Rolle, statt eine zu erfinden.
Die stale role='primary'-Zeile auf utm-2 wurde separat direkt in der
node-lokalen DB korrigiert. Sie war nicht nur kosmetisch: faellt pg_role
irgendwann aus (leer/'standalone'), greift in keepalived.go der
role-Fallback — und genau diese Konstellation hat 2026-05 schon einmal
einen Split-Brain ausgeloest (beide Knoten Prio 200, hoehere IP gewinnt).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Auf utm-2 war das Zertifikat der Management-UI seit dem 28.08. abgelaufen
(entdeckt am 11.09.) und haette sich aus eigener Kraft nie wieder
erneuert. Zwei Ursachen trafen zusammen:
1) Das FQDN eines per Join dazugekommenen Nodes landet in KEINER
tls_certs-Zeile — es wird beim Setup einmalig ausgestellt und danach
von niemandem mehr angefasst. certrenewer arbeitet nur die Tabelle ab
und sieht es deshalb nie. (utm-1 steht dort drin, weil sein FQDN beim
Erst-Setup regulaer als Domain angelegt wurde — deshalb fiel es dort
nicht auf.)
2) Der Scheduler blockt auf einem Nicht-VIP-Master jede ACME-Erneuerung
(v1.3.20). Fuer geteilte Domains ist das richtig: die zeigen per DNS
auf die VIP, nur der Master kann die Challenge bestehen. Fuer das
eigene Management-FQDN stimmt es nicht — utm-2.netcell-it.de zeigt auf
89.163.205.8, die eigene IP des Nodes, und Port 80 antwortet dort.
Neu: runManagementCertRenew laeuft auf JEDEM Node unabhaengig von der
VIP, aber ausschliesslich fuer das eigene FQDN aus setup.json. Prueft die
Restlaufzeit der Datei unter /etc/edgeguard/tls und erneuert ab 30 Tagen
Rest (gleicher Schwellwert wie bei den Domain-Certs).
Bewusst NICHT ueber tls_certs: das ist eine replizierte Shared-Table, und
cluster-reconcile-replication TRUNCATEt solche Tabellen beim Refresh —
eine lokal auf dem Subscriber eingefuegte Zeile waere beim naechsten
Paket-Upgrade wieder weg. Das Management-Zertifikat ist node-lokale
Infrastruktur und wird wie die cluster-tls-Certs als reine Datei
behandelt. Existiert dagegen bereits eine tls_certs-Zeile fuer das eigene
FQDN (Fall utm-1), bleibt alles beim Alten und certrenewer behaelt die
Zustaendigkeit — sonst haetten zwei Mechanismen dieselbe Datei.
Tests decken die Schwellwert-Entscheidung ab, inklusive des
utm-2-Falls (bereits abgelaufen) und kaputter PEM-Dateien.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Symptom: Klick auf einen Link und zurueck aufs Dashboard →
"EdgeGuard konnte nicht laden / TypeError: Cannot read properties of
undefined (reading 'length')". Nach F5 ging es wieder, bis man erneut
navigierte.
Ursache ist ein Cache-Key-Konflikt. Unter ['haproxy','stats'] lagen zwei
unvereinbare Formate:
- Dashboard cachte { backends, frontends, error } (es zeigt auch
Frontends an),
- Domains, Domains/Detail, Backends, Backends/Detail und RoutingRules
cachten via listHAProxyStats nur das Backend-ARRAY.
Wer zuletzt lud, bestimmte die Form im Cache. Nach einem Besuch einer
dieser Seiten bekam das Dashboard bei der Rueckkehr das Array serviert,
stats.frontends war undefined und der Throw landete in der
ErrorBoundary. Ein Reload half nur, weil er den Cache leert und das
Dashboard wieder selbst befuellt.
Fix: alle sechs Stellen cachen jetzt die vollstaendige Antwort; die fuenf
Seiten, die nur die Backends brauchen, reduzieren per `select`. Damit
gibt es unter dem Key genau eine Form, egal wer zuerst laedt.
Zusaetzlich im Dashboard defensive Guards (`?? []`) auf data.vips,
stats.frontends und stats.backends. Ein unerwartetes Format darf eine
einzelne Karte kosten, aber nie die komplette Oberflaeche.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Bisher war der zweite Node nach dem Join zwar im Cluster registriert und
in der UI sichtbar, replizierte aber keine einzige geteilte Tabelle —
dafuer musste jemand manuell `edgeguard-ctl cluster-setup-standby`
ausfuehren. Wer das uebersah, merkte es erst beim Failover: der neue
Primary stand ohne Domains, Backends, Firewall-Regeln und WireGuard-Keys
da. Das ist jetzt Teil des Join-Vorgangs.
Beide Seiten muessen dafuer vorbereitet sein:
1) Primary, beim Erzeugen des Join-Tokens: ein frisch installierter
Single-Node hat weder Replikations-Rolle noch PUBLICATION noch
wal_level=logical. Ohne das liefe das spaetere CREATE SUBSCRIPTION in
ein 404. Der Token wird deshalb erst ausgegeben, nachdem die
Publisher-Seite steht — inklusive des einmaligen PG-Restarts
(wal_level ist ein postmaster-Parameter), der bewusst hier passiert,
solange der Admin danebensteht und noch kein Peer Traffic erwartet.
WICHTIG dabei: setupReplicationPrimary rotiert bei jedem Lauf das
Replikations-Passwort (ALTER ROLE … PASSWORD). Auf einem Cluster mit
bereits angebundenem Subscriber wuerde ein zweiter Token-Klick dessen
Connection-String ungueltig machen und die Replikation still
anhalten. Deshalb laeuft die Initialisierung nur, wenn PUBLICATION
und Secret nicht bereits existieren.
2) Neuer Node, nach erfolgreichem Join: cluster-setup-standby laeuft
detached (die Initialkopie dauert je nach Datenmenge Minuten), der
Wizard pollt GET /setup/replication-status und zeigt running/done/
failed an. Schlaegt es fehl, steht das manuelle Kommando inkl.
Primary-Host direkt daneben statt nur einer Fehlermeldung.
Beides braucht root (psql als postgres, pg_hba, PG-Restart), die API
laeuft als unprivilegierter edgeguard → Aufruf via sudo mit gepinnten
Regeln. Das einzige variable Argument (Primary-Host) wird vorher gegen
Hostname/IP-Syntax geprueft; der Aufruf laeuft ohne Shell. Test dafuer
liegt bei.
Ausserdem zwei Doku-Korrekturen: architecture.md behauptete,
cluster-join richte die Replikation gleich mit ein (tut es nicht,
clusterjoin.Join macht nur Cert + Registrierung), und der Hinweistext
von cluster-join verwies noch auf "PG-Basebackup + KeyDB, Phase 3.5" —
beides laut Doku laengst verworfen.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
squid und unbound lauschen auf den VLAN-Gateway-VIPs. Bei
ip_nonlocal_bind=0 kann ein Node diese Adressen nur binden, waehrend er
die VIP haelt. Bootet ein Node als Standby, scheitert der Start deshalb
mit "FATAL: Unable to open HTTP Socket" und die Unit bleibt dauerhaft
`failed` — systemd versucht es nicht erneut, keepalived-master.sh startet
sie erst bei VIP-Uebernahme (Kaltstart im Umschaltmoment).
Zwei Probleme daran: der Dauer-`failed`-Zustand ist nicht von einem
echten Ausfall zu unterscheiden, und beim Failover kommen die Dienste
erst nach dem Start hoch statt sofort bereit zu stehen.
Mit nonlocal_bind laufen beide auf beiden Nodes durch. Traffic bekommt
weiterhin nur der Node, der die VIP per ARP haelt — die VIP-Wahl selbst
ist nicht betroffen, keepalived-check.sh prueft ausschliesslich
edgeguard-api, haproxy und den :443-Bind.
Zusaetzlich repariert der postinst gezielt Units, die enabled UND failed
sind (also genau den obigen Fall); laufende oder bewusst deaktivierte
Dienste bleiben unangetastet.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Die Dashboard-Cluster-Karte zeigte ha_nodes.role als nacktes "primary".
Das ist die DB-/Cluster-Rolle (wohin Schreibzugriffe gehen); sie wandert
bewusst NICHT mit der VIP und aendert sich nur durch `edgeguard-ctl
promote`. Direkt daneben steht aber die VIP/VRRP-Karte mit "BACKUP" —
waehrend eines Failovers (z.B. Node-Reboot) sah der Operator also
gleichzeitig "primary" und "BACKUP" und musste raten, was stimmt.
Die Daten waren korrekt, nur das Label mehrdeutig: jetzt "DB-Primary"
statt "primary", plus Tooltip der den Unterschied zur VRRP-Rolle
benennt. Auf der Cluster-Seite bleibt es unveraendert — dort steht die
Spalte direkt neben pg_role, der Kontext erklaert sich dort selbst.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Backend:
- Go-Toolchain 1.26.4/1.26.6 -> 1.27.1
- alle 9 veralteten direkten Module auf latest (gin 1.10->1.12,
pgx 5.9->5.11, go-oidc 3.18->3.21, minio 7.1->7.3, goose 3.27->3.28,
haproxy-go 0.0.8->0.1.1, sftp, x/crypto 0.57, x/oauth2 0.37)
- quic-go 0.59.0 -> 0.59.1: GO-2026-5676 (HTTP/3 QPACK Trailer Memory
Exhaustion) kam mit dem Modul-Update rein und wurde vom govulncheck-
Gate gefangen.
Toolchain-Falle im Makefile (der eigentliche Knackpunkt): golangci-lint
und govulncheck verweigern die Arbeit, sobald go.mod eine neuere Go-Version
zielt als die, mit der sie selbst gebaut wurden — govulncheck meldete dann
"package requires newer Go version" fuer JEDES Paket, statt zu scannen. Ein
gruenes Ergebnis waere hier also nicht "keine Lücken", sondern "gar nicht
geprüft" gewesen. Beide Targets pinnen jetzt GOTOOLCHAIN auf die go.mod-
Version und installieren neu, sobald die Build-Go-Version der vorhandenen
Binary abweicht. Der bisherige `command -v`-Check hat eine veraltete Binary
nie erneuert.
Frontend: antd 6.3->6.6.3, React 19.2->19.3, axios 1.16->1.20,
tanstack-query 5.100->5.102, react-router 7.15->7.18, zustand, icons,
i18next 25->26, react-i18next 16->17, eslint 9->10.
TypeScript bewusst auf 6.0.3 statt 7.0.2: typescript-eslint unterstuetzt
auch in der neuesten Release (8.70.0) nur `typescript <6.1.0`. Mit TS 7
baut das UI zwar, aber ESLint bricht komplett ab ("typescript-eslint does
not support TS 7.0") — das Frontend-Linting waere damit still weg. 6.0.3
ist die neueste Version innerhalb der unterstuetzten Range.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
- 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>
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>
- 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>
Erster Schritt des golangci-lint-Rollouts (non-blocking): 44 misspell + 4
staticcheck automatisch behoben (32 Dateien, nur Tippfehler/mechanisch).
build+test grün. Kein Runtime-Change → kein Deploy.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Build-Host-Toolchain ist go1.26.4; go.mod stand noch auf 1.26.0. Direktive
nachgezogen — Binaries wurden (GOTOOLCHAIN=auto, lokal>=Direktive) ohnehin
schon mit 1.26.4 gebaut, daher kein Binary-/Runtime-Change, kein Redeploy nötig.
go build/vet/test mit 1.26.4 grün.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Der tägliche audit_log-Cleanup (cmd/edgeguard-scheduler) schlug auf beiden Nodes
jeden Tag fehl:
"audit cleanup failed" keep_days=90
error: unable to encode 90 into text format for text (OID 25): cannot find encode plan
Ursache: audit.go nutzte `($1::text || ' days')::interval`, übergab keepDays aber
als int → pgx kann int nicht als text (OID 25) encoden. Folge: Cleanup lief nie,
tägliches WARN-Rauschen + langfristig unbegrenztes audit_log-Wachstum (Einträge
>keep_days wurden nie gelöscht).
Fix: `NOW() - make_interval(days => $1)` — $1 bleibt sauber int-typisiert.
Gegen Live-DB validiert (gültige Syntax, 0 betroffene Rows aktuell).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
chrony honoriert nur EINE bindaddress pro Adressfamilie. Der Generator emittierte
aber eine bindaddress PRO Listen-IP (mehrere VLAN-/Cluster-VIPs) → chrony band nur
die letzte (10.0.50.1), alle anderen Clients (z. B. auf 10.0.5.1) erreichten den
NTP-Server NICHT. Ein Restart hilft nicht (Config-Bug, nicht stale binding).
Fix: chrony.cfg.tpl emittiert KEIN bindaddress mehr → bind-all; WER bedient wird,
regeln die allow-ACL + die nftables-Regeln (UDP/123 nur auf den Listen-IPs/VIPs
offen, nicht öffentlich). Zugleich failover-robust: chrony bedient automatisch
jede VIP, die der Node gerade hält, ohne Restart bei Master-Wechsel.
Test: internal/chrony/chrony_test.go (kein bindaddress, allow vorhanden; port 0
bei serve_clients=false).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>