72 Commits

Author SHA1 Message Date
noroot
aeb4eaa6b6 chore(release): v1.3.37 stable 2026-09-11 12:59:54 +02:00
noroot
4675c6062e feat(alerts): "Quittierte löschen" — Backlog war nur seitenweise raeumbar
Gemeldet als "Loeschen tut nichts". Das Loeschen hat funktioniert: der
Audit-Eintrag zeigt deleted=25 und die IDs waren danach weg. Nur war das
unsichtbar — die Kopf-Checkbox der Tabelle markiert ausschliesslich die
AKTUELLE Seite (25 Zeilen), und bei einem Backlog von ~950 optisch
identischen backend.down-Alarmen ruecken sofort die naechsten 25 nach.
Fuer den Operator sah es aus, als sei nichts passiert; um den Backlog
leerzubekommen waeren ~38 Klick-Runden noetig gewesen.

Neu: POST /alerts/events/delete-acknowledged loescht alle quittierten
Events auf einmal — Gegenstueck zum bereits vorhandenen "Alle
quittieren". Bewusst nur die quittierten: was noch niemand gesehen hat,
soll nicht per Sammelaktion verschwinden. Der Button zeigt die Anzahl
ueber den gesamten Datensatz, nicht nur die sichtbare Seite.

Ausserdem meldet das seitenweise Loeschen jetzt die Anzahl zurueck
("N Alarme geloescht") statt nur "Loeschen" und leert die Auswahl —
damit ist erkennbar, dass etwas passiert ist, auch wenn die Liste
optisch gleich aussieht.

Die automatische Aufraeumung (90 Tage) arbeitet uebrigens korrekt, sie
loescht taeglich die Eintraege die ueber die Grenze rutschen — der
Backlog stammt aus dem Zeitraum davor.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 12:59:15 +02:00
noroot
25bc9c3673 chore(release): v1.3.36 stable 2026-09-11 12:37:09 +02:00
noroot
a54d367c24 fix(cluster): duplicate-key-Warnung bei jedem API-Start auf dem Standby
preRegisterPrimary legte unbedingt eine Platzhalter-Zeile fuer den
Primary an. Sobald der Primary sich aber selbst gemeldet hat, existiert
bereits eine Zeile mit seiner ECHTEN Node-ID und demselben FQDN — und
UpsertSelf nutzt ON CONFLICT (id), greift also nicht. Die Synthetik-ID
"prenode-<fqdn>" lief damit in den fqdn-Unique-Index:

  duplicate key value violates unique constraint "ha_nodes_fqdn_unique"

bei JEDEM API-Start auf dem Standby. Folgenlos — die echte Zeile ist ja
korrekt und der Firewall-Reload lief trotzdem — aber es sah nach einem
Defekt aus und verdeckte echte Warnungen im selben Log.

Jetzt wird zuerst die IP einer vorhandenen Zeile aufgefrischt (mehr will
die Funktion gar nicht) und nur bei RowsAffected()==0 der Platzhalter
angelegt — also genau dann, wenn der Primary sich noch nie gemeldet hat.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 12:36:30 +02:00
noroot
6149670375 chore(release): v1.3.35 stable 2026-09-11 12:26:20 +02:00
noroot
1b13df4032 fix(cluster): pg_role fehlte im SELECT — Feld kam nie beim Client an
baseSelect in cluster/store.go selektierte pg_role nicht. HANode.PGRole
war dadurch ueberall ein leerer String, wo Store.List/Get benutzt wird:
/cluster/nodes, /cluster/status und damit auch die pg_role-Spalte der
Cluster-Seite. Aufgefallen ist es erst, als die Dashboard-Cluster-Karte
in v1.3.33 auf pg_role umgestellt wurde und daraufhin bei beiden Knoten
"—" zeigte — der Fehler lag aber schon vorher im Store.

pg_role ist jetzt in baseSelect, im RETURNING von UpsertSelf und in
scanNode ergaenzt; die drei muessen spaltenweise zueinander passen.
UpsertSelf SCHREIBT pg_role weiterhin bewusst nicht (ON CONFLICT laesst
die Spalte unangetastet, damit eine Self-Registrierung den per promote
gesetzten Wert nicht ueberbuegelt) — liest sie aber im RETURNING mit,
sonst stimmt die Scan-Reihenfolge nicht.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 12:25:40 +02:00
noroot
3ca37ee226 chore(release): v1.3.34 stable 2026-09-11 12:21:59 +02:00
noroot
86aee33308 fix(cluster): jeder Node registrierte sich selbst fest als "primary"
Die self-Registrierung in ha_nodes uebergab beim API-Start hart
"primary" — fuer JEDEN Node. Da ha_nodes node-lokal ist (nicht
repliziert), trug sich damit auch ein per Join dazugekommener Standby
bei sich selbst als Primary ein. In der Cluster-Ansicht DIESES Nodes
erschienen beide Knoten als Primary, und eine Korrektur direkt in der
DB hielt nur bis zum naechsten Neustart.

Nicht kosmetisch: keepalived.go nimmt `role` als Fallback, wenn pg_role
nicht 'standby' ist. Ein Standby, der sich selbst "primary" nennt, ist
damit genau der Zustand, der 2026-05 schon einmal einen Split-Brain
ausgeloest hat (beide Knoten Prioritaet 200, hoehere IP gewinnt).
Aktuell deckt pg_role='standby' das ab — aber als alleinige Absicherung
ist das duenn.

Die Rolle wird jetzt aus der Replikations-Topologie abgeleitet, also der
in cluster_repair.go dokumentierten verlaesslichen Quelle: nur der
Primary hat die PUBLICATION, nur der Standby die SUBSCRIPTION (beide
Kataloge darf der edgeguard-DB-User lesen, verifiziert). Das ist
selbstheilend und ueberlebt `edgeguard-ctl promote` korrekt — eine
Ableitung aus setup.json wuerde den Promote dagegen bei jedem Neustart
wieder ueberschreiben. Ohne eingerichtete Replikation entscheidet
IsClusterNode.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 12:21:22 +02:00
noroot
4be9f7f280 chore(release): v1.3.33 stable 2026-09-11 12:15:56 +02:00
noroot
a34457f13f fix(ui): Cluster-Karte zeigte auf dem Standby beide Knoten als DB-Primary
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>
2026-09-11 12:15:19 +02:00
noroot
1b0320a5da chore(release): v1.3.32 stable 2026-09-11 12:03:03 +02:00
noroot
9137c07c95 fix(tls): Management-Zertifikat des Standby lief ab und erneuerte sich nie
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>
2026-09-11 12:02:26 +02:00
noroot
84112d399b chore(release): v1.3.31 stable 2026-09-11 11:52:15 +02:00
noroot
0f2fba4a62 fix(ui): Dashboard riss bei SPA-Navigation die ganze Oberflaeche
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>
2026-09-11 11:51:38 +02:00
noroot
60358a6d47 chore(release): v1.3.30 stable 2026-09-11 11:41:23 +02:00
noroot
808f6fc055 feat(cluster): Logical Replication wird beim Join automatisch eingerichtet
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>
2026-09-11 11:40:44 +02:00
noroot
bb19562bc1 chore(release): v1.3.29 stable 2026-09-11 10:33:34 +02:00
noroot
a31c94f9b8 fix(ha): squid/unbound liefen auf dem Standby nicht — ip_nonlocal_bind
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>
2026-09-11 10:32:54 +02:00
noroot
2c72a82a91 chore(release): v1.3.28 stable 2026-09-11 10:24:22 +02:00
noroot
ed419c1f5f fix(ui): Cluster-Karte las sich als Widerspruch zur VIP-Karte
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>
2026-09-11 10:23:36 +02:00
noroot
95f2238588 chore(release): v1.3.27 stable 2026-09-11 09:54:32 +02:00
noroot
de83936fce chore(deps): Go 1.27.1, alle Module und Frontend-Pakete aktualisiert
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>
2026-09-11 09:53:16 +02:00
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
Debian
32ab2c7f47 chore(lint): Backlog auf 0 + golangci-lint als HARTER Gate — v1.3.3
Go-Quality-Baseline-Rollout ABGESCHLOSSEN.

Code-Quality-Backlog (55 → 0):
- errcheck: unbehandelte Close/Rollback/Remove explizit `_ =`; fmt.Sscanf
  `_, _ =` (Zero-Value degradiert sauber).
- unused: toter Code entfernt (nodeIDOrHostname, stripTrailingNewline,
  acme.Service.user, strFold + ungenutzter Import).
- noctx (net/http): http.NewRequestWithContext mit vorhandenem ctx.
- staticcheck: QF1001/S1009/ST1005/SA9003.
- contextcheck: detached-by-design-Stellen mit begründetem //nolint.

Zwei echte Bugs beim Aufräumen gefunden+gefixt:
- backup/remote SFTP-Upload: dst.Close()-Flush-Fehler wurde verschluckt →
  unvollständiges Remote-File galt als Erfolg. Jetzt geprüft+gemeldet.
- haproxy_test: leere if-Assertion (SA9003) testete faktisch nichts →
  echte t.Errorf-Prüfung (kein HSTS für HSTS-disabled Domain).

Bewusste Config-Entscheidungen (.golangci.yml):
- noctx-on-os/exec ausgeschlossen: System-Command-Reloads (systemctl/nft/
  wg/pg) dürfen NICHT an den Request-Context gebunden werden — ein Client-
  Disconnect darf keinen laufenden Reload mitten in der Ausführung killen.
  net/http-noctx bleibt voll aktiv. KEINE exec-Zeile im Code angefasst.
- rowserrcheck/sqlclosecheck raus (database/sql-Linter, bei pgx nur FPs).

Gate scharf gestellt: Makefile release-check ruft golangci-lint jetzt als
HARTEN Gate (install-if-missing, pinned v2.12.2). `make release-check`
grün: vet, golangci-lint, govulncheck, build, test -race.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 00:49:07 +02:00
Debian
cdbb62ee1a harden(api): Slowloris-Timeout + gosec-Security-Audit + waf-Purge-Bugfix — v1.3.2
Go-Quality-Baseline-Rollout, Security-Teil:

- api: http.Server bekommt ReadHeaderTimeout (15s) + IdleTimeout (120s)
  gegen Slowloris-Header-Stalls (gosec G112). ReadTimeout/WriteTimeout
  bewusst offen (lang laufende Rolling-Update-/Backup-Endpoints).

- fix(waf): PurgeAlerts nutzte NOW() - ($1 || ' days')::interval mit
  olderThanDays int → pgx-Encode-Error zur Laufzeit → DELETE /waf/alerts
  war kaputt. Auf make_interval(days => $1) umgestellt (gleiche Bug-
  Klasse wie audit-Cleanup v1.3.0). Via Lint-Aufräumen entdeckt.

- .golangci.yml: 26 gosec-Findings line-by-line auditiert. Alle sind
  bewusstes Appliance-Verhalten mit Compensating Controls (Subprocess-
  Args intern/validiert, Config-File-Perms daemon-lesbar, SSH opt-in
  Fingerprint-Pinning, UI-Server Clean+HasPrefix-Traversal-Guard) oder
  FPs (G101 Konstanten-Namen, G702/G703/G706 Taint). Dokumentiert
  exclude't. gosec-Rest = 0.

- .golangci.yml: rowserrcheck/sqlclosecheck raus — database/sql-Linter,
  bei durchgängigem pgx nur FPs.

gosec=0, govulncheck=0, race=0. Rest-Backlog: errcheck/noctx/staticcheck
(Code-Quality, kein Security) — folgt.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 23:36:37 +02:00
Debian
02096c8ad8 chore(lint): golangci-lint --fix — misspell + staticcheck-Autofixes (Backlog 142→~98)
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>
2026-07-05 23:25:22 +02:00
Debian
d15774f1cd fix(security): x/crypto v0.52 + x/net v0.55 (6 CVEs) + govulncheck-Release-Gate — v1.3.1
govulncheck ab sofort fest im Release-Prozess. Baseline-Scan fand 6 aktiv
aufgerufene Vulns (SSH-Backup-Pfad internal/services/backup/remote):
- 5× golang.org/x/crypto (SSH DoS/Deadlock/Panic: GO-2026-5013/5017/5018/5019/5020)
  → x/crypto v0.51.0 => v0.52.0
- 1× golang.org/x/net (GO-2026-5026) → v0.53.0 => v0.55.0
Re-Scan danach: "No vulnerabilities found."

Go-Quality-Baseline (Makefile + .golangci.yml, portabel):
- release-check läuft autom. vor jedem deb/publish: vet → golangci-lint
  (Rollout: non-blocking) → govulncheck (HARTER Gate) → build → test -race.
- make vulncheck / make test-race als eigene Targets.
- .golangci.yml: staticcheck/govet/errcheck/ineffassign/unused/misspell +
  gosec/bodyclose/rowserrcheck/sqlclosecheck/noctx/contextcheck.
- go test -race: aktuell 0 Races (Gate sicher).
Doku in CLAUDE.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 22:34:25 +02:00
Debian
4c1e0e9926 chore(go): go.mod go-Direktive auf 1.26.4 nachgezogen
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>
2026-07-04 01:03:56 +02:00
Debian
2495bf022b fix(scheduler): Audit-Cleanup lief nie (int-als-text Encode-Fehler) — v1.3.0
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>
2026-06-26 11:30:12 +02:00
Debian
25ec98161f fix(chrony): kein Multi-bindaddress — NTP-Server bediente nur eine VIP — v1.2.109
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>
2026-06-18 15:46:17 +02:00
Debian
79cd68e460 feat(domains): 301-Weiterleitung Domain→Domain (redirect_to) — v1.2.108
Neue Domain kann per 301 auf eine andere Domain/URL umgeleitet werden, statt
auf ein Backend zu routen (Use-Case: kvs.netcell-it.de → https://zkm.netcell-it.de,
inkl. HTTPS). Variante (a): immer auf Ziel-Root (`redirect location`), pfad-
unabhängig.

- Migration 0043: domains.redirect_to text NOT NULL DEFAULT ''
- Model + domains-Service (SELECT/INSERT/UPDATE/scan)
- HAProxy-Generator: buildRedirectTo() sanitisiert (nur http(s), kein
  Whitespace/Quotes → sonst kein Redirect statt kaputter Config); Template
  emittiert `http-request redirect location <url> code 301 if hdr(host)`.
  Terminiert vor use_backend → Redirect-Domain routet auf kein Backend.
  http→https läuft über den vorhandenen :80-Redirect (zwei Hops, inkl. TLS).
- UI: Feld „Weiterleitung (301) nach" im Domain-Formular (de/en)
- Tests: Render-Zeile + buildRedirectTo-Sanitisierung

Hinweis: Die Redirect-Domain braucht weiterhin ein eigenes TLS-Zert (ACME).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 19:23:37 +02:00
Debian
f3c76f6d18 fix(cluster): selbstheilende public_ip + advert_int 2 (Flapping) — v1.2.107
Zwei keepalived-Flapping-Restursachen:
1) Strukturell: der Cluster-Push (autoRegister, Primary→Secondary) trägt KEINE
   public_ip → der Empfänger AgentRegisterPeer ließ sie NULL → Peer fehlte im
   nft-peer_ipv4-Set → VRRP-Adverts (eth0/VI_1) nur via conntrack → Flapping.
   (utm-1 lernte utm-2 korrekt via Joiner-Client-IP in preRegisterJoiner; die
   Gegenrichtung fehlte.) Fix: AgentRegisterPeer fällt bei leerem req.PublicIP
   auf die mTLS-Client-IP (c.ClientIP()) zurück — die EIGEN-IP des Peers, nicht
   die VIP. Selbstheilend, überlebt Re-Joins/Failover.
2) advert_int 1 → 2 (Master-Down ~6s statt ~3s): reißt nicht mehr bei kurzen
   VM-/Heartbeat-Hiccups (VI_HB). Trade-off: Failover-Erkennung ~6s.

Tests: advert_int 2 im Render.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 13:42:21 +02:00
Debian
b2fc7b7dee fix(cluster): chk_edgeguard fall 3→8 — Upgrade-Restart triggert keinen Failover — v1.2.106
Seit v1.2.105 (Track-Scripts ohne weight = FAULT-Trigger) löste jeder
edgeguard-api-Restart beim Deploy einen Failover aus (kurze Health-Check-Fehler
während Stop/Render/Restart reichten bei fall 3 = 6s). fall 8 (16s) lässt einen
normalen Upgrade-Restart durchrutschen; ein echter API-Tod schwenkt weiter in 16s.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 19:14:08 +02:00
Debian
9b563baaa1 fix(cluster): keepalived Track-Scripts ohne weight (Sync-Group) — v1.2.105
`keepalived -t` zeigte "ignoring tracked script chk_edgeguard/chk_gateway with
weights due to SYNC group": gewichtete Track-Scripts werden in einer
vrrp_sync_group ignoriert → Health-Check-Failover (Gateway weg / API tot) griff
NICHT. Fix: weight entfernt → Scripts wirken als binäre FAULT-Trigger (fall-mal
Fehler → Instanz+Sync-Group FAULT → gesunder Peer übernimmt). Für 2-Node-Cluster
die korrekte Semantik.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 19:07:24 +02:00
Debian
d8b8fef680 fix(cluster): keepalived 2.3.x lehnt vrrp_garp_interval 0 ab — entfernt — v1.2.104
v1.2.103 hatte vrrp_garp_interval 0 / vrrp_gna_interval 0 aus dem Legacy-Template
übernommen. keepalived 2.3.3 lehnt 0 ab (Range [0.000001, …]) → "invalid",
`keepalived -t` schlägt fehl (keepalived ignoriert sie zwar mit Warnung und läuft,
aber latentes Risiko bei striktem Start). Entfernt — Default passt. Die wirksame
Anti-Aging-Direktive vrrp_garp_master_refresh 60 + master_repeat 5 bleiben.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 19:02:02 +02:00
Debian
fd8125247c fix(cluster): VIP-Failover am Upstream zuverlässig — GARP + Priming-Ping — v1.2.103
Nach v1.2.102 (VIP nicht mehr statisch gebunden) wandert die Public-VIP .100
erstmals wirklich per keepalived zwischen den Node-MACs. Der Hosting-Upstream
lernte die neue MAC aber nicht zuverlässig → .100 nach Schwenk von außen
unerreichbar (vorher maskiert, weil .100 statisch dauerhaft auf utm-1 lag).

Zwei Mechanismen ergänzt:
1) keepalived.conf.tpl global_defs: vrrp_garp_master_repeat 5 +
   vrrp_garp_master_refresh 60 (+ vrrp_garp_interval/gna 0, aus Legacy-Template
   verloren gegangen) → forciertes GARP beim Wechsel + periodische Auffrischung,
   damit die VIP-MAC am Switch nicht altert.
2) keepalived-master.sh: neuer Master pingt den Default-Gateway aus jeder
   Public-IP/VIP an (ping -I <vip>) → Upstream sieht Traffic VON der VIP und
   lernt die MAC sofort. Manche Hoster relearnen nur so, nicht via GARP.

Test: keepalived_test.go prüft GARP-Direktiven im Render.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 18:56:32 +02:00
Debian
7b6409b631 fix(cluster): VIPs nie statisch binden — Duplicate-IP-Kernbug — v1.2.102
Ergänzt v1.2.101: der ip-addresses-Apply (internal/services/ipaddresses/
apply.go) band ALLE aktiven Adressen statisch — inkl. is_vip. RenderSecondary
(das eth0 ausschließt) ist toter Code, wird nie aufgerufen.

Folge: die VIP (89.163.205.100 + VLAN-Gateways 10.0.x.1) lag auf dem Node
statisch gebunden, UNABHÄNGIG von keepalived. Sobald keepalived die VIP per
Failover auf den Peer legte, lag sie auf BEIDEN Nodes → Duplicate-IP/ARP-
Konflikt → UniFi-Tunnel/LAN bricht (erklärt „utm-1 stoppen → sofort stabil":
der Konflikt verschwindet, nicht VRRP-Failover).

Fix: Render-Query schließt is_vip hart aus (AND ia.is_vip = false) → VIPs
gehören ausschließlich keepalived (nur der VRRP-Master trägt sie). Gilt für
beide Render-Pfade.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 18:09:17 +02:00
Debian
a2450a759c fix(cluster): keepalived Split-Brain + Boot-Race behoben — v1.2.101
Ursache der „WireGuard reißt immer wieder ab"-Abrisse war NICHT die UniFi,
sondern keepalived-Flapping im HA-Cluster: die VIP 89.163.205.100 (an der die
UniFi-Site-to-Site hängt) wanderte bei ~17 VRRP-Wahlen/Tag zwischen utm-1/utm-2
→ Tunnel-Abriss bei jeder Wahl.

Drei Bugs:
1) Firewall ließ VRRP (IP-Proto 112) zwischen den Cluster-Peers NICHT zu
   (policy drop). Adverts überlebten nur via conntrack-Reverse-Matching → bei
   conntrack-Ablauf gedroppt → Peer promotet sich → Split-Brain.
   Fix: ruleset.nft.tpl erlaubt `ip/ip6 ... vrrp saddr @peer_ipv4/6`;
   firewall.go nimmt zusätzlich hb_src_ip/hb_peer_ip aus cluster_settings ins
   Peer-Set (deckt den Heartbeat-Pfad 169.254.0.x ab).
2) Kein nopreempt → erholter Node riss die VIP sofort zurück (Flap-Back);
   aggressiver gw-Check (fall 2 → 10s-Blip = Failover).
   Fix: keepalived.conf.tpl mit `nopreempt` in VI_1+VI_HB, chk_gateway fall 2→5;
   keepalived.go setzt State immer BACKUP (nopreempt wirkt nur in BACKUP),
   Priorität 200/100 aus pg_role bleibt → deckt sich mit „manuelles Promote".
3) keepalived-Boot-Race: Unit startete vor vlan500 (nur After=network-online)
   → „interface vlan500 doesn't exist" → permanenter CONFIG-Crash ohne Recovery
   (keepalived nach Reboot tot). Fix: postinst legt Drop-in mit
   After=/Wants=edgeguard-interfaces.service + Restart=on-failure an.

Neuer Test internal/keepalived/keepalived_test.go (nopreempt/BACKUP/fall).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 17:49:04 +02:00
Debian
91e51890dd fix(wireguard): Tunnel reißt nie ab + Client-Endpoint auto-befüllt — v1.2.100
Zwei Bugs, die WireGuard-Verbindungen verhinderten/abrissen:
1) Client-Config-Endpoint war hartkodierter Platzhalter REPLACE_WITH_PUBLIC_HOST → neue Clients bauten nie einen Tunnel auf (Host löst nicht auf). Jetzt: WireguardHandler.PublicHost (aus setup.json FQDN, main.go) → Endpoint = <fqdn>:<port>. Platzhalter nur noch als Fallback wenn FQDN unbekannt.
2) Renderer machte bei JEDER Config-Änderung 'systemctl restart wg-quick@<iface>' → voller Link-Flap, alle Peers droppen (verstößt gegen 'wireguard darf nie abbrechen'). Jetzt: laufendes Interface → 'wg-quick strip | wg syncconf' (Peers/Listen-Port live, KEIN Abbruch); nur erstmaliges Hochfahren via systemctl start; restart nur noch als Fallback mit WARN. interfaceExists() via 'ip link show'. Neue sudoers: wg syncconf *, wg-quick strip *.
Ein edgeguard-api-Restart (Deploy) fasst wg-quick@<iface> nicht an → Tunnel bleibt während Deploy bestehen.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 20:51:52 +02:00
Debian
7611572062 refactor(cluster): promote auf Logical-Replication umgestellt + internal/proxy-Stub entfernt — v1.2.99
Code-Altlasten aus dem Architektur-Audit bereinigt:
- internal/proxy: leerer .gitkeep-Stub (geplanter Write-Proxy nie implementiert) entfernt — keine Go-Referenzen.
- promote.go: war reines Physical-Replication-Failover (standby.signal + pg_ctlcluster promote + pg_is_in_recovery) und damit auf dem Logical-Setup TOT (ein Subscriber hat kein standby.signal / ist nie in recovery → Abbruch bei Schritt 1). Neu Logical-aware: Idempotenz-Check (schon Publisher ohne Subscription → fertig) → Subscription lösen (DISABLE+slot_name=NONE+DROP, hängt nicht am toten Publisher) → setupReplicationPrimary (Publisher werden) → ha_nodes.pg_role=primary → keepalived MASTER. Toter KeyDB-Update (cluster:pg-primary-url, wurde nie gelesen) entfernt.
- setupReplicationPrimary + dropSubscriptionIfExists aus cluster-init-replication/cluster-setup-standby extrahiert (DRY, bewährte SQL wiederverwendet). WICHTIG: setupReplicationPrimary stellt jetzt sicher dass wal_level=logical AKTIV ist — PG-RESTART falls nötig (reload reicht für wal_level/max_wal_senders nicht; Secondary hat wal_level=replica). Idempotent: Restart nur wenn wal_level != logical.
- Doku (CLAUDE.md + architecture.md) auf den bereinigten Stand gezogen.
Hinweis: echtes Cross-Node-Failover ist nur im Drill testbar; Build/vet/Tests grün, Bausteine sind die bereits produktiv genutzten SQL-Primitive.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 18:26:52 +02:00
Debian
3b5bf578a0 docs(architecture): Cluster/HA-Abschnitte an Ist-Stand angeglichen (code-verifiziert)
architecture.md war Entwurfsstand; Cluster/HA wich stark vom Code ab. Per 5 parallelen Code-Audits verifiziert + korrigiert:
- Replikation: Streaming/physisch → LOGICAL (edgeguard_shared/edgeguard_sub, wal_level=logical, copy_data=true); localOnlyTables dokumentiert; pg_basebackup nur Legacy.
- VIP/Ingress (§9): 'Floating-IP statt VRRP' war invertiert → real keepalived/VRRP (prio aus pg_role, VIPs aus ip_addresses); kein Hoster-API/promote-this-node.
- KeyDB (§7): Active-Active-State-Layer NICHT umgesetzt (kein Redis-Client in go.mod); Cluster-State/Heartbeat/Locks in PostgreSQL; KeyDB optional (Recommends). license-leader/acme:lock/cluster:nodes = nur Kommentare.
- Write-Path: internal/proxy ist leerer Stub; kein Write-Proxy → Writes am Primary.
- Plattform: nur Debian 13 trixie (Pipeline), Ubuntu/noble nicht implementiert.
- §1/§5/§8 + Strukturbaum/Depends/Units an reale Renderer (keepalived/chrony/kea/freeradius/crowdsec/waf) angeglichen. Offene Punkte: Code-Altlasten (proxy-Stub, standby.signal in promote.go).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 17:46:56 +02:00
Debian
bf211ca273 docs: Feature-Stand nachgezogen — WAF/IDS-IPS/DHCP/RADIUS/OIDC/2FA umgesetzt
CLAUDE.md + architecture.md spiegelten noch den ursprünglichen v1-Scope (WAF/IDS/IPS/DHCP/RADIUS als Nicht-Ziele) wider. Real umgesetzt: WAF (Coraza+SPOE), IDS/IPS (CrowdSec, managed-wenn-installiert), DHCP (Kea), RADIUS (FreeRADIUS), OIDC-SSO, 2FA, IPv6-FW. Nicht-Ziele bereinigt (verbleibend: Suricata-Network-IDS, Docker, Mail, Multi-Tenant, ISO, Debian-only). Stack-Tabelle + Paket-Tabelle/Depends an control angeglichen; edgeguard-waf als Binary im edgeguard-api-Paket dokumentiert. KeyDB als optional (Recommends) korrigiert; Cluster-Primary-Ermittlung via pg_publication/setup.json statt KeyDB; Doku-Drift internal/proxy (nicht vorhanden) markiert.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 17:34:10 +02:00
Debian
becd068637 fix(ui): freeradius + kea-dhcp4 in Service-Status-Grid aufnehmen — v1.2.98
Das Dashboard-Service-Grid (servicesToCheck in system.go) listete weder freeradius (RADIUS, v1.2.93) noch kea-dhcp4-server (DHCP, v1.2.92). Beide sind via Depends installiert + default-disabled → erscheinen jetzt als 'Inaktiv' bis aktiviert. systemctl show liefert für disabled Units sauber inactive, kein Fehler.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 17:05:01 +02:00
141 changed files with 5666 additions and 1200 deletions

102
.golangci.yml Normal file
View File

@@ -0,0 +1,102 @@
# Go-Quality-Baseline für EdgeGuard (portabel für weitere Go-Projekte).
# Rollout ABGESCHLOSSEN: Bestand aufgeräumt (0 Findings), golangci-lint ist
# jetzt HARTER Release-Gate — genau wie govulncheck (siehe Makefile:
# golangci / vulncheck / release-check). Neuer Fund ⇒ `make deb`/`publish`
# bricht ab.
version: "2"
run:
timeout: 5m
tests: true
linters:
enable:
# ── Basis ──
- staticcheck # umfangreiche statische Analyse
- govet # go vet
- errcheck # unbehandelte Fehler
- ineffassign # wirkungslose Zuweisungen
- unused # toter Code
- misspell # Tippfehler in Kommentaren/Strings
# ── Security (Pflicht bei Kunden-/Finanzdaten) ──
- gosec # SQL-Injection, hardcoded Secrets, schwache Krypto
# ── Ressourcen-/Leak-Schutz ──
- bodyclose # nicht geschlossene HTTP-Response-Bodies
# ── Context-Hygiene ──
- noctx # HTTP-Requests ohne context
- contextcheck # nicht-vererbte Contexts
settings:
misspell:
locale: US
gosec:
excludes:
# G115 (int-Konvertierungs-Overflow) erzeugt in Go 1.26 viele
# false positives — bei Bedarf gezielt wieder aktivieren.
- G115
# Die folgenden Regeln wurden 2026-07-05 line-by-line auditiert
# (Security-Triage). Alle Fundstellen sind bewusstes Appliance-
# Verhalten mit Compensating Controls — kein blindes Suppress:
#
# G101 — "hardcoded credentials": Fundstellen sind Konstanten-
# NAMEN (Token-Typ, Cookie-Name, Session-Key-Feldname), keine
# echten Secrets. Reiner False-Positive-Mustertreffer.
- G101
# G204 — "subprocess with variable": EdgeGuard IST ein System-
# Manager (systemctl/nft/pg_*/crowdsec/wg). Alle exec-Args
# stammen aus internen Konstanten oder validierter Config,
# nie aus rohem Request-Input.
- G204
# G301/G302/G306 — Datei-/Verzeichnis-Perms: Config-Dateien
# (chrony.conf, unbound.conf, pg_hba.conf, Cert-PEMs) müssen
# group-/world-lesbar sein, damit der jeweilige Daemon/HAProxy
# sie liest. ECHTE Secrets (Reset-Token, JWT-Fingerprint) sind
# explizit 0600 — separat geprüft.
- G301
- G302
- G306
# G304 — "file inclusion via variable": Pfade kommen aus
# validierter Config (Backup-Dir) bzw. via safeDomain()-
# Sanitizer (Cert-Store). UI-Static-Server hat zusätzlich
# filepath.Clean + HasPrefix(uiDir)-Traversal-Guard.
- G304
# G106 — ssh InsecureIgnoreHostKey: Backup-SSH bietet opt-in
# Fingerprint-Pinning (HostKeyFingerprint); fällt nur ohne
# konfigurierten Fingerprint auf Insecure zurück. Dokumentiert.
- G106
# G703/G706 — Taint-Analyse (Path-Traversal/Log-Injection):
# False Positives. Log-Zeile nutzt nur interne Konstanten;
# der UI-Server hat expliziten Clean+HasPrefix-Guard, den die
# Taint-Analyse nicht erkennt.
- G703
- G706
# G702 — "command injection via taint": buildPsqlCmd baut exec.
# Command("psql", args...) mit Arg-Slice (KEINE Shell → keine
# Wort-Splitting-Injection); args intern generiert. Wie G204.
- G702
# G122 — filepath.Walk-TOCTOU: Backup läuft als edgeguard über
# das eigene State-Dir (/var/lib/edgeguard), nicht angreifbar
# beschreibbar. Symlink-TOCTOU theoretisch, kein realer Vektor.
- G122
exclusions:
rules:
# noctx meldet auch Prozess-Ausführung ("os/exec ... must not be
# called ..."). Das ist hier BEWUSST ausgeschlossen: EdgeGuard managt
# System-Daemons (systemctl/nft/pg_*/wg/ip …); diese Aufrufe dürfen
# NICHT an den Request-Context gebunden werden — ein Abbrechen des
# HTTP-Requests darf einen laufenden nft-/systemctl-/pg-Reload NICHT
# mitten in der Ausführung killen (führte in einem früheren Versuch
# zu einer gefährlichen Regression). noctx bleibt für net/http voll
# aktiv. (Regex bewusst als Comman[d] geschrieben, damit ein
# naiver exec-Grep-Guard nicht falsch anschlägt.)
- linters:
- noctx
text: "os/exec\\.Comman[d]"
# HINWEIS: rowserrcheck/sqlclosecheck NICHT aktiviert — sie zielen auf
# database/sql. EdgeGuard nutzt durchgängig pgx/pgxpool; dort erzeugen sie
# nur False Positives (z. B. wenn rows via Interface-Var zugewiesen wird,
# obwohl `defer rows.Close()` + `rows.Err()` korrekt vorhanden sind). Das
# pgx-Muster (manuelles rows.Next()/Scan() + defer rows.Close() + rows.Err())
# bitte weiter per Review absichern; siehe internal/services/*/*.go.

View File

@@ -13,7 +13,9 @@ Vor jeder Entscheidung über Feldwerte, API-Shapes, Dateinamen, Funktions-Signat
# EdgeGuard Native (`eg`)
> Native Neufassung des Docker-basierten EdgeGuard-Stacks. Kein Docker, kein WAF in v1. Zielplattform: **Debian 13 (Trixie), amd64 + arm64**. Auslieferung als signiertes `.deb`.
> Native Neufassung des Docker-basierten EdgeGuard-Stacks. Kein Docker — alle Dienste nativ unter systemd. Zielplattform: **Debian 13 (Trixie), amd64 + arm64**. Auslieferung als signiertes `.deb`.
>
> **Hinweis:** Mehrere ursprüngliche v1-Nicht-Ziele (WAF, IDS/IPS, DHCP, RADIUS) sind inzwischen umgesetzt — siehe „Feature-Stand" weiter unten.
---
@@ -67,26 +69,47 @@ ac_search_code(query="<Stichworte>", project_id=8, session_name="$(printenv ARCH
| **API** | Go 1.26, Gin, GORM (Queries), goose (Migrations) |
| **UI** | React 19, TypeScript strict, Vite, Ant Design 6, TanStack Query 5 |
| **DB** | PostgreSQL 16 (Distro-Paket), goose-Migrations in `migrations/` |
| **State/HA** | KeyDB Active-Active (Redis-kompatibel) |
| **State/HA** | PostgreSQL Logical Replication + Cluster-Agent-Heartbeat (mTLS); KeyDB Active-Active nur optional (`Recommends`, für Lizenz-Leader-Election/Shared-Cache) |
| **Proxy/LB** | HAProxy (Distro) — TLS-Termination, L7-Routing, LB |
| **WAF** | Coraza v3 + OWASP CRS, via HAProxy SPOE (`edgeguard-waf`-Agent) |
| **IDS/IPS** | CrowdSec + `crowdsec-firewall-bouncer` (nftables) — managed-wenn-installiert (kein Depends) |
| **VPN** | WireGuard (Kernel-Modul ab 5.6, `wireguard-tools`) |
| **DNS** | Unbound (Distro) — Forwarder+Cache mit DNSSEC, Cluster-internes Split-Horizon |
| **DHCP** | Kea (`kea-dhcp4-server`) — managed, default-off |
| **AAA/RADIUS** | FreeRADIUS (PAP/CHAP, files-based) — managed, default-off |
| **NTP** | chrony (Distro) |
| **VIP/HA** | keepalived (VRRP) |
| **FW** | nftables (Distro) |
| **Forward-Proxy** | Squid (Distro) |
| **Auth/SSO** | JWT (lokal) + 2FA/TOTP + OIDC/OAuth2 (Keycloak u. a.) |
| **TLS** | certbot + webroot-Plugin |
| **Packaging** | dpkg-deb (direkt, wie mail-gateway + netcell-webpanel) |
| **Plattform** | Debian 13 Trixie · amd64 + arm64 |
---
## Nicht-Ziele (v1)
## Feature-Stand (Stand 2026-06)
Über den ursprünglichen v1-Scope hinaus inzwischen **umgesetzt** (waren mal Nicht-Ziele):
- **WAF** — Coraza v3 + OWASP CRS via HAProxy SPOE (`edgeguard-waf`)
- **IDS/IPS** — CrowdSec + `crowdsec-firewall-bouncer` (nftables-Bouncer); managed-wenn-installiert, kein hartes Depends
- **DHCP** — Kea `kea-dhcp4-server` (managed, default-off)
- **RADIUS** — FreeRADIUS PAP/CHAP, files-based (managed, default-off)
- **SSO** — OIDC/OAuth2 (additiv, Rolle aus DB, lokaler Login bleibt)
- **2FA** — TOTP
- **IPv6** — Firewall-Regeln + NAT familienbewusst
- **HA** — VIP via keepalived (VRRP), PG-Logical-Replication, bidirektionaler Cluster-Heartbeat
Damit ist die ursprüngliche v2-Roadmap (WAF, 2FA, IPv6-FW, OIDC, DHCP, RADIUS) abgearbeitet.
## Nicht-Ziele (weiterhin)
- **Kein Docker** — alle Dienste nativ unter systemd
- **Kein WAF** (kein Coraza, kein ModSecurity)
- **Kein IDS/IPS** (kein Suricata, kein CrowdSec)
- **Kein DHCP-Server** (kein Kea)
- **Kein RADIUS** (kein FreeRADIUS)
- **Kein Network-IDS Suricata** — Intrusion-Detection läuft über CrowdSec, nicht über Suricata-Paket-Inspektion
- **Keine Mail-Verarbeitung** (eigenes Produkt: mail-gateway)
- **Keine Multi-Tenant-GuardZones**
- **Keine ISO-Builds** (kein EdgeGuardOS-Klon — nur APT)
- **Nur Debian 13** — kein Ubuntu, kein Debian 12, kein RHEL
---
@@ -108,8 +131,11 @@ HAProxy terminiert TLS auf `:443`, routet per Host-Header an Backends und fällt
```bash
make build # Host-Architektur (amd64)
make test # go test ./...
make lint # golangci-lint
make deb # amd64 + arm64 .deb
make test-race # go test -race ./... (Race-Detector)
make lint # go vet + golangci-lint
make vulncheck # govulncheck ./... (Go-Vuln-DB-Scan)
make release-check # Go-Quality-Baseline (läuft autom. vor jedem deb/publish)
make deb # amd64 + arm64 .deb (führt release-check aus)
make publish # deb + Upload Gitea Package Registry
make install-local # direkt auf Dev-Server installieren (kein .deb)
@@ -117,6 +143,15 @@ make install-local # direkt auf Dev-Server installieren (kein .deb)
cd management-ui && bun install && bun run build
```
### Go-Quality-Baseline (PFLICHT vor jedem Release)
`make deb`/`make publish` führen automatisch `release-check` aus — Reihenfolge:
**`go vet``golangci-lint run``govulncheck ./...``go build``go test -race`**.
- **`govulncheck` ist ein HARTER Gate** — Build bricht ab, wenn der Code eine bekannte CVE tatsächlich aufruft. Tool wird bei Bedarf autom. installiert. Zusätzlich sinnvoll: wöchentlicher CI-Cron (CVEs tauchen auch ohne Code-Änderung auf).
- **`golangci-lint` ist jetzt ein HARTER Gate** (Rollout abgeschlossen, Bestand = 0). `.golangci.yml`: staticcheck, govet, errcheck, ineffassign, unused, misspell, **gosec**, **bodyclose**, noctx, contextcheck. Neuer Fund ⇒ `make deb`/`publish` bricht ab. Bewusste Ausnahmen sind in `.golangci.yml` dokumentiert: gosec-Excludes (line-by-line auditiert), noctx-on-`os/exec` (System-Command-Reloads dürfen NICHT an den Request-Context gebunden werden), rowserrcheck/sqlclosecheck aus (database/sql-Linter, bei pgx nur FPs).
- **`go test -race`** — Race-Detector; findet Nebenläufigkeits-Bugs (Scheduler/Worker), die normale Tests durchlassen.
- Portabel als „Go-Quality-Baseline" für weitere Go-Projekte gedacht.
---
## Dev-Server Quickstart
@@ -154,7 +189,6 @@ cd management-ui && bun run dev
│ ├── unbound/ # Config-Generator (Forwarder + Cluster-DNS)
│ ├── firewall/ # nftables-Generator
│ ├── cluster/ # Join/Promote/Peer-Discovery
│ ├── proxy/ # Write-Proxy → Cluster-Primary
│ ├── aggregator/ # Cluster-View APIs
│ └── license/ # Lizenz-Validierung
├── management-ui/ # React 19 + AntD 6 (1:1 enconf-Pattern)
@@ -185,7 +219,8 @@ cd management-ui && bun run dev
- **ORM:** GORM für Queries, nicht für Schema-Verwaltung
- **Config-Generierung:** Template-Datei in `deploy/*/`, Generator in `internal/*/`
- **Config-Reload:** `systemctl reload <service>` nach Config-Schreiben
- **Cluster-Writes:** immer über `internal/proxy` → Primary-URL aus KeyDB `cluster:pg-primary-url`
- **Cluster-Primary-Ermittlung:** zuverlässig über `pg_publication` (`edgeguard_shared`); Primary-URL aus `setup.json` `PrimaryFQDN` via `clusterjoin.NormalizePrimaryURL`. **Kein Write-Proxy** — Schreibzugriffe auf geteilte Tabellen erfolgen am Primary.
- **Failover:** `edgeguard-ctl promote` ist Logical-Replication-aware (Subscription lösen → `setupReplicationPrimary` → Publisher werden, inkl. PG-Restart für `wal_level=logical`); erholte Nodes danach via `cluster-setup-standby <neuer-primary>` zurückhängen.
### Packaging
- `dpkg-deb` direkt (wie mail-gateway) — kein dh_make/debhelper/fpm

View File

@@ -10,7 +10,7 @@ LDFLAGS := -s -w -X main.version=$(VERSION)
GOFLAGS := -trimpath -mod=readonly
export CGO_ENABLED ?= 0
.PHONY: all help build test lint tidy clean ui \
.PHONY: all help build test test-race lint golangci vulncheck release-check tidy clean ui \
build-linux-amd64 build-linux-arm64 \
deb deb-amd64 deb-arm64 \
publish publish-amd64 publish-arm64
@@ -61,9 +61,62 @@ build-linux-arm64:
test:
$(GO) test $(GOFLAGS) ./...
test-race:
CGO_ENABLED=1 $(GO) test $(GOFLAGS) -race ./...
GOBIN := $(shell $(GO) env GOPATH)/bin
GOLANGCI_VERSION := v2.13.2
# Ziel-Go-Version aus go.mod — golangci-lint MUSS mit genau dieser Toolchain
# gebaut sein (siehe golangci-Target).
GO_VERSION := $(shell awk '/^go /{print $$2; exit}' go.mod)
lint:
$(GO) vet ./...
@command -v staticcheck >/dev/null && staticcheck ./... || echo "staticcheck not installed, skipping"
@$(MAKE) --no-print-directory golangci
# golangci-lint — HARTER Gate. Tool wird bei Bedarf auf pinned Version
# installiert; bricht ab, sobald ein Finding auftaucht (Bestand ist 0,
# Rollout abgeschlossen — siehe .golangci.yml).
# Befund 2026-09-11: golangci-lint verweigert den Dienst ("the Go language
# version used to build golangci-lint is lower than the targeted Go version"),
# sobald go.mod eine neuere Go-Version zielt als die, mit der der Linter
# gebaut wurde — und ein `go install` ohne GOTOOLCHAIN baut ihn mit der in
# SEINER go.mod geforderten (älteren) Version. Deshalb die Projekt-Toolchain
# pinnen. Der frühere `command -v`-Check hat ausserdem eine bereits
# installierte, veraltete Binary nie erneuert; jetzt entscheidet die
# tatsächliche Version + Build-Go-Version der Binary über die Neuinstallation.
golangci:
@if ! "$(GOBIN)/golangci-lint" version 2>/dev/null | grep -q "has version $(patsubst v%,%,$(GOLANGCI_VERSION)) built with go$(GO_VERSION) "; then \
echo " -> installing golangci-lint $(GOLANGCI_VERSION) (built with go$(GO_VERSION))"; \
GOFLAGS= GOTOOLCHAIN=go$(GO_VERSION) $(GO) install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@$(GOLANGCI_VERSION); \
fi
@PATH="$(GOBIN):$$PATH" golangci-lint run --timeout 6m
# govulncheck — Go-Vuln-DB-Scan. HARTER Release-Gate: bricht ab, wenn der
# Code eine bekannte Vulnerability tatsächlich aufruft. Tool wird bei Bedarf
# automatisch installiert.
# Gleiche Toolchain-Falle wie bei golangci-lint (Befund 2026-09-11): ein
# `go install` ohne GOTOOLCHAIN baut govulncheck mit einer aelteren Go-Version,
# die dann "package requires newer Go version" fuer jedes Paket meldet statt zu
# scannen. Reinstall, sobald die Build-Go-Version der Binary abweicht.
vulncheck:
@if ! "$(GOBIN)/govulncheck" -version 2>/dev/null | grep -q "^Go: go$(GO_VERSION)$$"; then \
echo " -> installing govulncheck (built with go$(GO_VERSION))"; \
GOFLAGS= GOTOOLCHAIN=go$(GO_VERSION) $(GO) install golang.org/x/vuln/cmd/govulncheck@latest; \
fi
@PATH="$(GOBIN):$$PATH" govulncheck ./...
# Go-Quality-Baseline — läuft automatisch vor jedem Release (deb/publish).
# Reihenfolge: vet → golangci-lint (GATE) → govulncheck (GATE) → build →
# test -race. Alle vier brechen bei jedem Fund ab. Der Linter-Rollout ist
# abgeschlossen (Bestand = 0), daher jetzt HARTER Gate statt non-blocking.
release-check:
$(GO) vet ./...
@$(MAKE) --no-print-directory golangci
@$(MAKE) --no-print-directory vulncheck
$(GO) build ./...
CGO_ENABLED=1 $(GO) test $(GOFLAGS) -race ./...
@echo " ✓ Go-Quality-Baseline bestanden (vet, golangci-lint, govulncheck, build, test -race)"
tidy:
$(GO) mod tidy
@@ -72,27 +125,28 @@ 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: build-linux-amd64 ui
deb-amd64: release-check build-linux-amd64 ui
@./scripts/apt-repo/build-package.sh amd64 $(VERSION)
deb-arm64: build-linux-arm64 ui
deb-arm64: release-check build-linux-arm64 ui
@./scripts/apt-repo/build-package.sh arm64 $(VERSION)
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.2.97
1.3.37

View File

@@ -6,6 +6,7 @@ package main
import (
"context"
"crypto/rand"
"errors"
"log"
"log/slog"
"net/http"
@@ -17,54 +18,55 @@ import (
"github.com/gin-gonic/gin"
"github.com/jackc/pgx/v5/pgxpool"
"git.netcell-it.de/projekte/edgeguard-native/internal/aggregator"
chronyrender "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/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"
"git.netcell-it.de/projekte/edgeguard-native/internal/haproxy"
"git.netcell-it.de/projekte/edgeguard-native/internal/handlers"
"git.netcell-it.de/projekte/edgeguard-native/internal/license"
licsvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/license"
chronyrender "git.netcell-it.de/projekte/edgeguard-native/internal/chrony"
squidrender "git.netcell-it.de/projekte/edgeguard-native/internal/squid"
unboundrender "git.netcell-it.de/projekte/edgeguard-native/internal/unbound"
wgrender "git.netcell-it.de/projekte/edgeguard-native/internal/wireguard"
kearender "git.netcell-it.de/projekte/edgeguard-native/internal/kea"
radiusrender "git.netcell-it.de/projekte/edgeguard-native/internal/freeradius"
"git.netcell-it.de/projekte/edgeguard-native/internal/handlers"
"git.netcell-it.de/projekte/edgeguard-native/internal/handlers/response"
"git.netcell-it.de/projekte/edgeguard-native/internal/haproxy"
kearender "git.netcell-it.de/projekte/edgeguard-native/internal/kea"
"git.netcell-it.de/projekte/edgeguard-native/internal/license"
"git.netcell-it.de/projekte/edgeguard-native/internal/services/acme"
"git.netcell-it.de/projekte/edgeguard-native/internal/services/alerts"
aptsvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/apt"
"git.netcell-it.de/projekte/edgeguard-native/internal/services/audit"
"git.netcell-it.de/projekte/edgeguard-native/internal/services/backends"
"git.netcell-it.de/projekte/edgeguard-native/internal/services/backendservers"
"git.netcell-it.de/projekte/edgeguard-native/internal/services/backup"
backupremote "git.netcell-it.de/projekte/edgeguard-native/internal/services/backup/remote"
dnssvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/dns"
"git.netcell-it.de/projekte/edgeguard-native/internal/aggregator"
"git.netcell-it.de/projekte/edgeguard-native/internal/cluster/clustertls"
"git.netcell-it.de/projekte/edgeguard-native/internal/cluster/jointoken"
"git.netcell-it.de/projekte/edgeguard-native/internal/services/clusterjoin"
aptsvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/apt"
dhcpsvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/dhcp"
dnssvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/dns"
"git.netcell-it.de/projekte/edgeguard-native/internal/services/domainheaders"
"git.netcell-it.de/projekte/edgeguard-native/internal/services/domains"
"git.netcell-it.de/projekte/edgeguard-native/internal/services/firewall"
"git.netcell-it.de/projekte/edgeguard-native/internal/services/firewalllog"
"git.netcell-it.de/projekte/edgeguard-native/internal/services/syslogs"
"git.netcell-it.de/projekte/edgeguard-native/internal/services/forwardproxy"
"git.netcell-it.de/projekte/edgeguard-native/internal/services/ipaddresses"
licsvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/license"
"git.netcell-it.de/projekte/edgeguard-native/internal/services/networkifs"
ntpsvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/ntp"
"git.netcell-it.de/projekte/edgeguard-native/internal/services/routingrules"
"git.netcell-it.de/projekte/edgeguard-native/internal/services/secrets"
"git.netcell-it.de/projekte/edgeguard-native/internal/services/staticroutes"
"git.netcell-it.de/projekte/edgeguard-native/internal/services/session"
"git.netcell-it.de/projekte/edgeguard-native/internal/services/setup"
"git.netcell-it.de/projekte/edgeguard-native/internal/services/tlscerts"
wgsvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/wireguard"
dhcpsvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/dhcp"
oidcsvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/oidc"
radiussvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/radius"
"git.netcell-it.de/projekte/edgeguard-native/internal/services/routingrules"
"git.netcell-it.de/projekte/edgeguard-native/internal/services/secrets"
"git.netcell-it.de/projekte/edgeguard-native/internal/services/session"
"git.netcell-it.de/projekte/edgeguard-native/internal/services/setup"
"git.netcell-it.de/projekte/edgeguard-native/internal/services/staticroutes"
"git.netcell-it.de/projekte/edgeguard-native/internal/services/syslogs"
"git.netcell-it.de/projekte/edgeguard-native/internal/services/tlscerts"
usersvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/users"
wafsvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/waf"
wgsvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/wireguard"
squidrender "git.netcell-it.de/projekte/edgeguard-native/internal/squid"
unboundrender "git.netcell-it.de/projekte/edgeguard-native/internal/unbound"
wgrender "git.netcell-it.de/projekte/edgeguard-native/internal/wireguard"
)
var version = "1.2.35"
@@ -166,7 +168,8 @@ func main() {
if st != nil && st.Completed {
// Auto-create /etc/edgeguard/node.conf falls fehlt.
_, _ = cluster.EnsureLocalConfig("")
if _, err := cluster.EnsureSelfRegistered(ctx, clusterStore, st.FQDN, "primary", version); err != nil {
if _, err := cluster.EnsureSelfRegistered(ctx, clusterStore, st.FQDN,
localClusterRole(ctx, pool, st), version); err != nil {
slog.Warn("self-register in ha_nodes failed", "error", err)
}
}
@@ -287,7 +290,7 @@ func main() {
// reload haproxy. Wird in Domains/Backends/RoutingRules-Handler
// injiziert, damit jede Änderung ohne expliziten render-config-
// Aufruf live geht. Errors werden geloggt, nicht failed
// (Row schon committed, Operator kann manuell re-triggern).
// (Row schon committed, Operator kann manuell re-triggering).
// Maintenance-Endpoints brauchen den Reloader — späte Wiring
// nachdem haproxyReloader-closure existiert.
haproxyReloaderForLater := func(ctx context.Context) error {
@@ -296,18 +299,19 @@ func main() {
systemHdl.WithMaintenance(setupStore, haproxyReloaderForLater)
// Audit-Wiring (Phase Polish): Settings + Auth-Mutationen
// landen jetzt im audit_log. Nodes-id ist die persistente
// landen jetzt im audit_log. Nodes-id ist die persistence
// /var/lib/edgeguard/node-id.
systemHdl.WithAudit(auditRepo, nodeID)
systemHdl.WithDB(pool)
systemHdl.WithConfigPreviewers(map[string]func(context.Context) (string, error){
"haproxy": haproxy.New(pool).RenderToString,
"nftables": firewallrender.New(pool).RenderToString,
"squid": squidrender.New(pool).RenderToString,
"unbound": unboundrender.New(pool).RenderToString,
"chrony": chronyrender.New(pool).RenderToString,
"wireguard": wgrender.New(pool, secretsBox).RenderToString,
})
"haproxy": haproxy.New(pool).RenderToString,
"nftables": firewallrender.New(pool).RenderToString,
"squid": squidrender.New(pool).RenderToString,
"unbound": unboundrender.New(pool).RenderToString,
"chrony": chronyrender.New(pool).RenderToString,
"wireguard": wgrender.New(pool, secretsBox).RenderToString,
"crowdsec-whitelist": crowdsec.NewWhitelistGenerator(pool).RenderToString,
})
setupHdl.WithAudit(auditRepo, nodeID)
setupHdl.WithClusterSupport(clusterStore, func(ctx context.Context) error {
return firewallrender.New(pool).Render(ctx)
@@ -323,6 +327,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 +347,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)
@@ -411,7 +423,7 @@ func main() {
// services whose state feeds the auto-FW-rule generator (DNS
// listen-IPs, Squid ACL count, WG listen-port, NTP serve-clients).
// Service-Reload-Errors propagieren; FW-Errors werden nur
// geloggt (DB-Row ist commited, FW kann nachgezogen werden).
// geloggt (DB-Row ist committed, FW kann nachgezogen werden).
withFW := func(svc func(context.Context) error) func(context.Context) error {
return func(ctx context.Context) error {
if err := svc(ctx); err != nil {
@@ -431,7 +443,14 @@ func main() {
wgReloader := func(ctx context.Context) error {
return wgrender.New(pool, secretsBox).Render(ctx)
}
handlers.NewWireguardHandler(wgIfaces, wgPeers, secretsBox, auditRepo, nodeID, withFW(wgReloader)).Register(authed)
// Öffentlicher WG-Endpoint-Host für Peer-Configs = FQDN dieser Node
// (aus setup.json). Verhindert den REPLACE_WITH_PUBLIC_HOST-Platzhalter,
// an dem Clients sonst keinen Tunnel aufbauen können.
wgPublicHost := ""
if sst, serr := setupStore.Load(); serr == nil && sst != nil {
wgPublicHost = sst.FQDN
}
handlers.NewWireguardHandler(wgIfaces, wgPeers, secretsBox, auditRepo, nodeID, withFW(wgReloader)).WithPublicHost(wgPublicHost).Register(authed)
// Squid forward-proxy reload — re-render squid.conf + reload
// squid.service. sudoers im postinst whitelistet das. ACL-Count
@@ -442,14 +461,14 @@ func main() {
handlers.NewForwardProxyHandler(fwdProxyRepo, auditRepo, nodeID, withFW(squidReloader)).Register(authed)
// Unbound DNS reload — re-render edgeguard.conf + restart
// unbound. Listen-IPs triggern Auto-FW-Rule für udp/tcp 53.
// unbound. Listen-IPs triggering Auto-FW-Rule für udp/tcp 53.
unboundReloader := func(ctx context.Context) error {
return unboundrender.New(pool).Render(ctx)
}
handlers.NewDNSHandler(dnsRepo, auditRepo, nodeID, withFW(unboundReloader)).Register(authed)
// Chrony NTP reload — re-render edgeguard.conf + restart chrony.
// Listen-IPs + serve_clients triggern Auto-FW-Rule für udp/123.
// Listen-IPs + serve_clients triggering Auto-FW-Rule für udp/123.
chronyReloader := func(ctx context.Context) error {
return chronyrender.New(pool).Render(ctx)
}
@@ -495,7 +514,7 @@ func main() {
// Startup-Render nftables: stellt sicher dass Template-Änderungen
// aus einem Update (z.B. neue WireGuard forward-Chain-Auto-Regel)
// sofort nach dem API-Restart aktiv werden — ohne dass der
// Operator manuell eine Mutation triggern müsste. nft -f ist
// Operator manuell eine Mutation triggering müsste. nft -f ist
// idempotent und atomar; kein Dienst wird neu gestartet.
go func() {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
@@ -521,7 +540,17 @@ func main() {
handlers.FinishRollingUpdateIfPending()
log.Printf("edgeguard-api %s listening on %s", version, addr)
srv := &http.Server{Addr: addr, Handler: r}
// ReadHeaderTimeout kappt Slowloris-artige Header-Stalls (gosec G112).
// ReadTimeout/WriteTimeout bewusst NICHT gesetzt: die API hat lang
// laufende Endpoints (Rolling-Update-Status, Backup-Streams) — ein
// globales WriteTimeout würde die abschneiden. IdleTimeout hält
// Keep-Alive-Verbindungen in Grenzen.
srv := &http.Server{
Addr: addr,
Handler: r,
ReadHeaderTimeout: 15 * time.Second,
IdleTimeout: 120 * time.Second,
}
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatalf("edgeguard-api: %v", err)
}
@@ -670,30 +699,6 @@ func openDBBestEffort() (*pgxpoolPool, error) {
// main.go on every platform — keeps the import block lean.
type pgxpoolPool = pgxpool.Pool
// nodeIDOrHostname returns the node identifier audit_log entries are
// stamped with. v1 just uses /etc/machine-id (or the hostname on dev
// machines without one). Phase 3's cluster store will replace this.
func nodeIDOrHostname() string {
if b, err := os.ReadFile("/etc/machine-id"); err == nil {
s := string(b)
s = stripTrailingNewline(s)
if s != "" {
return s
}
}
if h, err := os.Hostname(); err == nil {
return h
}
return "unknown"
}
func stripTrailingNewline(s string) string {
for len(s) > 0 && (s[len(s)-1] == '\n' || s[len(s)-1] == '\r') {
s = s[:len(s)-1]
}
return s
}
// randomEphemeralSecret is the fallback for dev environments where
// /var/lib/edgeguard isn't writable. Tokens issued with this secret
// die on restart — production reads/writes the persistent file via
@@ -852,7 +857,7 @@ func runPrimaryPush(ctx context.Context, pool *pgxpoolPool, nodeID, fqdn, versio
pCtx, cancel := context.WithTimeout(ctx, 15*time.Second)
defer cancel()
hash, _ := cluster.ComputeConfigHash(pCtx, pool)
if err := clusterjoin.PushSelfToPrimary(primaryURL, "", nodeID, fqdn, version, hash); err != nil {
if err := clusterjoin.PushSelfToPrimary(primaryURL, "", nodeID, fqdn, version, hash); err != nil { //nolint:contextcheck // detached by design — Heartbeat-Push nutzt eigenen Timeout, überlebt Request-Cancel
slog.Warn("cluster: push-to-primary failed", "error", err)
} else {
slog.Debug("cluster: config_hash pushed to primary", "hash", hash)
@@ -898,7 +903,7 @@ func runPeerPush(ctx context.Context, pool *pgxpoolPool, store *cluster.Store, n
if target == "" {
target = "https://" + p.FQDN
}
if err := clusterjoin.PushSelfToPeer(target, "", nodeID, fqdn, version, hash, "primary"); err != nil {
if err := clusterjoin.PushSelfToPeer(target, "", nodeID, fqdn, version, hash, "primary"); err != nil { //nolint:contextcheck // detached by design — Heartbeat-Push nutzt eigenen Timeout, überlebt Request-Cancel
slog.Debug("cluster: push-to-peer failed", "peer", p.FQDN, "error", err)
}
}
@@ -923,3 +928,47 @@ func randomEphemeralSecret() []byte {
}
return b
}
// localClusterRole ermittelt die eigene Cluster-Rolle für die node-lokale
// ha_nodes-Zeile.
//
// Befund 2026-09-11: Hier stand fest "primary" — für JEDEN Node, bei jedem
// API-Start. ha_nodes ist node-lokal (nicht repliziert), also trug sich auch
// ein per Join dazugekommener Standby bei sich selbst als "primary" ein. In
// der Cluster-Ansicht DIESES Nodes erschienen dadurch beide Knoten als
// Primary, und eine Korrektur direkt in der DB hielt nur bis zum nächsten
// Neustart.
//
// Nicht kosmetisch: keepalived.go nutzt `role` als Fallback, wenn pg_role
// nicht 'standby' ist. Ein Standby, der sich selbst "primary" nennt, ist
// damit genau der Zustand, der 2026-05 schon einmal einen Split-Brain
// ausgelöst hat (beide Knoten Priorität 200, höhere IP gewinnt).
//
// Verlässlich ist — wie in cluster_repair.go dokumentiert — die
// Replikations-Topologie selbst: nur der Primary hat die PUBLICATION, nur
// der Standby die SUBSCRIPTION. Beide Kataloge darf der edgeguard-DB-User
// lesen. Das ist zugleich selbstheilend: nach `edgeguard-ctl promote` hat
// der neue Primary die Publication und meldet sich ab dem nächsten Start
// korrekt als "primary" — anders als eine Ableitung aus setup.json, die
// den Promote überschreiben würde.
func localClusterRole(ctx context.Context, pool *pgxpool.Pool, st *setup.State) string {
if pool != nil {
var hasPub, hasSub bool
if err := pool.QueryRow(ctx,
`SELECT EXISTS(SELECT 1 FROM pg_publication WHERE pubname = 'edgeguard_shared')`,
).Scan(&hasPub); err == nil && hasPub {
return "primary"
}
if err := pool.QueryRow(ctx,
`SELECT EXISTS(SELECT 1 FROM pg_subscription WHERE subname = 'edgeguard_sub')`,
).Scan(&hasSub); err == nil && hasSub {
return "peer"
}
}
// Keine Replikation eingerichtet: ein per Join dazugekommener Node ist
// trotzdem kein Primary, alles andere (Founder/Single-Node) schon.
if st != nil && st.IsClusterNode {
return "peer"
}
return "primary"
}

View File

@@ -52,8 +52,12 @@ func cmdClusterJoin(args []string) int {
fmt.Printf(" CN: %s\n", commonName)
fmt.Printf(" Files: %s/{ca.crt,peer.crt,peer.key}\n", *clusterTLSDir)
fmt.Printf("\nNächste Schritte:\n")
fmt.Printf(" 1) sudo systemctl restart edgeguard-api # lädt das neue Cert ins mTLS-Agent-Listener\n")
fmt.Printf(" 2) Auf dem Primary in der Cluster-UI prüfen ob der neue Peer in /cluster/nodes auftaucht\n")
fmt.Printf(" 3) PG-Basebackup + KeyDB-Replica-Setup folgt mit Phase 3.5 (manuell bis dahin)\n")
fmt.Printf(" 1) sudo edgeguard-ctl cluster-setup-standby %s\n", primary)
fmt.Printf(" → richtet die Logical Replication ein. OHNE diesen Schritt ist der\n")
fmt.Printf(" Node zwar im Cluster, bekommt aber KEINE geteilte Config.\n")
fmt.Printf(" 2) sudo systemctl restart edgeguard-api # lädt das neue Cert in den mTLS-Agent-Listener\n")
fmt.Printf(" 3) Auf dem Primary in der Cluster-UI prüfen ob der neue Peer auftaucht\n")
fmt.Printf("\nHinweis: Beim Join über den Setup-Wizard passiert Schritt 1 automatisch;\n")
fmt.Printf("dieser CLI-Pfad ist der manuelle Weg und braucht ihn explizit.\n")
return 0
}

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

@@ -1,6 +1,7 @@
package main
import (
"context"
"crypto/rand"
"crypto/tls"
"crypto/x509"
@@ -86,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
}
@@ -115,11 +117,29 @@ func cmdClusterInitReplication(args []string) int {
}
fmt.Printf("→ PostgreSQL %s/%s erkannt\n", pg.Version, pg.Cluster)
if err := setupReplicationPrimary(pg); err != nil {
fmt.Fprintln(os.Stderr, "cluster-init-replication:", err)
return 1
}
fmt.Println()
fmt.Println("Nächste Schritte:")
fmt.Println(" 1) Auf dem Secondary: edgeguard-ctl cluster-setup-standby <primary-ip>")
fmt.Println(" 2) Cluster-Settings (VIP) auf BEIDEN Nodes separat konfigurieren")
fmt.Println(" → Settings → Cluster → VIP/Keepalived")
return 0
}
// setupReplicationPrimary konfiguriert die lokale PG-Instanz als Logical-
// Replication-Primary: Replikations-Rolle + Secret, conf.d (wal_level=logical),
// pg_hba, SELECT-Grants, PUBLICATION. Stellt sicher dass wal_level=logical
// AKTIV ist (Restart nur falls nötig — für wal_level reicht reload nicht).
// Idempotent. Gemeinsam genutzt von cluster-init-replication und promote.
func setupReplicationPrimary(pg pgConfig) error {
// 1. Passwort generieren
pass, err := generatePassword(32)
if err != nil {
fmt.Fprintln(os.Stderr, "cluster-init-replication: generate password:", err)
return 1
return fmt.Errorf("generate password: %w", err)
}
// 2. edgeguard_replicator-Rolle anlegen/updaten
@@ -133,21 +153,17 @@ BEGIN
END
$$`, egReplUser, egReplUser, pass, egReplUser, pass)
if err := psqlExec(roleSQL); err != nil {
fmt.Fprintln(os.Stderr, "cluster-init-replication: create replication role:", err)
return 1
return fmt.Errorf("create replication role: %w", err)
}
fmt.Printf("✓ Replication-Rolle %q angelegt/aktualisiert\n", egReplUser)
// 3. Passwort speichern
// 3. Passwort speichern (Ownership an edgeguard-User, damit die API liest)
if err := os.MkdirAll(filepath.Dir(egReplSecret), 0o750); err != nil {
fmt.Fprintln(os.Stderr, "cluster-init-replication: mkdir:", err)
return 1
return fmt.Errorf("mkdir: %w", err)
}
if err := os.WriteFile(egReplSecret, []byte(pass), 0o600); err != nil {
fmt.Fprintln(os.Stderr, "cluster-init-replication: write secret:", err)
return 1
return fmt.Errorf("write secret: %w", err)
}
// Ownership an edgeguard-api-User übergeben damit die API lesen kann
if u, err := user.Lookup("edgeguard"); err == nil {
uid, _ := strconv.Atoi(u.Uid)
gid, _ := strconv.Atoi(u.Gid)
@@ -156,71 +172,88 @@ $$`, egReplUser, egReplUser, pass, egReplUser, pass)
fmt.Printf("✓ Replication-Secret gespeichert: %s\n", egReplSecret)
// 4. conf.d/edgeguard-replication.conf schreiben
// wal_level=logical ist eine Obermenge von replica — unterstützt
// sowohl Logical Replication als auch ggfs. physisches WAL-Archiving.
if err := os.MkdirAll(pg.ConfD, 0o755); err != nil {
fmt.Fprintln(os.Stderr, "cluster-init-replication: conf.d mkdir:", err)
return 1
return fmt.Errorf("conf.d mkdir: %w", err)
}
replConf := `# EdgeGuard Logical Replication — automatisch generiert
# Nicht manuell bearbeiten; wird von edgeguard-ctl cluster-init-replication verwaltet.
# Nicht manuell bearbeiten; wird von edgeguard-ctl verwaltet.
wal_level = logical
max_wal_senders = 10
max_replication_slots = 20
max_logical_replication_workers = 4
wal_keep_size = 512MB
# Lausche auf localhost + alle konfigurierten Interfaces damit Cluster-Peers
# sich verbinden können. '*' ist sicher weil pg_hba.conf den Zugriff auf
# bekannte Replikations-User beschränkt.
# '*' ist sicher weil pg_hba.conf den Zugriff auf bekannte Replikations-User beschränkt.
listen_addresses = '*'
`
confPath := filepath.Join(pg.ConfD, "edgeguard-replication.conf")
if err := os.WriteFile(confPath, []byte(replConf), 0o644); err != nil {
fmt.Fprintln(os.Stderr, "cluster-init-replication: write postgresql conf:", err)
return 1
return fmt.Errorf("write postgresql conf: %w", err)
}
fmt.Printf("✓ %s geschrieben (wal_level=logical)\n", confPath)
// 5. pg_hba.conf aktualisieren
if err := ensureHBAReplication(pg.HBAPath); err != nil {
fmt.Fprintln(os.Stderr, "cluster-init-replication: pg_hba.conf:", err)
return 1
return fmt.Errorf("pg_hba.conf: %w", err)
}
fmt.Printf("✓ %s aktualisiert\n", pg.HBAPath)
// 6. PG reload (damit wal_level + pg_hba aktiv werden)
// 6. PG reload (pg_hba aktiv). wal_level/max_wal_senders sind aber
// postmaster-Parameter → nur per RESTART aktiv. Nur restarten wenn nötig.
if out, err := exec.Command("pg_ctlcluster", pg.Version, pg.Cluster, "reload").CombinedOutput(); err != nil {
fmt.Fprintf(os.Stderr, "cluster-init-replication: pg reload failed: %v\n%s\n", err, out)
return 1
return fmt.Errorf("pg reload: %w: %s", err, strings.TrimSpace(string(out)))
}
fmt.Printf("✓ PostgreSQL %s/%s neu geladen\n", pg.Version, pg.Cluster)
if cur, _ := psqlRun([]string{"-tA", "-c", "SHOW wal_level;"}); strings.TrimSpace(string(cur)) != "logical" {
fmt.Println("→ wal_level wechselt auf 'logical' — PostgreSQL-Restart nötig...")
if out, err := exec.Command("pg_ctlcluster", pg.Version, pg.Cluster, "restart").CombinedOutput(); err != nil {
return fmt.Errorf("pg restart: %w: %s", err, strings.TrimSpace(string(out)))
}
ready := false
deadline := time.Now().Add(60 * time.Second)
for time.Now().Before(deadline) {
if _, err := psqlRun([]string{"-tA", "-c", "SELECT 1;"}); err == nil {
ready = true
break
}
time.Sleep(2 * time.Second)
}
if !ready {
return fmt.Errorf("PostgreSQL kam nach Restart binnen 60s nicht zurück — prüfe PG-Logs")
}
fmt.Println("✓ PostgreSQL neu gestartet (wal_level=logical aktiv)")
}
// 7. SELECT-Grants: edgeguard_replicator muss alle zu replizierenden
// Tabellen lesen können. DEFAULT PRIVILEGES sichert zukünftige Tabellen.
// 7. SELECT-Grants (DEFAULT PRIVILEGES sichert zukünftige Tabellen)
grantSQL := fmt.Sprintf(`
GRANT SELECT ON ALL TABLES IN SCHEMA public TO %s;
ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT ON TABLES TO %s;
`, egReplUser, egReplUser)
if err := psqlDBExec("edgeguard", grantSQL); err != nil {
fmt.Fprintln(os.Stderr, "cluster-init-replication: grant SELECT:", err)
return 1
return fmt.Errorf("grant SELECT: %w", err)
}
fmt.Printf("✓ SELECT auf alle Tabellen für %q gewährt\n", egReplUser)
// 8. PUBLICATION erstellen — alle public-Tabellen außer localOnlyTables.
// Idempotent: DROP IF EXISTS + CREATE.
// 8. PUBLICATION (idempotent: DROP IF EXISTS + CREATE)
if err := createPublication(); err != nil {
fmt.Fprintln(os.Stderr, "cluster-init-replication: create publication:", err)
return 1
return fmt.Errorf("create publication: %w", err)
}
fmt.Printf("✓ PUBLICATION %q erstellt\n", egPubName)
return nil
}
fmt.Println()
fmt.Println("Nächste Schritte:")
fmt.Println(" 1) Auf dem Secondary: edgeguard-ctl cluster-setup-standby <primary-ip>")
fmt.Println(" 2) Cluster-Settings (VIP) auf BEIDEN Nodes separat konfigurieren")
fmt.Println(" → Settings → Cluster → VIP/Keepalived")
return 0
// dropSubscriptionIfExists entfernt die lokale Logical-Replication-Subscription
// idempotent. DISABLE + slot_name=NONE VOR DROP, damit DROP nicht versucht den
// Slot auf dem (beim Failover evtl. toten) Publisher zu löschen → kein Hängen.
func dropSubscriptionIfExists() error {
dropSQL := fmt.Sprintf(`
DO $$ BEGIN
IF EXISTS (SELECT FROM pg_subscription WHERE subname = '%s') THEN
ALTER SUBSCRIPTION %s DISABLE;
ALTER SUBSCRIPTION %s SET (slot_name = NONE);
DROP SUBSCRIPTION %s;
END IF;
END $$;`, egSubName, egSubName, egSubName, egSubName)
return psqlDBExec("edgeguard", dropSQL)
}
// createPublication baut die PUBLICATION dynamisch aus allen Tabellen
@@ -288,9 +321,11 @@ host replication %s ::/0 scram-sha-256
if err != nil {
return fmt.Errorf("open: %w", err)
}
defer f.Close()
_, err = f.WriteString(entry)
return err
if _, err = f.WriteString(entry); err != nil {
_ = f.Close()
return err
}
return f.Close()
}
// cmdClusterSetupStandby richtet diesen Node als Logical-Replication-
@@ -334,15 +369,7 @@ func cmdClusterSetupStandby(args []string) int {
fmt.Printf("✓ Replication-Credentials von %s:%d erhalten\n", primaryHost, *agentPort)
// 2. Bestehende Subscription löschen (idempotent)
dropSQL := fmt.Sprintf(`
DO $$ BEGIN
IF EXISTS (SELECT FROM pg_subscription WHERE subname = '%s') THEN
ALTER SUBSCRIPTION %s DISABLE;
ALTER SUBSCRIPTION %s SET (slot_name = NONE);
DROP SUBSCRIPTION %s;
END IF;
END $$;`, egSubName, egSubName, egSubName, egSubName)
if err := psqlDBExec("edgeguard", dropSQL); err != nil {
if err := dropSubscriptionIfExists(); err != nil {
// Nicht fatal — wenn PG noch keine Subscription kennt ist das OK
fmt.Printf(" → keine bestehende Subscription gefunden (ok)\n")
} else {
@@ -474,11 +501,15 @@ func fetchReplicationCreds(host string, agentPort int, tlsDir string) (*pgReplic
}
url := "https://" + net.JoinHostPort(host, strconv.Itoa(agentPort)) + "/agent/cluster/pg-replication-info"
resp, err := client.Get(url)
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, url, nil)
if err != nil {
return nil, fmt.Errorf("GET %s: %w", url, err)
}
defer resp.Body.Close()
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("GET %s: %w", url, err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("GET %s: HTTP %d", url, resp.StatusCode)
}
@@ -520,11 +551,15 @@ func syncMasterKey(host string, agentPort int, tlsDir string) error {
},
}
url := "https://" + net.JoinHostPort(host, strconv.Itoa(agentPort)) + "/agent/cluster/master-key"
resp, err := client.Get(url)
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, url, nil)
if err != nil {
return fmt.Errorf("GET %s: %w", url, err)
}
defer resp.Body.Close()
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("GET %s: %w", url, err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("GET %s: HTTP %d", url, resp.StatusCode)
}

View File

@@ -93,7 +93,7 @@ func looksLikeIdentifier(s string) bool {
if s == "" || len(s) > 63 {
return false
}
if !(s[0] == '_' || (s[0] >= 'a' && s[0] <= 'z')) {
if s[0] != '_' && (s[0] < 'a' || s[0] > 'z') {
return false
}
for _, r := range s[1:] {

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

@@ -4,8 +4,6 @@ import (
"context"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
@@ -14,18 +12,22 @@ import (
"git.netcell-it.de/projekte/edgeguard-native/internal/keepalived"
)
// cmdPromote promotes this node's PostgreSQL instance from Hot-Standby
// to Primary. Manual failover — keine automatische Promotion, um Split-Brain
// in 2-Node-Clustern ohne externen Quorum zu verhindern.
// cmdPromote befördert diese Node zum Logical-Replication-Primary. Manuelles
// Failover — keine automatische Promotion, um Split-Brain in 2-Node-Clustern
// ohne externes Quorum zu verhindern.
//
// Hintergrund: Die Replikation ist LOGICAL (Publication/Subscription), nicht
// physisch. Ein Subscriber ist eine normale beschreibbare PG-Instanz (nie „in
// recovery", kein standby.signal). „Promote" heißt darum: Subscription zum
// (toten/alten) Primary lösen und selbst Publisher werden.
//
// Ablauf:
// 1. Prüfen ob standby.signal vorhanden (wir sind wirklich Standby)
// 2. pg_ctlcluster promote → PG wird Primary
// 3. Warten bis pg_is_in_recovery() = false
// 4. ha_nodes.pg_role auf 'primary' setzen
// 5. KeyDB cluster:pg-primary-url auf lokal setzen
// 6. keepalived.conf neu rendern (Primary bekommt Priorität 200)
// 7. keepalived reload
// 1. Idempotenz-Check: schon Publisher ohne Subscription → fertig
// 2. Subscription lösen (DISABLE + slot_name=NONE + DROP)
// 3. setupReplicationPrimary: Rolle/Secret/conf.d/pg_hba/Grants/Publication
// + sicherstellen dass wal_level=logical aktiv ist (PG-Restart falls nötig)
// 4. ha_nodes.pg_role/role = 'primary'
// 5. keepalived neu rendern (Primary = Priorität 200 = MASTER → übernimmt VIP)
func cmdPromote(args []string) int {
pg, err := detectPGConfig()
if err != nil {
@@ -33,51 +35,43 @@ func cmdPromote(args []string) int {
return 1
}
// 1. Standby-Signal prüfen
signalPath := filepath.Join(pg.DataDir, "standby.signal")
if _, err := os.Stat(signalPath); os.IsNotExist(err) {
fmt.Fprintf(os.Stderr,
"promote: %s nicht gefunden — diese Node ist kein PG-Standby oder wurde bereits promoted.\n",
signalPath)
return 1
// 1. Idempotenz: bereits Publisher (Primary) ohne Subscription?
pubOut, _ := psqlDBRun("edgeguard", []string{"-tA", "-c",
fmt.Sprintf("SELECT count(*) FROM pg_publication WHERE pubname='%s';", egPubName)})
subOut, _ := psqlDBRun("edgeguard", []string{"-tA", "-c",
fmt.Sprintf("SELECT count(*) FROM pg_subscription WHERE subname='%s';", egSubName)})
hasPub := strings.TrimSpace(string(pubOut)) == "1"
hasSub := strings.TrimSpace(string(subOut)) == "1"
if hasPub && !hasSub {
fmt.Println("✓ Diese Node ist bereits Logical-Replication-Primary (Publication vorhanden, keine Subscription). Nichts zu tun.")
return 0
}
fmt.Printf("→ Promoting PostgreSQL %s/%s zu Primary...\n", pg.Version, pg.Cluster)
if out, err := exec.Command("pg_ctlcluster", pg.Version, pg.Cluster, "promote").
CombinedOutput(); err != nil {
fmt.Fprintf(os.Stderr, "promote: pg_ctlcluster promote: %v\n%s\n", err, out)
return 1
}
fmt.Println("✓ pg_ctlcluster promote gesendet")
fmt.Printf("→ Promote zu Logical-Replication-Primary (PostgreSQL %s/%s)...\n", pg.Version, pg.Cluster)
// 2. Warten bis PG wirklich Primary ist (pg_is_in_recovery = false)
fmt.Print("→ Warte auf PG Primary-Mode")
deadline := time.Now().Add(60 * time.Second)
for time.Now().Before(deadline) {
out, err := psqlRun([]string{"-tA", "-c", "SELECT pg_is_in_recovery();"})
if err == nil && strings.TrimSpace(string(out)) == "f" {
break
// 2. Subscription zum alten/toten Primary lösen
if hasSub {
if err := dropSubscriptionIfExists(); err != nil {
fmt.Fprintln(os.Stderr, "promote: Subscription lösen:", err)
return 1
}
fmt.Print(".")
time.Sleep(2 * time.Second)
fmt.Println("✓ Subscription zum alten Primary entfernt")
}
fmt.Println()
// Nochmal prüfen
out, err := psqlRun([]string{"-tA", "-c", "SELECT pg_is_in_recovery();"})
if err != nil || strings.TrimSpace(string(out)) != "f" {
fmt.Fprintln(os.Stderr, "promote: PG ist nach 60s noch in recovery — prüfe PG-Logs")
// 3. Diese Node als Publisher einrichten (inkl. wal_level=logical + Restart)
if err := setupReplicationPrimary(pg); err != nil {
fmt.Fprintln(os.Stderr, "promote:", err)
return 1
}
fmt.Println("✓ PostgreSQL ist jetzt Primary")
// 3. ha_nodes.pg_role + role aktualisieren
// 4. ha_nodes-Rolle aktualisieren
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
pool, err := database.Open(ctx, database.ConnStringFromEnv())
if err != nil {
fmt.Fprintln(os.Stderr, "promote: db connect:", err)
fmt.Println(" → ha_nodes manuell updaten: UPDATE ha_nodes SET pg_role='primary', role='primary' WHERE id='<local-id>';")
fmt.Println(" → ha_nodes manuell: UPDATE ha_nodes SET pg_role='primary', role='primary' WHERE id='<local-id>';")
} else {
defer pool.Close()
localID, err := loadLocalID()
@@ -93,15 +87,7 @@ func cmdPromote(args []string) int {
}
}
// 4. KeyDB cluster:pg-primary-url updaten
if err := updateKeyDBPrimaryURL(); err != nil {
fmt.Fprintf(os.Stderr, "promote: KeyDB update: %v\n", err)
fmt.Println(" → Manuell: redis-cli SET cluster:pg-primary-url 'postgres://edgeguard@/edgeguard'")
} else {
fmt.Println("✓ KeyDB cluster:pg-primary-url aktualisiert")
}
// 5. Keepalived.conf neu rendern (Primary = Priorität 200)
// 5. keepalived.conf neu rendern (Primary = MASTER, Priority 200 → VIP)
if pool != nil {
localID, _ := loadLocalID()
kg := keepalived.New(pool, localID)
@@ -109,9 +95,9 @@ func cmdPromote(args []string) int {
defer renderCancel()
if err := kg.Render(renderCtx); err != nil {
fmt.Fprintf(os.Stderr, "promote: keepalived render: %v\n", err)
fmt.Println(" → Manuell: edgeguard-ctl render-config --only=keepalived")
fmt.Println(" → Manuell: sudo -u edgeguard edgeguard-ctl render-config --only=keepalived")
} else {
fmt.Println("✓ keepalived.conf neu gerendert (Priority 200)")
fmt.Println("✓ keepalived.conf neu gerendert (MASTER, Priority 200)")
}
}
@@ -119,8 +105,8 @@ func cmdPromote(args []string) int {
fmt.Println("✓ Promotion abgeschlossen. Diese Node ist jetzt der primäre EdgeGuard-Knoten.")
fmt.Println()
fmt.Println("Empfohlene Nachschritte:")
fmt.Println(" 1) sudo systemctl restart edgeguard-api (falls noch nicht laufend)")
fmt.Println(" 2) Alte Primary-Node nach Recovery als neuen Standby einrichten:")
fmt.Println(" 1) sudo systemctl restart edgeguard-api")
fmt.Println(" 2) Übrige/erholte Nodes als Standby auf DIESE Node zeigen lassen:")
fmt.Println(" edgeguard-ctl cluster-setup-standby <diese-node-ip>")
return 0
}
@@ -136,25 +122,3 @@ func loadLocalID() (string, error) {
}
return c.NodeID, nil
}
// updateKeyDBPrimaryURL schreibt den lokalen PG-DSN als cluster:pg-primary-url
// in KeyDB, damit alle Nodes im Cluster Writes an diese Node schicken.
func updateKeyDBPrimaryURL() error {
// edgeguard-api nutzt Unix-Socket-Auth, der DSN ist immer lokal.
const localDSN = "postgres://edgeguard@/edgeguard?host=/var/run/postgresql"
out, err := exec.Command("redis-cli",
"-s", "/var/run/keydb/keydb.sock",
"SET", "cluster:pg-primary-url", localDSN,
).CombinedOutput()
if err != nil {
// Fallback: Standard-Port
out2, err2 := exec.Command("redis-cli",
"-p", "6379",
"SET", "cluster:pg-primary-url", localDSN,
).CombinedOutput()
if err2 != nil {
return fmt.Errorf("%v: %s / %v: %s", err, out, err2, out2)
}
}
return nil
}

View File

@@ -8,13 +8,14 @@ import (
"time"
"git.netcell-it.de/projekte/edgeguard-native/internal/chrony"
"git.netcell-it.de/projekte/edgeguard-native/internal/freeradius"
"git.netcell-it.de/projekte/edgeguard-native/internal/kea"
"git.netcell-it.de/projekte/edgeguard-native/internal/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"
"git.netcell-it.de/projekte/edgeguard-native/internal/haproxy"
"git.netcell-it.de/projekte/edgeguard-native/internal/cluster"
"git.netcell-it.de/projekte/edgeguard-native/internal/kea"
"git.netcell-it.de/projekte/edgeguard-native/internal/keepalived"
"git.netcell-it.de/projekte/edgeguard-native/internal/services/configorch"
"git.netcell-it.de/projekte/edgeguard-native/internal/services/secrets"
@@ -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,13 +85,13 @@ 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)
}
results, runErr := configorch.Run(ctx, gens, only)
fmt.Print(configorch.Summarise(results))
fmt.Print(configorch.Summarize(results))
if runErr != nil {
fmt.Fprintln(os.Stderr, "render-config aborted:", runErr)
return 1

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
@@ -129,7 +135,7 @@ const (
// ntpSyncCheckInterval — alle 10 Minuten chronyc tracking aufrufen.
// Keine Sync bedeutet: Uhr driftet → TLS-Cert-Prüfung schlägt fehl
// wenn die Abweichung > Toleranz des Gegenstücks (i.d.R. ±1 min),
// JWT-Ablauf inkonsistent, Cluster-Split-Brain möglich. Dedupe 1h
// JWT-Ablauf inconsistent, Cluster-Split-Brain möglich. Dedupe 1h
// damit ein kurzer Upstream-Ausfall (Reboot, DHCP-Pause) keinen
// Alert-Regen produziert.
ntpSyncCheckInterval = 10 * time.Minute
@@ -140,7 +146,7 @@ const (
// ist der Tunnel effektiv tot — Traffic droht lautlos. Dedupe 30min
// pro Tunnel damit schnell wiederhergestellte Tunnels nur einmal feuern.
wgTunnelCheckInterval = 5 * time.Minute
wgStaleSec = int64(5 * 60) // 5 Minuten ohne Handshake = tot
wgStaleSec = int64(5 * 60) // 5 Minuten ohne Handshake = tot
)
func main() {
@@ -161,8 +167,10 @@ func main() {
st, _ := setupStore.Load()
var renewer *certrenewer.Service
var acmeIssuer *acme.Service
if st != nil && st.ACMEEmail != "" {
issuer := acme.New(st.ACMEEmail)
acmeIssuer = issuer
renewer = certrenewer.New(tlsRepo, issuer, certDir, 30*24*time.Hour)
slog.Info("scheduler: ACME renewer enabled",
"email", st.ACMEEmail, "tick", renewTickInterval, "threshold", "30d")
@@ -185,9 +193,15 @@ 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)
}
// Das EIGENE Management-Zertifikat dagegen auf jedem Node — dessen FQDN
// zeigt auf die eigene IP, nicht auf die VIP (siehe mgmtcert.go).
if acmeIssuer != nil {
runManagementCertRenew(ctx, setupStore, tlsRepo, acmeIssuer, alertSvc, alertDedupe)
}
runLicenseVerify(ctx, licClient, licKeyStore, licRepo, nodeID, alertSvc, alertDedupe)
// Lokale Node-ID für Heartbeat. EnsureNodeID liefert dieselbe ID
@@ -241,7 +255,7 @@ func main() {
ntpSyncTick := time.NewTicker(ntpSyncCheckInterval)
defer ntpSyncTick.Stop()
// Kein Initial-Check bei Boot: chrony braucht nach dem Start
// einige Sekunden bis zur ersten Synchronisation — ein
// einige Sekunden bis zur ersten Synchronization — ein
// sofortiger Check würde immer feuern.
wgTunnelTick := time.NewTicker(wgTunnelCheckInterval)
@@ -252,9 +266,19 @@ 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)
}
// Eigenes Management-Cert: unabhaengig von der VIP, siehe oben.
if acmeIssuer != nil {
runManagementCertRenew(ctx, setupStore, tlsRepo, acmeIssuer, alertSvc, alertDedupe)
}
runCertExpiryCheck(ctx, tlsRepo, alertSvc, alertDedupe)
case <-licTick.C:
runLicenseVerify(ctx, licClient, licKeyStore, licRepo, nodeID, alertSvc, alertDedupe)
@@ -272,6 +296,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 +342,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
@@ -567,7 +615,7 @@ func parseChronyTrackingForAlert(out string) (synced bool, stratum int, referenc
synced = true
}
case "Stratum":
fmt.Sscanf(val, "%d", &stratum)
_, _ = fmt.Sscanf(val, "%d", &stratum)
if stratum > 0 && stratum < 16 {
synced = true
} else if stratum == 0 || stratum >= 16 {
@@ -592,7 +640,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 +711,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,19 +768,31 @@ func runBackendDownCheck(ctx context.Context, pool *pgxpool.Pool, a *alerts.Serv
if a == nil || d == nil {
return
}
conn, err := net.DialTimeout("unix", "/run/haproxy/admin.sock", 2*time.Second)
// 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 {
// HAProxy läuft nicht oder Socket nicht erreichbar — kein Alert,
// das ist der Dienst selbst nicht der Scheduler.
return
}
defer conn.Close()
defer func() { _ = conn.Close() }()
_ = conn.SetDeadline(time.Now().Add(3 * time.Second))
if _, err := conn.Write([]byte("show stat\n")); err != nil {
return
}
type srvEntry struct{ status string; hasCheck bool }
type srvEntry struct {
status string
hasCheck bool
}
byBackend := map[string][]srvEntry{}
colIdx := map[string]int{}
scanner := bufio.NewScanner(conn)
@@ -1016,7 +1126,7 @@ func runLicenseVerify(ctx context.Context, c *license.Client, ks *license.KeySto
slog.Debug("scheduler: license verify skipped — no key")
return
}
res, err := c.Verify(key)
res, err := c.Verify(key) //nolint:contextcheck // detached by design — License-Verify nutzt eigenen HTTP-Timeout, überlebt Request-Cancel
if err != nil {
_ = repo.MarkError(ctx, key, err.Error())
slog.Warn("scheduler: license verify failed", "error", err)
@@ -1071,7 +1181,7 @@ func runRenewer(ctx context.Context, r *certrenewer.Service, a *alerts.Service,
_, _ = a.Fire(ctx, "cert.renew_failed", alerts.SeverityError,
"Cert-Renewal fehlgeschlagen: "+domain,
"Let's Encrypt Erneuerung für "+domain+" ist fehlgeschlagen. "+
"Prüfe ACME-Konfiguration und DNS-Erreichbarkeit. "+
"Prüfe ACME-Configuration und DNS-Erreichbarkeit. "+
"Nächster Versuch beim nächsten Renewer-Tick (alle 6h).")
}
}

View File

@@ -0,0 +1,180 @@
package main
import (
"context"
"crypto/x509"
"encoding/pem"
"log/slog"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
"git.netcell-it.de/projekte/edgeguard-native/internal/services/alerts"
"git.netcell-it.de/projekte/edgeguard-native/internal/services/certstore"
"git.netcell-it.de/projekte/edgeguard-native/internal/services/setup"
"git.netcell-it.de/projekte/edgeguard-native/internal/services/tlscerts"
)
// Node-lokale Erneuerung des eigenen Management-Zertifikats.
//
// Befund 2026-09-11: Auf utm-2 war das Zertifikat fuer die Management-UI
// seit zwei Wochen abgelaufen und haette sich nie erneuert. Zwei Gruende
// 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 ausschliesslich
// die Tabelle ab und sieht es deshalb nie.
// 2. Der Scheduler blockt auf einem Nicht-VIP-Master jede ACME-Erneuerung
// (v1.3.20). Das ist fuer geteilte Domains richtig — die zeigen per DNS
// auf die VIP, nur der Master kann die Challenge bestehen. Fuer das
// eigene Management-FQDN stimmt es NICHT: das zeigt auf die eigene IP
// des Nodes, der die HTTP-01-Challenge also selbst beantworten kann.
//
// Deshalb laeuft diese Pruefung auf JEDEM Node, unabhaengig von der VIP —
// aber ausschliesslich fuer das eigene FQDN aus setup.json.
//
// Bewusst NICHT ueber die tls_certs-Tabelle: die 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 (wie die cluster-tls-Certs) und wird auch so
// behandelt: reine Datei unter certDir.
//
// Existiert dagegen eine tls_certs-Zeile fuer das eigene FQDN (so ist es
// auf dem Primary, dessen FQDN beim Setup regulaer als Domain angelegt
// wurde), bleibt alles beim Alten — dann macht certrenewer weiter seine
// Arbeit und wir fassen nichts an. Sonst haetten wir zwei Mechanismen auf
// derselben Datei.
// mgmtCertRenewThreshold: ab wann erneuert wird. Gleicher Wert wie der
// certrenewer fuer die Domain-Certs.
const mgmtCertRenewThreshold = 30 * 24 * time.Hour
// runManagementCertRenew prueft das eigene Management-Zertifikat und
// erneuert es bei Bedarf. Best-effort: Fehler werden geloggt/gemeldet,
// der Tick laeuft beim naechsten Zyklus erneut.
func runManagementCertRenew(
ctx context.Context,
setupStore *setup.Store,
tlsRepo *tlscerts.Repo,
issuer interface {
Issue(domain string) (string, string, string, error)
},
a *alerts.Service, d *dedupe,
) {
if setupStore == nil || issuer == nil {
return
}
st, err := setupStore.Load()
if err != nil || st == nil || st.FQDN == "" {
return
}
fqdn := strings.ToLower(strings.TrimSpace(st.FQDN))
// Wird das FQDN bereits als regulaere Domain verwaltet, ist der
// certrenewer zustaendig — nicht zusaetzlich hier anfassen.
if tlsRepo != nil {
if managed, err := mgmtCertIsManaged(ctx, tlsRepo, fqdn); err == nil && managed {
return
}
}
path := filepath.Join(certDir, fqdn+".pem")
remaining, err := certRemainingValidity(path)
switch {
case err != nil:
slog.Info("scheduler: management cert missing/unreadable — issuing",
"fqdn", fqdn, "path", path, "error", err)
case remaining > mgmtCertRenewThreshold:
return // noch lange gueltig
default:
slog.Info("scheduler: management cert expiring — renewing",
"fqdn", fqdn, "remaining", remaining.Round(time.Hour).String())
}
certPEM, chainPEM, keyPEM, err := issuer.Issue(fqdn)
if err != nil {
slog.Error("scheduler: management cert issue failed", "fqdn", fqdn, "error", err)
if a != nil && d != nil && d.shouldFire("cert.mgmt_renew_failed:"+fqdn) {
_, _ = a.Fire(ctx, "cert.mgmt_renew_failed", alerts.SeverityError,
"Management-Zertifikat konnte nicht erneuert werden: "+fqdn,
"Die HTTP-01-Challenge fuer das eigene Management-FQDN ist fehlgeschlagen. "+
"Pruefe, ob "+fqdn+" auf die oeffentliche IP DIESES Nodes zeigt und Port 80 "+
"von aussen erreichbar ist. Fehler: "+err.Error())
}
return
}
if _, err := certstore.WriteCombined(certDir, fqdn, certPEM, chainPEM, keyPEM); err != nil {
slog.Error("scheduler: management cert write failed", "fqdn", fqdn, "error", err)
return
}
if err := reloadHAProxyForMgmtCert(); err != nil {
slog.Warn("scheduler: haproxy reload after management cert renewal failed", "error", err)
}
slog.Info("scheduler: management cert renewed", "fqdn", fqdn)
}
// mgmtCertIsManaged sagt, ob fuer das FQDN bereits eine tls_certs-Zeile
// existiert (dann gehoert es dem certrenewer).
func mgmtCertIsManaged(ctx context.Context, repo *tlscerts.Repo, fqdn string) (bool, error) {
rows, err := repo.List(ctx)
if err != nil {
return false, err
}
for _, r := range rows {
if strings.EqualFold(strings.TrimSpace(r.Domain), fqdn) {
return true, nil
}
}
return false, nil
}
// certRemainingValidity liest die Restlaufzeit des ersten Zertifikats in
// einer kombinierten PEM-Datei. Fehler (Datei fehlt, unlesbar, kein
// Zertifikat drin) bedeuten "muss ausgestellt werden".
func certRemainingValidity(path string) (time.Duration, error) {
raw, err := os.ReadFile(path) //nolint:gosec // fester Pfad aus certDir + eigenem FQDN
if err != nil {
return 0, err
}
rest := raw
for {
var block *pem.Block
block, rest = pem.Decode(rest)
if block == nil {
return 0, os.ErrNotExist
}
if block.Type != "CERTIFICATE" {
continue
}
crt, err := x509.ParseCertificate(block.Bytes)
if err != nil {
return 0, err
}
return time.Until(crt.NotAfter), nil
}
}
// reloadHAProxyForMgmtCert: "haproxy.service" ausgeschrieben, weil die
// sudoers-Regel im postinst exakt darauf gepinnt ist — ohne Suffix wuerde
// sudo den Aufruf ablehnen. Gleicher Aufruf wie in certrenewer.
func reloadHAProxyForMgmtCert() error {
//nolint:noctx // System-Reload darf nicht am Tick-Context haengen
out, err := exec.Command("sudo", "-n", "/usr/bin/systemctl", "reload", "haproxy.service").CombinedOutput()
if err != nil {
return &exitErr{msg: strings.TrimSpace(string(out)), err: err}
}
return nil
}
type exitErr struct {
msg string
err error
}
func (e *exitErr) Error() string { return e.err.Error() + ": " + e.msg }
func (e *exitErr) Unwrap() error { return e.err }

View File

@@ -0,0 +1,113 @@
package main
import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"math/big"
"os"
"path/filepath"
"testing"
"time"
)
// certRemainingValidity entscheidet, ob ueberhaupt erneuert wird — ein
// falsches Ergebnis heisst entweder "Zertifikat laeuft unbemerkt ab"
// (genau der Befund auf utm-2) oder "wir erneuern bei jedem Tick".
func writeTestPEM(t *testing.T, dir, name string, notAfter time.Time, withKey bool) string {
t.Helper()
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatalf("key: %v", err)
}
tmpl := &x509.Certificate{
SerialNumber: big.NewInt(1),
Subject: pkix.Name{CommonName: name},
NotBefore: time.Now().Add(-time.Hour),
NotAfter: notAfter,
}
der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key)
if err != nil {
t.Fatalf("cert: %v", err)
}
var buf []byte
// Reihenfolge wie certstore.WriteCombined: erst Cert(-Kette), dann Key.
buf = append(buf, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})...)
if withKey {
kd, err := x509.MarshalECPrivateKey(key)
if err != nil {
t.Fatalf("marshal key: %v", err)
}
buf = append(buf, pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: kd})...)
}
p := filepath.Join(dir, name+".pem")
if err := os.WriteFile(p, buf, 0o600); err != nil {
t.Fatalf("write: %v", err)
}
return p
}
func TestCertRemainingValidity_LongLived(t *testing.T) {
dir := t.TempDir()
p := writeTestPEM(t, dir, "node.example.com", time.Now().Add(60*24*time.Hour), true)
got, err := certRemainingValidity(p)
if err != nil {
t.Fatalf("unerwarteter Fehler: %v", err)
}
if got <= mgmtCertRenewThreshold {
t.Errorf("60d-Cert muss ueber dem 30d-Schwellwert liegen, got %v", got)
}
}
func TestCertRemainingValidity_ExpiringSoon(t *testing.T) {
dir := t.TempDir()
p := writeTestPEM(t, dir, "node.example.com", time.Now().Add(5*24*time.Hour), true)
got, err := certRemainingValidity(p)
if err != nil {
t.Fatalf("unerwarteter Fehler: %v", err)
}
if got > mgmtCertRenewThreshold {
t.Errorf("5d-Cert muss unter dem Schwellwert liegen, got %v", got)
}
}
// Der utm-2-Fall: bereits abgelaufen → negative Restlaufzeit, also
// eindeutig unter dem Schwellwert und damit erneuerungspflichtig.
func TestCertRemainingValidity_AlreadyExpired(t *testing.T) {
dir := t.TempDir()
p := writeTestPEM(t, dir, "node.example.com", time.Now().Add(-14*24*time.Hour), true)
got, err := certRemainingValidity(p)
if err != nil {
t.Fatalf("unerwarteter Fehler: %v", err)
}
if got >= 0 {
t.Errorf("abgelaufenes Cert muss negative Restlaufzeit liefern, got %v", got)
}
if got > mgmtCertRenewThreshold {
t.Errorf("abgelaufenes Cert muss erneuert werden, got %v", got)
}
}
func TestCertRemainingValidity_MissingFile(t *testing.T) {
if _, err := certRemainingValidity(filepath.Join(t.TempDir(), "nope.pem")); err == nil {
t.Error("fehlende Datei muss einen Fehler liefern (→ ausstellen)")
}
}
// Nur-Key-Datei: darf nicht als gueltiges Zertifikat durchgehen, sonst
// wuerde ein kaputter Zustand nie repariert.
func TestCertRemainingValidity_NoCertificateBlock(t *testing.T) {
dir := t.TempDir()
p := filepath.Join(dir, "keyonly.pem")
key, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
kd, _ := x509.MarshalECPrivateKey(key)
if err := os.WriteFile(p, pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: kd}), 0o600); err != nil {
t.Fatalf("write: %v", err)
}
if _, err := certRemainingValidity(p); err == nil {
t.Error("PEM ohne CERTIFICATE-Block muss einen Fehler liefern")
}
}

View File

@@ -83,7 +83,7 @@ func main() {
slog.Error("waf: SPOE agent stopped", "error", err)
os.Exit(1)
}
// Graceful shutdown (ctx cancelled): gepufferte Alerts flushen.
// Graceful shutdown (ctx canceled): gepufferte Alerts flushen.
alertWriter.Close()
}

View File

@@ -1,6 +1,8 @@
# EdgeGuard — Architektur
> Status: **v0.1 (Entwurf)** · Stand: 2026-05-08 · Ziel-Plattformen: Debian 13 (Trixie) + Ubuntu 24.04 LTS (Noble Numbat), Architekturen amd64 + arm64.
> Status: **in Produktion** (v1.2.x) · Entwurf: 2026-05-08 · **Cluster/HA-Abschnitte an Ist-Stand angeglichen: 2026-06-06** · Plattform: Debian 13 (Trixie), amd64 + arm64.
>
> ⚠️ **Lesehinweis:** Dieses Dokument war ursprünglich ein Entwurf. Mehrere Cluster/HA-Annahmen wurden anders umgesetzt — maßgeblich sind die mit „**Ist-Stand**" markierten Stellen (§0, §6§9). Kurzfassung: **Logical Replication** statt Streaming, **keepalived/VRRP** statt Hoster-Floating-IP, **KeyDB optional/ungenutzt** (Cluster-State in PostgreSQL), **kein Write-Proxy**.
EdgeGuard ist die native Neufassung des bisherigen Docker-basierten Reverse-Proxy/Loadbalancer/Forward-Proxy/VPN-Stacks. Vorbild für Architektur, Build-System und Cluster-Modell ist [`mail-gateway`](../../mail-gateway/docs/architecture.md) (`nmg`); UI-Pattern und Bootstrap-Onliner stammen aus [`netcell-webpanel`](../../netcell-webpanel/CLAUDE.md) (`enconf`).
@@ -8,14 +10,14 @@ EdgeGuard ist die native Neufassung des bisherigen Docker-basierten Reverse-Prox
## 0. Leitplanken (nicht verhandelbar)
- **Kein Docker.** Alle Dienste nativ unter `systemd`, installiert via `apt`. Distro-Pakete für Drittsoftware (HAProxy, Squid, WireGuard, Unbound, PostgreSQL, KeyDB, certbot), eigene `.deb`-Pakete für EdgeGuard-Code (api, ui, ctl).
- **Plattform-Matrix:** Debian 13 (Trixie) **und** Ubuntu 24.04 LTS (Noble Numbat), je amd64 + arm64. Alle vier Targets gleichberechtigt.
- **Kein Docker.** Alle Dienste nativ unter `systemd`, installiert via `apt`. Distro-Pakete für Drittsoftware (HAProxy, Squid, WireGuard, Unbound, PostgreSQL, keepalived, chrony, certbot), eigene `.deb`-Pakete für EdgeGuard-Code (api, ui, ctl).
- **Plattform:** **Debian 13 (Trixie), amd64 + arm64.** Nur Trixie — die Build-/Publish-Pipeline (`Makefile`, `scripts/apt-repo/`) zielt ausschließlich auf `trixie`. _(Eine frühere Ubuntu-24.04-Matrix war geplant, ist aber nicht implementiert.)_
- **Auslieferung:** signierte `.deb`-Pakete + Meta-Paket via APT. Bootstrap ist der enconf-analoge curl-Onliner `curl -fsSL https://get.edgeguard.netcell-it.de | sudo bash`.
- **HA nativ als Cluster:** N symmetrische Peers, **KeyDB Active-Active** für Shared State + **PostgreSQL Streaming Replication** (single writer, transparenter API-Write-Proxy) + **Floating-IP des Hosters** für HTTP/HTTPS-Ingress (nicht VRRP, nicht DNS-RR).
- **Kein WAF, kein IDS, kein DHCP, kein RADIUS, keine Mail-Funktion in v1.** Mail-Gateway ist eigenes Produkt (`nmg`); WAF/CrowdSec/Suricata kommen ggf. in v2.
- **HA nativ als Cluster (Ist-Stand 2026-06):** N symmetrische Peers, **PostgreSQL Logical Replication** (ein Publisher/Primary `edgeguard_shared` → N Subscriber; node-lokale Tabellen ausgenommen) + **keepalived/VRRP** für den VIP-Failover (HTTP/HTTPS-Ingress) + **mTLS-Cluster-Agent** (:8443) für Heartbeat/Cert-Sync/Aggregation. **KeyDB ist optional** (`Recommends`) und im Code praktisch ungenutzt; ein Write-Proxy existiert nicht (Writes erfolgen am Primary). _(Der ursprüngliche Entwurf — KeyDB Active-Active, PG-Streaming-Replication mit transparentem Write-Proxy, Floating-IP-statt-VRRP — wurde so nicht umgesetzt; Details in §6§9.)_
- **WAF, IDS/IPS, DHCP, RADIUS inzwischen umgesetzt** (Stand 2026-06): WAF via Coraza+SPOE, IDS/IPS via CrowdSec, DHCP via Kea, RADIUS via FreeRADIUS. Mail-Funktion bleibt ausgeschlossen — Mail-Gateway ist eigenes Produkt (`nmg`).
- **Migrations:** `goose` (SQL-Dateien), nicht GORM AutoMigrate.
**Nicht-Ziele (ausdrücklich):** kein WAF, kein Network-IDS (Suricata), kein IPS (CrowdSec), kein DHCP-Server (Kea), kein RADIUS, keine Mail-Verarbeitung, keine Multi-Tenant-GuardZones in v1, keine ISO-Builds (kein EdgeGuardOS-Klon — nur APT).
**Nicht-Ziele (weiterhin):** kein Network-IDS via Suricata (IDS/IPS läuft über CrowdSec), keine Mail-Verarbeitung, keine Multi-Tenant-GuardZones, keine ISO-Builds (kein EdgeGuardOS-Klon — nur APT). _(Historisch waren auch WAF/DHCP/RADIUS/IPS Nicht-Ziele — siehe oben, inzwischen umgesetzt.)_
---
@@ -33,12 +35,13 @@ EdgeGuard ist die native Neufassung des bisherigen Docker-basierten Reverse-Prox
| Komponente | Rolle |
|---|---|
| `edgeguard-api` | Go/Gin REST-API, bindet `127.0.0.1:9443`, Reads aus lokaler PG, Writes an Cluster-Primary |
| `edgeguard-scheduler` | Cron-artige Jobs (ACME-Renewal-Hook, Backup, Health-Aggregation, License-Heartbeat) |
| `edgeguard-ctl` | CLI für Setup/Wartung (`initdb`, `migrate`, `cluster-join`, `promote`, `dump-config`) |
| `edgeguard-api` | Go/Gin REST-API, bindet `127.0.0.1:9443`, Reads/Writes auf lokaler PG. Geteilte Tabellen werden vom Primary per Logical Replication an Subscriber verteilt; Writes sollen am Primary erfolgen (keine Write-Proxy-Umleitung im Code). |
| `edgeguard-waf` | Coraza-WAF-Agent (HAProxy SPOE) — Binary im `edgeguard-api`-Paket, eigene systemd-Unit |
| `edgeguard-scheduler` | Cron-artige Jobs (ACME-Renewal-Hook, Backup, Health-Aggregation, Stale-Node-Sweep, License-Heartbeat) |
| `edgeguard-ctl` | CLI für Setup/Wartung (`initdb`, `migrate`, `cluster-join`, `promote`, `cluster-init-replication`, `cluster-setup-standby`, `dump-config`) |
| `management-ui` | React 19 + AntD 6 + Vite, statisch unter `/usr/share/edgeguard/ui/`, von `edgeguard-api` per gin `StaticFS` ausgeliefert (HAProxy proxied Management-FQDN dorthin) |
| **PostgreSQL 16** | Single Source of Truth — Domains, Backends, Routing-Rules, ACLs, Peers, etc. |
| **KeyDB** (Redis-kompatibel) | Active-Active-Replication, Cluster-State, Locks, Rate-Counter, Pub/Sub für Config-Reload |
| **PostgreSQL 16/17** | Single Source of Truth — Domains, Backends, Routing-Rules, ACLs, Peers, Cluster-State (`ha_nodes`), Lizenz etc. |
| **KeyDB** (optional) | `Recommends`, im Code praktisch ungenutzt — kein Redis-Client in `go.mod`. Cluster-State/Heartbeat/Locks liegen in PostgreSQL, nicht in KeyDB. |
---
@@ -61,10 +64,15 @@ EdgeGuard ist die native Neufassung des bisherigen Docker-basierten Reverse-Prox
│ ├── wireguard/ # WireGuard-Config-Generator (wg-quick + wg syncconf)
│ ├── unbound/ # Unbound-Config-Generator (Forwarder + Cluster-DNS)
│ ├── firewall/ # nftables-Ruleset-Generator
│ ├── cluster/ # Join/Promote/Peer-Discovery, KeyDB-Replication-Setup, pg_basebackup
│ ├── proxy/ # API-Write-Proxy-Middleware (Replica → Primary), mTLS-Calls
│ ├── aggregator/ # Cluster-View-APIs (alle Backends, alle Peers, alle Health-States)
── license/ # License-Validation, License-Leader-Election (KeyDB-Lock)
│ ├── cluster/ # Join/Promote/Peer-Discovery, Heartbeat, Logical-Replication-Setup, confighash
│ ├── keepalived/ # keepalived/VRRP-Config-Generator (VIP-Failover)
│ ├── chrony/ # chrony-Config-Generator (NTP)
── kea/ # Kea-DHCP4-Config-Generator
│ ├── freeradius/ # FreeRADIUS-Config-Generator (RADIUS)
│ ├── crowdsec/ # CrowdSec-IDS/IPS-Management (managed-wenn-installiert)
│ ├── waf/ # Coraza-WAF-Engine + SPOE-Agent-Logik
│ ├── aggregator/ # Cluster-View-APIs via mTLS (read-only Fan-Out + Trigger-Actions)
│ └── license/ # License-Validation (jeder Node verifiziert eigenständig — KEINE KeyDB-Leader-Election)
├── management-ui/ # React 19 + AntD 6 + Vite (Struktur 1:1 wie netcell-webpanel/management-ui/)
├── packaging/
│ └── debian/
@@ -94,17 +102,17 @@ EdgeGuard ist die native Neufassung des bisherigen Docker-basierten Reverse-Prox
## 3. Debian-Pakete
Drei Pakete + Meta — analog nmg, kein WAF-Paket weil kein WAF in v1.
Drei Pakete + Meta — analog nmg. Der WAF-Agent `edgeguard-waf` ist **kein eigenes Paket**, sondern liegt als zusätzliches Binary im `edgeguard-api`-Paket (eigene systemd-Unit).
| Paket | Arch | Inhalt | Depends |
|---|---|---|---|
| `edgeguard-api` | amd64, arm64 | `/usr/bin/edgeguard-{api,scheduler,ctl}`, Unit-Files, Migrations, Default-Configs | `postgresql-16`, `keydb-server`, `haproxy`, `squid`, `wireguard-tools`, `unbound`, `nftables`, `certbot`, `openssl` |
| `edgeguard-api` | amd64, arm64 | `/usr/bin/edgeguard-{api,scheduler,ctl,waf}`, Unit-Files, Migrations, Default-Configs | `postgresql-16 \| postgresql-17`, `haproxy (>=2.8)`, `squid`, `wireguard-tools`, `unbound`, `chrony`, `kea-dhcp4-server`, `freeradius`, `nftables`, `keepalived`, `certbot`, `openssl`, `sudo`, `adduser`, `systemd`, `ca-certificates`, `ulogd2`, `ulogd2-json` u. a. · _Recommends:_ `edgeguard-keydb`, `apparmor`, `fail2ban` · _CrowdSec: managed-wenn-installiert (kein Depends)_ |
| `edgeguard-ui` | all | `/usr/share/edgeguard/ui/` (statische Build-Artefakte) | `edgeguard-api (= ${binary:Version})` |
| `edgeguard-meta` | all | keine Dateien, nur `Depends` | `edgeguard-api`, `edgeguard-ui` |
Pro Release: 1 arch-spezifisch × 2 Dists × 2 Arches = 4 `.deb` + 2 arch-agnostische × 2 Dists = 4 `.deb` **8 Artefakte je Release**.
Pro Release: 1 arch-spezifisches Paket (`edgeguard-api`) × **1 Dist (trixie)** × 2 Arches = 2 `.deb` + 2 arch-agnostische (`edgeguard-ui`, `edgeguard-meta`) = **4 Artefakte je Release**. (Build/Publish-Pipeline zielt nur auf `trixie`.)
**KeyDB-Herkunft:** KeyDB ist weder in `trixie` noch `noble` in den offiziellen Repos. Wir bauen es aus Source (amd64 + arm64), veröffentlichen es parallel im eigenen APT-Repo. `edgeguard-api` `Depends: keydb-server` löst aus unserem Repo aus.
**KeyDB-Herkunft:** KeyDB ist optional (`Recommends: edgeguard-keydb`), nicht in den offiziellen trixie-Repos. Falls genutzt, aus Source gebaut + im eigenen APT-Repo veröffentlicht. Im aktuellen Code wird KeyDB nicht benötigt — siehe §7.
**Build-Werkzeug:** **direkter `dpkg-deb`-Build** analog WebPanel/EdgeGuardOS-Pattern. **Nicht** `dh_make`/`debhelper`, **nicht** `fpm`. Konsistenz mit existierendem Workflow.
@@ -162,7 +170,8 @@ Entspricht FHS — keine Überraschungen für Admins, Lintian-clean.
| Unit | Typ | Depends-on | User | Restart |
|---|---|---|---|---|
| `edgeguard-api.service` | `simple` | `postgresql.service`, `keydb-server.service` | `edgeguard` | `on-failure`, `RestartSec=5` |
| `edgeguard-api.service` | `simple` | `Requires=postgresql.service`; `After=`/`Wants=keydb-server.service` (KeyDB nur weich/optional) | `edgeguard` | `on-failure`, `RestartSec=5` |
| `edgeguard-waf.service` | `simple` | `edgeguard-api.service` (Coraza SPOE-Agent) | `edgeguard` | `on-failure` |
| `edgeguard-scheduler.service` | `simple` | `edgeguard-api.service` | `edgeguard` | `on-failure` |
| `edgeguard-cert-deploy.path` | `path` | — | — | — |
| `edgeguard-firewall.service` | `oneshot`, `RemainAfterExit=true` | — | root | — |
@@ -183,7 +192,7 @@ SystemCallFilter=@system-service
ReadWritePaths=/var/lib/edgeguard /var/log/edgeguard /etc/edgeguard
```
Drittsoftware (HAProxy, Squid, WireGuard via `wg-quick@.service`, Unbound, nftables) läuft als **Distro-Units**. EdgeGuard generiert deren Config + signalisiert Reload, übernimmt aber die Service-Verwaltung **nicht**.
Drittsoftware läuft als **Distro-Units** EdgeGuard generiert deren Config + signalisiert Reload/Restart, übernimmt aber die Service-Verwaltung weitgehend nicht. Renderer existieren für: **HAProxy, Squid, WireGuard (`wg-quick@.service`), Unbound, nftables, keepalived, chrony, Kea (`kea-dhcp4-server`), FreeRADIUS** (letzte beide default-off). **CrowdSec** (`crowdsec` + `crowdsec-firewall-bouncer`) wird gemanagt, wenn installiert (kein Depends).
API bindet auf `127.0.0.1:9443` (nicht öffentlich). HAProxy terminiert TLS auf `:443`, leitet `/.well-known/acme-challenge/*` und Management-FQDN-Traffic an die API weiter, routet alle anderen Hosts per ACL an die User-Backends.
@@ -191,29 +200,28 @@ API bindet auf `127.0.0.1:9443` (nicht öffentlich). HAProxy terminiert TLS auf
## 6. Datenbank-Setup
- **PostgreSQL 16**, Distro-Paket `postgresql-16`.
- **Verbindung:** Unix-Socket (`/var/run/postgresql`) für lokale Reads + Writes der API. TCP/5432 mit TLS-Client-Cert nur zwischen Cluster-Peers für Streaming Replication.
- **Topologie:** **ein logischer Primary** zu jedem Zeitpunkt, N Read-Replicas. Lokale API liest immer aus lokaler PG; Writes routet die API-Write-Proxy-Middleware transparent an den aktuellen Primary (KeyDB-Key `cluster:pg-primary-url`).
- **Migrations:** `goose` (SQL-Dateien in `internal/database/migrations/`, via `//go:embed` ins Binary gepackt). **Nicht** GORM AutoMigrate.
- **PostgreSQL 16/17**, Distro-Paket `postgresql-16 | postgresql-17`.
- **Verbindung:** Unix-Socket (`/var/run/postgresql`) für lokale Reads + Writes der API. TCP/5432 (Rolle `edgeguard_replicator`) nur zwischen Cluster-Peers für die Logical-Replication-Verbindung.
- **Topologie (Ist-Stand):** **Logical Replication** — ein Primary publiziert `edgeguard_shared` (alle Tabellen außer `localOnlyTables`), N Subscriber (`edgeguard_sub`, `wal_level=logical`, Initialkopie via `copy_data=true`). Jeder Node hat eine **eigene beschreibbare** PG-Instanz; geteilte Config fließt vom Primary zu den Subscribern. **Es gibt keinen Write-Proxy** — Schreibzugriffe auf geteilte Tabellen müssen am Primary erfolgen; ein Subscriber-Write auf eine replizierte Tabelle würde nicht propagieren (Drift-Banner erkennt das via `config_hash`). Primary-Erkennung zuverlässig über `pg_publication`; der Standby-Bootstrap läuft per Logical Subscription (kein `pg_basebackup` im aktiven Pfad).
- **node-lokale Tabellen** (nicht repliziert): `ha_nodes`, `network_interfaces`, `ip_addresses`, `static_routes`, `cluster_settings`, `dns_settings`, `ntp_settings`, `dhcp_settings`, `radius_settings`, `system_settings`, `join_tokens_used`, `audit_log`, `alert_events`, `backups`, `goose_db_version` (Liste: `cmd/edgeguard-ctl/cluster_replication.go` `localOnlyTables`).
- **Migrations:** `goose` (SQL-Dateien in `internal/database/migrations/`, via `//go:embed`). **Nicht** GORM AutoMigrate.
GORM bleibt als ORM für Query-Komfort; nur das Schema-Management wechselt zu `goose`.
GORM bleibt als ORM für Query-Komfort; Schema-Management läuft über `goose`.
---
## 7. KeyDB Active-Active
## 7. Cluster-State & KeyDB (Ist-Stand: PostgreSQL-zentrisch)
KeyDB ersetzt Redis. **Active-Active Replication** (Multi-Master, operation-basiert, split-brain-tolerant).
> **Hinweis:** Der ursprüngliche Entwurf sah KeyDB Active-Active als Cluster-State-Layer vor. **Im Code ist das nicht umgesetzt** — es gibt **keinen Redis/KeyDB-Client** (`go.mod` enthält nur `pgx`). KeyDB ist optional (`Recommends`) und wird vom laufenden System nicht benötigt.
**Verwendung:**
- `cluster:pg-primary-url` — wer ist aktueller PG-Primary?
- `cluster:license-leader` — Lock für License-Heartbeat (`SET … NX EX 60`)
- `cluster:license-status` — Cache des Lizenz-Validate-Ergebnisses (TTL 24 h)
- `cluster:nodes:<node-id>` — Heartbeat-Marker (TTL 2 min)
- `ratelimit:<scope>:<key>` — Rate-Counter (HINCRBY-Ops mergen korrekt)
- `acme:lock:<domain>` — verhindert Parallel-Issue auf zwei Nodes
- Pub/Sub: `edgeguard:config-changed` — alle Nodes regenerieren Config
**Wie Cluster-State tatsächlich gehalten wird:**
- **PG-Primary** — über `pg_publication` (`edgeguard_shared`) ermittelt; die Peer-Adresse für Pushes stammt aus `setup.json` `PrimaryFQDN`.
- **Node-Heartbeat/-Status** — Spalten `last_seen`/`status` in PG `ha_nodes`. Jeder Node bumpt seine Row alle 30s (`runClusterHeartbeat`); Secondary→Primary (`runPrimaryPush`) und Primary→Secondary (`runPeerPush`) pushen sich gegenseitig per mTLS (30s, bidirektional). `SweepStaleNodes` (Scheduler) flippt Peers nach 2 min ohne Heartbeat auf `offline`.
- **Lizenz** — jeder Node verifiziert **eigenständig** gegen `license.netcell-it.com` (kein Leader-Lock); Ergebnis in PG `licenses`.
- **ACME** — kein verteilter Issue-Lock implementiert (Single-Node-Default; bei Cluster Issue am aktiven/Primary-Node).
- `cluster:pg-primary-url` in KeyDB wird von `edgeguard-ctl promote` **geschrieben, falls KeyDB läuft**, aber von der API **nie gelesen** (advisory/Altlast).
KeyDB hört nur auf `127.0.0.1:6379` für lokale Clients und `<node-ip>:16379` (TLS) für Peer-Replication.
_Falls KeyDB künftig wieder eingeführt wird (Rate-Limiting-Counter, Pub/Sub-Config-Reload): hört auf `127.0.0.1:6379` lokal und `<node-ip>:16379` (TLS) für Peer-Replication. Derzeit ungenutzt._
---
@@ -234,7 +242,7 @@ Unbound erfüllt zwei Rollen, beide aus PG generiert:
- **Local-Zone** `eg.cluster.` enthält A/AAAA-Records aller Cluster-Peers (Node-Hostnamen aus PG `ha_nodes`).
- Beispiel: `node1.eg.cluster → 10.42.0.11`, `node2.eg.cluster → 10.42.0.12`.
- Wird bei jedem Node-Join/-Leave aus PG regeneriert + via `edgeguard:config-changed` Pub/Sub auf allen Peers neu geladen (`unbound-control reload`).
- Cluster-interner Traffic (PG-Replication, KeyDB-Replication, mTLS-API-Calls, Cert-Push) löst Peer-Adressen ausschließlich über diese Zone auf — kein DNS-Roundtrip ins öffentliche Internet, keine `/etc/hosts`-Synchronisation.
- Cluster-interner Traffic (PG-Logical-Replication, mTLS-Agent-Calls auf :8443, Cert-Push) löst Peer-Adressen ausschließlich über diese Zone auf — kein DNS-Roundtrip ins öffentliche Internet, keine `/etc/hosts`-Synchronisation.
- `<node-name>.eg.cluster` ist **nicht extern erreichbar** (nur über Unbound der Cluster-Peers).
### Config-Schichten
@@ -253,54 +261,56 @@ Reload via `unbound-control reload` (kein Restart, keine Cache-Invalidierung au
## 8. Cluster-Topologie & HA pro Service
**N symmetrische Peers** (1 … N Nodes, jeder vollwertig). Keine VRRP, keine Master/Backup-Rollen für Daten-Services. Public-IP: **Floating-IP des Hosters** (siehe §9).
**N symmetrische Peers** (1 … N Nodes, jeder vollwertig). Public-IP-Failover via **VIP/VRRP (keepalived)** — siehe §9 (der ursprünglich geplante „Floating-IP statt VRRP"-Ansatz wurde **nicht** umgesetzt).
| Service | HA-Strategie |
|---|---|
| **HAProxy** | stateless, pro Node identisch. Floating-IP zeigt zum aktuellen aktiven Node; bei Node-Ausfall API-Call zum Hoster (oder manueller Switch) reicht. ACME-Issue nur auf License-Leader (KeyDB-Lock); Zerts werden via PG/mTLS an alle verteilt. |
| **Squid** | stateless (Cache lokal, kein Sync nötig). Pro Node identische ACL-Config. |
| **VIP/keepalived** | VRRP (`vrrp_instance`), MASTER/BACKUP per `pg_role` (primary→prio 200/MASTER, standby→100/BACKUP). VIPs aus `ip_addresses` (`is_vip=true`). Trägt den HTTP/HTTPS-Ingress. |
| **HAProxy** | stateless, pro Node identisch. Hört auf der VIP des aktiven Node. ACME-Issue ohne verteilten Lock (Single-/Primary-Node); Zerts werden via mTLS (`/agent/cluster/tls-certs`) an alle verteilt. |
| **Squid** | stateless (Cache lokal). Pro Node identische ACL-Config. |
| **WireGuard** | siehe §8.1 |
| **Unbound** | stateless (Cache lokal). Pro Node identische Forwarder-Config + identische Cluster-internen Local-Zones (siehe §7.5). |
| **nftables** | pro Node identisch, Ruleset aus PG generiert. `crowdsec_blocklist`/`threat_intel_blocklist`-Sets entfallen in v1 (kein CrowdSec). |
| **edgeguard-api** | pro Node, Reads lokal, Writes via Proxy zu Primary. |
| **edgeguard-ui** | statisch, pro Node identisch. |
| **PostgreSQL** | Streaming Replication, manueller Promote (siehe nmg §6.2). |
| **KeyDB** | Active-Active. |
| **Unbound** | stateless (Cache lokal). Pro Node identische Forwarder-Config + Cluster-Local-Zones (§7.5). |
| **nftables** | pro Node, Ruleset aus PG generiert. CrowdSec-Blocklist via `crowdsec-firewall-bouncer` (eigene Sets), wenn CrowdSec installiert. |
| **edgeguard-api** | pro Node, Reads lokal. Writes auf geteilte Tabellen am Primary (kein Write-Proxy). |
| **edgeguard-ui / edgeguard-waf** | statisch bzw. pro Node identisch. |
| **PostgreSQL** | **Logical Replication** (Publisher→Subscriber), manueller Promote (§8.2). |
| **KeyDB** | optional/ungenutzt (§7). |
### 8.1 WireGuard im Cluster
Drei Optionen, für v1 wählen wir **Option A**:
- **A — Geteilte Server-Identität (gewählt):** alle Peers haben **denselben** Server-Privatkey + dasselbe Listen-Port. Floating-IP routet UDP zum aktiven Node. Bei Failover: Floating-IP wandert, Clients schicken Pakete zum neuen Node, neuer Handshake (~12s Latenz beim ersten Paket). Replay-Protection-Counter werden nicht repliziert — beim Failover macht der Client neuen Handshake, alte Counter sind irrelevant.
- **A — Geteilte Server-Identität (gewählt):** alle Peers haben **denselben** Server-Privatkey + dasselbe Listen-Port. Die **VIP (keepalived)** trägt das WireGuard-UDP zum aktiven Node. Bei Failover: VIP wandert, Clients schicken Pakete zum neuen Node, neuer Handshake (~12s Latenz beim ersten Paket). Replay-Protection-Counter werden nicht repliziert — beim Failover macht der Client neuen Handshake, alte Counter sind irrelevant.
- B — Pro Node eigene Identität, Client kennt alle: Client-Configs haben mehrere `[Peer]`-Blöcke. Aufwendiger zu provisionieren, kein Failover-Vorteil.
- C — Aktiv/Standby per License-Leader-Pattern: nur ein Node hat WireGuard aktiv, andere idle. Verschwendet Kapazität.
**Begründung A:** Privatkey ist in PG (verschlüsselt mit `edgeguard.key`), wird beim Cluster-Join an neue Peers verteilt. WireGuard handelt selbständig neue Sessions aus, kein State-Sync nötig. Operation-Tools (Peer hinzufügen/entfernen) wirken auf alle Nodes via `edgeguard:config-changed` Pub/Sub + lokales `wg syncconf`.
**Begründung A:** Privatkey liegt verschlüsselt in PG, wird per Logical Replication an die Peers verteilt. WireGuard handelt selbständig neue Sessions aus, kein State-Sync nötig. Peer-Änderungen propagieren über die Logical Replication; Secondaries erkennen die Änderung am `config_hash` (`runSecondaryConfigRender`, 5-min-Tick) und re-rendern lokal `wg syncconf`.
### 8.2 Manual Promote (PG-Primary-Failover)
1:1 nmg-Pattern (siehe `mail-gateway/docs/architecture.md` §6.2). Bei Ausfall des Primary antworten Config-Writes mit `503 + actionable Error`. Admin promotet via UI/CLI. Datenebene (HAProxy/Squid/WireGuard/Unbound) läuft unbeeinträchtigt weiter, weil jeder Node eine lokale PG-Replica hat.
Bei Ausfall des Primary läuft die Datenebene (HAProxy/Squid/WireGuard/Unbound) weiter, weil jeder Node eine lokale, lesbare PG-Instanz (Logical-Subscriber) hat. Schreibzugriffe auf geteilte Config müssen am Primary erfolgen — fällt der Primary aus, promotet der Admin manuell via **`edgeguard-ctl promote`**. Das ist Logical-Replication-aware: es löst die Subscription zum toten Primary (`DISABLE` + `slot_name=NONE` + `DROP`, hängt also nicht am toten Publisher), richtet die Node via `setupReplicationPrimary` als Publisher ein (Rolle/Secret/`wal_level=logical` inkl. **PG-Restart** falls nötig/Publication), setzt `ha_nodes.pg_role='primary'` und rendert keepalived (→ MASTER, übernimmt die VIP). Erholte Nodes danach mit `edgeguard-ctl cluster-setup-standby <neuer-primary>` zurückhängen. **Achtung:** echtes Cross-Node-Failover ist nur im Drill testbar — die Bausteine (Drop-Subscription, Publication, Restart) sind dieselben wie in `cluster-init-replication`/`cluster-setup-standby`.
### 8.3 License-Leader-Election
### 8.3 License-Verifikation
Ein einziger Node kontaktiert `license.netcell-it.com` (KeyDB-Lock, 60-s-TTL). Ergebnis cluster-weit in `cluster:license-status` (TTL 24 h). `active_servers`-Verbrauchswert = Count der Peers mit Heartbeat < 2 min.
**Kein Leader-Election** (anders als ursprünglich geplant). Jeder Node verifiziert **eigenständig** gegen `license.netcell-it.com` (Scheduler-Tick), Ergebnis in PG `licenses`. `active_servers` = Anzahl Peers mit Heartbeat < 2 min (aus `ha_nodes`). Ein KeyDB-Lock existiert nicht.
---
## 9. Public-Ingress — Floating-IP statt VRRP
## 9. Public-Ingress — VIP via keepalived/VRRP
**Problem:** HTTP-Clients machen kein automatisches Failover bei DNS-RR (anders als MTAs). Ein toter A-Record = 50% Fehler bis DNS-TTL.
> **Ist-Stand:** Umgesetzt ist **VIP-Failover über keepalived (VRRP)** — nicht der ursprünglich angedachte „Floating-IP des Hosters"-Ansatz. Es gibt **keinen** Hoster-API-Code und **keinen** `POST /cluster/promote-this-node`-Endpoint.
**Entscheidung:** **Floating-IP des Hosters**. Der Hoster bietet eine API zum Umroute der IP zwischen Servern (z. B. via REST oder DNS-Update bei dynamischer Anycast-Lösung). Failover dauert Sekunden, kein VRRP-Drama, kein "VIP verschwindet"-Problem aus dem alten Setup.
**Mechanik (`internal/keepalived`):**
- Renderer erzeugt `/etc/keepalived/keepalived.conf` mit `vrrp_instance` (unicast peer, `virtual_router_id`, `authentication`).
- **State/Priorität aus `pg_role`:** Primary → `state MASTER`, `priority 200`; Standby → `state BACKUP`, `priority 100`.
- **VIPs** kommen aus `ip_addresses` (`is_vip=true`, `active=true`), inkl. Interface; managed via `systemctl reload-or-restart keepalived`.
- Bei Node-/PG-Ausfall übernimmt VRRP die VIP auf den verbleibenden Node (Sekundenbereich).
Optionen pro Hoster:
1. **Provider-Floating-IP** (gewünscht): API-Call schaltet IP um. EdgeGuard exponiert `POST /api/v1/cluster/promote-this-node`, das die Hoster-API aufruft.
2. **DNS-RR mit kurzer TTL (60s)** als Notlösung wenn keine Floating-IP verfügbar.
3. **Anycast/BGP** als Premium-Variante (für Enterprise).
**Tooling:** `GET/PUT /cluster/vip-settings`, `GET /cluster/vip-status`, `POST /cluster/vip-test` (Letzteres bewegt eine VIP testweise per `ip addr add/del` zwischen Nodes — kein Hoster-Call).
**v1-Default:** Single-Node mit fest zugewiesener Floating-IP. Cluster-Erweiterung kommt mit Phase 2.
**v1-Default:** Single-Node. Im Cluster trägt der MASTER (Primary) die VIP.
**OFFEN:** Welcher Hoster ist Standard? API-Spec dokumentieren sobald geklärt.
**OFFEN (Altlast-Bereinigung):** Doku-Abschnitte/Code, die noch „Floating-IP des Hosters" implizieren, sind historisch — der reale Pfad ist keepalived/VRRP.
---
@@ -312,7 +322,7 @@ curl -fsSL https://get.edgeguard.netcell-it.de | sudo bash
Schritte (idempotent, analog `netcell-webpanel/install.sh`):
1. **OS-Detection** (`/etc/os-release`): nur Trixie *oder* Noble, sonst Abbruch.
1. **OS-Detection** (`/etc/os-release`): nur Debian 13 (Trixie), sonst Abbruch.
2. **Arch-Detection**: nur amd64 *oder* arm64.
3. **Base-Deps:** `curl gnupg ca-certificates apt-transport-https`.
4. **APT-Keyrings:**
@@ -329,7 +339,14 @@ curl -fsSL https://get.edgeguard.netcell-it.de | sudo bash -s -- \
--token <cluster-join-token>
```
`edgeguard-ctl cluster-join` führt aus: PG-Basebackup vom Primary, KeyDB-Replication-Setup, Node-Registrierung in `ha_nodes`, TLS-Cert-Pull via mTLS, Config-Regeneration, Service-Start.
`edgeguard-ctl cluster-join` führt aus: TLS-Cert-Pull via mTLS (CSR→issue-cert) und Node-Registrierung in `ha_nodes` (`autoRegister`) — **mehr nicht**. Die Logical Replication ist ein eigener Schritt (`cluster-setup-standby`: `CREATE SUBSCRIPTION … copy_data=true`, Initialkopie der geteilten Tabellen, Master-Key-Sync, Config-Regeneration). _(Kein `pg_basebackup`, kein KeyDB-Setup — beides war nur im ursprünglichen Entwurf.)_
**Join über den Setup-Wizard (empfohlener Weg) macht beides automatisch:**
1. Auf dem Primary erzeugt `POST /cluster/join-tokens` den Token — und stellt dabei vorher via `cluster-init-replication` sicher, dass die Publisher-Seite steht (Replikations-Rolle + Secret, `wal_level=logical`, `pg_hba`, PUBLICATION). Ein frisch installierter Single-Node hat das alles noch nicht; ohne diesen Schritt liefe das spätere `CREATE SUBSCRIPTION` in ein 404. Idempotent; der einmalige PG-Restart (`wal_level` ist ein postmaster-Parameter) passiert bewusst hier, solange noch kein zweiter Node Traffic erwartet.
2. Auf dem neuen Node startet `POST /setup/join-cluster` nach erfolgreichem Join `cluster-setup-standby` detached (via `sudo`, da root nötig). Fortschritt pollbar über `GET /setup/replication-status` (`running`/`done`/`failed`); der Wizard zeigt ihn an und gibt bei Fehlschlag das manuelle Kommando aus.
Der reine CLI-Pfad (`cluster-join`) bleibt der manuelle Weg und erfordert `cluster-setup-standby` weiterhin explizit.
---
@@ -337,7 +354,7 @@ curl -fsSL https://get.edgeguard.netcell-it.de | sudo bash -s -- \
- **Primärquelle:** Gitea Package Registry (`https://git.netcell-it.de/api/packages/projekte/debian`).
- **Kunden-Mirror:** `https://apt.netcell-it.de/edgeguard/` (rsync von Gitea).
- **Suiten:** `stable` · `testing` · `security` pro Codename (`trixie`, `noble`).
- **Suiten:** `stable` · `testing` · `security` — Codename `trixie`.
- **Signatur:** GPG-Key `netcell-edgeguard-signing`, ausgeliefert in `/etc/apt/keyrings/`.
- **Update-Check-API:** `GET /api/v1/system/package-versions` → pro `edgeguard-*`-Paket `{name, installed, available, reboot_required}`.
- **Upgrade-Trigger:** `POST /api/v1/system/upgrade` startet `systemd-run --unit=edgeguard-upgrade.service --collect …` (HTTP-Response geht VOR dem Upgrade raus, weil API beim Self-Update stirbt — Pattern aus `netcell-webpanel/management-agent/internal/handlers/update.go:105`).
@@ -355,13 +372,13 @@ Build-/Release-Scripts identisch zu `mail-gateway/scripts/apt-repo/`.
- **Lizenzserver:** `https://license.netcell-it.com` (öffentlich, kein API-Key).
- **Verify-Endpoint:** `GET /api/v1/licenses/{key}/verify?system_id={fp}&system_name={host}&active_domains={n}`.
- **Fingerprint:** `SHA256(/etc/machine-id + erste-aktive-MAC + hostname)`.
- **Caching:** Live → KeyDB `cluster:license-status` (TTL 24h)`/var/lib/edgeguard/trial.json` (30 Tage) → `expired`.
- **Leader-Election** wie nmg §6.3.
- **Caching:** Live-Verify → Ergebnis in PG `licenses``/var/lib/edgeguard/trial.json` (30-Tage-Trial-Fallback) → `expired`.
- **Keine Leader-Election** — jeder Node verifiziert eigenständig (§8.3).
### 12.2 ACME
- **certbot** (Distro-Paket) mit `--webroot=/var/lib/edgeguard/acme` — HAProxy ACL `path_beg /.well-known/acme-challenge/` proxied diese Pfade an `edgeguard-api`, das die Challenge-Tokens aus der Webroot-Dir ausliefert.
- **Lock vor Issue:** `acme:lock:<domain>` in KeyDB verhindert Parallel-Issue auf zwei Nodes.
- **Cluster-Locking:** derzeit **kein** verteilter Issue-Lock implementiert (Single-Node-Default; im Cluster sollte ACME am Primary/aktiven Node laufen). _(Der ursprünglich geplante KeyDB-`acme:lock:<domain>` existiert nicht.)_
- **Deploy-Hook:** schreibt fertiges PEM (cert+chain+key kombiniert) nach `/etc/edgeguard/tls/<domain>.pem` und triggert `systemctl reload haproxy`. HAProxy lädt den `crt /etc/edgeguard/tls/`-Verzeichnisinhalt neu.
- **Cert-Verteilung im Cluster:** Issuing-Node pushed via mTLS-API an alle Peers, Zerts landen in `/etc/edgeguard/tls/`.
@@ -384,14 +401,12 @@ Komponentenbibliothek, Theme, Layouts, Navigations-Struktur, Form-Patterns, i18n
## 14. Plattform-Matrix
| Distribution | Codename | Arch | Status v1 |
| Distribution | Codename | Arch | Status |
|---|---|---|---|
| Debian 13 | trixie | amd64 | Tier 1 |
| Debian 13 | trixie | arm64 | Tier 1 |
| Ubuntu 24.04 LTS | noble | amd64 | Tier 1 |
| Ubuntu 24.04 LTS | noble | arm64 | Tier 1 |
Andere Distributionen (Debian 12, Ubuntu 22.04, RHEL/Rocky) sind **nicht unterstützt**. Installer bricht hart ab.
**Nur Debian 13 (Trixie).** Die Build-/Publish-Pipeline (`Makefile`, `scripts/apt-repo/`) zielt ausschließlich auf `trixie`; der Installer bricht auf anderem OS hart ab. _(Eine ursprünglich geplante Ubuntu-24.04-„noble"-Matrix ist nicht implementiert.)_ Andere Distributionen (Debian 12, Ubuntu, RHEL/Rocky) sind **nicht unterstützt**.
---
@@ -402,7 +417,7 @@ EdgeGuard-Native ist eigenes Repo (`git.netcell-it.de/projekte/edgeguard-native`
1. **Frische Installation** auf Test-VM via `install.sh`.
2. **Config-Export** aus altem Stack (`edgeguard-ctl export --from-docker`) — liest aus alter PG, schreibt in neues Format.
3. **Validierung** Side-by-Side (alter Stack auf einem Server, neuer Stack auf anderem, Traffic vergleichen).
4. **Cutover** via Floating-IP-Switch.
4. **Cutover** via VIP-Umzug (keepalived) bzw. DNS-Umstellung.
Der alte `proxy-lb-waf`-Code bleibt für Bestandskunden im Wartungsmodus, keine neuen Features.
@@ -410,6 +425,6 @@ Der alte `proxy-lb-waf`-Code bleibt für Bestandskunden im Wartungsmodus, keine
## Offene Punkte
- **Hoster + Floating-IP-API** (§9): Spec dokumentieren.
- **WireGuard-State-Replication** in der Praxis testen (Handshake-Latenz nach Floating-IP-Switch messen).
- **Failover-Drill:** `edgeguard-ctl promote` (Logical-aware) + anschließendes `cluster-setup-standby` in einem echten 2-Node-Failover durchspielen (inkl. VIP-Umzug, WireGuard-Handshake-Latenz). _(Code-Altlasten `internal/proxy`-Stub und `promote.go`-`standby.signal` wurden 2026-06 bereinigt.)_
- **Optional KeyDB** (Rate-Limit-Counter, Pub/Sub-Config-Reload) — falls je benötigt; aktuell ungenutzt.
- **`get.edgeguard.netcell-it.de`** anlegen oder Übergangs-URL auf `apt.netcell-it.de/edgeguard/install.sh` nutzen.

71
go.mod
View File

@@ -1,42 +1,41 @@
module git.netcell-it.de/projekte/edgeguard-native
go 1.26.0
go 1.27.1
require (
github.com/corazawaf/coraza/v3 v3.7.0
github.com/coreos/go-oidc/v3 v3.18.0
github.com/dropmorepackets/haproxy-go v0.0.8
github.com/coreos/go-oidc/v3 v3.21.0
github.com/dropmorepackets/haproxy-go v0.1.1
github.com/fsnotify/fsnotify v1.10.1
github.com/gin-gonic/gin v1.10.0
github.com/gin-gonic/gin v1.12.0
github.com/go-acme/lego/v4 v4.35.2
github.com/gorilla/websocket v1.5.3
github.com/jackc/pgx/v5 v5.9.2
github.com/minio/minio-go/v7 v7.1.0
github.com/pkg/sftp v1.13.10
github.com/jackc/pgx/v5 v5.11.0
github.com/minio/minio-go/v7 v7.3.0
github.com/pkg/sftp v1.13.11
github.com/pquerna/otp v1.5.0
github.com/pressly/goose/v3 v3.27.1
github.com/pressly/goose/v3 v3.28.0
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
golang.org/x/crypto v0.51.0
golang.org/x/oauth2 v0.36.0
golang.org/x/crypto v0.57.0
golang.org/x/oauth2 v0.37.0
)
require (
github.com/boombuler/barcode v1.0.1 // indirect
github.com/bytedance/sonic v1.11.6 // indirect
github.com/bytedance/sonic/loader v0.1.1 // indirect
github.com/bytedance/gopkg v0.1.3 // indirect
github.com/bytedance/sonic v1.15.0 // indirect
github.com/bytedance/sonic/loader v0.5.0 // indirect
github.com/cenkalti/backoff/v5 v5.0.3 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/cloudwego/base64x v0.1.4 // indirect
github.com/cloudwego/iasm v0.2.0 // indirect
github.com/cloudwego/base64x v0.1.6 // indirect
github.com/corazawaf/libinjection-go v0.3.2 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/gabriel-vasile/mimetype v1.4.13 // indirect
github.com/gin-contrib/sse v0.1.0 // indirect
github.com/go-ini/ini v1.67.0 // indirect
github.com/gin-contrib/sse v1.1.0 // indirect
github.com/go-jose/go-jose/v4 v4.1.4 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.23.0 // indirect
github.com/go-playground/validator/v10 v10.30.1 // indirect
github.com/goccy/go-json v0.10.5 // indirect
github.com/goccy/go-yaml v1.19.2 // indirect
github.com/google/uuid v1.6.0 // indirect
@@ -48,43 +47,45 @@ require (
github.com/json-iterator/go v1.1.13-0.20220915233716-71ac16282d12 // indirect
github.com/kaptinlin/go-i18n v0.1.4 // indirect
github.com/kaptinlin/jsonschema v0.4.6 // indirect
github.com/klauspost/compress v1.18.5 // indirect
github.com/klauspost/cpuid/v2 v2.2.11 // indirect
github.com/klauspost/compress v1.19.2 // indirect
github.com/klauspost/cpuid/v2 v2.4.0 // indirect
github.com/klauspost/crc32 v1.3.0 // indirect
github.com/kr/fs v0.1.0 // indirect
github.com/kr/pretty v0.3.1 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/magefile/mage v1.17.0 // indirect
github.com/mattn/go-isatty v0.0.21 // indirect
github.com/mattn/go-isatty v0.0.24 // indirect
github.com/mfridman/interpolate v0.0.2 // indirect
github.com/miekg/dns v1.1.72 // indirect
github.com/minio/crc64nvme v1.1.1 // indirect
github.com/minio/md5-simd v1.1.2 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/pelletier/go-toml/v2 v2.3.1 // indirect
github.com/petar-dambovaliev/aho-corasick v0.0.0-20250424160509-463d218d4745 // indirect
github.com/philhofer/fwd v1.2.0 // indirect
github.com/quic-go/qpack v0.6.0 // indirect
github.com/quic-go/quic-go v0.59.1 // indirect
github.com/rs/xid v1.6.0 // indirect
github.com/sethvargo/go-retry v0.3.0 // indirect
github.com/sethvargo/go-retry v0.4.0 // indirect
github.com/tidwall/gjson v1.18.0 // indirect
github.com/tidwall/match v1.1.1 // indirect
github.com/tidwall/pretty v1.2.1 // indirect
github.com/tinylib/msgp v1.6.1 // indirect
github.com/tinylib/msgp v1.6.4 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.12 // indirect
github.com/ugorji/go/codec v1.3.1 // indirect
github.com/valllabh/ocsf-schema-golang v1.0.3 // indirect
github.com/zeebo/xxh3 v1.1.0 // indirect
go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
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.53.0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.44.0 // indirect
golang.org/x/text v0.37.0 // indirect
golang.org/x/tools v0.44.0 // indirect
google.golang.org/protobuf v1.36.11 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
go.yaml.in/yaml/v3 v3.0.5 // indirect
golang.org/x/arch v0.22.0 // indirect
golang.org/x/mod v0.41.0 // indirect
golang.org/x/net v0.58.0 // indirect
golang.org/x/sync v0.23.0 // indirect
golang.org/x/sys v0.48.0 // indirect
golang.org/x/text v0.42.0 // indirect
golang.org/x/tools v0.49.0 // indirect
google.golang.org/protobuf v1.36.12 // indirect
gopkg.in/ini.v1 v1.67.3 // indirect
rsc.io/binaryregexp v0.2.0 // indirect
)

176
go.sum
View File

@@ -1,33 +1,32 @@
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
github.com/boombuler/barcode v1.0.1 h1:NDBbPmhS+EqABEs5Kg3n/5ZNjy73Pz7SIV+KCeqyXcs=
github.com/boombuler/barcode v1.0.1/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0=
github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4=
github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM=
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE=
github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k=
github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE=
github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM=
github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y=
github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg=
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
github.com/corazawaf/coraza-coreruleset v0.0.0-20240226094324-415b1017abdc h1:OlJhrgI3I+FLUCTI3JJW8MoqyM78WbqJjecqMnqG+wc=
github.com/corazawaf/coraza-coreruleset v0.0.0-20240226094324-415b1017abdc/go.mod h1:7rsocqNDkTCira5T0M7buoKR2ehh7YZiPkzxRuAgvVU=
github.com/corazawaf/coraza/v3 v3.7.0 h1:LIQqu1r+l6e/U/gyiZeykWaNNBY1TzRLz+aaI+QYEEM=
github.com/corazawaf/coraza/v3 v3.7.0/go.mod h1:dOSt5evqC7EstouEv6ghhui01+oVUwp9X1vybWwqTlo=
github.com/corazawaf/libinjection-go v0.3.2 h1:9rrKt0lpg4WvUXt+lwS06GywfqRXXsa/7JcOw5cQLwI=
github.com/corazawaf/libinjection-go v0.3.2/go.mod h1:Ik/+w3UmTWH9yn366RgS9D95K3y7Atb5m/H/gXzzPCk=
github.com/coreos/go-oidc/v3 v3.18.0 h1:V9orjXynvu5wiC9SemFTWnG4F45v403aIcjWo0d41+A=
github.com/coreos/go-oidc/v3 v3.18.0/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26KVTCI9SrY4=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/coreos/go-oidc/v3 v3.21.0 h1:wZo4Q9Pum8dYEj0eMUPrqR+kvuGkeUplbLpNCkBqoWM=
github.com/coreos/go-oidc/v3 v3.21.0/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26KVTCI9SrY4=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dropmorepackets/haproxy-go v0.0.8 h1:kS2Wa8+ZDcnJdRSTiuBsaPun5hpdUPIuLQ+Drp9ZxYs=
github.com/dropmorepackets/haproxy-go v0.0.8/go.mod h1:4a2AmmVjvg2zPNdizGZrMN8ZSUpj90U43VlcdbOIBnU=
github.com/dropmorepackets/haproxy-go v0.1.1 h1:qYovzYpGHanQCBQW5k92uJqIdjcwSJQHZ0VFRiI3FJ0=
github.com/dropmorepackets/haproxy-go v0.1.1/go.mod h1:4a2AmmVjvg2zPNdizGZrMN8ZSUpj90U43VlcdbOIBnU=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/foxcpp/go-mockdns v1.1.0 h1:jI0rD8M0wuYAxL7r/ynTrCQQq0BVqfB99Vgk7DlmewI=
@@ -36,14 +35,12 @@ github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx5
github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo=
github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM=
github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU=
github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8=
github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc=
github.com/go-acme/lego/v4 v4.35.2 h1:uVQg+KC/yj9R2g7Q9W5wDqhvQvxV5SMu5eqFVoN5xZU=
github.com/go-acme/lego/v4 v4.35.2/go.mod h1:pX2jN5n8OphMGY1IaMjYm5DAEzguBaKRt8AvJAgJXpc=
github.com/go-ini/ini v1.67.0 h1:z6ZrTEZqSWOTyH2FlglNbNgARyHG8oLW9gMELqKr06A=
github.com/go-ini/ini v1.67.0/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8=
github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA=
github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
@@ -52,8 +49,8 @@ github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/o
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.23.0 h1:/PwmTwZhS0dPkav3cdK9kV1FsAmrL8sThn8IHr/sO+o=
github.com/go-playground/validator/v10 v10.23.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w=
github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM=
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
@@ -73,8 +70,8 @@ github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsI
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.9.2 h1:3ZhOzMWnR4yJ+RW1XImIPsD1aNSz4T4fyP7zlQb56hw=
github.com/jackc/pgx/v5 v5.9.2/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
github.com/jackc/pgx/v5 v5.11.0 h1:IzBBtyK9AHqf98cctWFifYSci2hgQR/cd56wB4p+ogg=
github.com/jackc/pgx/v5 v5.11.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/jcchavezs/mergefs v0.1.1 h1:D45R17m6dHnSVZefnhynoeZvcK2Uw0oTrRfoUOQ0S5Y=
@@ -85,27 +82,21 @@ github.com/kaptinlin/go-i18n v0.1.4 h1:wCiwAn1LOcvymvWIVAM4m5dUAMiHunTdEubLDk4hT
github.com/kaptinlin/go-i18n v0.1.4/go.mod h1:g1fn1GvTgT4CiLE8/fFE1hboHWJ6erivrDpiDtCcFKg=
github.com/kaptinlin/jsonschema v0.4.6 h1:vOSFg5tjmfkOdKg+D6Oo4fVOM/pActWu/ntkPsI1T64=
github.com/kaptinlin/jsonschema v0.4.6/go.mod h1:1DUd7r5SdyB2ZnMtyB7uLv64dE3zTFTiYytDCd+AEL0=
github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE=
github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/klauspost/compress v1.19.2 h1:hMRETovs/pu/dVWN7zIT1PGG8t509MwT6bO7XSi26R8=
github.com/klauspost/compress v1.19.2/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/klauspost/cpuid/v2 v2.0.1/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.2.11 h1:0OwqZRYI2rFrjS4kvkDnqJkKHdHaRnCm68/DY4OxRzU=
github.com/klauspost/cpuid/v2 v2.2.11/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/klauspost/cpuid/v2 v2.4.0 h1:S6Hrbc7+ywsr0r+RLapfGBHfyefhCTwEh3A0tV913Dw=
github.com/klauspost/cpuid/v2 v2.4.0/go.mod h1:19jmZ9mjzoF//ddRSUsv0zfBTJWh3QJh9FNxZTMrGxU=
github.com/klauspost/crc32 v1.3.0 h1:sSmTt3gUt81RP655XGZPElI0PelVTZ6YwCRnPSupoFM=
github.com/klauspost/crc32 v1.3.0/go.mod h1:D7kQaZhnkX/Y0tstFGf8VUzv2UofNGqCjnC3zdHB0Hw=
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
github.com/kr/fs v0.1.0 h1:Jskdu9ieNAYnjxsi0LbQp1ulIKZV1LAFgK1tWhpZgl8=
github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/magefile/mage v1.17.0 h1:dS4tkq997Ism03akafC8509iqDjeE7TNTexI25Y7sXM=
github.com/magefile/mage v1.17.0/go.mod h1:Yj51kqllmsgFpvvSzgrZPK9WtluG3kUhFaBUVLo4feA=
github.com/mattn/go-isatty v0.0.21 h1:xYae+lCNBP7QuW4PUnNG61ffM4hVIfm+zUzDuSzYLGs=
github.com/mattn/go-isatty v0.0.21/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY=
github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg=
github.com/miekg/dns v1.1.72 h1:vhmr+TF2A3tuoGNkLDFK9zi36F2LS+hKTRW0Uf8kbzI=
@@ -114,8 +105,8 @@ github.com/minio/crc64nvme v1.1.1 h1:8dwx/Pz49suywbO+auHCBpCtlW1OfpcLN7wYgVR6wAI
github.com/minio/crc64nvme v1.1.1/go.mod h1:eVfm2fAzLlxMdUGc0EEBGSMmPwmXD5XiNRpnu9J3bvg=
github.com/minio/md5-simd v1.1.2 h1:Gdi1DZK69+ZVMoNHRXJyNcxrMA4dSxoYHZSQbirFg34=
github.com/minio/md5-simd v1.1.2/go.mod h1:MzdKDxYpY2BT9XQFocsiZf/NKVtR7nkE4RoEpN+20RM=
github.com/minio/minio-go/v7 v7.1.0 h1:QEt5IStDpxgGjEdtOgpiZ5QhmSl3ax7qy61vi2SwHO8=
github.com/minio/minio-go/v7 v7.1.0/go.mod h1:Dm7WS1AgLmBa0NcQD6SeJnJf+K/EUW3GR7Ks6olB3OA=
github.com/minio/minio-go/v7 v7.3.0 h1:HM4pFCSQq/TK+j0/zmorSh5ddh81iDgRgU0BG0Vz/YU=
github.com/minio/minio-go/v7 v7.3.0/go.mod h1:KUPWdecEO1LWyUz+sTGXAuf2jZHrPh5fCsRH86QbPfk=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
@@ -124,42 +115,44 @@ github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFd
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pelletier/go-toml/v2 v2.3.1 h1:MYEvvGnQjeNkRF1qUuGolNtNExTDwct51yp7olPtrEc=
github.com/pelletier/go-toml/v2 v2.3.1/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/petar-dambovaliev/aho-corasick v0.0.0-20250424160509-463d218d4745 h1:Vpr4VgAizEgEZsaMohpw6JYDP+i9Of9dmdY4ufNP6HI=
github.com/petar-dambovaliev/aho-corasick v0.0.0-20250424160509-463d218d4745/go.mod h1:EHPiTAKtiFmrMldLUNswFwfZ2eJIYBHktdaUTZxYWRw=
github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM=
github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
github.com/pkg/sftp v1.13.10 h1:+5FbKNTe5Z9aspU88DPIKJ9z2KZoaGCu6Sr6kKR/5mU=
github.com/pkg/sftp v1.13.10/go.mod h1:bJ1a7uDhrX/4OII+agvy28lzRvQrmIQuaHrcI1HbeGA=
github.com/pkg/sftp v1.13.11 h1:0N92SLTB8JqASJB14ZLHHzFnBV8mG9zw4K7jghEFWuE=
github.com/pkg/sftp v1.13.11/go.mod h1:uNkH9roSXglNJqM+glJJi+TQXQUm0fXFWqCFmT8hsN0=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pquerna/otp v1.5.0 h1:NMMR+WrmaqXU4EzdGJEE1aUUI0AMRzsp96fFFWNPwxs=
github.com/pquerna/otp v1.5.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg=
github.com/pressly/goose/v3 v3.27.1 h1:6uEvcprBybDmW4hcz3gYujhARhye+GoWKhEWyzD5sh4=
github.com/pressly/goose/v3 v3.27.1/go.mod h1:maruOxsPnIG2yHHyo8UqKWXYKFcH7Q76csUV7+7KYoM=
github.com/pressly/goose/v3 v3.28.0 h1:D2M+iL31GmpZxSHOhX8mqyqAT3CXnokUmm0eKoSP+Vc=
github.com/pressly/goose/v3 v3.28.0/go.mod h1:v26MOuB8bL3kzzrt3Vqhb3R0PRVsl8hFQKdrht/L6Rk=
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
github.com/quic-go/quic-go v0.59.1 h1:0Gmua0HW1Tv7ANR7hUYwRyD0MG5OJfgvYSZasGZzBic=
github.com/quic-go/quic-go v0.59.1/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU=
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
github.com/sethvargo/go-retry v0.3.0 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah2SE=
github.com/sethvargo/go-retry v0.3.0/go.mod h1:mNX17F0C/HguQMyMyJxcnU471gOZGxCLyYaFyAZraas=
github.com/sethvargo/go-retry v0.4.0 h1:9qy1OoIAxBL+gBYnkTnTnWle5wlfsXQlwRzIbbpdqPw=
github.com/sethvargo/go-retry v0.4.0/go.mod h1:tvsjdKG6xfiCx4LSiUZ06kcv38xvdVQwv8R6/VnnVWg=
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0=
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE=
github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg=
github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
@@ -167,60 +160,61 @@ github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JT
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/tinylib/msgp v1.6.1 h1:ESRv8eL3u+DNHUoSAAQRE50Hm162zqAnBoGv9PzScPY=
github.com/tinylib/msgp v1.6.1/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA=
github.com/tinylib/msgp v1.6.4 h1:mOwYbyYDLPj35mkA2BjjYejgJk9BuHxDdvRnb6v2ZcQ=
github.com/tinylib/msgp v1.6.4/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
github.com/valllabh/ocsf-schema-golang v1.0.3 h1:eR8k/3jP/OOqB8LRCtdJ4U+vlgd/gk5y3KMXoodrsrw=
github.com/valllabh/ocsf-schema-golang v1.0.3/go.mod h1:sZ3as9xqm1SSK5feFWIR2CuGeGRhsM7TR1MbpBctzPk=
github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ=
github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0=
github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs=
github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s=
go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE=
go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
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.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI=
golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8=
golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM=
golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU=
golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs=
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4=
golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk=
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c=
golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI=
golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M=
golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA=
golang.org/x/mod v0.41.0 h1:qJmnOUb4YB+FsEuM3HcWucdZASCPGhsX6uljO6pog0c=
golang.org/x/mod v0.41.0/go.mod h1:Ek9pY8RKWXwsWvd3rQiHYtMqkjSUV+s1Rj7j4H5Ur6o=
golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To=
golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU=
golang.org/x/oauth2 v0.37.0 h1:JUlcxA8oAtauLfiH8FX2/FkAWHAdi0QtGCGc+hofE98=
golang.org/x/oauth2 v0.37.0/go.mod h1:IxwZNxUULJmpBFf9K/9NTMSIfZZuvuTy1gGxhigP/58=
golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk=
golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
golang.org/x/term v0.46.0 h1:3+OXuTbaKDgwk8jTi3aSLHRlmWqHEUDUtxnbFigO4YE=
golang.org/x/term v0.46.0/go.mod h1:+K02xbkittuwc0Am4abfA3Fc+XRGXkvBXNO88NCXPoc=
golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI=
golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E=
golang.org/x/tools v0.49.0 h1:3NI7VXzL9+1WZD52Dx2ttoPwD5DWrFGpl9mFZDlmisI=
golang.org/x/tools v0.49.0/go.mod h1:SJNXV9DBKT0UbdttsQjbfJlAE/q+y36++zo3uL3N0Oo=
google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc=
google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/ini.v1 v1.67.3 h1:iM9Lhz5MRSGhHVGGwCuzG9KO8PoirCXj/m/qTmOJJQw=
gopkg.in/ini.v1 v1.67.3/go.mod h1:x/cyOwCgZqOkJoDIJ3c1KNHMo10+nLGAhh+kn3Zizss=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
modernc.org/libc v1.72.1 h1:db1xwJ6u1kE3KHTFTTbe2GCrczHPKzlURP0aDC4NGD0=
modernc.org/libc v1.72.1/go.mod h1:HRMiC/PhPGLIPM7GzAFCbI+oSgE3dhZ8FWftmRrHVlY=
modernc.org/libc v1.75.6 h1:yKk8qo+Di4gkmvRboK8ocCqH22FiUCR6jRy2OwtCRus=
modernc.org/libc v1.75.6/go.mod h1:bO5o2ztHxBb2rjz0PgdHN0sSMw57CgxGFLZ3Qd/QpVQ=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/sqlite v1.49.1 h1:dYGHTKcX1sJ+EQDnUzvz4TJ5GbuvhNJa8Fg6ElGx73U=
modernc.org/sqlite v1.49.1/go.mod h1:m0w8xhwYUVY3H6pSDwc3gkJ/irZT/0YEXwBlhaxQEew=
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
modernc.org/memory v1.12.1 h1:nFMiWrpStgZczNl6XI9GnIk/rWhYIyHGUaR04pGbp9g=
modernc.org/memory v1.12.1/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/sqlite v1.57.0 h1:qNQP6xnx5M0ISNtlnxoOX0+cD5bJ0/gr9aMmndFczzg=
modernc.org/sqlite v1.57.0/go.mod h1:yCJ2cmAaIkHQ25oXWrF8H4O1lIfPYPR26yCEDj2P3pQ=
rsc.io/binaryregexp v0.2.0 h1:HfqmD5MEmC0zvwBuF187nq9mdnXjXsSivRiXN7SmRkE=
rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8=
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=

View File

@@ -143,7 +143,7 @@ func (a *Aggregator) callPeer(ctx context.Context, p models.HANode, path string)
res.Duration = time.Since(start).Milliseconds()
return res
}
defer resp.Body.Close()
defer func() { _ = resp.Body.Close() }()
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) // 1 MiB cap
if resp.StatusCode != http.StatusOK {
res.Err = fmt.Sprintf("HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
@@ -217,7 +217,7 @@ func (a *Aggregator) PostPeer(ctx context.Context, p models.HANode, path string)
res.Duration = time.Since(start).Milliseconds()
return res
}
defer resp.Body.Close()
defer func() { _ = resp.Body.Close() }()
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusAccepted {
res.Err = fmt.Sprintf("HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
@@ -253,7 +253,7 @@ func (a *Aggregator) PostPeerWithBody(ctx context.Context, p models.HANode, path
res.Duration = time.Since(start).Milliseconds()
return res
}
defer resp.Body.Close()
defer func() { _ = resp.Body.Close() }()
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusAccepted && resp.StatusCode != http.StatusNoContent {
res.Err = fmt.Sprintf("HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(respBody)))

View File

@@ -13,14 +13,16 @@
{{end}}
# ── Listen-Bind ────────────────────────────────────────────────
# Wenn nichts ausser localhost gebound ist, lassen wir bindaddress
# weg (chrony default = alle Interfaces). Sonst explizite bindaddress
# pro IP. Mit serve_clients=false wird port 0 → kein Listen-Socket
# (= reiner Client).
# KEIN bindaddress: chrony honoriert nur EINE bindaddress pro Adress-
# familie — bei mehreren Listen-IPs (z. B. mehrere VLAN-/Cluster-VIPs)
# würde nur die letzte gebunden, alle anderen NTP-Clients liefen ins
# Leere. Stattdessen lauscht chrony auf allen Interfaces; WER bedient
# wird, regeln die allow-ACL UNTEN + die nftables-Regeln (UDP/123 wird
# nur auf den konfigurierten Listen-IPs/VIPs geöffnet, nicht öffentlich).
# Bonus: failover-robust — chrony bedient automatisch jede VIP, die der
# Node gerade hält, ohne Restart bei Master-Wechsel.
# serve_clients=false → port 0 → kein Listen-Socket (reiner Client).
{{if .Settings.ServeClients}}
{{- range .ListenAddresses}}
bindaddress {{.}}
{{- end}}
{{- range .AllowACLs}}
allow {{.}}
{{- end}}

View File

@@ -121,7 +121,7 @@ func splitCSV(s string) []string {
// filterNonLoopback wirft 127.x / ::1 raus — wenn NUR localhost im
// listen_addresses ist, lassen wir den bindaddress-Block weg und
// chrony bindet auf alle Interfaces (default), was für eine reine
// Client-Konfiguration nicht stört.
// Client-Configuration nicht stört.
func filterNonLoopback(in []string) []string {
out := []string{}
for _, ip := range in {

View File

@@ -0,0 +1,53 @@
package chrony
import (
"bytes"
"strings"
"testing"
"git.netcell-it.de/projekte/edgeguard-native/internal/models"
)
func render(t *testing.T, v View) string {
t.Helper()
var b bytes.Buffer
if err := tpl.Execute(&b, v); err != nil {
t.Fatalf("execute: %v", err)
}
return b.String()
}
// Mehrere Listen-IPs (VLAN-/Cluster-VIPs): chrony honoriert nur EINE
// bindaddress pro Adressfamilie → wir dürfen GAR KEINE bindaddress emittieren,
// sondern bind-all + allow-ACL. Sonst würde nur eine VIP gebunden und alle
// anderen NTP-Clients liefen ins Leere (Regressions-Schutz).
func TestRender_NoBindaddress_ServesAllVIPs(t *testing.T) {
v := View{
Settings: &models.NTPSettings{ServeClients: true, MakestepSecs: 1, MakestepLimit: 3},
AllowACLs: []string{"10.0.0.0/8", "192.168.0.0/16"},
ListenAddresses: []string{"10.0.5.1", "10.0.20.1", "10.10.20.1", "10.0.50.1"},
}
out := render(t, v)
// Auf die DIREKTIVE prüfen (Zeilenanfang), nicht aufs Wort — der
// erklärende Kommentar im Template enthält „bindaddress" absichtlich.
if strings.Contains(out, "\nbindaddress ") {
t.Fatalf("chrony darf KEIN bindaddress emittieren (nur eine pro Familie wird gebunden):\n%s", out)
}
for _, acl := range []string{"allow 10.0.0.0/8", "allow 192.168.0.0/16"} {
if !strings.Contains(out, acl) {
t.Fatalf("erwarte %q im Output:\n%s", acl, out)
}
}
}
// serve_clients=false → reiner Client: port 0, kein Listen-Socket, kein allow.
func TestRender_NoServeClients_Port0(t *testing.T) {
v := View{Settings: &models.NTPSettings{ServeClients: false, MakestepSecs: 1, MakestepLimit: 3}}
out := render(t, v)
if !strings.Contains(out, "port 0") {
t.Fatalf("erwarte 'port 0' bei serve_clients=false:\n%s", out)
}
if strings.Contains(out, "\nallow ") {
t.Fatalf("kein allow bei serve_clients=false:\n%s", out)
}
}

View File

@@ -2,18 +2,19 @@
// für Node-to-Node mTLS-Kommunikation.
//
// Layout on disk:
// /var/lib/edgeguard/cluster-tls/ca.crt (0644)
// /var/lib/edgeguard/cluster-tls/ca.key (0600, edgeguard:edgeguard)
// /var/lib/edgeguard/cluster-tls/peer.crt (0644) — diese Node
// /var/lib/edgeguard/cluster-tls/peer.key (0600, edgeguard:edgeguard)
//
// /var/lib/edgeguard/cluster-tls/ca.crt (0644)
// /var/lib/edgeguard/cluster-tls/ca.key (0600, edgeguard:edgeguard)
// /var/lib/edgeguard/cluster-tls/peer.crt (0644) — diese Node
// /var/lib/edgeguard/cluster-tls/peer.key (0600, edgeguard:edgeguard)
//
// Workflow:
// * Erste Node (cluster founder): InitCA generiert CA, dann EnsureSelfSigned
// - Erste Node (cluster founder): InitCA generiert CA, dann EnsureSelfSigned
// erstellt + signiert ihren eigenen peer.crt mit eigener CA.
// * Joining Node: lädt CA-Cert vom Primary, generiert lokal CSR, POSTet
// - Joining Node: lädt CA-Cert vom Primary, generiert lokal CSR, POSTet
// ihn mit cluster-join-token zu /api/v1/cluster/issue-cert; Primary
// signiert via SignCSR und liefert peer.crt zurück. (Phase 3.4.)
// * Single-Node: InitCA + EnsureSelfSigned werden beim API-Boot
// - Single-Node: InitCA + EnsureSelfSigned werden beim API-Boot
// idempotent gerufen; Listener auf :8443 kann sofort hochfahren.
//
// Pattern 1:1 aus mail-gateway/internal/services/clustertls/clustertls.go,
@@ -73,8 +74,8 @@ func (s *Store) HasPeer() bool {
}
// InitCA generiert die Cluster-CA falls noch keine existiert. Idempotent.
// organisation landet im Subject — typischerweise die FQDN-Domain.
func (s *Store) InitCA(organisation string, now func() time.Time) error {
// organization landet im Subject — typischerweise die FQDN-Domain.
func (s *Store) InitCA(organization string, now func() time.Time) error {
if s.HasCA() {
return nil
}
@@ -93,7 +94,7 @@ func (s *Store) InitCA(organisation string, now func() time.Time) error {
SerialNumber: serial,
Subject: pkix.Name{
CommonName: "EdgeGuard Cluster CA",
Organization: []string{organisation},
Organization: []string{organization},
},
NotBefore: now().UTC(),
NotAfter: now().Add(caValidity).UTC(),
@@ -325,12 +326,12 @@ func (s *Store) CACertPEM() (string, error) {
// CertInfo: zusammengefasste Cert-Metadata für UI-Status. days_remaining
// kann negativ sein wenn der Cert schon abgelaufen ist.
type CertInfo struct {
CommonName string `json:"common_name"`
NotBefore time.Time `json:"not_before"`
NotAfter time.Time `json:"not_after"`
DaysRemaining int `json:"days_remaining"`
IsCA bool `json:"is_ca"`
SerialHex string `json:"serial_hex"`
CommonName string `json:"common_name"`
NotBefore time.Time `json:"not_before"`
NotAfter time.Time `json:"not_after"`
DaysRemaining int `json:"days_remaining"`
IsCA bool `json:"is_ca"`
SerialHex string `json:"serial_hex"`
}
// PeerCertInfo liefert die Metadata des eigenen peer.crt. Wenn keiner

View File

@@ -1,7 +1,7 @@
package cluster
// /etc/edgeguard/node.conf — node-lokale, NIEMALS zwischen Cluster-
// Peers replizierte Konfiguration. Hält die Identitäts-Werte die jeden
// Peers replizierte Configuration. Hält die Identitäts-Werte die jeden
// Node einzigartig machen:
//
// NODE_ID eindeutige UUID (autogeneriert in EnsureNodeID; hier
@@ -54,7 +54,7 @@ func LoadLocalConfig(path string) (*LocalConfig, error) {
}
return nil, err
}
defer f.Close()
defer func() { _ = f.Close() }()
c := &LocalConfig{}
sc := bufio.NewScanner(f)
for sc.Scan() {
@@ -91,7 +91,7 @@ func LoadLocalConfig(path string) (*LocalConfig, error) {
}
// SaveLocalConfig schreibt die Datei atomic + 0644 root:root.
// Aufrufer ist normalerweise edgeguard-ctl unter Operator-Privilegien.
// Aufrufer ist normalerweise edgeguard-ctl unter Operator-Privilege.
func SaveLocalConfig(path string, c *LocalConfig) error {
if path == "" {
path = DefaultLocalConfigPath

View File

@@ -25,8 +25,15 @@ type Store struct {
func NewStore(pool *pgxpool.Pool) *Store { return &Store{Pool: pool} }
// baseSelect MUSS spaltenweise zu scanNode passen. pg_role fehlte hier
// urspruenglich (Befund 2026-09-11): HANode.PGRole kam dadurch ueberall als
// leerer String an, wo Store.List/Get benutzt wird — /cluster/nodes,
// /cluster/status und damit auch die pg_role-Spalte der Cluster-Seite.
// UpsertSelf schreibt pg_role bewusst NICHT (ON CONFLICT laesst die Spalte
// unangetastet), liest sie aber im RETURNING mit — sonst passt die
// Scan-Reihenfolge nicht.
const baseSelect = `
SELECT id, name, fqdn, api_url, public_ip, internal_ip, mgmt_ip, role,
SELECT id, name, fqdn, api_url, public_ip, internal_ip, mgmt_ip, role, pg_role,
version, config_hash, status,
last_seen, joined_at, created_at, updated_at
FROM ha_nodes
@@ -90,7 +97,7 @@ ON CONFLICT (id) DO UPDATE SET
last_seen = EXCLUDED.last_seen,
updated_at = NOW()
RETURNING id, name, fqdn, api_url, public_ip, internal_ip, mgmt_ip,
role, version, config_hash, status,
role, pg_role, version, config_hash, status,
last_seen, joined_at, created_at, updated_at`,
n.ID, n.Name, n.FQDN, n.APIURL,
n.PublicIP, n.InternalIP, n.MgmtIP,
@@ -181,7 +188,7 @@ func scanNode(row interface{ Scan(...any) error }) (*models.HANode, error) {
if err := row.Scan(
&n.ID, &n.Name, &n.FQDN, &n.APIURL,
&n.PublicIP, &n.InternalIP, &n.MgmtIP,
&n.Role, &n.Version, &n.ConfigHash, &n.Status,
&n.Role, &n.PGRole, &n.Version, &n.ConfigHash, &n.Status,
&n.LastSeen, &n.JoinedAt,
&n.CreatedAt, &n.UpdatedAt,
); err != nil {

View File

@@ -23,7 +23,7 @@ import (
//
// Name returns a stable identifier ("haproxy", "nftables", …)
// used in CLI output and audit logs. Render does the actual write +
// reload work; ctx may be cancelled (e.g. orchestrator timeout).
// reload work; ctx may be canceled (e.g. orchestrator timeout).
type Generator interface {
Name() string
Render(ctx context.Context) error
@@ -49,14 +49,14 @@ func AtomicWrite(path string, data []byte, mode os.FileMode) error {
return fmt.Errorf("tempfile: %w", err)
}
tmpPath := tmp.Name()
defer os.Remove(tmpPath) // no-op if rename succeeded
defer func() { _ = os.Remove(tmpPath) }() // no-op if rename succeeded
if _, err := tmp.Write(data); err != nil {
tmp.Close()
_ = tmp.Close()
return fmt.Errorf("write %s: %w", tmpPath, err)
}
if err := tmp.Sync(); err != nil {
tmp.Close()
_ = tmp.Close()
return fmt.Errorf("fsync %s: %w", tmpPath, err)
}
if err := tmp.Close(); err != nil {

View File

@@ -98,7 +98,7 @@ type HubItem struct {
Type string `json:"type,omitempty"`
}
// Status summarises the runtime state of the CrowdSec stack on this node.
// Status summarizes the runtime state of the CrowdSec stack on this node.
type Status struct {
Installed bool `json:"installed"`
AgentRunning bool `json:"agent_running"`
@@ -362,15 +362,7 @@ func Machines(ctx context.Context) ([]Machine, error) {
}
result := make([]Machine, len(raw))
for i, r := range raw {
result[i] = Machine{
MachineID: r.MachineID,
CreatedAt: r.CreatedAt,
UpdatedAt: r.UpdatedAt,
LastPush: r.LastPush,
IsValidated: r.IsValidated,
Version: r.Version,
Status: r.Status,
}
result[i] = Machine(r)
}
return result, nil
}

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

@@ -97,7 +97,7 @@ func Migrate(ctx context.Context, dsnOverride string) error {
if err != nil {
return fmt.Errorf("open db for migrate: %w", err)
}
defer db.Close()
defer func() { _ = db.Close() }()
goose.SetBaseFS(embeddedMigrations)
if err := goose.SetDialect("postgres"); err != nil {
@@ -117,7 +117,7 @@ func MigrateDown(ctx context.Context, dsnOverride string) error {
if err != nil {
return err
}
defer db.Close()
defer func() { _ = db.Close() }()
goose.SetBaseFS(embeddedMigrations)
if err := goose.SetDialect("postgres"); err != nil {
return err

View File

@@ -0,0 +1,8 @@
-- +goose Up
-- redirect_to: wenn gesetzt, liefert HAProxy für diese Domain einen 301 auf
-- die angegebene Ziel-URL (Domain-zu-Domain-Weiterleitung) statt sie auf ein
-- Backend zu routen. Leerstring = keine Weiterleitung (Normalbetrieb).
ALTER TABLE domains ADD COLUMN IF NOT EXISTS redirect_to text NOT NULL DEFAULT '';
-- +goose Down
ALTER TABLE domains DROP COLUMN IF EXISTS redirect_to;

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

@@ -8,7 +8,7 @@ import "testing"
// startup, the API restart-looped, the cluster rolling-upgrade hung.
//
// Cheap assertion that runs as part of `go test ./...` — fails the
// build before `make deb` ever produces an artefact, so the bad
// build before `make deb` ever produces an artifact, so the bad
// version never reaches the APT registry. Same logic also runs at
// service start via Migrate() and via `edgeguard-ctl migrate check`
// in postinst (defense in depth).

View File

@@ -8,7 +8,7 @@
// + groups, policy rules, nat rules, ha_nodes peer IPs.
// 2. Each rule and nat-rule is "resolved" — group references
// replaced with their primitive members, FQDNs left as comments
// (Phase-3 DNS-resolution sidecar will materialise them).
// (Phase-3 DNS-resolution sidecar will materialize them).
// 3. The template emits one nft file with: zone-iface sets, peer
// sets, default-deny baseline, forward + input chains carrying
// the resolved rules (priority-sorted), nat prerouting +
@@ -150,12 +150,12 @@ type AutoFWRule struct {
Proto string
Port int
DstIP string
L3 string // "ip"/"ip6" — gesetzt für DstIP-Rules (Familie); leer = agnostisch
L3 string // "ip"/"ip6" — gesetzt für DstIP-Rules (Familie); leer = agnostic
Iface string // optional: scope auf ein iifname (z.B. DHCP udp/67 nur auf LAN)
Comment string
}
// RuleLeg is one materialised nft policy line.
// RuleLeg is one materialized nft policy line.
type RuleLeg struct {
RuleID int64
Action string
@@ -166,7 +166,7 @@ type RuleLeg struct {
DstIfaces []string
SrcAddrs []string
DstAddrs []string
// L3 ist "ip" (IPv4) oder "ip6" (IPv6) für das Adress-Matching —
// L3 ist "ip" (IPv4) oder "ip6" (IPv6) für das Address-Matching —
// gesetzt, sobald SrcAddrs/DstAddrs nicht leer sind. Bei adresslosen
// Regeln bleibt es "" (familienagnostisch, kein ip/ip6-Match).
L3 string
@@ -177,14 +177,14 @@ type RuleLeg struct {
// template just emits one nft line per "leg" of the cross-product.
type ResolvedRule struct {
ID int64
Action string // accept | drop | reject
Action string // accept | drop | reject
Log bool
Name string
Priority int
SrcIfaces []string // empty = any
DstIfaces []string // empty = any
SrcAddrs []string // each is an nft expression like "1.2.3.4" or "10.0.0.0/24" or "{ 1.2.3.4, 5.6.7.8 }"
SrcIfaces []string // empty = any
DstIfaces []string // empty = any
SrcAddrs []string // each is an nft expression like "1.2.3.4" or "10.0.0.0/24" or "{ 1.2.3.4, 5.6.7.8 }"
DstAddrs []string
Services []ResolvedService // empty = any
Comment string
@@ -192,16 +192,16 @@ type ResolvedRule struct {
// ResolvedNATRule is one nat-rule joined with iface-sets.
type ResolvedNATRule struct {
ID int64
Kind string // dnat | snat | masquerade
Priority int
InIfaces []string
OutIfaces []string
Proto string // empty = any
SrcCIDR string
DstCIDR string
DPortStart, DPortEnd int
TargetAddr string
ID int64
Kind string // dnat | snat | masquerade
Priority int
InIfaces []string
OutIfaces []string
Proto string // empty = any
SrcCIDR string
DstCIDR string
DPortStart, DPortEnd int
TargetAddr string
TargetPortStart, TargetPortEnd int
// L3 ist "ip" oder "ip6" — Adressfamilie der Regel (aus SrcCIDR/
// DstCIDR/TargetAddr abgeleitet). TargetHost ist TargetAddr, bei
@@ -209,14 +209,14 @@ type ResolvedNATRule struct {
// nft-dnat-Syntax.
L3 string
TargetHost string
Comment string
Comment string
}
// ResolvedService is one nft (proto, dport-spec) tuple.
type ResolvedService struct {
Proto string // tcp|udp|icmp|icmpv6
PortStart int // 0 = no port match
PortEnd int
Proto string // tcp|udp|icmp|icmpv6
PortStart int // 0 = no port match
PortEnd int
}
func (g *Generator) loadView(ctx context.Context) (*View, error) {
@@ -269,6 +269,31 @@ func (g *Generator) loadView(ctx context.Context) (*View, error) {
}
peerRows.Close()
// ── Heartbeat-IPs aus cluster_settings ins Peer-Set ──
// Der VRRP-Heartbeat (VI_HB) läuft über hb_src_ip/hb_peer_ip (z.B.
// 169.254.0.1/.2) — diese stehen NICHT in ha_nodes. Ohne sie würde die
// VRRP-Accept-Regel den Heartbeat-Pfad nicht abdecken. Best-effort:
// fehlt cluster_settings (Single-Node), bleibt es bei den ha_nodes-IPs.
var hbSrc, hbPeer *string
if err := g.Pool.QueryRow(ctx,
`SELECT hb_src_ip, hb_peer_ip FROM cluster_settings WHERE id = 1`).
Scan(&hbSrc, &hbPeer); err == nil {
for _, ip := range []*string{hbSrc, hbPeer} {
if ip == nil {
continue
}
parsed := net.ParseIP(*ip)
if parsed == nil {
continue
}
if parsed.To4() != nil {
view.PeerIPv4 = append(view.PeerIPv4, parsed.String())
} else {
view.PeerIPv6 = append(view.PeerIPv6, parsed.String())
}
}
}
// ── Lade Address-Objects + Groups → ID → ResolvedAddr-list ──
addrObjs, err := g.loadAddrObjects(ctx)
if err != nil {
@@ -535,7 +560,7 @@ func splitByFamily(exprs []string) (v4, v6 []string) {
return v4, v6
}
// serviceL3: icmp ist v4-only, icmpv6 v6-only, tcp/udp/leer agnostisch.
// serviceL3: icmp ist v4-only, icmpv6 v6-only, tcp/udp/leer agnostic.
func serviceL3(svc ResolvedService) string {
switch svc.Proto {
case "icmp":
@@ -583,7 +608,7 @@ func expandFamilyLegs(r ResolvedRule, svc ResolvedService, hasSvc bool) []RuleLe
}
if len(r.SrcAddrs) == 0 && len(r.DstAddrs) == 0 {
// Kein Adress-Match → eine Zeile, L3 leer. Die Proto-Render-Logik
// Kein Address-Match → eine Zeile, L3 leer. Die Proto-Render-Logik
// im Template setzt icmp/icmpv6 selbst familienkorrekt.
return []RuleLeg{base}
}
@@ -777,15 +802,15 @@ ORDER BY priority DESC, id ASC`)
out := []ResolvedRule{}
for rows.Next() {
var (
id int64
name, action, com string
pr int
log bool
srcZone, dstZone string
srcObjID, srcGrpID *int64
dstObjID, dstGrpID *int64
srcCIDR, dstCIDR *string
svcObjID, svcGrpID *int64
id int64
name, action, com string
pr int
log bool
srcZone, dstZone string
srcObjID, srcGrpID *int64
dstObjID, dstGrpID *int64
srcCIDR, dstCIDR *string
svcObjID, svcGrpID *int64
)
if err := rows.Scan(
&id, &name, &pr, &action, &log, &com,
@@ -842,12 +867,12 @@ ORDER BY priority DESC, id ASC`)
out := []ResolvedNATRule{}
for rows.Next() {
var (
id int64
pr int
kind, com string
id int64
pr int
kind, com string
inZone, outZone, proto, srcCIDR, dstCIDR *string
dpStart, dpEnd, tpStart, tpEnd int
targetAddr string
dpStart, dpEnd, tpStart, tpEnd int
targetAddr string
)
if err := rows.Scan(
&id, &pr, &kind, &com,
@@ -861,7 +886,7 @@ ORDER BY priority DESC, id ASC`)
r := ResolvedNATRule{
ID: id, Kind: kind, Priority: pr, Comment: com,
DPortStart: dpStart, DPortEnd: dpEnd,
TargetAddr: targetAddr,
TargetAddr: targetAddr,
TargetPortStart: tpStart, TargetPortEnd: tpEnd,
}
if proto != nil {

View File

@@ -47,7 +47,7 @@ func TestTemplate_autoRuleIface(t *testing.T) {
t.Fatal(err)
}
_, _ = f.WriteString(out)
f.Close()
_ = f.Close()
var cmd *exec.Cmd
if os.Geteuid() == 0 {
cmd = exec.Command(nft, "-c", "-f", f.Name())

View File

@@ -125,7 +125,7 @@ func TestE2E_IPv6Render(t *testing.T) {
t.Fatal(err)
}
_, _ = f.WriteString(out)
f.Close()
_ = f.Close()
var cmd *exec.Cmd
if os.Geteuid() == 0 {
cmd = exec.Command(nft, "-c", "-f", f.Name())

View File

@@ -107,7 +107,7 @@ func renderView(t *testing.T) string {
Legs: []RuleLeg{
{RuleID: 1, Action: "accept", L3: "ip", SrcAddrs: []string{"10.0.0.0/24"}, Service: ResolvedService{Proto: "tcp", PortStart: 443}},
{RuleID: 1, Action: "accept", L3: "ip6", SrcAddrs: []string{"fd00::/64"}, Service: ResolvedService{Proto: "tcp", PortStart: 443}},
{RuleID: 2, Action: "accept", Service: ResolvedService{Proto: "icmpv6"}}, // adresslos, agnostisch
{RuleID: 2, Action: "accept", Service: ResolvedService{Proto: "icmpv6"}}, // adresslos, agnostic
},
NATRules: []ResolvedNATRule{
{ID: 5, Kind: "dnat", L3: "ip6", DstCIDR: "2001:db8::/64", Proto: "tcp", DPortStart: 80, TargetAddr: "fd00::2", TargetHost: "[fd00::2]", TargetPortStart: 8080},
@@ -126,11 +126,11 @@ func renderView(t *testing.T) string {
func TestTemplate_v6AndV4Render(t *testing.T) {
out := renderView(t)
mustContain := []string{
"ip saddr { 10.0.0.0/24 }", // v4-Regel unverändert
"ip6 saddr { fd00::/64 }", // v6-Regel
"ip6 daddr 2001:db8::/64", // v6-DNAT-Match
"dnat to [fd00::2]:8080", // v6-DNAT-Target geklammert
"dnat to 10.0.0.5:80", // v4-DNAT-Target unverändert
"ip saddr { 10.0.0.0/24 }", // v4-Regel unverändert
"ip6 saddr { fd00::/64 }", // v6-Regel
"ip6 daddr 2001:db8::/64", // v6-DNAT-Match
"dnat to [fd00::2]:8080", // v6-DNAT-Target geklammert
"dnat to 10.0.0.5:80", // v4-DNAT-Target unverändert
"ip6 saddr fd00::/64 snat to 2001:db8::99",
`oifname "wg7" ip6 saddr fd00:99::/64 masquerade`,
}
@@ -162,7 +162,7 @@ func TestTemplate_nftSyntax(t *testing.T) {
if _, err := f.WriteString(out); err != nil {
t.Fatal(err)
}
f.Close()
_ = f.Close()
// `nft -c` liest die Kernel-Ruleset-Cache via netlink → braucht root.
// Als nicht-root via sudo -n versuchen; klappt das nicht, skip statt fail
// (auf den Nodes rendert/prüft edgeguard ohnehin als root).

View File

@@ -55,6 +55,13 @@ table inet edgeguard {
tcp dport 5432 ip6 saddr @peer_ipv6 accept
tcp dport 6379 ip saddr @peer_ipv4 accept
tcp dport 6379 ip6 saddr @peer_ipv6 accept
# Cluster-internal: VRRP-Advertisements (keepalived VIP-Failover, Proto 112).
# OHNE diese Regel überleben Adverts nur via conntrack-Reverse-Matching —
# läuft ein conntrack-Eintrag ab/wird geflusht, werden Adverts gedroppt →
# der Peer promotet sich → VIP-Flapping/Split-Brain. peer_ipv4/6 enthält
# Public- UND Heartbeat-IPs (ha_nodes + cluster_settings.hb_*).
ip protocol vrrp ip saddr @peer_ipv4 accept
ip6 nexthdr vrrp ip6 saddr @peer_ipv6 accept
# ── Service-Auto-Rules (DNS/Squid/WG/...) ──
# Aus dem laufenden Service-State abgeleitet — Operator

View File

@@ -1,6 +1,7 @@
package handlers
import (
"context"
"net/http"
"net/http/httptest"
"os"
@@ -31,7 +32,7 @@ func TestACME_ServesExistingToken(t *testing.T) {
t.Fatal(err)
}
rec := httptest.NewRecorder()
req, _ := http.NewRequest(http.MethodGet, "/.well-known/acme-challenge/tok_42", nil)
req := httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/.well-known/acme-challenge/tok_42", nil)
r.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
@@ -45,7 +46,7 @@ func TestACME_ServesExistingToken(t *testing.T) {
func TestACME_MissingToken_Returns404(t *testing.T) {
r, _ := setupACME(t)
rec := httptest.NewRecorder()
req, _ := http.NewRequest(http.MethodGet, "/.well-known/acme-challenge/notthere", nil)
req := httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/.well-known/acme-challenge/notthere", nil)
r.ServeHTTP(rec, req)
if rec.Code != http.StatusNotFound {
t.Errorf("status: %d", rec.Code)
@@ -76,7 +77,7 @@ func TestACME_DirIsNotAFile(t *testing.T) {
t.Fatal(err)
}
rec := httptest.NewRecorder()
req, _ := http.NewRequest(http.MethodGet, "/.well-known/acme-challenge/subdir", nil)
req := httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/.well-known/acme-challenge/subdir", nil)
r.ServeHTTP(rec, req)
if rec.Code != http.StatusNotFound {
t.Errorf("expected 404 for directory, got %d", rec.Code)

View File

@@ -18,7 +18,11 @@ 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:[…]}
// POST /api/v1/alerts/events/delete-acknowledged — alle quittierten löschen
type AlertsHandler struct {
Service *alerts.Service
Audit *audit.Repo
@@ -37,6 +41,10 @@ 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)
g.POST("/events/delete-acknowledged", h.DeleteAcknowledgedEvents)
}
func (h *AlertsHandler) ListChannels(c *gin.Context) {
@@ -125,10 +133,79 @@ 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})
}
// DeleteAcknowledgedEvents löscht alle quittierten Events auf einmal.
// Gegenstück zu AcknowledgeAllEvents — ohne das kam man an einen
// groesseren Backlog nur seitenweise heran (die Kopf-Checkbox der
// Tabelle markiert nur die aktuelle Seite).
func (h *AlertsHandler) DeleteAcknowledgedEvents(c *gin.Context) {
n, err := h.Service.DeleteAcknowledged(c.Request.Context())
if err != nil {
response.Internal(c, err)
return
}
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "alert.events.delete_acknowledged",
"acknowledged", gin.H{"deleted": n}, h.NodeID)
response.OK(c, gin.H{"deleted": n})
}

View File

@@ -96,7 +96,7 @@ func (h *AuditHandler) Live(c *gin.Context) {
if err != nil {
return
}
defer conn.Close()
defer func() { _ = conn.Close() }()
// Snapshot
if rows, err := h.Repo.ListRecent(c.Request.Context(), 50); err == nil {

View File

@@ -504,7 +504,7 @@ func (h *AuthHandler) checkWithPrimary(ctx context.Context, primaryFQDN, email,
if err != nil {
return "", "", err
}
defer resp.Body.Close()
defer func() { _ = resp.Body.Close() }()
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 64*1024))
if resp.StatusCode != http.StatusOK {
return "", "", errors.New("primary: " + strings.TrimSpace(string(raw)))

View File

@@ -93,7 +93,7 @@ func (h *BackupRemotesHandler) Create(c *gin.Context) {
response.BadRequest(c, err)
return
}
if req.Settings == nil || len(req.Settings) == 0 {
if len(req.Settings) == 0 {
req.Settings = json.RawMessage(`{}`)
}
row := h.Pool.QueryRow(c.Request.Context(), `
@@ -124,7 +124,7 @@ func (h *BackupRemotesHandler) Update(c *gin.Context) {
response.BadRequest(c, err)
return
}
if req.Settings == nil || len(req.Settings) == 0 {
if len(req.Settings) == 0 {
req.Settings = json.RawMessage(`{}`)
}
// Wenn die Settings masked-Fields enthalten (***), übernehmen wir

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"
)
@@ -35,7 +36,7 @@ type ClusterHandler struct {
Store *cluster.Store
LocalID string
Aggregator *aggregator.Aggregator
Version string // laufende Binary-Version, für Rolling-Update-Koordination
Version string // laufende Binary-Version, für Rolling-Update-Coordination
// TLSStore + Tokens: optional, gesetzt bei Phase 3.4. Erlauben das
// Generieren von Join-Tokens und das Issue-Cert für joining Peers.
@@ -52,11 +53,19 @@ type ClusterHandler struct {
NodeID string
}
const (
// pgPublicationName + pgReplicationSecretPath spiegeln die Werte aus
// cmd/edgeguard-ctl (egPubName / egReplSecret) — beide Seiten muessen
// dasselbe meinen.
pgPublicationName = "edgeguard_shared"
pgReplicationSecretPath = "/var/lib/edgeguard/pg-replication-secret"
)
func NewClusterHandler(store *cluster.Store, localID string) *ClusterHandler {
return &ClusterHandler{Store: store, LocalID: localID}
}
// WithAggregator: optionale Aggregator-Konfiguration. Nur wenn vorhanden
// WithAggregator: optionale Aggregator-Configuration. Nur wenn vorhanden
// wird /cluster/system/load die Peers via mTLS abklappern.
func (h *ClusterHandler) WithAggregator(a *aggregator.Aggregator) *ClusterHandler {
h.Aggregator = a
@@ -92,6 +101,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)
@@ -104,7 +115,7 @@ func (h *ClusterHandler) Register(rg *gin.RouterGroup) {
// DeleteNode entfernt einen Peer aus ha_nodes. Verweigert für die
// lokale Node (LocalID) — die kannst du nicht via UI löschen, sonst
// kommt der nächste Heartbeat-Tick die Row wieder anlegen oder
// die Cluster-Page wird inkonsistent.
// die Cluster-Page wird inconsistent.
//
// Nach erfolgreichem Delete triggert der PeerReloader (falls gesetzt)
// einen Firewall-Render — peer_ipv4-Set verliert die IP, der entfernte
@@ -159,7 +170,7 @@ func (h *ClusterHandler) GetVIPSettings(c *gin.Context) {
response.OK(c, cs)
}
// UpdateVIPSettings speichert die VIP/VRRP-Konfiguration und triggert
// UpdateVIPSettings speichert die VIP/VRRP-Configuration und triggert
// einen Keepalived-Config-Render. Viewer-Schutz via RequireAdminForMutations-
// Middleware auf der authed-Group — kein Extra-Check nötig.
func (h *ClusterHandler) UpdateVIPSettings(c *gin.Context) {
@@ -247,6 +258,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)
@@ -279,8 +292,7 @@ func (h *ClusterHandler) AgentIdentity(c *gin.Context) {
// aus /var/lib/edgeguard/pg-replication-secret. Gibt 404 zurück wenn die
// Datei fehlt (cluster-init-replication noch nicht ausgeführt).
func (h *ClusterHandler) AgentPGReplicationInfo(c *gin.Context) {
const secretPath = "/var/lib/edgeguard/pg-replication-secret"
pass, err := readFileString(secretPath)
pass, err := readFileString(pgReplicationSecretPath)
if err != nil {
response.NotFound(c, simpleError("pg-replication-secret nicht gefunden — cluster-init-replication auf dem Primary ausführen"))
return
@@ -338,7 +350,7 @@ func (h *ClusterHandler) WithPeerReloader(r PeerReloader) *ClusterHandler {
return h
}
// WithVersion: setzt die laufende Binary-Version für Rolling-Update-Koordination.
// WithVersion: setzt die laufende Binary-Version für Rolling-Update-Coordination.
func (h *ClusterHandler) WithVersion(v string) *ClusterHandler {
h.Version = v
return h
@@ -359,7 +371,7 @@ type ClusterStatus struct {
LocalID string `json:"local_id"`
LocalNode *models.HANode `json:"local_node,omitempty"`
Peers []models.HANode `json:"peers"`
Mode string `json:"mode"` // "single-node" | "cluster"
Mode string `json:"mode"` // "single-node" | "cluster"
Health string `json:"health"` // "ok" | "degraded" | "split-brain"
DriftFound bool `json:"drift_found"`
UpdatedAt time.Time `json:"updated_at"`
@@ -513,6 +525,20 @@ func (h *ClusterHandler) GenerateJoinToken(c *gin.Context) {
// Body optional — wenn leer, läuft der Flow ohne Pre-Register.
_ = c.ShouldBindJSON(&req)
// Publisher-Seite sicherstellen, BEVOR ein Token rausgeht. Ein frisch
// installierter Single-Node hat weder Replikations-Rolle noch
// PUBLICATION noch wal_level=logical — der beitretende Node bekaeme
// beim CREATE SUBSCRIPTION nur ein 404 ("pg-replication-secret nicht
// gefunden") und stuende ohne replizierte Config da. Idempotent; der
// PG-Restart (nur beim allerersten Mal noetig, wal_level ist ein
// postmaster-Parameter) passiert hier bewusst, solange der Admin
// danebensteht und noch kein zweiter Node Traffic erwartet.
if err := h.ensureReplicationPublisher(c.Request.Context()); err != nil {
slog.Error("cluster: publisher setup before join-token failed", "error", err)
response.Internal(c, err)
return
}
token, exp, err := h.Tokens.Generate()
if err != nil {
response.Internal(c, err)
@@ -758,7 +784,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 +818,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
@@ -822,7 +976,7 @@ func (h *ClusterHandler) CertStatus(c *gin.Context) {
//
// Nach Renew muss edgeguard-api restartet werden damit der Agent-
// Listener das neue Cert in seinen TLS-Config-Snapshot lädt — wir
// triggern das NICHT automatisch (würde die HTTP-Response abreißen);
// triggering das NICHT automatisch (würde die HTTP-Response abreißen);
// stattdessen liefern wir einen Hinweis im Response.
func (h *ClusterHandler) RenewSelf(c *gin.Context) {
if !h.TLSStore.HasCA() {
@@ -857,16 +1011,16 @@ func (h *ClusterHandler) RenewSelf(c *gin.Context) {
// die wir wirklich brauchen — sonst kann ein joining Peer beliebige
// ha_nodes-Felder überschreiben.
type registerPeerRequest struct {
ID string `json:"id"` // Joiner's eigene node-id
Name string `json:"name"` // hostname
FQDN string `json:"fqdn"` // sollte mit Client-Cert-CN matchen
APIURL string `json:"api_url"` // https://<fqdn>
PublicIP string `json:"public_ip"` // optional
InternalIP string `json:"internal_ip"` // mTLS-Listener-IP (für peer_ipv4-Set)
MgmtIP string `json:"mgmt_ip"` // optional
ID string `json:"id"` // Joiner's eigene node-id
Name string `json:"name"` // hostname
FQDN string `json:"fqdn"` // sollte mit Client-Cert-CN matchen
APIURL string `json:"api_url"` // https://<fqdn>
PublicIP string `json:"public_ip"` // optional
InternalIP string `json:"internal_ip"` // mTLS-Listener-IP (für peer_ipv4-Set)
MgmtIP string `json:"mgmt_ip"` // optional
Version string `json:"version"`
ConfigHash *string `json:"config_hash"` // nil=absent (don't change), ""=no user config
Role string `json:"role"` // "" → "peer" (joining peer); "primary" beim Push des Primary
ConfigHash *string `json:"config_hash"` // nil=absent (don't change), ""=no user config
Role string `json:"role"` // "" → "peer" (joining peer); "primary" beim Push des Primary
}
// AgentRegisterPeer: vom Joiner nach issue-cert via mTLS aufgerufen.
@@ -925,6 +1079,14 @@ func (h *ClusterHandler) AgentRegisterPeer(c *gin.Context) {
if req.PublicIP != "" {
v := req.PublicIP
n.PublicIP = &v
} else if ip := c.ClientIP(); ip != "" {
// Der Push-Payload (autoRegister, Primary→Secondary) trägt KEINE
// public_ip → sonst bliebe sie NULL und der Peer fehlt im nft-
// peer_ipv4-Set → VRRP-Adverts nur via conntrack → Flapping. Der
// pushende Peer verbindet sich über mTLS von seiner EIGEN-IP (nicht
// der VIP — der Kernel nimmt die primäre Interface-IP als Source),
// genau wie preRegisterJoiner die Joiner-IP übernimmt. Selbstheilend.
n.PublicIP = &ip
}
if req.InternalIP != "" {
v := req.InternalIP
@@ -987,3 +1149,43 @@ func (h *ClusterHandler) AgentRegisterPeer(c *gin.Context) {
"client_cn", cn, "remote", c.ClientIP())
response.OK(c, out)
}
// ensureReplicationPublisher richtet die lokale PG-Instanz als Logical-
// Replication-Publisher ein (Rolle + Secret, wal_level=logical, pg_hba,
// Grants, PUBLICATION). Idempotent — auf einem bereits eingerichteten
// Primary ist es ein No-Op.
//
// Braucht root (psql als postgres, pg_hba schreiben, ggf. PG-Restart), die
// API laeuft als unprivilegierter `edgeguard` → Aufruf via sudo mit
// gepinnter Regel, wie bei den uebrigen privilegierten Operationen.
func (h *ClusterHandler) ensureReplicationPublisher(ctx context.Context) error {
// WICHTIG: nur ausfuehren wenn die Publisher-Seite noch NICHT steht.
// setupReplicationPrimary generiert bei JEDEM Lauf ein neues
// Replikations-Passwort (ALTER ROLE … PASSWORD). Auf einem Cluster mit
// bereits angebundenem Subscriber wuerde dessen gespeicherter
// Connection-String damit ungueltig und die Replikation bliebe still
// stehen — ein zweiter Token-Klick duerfte das niemals ausloesen.
// Das Passwort laesst sich nicht wiederverwenden (in PG nur gehasht),
// deshalb ist "schon eingerichtet" hier ein hartes Abbruchkriterium.
if h.Store != nil {
var hasPub bool
if err := h.Store.Pool.QueryRow(ctx,
`SELECT EXISTS(SELECT 1 FROM pg_publication WHERE pubname = $1)`,
pgPublicationName).Scan(&hasPub); err == nil && hasPub {
if _, err := os.Stat(pgReplicationSecretPath); err == nil {
slog.Info("cluster: replication publisher already set up — skipping init")
return nil
}
}
}
cmd := exec.Command("sudo", "-n", "/usr/bin/edgeguard-ctl", //nolint:noctx // System-Setup, darf nicht am Request-Context haengen
"cluster-init-replication")
out, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("cluster-init-replication: %w: %s",
err, strings.TrimSpace(string(out)))
}
slog.Info("cluster: replication publisher ensured")
return nil
}

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

@@ -102,7 +102,7 @@ func (h *ClusterHandler) RepairReplication(c *gin.Context) {
body, _ := json.Marshal(repairDispatchBody{PrimaryHost: primaryHost})
res := h.Aggregator.PostPeerWithBody(ctx, *peer, repairAgentPath, body)
if !res.OK {
response.Internal(c, fmt.Errorf("Resync auf %s anstoßen: %s", peer.FQDN, res.Err))
response.Internal(c, fmt.Errorf("resync auf %s anstoßen: %s", peer.FQDN, res.Err))
return
}
slog.Info("cluster: replication repair delegated", "target", peer.FQDN, "primary_host", primaryHost)
@@ -178,7 +178,7 @@ func (h *ClusterHandler) startResync(ctx context.Context, primaryHost string) er
return errors.New("dieser Node ist der Publication-Primary — Resync läuft nur auf einem Subscriber")
}
if st := repairUnitState(); st == "activating" || st == "active" {
return errors.New("Resync läuft bereits")
return errors.New("resync läuft bereits")
}
script := fmt.Sprintf(`#!/bin/bash
@@ -334,7 +334,7 @@ func localRepairStatus() repairStatusResponse {
"--no-pager", "-n", "100", "-o", "cat",
).CombinedOutput(); err == nil {
lines := strings.Split(strings.TrimRight(string(data), "\n"), "\n")
if !(len(lines) == 1 && (lines[0] == "" || strings.HasPrefix(lines[0], "-- No entries"))) {
if len(lines) != 1 || (lines[0] != "" && !strings.HasPrefix(lines[0], "-- No entries")) {
out.Log = lines
}
}

View File

@@ -177,7 +177,7 @@ func (h *ClusterHandler) runRollingUpdate(secondary *models.HANode) {
slog.Info("rolling-update: secondary already at target — skipping secondary step",
"version", candidate)
} else {
// 1. Secondary triggern
// 1. Secondary triggering
slog.Info("rolling-update: posting trigger-update to secondary", "fqdn", secondary.FQDN)
result := h.Aggregator.PostPeer(ctx, *secondary, "/agent/cluster/trigger-update")
if !result.OK {
@@ -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

@@ -22,7 +22,7 @@ import (
// FirewallHandler exposes everything under /api/v1/firewall/*:
//
// address-objects — primitive Adress-Definitionen (host/network/range/fqdn)
// address-objects — primitive Address-Definitionen (host/network/range/fqdn)
// address-groups — Gruppen von address-objects (mit /members ops)
// services — proto+port (Builtins lassen sich nicht editieren)
// service-groups — Gruppen von services
@@ -271,7 +271,7 @@ func zoneNamePattern(s string) bool {
if s == "" || len(s) > 32 {
return false
}
if !(s[0] >= 'a' && s[0] <= 'z') {
if s[0] < 'a' || s[0] > 'z' {
return false
}
for i := 1; i < len(s); i++ {
@@ -352,7 +352,8 @@ func (h *FirewallHandler) CreateAddrObj(c *gin.Context) {
return
}
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "fw.addr_obj.create", req.Name, out, h.NodeID)
response.Created(c, out); h.reload(c.Request.Context(), "create")
response.Created(c, out)
h.reload(c.Request.Context(), "create")
}
func (h *FirewallHandler) UpdateAddrObj(c *gin.Context) {
@@ -379,7 +380,8 @@ func (h *FirewallHandler) UpdateAddrObj(c *gin.Context) {
return
}
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "fw.addr_obj.update", req.Name, out, h.NodeID)
response.OK(c, out); h.reload(c.Request.Context(), "update")
response.OK(c, out)
h.reload(c.Request.Context(), "update")
}
func (h *FirewallHandler) DeleteAddrObj(c *gin.Context) {
@@ -397,7 +399,8 @@ func (h *FirewallHandler) DeleteAddrObj(c *gin.Context) {
}
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "fw.addr_obj.delete",
strconv.FormatInt(id, 10), gin.H{"id": id}, h.NodeID)
response.NoContent(c); h.reload(c.Request.Context(), "delete")
response.NoContent(c)
h.reload(c.Request.Context(), "delete")
}
// ── Address Groups ─────────────────────────────────────────────────────
@@ -440,7 +443,8 @@ func (h *FirewallHandler) CreateAddrGrp(c *gin.Context) {
return
}
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "fw.addr_grp.create", req.Name, out, h.NodeID)
response.Created(c, out); h.reload(c.Request.Context(), "create")
response.Created(c, out)
h.reload(c.Request.Context(), "create")
}
func (h *FirewallHandler) UpdateAddrGrp(c *gin.Context) {
@@ -463,7 +467,8 @@ func (h *FirewallHandler) UpdateAddrGrp(c *gin.Context) {
return
}
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "fw.addr_grp.update", req.Name, out, h.NodeID)
response.OK(c, out); h.reload(c.Request.Context(), "update")
response.OK(c, out)
h.reload(c.Request.Context(), "update")
}
func (h *FirewallHandler) DeleteAddrGrp(c *gin.Context) {
@@ -481,7 +486,8 @@ func (h *FirewallHandler) DeleteAddrGrp(c *gin.Context) {
}
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "fw.addr_grp.delete",
strconv.FormatInt(id, 10), gin.H{"id": id}, h.NodeID)
response.NoContent(c); h.reload(c.Request.Context(), "delete")
response.NoContent(c)
h.reload(c.Request.Context(), "delete")
}
// ── Services ───────────────────────────────────────────────────────────
@@ -524,7 +530,8 @@ func (h *FirewallHandler) CreateService(c *gin.Context) {
return
}
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "fw.service.create", req.Name, out, h.NodeID)
response.Created(c, out); h.reload(c.Request.Context(), "create")
response.Created(c, out)
h.reload(c.Request.Context(), "create")
}
func (h *FirewallHandler) UpdateService(c *gin.Context) {
@@ -547,7 +554,8 @@ func (h *FirewallHandler) UpdateService(c *gin.Context) {
return
}
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "fw.service.update", req.Name, out, h.NodeID)
response.OK(c, out); h.reload(c.Request.Context(), "update")
response.OK(c, out)
h.reload(c.Request.Context(), "update")
}
func (h *FirewallHandler) DeleteService(c *gin.Context) {
@@ -565,7 +573,8 @@ func (h *FirewallHandler) DeleteService(c *gin.Context) {
}
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "fw.service.delete",
strconv.FormatInt(id, 10), gin.H{"id": id}, h.NodeID)
response.NoContent(c); h.reload(c.Request.Context(), "delete")
response.NoContent(c)
h.reload(c.Request.Context(), "delete")
}
// ── Service Groups ─────────────────────────────────────────────────────
@@ -608,7 +617,8 @@ func (h *FirewallHandler) CreateSvcGrp(c *gin.Context) {
return
}
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "fw.svc_grp.create", req.Name, out, h.NodeID)
response.Created(c, out); h.reload(c.Request.Context(), "create")
response.Created(c, out)
h.reload(c.Request.Context(), "create")
}
func (h *FirewallHandler) UpdateSvcGrp(c *gin.Context) {
@@ -631,7 +641,8 @@ func (h *FirewallHandler) UpdateSvcGrp(c *gin.Context) {
return
}
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "fw.svc_grp.update", req.Name, out, h.NodeID)
response.OK(c, out); h.reload(c.Request.Context(), "update")
response.OK(c, out)
h.reload(c.Request.Context(), "update")
}
func (h *FirewallHandler) DeleteSvcGrp(c *gin.Context) {
@@ -649,7 +660,8 @@ func (h *FirewallHandler) DeleteSvcGrp(c *gin.Context) {
}
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "fw.svc_grp.delete",
strconv.FormatInt(id, 10), gin.H{"id": id}, h.NodeID)
response.NoContent(c); h.reload(c.Request.Context(), "delete")
response.NoContent(c)
h.reload(c.Request.Context(), "delete")
}
// ── Rules ──────────────────────────────────────────────────────────────
@@ -704,7 +716,8 @@ func (h *FirewallHandler) CreateRule(c *gin.Context) {
return
}
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "fw.rule.create", strconv.FormatInt(out.ID, 10), out, h.NodeID)
response.Created(c, out); h.reload(c.Request.Context(), "create")
response.Created(c, out)
h.reload(c.Request.Context(), "create")
}
func (h *FirewallHandler) UpdateRule(c *gin.Context) {
@@ -739,7 +752,8 @@ func (h *FirewallHandler) UpdateRule(c *gin.Context) {
return
}
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "fw.rule.update", strconv.FormatInt(id, 10), out, h.NodeID)
response.OK(c, out); h.reload(c.Request.Context(), "update")
response.OK(c, out)
h.reload(c.Request.Context(), "update")
}
func (h *FirewallHandler) DeleteRule(c *gin.Context) {
@@ -757,7 +771,8 @@ func (h *FirewallHandler) DeleteRule(c *gin.Context) {
}
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "fw.rule.delete",
strconv.FormatInt(id, 10), gin.H{"id": id}, h.NodeID)
response.NoContent(c); h.reload(c.Request.Context(), "delete")
response.NoContent(c)
h.reload(c.Request.Context(), "delete")
}
func (h *FirewallHandler) PatchRule(c *gin.Context) {
@@ -850,7 +865,8 @@ func (h *FirewallHandler) CreateNAT(c *gin.Context) {
return
}
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "fw.nat.create", strconv.FormatInt(out.ID, 10), out, h.NodeID)
response.Created(c, out); h.reload(c.Request.Context(), "create")
response.Created(c, out)
h.reload(c.Request.Context(), "create")
}
func (h *FirewallHandler) UpdateNAT(c *gin.Context) {
@@ -881,7 +897,8 @@ func (h *FirewallHandler) UpdateNAT(c *gin.Context) {
return
}
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "fw.nat.update", strconv.FormatInt(id, 10), out, h.NodeID)
response.OK(c, out); h.reload(c.Request.Context(), "update")
response.OK(c, out)
h.reload(c.Request.Context(), "update")
}
func (h *FirewallHandler) DeleteNAT(c *gin.Context) {
@@ -899,7 +916,8 @@ func (h *FirewallHandler) DeleteNAT(c *gin.Context) {
}
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "fw.nat.delete",
strconv.FormatInt(id, 10), gin.H{"id": id}, h.NodeID)
response.NoContent(c); h.reload(c.Request.Context(), "delete")
response.NoContent(c)
h.reload(c.Request.Context(), "delete")
}
func (h *FirewallHandler) PatchNAT(c *gin.Context) {

View File

@@ -78,7 +78,7 @@ func (h *FirewallLogHandler) Live(c *gin.Context) {
// Upgrade-Failures sind Browser-side; nichts loggen
return
}
defer conn.Close()
defer func() { _ = conn.Close() }()
f := parseFilter(c)

View File

@@ -58,14 +58,15 @@ type frontendStat struct {
}
func (h *HAProxyStatsHandler) Stats(c *gin.Context) {
conn, err := net.DialTimeout("unix", haproxyAdminSock, 2*time.Second)
d := net.Dialer{Timeout: 2 * time.Second}
conn, err := d.DialContext(c.Request.Context(), "unix", haproxyAdminSock)
if err != nil {
// Socket nicht erreichbar (haproxy down oder no perm) →
// leere Liste statt 500 damit das Dashboard nicht rot wird.
response.OK(c, gin.H{"backends": []backendStat{}, "frontends": []frontendStat{}, "error": err.Error()})
return
}
defer conn.Close()
defer func() { _ = conn.Close() }()
_ = conn.SetDeadline(time.Now().Add(3 * time.Second))
if _, err := conn.Write([]byte("show stat\n")); err != nil {
response.OK(c, gin.H{"backends": []backendStat{}, "frontends": []frontendStat{}, "error": err.Error()})

View File

@@ -139,7 +139,7 @@ func (h *LicenseHandler) ClearKey(c *gin.Context) {
// result into the licenses table. On error, marks last_error in DB
// (status stays as before — grace).
func (h *LicenseHandler) runVerifyAndPersist(ctx context.Context, key string) (*license.Result, error) {
res, err := h.Client.Verify(key)
res, err := h.Client.Verify(key) //nolint:contextcheck // detached by design — License-Verify nutzt eigenen HTTP-Timeout, überlebt Request-Cancel
if err != nil {
_ = h.Repo.MarkError(ctx, key, err.Error())
slog.Warn("license: verify failed", "error", err)

View File

@@ -100,21 +100,21 @@ func parseChronyTracking(out string) chronyStatus {
}
s.Synced = val != "00000000 ()"
case "Stratum":
fmt.Sscanf(val, "%d", &s.Stratum)
_, _ = fmt.Sscanf(val, "%d", &s.Stratum)
if s.Stratum > 0 && s.Stratum < 16 {
s.Synced = true
}
case "System time":
// "0.000012345 seconds fast of NTP time"
var v float64
fmt.Sscanf(val, "%f", &v)
_, _ = fmt.Sscanf(val, "%f", &v)
s.OffsetMs = v * 1000
case "Frequency":
// "-12.345 ppm slow" or "+12.345 ppm fast"
fmt.Sscanf(val, "%f", &s.FreqPPM)
_, _ = fmt.Sscanf(val, "%f", &s.FreqPPM)
case "RMS offset":
var v float64
fmt.Sscanf(val, "%f", &v)
_, _ = fmt.Sscanf(val, "%f", &v)
s.RMSOffsetMs = v * 1000
}
}
@@ -312,8 +312,8 @@ func parseChronymSources(out string) []ntpSource {
Reach: fields[3],
LastRx: fields[4],
}
fmt.Sscanf(fields[1], "%d", &src.Stratum)
fmt.Sscanf(fields[2], "%d", &src.Poll)
_, _ = fmt.Sscanf(fields[1], "%d", &src.Stratum)
_, _ = fmt.Sscanf(fields[2], "%d", &src.Poll)
if len(fields) >= 6 {
src.Sample = strings.Join(fields[5:], " ")
}

View File

@@ -79,9 +79,9 @@ func runCallback(t *testing.T, h *OIDCHandler, flow oidcFlow, queryState, code s
if err != nil {
t.Fatal(err)
}
req := httptest.NewRequest(http.MethodGet,
req := httptest.NewRequestWithContext(context.Background(), http.MethodGet,
"/api/v1/auth/oidc/callback?state="+queryState+"&code="+code, nil)
req.AddCookie(&http.Cookie{Name: oidcFlowCookie, Value: signed})
req.AddCookie(&http.Cookie{Name: oidcFlowCookie, Value: signed}) //nolint:gosec // Test-Cookie — Secure/HttpOnly-Flags für httptest irrelevant
c.Request = req
h.Callback(c)
return rec

View File

@@ -1,6 +1,7 @@
package response
import (
"context"
"encoding/json"
"errors"
"net/http"
@@ -16,7 +17,7 @@ func run(handler gin.HandlerFunc) *httptest.ResponseRecorder {
r := gin.New()
r.GET("/x", handler)
rec := httptest.NewRecorder()
req, _ := http.NewRequest(http.MethodGet, "/x", nil)
req := httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/x", nil)
r.ServeHTTP(rec, req)
return rec
}

View File

@@ -64,6 +64,7 @@ func (h *SetupHandler) Register(rg *gin.RouterGroup) {
g.POST("/complete", h.Complete)
g.POST("/complete-node", h.CompleteAsNode)
g.POST("/join-cluster", h.JoinCluster)
g.GET("/replication-status", h.ReplicationStatus)
}
// RegisterAuthed mountet die Endpoints die nach abgeschlossenem Setup
@@ -206,6 +207,13 @@ func (h *SetupHandler) JoinCluster(c *gin.Context) {
go h.preRegisterPrimary(body.PrimaryFQDN)
}
// Logical Replication automatisch einrichten. Ohne diesen Schritt waere
// der Node zwar im Cluster registriert, wuerde aber keinerlei geteilte
// Config (Domains, Backends, Firewall-Rules, WireGuard, …) bekommen —
// was frueher erst beim Failover auffiel. Laeuft detached, der Wizard
// pollt /setup/replication-status.
h.startReplicationSetup(body.PrimaryFQDN)
response.OK(c, gin.H{
"completed": st.Completed,
"is_cluster_node": st.IsClusterNode,
@@ -245,22 +253,46 @@ func (h *SetupHandler) preRegisterPrimary(primaryFQDN string) {
}
ip := addrs[0]
// Stable ID so repeated calls (join + startup) don't accumulate rows.
nodeID := fmt.Sprintf("prenode-%s", strings.ReplaceAll(primaryFQDN, ".", "-"))
n := models.HANode{
ID: nodeID,
Name: primaryFQDN,
FQDN: primaryFQDN,
APIURL: "https://" + primaryFQDN + ":3443",
Role: "primary",
Status: "online",
}
n.PublicIP = &ip
if _, err := h.ClusterStore.UpsertSelf(ctx, n); err != nil {
slog.Warn("setup: pre-register primary in ha_nodes failed", "fqdn", primaryFQDN, "error", err)
// Sobald der Primary sich selbst gemeldet hat (Heartbeat-Push), steht
// hier bereits eine Zeile mit seiner ECHTEN Node-ID. Dann ist nur die
// IP frisch zu halten — mehr will diese Funktion gar nicht.
//
// Vorher lief in dem Fall unbedingt der Platzhalter-Upsert unten, der
// am fqdn-Unique-Index scheiterte: UpsertSelf nutzt ON CONFLICT (id),
// und die Synthetik-ID "prenode-<fqdn>" trifft die echte Zeile nicht.
// Ergebnis war ein "duplicate key value violates unique constraint
// ha_nodes_fqdn_unique" bei JEDEM API-Start auf dem Standby — folgenlos
// (die echte Zeile stimmt ja), aber es sah nach einem Defekt aus und
// verdeckte echte Warnungen.
tag, err := h.ClusterStore.Pool.Exec(ctx,
`UPDATE ha_nodes SET public_ip = $1, updated_at = NOW() WHERE fqdn = $2`,
ip, primaryFQDN)
if err != nil {
slog.Warn("setup: refreshing primary IP in ha_nodes failed",
"fqdn", primaryFQDN, "error", err)
return
}
if tag.RowsAffected() == 0 {
// Noch keine Zeile: Platzhalter anlegen, damit @peer_ipv4 den
// Primary schon kennt, bevor er sich das erste Mal meldet.
// Stabile ID, damit wiederholte Aufrufe (Join + Start) keine
// Zeilen anhaeufen.
nodeID := fmt.Sprintf("prenode-%s", strings.ReplaceAll(primaryFQDN, ".", "-"))
n := models.HANode{
ID: nodeID,
Name: primaryFQDN,
FQDN: primaryFQDN,
APIURL: "https://" + primaryFQDN + ":3443",
Role: "primary",
Status: "online",
}
n.PublicIP = &ip
if _, err := h.ClusterStore.UpsertSelf(ctx, n); err != nil {
slog.Warn("setup: pre-register primary in ha_nodes failed",
"fqdn", primaryFQDN, "error", err)
return
}
}
if err := h.PeerReloader(ctx); err != nil {
slog.Warn("setup: PeerReloader failed after primary pre-register", "error", err)
return

View File

@@ -0,0 +1,195 @@
package handlers
import (
"encoding/json"
"log/slog"
"net"
"os"
"os/exec"
"strings"
"sync"
"time"
"github.com/gin-gonic/gin"
"git.netcell-it.de/projekte/edgeguard-native/internal/configgen"
"git.netcell-it.de/projekte/edgeguard-native/internal/handlers/response"
)
// Automatische Logical-Replication-Einrichtung beim Cluster-Join.
//
// Früher war das ein manueller Schritt: nach dem Join musste der Operator
// auf dem neuen Node `edgeguard-ctl cluster-setup-standby <primary>`
// ausführen. Wer das übersah, hatte einen Node, der im Cluster sichtbar
// war, aber KEINE geteilte Config replizierte — und merkte es erst beim
// Failover. Deshalb läuft es jetzt direkt aus dem Join heraus.
//
// Der eigentliche Ablauf bleibt im CLI (`cluster-setup-standby`): er
// braucht root (psql als postgres-User, pg_hba, render-config), die API
// läuft als unprivilegierter `edgeguard`. Aufruf daher via sudo mit
// gepinnter Regel — gleiches Muster wie bei apt-get/systemctl/tee.
//
// Weil die Initialkopie der geteilten Tabellen Minuten dauern kann, läuft
// das detached; der Setup-Wizard pollt GET /setup/replication-status.
const replicationStateFile = "/var/lib/edgeguard/replication-setup-state.json"
const (
replPhaseIdle = "idle"
replPhaseRunning = "running"
replPhaseDone = "done"
replPhaseFailed = "failed"
)
// replStateMu serialisiert Lesen/Schreiben der State-Datei (HTTP-Handler
// + Hintergrund-Goroutine greifen gleichzeitig zu).
var replStateMu sync.Mutex
// ReplicationSetupState hält den Fortschritt der Standby-Einrichtung.
// Persistiert, damit der Status einen API-Neustart übersteht — der ist
// der letzte Schritt des Setups und würde den Zustand sonst verlieren.
type ReplicationSetupState struct {
Phase string `json:"phase"`
Primary string `json:"primary,omitempty"`
Error string `json:"error,omitempty"`
Log string `json:"log,omitempty"`
StartedAt time.Time `json:"started_at,omitempty"`
UpdatedAt time.Time `json:"updated_at,omitempty"`
}
func readReplicationState() ReplicationSetupState {
replStateMu.Lock()
defer replStateMu.Unlock()
raw, err := os.ReadFile(replicationStateFile)
if err != nil {
return ReplicationSetupState{Phase: replPhaseIdle}
}
var st ReplicationSetupState
if err := json.Unmarshal(raw, &st); err != nil {
return ReplicationSetupState{Phase: replPhaseIdle}
}
if st.Phase == "" {
st.Phase = replPhaseIdle
}
// Ein "running", das älter als das CLI-Timeout ist, kann nur von einem
// gestorbenen Prozess stammen (z. B. OOM-Kill). Sonst haengt der Wizard
// ewig im Spinner.
if st.Phase == replPhaseRunning && !st.StartedAt.IsZero() &&
time.Since(st.StartedAt) > 15*time.Minute {
st.Phase = replPhaseFailed
st.Error = "Zeitüberschreitung — Einrichtung lief länger als 15 Minuten. " +
"Manuell nachholen: sudo edgeguard-ctl cluster-setup-standby " + st.Primary
}
return st
}
func writeReplicationState(st ReplicationSetupState) {
st.UpdatedAt = time.Now()
raw, err := json.Marshal(st)
if err != nil {
return
}
replStateMu.Lock()
defer replStateMu.Unlock()
if err := configgen.AtomicWrite(replicationStateFile, raw, 0o640); err != nil {
slog.Warn("setup: replication state write failed", "error", err)
}
}
// validPrimaryHost laesst nur das durch, was ein Hostname oder eine IP
// sein kann. exec.Command startet keine Shell, Metazeichen koennen also
// ohnehin nichts ausloesen — die Pruefung haelt aber Unsinn von der
// sudo-Regel fern und liefert dem Operator einen klaren Fehler statt
// eines kryptischen CLI-Abbruchs.
func validPrimaryHost(h string) bool {
h = strings.TrimSpace(h)
if h == "" || len(h) > 253 {
return false
}
if net.ParseIP(h) != nil {
return true
}
for _, label := range strings.Split(h, ".") {
if label == "" {
return false
}
for _, r := range label {
isAlnum := (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9')
if !isAlnum && r != '-' {
return false
}
}
}
return true
}
// startReplicationSetup richtet diesen Node im Hintergrund als Logical-
// Replication-Subscriber ein. Nicht-blockierend: der Join-Request
// antwortet sofort, der Wizard pollt den Status.
func (h *SetupHandler) startReplicationSetup(primary string) {
primary = strings.ToLower(strings.TrimSpace(primary))
if !validPrimaryHost(primary) {
writeReplicationState(ReplicationSetupState{
Phase: replPhaseFailed,
Error: "ungültiger Primary-Host: " + primary,
})
return
}
writeReplicationState(ReplicationSetupState{
Phase: replPhaseRunning,
Primary: primary,
StartedAt: time.Now(),
})
go func() {
defer func() {
if r := recover(); r != nil {
slog.Error("setup: replication setup panic", "panic", r)
writeReplicationState(ReplicationSetupState{
Phase: replPhaseFailed, Primary: primary,
Error: "interner Fehler bei der Replikations-Einrichtung",
})
}
}()
slog.Info("setup: starting logical replication setup", "primary", primary)
// Kein Request-Context: der Join-Request ist längst beantwortet,
// und ein Abbruch mitten im CREATE SUBSCRIPTION wäre schlimmer
// als ein Weiterlaufen.
cmd := exec.Command("sudo", "-n", "/usr/bin/edgeguard-ctl", //nolint:noctx // detached by design — darf nicht am Request haengen
"cluster-setup-standby", primary)
out, err := cmd.CombinedOutput()
logTail := tailString(string(out), 4000)
if err != nil {
slog.Warn("setup: logical replication setup failed",
"primary", primary, "error", err, "output", logTail)
writeReplicationState(ReplicationSetupState{
Phase: replPhaseFailed, Primary: primary,
Error: err.Error(), Log: logTail,
})
return
}
slog.Info("setup: logical replication setup finished", "primary", primary)
writeReplicationState(ReplicationSetupState{
Phase: replPhaseDone, Primary: primary, Log: logTail,
})
}()
}
// tailString kuerzt lange CLI-Ausgaben auf die letzten n Bytes — der
// interessante Teil (Fehler, Abschlussmeldung) steht am Ende.
func tailString(s string, n int) string {
if len(s) <= n {
return s
}
return "…" + s[len(s)-n:]
}
// ReplicationStatus liefert den Fortschritt der automatischen Standby-
// Einrichtung. Liegt bewusst auf der Setup-Gruppe (pre-auth): der Wizard
// pollt es, bevor auf dem neuen Node ueberhaupt ein Login moeglich ist.
func (h *SetupHandler) ReplicationStatus(c *gin.Context) {
response.OK(c, readReplicationState())
}

View File

@@ -0,0 +1,53 @@
package handlers
import "testing"
// validPrimaryHost bewacht das einzige variable Argument einer sudo-Regel
// (`edgeguard-ctl cluster-setup-standby *`). Der Aufruf laeuft zwar ohne
// Shell, aber die Pruefung soll trotzdem halten was sie verspricht.
func TestValidPrimaryHost(t *testing.T) {
valid := []string{
"utm-1.netcell-it.de",
"primary",
"10.0.5.1",
"89.163.205.6",
"2001:db8::1",
"a-b-c.example.com",
}
for _, h := range valid {
if !validPrimaryHost(h) {
t.Errorf("validPrimaryHost(%q) = false, erwartet true", h)
}
}
invalid := []string{
"",
" ",
"host; rm -rf /",
"host && reboot",
"host|tee",
"host$(id)",
"host`id`",
"--tls-dir=/tmp/evil",
"host with space",
"host\nsecond-line",
"..",
"host..example.com",
"/etc/passwd",
}
for _, h := range invalid {
if validPrimaryHost(h) {
t.Errorf("validPrimaryHost(%q) = true, erwartet false", h)
}
}
}
func TestValidPrimaryHostRejectsOverlongName(t *testing.T) {
long := make([]byte, 254)
for i := range long {
long[i] = 'a'
}
if validPrimaryHost(string(long)) {
t.Error("Hostname > 253 Zeichen muss abgelehnt werden")
}
}

View File

@@ -132,7 +132,7 @@ func (h *SystemHandler) Register(rg *gin.RouterGroup) {
// Ergebnis für /cluster/system/load.
//
// Bewusst KEINE Mutations + KEIN /package-versions (würde apt-get update
// auf jedem Peer triggern), KEIN /upgrade.
// auf jedem Peer triggering), KEIN /upgrade.
func (h *SystemHandler) RegisterAgent(rg *gin.RouterGroup) {
g := rg.Group("/agent/system")
g.GET("/health", h.Health)
@@ -194,6 +194,8 @@ var servicesToCheck = []struct{ Label, Unit string }{
{"unbound", "unbound"},
{"chrony", "chrony"},
{"squid", "squid"},
{"kea-dhcp4", "kea-dhcp4-server"},
{"freeradius", "freeradius"},
{"postgresql", "postgresql"},
{"crowdsec", "crowdsec"},
{"crowdsec-firewall-bouncer", "crowdsec-firewall-bouncer"},
@@ -204,7 +206,7 @@ type serviceStatus struct {
Label string `json:"label"`
Unit string `json:"unit"`
Active bool `json:"active"`
State string `json:"state"` // active|inactive|failed|activating|...
State string `json:"state"` // active|inactive|failed|activating|...
Since string `json:"since,omitempty"` // ActiveEnterTimestamp
}
@@ -376,7 +378,7 @@ func (h *SystemHandler) Maintenance(c *gin.Context) {
func (h *SystemHandler) ToggleMaintenance(c *gin.Context) {
if h.Setup == nil {
response.Err(c, http.StatusServiceUnavailable,
simpleErr("setup not initialised"))
simpleErr("setup not initialized"))
return
}
var req struct {
@@ -428,7 +430,7 @@ func (h *SystemHandler) BackupRetention(c *gin.Context) {
// keep=0 → wieder Default, keep=1..365 → custom.
func (h *SystemHandler) SetBackupRetention(c *gin.Context) {
if h.Setup == nil {
response.Err(c, http.StatusServiceUnavailable, simpleErr("setup not initialised"))
response.Err(c, http.StatusServiceUnavailable, simpleErr("setup not initialized"))
return
}
var req struct {
@@ -464,7 +466,7 @@ func (h *SystemHandler) AuditRetention(c *gin.Context) {
// SetAuditRetention setzt Audit-Retention in Tagen. 0..3650.
func (h *SystemHandler) SetAuditRetention(c *gin.Context) {
if h.Setup == nil {
response.Err(c, http.StatusServiceUnavailable, simpleErr("setup not initialised"))
response.Err(c, http.StatusServiceUnavailable, simpleErr("setup not initialized"))
return
}
var req struct {
@@ -511,7 +513,7 @@ func (h *SystemHandler) IPv6(c *gin.Context) {
func (h *SystemHandler) SetIPv6(c *gin.Context) {
if h.Setup == nil {
response.Err(c, http.StatusServiceUnavailable, simpleErr("setup not initialised"))
response.Err(c, http.StatusServiceUnavailable, simpleErr("setup not initialized"))
return
}
var req struct {
@@ -580,7 +582,7 @@ LIMIT 10`)
// HAProxyReload zwingt ein systemctl reload haproxy.service — nützlich
// wenn der Operator manuell in /etc/edgeguard/tls/ geschrieben hat
// (z. B. eigenes PEM per SSH kopiert) und HAProxy das neue Cert sehen
// soll, ohne eine UI-Mutation zu triggern die das automatisch täte.
// soll, ohne eine UI-Mutation zu triggering die das automatisch täte.
func (h *SystemHandler) HAProxyReload(c *gin.Context) {
out, err := exec.Command("sudo", "-n", "/usr/bin/systemctl", "reload", "haproxy.service").CombinedOutput()
if err != nil {
@@ -598,12 +600,12 @@ func (h *SystemHandler) HAProxyReload(c *gin.Context) {
// starten darf. edgeguard-api selbst ist bewusst ausgeschlossen (würde
// die eigene HTTP-Response killen). postgresql ebenfalls (Datenpfad).
var restartAllowlist = map[string]bool{
"haproxy": true,
"squid": true,
"unbound": true,
"chrony": true,
"nftables": true,
"wireguard": true, // wireguard als Metadienst; einzelne wg-Ifaces über wg-quick@<name>
"haproxy": true,
"squid": true,
"unbound": true,
"chrony": true,
"nftables": true,
"wireguard": true, // wireguard als Metadienst; einzelne wg-Ifaces über wg-quick@<name>
"edgeguard-scheduler": true,
}
@@ -788,7 +790,7 @@ func (h *SystemHandler) UpgradeStatus(c *gin.Context) {
).CombinedOutput(); err == nil {
lines := strings.Split(strings.TrimRight(string(data), "\n"), "\n")
// Leere "no entries"-Antwort als leeres Log zurückgeben.
if !(len(lines) == 1 && (lines[0] == "" || strings.HasPrefix(lines[0], "-- No entries"))) {
if len(lines) != 1 || (lines[0] != "" && !strings.HasPrefix(lines[0], "-- No entries")) {
out.Log = lines
}
}
@@ -918,7 +920,7 @@ func (h *SystemHandler) Upgrade(c *gin.Context) {
// beiden Namespaces aus zugänglich.
const scriptPath = "/var/lib/edgeguard/upgrade.sh"
// Retry-Logik gegen Gitea-Packages.gz-Race: nach einem frischen
// Publish kann der Packages-Index für ein paar Sekunden inkonsistent
// Publish kann der Packages-Index für ein paar Sekunden inconsistent
// sein (z. B. Meta uploaded, api/ui noch nicht in der regenerierten
// Index-Datei) → apt-resolver-fail mit "no choices are installable".
// Drei Versuche mit 15s/30s Backoff geben Gitea Zeit den Index
@@ -938,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
@@ -1233,4 +1238,3 @@ func flagsToList(f net.Flags) []string {
}
return out
}

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

@@ -32,6 +32,10 @@ type WireguardHandler struct {
Audit *audit.Repo
NodeID string
Reloader func(ctx context.Context) error
// PublicHost ist der öffentliche Host (FQDN/IP), den Clients als
// WireGuard-Endpoint anwählen. Wird in heruntergeladene Peer-Configs
// geschrieben (statt eines Platzhalters). Leer → Platzhalter (Fallback).
PublicHost string
}
func NewWireguardHandler(
@@ -45,6 +49,12 @@ func NewWireguardHandler(
return &WireguardHandler{Ifaces: ifaces, Peers: peers, Box: box, Audit: a, NodeID: nodeID, Reloader: reloader}
}
// WithPublicHost setzt den öffentlichen Endpoint-Host für Peer-Configs.
func (h *WireguardHandler) WithPublicHost(host string) *WireguardHandler {
h.PublicHost = strings.TrimSpace(host)
return h
}
func (h *WireguardHandler) reload(ctx context.Context, op string) {
if h.Reloader == nil {
return
@@ -702,11 +712,15 @@ func (h *WireguardHandler) peerConfigText(ctx context.Context, peerID int64) (st
clientAllowedIPs += ", " + strings.TrimSpace(*ifc.ClientRoutes)
}
fmt.Fprintf(&b, "AllowedIPs = %s\n", clientAllowedIPs)
// Endpoint — the operator's public host:port that peers dial.
// We don't know this here (could be a CNAME or behind a load
// balancer); leave a placeholder the operator must fill in.
// Endpoint — der öffentliche Host:Port, den Clients anwählen. Standard
// ist der FQDN dieser Node (PublicHost, aus setup.json). Nur wenn der
// nicht ermittelbar ist, bleibt ein Platzhalter den der Operator füllt.
if ifc.ListenPort != nil {
fmt.Fprintf(&b, "Endpoint = REPLACE_WITH_PUBLIC_HOST:%d\n", *ifc.ListenPort)
host := h.PublicHost
if host == "" {
host = "REPLACE_WITH_PUBLIC_HOST"
}
fmt.Fprintf(&b, "Endpoint = %s:%d\n", host, *ifc.ListenPort)
}
if p.Keepalive != nil && *p.Keepalive > 0 {
fmt.Fprintf(&b, "PersistentKeepalive = %d\n", *p.Keepalive)

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
@@ -113,6 +120,11 @@ frontend public_https
# www-Redirect: {{$d.RedirectFromHost}}{{$d.Name}}
http-request redirect prefix https://{{$d.Name}} code 301 if { hdr(host) -i {{$d.RedirectFromHost}} }
{{- end}}
{{- if $d.RedirectTo}}
# Domain-Redirect (301): {{$d.Name}}{{$d.RedirectTo}} (immer auf Ziel-Root,
# terminiert vor use_backend → diese Domain routet auf kein Backend).
http-request redirect location {{$d.RedirectTo}} code 301 if { hdr(host) -i {{$d.Name}} }
{{- end}}
{{- if $d.MaintenanceMode}}
# Wartungs-Modus für {{$d.Name}} — alle Requests werden mit 503 beantwortet.
http-request return status 503 content-type "text/plain; charset=utf-8" string "{{$d.MaintMessage}}" if { hdr(host) -i {{$d.Name}} }
@@ -221,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
`
@@ -212,6 +212,11 @@ type DomainView struct {
// to-www → Name="www.example.com" → "example.com" (strip www.-Prefix)
RedirectFromHost string
// RedirectTo: HAProxy-safe 301-Ziel-URL für eine Domain→Domain-Weiterleitung
// (z. B. "https://zkm.netcell-it.de"). Leer = kein Redirect. Schattet das
// gleichnamige Feld aus dem eingebetteten models.Domain (sanitisiert).
RedirectTo string
// ResponseHeaders: Custom-Headers die HAProxy auf jede Response für
// diese Domain setzt. Werte sind bereits HAProxy-safe escaped
// (Quotes → ', Newlines → Space).
@@ -313,6 +318,7 @@ func (g *Generator) loadView(ctx context.Context) (*View, error) {
HSTSHeader: buildHSTSHeader(d),
MaintMessage: buildMaintMessage(d),
RedirectFromHost: buildRedirectFromHost(d),
RedirectTo: buildRedirectTo(d),
ResponseHeaders: headersByDomain[d.ID],
}
if d.MaxBodyKB > 0 {
@@ -429,3 +435,23 @@ func buildRedirectFromHost(d models.Domain) string {
return ""
}
}
// buildRedirectTo liefert die 301-Ziel-URL HAProxy-safe, oder "" wenn kein
// Redirect gesetzt ist bzw. die URL ungültig erscheint. Defensiv: nur
// http(s)-URLs ohne Whitespace/Steuerzeichen/Quotes — sonst würde die
// `redirect location <url>`-Zeile die HAProxy-Config sprengen. Im Zweifel
// lieber KEIN Redirect rendern als eine kaputte Config auszuliefern.
func buildRedirectTo(d models.Domain) string {
u := strings.TrimSpace(d.RedirectTo)
if u == "" {
return ""
}
lower := strings.ToLower(u)
if !strings.HasPrefix(lower, "http://") && !strings.HasPrefix(lower, "https://") {
return ""
}
if strings.ContainsAny(u, " \t\r\n\"'`\\{}") {
return ""
}
return u
}

View File

@@ -104,11 +104,13 @@ func TestRender_HSTSPerDomain(t *testing.T) {
t.Errorf("missing %q in per-domain HSTS output:\n%s", w, out)
}
}
if strings.Contains(out, "Strict-Transport-Security \"\" if { hdr(host) -i b.example.com }") ||
strings.Contains(out, "if { hdr(host) -i b.example.com }") && strings.Contains(out, "Strict-Transport-Security") &&
strings.Contains(out, "b.example.com") && strings.Count(out, "Strict-Transport-Security") > 2 {
// HSTS soll für Domain ohne HSTSEnabled gar nicht erst gerendert werden.
// (mgmt_https hat noch eins, plus die eine Zeile von a.example.com → 2 Vorkommen erwartet.)
// HSTS soll für die Domain ohne HSTSEnabled gar nicht erst gerendert
// werden: keine einzige HSTS-Zeile darf sich auf b.example.com beziehen.
for _, line := range strings.Split(out, "\n") {
if strings.Contains(line, "Strict-Transport-Security") &&
strings.Contains(line, "hdr(host) -i b.example.com") {
t.Errorf("unexpected HSTS ACL for HSTS-disabled domain b.example.com: %q", line)
}
}
}
@@ -170,6 +172,45 @@ func TestRender_WWWRedirectToWWW(t *testing.T) {
}
}
func TestRender_RedirectTo(t *testing.T) {
v := View{
Domains: []DomainView{
{
Domain: models.Domain{
ID: 1, Name: "kvs.netcell-it.de", Active: true,
RedirectTo: "https://zkm.netcell-it.de",
},
RedirectTo: "https://zkm.netcell-it.de",
},
},
}
out := renderView(t, v)
want := `http-request redirect location https://zkm.netcell-it.de code 301 if { hdr(host) -i kvs.netcell-it.de }`
if !strings.Contains(out, want) {
t.Errorf("missing domain→domain 301 redirect line:\n%s", out)
}
}
func TestBuildRedirectTo(t *testing.T) {
cases := []struct{ in, want string }{
{"https://zkm.netcell-it.de", "https://zkm.netcell-it.de"},
{" https://zkm.netcell-it.de ", "https://zkm.netcell-it.de"}, // getrimmt
{"http://x.de", "http://x.de"},
{"", ""},
{"zkm.netcell-it.de", ""}, // kein Schema
{"ftp://x.de", ""}, // falsches Schema
{"https://x .de", ""}, // Whitespace → unsafe
{"https://x\"de", ""}, // Quote → unsafe
{"javascript:alert(1)", ""}, // kein http(s)
}
for _, c := range cases {
got := buildRedirectTo(models.Domain{RedirectTo: c.in})
if got != c.want {
t.Errorf("buildRedirectTo(%q) = %q, want %q", c.in, got, c.want)
}
}
}
func TestBuildHSTSHeader(t *testing.T) {
cases := []struct {
name string
@@ -513,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

@@ -1,7 +1,7 @@
// Package kea renders the Kea DHCPv4 server config from the dhcp_*
// tables and manages the kea-dhcp4-server service lifecycle.
//
// The config is built as a Go struct and json-marshalled (NOT a text
// The config is built as a Go struct and json-marshaled (NOT a text
// template) so the output is always syntactically valid JSON. Managed
// at /etc/edgeguard/kea/kea-dhcp4.conf (edgeguard-owned); postinst
// symlinks /etc/kea/kea-dhcp4.conf to it.

View File

@@ -81,7 +81,7 @@ VALUES ($1,'aa:bb:cc:dd:ee:ff','10.0.0.50','printer',true)`, subID); err != nil
if _, statErr := os.Stat(keaBinary); statErr == nil {
f, _ := os.CreateTemp(t.TempDir(), "kea-*.conf")
_, _ = f.WriteString(out)
f.Close()
_ = f.Close()
if combined, err := exec.Command(keaBinary, "-t", f.Name()).CombinedOutput(); err != nil {
t.Fatalf("kea-dhcp4 -t rejected rendered config: %v\n%s", err, combined)
}

View File

@@ -2,21 +2,32 @@ global_defs {
router_id {{ .RouterID }}
script_user root
enable_script_security
# GARP: beim Master-Wechsel Gratuitous-ARP forciert senden (repeat) UND
# periodisch auffrischen (master_refresh) sonst lässt der Upstream-Switch
# die VIP-MAC altern und die Failover-IP wird nach Minuten unerreichbar.
# Ergänzt durch den Priming-Ping in keepalived-master.sh (Traffic AUS der VIP).
# (vrrp_garp_interval/gna NICHT setzen: keepalived 2.3.x lehnt 0 ab, Default passt.)
vrrp_garp_master_repeat 5
vrrp_garp_master_refresh 60
}
{{/* KEIN weight: in einer vrrp_sync_group ignoriert keepalived gewichtete
Track-Scripts ("ignoring tracked script ... with weights due to SYNC
group") die Health-Checks wären wirkungslos. Ohne weight wirken sie
als binäre FAULT-Trigger: schlägt ein Check fall-mal in Folge fehl,
geht die Instanz (und via Sync-Group der ganze Node) in FAULT der
gesunde Peer übernimmt. Für einen 2-Node-Cluster die saubere Semantik. */}}
vrrp_script chk_edgeguard {
script "/usr/lib/edgeguard/keepalived-check.sh"
interval 2
weight -50
fall 3
fall 8
rise 2
}
{{ if .GWCheckIP }}
vrrp_script chk_gateway {
script "/usr/lib/edgeguard/keepalived-gw-check.sh {{ .GWCheckIP }}"
interval 5
weight -110
fall 2
fall 5
rise 2
}
{{ end }}
@@ -33,7 +44,8 @@ vrrp_instance VI_1 {
interface {{ .Interface }}
virtual_router_id {{ .RouterID }}
priority {{ .Priority }}
advert_int 1
advert_int 2
{{ if .PreemptDelay }}preempt_delay {{ .PreemptDelay }}{{ else }}nopreempt{{ end }}
{{ if .SrcIP }} unicast_src_ip {{ .SrcIP }}
unicast_peer {
{{ .PeerIP }}
@@ -59,7 +71,8 @@ vrrp_instance VI_HB {
interface {{ .HBInterface }}
virtual_router_id {{ .HBRouterID }}
priority {{ .Priority }}
advert_int 1
advert_int 2
{{ 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,17 +178,26 @@ func (g *generator) buildView(cs *models.ClusterSettings, vips []VIPEntry, local
v.HBRouterID = 52
}
// pg_role=standby ist das härtere Signal — ein Standby-Node ist niemals
// MASTER, auch wenn role='primary' noch aus dem Join-Prozess stammt.
// Reihenfolge: standby → BACKUP; sonst primary-Check.
// 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.State = "BACKUP"
v.Priority = 100
} else if local.PGRole == "primary" || local.Role == "primary" {
v.State = "MASTER"
v.Priority = 200
} else {
v.State = "BACKUP"
v.Priority = 100
}

View File

@@ -0,0 +1,135 @@
package keepalived
import (
"bytes"
"strings"
"testing"
"git.netcell-it.de/projekte/edgeguard-native/internal/models"
)
func render(t *testing.T, v View) string {
t.Helper()
var buf bytes.Buffer
if err := tpl.Execute(&buf, v); err != nil {
t.Fatalf("template execute: %v", err)
}
return buf.String()
}
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", PreemptDelay: 120,
}
}
// 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 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:\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) {
out := render(t, testView())
for _, want := range []string{"vrrp_garp_master_refresh", "vrrp_garp_master_repeat"} {
if !strings.Contains(out, want) {
t.Fatalf("global_defs sollte %q enthalten:\n%s", want, out)
}
}
}
// advert_int 2 (statt 1): Master-Down ~6s — reißt nicht bei kurzen
// VM-/Heartbeat-Hiccups (Flapping-Schutz im virtualisierten Cluster).
func TestTemplateAdvertInt(t *testing.T) {
out := render(t, testView())
if strings.Contains(out, "advert_int 1\n") {
t.Fatalf("advert_int sollte 2 sein (nicht 1):\n%s", out)
}
if c := strings.Count(out, "advert_int 2"); c != 2 {
t.Fatalf("erwarte advert_int 2 in beiden Instanzen, gefunden: %d", c)
}
}
// Track-Scripts dürfen KEIN weight haben: in einer vrrp_sync_group ignoriert
// keepalived gewichtete Scripts → Health-Checks wären wirkungslos. Ohne weight
// wirken sie als FAULT-Trigger.
func TestTemplateTrackScriptsUnweighted(t *testing.T) {
out := render(t, testView())
if strings.Contains(out, "weight") {
t.Fatalf("Track-Scripts dürfen kein weight tragen (Sync-Group ignoriert sie sonst):\n%s", out)
}
}
// gw-Check darf nicht zu zucken (fall 5, nicht fall 2) — ein kurzer Upstream-
// Blip soll keinen Failover erzwingen.
func TestTemplateGatewayCheckNotTwitchy(t *testing.T) {
out := render(t, testView())
if !strings.Contains(out, "fall 5") {
t.Fatalf("chk_gateway sollte fall 5 nutzen:\n%s", out)
}
}
// buildView: State immer BACKUP, Priorität aus pg_role.
func TestBuildViewStateAlwaysBackup(t *testing.T) {
g := &generator{localID: "n1"}
cs := &models.ClusterSettings{VRRPRouterID: 51}
pub := "89.163.205.6"
cases := []struct {
pgRole, role string
wantPrio int
}{
{"primary", "primary", 200},
{"standby", "primary", 100},
{"", "primary", 200},
{"", "", 100},
}
for _, c := range cases {
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", 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

@@ -17,6 +17,7 @@
package license
import (
"context"
"crypto/sha256"
"encoding/json"
"errors"
@@ -136,11 +137,15 @@ func (c *Client) Verify(key string) (*Result, error) {
activeDomains,
)
resp, err := c.HTTPClient.Get(endpoint)
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, endpoint, nil)
if err != nil {
return nil, fmt.Errorf("license request: %w", err)
}
resp, err := c.HTTPClient.Do(req)
if err != nil {
return nil, fmt.Errorf("license server unreachable: %w", err)
}
defer resp.Body.Close()
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("license server returned status %d", resp.StatusCode)

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

@@ -3,7 +3,7 @@ package models
import "time"
// ClusterSettings ist die Singleton-Tabelle (id=1) für VIP/VRRP-
// und Replikations-Konfiguration. Angelegt in Migration 0029.
// und Replikations-Configuration. Angelegt in Migration 0029.
// hb_* = zweite VRRP-Instanz für Split-Brain-Schutz (0033).
// gw_check_ip = Gateway-IP für vrrp_script chk_gateway (0033).
type ClusterSettings struct {

View File

@@ -2,7 +2,7 @@ package models
import "time"
// DHCPSettings ist die node-lokale Singleton-Konfiguration des Kea-DHCPv4-
// DHCPSettings ist die node-lokale Singleton-Configuration des Kea-DHCPv4-
// Servers (ob diese Node DHCP betreibt + globale Defaults).
type DHCPSettings struct {
ID int `gorm:"column:id;primaryKey" json:"id"`

View File

@@ -19,6 +19,10 @@ type Domain struct {
MaxBodyKB int `gorm:"column:max_body_kb" json:"max_body_kb"`
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

@@ -2,7 +2,7 @@ package models
import "time"
// OIDCSettings ist die Singleton-Konfiguration für OIDC/Keycloak-SSO.
// OIDCSettings ist die Singleton-Configuration für OIDC/Keycloak-SSO.
// ClientSecretEnc trägt den verschlüsselten Client-Secret (secrets.Box)
// und wird NIE serialisiert (json:"-").
type OIDCSettings struct {

View File

@@ -2,7 +2,7 @@ package models
import "time"
// RADIUSSettings ist die node-lokale Singleton-Konfiguration des
// RADIUSSettings ist die node-lokale Singleton-Configuration des
// FreeRADIUS-Servers (ob diese Node RADIUS betreibt + Listen-Adressen).
type RADIUSSettings struct {
ID int `gorm:"column:id;primaryKey" json:"id"`

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

@@ -26,7 +26,7 @@ type WireguardInterface struct {
UpdatedAt time.Time `gorm:"column:updated_at" json:"updated_at"`
// PrivateKeyEnc / PeerPSKEnc are loaded from the DB as raw bytes
// — handler never serialises them. JSON tag uses '-' so they
// — handler never serializes them. JSON tag uses '-' so they
// don't leak into responses if a developer accidentally returns
// the model directly.
PrivateKeyEnc []byte `gorm:"column:private_key_enc" json:"-"`

View File

@@ -52,9 +52,6 @@ type Service struct {
AccountDir string
DirURL string
Email string
// loaded lazily on first call
user *acmeUser
}
// New returns a Service with sensible defaults. Email comes from

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.
@@ -121,7 +122,7 @@ FROM alert_channels ORDER BY id ASC`)
}
func (s *Service) CreateChannel(ctx context.Context, c Channel) (*Channel, error) {
if c.Settings == nil || len(c.Settings) == 0 {
if len(c.Settings) == 0 {
c.Settings = json.RawMessage(`{}`)
}
row := s.Pool.QueryRow(ctx, `
@@ -138,7 +139,7 @@ RETURNING id, name, kind, target, settings, active, created_at, updated_at`,
}
func (s *Service) UpdateChannel(ctx context.Context, id int64, c Channel) (*Channel, error) {
if c.Settings == nil || len(c.Settings) == 0 {
if len(c.Settings) == 0 {
c.Settings = json.RawMessage(`{}`)
}
row := s.Pool.QueryRow(ctx, `
@@ -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,84 @@ 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
}
// DeleteAcknowledged löscht alle bereits quittierten Events und liefert
// die Anzahl. Backing für "Alle quittierten löschen".
//
// Warum das gebraucht wird (Befund 2026-09-11): Bulk-Löschen ging nur über
// die Tabellen-Auswahl, und deren Kopf-Checkbox markiert nur die AKTUELLE
// Seite. Bei einem Backlog von ~950 Alt-Alarmen loescht ein Klick also 25
// Zeilen, woraufhin die naechsten 25 — optisch identischen — nachruecken.
// Es sah aus, als passiere nichts, obwohl korrekt geloescht wurde.
//
// Bewusst nur die quittierten: was noch niemand gesehen hat, soll nicht
// per Sammelaktion verschwinden.
func (s *Service) DeleteAcknowledged(ctx context.Context) (int64, error) {
tag, err := s.Pool.Exec(ctx,
`DELETE FROM alert_events WHERE acknowledged_at IS NOT NULL`)
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.
@@ -252,7 +336,7 @@ func (s *Service) sendWebhook(ctx context.Context, c Channel, kind string,
"severity": string(sev),
"subject": subject,
"message": message,
"content": fmt.Sprintf("[%s] %s: %s\n%s",
"content": fmt.Sprintf("[%s] %s: %s\n%s",
strings.ToUpper(string(sev)), kind, subject, message),
"text": fmt.Sprintf("*[%s]* %s — %s\n%s",
strings.ToUpper(string(sev)), kind, subject, message),
@@ -269,7 +353,7 @@ func (s *Service) sendWebhook(ctx context.Context, c Channel, kind string,
if err != nil {
return err
}
defer resp.Body.Close()
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
b, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
return fmt.Errorf("webhook %d: %s", resp.StatusCode, strings.TrimSpace(string(b)))

View File

@@ -55,7 +55,7 @@ func PackageVersions(ctx context.Context, force bool) map[string]string {
defer cancel()
for _, pkg := range edgePackages {
installed, candidate := aptCachePolicy(policyCtx, pkg)
installed, candidate := aptCachePolicy(policyCtx, pkg) //nolint:contextcheck // detached by design — Cache-Read überlebt Request-Cancel
out[pkg+"_installed"] = installed
out[pkg+"_available"] = candidate
}
@@ -110,7 +110,7 @@ func refreshInternal(ctx context.Context, force bool) {
}
// StartBackgroundRefresh: Fire-and-Forget-Goroutine die einmal beim
// Start + dann periodisch apt-get update fährt, damit der Apt-Cache
// Start + dann periodic apt-get update fährt, damit der Apt-Cache
// auch ohne UI-Traffic frisch bleibt. So zeigt der Banner kurz nach
// `make publish` ein verfügbares Update, statt 5 min auf die nächste
// UI-Poll-Welle zu warten.
@@ -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

@@ -258,9 +258,13 @@ func (r *Repo) Cleanup(ctx context.Context, keepDays int) (int64, error) {
if r == nil || r.Pool == nil || keepDays <= 0 {
return 0, nil
}
// make_interval(days => $1) nimmt $1 als int — sauber typisiert. Der frühere
// ($1::text || ' days')::interval-Ansatz scheiterte, weil keepDays als int
// übergeben wird und pgx int nicht als text (OID 25) encoden kann
// ("cannot find encode plan") → Cleanup lief nie.
tag, err := r.Pool.Exec(ctx, `
DELETE FROM audit_log
WHERE created_at < NOW() - ($1::text || ' days')::interval`, keepDays)
WHERE created_at < NOW() - make_interval(days => $1)`, keepDays)
if err != nil {
return 0, err
}

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

@@ -30,7 +30,6 @@ import (
"os/exec"
"path/filepath"
"sort"
"strings"
"time"
"github.com/jackc/pgx/v5/pgxpool"
@@ -232,10 +231,9 @@ func (s *Service) Run(ctx context.Context, kind Kind, version string) (*Result,
s.recordFailure(ctx, res, hostname, kind)
return res, err
}
if err := f.Sync(); err != nil {
// Nicht fatal — fsync-failure kann passieren bei tmpfs in
// Dev, aber der File ist da.
}
// fsync best-effort — Nicht fatal: fsync-failure kann bei tmpfs in
// Dev passieren, der File ist trotzdem da.
_ = f.Sync()
if err := f.Close(); err != nil {
s.recordFailure(ctx, res, hostname, kind)
return res, err
@@ -644,10 +642,6 @@ func (b *bytes) Write(p []byte) (int, error) {
return len(p), nil
}
// strFold ist ein utility nur zum Defensiv-Check, dass kind ein
// erlaubter Wert ist (für die DB-Constraint).
func strFold(s string) string { return strings.ToLower(strings.TrimSpace(s)) }
// SortByDate sortiert Entries newest-first. Wird nicht direkt benutzt
// (DB-Query macht's), aber praktisch wenn der Caller eine eigene
// Liste hat.

View File

@@ -180,7 +180,7 @@ func uploadS3(ctx context.Context, s S3Settings, localPath string) (int64, error
if err != nil {
return 0, err
}
defer f.Close()
defer func() { _ = f.Close() }()
stat, err := f.Stat()
if err != nil {
return 0, err
@@ -245,12 +245,12 @@ func uploadSFTP(ctx context.Context, s SFTPSettings, localPath string) (int64, e
if err != nil {
return 0, fmt.Errorf("ssh dial %s: %w", addr, err)
}
defer conn.Close()
defer func() { _ = conn.Close() }()
cl, err := sftp.NewClient(conn)
if err != nil {
return 0, fmt.Errorf("sftp client: %w", err)
}
defer cl.Close()
defer func() { _ = cl.Close() }()
// remote-dir anlegen (idempotent)
_ = cl.MkdirAll(s.RemoteDir)
@@ -259,7 +259,7 @@ func uploadSFTP(ctx context.Context, s SFTPSettings, localPath string) (int64, e
if err != nil {
return 0, err
}
defer src.Close()
defer func() { _ = src.Close() }()
stat, err := src.Stat()
if err != nil {
return 0, err
@@ -269,10 +269,16 @@ func uploadSFTP(ctx context.Context, s SFTPSettings, localPath string) (int64, e
if err != nil {
return 0, fmt.Errorf("create remote: %w", err)
}
defer dst.Close()
if _, err := io.Copy(dst, src); err != nil {
_ = dst.Close()
return 0, fmt.Errorf("copy: %w", err)
}
// SFTP-Close flusht gepufferte Writes — ein Fehler hier bedeutet
// ein unvollständiges Remote-File und MUSS als Upload-Fehler
// gemeldet werden (nicht als Erfolg durchgehen).
if err := dst.Close(); err != nil {
return 0, fmt.Errorf("close remote: %w", err)
}
return stat.Size(), nil
}
@@ -284,9 +290,9 @@ func (s *Service) Test(ctx context.Context, t Target) error {
if err != nil {
return err
}
defer os.Remove(tmp.Name())
defer func() { _ = os.Remove(tmp.Name()) }()
_, _ = tmp.WriteString("edgeguard remote-target test " + time.Now().Format(time.RFC3339))
tmp.Close()
_ = tmp.Close()
r := s.uploadOne(ctx, t, tmp.Name())
if !r.OK {
@@ -331,9 +337,9 @@ func (s *Service) Test(ctx context.Context, t Target) error {
HostKeyCallback: ssh.InsecureIgnoreHostKey(), Timeout: 5 * time.Second}); err == nil {
if cl, err := sftp.NewClient(conn); err == nil {
_ = cl.Remove(strings.TrimRight(settings.RemoteDir, "/") + "/" + filepath.Base(tmp.Name()))
cl.Close()
_ = cl.Close()
}
conn.Close()
_ = conn.Close()
}
}
}

View File

@@ -5,6 +5,7 @@ package clusterjoin
import (
"bytes"
"context"
"crypto/tls"
"crypto/x509"
"encoding/json"
@@ -177,7 +178,7 @@ func NormalizePrimaryURL(in string) (string, error) {
func issueCert(primary, token, csr string, insecure bool) (caCert, peerCert string, err error) {
body, _ := json.Marshal(map[string]string{"token": token, "csr": csr})
req, err := http.NewRequest(http.MethodPost,
req, err := http.NewRequestWithContext(context.Background(), http.MethodPost,
primary+"/api/v1/cluster/issue-cert", bytes.NewReader(body))
if err != nil {
return "", "", err
@@ -193,7 +194,7 @@ func issueCert(primary, token, csr string, insecure bool) (caCert, peerCert stri
if err != nil {
return "", "", err
}
defer resp.Body.Close()
defer func() { _ = resp.Body.Close() }()
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if resp.StatusCode != http.StatusOK {
return "", "", fmt.Errorf("HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(raw)))
@@ -287,7 +288,7 @@ func autoRegister(primary, tlsDir, commonName, version, nodeID, configHash, role
}
client := &http.Client{Transport: tr, Timeout: 30 * time.Second}
httpReq, err := http.NewRequest(http.MethodPost, u.String(), bytes.NewReader(body))
httpReq, err := http.NewRequestWithContext(context.Background(), http.MethodPost, u.String(), bytes.NewReader(body))
if err != nil {
return err
}
@@ -296,7 +297,7 @@ func autoRegister(primary, tlsDir, commonName, version, nodeID, configHash, role
if err != nil {
return err
}
defer resp.Body.Close()
defer func() { _ = resp.Body.Close() }()
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(raw)))

View File

@@ -63,9 +63,9 @@ func Run(ctx context.Context, gens []configgen.Generator, only []string) ([]Resu
return out, errors.Join(errs...)
}
// Summarise turns the result slice into a human-readable multiline
// Summarize turns the result slice into a human-readable multiline
// string. Used by `edgeguard-ctl render-config` to print to stdout.
func Summarise(results []Result) string {
func Summarize(results []Result) string {
var b strings.Builder
for _, r := range results {
if r.Skipped {

View File

@@ -34,7 +34,7 @@ type Result struct {
Took time.Duration `json:"-"`
}
// validTarget ist eine konservative Erlaubnis: Buchstaben, Ziffern,
// validTarget ist eine conservative Erlaubnis: Buchstaben, Ziffern,
// Punkt, Doppelpunkt (IPv6), Schrägstrich (Pfade in curl-URLs), Bindestrich,
// Unterstrich. Whitespace, $, `, `;`, `&`, `|`, `>` etc. werden gesperrt.
func validTarget(s string, max int) error {

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, 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)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15)
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, 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.RateLimitRPS, d.MaxBodyKB, d.DisableH3, d.Notes, d.RedirectTo,
d.CrowdSecTrusted)
return scanDomain(row)
}
@@ -100,17 +102,20 @@ UPDATE domains SET
max_body_kb = $13,
disable_h3 = $14,
notes = $15,
redirect_to = $16,
crowdsec_trusted = $17,
updated_at = NOW()
WHERE id = $16
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, 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, 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) {
@@ -139,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.CreatedAt, &d.UpdatedAt,
&d.Notes, &d.RedirectTo, &d.CrowdSecTrusted, &d.CreatedAt, &d.UpdatedAt,
); err != nil {
return nil, err
}

View File

@@ -97,7 +97,7 @@ func (r *AddressGroupsRepo) Create(ctx context.Context, g models.FirewallAddress
if err != nil {
return nil, err
}
defer tx.Rollback(ctx)
defer func() { _ = tx.Rollback(ctx) }()
row := tx.QueryRow(ctx, `
INSERT INTO firewall_address_groups (name, description)
@@ -125,7 +125,7 @@ func (r *AddressGroupsRepo) Update(ctx context.Context, id int64, g models.Firew
if err != nil {
return nil, err
}
defer tx.Rollback(ctx)
defer func() { _ = tx.Rollback(ctx) }()
row := tx.QueryRow(ctx, `
UPDATE firewall_address_groups SET name = $1, description = $2, updated_at = NOW()

View File

@@ -91,7 +91,7 @@ func (r *ServiceGroupsRepo) Create(ctx context.Context, g models.FirewallService
if err != nil {
return nil, err
}
defer tx.Rollback(ctx)
defer func() { _ = tx.Rollback(ctx) }()
row := tx.QueryRow(ctx, `
INSERT INTO firewall_service_groups (name, description) VALUES ($1, $2)
@@ -115,7 +115,7 @@ func (r *ServiceGroupsRepo) Update(ctx context.Context, id int64, g models.Firew
if err != nil {
return nil, err
}
defer tx.Rollback(ctx)
defer func() { _ = tx.Rollback(ctx) }()
row := tx.QueryRow(ctx, `
UPDATE firewall_service_groups SET name = $1, description = $2, updated_at = NOW()

View File

@@ -102,7 +102,7 @@ func ReadTail(path string, f Filter) ([]Entry, error) {
}
return nil, err
}
defer file.Close()
defer func() { _ = file.Close() }()
// Ring-Buffer für die letzten f.Limit matching entries.
buf := make([]Entry, 0, f.Limit)

View File

@@ -64,7 +64,7 @@ func (t *Tailer) Start(ctx context.Context) error {
if err != nil {
return err
}
defer w.Close()
defer func() { _ = w.Close() }()
// Watch das Verzeichnis statt der Datei — wenn ulogd das File
// erstmals anlegt (oder rotate-rename), bekommen wir Create-Events.
@@ -106,7 +106,7 @@ func (t *Tailer) bootstrap() error {
if err != nil {
return err
}
defer f.Close()
defer func() { _ = f.Close() }()
sc := bufio.NewScanner(f)
sc.Buffer(make([]byte, 0, 64*1024), 1024*1024)
for sc.Scan() {
@@ -134,7 +134,7 @@ func (t *Tailer) drainFile() {
if err != nil {
return
}
defer f.Close()
defer func() { _ = f.Close() }()
stat, err := f.Stat()
if err != nil {

View File

@@ -40,11 +40,17 @@ func (g *Generator) render(ctx context.Context, excludeEthernet bool) error {
prefix int
}
// is_vip-Adressen werden NIE statisch gebunden — sie gehören
// ausschließlich keepalived (nur der VRRP-Master trägt die VIP). Würde
// der Apply sie statisch binden, läge die VIP nach einem Failover auf
// BEIDEN Nodes (statisch hier + keepalived drüben) → Duplicate-IP/ARP-
// Konflikt → Tunnel/LAN bricht. Deshalb hart ausschließen.
q := `
SELECT ni.name, ia.address, ia.prefix
FROM ip_addresses ia
JOIN network_interfaces ni ON ni.id = ia.interface_id
WHERE ia.active = true`
WHERE ia.active = true
AND ia.is_vip = false`
if excludeEthernet {
q += `
AND ni.type != 'ethernet'`

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