201 Commits

Author SHA1 Message Date
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
Debian
b3dda81b49 feat(cluster): bidirektionaler Peer-Heartbeat (Primary→Secondary Push) — v1.2.97
Bisher pushte nur der Secondary seine Liveness an den Primary (runPrimaryPush). Der Primary pushte nichts → in der lokalen ha_nodes des Secondary fror die Primary-Row nach dem Boot ein → die vom Secondary ausgelieferte UI zeigte den Primary als offline.
Neu: runPeerPush auf dem Primary/Founder pusht alle 30s self (role=primary) an jeden Peer via mTLS (/agent/cluster/peers). PushSelfToPeer(role) generalisiert PushSelfToPrimary; registerPeerRequest+AgentRegisterPeer akzeptieren ein role-Feld (default 'peer' → joining-Peer-Verhalten unverändert). Peer-Register-Log bei Routine-Pushes auf Debug (Info nur bei neuem Peer/IP-Wechsel) gegen 30s-Spam.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 13:24:13 +02:00
Debian
b20ace8763 fix(cluster): periodischer Peer-Heartbeat (30s) + Rolling-Update candidate-aware — v1.2.96
Fix 1 — Peer zeigt fälschlich 'offline': runPrimaryPush (Secondary→Primary, einziger periodischer Cross-Node-ha_nodes-Refresh) tickte mit 5 min, SweepStaleNodes-Threshold ist aber 2 min → Secondary war 2 min online, dann 3 min offline, im 5-min-Takt. Tick auf 30s (4× Marge unter Threshold). Receiver lädt nftables nur bei IP-Änderung → kein Reload-Sturm.
Fix 2 — Rolling-Update konnte nie fertig werden wenn der Secondary die Zielversion schon hatte (baseline==target → Warten auf unmöglichen Flip → 10-min-Timeout). runRollingUpdate ist jetzt candidate-aware: ermittelt apt-Candidate, überspringt den Secondary-Schritt wenn dieser schon aktuell ist, erkennt den Flip via 'erreicht candidate ODER bewegt sich von baseline', und schließt direkt mit 'done' wenn auch der Primary schon aktuell ist. FinishRollingUpdateIfPending setzt hängende updating/waiting-secondary-Phasen beim Boot auf idle zurück (tote Orchestrierungs-Goroutine nach Restart).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 13:10:40 +02:00
Debian
053b38e46c fix: AlertWriter graceful flush (#15) + Rolling-Update Robustheit (#19) — v1.2.95
#15 waf/alerts.go: AlertWriter.Close() flusht gepufferte Alerts + stoppt die Goroutine (stop/done-Channels, sync.Once, atomic closed; Kanal wird NIE geschlossen → Send racet ohne Panic). Wiring in cmd/edgeguard-waf nach ListenAndServe (graceful shutdown). -race-Test alerts_test.go.
#19 handlers/cluster_rollingupdate.go: (a) RollingUpdateStatus mutiert State nicht mehr beim GET — terminale Zustände altern in readRollingUpdateState nach 10 min aus (kein verlorenes 'done' bei parallelen Pollern). (b) State-File via sync.Mutex + configgen.AtomicWrite (kein partieller Read / Race zwischen Handler & Goroutine). (c) Version-Flip wird gegen die VORHER erfasste Secondary-Baseline geprüft statt gegen die Primary-Version (verhindert sofort-/nie-Flip).
Bewusst belassen: geteilter upgrade.sh-Pfad ist deterministischer Inhalt + an exakte sudoers-Zeile gebunden → Überschreib-Race benign; MST-Timestamp-Parse locale (Server laufen C-Locale).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 11:21:50 +02:00
Debian
df31bfa720 fix: Audit-Bugfixes (Auth/WAF/Firewall/Cluster/Renderer) — v1.2.94
Verifizierte Bugs aus dem Code-Audit behoben (je mit Test/Build/nft -c geprüft):
- session: IssueWithRoleTTL mutierte geteiltes s.TTL (Data-Race + falsche TTL) → interne issue(); -race-Test.
- auth: Fallback/Federation leiteten role/TOTP nicht aus DB ab (2FA-Bypass auf Secondary, Rolle aus Remote) → viaDB-Flag + DB-Re-Lookup.
- waf: TrustedProxies waren No-op (bogus-Direktive) → XFF-Auflösung im SPOE-Agent (rightmostXFF/ipMatchesAny); RuleExclusions/TrustedProxies validiert (Direktiven-Injection); GetForHost via net.SplitHostPort.
- firewall: Auto-Rule mit IPv6-DstIP erzeugte 'ip daddr <v6>' → bricht ganzes nft-Ruleset; jetzt familienbewusst (ip/ip6, ungültige raus).
- kea: 'interfaces': null bei 0 Subnets → leeres Array.
- cluster_repair: nodeHasPublication schluckte DB-Fehler (Resync auf falschem Node) → (bool,error) fail-closed; IPv6-Primary-URL via net.JoinHostPort.
- cluster_replication: Replikations-Passwort via stdin statt psql -c (nicht mehr in argv/Logs).
- wireguard: Config (Private Key) jetzt configgen.AtomicWrite VOR Symlink/enable; SkipReload-Feld.
- render.go: --no-reload jetzt für alle Renderer (squid/unbound/chrony/wireguard).
- radius: leeres Secret/Passwort + Newlines abgelehnt; freeradius confEscape strippt CR/LF.
- configorch: continue-on-error + errors.Join statt Abbruch mitten in der Sequenz.
- i18n: fehlender Key common.status (de/en).
Verworfen als kein Bug: WAF detection-'blocked' (DetectionOnly liefert keine Interruption), render secrets.New('') (nutzt Default-Masterkey), FanOut-Sort (nur Kommentar), pg_hba (durch nft abgesichert).
Offen/bewusst zurückgestellt (low/risk): AlertWriter-Close (langlebiger Worker, vernachlässigbar), Rolling-Update-Kleinkram (sudoers-gebundener Script-Pfad / GET-State).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 10:55:21 +02:00
Debian
5f92851a96 feat(radius): RADIUS-Server via FreeRADIUS (PAP/CHAP) — v1.2.93
Files-basierter RADIUS-Server (Clients + Users), managed analog DHCP/WireGuard.
- Migration 0042: radius_settings (singleton, node-lokal), radius_clients (secret_enc), radius_users (password_enc) — Secrets via secrets.Box verschlüsselt.
- internal/freeradius: Multi-File-Renderer (clients.conf + authorize) via Box.Open, Secret-Escaping (" \), Service default-off/an enabled gekoppelt. internal/services/radius + internal/handlers/radius.go: Settings + Client/User-CRUD, write-only Secret-Semantik, Validierung (IP/CIDR, name-charset), GET liefert secret_configured statt Secret.
- Firewall: udp 1812/1813 Auto-Rule bei enabled. Cluster: clients/users repliziert (hashSpec), radius_settings node-lokal.
- main.go + render.go + WithAllReloaders. Packaging: freeradius Dependency, setgid-Dir /etc/edgeguard/freeradius (Gruppe freeradius), Symlinks clients.conf+authorize, disable-on-install, sudoers.
- UI: RADIUS-Seite (Einstellungen + Clients + Benutzer) unter Sicherheit, Route/Nav/i18n de/en.
- Tests (guarded): Renderer-Inhalt + Secret-Escaping/Roundtrip + Masking. Scope v1: PAP/CHAP files-based (kein EAP/802.1X).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 19:46:42 +02:00
Debian
bac7c7e349 feat(dhcp): DHCPv4-Server via Kea (kea-dhcp4-server) — v1.2.92
Verwalteter DHCPv4-Server analog Unbound/Squid/Chrony.
- Migration 0041: dhcp_settings (singleton, node-lokal), dhcp_subnets, dhcp_reservations.
- internal/kea: Renderer baut Kea-JSON via Go-Struct→Marshal (garantiert valide), managed /etc/edgeguard/kea/kea-dhcp4.conf (Symlink von /etc/kea), Service-Lifecycle an enabled gekoppelt (default AUS, kein rogue DHCP). Interface per NAME (cluster-sicher, kein node-lokaler FK).
- internal/services/dhcp + internal/handlers/dhcp.go: Settings + Subnet/Reservation-CRUD, Validierung (CIDR/IP/MAC/interface exists).
- configgen: Stop/Enable/DisableService. Firewall: AutoFWRule.Iface → udp/67 pro LAN-Interface gescopt (kein WAN). Cluster: subnets/reservations repliziert (hashSpec), dhcp_settings node-lokal (localOnlyTables).
- main.go + render.go + WithAllReloaders Wiring. Packaging: kea-dhcp4-server Dependency, /etc/edgeguard/kea Dir, Symlink, disable-on-install, sudoers (restart/stop/enable/disable).
- UI: DHCP-Seite (Settings + Subnets + Reservierungen pro Subnet), Route/Nav/i18n de/en, HA-Warnung 'nur auf einer Node aktivieren'.
- Tests (guarded EG_FWTEST_DSN): Kea-Renderer gegen DB (valides JSON + Felder), FW-Auto-Rule-Iface inkl. nft -c. Scope v1: DHCPv4.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 18:56:30 +02:00
Debian
3a707e2e3f feat(auth): OIDC/Keycloak SSO-Login (additiv) — v1.2.91
SSO per OpenID Connect (Authorization Code + PKCE) zusätzlich zum lokalen Login.
- Regeln: kein Auto-Provisioning (E-Mail muss als User existieren), Rolle aus DB (nie aus Token), lokaler Login+TOTP unangetastet.
- Migration 0040: oidc_settings (Singleton, client_secret_enc via secrets.Box) + users.oidc_subject.
- internal/services/oidc: Settings-Repo (write-only Secret) + lazy go-oidc Client (testbarer Authenticator-Seam).
- internal/handlers/oidc.go: GET/PUT /oidc/settings (admin), GET /auth/oidc/{settings,login,callback}. Flow-State (state/PKCE/nonce) stateless im 5-min signierten HttpOnly-Cookie (SameSite=Lax). email_verified erzwungen, opportunistisches sub-Linking, Session via setSessionCookie+Signer.
- session.SignBlob/VerifyBlob; users.Get/SetOIDCSubject; main.go-Wiring.
- Frontend: App.tsx /auth/me-Bootstrap (für Cookie-Session nach Callback), Login-SSO-Button + sso_error, Settings OIDC-Card, i18n de/en.
- Tests (guarded EG_FWTEST_DSN): Secret-Roundtrip + Callback (Rolle-aus-DB, no_account, disabled, unverified, nonce, state).
Deps: go-oidc/v3, x/oauth2. Scope v1: nur Login (kein SLO/Refresh).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 16:04:13 +02:00
Debian
f85552a475 test(firewall): End-to-End-IPv6-Test (echte DB + Generator + nft -c)
Guarded per EG_FWTEST_DSN (sonst skip). Migrations + v4/v6-Adressobjekte/Gruppe/icmpv6/v6-DNAT seeden, echten Generator.RenderToString laufen lassen, Output mit nft -c (via sudo) validieren. Verifiziert u.a.: gemischte Adressgruppe splittet in ip+ip6, gemischte v4/v6-NAT wird übersprungen, v6-DNAT-Target [..]:port.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 10:15:06 +02:00
Debian
92c5e25557 test(firewall): nft -c-Check via sudo lauffähig machen (netlink braucht root)
nft -c liest den Kernel-Ruleset-Cache via netlink → root nötig. Test nutzt nun sudo -n (sonst skip). Verifiziert: gerendertes Dual-Stack-Ruleset (v4+v6-Regeln, v6-DNAT [..]:port, v6-SNAT/Masquerade, icmpv6) ist mit nft v1.1.3 syntaktisch gültig.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 22:17:34 +02:00
Debian
9383b870b0 feat(firewall): IPv6 in Regeln + NAT (familienbewusstes nft-Rendering) — v1.2.90
Bisher rendete das Template alle Adress-Matches als 'ip saddr/daddr' (v4-only); ein v6-Eintrag hätte 'nft -f' (und damit das ganze Ruleset) gebrochen. Jetzt:
- Adressausdrücke werden je Eintrag als v4/v6 klassifiziert (addrFamily).
- Regeln mit Adressen werden pro Familie als separate nft-Zeile gerendert (ip vs ip6 saddr/daddr); adresslose Regeln bleiben eine familienagnostische Zeile (v4-Verhalten unverändert).
- icmp nur auf v4-, icmpv6 nur auf v6-Zeilen.
- NAT familienbewusst inkl. v6-DNAT-Target [..]:port; gemischte v4/v6-NAT-Regeln werden übersprungen (statt nft -f zu brechen) + geloggt.
- WireGuard site-to-site Masquerade v6-fähig.
Eingabeseite war bereits v6-fähig (validateAddrObjValue/validateRule via net.ParseIP/ParseCIDR; Service-Proto-CHECK erlaubt icmpv6; Builtin PING-v6). Neue Unit-Tests (firewall_ipv6_test.go) inkl. optionalem 'nft -c'-Syntaxcheck.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 21:04:38 +02:00
Debian
4416d361a0 docs(migrations): 0030-Kommentar korrigieren — network/ip node-lokal, nicht repliziert
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 13:39:44 +02:00
Debian
58e42eb269 fix(cluster): node-lokale Tabellen aus Drift-Hash entfernen — v1.2.89
network_interfaces + ip_addresses standen in confighash hashSpec, aber in cluster_replication.go localOnlyTables (= nicht repliziert, node-spezifische IPs). Dadurch waren die config_hash-Werte zweier Nodes ZWANGSLÄUFIG dauerhaft verschieden → Drift-Banner, das kein Resync beheben konnte. Beide Tabellen aus dem Hash entfernt; Drift erkennt jetzt nur noch wirklich replizierte Service-Config. Muss auf BEIDEN Nodes installiert sein (gleicher hashSpec für vergleichbare Hashes).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 13:35:32 +02:00
Debian
90f0df4c45 fix(cluster): Repair-Rollenerkennung über pg_publication statt ha_nodes.role — v1.2.88
Bug: Dispatch an utm-2 schlug fehl ('dieser Node ist der Cluster-Primary'), weil ha_nodes je Node lokal ist und sich JEDE Node selbst als role=primary markiert. Fix: Primary-Erkennung über pg_publication (edgeguard_shared, für jeden DB-User lesbar) statt role/pg_role. Primary gibt dem Subscriber seine eigene Adresse als primary_host mit (PostPeerWithBody); Agent-Handler vertraut dem mTLS-Dispatch mit Safety-Guard 'läuft nie auf dem Publication-Primary'. Funktioniert auch bei Direktzugriff auf den Subscriber. UI-Gating vereinfacht (Drift + Peer + Admin).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 13:18:54 +02:00
Debian
66c71c5fa8 fix(cluster): Repair-Button-Gating auf role statt pg_role — v1.2.87
pg_role bleibt nach cluster-setup-standby auf 'standalone' (nur 'promote' setzt 'primary'), daher erschien der Button auf dem Primary (role=primary, pg_role=standalone) nicht. Gating + Dispatch + Status nutzen jetzt isPrimaryNode = role=='primary' || pg_role=='primary' (wie keepalived); Resync-Ziel = Nicht-Primary-Peer. Backend (cluster_repair.go) + UI (Cluster/index.tsx).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 13:03:17 +02:00
Debian
f7dd7a3a4b feat(cluster): GUI-Repair-Button für Config-Drift + Stale-Chunk-Auto-Reload — v1.2.86
Cluster/Replication:
- Drift-Banner: Button 'Resync erzwingen' baut die PG-Logical-Replication-
  Subscription neu auf (via edgeguard-ctl cluster-setup-standby).
- Primary-Dispatch: Button auf dem Primary delegiert per mTLS an den
  Standby (POST /agent/cluster/repair-replication); auf dem Standby lokal.
- Status-Proxy Primary->Standby via Aggregator.FanOut; Erfolg = Job-success
  ODER drift_found wird false (--collect-Unit verschwindet nach Erfolg).
- Job als transiente systemd-Unit edgeguard-repair-replication.service
  (sudoers exact-match + festes Script wie upgrade.sh).
- Banner-Text korrigiert (keine 'Outbox').

Frontend-Stabilität:
- Stale-Chunk-Auto-Reload: Lazy-Import-Fehler nach Deploy ('Failed to fetch
  dynamically imported module') lösen einen einmaligen Reload aus (Loop-
  Schutz via sessionStorage) statt einer Fehlerseite. Globaler
  vite:preloadError-Listener + ErrorBoundary-Integration.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 12:10:41 +02:00
Debian
025854150d feat(waf): ausgeschlossene Regeln grün + Bereits-Ausnahme-Tag — v1.2.84
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 13:52:03 +02:00
Debian
134466293d fix(waf): Setup-Regeln (900xxx–909xxx) nicht als Alerts speichern — v1.2.83
CRS 900xxx/901xxx (init, body inspection, paranoia setup) feuern auf
JEDEM Request — keine Security-Events. Filter: nur rule_id >= 910000
wird als Alert in DB geschrieben. Buffer 512 → 2048.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 13:45:04 +02:00
Debian
13d0b557c1 fix(waf): letzter String-Fehler in crsRules.ts
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 13:30:26 +02:00
Debian
ac3223411a fix(waf): \" → ' in crsRules.ts
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 13:28:45 +02:00
Debian
19107ee702 fix(waf): TS-Syntaxfehler in crsRules.ts — Backslash escaped
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 13:27:57 +02:00
Debian
2ab9da8e36 fix(waf): CRS v4 Regeln extrahiert + Control-Flow-Regeln filtern — v1.2.82
- crsRules.ts neu: 331 echte CRS v4.7.0 Regeln aus installierten Dateien
  (v3-Nummernschema war falsch, v4 hat andere IDs — 949152 war skip-Regel)
- spoe.go: Regeln ohne Message nicht als Alert speichern

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 13:25:46 +02:00
Debian
f0120b64f3 feat(waf): CRS-Regel-Beschreibungen als Tooltip — v1.2.81
~250 CRS-Regeln (Setup, Scanner, Protocol, IP-Reputation, LFI, RFI, RCE,
PHP/Node.js/Java Injection, XSS, SQLi, Session-Fixation, Data-Leakage).
Rule-ID-Tag in Alerts-Tabelle + Ausnahmen-Liste zeigt Tooltip bei Hover.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 11:43:07 +02:00
Debian
4f31e18d66 fix(waf): i18n-Keys für Exception-Modal in waf.alerts verschoben — v1.2.80
exceptionModalTitle/Hint/NotePlaceholder lagen in waf.config statt waf.alerts.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 11:35:48 +02:00
Debian
8a4fefee73 feat(waf): Ausnahmen mit Notiz + Ausnahmen-Liste im Drawer — v1.2.79
- Migration 0039: exclusion_notes JSONB in waf_configs (rule_id → note)
- Model/Service/Handler: exclusion_notes in Upsert + GET durchgereicht
- Alerts-Tab: "Als Ausnahme"-Button öffnet Modal mit Notiz-Textarea;
  Notiz wird in exclusion_notes gespeichert
- Config-Drawer: Ausnahmen als Liste (Rule-ID + Notiz + Entfernen-Button)
  statt rohem Textfeld; Ausnahmen nur noch via Alert-Tab hinzufügbar
- i18n EN + DE

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 11:31:49 +02:00
Debian
801fa26da7 feat(waf): Regel direkt aus Alert als Ausnahme hinzufügen — v1.2.78
Jede Alert-Zeile hat jetzt einen "Als Ausnahme"-Button. Klick:
1. Lädt aktuelle WAF-Config der betroffenen Domain
2. Fügt die Rule-ID zu rule_exclusions hinzu (dedupliziert)
3. Speichert via PUT /waf/configs/:domain_id → triggert HAProxy-Reload
4. Erfolgsmeldung + WAF-Config-Query invalidiert

Button disabled wenn domain_id fehlt (Domain nicht aufgelöst) oder Viewer-Rolle.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 11:20:52 +02:00
Debian
220d9d7050 feat(waf): Alerts — Regelübereinstimmungen in DB + UI — v1.2.77
- Migration 0038: waf_alerts-Tabelle
- AlertWriter (Buffered-Channel → async DB-Write)
- SPOE: MatchedRules → sendAlert() nach ProcessRequestHeaders()
- API: GET /waf/alerts + DELETE /waf/alerts
- WAF-Page: Tabs Domains | Alarme; Alarme-Tabelle mit Rule-ID,
  Severity, Aktion (Detected/Blocked), URI, Client-IP + Purge-Button

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 10:40:43 +02:00
Debian
c83bb7b137 fix(haproxy): option tcplog + no option forwardfor entfernt — v1.2.76
HAProxy 3.x kennt weder 'option tcplog' in Backends noch 'no option forwardfor'
als Negation — beides ALERT-Fehler die HAProxy am Start hindern.
SPOE-Backend benötigt weder forwardfor noch tcplog.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 10:27:05 +02:00
Debian
08119f8ccf fix(waf): Engine nur bei Konfigurationsänderung neu bauen — v1.2.75
Manager.Reload() hat bisher bei jedem 30s-Tick alle Engines neu gebaut
(BuildEngine mit CRS = 2-5s). Fix: configKey (enabled, mode, paranoia_level,
updatedAt) cachen — Engine wird nur neu gebaut wenn sich der Key ändert.
Spart CPU und verhindert sporadische Latenzen im SPOE-Handling.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 07:07:30 +02:00
Debian
8041e3924d fix(waf): no option forwardfor + timeout server 1s für SPOE-Backend — v1.2.74
SPOE-Backend ist TCP-Mode — forwardfor aus defaults-Block gilt nicht,
HAProxy wirft Warning. no option forwardfor explizit setzen unterdrückt das.
timeout server auf 1s angehoben (konsistent mit processing-Timeout).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 07:04:26 +02:00
Debian
884c52a8f3 fix(waf): continue-on-error + timeout 1s + alle CRS-Dateien — v1.2.73
- SPOE-Config: option continue-on-error — HAProxy blockt nie wegen
  SPOE-Timeout (z.B. CRS-Engine-Load beim ersten Request). Ohne dieses
  Flag waren alle Requests geblockt wenn der WAF-Agent kurz nicht
  antwortete, auch für Domains ohne WAF-Konfiguration.
- SPOE-Config: timeout processing 50ms → 1s — CRS-Load braucht >50ms
- postinst: *.conf → * beim CRS-Copy — .data-Dateien wurden nicht
  mitkopiert, SecRule @pmFromFile scanners-user-agents.data fehlte

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-02 22:30:17 +02:00
Debian
05ac3344fa fix(waf): HAProxy-Reload nach WAF-Config + cfg-file → config — v1.2.72
- handlers/waf.go: Reloader-Func; nach Upsert wird HAProxy async
  neu gerendert (SPOE-Filter erscheint/verschwindet je nach enabled-Stand)
- main.go: haproxyReloader an NewWafHandler übergeben
- haproxy.cfg.tpl: cfg-file → config (HAProxy 3.0 kennt cfg-file nicht)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-02 22:10:55 +02:00
Debian
4d81d31022 fix(waf): TS-Fehler in WAF-Page beheben
- Badge-Import entfernt (unused)
- fetchWafConfig: r.data.data korrekt gecastet
- WafFormValues-Interface für Form (rule_exclusions_str, trusted_proxies_str)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-02 22:04:11 +02:00
Debian
4f887df658 feat(waf): Phase 5 — WAF-UI (per-Domain Konfiguration) — v1.2.71
- pages/WAF/index.tsx: neue WAF-Seite mit Status-Strip,
  Domänen-Tabelle (Toggle/Mode/PL) + Konfigurations-Drawer pro Domain
  (enabled, mode, paranoia_level 1-4, rule_exclusions, trusted_proxies,
  custom_rules). Quick-Toggle ohne Drawer; Hinweis: erst Detection, dann Blocking.
- App.tsx: /waf Route + lazy import
- Sidebar.tsx: WAF im Security-Bereich
- i18n EN + DE

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-02 22:03:01 +02:00
Debian
66dec8cf61 fix(ui): ErrorBoundary bei Navigation resetten — v1.2.70
ErrorBoundary lag in main.tsx außerhalb des Routers und hatte keinen
Zugriff auf useLocation. Einmal gecatchter Fehler blieb erhalten bis
zum nächsten Reload — daher "EdgeGuard konnte nicht laden" bei
Navigation zum Dashboard.

Fix: LocationKeyBoundary-Wrapper innerhalb des BrowserRouter mit
key={pathname} — React remountet die ErrorBoundary bei jedem
Routenwechsel und löscht damit den Error-State automatisch.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-02 21:03:23 +02:00
Debian
3a122ffb0f feat(waf): Phase 4 — Packaging, CRS, systemd, sudoers — v1.2.69
- Makefile: edgeguard-waf zu BINARIES hinzugefügt
- deploy/systemd/edgeguard-waf.service: systemd-Unit (User=edgeguard,
  After=postgresql + edgeguard-api, Hardening, ReadWritePaths=/var/log/edgeguard)
- build-package.sh: edgeguard-waf Binary + Service ins .deb
- postinst: OWASP CRS v4.7.0 Download bei Erstinstall nach
  /usr/share/edgeguard/waf/crs/; graceful wenn kein Internet vorhanden
- postinst: edgeguard-waf.service enable + start bei install/upgrade
- postinst: sudoers für start/stop/enable/disable/restart edgeguard-waf.service
- system.go: edgeguard-waf in servicesToCheck + toggleAllowlist

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-02 16:03:37 +02:00
Debian
bf16ce6666 feat(waf): Phase 3 — HAProxy SPOE-Integration — v1.2.68
- haproxy.cfg.tpl: filter spoe + deny_status 403 + spoe-edgeguard-waf
  Backend (nur gerendert wenn WAFEnabled=true)
- haproxy.go: WAFEnabled in View; WafRepo.ListEnabled() prüft ob WAF
  aktiv; SPOE-Config-File (coraza-spoe.cfg) wird bei WAFEnabled
  atomar geschrieben; SPOEConfigPath konfigurierbar
- waf/spoe.go: uri statt path+query (HAProxy url-Sample = volle URI)

SPOE-Config definiert:
  - Agent: edgeguard-waf-agent, var-prefix=waf, timeout processing 50ms
  - Message: src, method, uri=url, ver=req.ver, headers=req.hdrs,
             host=req.hdr(host)
  - Backend: spoe-edgeguard-waf → 127.0.0.1:9000

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-02 15:53:07 +02:00
Debian
bd32bc343a feat(waf): Phase 2 — edgeguard-waf Binary + SPOE + Coraza Engine — v1.2.67
- cmd/edgeguard-waf/: neues Binary — lädt WAF-Configs aus DB, startet
  SPOE-Agent auf 127.0.0.1:9000, refreshed Configs alle 30s
- internal/waf/engine.go: BuildEngine() — Coraza WAF aus WafConfig bauen
  (SecLang-Direktiven: RuleEngine, PL, CRS-Include, Exclusions, Custom)
- internal/waf/manager.go: Manager — per-Hostname Coraza-Engine-Cache
  (thread-safe, Lazy-Init via Reload(), Port-Strip, IPv6-Brackets)
- internal/waf/spoe.go: SPOEAgent — haproxy-go SPOE-Handler
  (src/method/path/query/ver/host/headers aus HAProxy-Vars,
   Coraza-Transaction, Blocking: txn.waf.status=403 setzen)
- services/waf/waf.go: ListAllWithDomain() — JOIN domains+waf_configs
- go.mod: coraza/v3 v3.7.0 + dropmorepackets/haproxy-go v0.0.8

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-02 15:43:15 +02:00
Debian
72f793552e feat(waf): Phase 1 — Migration + Service + Handler — v1.2.66
- Migration 0037: waf_configs-Tabelle (domain_id FK, enabled=false default,
  mode/paranoia_level/rule_exclusions/trusted_proxies/custom_rules)
- models/waf.go: WafConfig-Model
- services/waf/waf.go: Repo (List, GetByDomain, Upsert, ListEnabled)
- handlers/waf.go: GET /waf/configs, GET /waf/configs/:id, PUT /waf/configs/:id
  — GET liefert Default-Config (disabled) wenn noch kein Row existiert
- main.go: WafHandler registriert

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-02 15:28:29 +02:00
Debian
b7b40ad641 fix(firewall): groupedSections nach filteredRules + pagination-Prop entfernt
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-02 14:06:24 +02:00
Debian
d425b696f1 feat(firewall): Regeln nach Zone-Pair gruppieren — v1.2.65
- Regeln werden standardmäßig nach Zone-Pair (src→dst) gruppiert
- Jede Gruppe hat einen farbigen Header mit Zone-Badges + Regelanzahl
- Toggle-Button in der Filter-Bar: Gruppen-Ansicht ↔ flache Liste
- Move-up/down bleibt global korrekt (Priority über alle Gruppen)
- CSS: .fw-zone-section* mit nahtlosem Header → Tabelle Übergang
- i18n EN + DE

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-02 14:04:56 +02:00
Debian
9580070a50 fix(crowdsec): cscli-Aufrufe nur wenn Agent läuft — v1.2.64
Wenn CrowdSec gestoppt war, hing ServiceStatus auf cscli decisions/alerts/
bouncers/machines (Socket des gestoppten Agents). Der Status-Endpoint
antwortete nie → status=undefined im UI → Switch war disabled (rotes Schild).

Fix: cscli-Datenabrufe (decisions, alerts, bouncers, machines) nur wenn
AgentRunning=true. Version-Abruf via cscli version bleibt (schlägt schnell
fehl wenn Agent gestoppt, kein Hang).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-02 09:09:39 +02:00
Debian
414dad6b3b feat(crowdsec): IDS ein-/ausschalten via Switch — v1.2.63
- system.go: ServiceToggle-Endpoint (POST /system/service-toggle)
  start/stop + enable/disable für crowdsec + crowdsec-firewall-bouncer
- system.go: crowdsec + crowdsec-firewall-bouncer in servicesToCheck
- postinst: sudoers-Einträge für systemctl start/stop/enable/disable
  beider CrowdSec-Units
- UI: Switch im StatusStrip für Agent + Bouncer, getrennt schaltbar,
  disabled wenn CrowdSec nicht installiert oder Viewer-Rolle

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 22:17:22 +02:00
Debian
112a945b5c fix(ui): Design-Harmonisierung mit mail-gateway — v1.2.62
- Header: blur 12px, box-shadow, padding 0 24px, bg rgba(0.97)
- Content-Area: padding 24px (war 12px/16px); @992px 16px (war 8px)
- Sidebar: active-color via CSS-Variable statt hardcodiertem #60a5fa
- Sidebar: item font-size 14px, padding 8px 10px (wie mail-gateway)
- Tabs: ink-bar height 3px (war 2px)
- stats-window-label: Pill-Style statt Block+kursiv
- page-toolbar: align flex-start, margin-bottom 20px, gap 16px
- ant-alert: left-accent 3px pro Typ (info/success/warning/error)
- Neu: ant-statistic, stat-card-*, ant-descriptions-*, ant-form-item rhythm

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 21:06:33 +02:00
Debian
1740cb7ae2 feat(crowdsec): CrowdSec IDS/IPS Management — v1.2.61
- Backend: internal/crowdsec/service.go — vollständige cscli-Wrapper
  (Decisions, Alerts, Bouncers, Machines, Collections, ServiceStatus)
- Handler: 12 REST-Endpoints mit Audit-Logging unter /crowdsec/*
- Migration 0036: crowdsec_settings-Tabelle
- postinst: CrowdSec-Auto-Install (crowdsec + crowdsec-firewall-bouncer-nftables)
  inkl. sudoers-Einträge für alle cscli-Operationen
- systemd: /var/lib/crowdsec in ReadWritePaths
- UI: CrowdSec-Page mit StatusStrip + 5 Tabs (Decisions, Alerts, Bouncers,
  Machines, Collections), Sidebar-Eintrag, i18n EN+DE
- firewall: flush ruleset → flush table inet edgeguard (CrowdSec-nftables-Table
  bleibt bei Firewall-Render erhalten)
- cluster: Firewall-Reload nur bei echter IP-Änderung, nicht bei jedem
  periodischen Secondary-Heartbeat (verhindert nftables-Counter-Reset)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 20:25:48 +02:00
Debian
e999eb68c2 fix(firewall/ui): Card-Tabs + no-cache Header für index.html
- Tabs type="card": jeder Tab hat eigene sichtbare Box statt Unterstrich
- cardBg: #F1F5F9 für inaktive Card-Tabs
- main.go: index.html + SPA-Fallback mit no-cache Header ausliefern
  (Vite hashed assets /assets/* bleiben immutable=1Jahr cached)
- Verhindert dass der Browser veraltete index.html nach Updates cached

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-31 23:37:56 +02:00
Debian
a84b9ae10a fix(firewall): Tab-Farben im globalen ConfigProvider — AntD 6 Tokens korrekt
Nested ConfigProvider überschreibt Root-Provider in AntD 6 nicht zuverlässig.
Tabs-Tokens (itemColor #334155, hover #0F172A, selected/inkBar #0EA5E9,
fontSize 13, padding 10px 18px) direkt in globalem antdTheme in App.tsx.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-31 23:16:41 +02:00
Debian
836016648c fix(firewall): Tab-Farben via ConfigProvider statt CSS-Override
AntD 6 CSS-in-JS überschreibt externe Styles — ConfigProvider mit
Tabs-Tokens ist der korrekte Weg: itemColor #475569, hover #1e293b,
active #1677ff, fontSize 13px, Padding 10px 16px.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-31 19:15:37 +02:00
Debian
6a4460dfdc fix(firewall): Tab-Navigation deutlich besser lesbar
- fw-tabs: Tab-Schrift 13px, font-weight 500, Farbe #64748B (statt AntD-Standard grau)
- Aktiver Tab: #1677ff + font-weight 600 + 2px Unterstrich
- Hover: #334155 für klare Reaktion
- Ink-Bar: 2px Höhe mit abgerundeten Ecken
- Nav-Separator: #E2E8F0 statt default hellgrau

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-31 19:02:25 +02:00
Debian
98aa7c0bcd fix(sidebar): Navigationspunkte besser lesbar — Kontrast + Trennlinien
- sidebar-section-label: #475569 → rgba(255,255,255,0.38) (Kontrast ~5:1)
- Aktiver Eintrag: color: #60a5fa + font-weight 500 statt #1677ff
- Hover: rgba(255,255,255,0.88) statt #CBD5E1
- Ruhige Items: rgba(255,255,255,0.6) statt #94A3B8
- Child-Items: rgba(255,255,255,0.42), eingerückt 32px
- sidebar-section--bordered: Trennlinie zwischen Sektionsgruppen
- Sektions-Padding leicht erhöht (18px oben)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-31 18:54:51 +02:00
Debian
b48ba65ce3 feat(firewall): Inline-Note + Labels direkt in der Tabelle
- Migration 0035: note TEXT + labels TEXT[] für firewall_rules + nat_rules
- PATCH /firewall/rules/:id + /nat-rules/:id für note/labels Updates
- InlineNote: gold Tag mit MessageOutlined, Klick zum Bearbeiten (Enter/Blur speichert)
- InlineLabels: geekblue Tags mit X-Button zum Entfernen, "+" zum Hinzufügen
- Name-Spalte: Name + Labels + Note in Zeile 1, comment/auto-desc in Zeile 2
- Gleiche UX für NAT-Regeln

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-31 18:46:54 +02:00
Debian
13cb9a8fc4 feat(firewall): NAT-Regeln enterprise-Design + Duplicate
- Status-Dot, monospace Priorität, icon-only Hover-Actions
- Duplicate-Button (CopyOutlined) — disabled=false copy + priority+1
- rowClassName für deaktivierte NAT-Regeln (fw-rule-row--disabled)
- Spaltenheader bereinigt (kein 'Edit'-Text mehr)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-31 18:27:28 +02:00
Debian
1d06b28064 feat: HA-Cluster v1.2.x — Split-Brain, TOTP, Enterprise-FW, Drift-Fix, VIP-Recovery
- keepalived: pg_role='standby' hat Vorrang vor role für BACKUP-Bestimmung
- keepalived-master.sh: gecrasht Dienste beim MASTER-Übergang starten (nicht nur reload)
- confighash: ip_addresses per Interface-Name hashen statt per FK (Cross-Node-Drift-Fix)
- TOTP/2FA: RFC 6238 — Setup-Flow, QR-Code, Admin-Disable; two-step Login
- Firewall-UI: Enterprise-Design — auto-Beschreibung, icon-only Actions, zero-hit Indikator
- fe80-Filter: Link-local IPv6 aus NTP/DNS Listen-Dropdowns entfernen
- VIP-Dashboard, Dual-Path VRRP, GW-Tracking (Migrations 0033/0034)
- Forward Proxy + DNS erweiterte Einstellungen (Migrations 0031/0032)
- unbound-control: edgeguard in unbound-Gruppe via postinst

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-31 18:18:31 +02:00
Debian
49899e984c fix(network): context.Background() für applyAsync + StartLimitIntervalSec=0
applyAsync()-Goroutinen in networks.go und ipaddresses.go nutzten
c.Request.Context() — der wird gecancelt wenn der Handler zurückkehrt,
was zu "query: context canceled" in jedem Apply-Lauf führte.
Fix: context.Background() direkt in der Goroutine.

StartLimitIntervalSec=0 in beiden apply-Services verhindert dass
systemd bei mehrfachen schnellen Triggers (Burst > 5) drosselt.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-30 13:41:29 +02:00
Debian
0ee754e231 feat(network): VLAN/bridge/bond interface provisioner + IP-address apply
Fügt zwei neue systemd-oneshot-Services hinzu:
- edgeguard-interfaces.service: erstellt VLAN/bridge/bond-Interfaces
  (ip link add/del) wenn der Operator sie über die GUI anlegt/entfernt.
  Ethernet + WireGuard bleiben OS-managed.
- edgeguard-ipaddresses.service: bindet/entfernt IP-Adressen (ip addr
  add/del) nach jeder GUI-Mutation; läuft jetzt After=edgeguard-interfaces
  damit Interfaces immer vor den Adressen existieren.

Beide Services triggern per applyAsync() in den zugehörigen Handlern
(networks.go → Interfaces, ipaddresses.go → Adressen). Diff-Ansatz
über *-applied.conf verhindert dass manuell gebundene Adressen/
Interfaces angefasst werden.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-30 13:25:26 +02:00
Debian
eaa6a04234 feat(cluster): PG Logical Replication setup + Master-Key-Sync + Firewall-Ports
cluster-init-replication:
- listen_addresses = '*' damit Cluster-Peers PG auf :5432 erreichen können
- max_replication_slots = 20 / max_wal_senders = 10 (verhindert Slot-Erschöpfung
  bei initaler Tabellen-Synchronisation mit vielen gleichzeitigen Sync-Workern)
- pg-replication-secret: Ownership an edgeguard-User (API-Lesezugriff)
- detectPGConfig() statt hardcoded PG 16 (System läuft PG 17)

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

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

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

v1.2.15

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

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

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

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

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

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

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

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

Fix: DeletePlaceholdersByFQDN vor UpsertSelf in beiden Code-Paths.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

v1.1.103

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

v1.1.102

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

v1.1.101

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

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

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

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

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

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

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

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

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

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

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

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

@@ -4,13 +4,13 @@
GO ?= $(shell which go || echo /usr/local/go/bin/go)
MODULE := git.netcell-it.de/projekte/edgeguard-native
BINARIES := edgeguard-api edgeguard-scheduler edgeguard-ctl
BINARIES := edgeguard-api edgeguard-scheduler edgeguard-ctl edgeguard-waf
VERSION := $(shell cat VERSION 2>/dev/null || echo 0.0.1-dev)
LDFLAGS := -s -w -X main.version=$(VERSION)
GOFLAGS := -trimpath -mod=readonly
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.1.89
1.3.36

View File

@@ -6,6 +6,7 @@ package main
import (
"context"
"crypto/rand"
"errors"
"log"
"log/slog"
"net/http"
@@ -17,50 +18,58 @@ 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"
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/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"
"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"
"git.netcell-it.de/projekte/edgeguard-native/internal/services/clusterjoin"
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/aggregator"
"git.netcell-it.de/projekte/edgeguard-native/internal/cluster/clustertls"
"git.netcell-it.de/projekte/edgeguard-native/internal/cluster/jointoken"
aptsvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/apt"
"git.netcell-it.de/projekte/edgeguard-native/internal/services/domainheaders"
"git.netcell-it.de/projekte/edgeguard-native/internal/services/domains"
"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"
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/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/staticroutes"
"git.netcell-it.de/projekte/edgeguard-native/internal/services/syslogs"
"git.netcell-it.de/projekte/edgeguard-native/internal/services/tlscerts"
wgsvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/wireguard"
usersvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/users"
wafsvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/waf"
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.1.89"
var version = "1.2.35"
func main() {
addr := os.Getenv("EDGEGUARD_API_ADDR")
@@ -111,7 +120,7 @@ func main() {
requireAuth := handlers.RequireAuth(signer)
setupHdl := handlers.NewSetupHandler(setupStore)
setupHdl := handlers.NewSetupHandler(setupStore).WithVersion(version)
setupHdl.Register(v1)
// systemHdl exists früh damit sowohl der frühe (DB-pool nicht
@@ -159,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)
}
}
@@ -175,6 +185,26 @@ func main() {
go runClusterHeartbeat(context.Background(), pool, nodeID, version)
}
// Secondary: push config_hash to primary every 5 min so the primary's
// ha_nodes reflects actual state. Without this, the primary retains the
// stale hash written at join-time and the drift banner never clears.
// st.IsClusterNode + PrimaryFQDN are only set on joined secondary nodes.
if nodeID != "" && st != nil && st.IsClusterNode && st.PrimaryFQDN != "" {
if primaryURL, normErr := clusterjoin.NormalizePrimaryURL(st.PrimaryFQDN); normErr == nil {
go runPrimaryPush(context.Background(), pool, nodeID, st.FQDN, version, primaryURL)
} else {
slog.Warn("cluster: cannot normalize primary URL for push", "primary", st.PrimaryFQDN, "error", normErr)
}
// runSecondaryConfigRender wird weiter unten gestartet sobald
// clusterAggregator verfügbar ist (braucht mTLS-Client für Cert-Sync).
} else if nodeID != "" && st != nil && st.Completed && st.FQDN != "" {
// Primary/Founder (kein joined Secondary): self (role=primary) an
// alle Peers pushen, damit deren lokale ha_nodes den Primary frisch
// hält — sonst zeigt die vom Secondary ausgelieferte UI den Primary
// als offline. No-op solange keine Peers existieren (Single-Node).
go runPeerPush(context.Background(), pool, clusterStore, nodeID, st.FQDN, version)
}
// Phase 3.3: Cluster-CA + Peer-Cert. Founder-Pfad — auf einem
// frisch installierten Single-Node generieren wir die CA und
// signieren uns selbst, damit der Agent-Listener auf :8443
@@ -219,6 +249,12 @@ func main() {
}
}
// Secondary-Config-Render: jetzt wo der Aggregator bereit ist starten.
// Aggregator wird für Cert-Sync (mTLS GET /agent/cluster/tls-certs) benötigt.
if nodeID != "" && st != nil && st.IsClusterNode && st.PrimaryFQDN != "" {
go runSecondaryConfigRender(context.Background(), pool, secrets.New(""), clusterAggregator, nodeID)
}
auditRepo := audit.New(pool)
domainsRepo := domains.New(pool)
domainHeadersRepo := domainheaders.New(pool)
@@ -254,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 {
@@ -263,7 +299,7 @@ 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)
@@ -272,20 +308,46 @@ func main() {
"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)
})
// Cluster-Node-Startup: Primary in lokalen ha_nodes eintragen damit
// nftables @peer_ipv4 korrekt ist — auch ohne erneuten Join.
go setupHdl.StartupPeerSync()
usersRepo := usersvc.New(pool)
authHdl.WithAudit(auditRepo, nodeID).WithUsers(usersRepo)
authHdl.WithAudit(auditRepo, nodeID).WithUsers(usersRepo).WithClusterTLS(clusterTLSStore)
systemHdl.WithUsers(usersRepo)
haproxyReloader := func(ctx context.Context) error {
return haproxy.New(pool).Render(ctx)
}
// 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)
handlers.NewUsersHandler(usersRepo, auditRepo, nodeID).Register(authed)
handlers.NewDomainsHandler(domainsRepo, routingRepo, domainHeadersRepo, auditRepo, nodeID, haproxyReloader).Register(authed)
// OIDC/Keycloak SSO — public Flow-Endpoints auf v1 (hinter SetupGate),
// Admin-Settings auf authed (PUT nur admin via RequireAdminForMutations).
oidcRepo := oidcsvc.New(pool, secretsBox)
oidcHdl := handlers.NewOIDCHandler(oidcRepo, oidcsvc.NewClient(oidcRepo), usersRepo, signer, setupStore).
WithAudit(auditRepo, nodeID)
oidcHdl.RegisterPublic(v1)
oidcHdl.RegisterAdmin(authed)
handlers.NewDomainsHandler(domainsRepo, routingRepo, domainHeadersRepo, auditRepo, nodeID, 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)
@@ -315,7 +377,9 @@ func main() {
clusterHdl := handlers.NewClusterHandler(clusterStore, nodeID).
WithAggregator(clusterAggregator).
WithJoinFlow(clusterTLSStore, joinTokens).
WithPeerReloader(peerReloader)
WithPeerReloader(peerReloader).
WithAudit(auditRepo, nodeID).
WithVersion(version)
clusterHdl.Register(authed)
// /cluster/issue-cert läuft PUBLIC — joining Peer hat noch
// keine Session/Cert. Token + Nonce-Tracking ist die einzige
@@ -351,13 +415,15 @@ func main() {
return firewallrender.New(pool).Render(ctx)
}
handlers.NewFirewallHandler(fwZones, fwAddrObj, fwAddrGrp, fwSvc, fwSvcGrp, fwRules, fwNAT, auditRepo, nodeID, fwReloader, pool).Register(authed)
handlers.NewCrowdSecHandler(auditRepo, nodeID).Register(authed)
handlers.NewWafHandler(wafsvc.New(pool), auditRepo, nodeID, haproxyReloader).Register(authed)
// withFW wraps a service-reloader so that AFTER the service is
// reloaded, the firewall is also re-rendered. Necessary for
// 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 {
@@ -377,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
@@ -388,19 +461,43 @@ 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)
}
handlers.NewNTPHandler(ntpRepo, auditRepo, nodeID, withFW(chronyReloader)).Register(authed)
// DHCP (Kea) — re-render kea-dhcp4.conf + manage service lifecycle.
keaReloader := func(ctx context.Context) error {
return kearender.New(pool).Render(ctx)
}
handlers.NewDHCPHandler(dhcpsvc.New(pool), auditRepo, nodeID, withFW(keaReloader)).Register(authed)
// RADIUS (FreeRADIUS) — re-render clients.conf + authorize + service lifecycle.
radiusReloader := func(ctx context.Context) error {
return radiusrender.New(pool, secretsBox).Render(ctx)
}
handlers.NewRADIUSHandler(radiussvc.New(pool, secretsBox), auditRepo, nodeID, withFW(radiusReloader)).Register(authed)
// Wire all service reloaders into systemHdl so RenderConfigs
// re-renders every service from DB state in one shot.
systemHdl.WithAllReloaders(map[string]func(context.Context) error{
"nftables": fwReloader,
"wireguard": wgReloader,
"squid": squidReloader,
"unbound": unboundReloader,
"chrony": chronyReloader,
"kea": keaReloader,
"freeradius": radiusReloader,
})
// License — node-local key store + DB-mirror of last verify
// result. Real verify runs against license.netcell-it.com via
// internal/license; the scheduler triggers daily re-verify.
@@ -417,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)
@@ -436,10 +533,24 @@ func main() {
// Listener wird nur gestartet wenn Cert-Material vorhanden ist;
// auf einer frisch installierten Box hat die Init-Phase oben das
// schon erledigt.
startAgentListener(version, agentHdl)
startAgentListener(version, agentHdl, systemHdl)
// Nach einem Upgrade-Neustart: wenn die State-Datei "updating-primary"
// enthält, sind wir gerade neu gestartet → Update abgeschlossen → "done".
handlers.FinishRollingUpdateIfPending()
log.Printf("edgeguard-api %s listening on %s", version, addr)
srv := &http.Server{Addr: addr, Handler: r}
// 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)
}
@@ -450,7 +561,7 @@ func main() {
// RegisterAgent — health + resources). Fehler im Cert-Load = no-op
// + log; Fehler beim Listen.Serve loggen wir aber lassen die API
// weiterlaufen.
func startAgentListener(version string, clusterHdl *handlers.ClusterHandler) {
func startAgentListener(version string, clusterHdl *handlers.ClusterHandler, sysHdl *handlers.SystemHandler) {
store := clustertls.New("")
serverTLS, err := store.ServerTLSConfig()
if err != nil {
@@ -470,7 +581,13 @@ func startAgentListener(version string, clusterHdl *handlers.ClusterHandler) {
// hier implizit aus dem Binary (Peer-Roundtrip ist immer same-major).
// Aggregator-Aufrufer sehen /agent/... direkt.
root := r.Group("")
// Nutze den gewiredeten systemHdl (mit Users + Setup) damit
// AgentAuthCheck Credentials gegen die echte DB prüfen kann.
if sysHdl != nil {
sysHdl.RegisterAgent(root)
} else {
handlers.NewSystemHandler(version).RegisterAgent(root)
}
if clusterHdl != nil {
// Phase 3.5: /agent/cluster/peers (Auto-Register).
clusterHdl.RegisterAgent(root)
@@ -535,10 +652,18 @@ func mountUI(r *gin.Engine) {
return
}
if info, err := os.Stat(full); err == nil && !info.IsDir() {
// Vite hashed assets are immutable — cache them forever.
// index.html must never be cached so updates take effect.
if strings.HasPrefix(clean, "/assets/") {
c.Header("Cache-Control", "public, max-age=31536000, immutable")
} else {
c.Header("Cache-Control", "no-cache, no-store, must-revalidate")
}
c.File(full)
return
}
// SPA fallback — React Router renders the right page.
c.Header("Cache-Control", "no-cache, no-store, must-revalidate")
c.File(indexPath)
})
}
@@ -574,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
@@ -655,6 +756,169 @@ func runClusterHeartbeat(ctx context.Context, pool *pgxpoolPool, localID, versio
}
}
// runSecondaryConfigRender läuft auf Secondary-Nodes und re-rendert alle
// Service-Configs wenn die Logical Replication Änderungen vom Primary
// geliefert hat. Erkennt das an einem geänderten config_hash.
// Tick: 5 min — balanciert Reaktionszeit gegen Reload-Overhead.
//
// Cert-Sync läuft auf jedem Tick unabhängig vom config_hash, da certbot-
// Renewals auf dem Primary den Hash nicht ändern.
func runSecondaryConfigRender(ctx context.Context, pool *pgxpoolPool, box *secrets.Box, agg *aggregator.Aggregator, localID string) {
const tick = 5 * time.Minute
t := time.NewTicker(tick)
defer t.Stop()
var lastHash string
render := func() {
rCtx, cancel := context.WithTimeout(ctx, 90*time.Second)
defer cancel()
// TLS-Zertifikate bei jedem Tick synchronisieren — unabhängig vom
// config_hash, da certbot-Renewals den Hash nicht berühren.
if err := handlers.SyncTLSCertsFromPrimary(rCtx, pool, agg, localID); err != nil {
slog.Warn("cluster: cert sync failed", "error", err)
}
hash, err := cluster.ComputeConfigHash(rCtx, pool)
if err != nil || hash == lastHash {
return
}
lastHash = hash
slog.Info("cluster: secondary config changed via replication, re-rendering", "hash", hash)
// HAProxy
if err := haproxy.New(pool).Render(rCtx); err != nil {
slog.Warn("cluster: secondary haproxy render failed", "error", err)
}
// nftables
if err := firewallrender.New(pool).Render(rCtx); err != nil {
slog.Warn("cluster: secondary nftables render failed", "error", err)
}
// WireGuard — Interface-Configs + wg-quick@<iface> reload
if err := wgrender.New(pool, box).Render(rCtx); err != nil {
slog.Warn("cluster: secondary wireguard render failed", "error", err)
}
// Squid forward proxy
if err := squidrender.New(pool).Render(rCtx); err != nil {
slog.Warn("cluster: secondary squid render failed", "error", err)
}
// Unbound DNS
if err := unboundrender.New(pool).Render(rCtx); err != nil {
slog.Warn("cluster: secondary unbound render failed", "error", err)
}
// Chrony NTP
if err := chronyrender.New(pool).Render(rCtx); err != nil {
slog.Warn("cluster: secondary chrony render failed", "error", err)
}
// Netzwerk-Interfaces (VLAN/Bridge/Bond) — erstellt Interface-Objekte,
// weist aber KEINE IPs zu (das ist node-spezifisch und darf nicht aus
// der Replikation kommen — sonst IP-Konflikt mit dem Primary).
if err := networkifs.NewGenerator(networkifs.New(pool)).Render(rCtx); err != nil {
slog.Warn("cluster: secondary interfaces render failed", "error", err)
}
// IP-Adressen werden auf dem Secondary NICHT aus der Replikation
// angewendet. Jeder Node konfiguriert seine eigenen IPs statisch
// (z.B. /etc/network/interfaces). Floating-Service-IPs werden von
// Keepalived verwaltet — nicht vom Renderer.
}
// Initialer Check nach kurzem Delay (Replication braucht einen Moment)
select {
case <-ctx.Done():
return
case <-time.After(30 * time.Second):
render()
}
for {
select {
case <-ctx.Done():
return
case <-t.C:
render()
}
}
}
// runPrimaryPush periodically pushes this secondary node's config_hash to the
// primary via mTLS. The primary's ha_nodes view only gets config_hash + last_seen
// written during join-time autoRegister — after that the primary never hears about
// the secondary unless we push. Without this, the drift banner shows stale hashes
// from join-time forever AND the secondary's last_seen freezes → SweepStaleNodes
// marks it offline.
//
// WICHTIG: tick MUSS deutlich unter dem Stale-Threshold (4× 30s = 2 min, siehe
// scheduler.staleThreshold / cluster.SweepStaleNodes) liegen. Sonst flippt der
// Secondary zwischen den Pushes zwangsläufig auf "offline" (bei 5-min-Tick:
// 2 min online, 3 min offline). 30s = 4 Pushes pro Stale-Fenster → ein
// verpasster Push (Netz-Glitch) ist unkritisch. Der Receiver (AgentRegisterPeer)
// lädt nftables nur bei IP-Änderung neu → kein Reload-Sturm durch häufige Pushes.
func runPrimaryPush(ctx context.Context, pool *pgxpoolPool, nodeID, fqdn, version, primaryURL string) {
const tick = 30 * time.Second
t := time.NewTicker(tick)
defer t.Stop()
push := func() {
pCtx, cancel := context.WithTimeout(ctx, 15*time.Second)
defer cancel()
hash, _ := cluster.ComputeConfigHash(pCtx, pool)
if err := clusterjoin.PushSelfToPrimary(primaryURL, "", nodeID, fqdn, version, hash); err != nil { //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)
}
}
push() // immediate push on API startup
for {
select {
case <-ctx.Done():
return
case <-t.C:
push()
}
}
}
// runPeerPush läuft auf dem Primary/Founder und pusht alle 30s die eigene
// Identität (role=primary) an jeden Peer via mTLS — das Gegenstück zu
// runPrimaryPush (Secondary→Primary). Zusammen ergibt das einen
// bidirektionalen Cross-Node-Heartbeat: beide Nodes sehen sich gegenseitig
// als online, egal von welchem Node die UI ausgeliefert wird. Tick wie
// runPrimaryPush deutlich unter dem 2-min-Stale-Threshold. No-op solange
// keine Peers existieren (Single-Node) bzw. wenn ein Peer down ist (Debug-Log).
func runPeerPush(ctx context.Context, pool *pgxpoolPool, store *cluster.Store, nodeID, fqdn, version string) {
const tick = 30 * time.Second
t := time.NewTicker(tick)
defer t.Stop()
push := func() {
pCtx, cancel := context.WithTimeout(ctx, 25*time.Second)
defer cancel()
peers, err := store.List(pCtx)
if err != nil {
slog.Warn("cluster: peer-push list failed", "error", err)
return
}
hash, _ := cluster.ComputeConfigHash(pCtx, pool)
for i := range peers {
p := peers[i]
if p.ID == nodeID {
continue // nicht an sich selbst pushen
}
target := p.APIURL
if target == "" {
target = "https://" + p.FQDN
}
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)
}
}
}
push() // immediate push on API startup
for {
select {
case <-ctx.Done():
return
case <-t.C:
push()
}
}
}
func randomEphemeralSecret() []byte {
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
@@ -664,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

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

View File

@@ -0,0 +1,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

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

View File

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

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

View File

@@ -0,0 +1,124 @@
package main
import (
"context"
"fmt"
"os"
"strings"
"time"
"git.netcell-it.de/projekte/edgeguard-native/internal/cluster"
"git.netcell-it.de/projekte/edgeguard-native/internal/database"
"git.netcell-it.de/projekte/edgeguard-native/internal/keepalived"
)
// cmdPromote 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. 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 {
fmt.Fprintln(os.Stderr, "promote: PG-Erkennung:", err)
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("→ Promote zu Logical-Replication-Primary (PostgreSQL %s/%s)...\n", pg.Version, pg.Cluster)
// 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.Println("✓ Subscription zum alten Primary entfernt")
}
// 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
}
// 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: UPDATE ha_nodes SET pg_role='primary', role='primary' WHERE id='<local-id>';")
} else {
defer pool.Close()
localID, err := loadLocalID()
if err != nil {
fmt.Fprintln(os.Stderr, "promote: local node ID:", err)
} else {
_, err = pool.Exec(ctx, `UPDATE ha_nodes SET pg_role='primary', role='primary', status='online', updated_at=NOW() WHERE id=$1`, localID)
if err != nil {
fmt.Fprintln(os.Stderr, "promote: update ha_nodes:", err)
} else {
fmt.Println("✓ ha_nodes.pg_role = 'primary' gesetzt")
}
}
}
// 5. keepalived.conf neu rendern (Primary = MASTER, Priority 200 → VIP)
if pool != nil {
localID, _ := loadLocalID()
kg := keepalived.New(pool, localID)
renderCtx, renderCancel := context.WithTimeout(context.Background(), 10*time.Second)
defer renderCancel()
if err := kg.Render(renderCtx); err != nil {
fmt.Fprintf(os.Stderr, "promote: keepalived render: %v\n", err)
fmt.Println(" → Manuell: sudo -u edgeguard edgeguard-ctl render-config --only=keepalived")
} else {
fmt.Println("✓ keepalived.conf neu gerendert (MASTER, Priority 200)")
}
}
fmt.Println()
fmt.Println("✓ Promotion abgeschlossen. Diese Node ist jetzt der primäre EdgeGuard-Knoten.")
fmt.Println()
fmt.Println("Empfohlene Nachschritte:")
fmt.Println(" 1) sudo systemctl restart edgeguard-api")
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
}
// loadLocalID liest die Node-ID aus /var/lib/edgeguard/node.conf.
func loadLocalID() (string, error) {
c, err := cluster.LoadLocalConfig("")
if err != nil {
return "", err
}
if c.NodeID == "" {
return "", fmt.Errorf("NODE_ID in node.conf ist leer")
}
return c.NodeID, nil
}

View File

@@ -8,10 +8,15 @@ import (
"time"
"git.netcell-it.de/projekte/edgeguard-native/internal/chrony"
"git.netcell-it.de/projekte/edgeguard-native/internal/cluster"
"git.netcell-it.de/projekte/edgeguard-native/internal/configgen"
"git.netcell-it.de/projekte/edgeguard-native/internal/crowdsec"
"git.netcell-it.de/projekte/edgeguard-native/internal/database"
"git.netcell-it.de/projekte/edgeguard-native/internal/firewall"
"git.netcell-it.de/projekte/edgeguard-native/internal/freeradius"
"git.netcell-it.de/projekte/edgeguard-native/internal/haproxy"
"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"
"git.netcell-it.de/projekte/edgeguard-native/internal/squid"
@@ -59,15 +64,34 @@ func cmdRenderConfig(args []string) int {
wg := wireguard.New(pool, secrets.New(""))
ub := unbound.New(pool)
cn := chrony.New(pool)
ke := kea.New(pool)
fr := freeradius.New(pool, secrets.New(""))
cw := crowdsec.NewWhitelistGenerator(pool)
if skipReload {
hap.SkipReload = true
fw.SkipReload = true
sq.SkipReload = true
wg.SkipReload = true
ub.SkipReload = true
cn.SkipReload = true
ke.SkipReload = true
fr.SkipReload = true
cw.SkipReload = true
}
gens := []configgen.Generator{hap, fw, sq, wg, ub, cn}
// keepalived: Node-ID aus node.conf für Prioritäts-Berechnung
var ka configgen.Generator
if lc, err := cluster.LoadLocalConfig(""); err == nil && lc.NodeID != "" {
ka = keepalived.New(pool, lc.NodeID)
}
gens := []configgen.Generator{hap, fw, sq, wg, ub, cn, ke, fr, 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

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

View File

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

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

@@ -0,0 +1,107 @@
// Command edgeguard-waf is the per-domain WAF SPOE agent for EdgeGuard.
// HAProxy connects to it via the SPOE protocol (127.0.0.1:9000).
// It loads per-domain WAF configs from PostgreSQL and uses Coraza v3
// with the OWASP Core Rule Set to inspect HTTP requests.
package main
import (
"context"
"log/slog"
"os"
"os/signal"
"syscall"
"time"
"git.netcell-it.de/projekte/edgeguard-native/internal/database"
"git.netcell-it.de/projekte/edgeguard-native/internal/models"
"git.netcell-it.de/projekte/edgeguard-native/internal/services/waf"
intwaf "git.netcell-it.de/projekte/edgeguard-native/internal/waf"
)
func main() {
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
dsn := database.ConnStringFromEnv()
pool, err := database.Open(ctx, dsn)
if err != nil {
slog.Error("waf: db connect", "error", err)
os.Exit(1)
}
defer pool.Close()
if err := database.Migrate(ctx, ""); err != nil {
slog.Error("waf: migrate", "error", err)
os.Exit(1)
}
repo := waf.New(pool)
crsDir := os.Getenv("EDGEGUARD_WAF_CRS_DIR")
if crsDir == "" {
crsDir = intwaf.DefaultCRSDir
}
spoeAddr := os.Getenv("EDGEGUARD_WAF_ADDR")
if spoeAddr == "" {
spoeAddr = intwaf.DefaultSPOEAddr
}
mgr := intwaf.NewManager(crsDir)
// Initial load.
if err := reload(ctx, repo, mgr); err != nil {
slog.Error("waf: initial load", "error", err)
os.Exit(1)
}
// Periodic config refresh every 30 seconds.
go func() {
t := time.NewTicker(30 * time.Second)
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case <-t.C:
if err := reload(ctx, repo, mgr); err != nil {
slog.Warn("waf: reload", "error", err)
}
}
}
}()
alertWriter := intwaf.NewAlertWriter(pool, 2048)
agent := intwaf.SPOEAgent{
Manager: mgr,
AlertWriter: alertWriter,
Addr: spoeAddr,
}
slog.Info("waf: SPOE agent starting", "addr", spoeAddr, "crs", crsDir)
if err := agent.ListenAndServe(ctx); err != nil && ctx.Err() == nil {
slog.Error("waf: SPOE agent stopped", "error", err)
os.Exit(1)
}
// Graceful shutdown (ctx canceled): gepufferte Alerts flushen.
alertWriter.Close()
}
// reload fetches all domain+waf_config pairs from DB and rebuilds engines.
func reload(ctx context.Context, repo *waf.Repo, mgr *intwaf.Manager) error {
configs, err := repo.ListAllWithDomain(ctx)
if err != nil {
return err
}
domains := make([]intwaf.DomainConfig, 0, len(configs))
for _, c := range configs {
domains = append(domains, intwaf.DomainConfig{
Hostname: c.Hostname,
Config: c.Config,
})
}
return mgr.Reload(domains)
}
// Ensure models package is used (imported transitively via services/waf).
var _ = models.WafConfig{}

View File

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

View File

@@ -41,7 +41,7 @@ SystemCallFilter=@system-service
# direkt in den distro-Conf-Dir (chrony+unbound) bzw. legen Symlinks
# nach /etc/edgeguard/wireguard (wg). Ohne diese Pfade scheitern alle
# UI-Mutationen an DNS/NTP/WireGuard-Settings still mit EROFS.
ReadWritePaths=/etc/edgeguard /var/lib/edgeguard /var/log/edgeguard /var/backups/edgeguard /var/lib/apt /var/cache/apt /etc/apt/apt.conf.d /etc/chrony/conf.d /etc/unbound/unbound.conf.d /etc/wireguard
ReadWritePaths=/etc/edgeguard /var/lib/edgeguard /var/log/edgeguard /var/backups/edgeguard /var/lib/apt /var/cache/apt /etc/apt/apt.conf.d /etc/chrony/conf.d /etc/unbound/unbound.conf.d /etc/wireguard /var/lib/crowdsec
[Install]
WantedBy=multi-user.target

View File

@@ -0,0 +1,32 @@
[Unit]
Description=EdgeGuard WAF SPOE Agent (Coraza/OWASP CRS)
Documentation=https://git.netcell-it.de/projekte/edgeguard-native
After=network-online.target postgresql.service edgeguard-api.service
Wants=network-online.target
Requires=postgresql.service
[Service]
Type=simple
User=edgeguard
Group=edgeguard
ExecStart=/usr/bin/edgeguard-waf
Restart=on-failure
RestartSec=5
# Hardening — WAF agent only needs DB access and one TCP listen socket.
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectControlGroups=true
PrivateTmp=true
PrivateDevices=true
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
SystemCallFilter=@system-service
# CRS rules are read from /usr/share/edgeguard/waf/crs/ (read-only, OK).
# Alerts/logs are written to /var/log/edgeguard/.
ReadWritePaths=/var/log/edgeguard
[Install]
WantedBy=multi-user.target

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.

86
go.mod
View File

@@ -1,71 +1,91 @@
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.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/pressly/goose/v3 v3.27.1
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.28.0
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
golang.org/x/crypto v0.51.0
golang.org/x/crypto v0.57.0
golang.org/x/oauth2 v0.37.0
)
require (
github.com/bytedance/sonic v1.11.6 // indirect
github.com/bytedance/sonic/loader v0.1.1 // indirect
github.com/boombuler/barcode v1.0.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/goccy/go-json v0.10.2 // 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
github.com/gotnospirit/makeplural v0.0.0-20180622080156-a5f48d94d976 // indirect
github.com/gotnospirit/messageformat v0.0.0-20221001023931-dfe49f1eb092 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/json-iterator/go v1.1.13-0.20220915233716-71ac16282d12 // indirect
github.com/klauspost/compress v1.18.5 // indirect
github.com/klauspost/cpuid/v2 v2.2.11 // indirect
github.com/kaptinlin/go-i18n v0.1.4 // indirect
github.com/kaptinlin/jsonschema v0.4.6 // 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/mattn/go-isatty v0.0.21 // indirect
github.com/magefile/mage v1.17.0 // 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/minio/minio-go/v7 v7.1.0 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect
github.com/pelletier/go-toml/v2 v2.2.2 // indirect
github.com/pelletier/go-toml/v2 v2.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/pkg/sftp v1.13.10 // 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/tinylib/msgp v1.6.1 // 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.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
)

216
go.sum
View File

@@ -1,34 +1,46 @@
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/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/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/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
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.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.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=
github.com/foxcpp/go-mockdns v1.1.0/go.mod h1:IhLeSFGed3mJIAXPH2aiRQB+kqz7oqu8ld2qVbOu7Wk=
github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho=
github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo=
github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM=
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=
@@ -37,10 +49,12 @@ 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/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
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=
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
@@ -48,37 +62,41 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/gotnospirit/makeplural v0.0.0-20180622080156-a5f48d94d976 h1:b70jEaX2iaJSPZULSUxKtm73LBfsCrMsIlYCUgNGSIs=
github.com/gotnospirit/makeplural v0.0.0-20180622080156-a5f48d94d976/go.mod h1:ZGQeOwybjD8lkCjIyJfqR5LD2wMVHJ31d6GdPxoTsWY=
github.com/gotnospirit/messageformat v0.0.0-20221001023931-dfe49f1eb092 h1:c7gcNWTSr1gtLp6PyYi3wzvFCEcHJ4YRobDgqmIgf7Q=
github.com/gotnospirit/messageformat v0.0.0-20221001023931-dfe49f1eb092/go.mod h1:ZZAN4fkkful3l1lpJwF8JbW41ZiG9TwJ2ZlqzQovBNU=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
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=
github.com/jcchavezs/mergefs v0.1.1/go.mod h1:eRLTrsA+vFwQZ48hj8p8gki/5v9C2bFtHH5Mnn4bcGk=
github.com/json-iterator/go v1.1.13-0.20220915233716-71ac16282d12 h1:9Nu54bhS/H/Kgo2/7xNSUuC5G28VR8ljfrLKU2G4IjU=
github.com/json-iterator/go v1.1.13-0.20220915233716-71ac16282d12/go.mod h1:TBzl5BIHNXfS9+C35ZyJaklL7mLDbgUkcgXzSLa8Tk0=
github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE=
github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/kaptinlin/go-i18n v0.1.4 h1:wCiwAn1LOcvymvWIVAM4m5dUAMiHunTdEubLDk4hTGs=
github.com/kaptinlin/go-i18n v0.1.4/go.mod h1:g1fn1GvTgT4CiLE8/fFE1hboHWJ6erivrDpiDtCcFKg=
github.com/kaptinlin/jsonschema v0.4.6 h1:vOSFg5tjmfkOdKg+D6Oo4fVOM/pActWu/ntkPsI1T64=
github.com/kaptinlin/jsonschema v0.4.6/go.mod h1:1DUd7r5SdyB2ZnMtyB7uLv64dE3zTFTiYytDCd+AEL0=
github.com/klauspost/compress v1.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.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM=
github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
github.com/klauspost/cpuid/v2 v2.2.11 h1:0OwqZRYI2rFrjS4kvkDnqJkKHdHaRnCm68/DY4OxRzU=
github.com/klauspost/cpuid/v2 v2.2.11/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/klauspost/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/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/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.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=
@@ -87,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=
@@ -97,26 +115,29 @@ github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFd
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
github.com/pelletier/go-toml/v2 v2.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/pressly/goose/v3 v3.27.1 h1:6uEvcprBybDmW4hcz3gYujhARhye+GoWKhEWyzD5sh4=
github.com/pressly/goose/v3 v3.27.1/go.mod h1:maruOxsPnIG2yHHyo8UqKWXYKFcH7Q76csUV7+7KYoM=
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.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=
@@ -127,56 +148,73 @@ github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UV
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/tinylib/msgp v1.6.1 h1:ESRv8eL3u+DNHUoSAAQRE50Hm162zqAnBoGv9PzScPY=
github.com/tinylib/msgp v1.6.1/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA=
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=
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/tinylib/msgp v1.6.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/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/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=
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
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=

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)))
@@ -151,7 +151,19 @@ func (a *Aggregator) callPeer(ctx context.Context, p models.HANode, path string)
return res
}
res.OK = true
// Agent-Endpoints liefern die Standard-API-Envelope zurück
// ({"data": {...}, "error": null, "message": "ok"}). Wir entpacken
// das `data`-Feld so dass der Aufrufer direkt die Nutzlast bekommt —
// konsistent mit dem Lokal-Pfad (der marshalt die Struct direkt ohne
// Envelope).
var env struct {
Data json.RawMessage `json:"data"`
}
if err := json.Unmarshal(body, &env); err == nil && len(env.Data) > 0 {
res.Data = env.Data
} else {
res.Data = body
}
res.Duration = time.Since(start).Milliseconds()
return res
}
@@ -180,6 +192,80 @@ func agentURL(apiURL string, agentPort int, path string) (string, error) {
return u.String(), nil
}
// PostPeer sendet einen POST-Request an einen einzelnen Peer.
// Wird vom Rolling-Update-Orchestrator genutzt um /agent/cluster/trigger-update
// auf dem Secondary auszulösen.
func (a *Aggregator) PostPeer(ctx context.Context, p models.HANode, path string) PeerResult {
start := time.Now()
res := PeerResult{NodeID: p.ID, FQDN: p.FQDN}
target, err := agentURL(p.APIURL, a.AgentPort, path)
if err != nil {
res.Err = "bad api_url: " + err.Error()
return res
}
reqCtx, cancel := context.WithTimeout(ctx, 15*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(reqCtx, http.MethodPost, target, nil)
if err != nil {
res.Err = err.Error()
return res
}
req.Header.Set("Content-Type", "application/json")
resp, err := a.HTTPClient.Do(req)
if err != nil {
res.Err = err.Error()
res.Duration = time.Since(start).Milliseconds()
return res
}
defer 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)))
res.Duration = time.Since(start).Milliseconds()
return res
}
res.OK = true
res.Duration = time.Since(start).Milliseconds()
return res
}
// PostPeerWithBody sendet einen POST-Request mit JSON-Body an einen Peer.
// Wird für VIP-Schwenk-Tests genutzt (/agent/cluster/vip-cmd).
func (a *Aggregator) PostPeerWithBody(ctx context.Context, p models.HANode, path string, body []byte) PeerResult {
start := time.Now()
res := PeerResult{NodeID: p.ID, FQDN: p.FQDN}
target, err := agentURL(p.APIURL, a.AgentPort, path)
if err != nil {
res.Err = "bad api_url: " + err.Error()
return res
}
reqCtx, cancel := context.WithTimeout(ctx, 15*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(reqCtx, http.MethodPost, target, strings.NewReader(string(body)))
if err != nil {
res.Err = err.Error()
return res
}
req.Header.Set("Content-Type", "application/json")
resp, err := a.HTTPClient.Do(req)
if err != nil {
res.Err = err.Error()
res.Duration = time.Since(start).Milliseconds()
return res
}
defer 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)))
res.Duration = time.Since(start).Milliseconds()
return res
}
res.OK = true
res.Data = respBody
res.Duration = time.Since(start).Milliseconds()
return res
}
// Compile-time check dass cluster importiert wird (für Drift-Detection
// vom hashSpec — die Aggregator-Resultate werden parallel im Drift-
// Banner mitverarbeitet). Nicht runtime-essentiell, aber dokumentiert

View File

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

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

View File

@@ -39,6 +39,9 @@ type hashTable struct {
Singleton bool // dns_settings, ntp_settings → eine row, id=1
ExtraExclude []string // Spalten die zusätzlich aus to_jsonb gefiltert werden
SkipUpdatedAt bool // setze true wenn updated_at semantisch relevant ist
MigrationDefault bool // Tabelle hat migrations-erzeugte Default-Rows (firewall_zones, ntp_pools…)
// → zählt nicht als "user hat config" bei der Empty-DB-Erkennung
CustomSQL string // wenn gesetzt: direkt als Hash-Query verwenden (überschreibt hashSQL)
}
// hashSpec ist die Reihenfolge-stabile Liste. NEUE Tabellen hier
@@ -49,15 +52,13 @@ var hashSpec = []hashTable{
{Name: "backends"},
{Name: "backend_servers"},
{Name: "routing_rules"},
{Name: "network_interfaces"},
{Name: "ip_addresses"},
{Name: "tls_certs", ExtraExclude: []string{"last_renewed_at", "last_error"}},
{Name: "firewall_zones"},
{Name: "firewall_zones", MigrationDefault: true},
{Name: "firewall_address_objects"},
{Name: "firewall_address_groups"},
{Name: "firewall_services"},
{Name: "firewall_service_groups"},
{Name: "firewall_services", MigrationDefault: true},
{Name: "firewall_service_groups", MigrationDefault: true},
{Name: "firewall_rules"},
{Name: "firewall_nat_rules"},
@@ -67,12 +68,29 @@ var hashSpec = []hashTable{
{Name: "dns_zones"},
{Name: "dns_records"},
{Name: "dns_settings", Singleton: true},
{Name: "ntp_pools"},
{Name: "ntp_settings", Singleton: true},
{Name: "ntp_pools", MigrationDefault: true},
{Name: "static_routes"},
// DHCP: Subnets + Reservierungen sind geteilte Config (repliziert).
// dhcp_settings ist node-lokal (ob DIESE Node DHCP betreibt) → NICHT hier.
{Name: "dhcp_subnets"},
{Name: "dhcp_reservations"},
// RADIUS: Clients + Users sind geteilte Config (repliziert).
// radius_settings ist node-lokal → NICHT hier.
{Name: "radius_clients"},
{Name: "radius_users"},
// network_interfaces + ip_addresses sind BEWUSST NICHT im Drift-Hash.
// Sie stehen in cluster_replication.go localOnlyTables, werden also NICHT
// repliziert und sind per Design node-spezifisch (jede Node hat eigene
// Mgmt-/Host-IPs, z.B. utm-1=.6, utm-2=.8). Würde man sie hashen, wäre
// der config_hash zwischen zwei Nodes ZWANGSLÄUFIG dauerhaft verschieden
// → Drift-Banner, das kein Resync je beheben kann (Resync kopiert nur
// replizierte Tabellen). Migration 0030 wollte sie zwar replizieren,
// localOnlyTables schließt sie aber weiter aus → wir hashen sie nicht.
//
// static_routes, dns_settings, ntp_settings bleiben ebenfalls node-spezifisch.
}
// hashSQL rendert die SHA-Input-SQL für eine Tabelle.
@@ -100,22 +118,39 @@ func hashSQL(t hashTable) string {
// ComputeConfigHash gibt den 16-hex-char-Hash über alle Spec-Tabellen
// zurück. Fehlende Tabellen (transienter schema-flux) werden als
// leerer Per-Table-Hash behandelt — kein Abbruch.
//
// Gibt "" zurück wenn alle user-konfigurierbaren Tabellen leer sind
// (Singleton- und MigrationDefault-Tabellen zählen nicht als User-Config).
// Das verhindert False-Positive-Drift-Banner auf frisch gejointen Secondaries.
func ComputeConfigHash(ctx context.Context, pool *pgxpool.Pool) (string, error) {
if pool == nil {
return "", fmt.Errorf("nil pool")
}
h := sha256.New()
hasUserConfig := false
for _, t := range hashSpec {
var s string
if err := pool.QueryRow(ctx, hashSQL(t)).Scan(&s); err != nil {
sql := t.CustomSQL
if sql == "" {
sql = hashSQL(t)
}
if err := pool.QueryRow(ctx, sql).Scan(&s); err != nil {
// Migration fehlt o.ä. → leeren string nehmen, weiter.
s = ""
}
if s != "" && !t.Singleton && !t.MigrationDefault {
hasUserConfig = true
}
h.Write([]byte(t.Name))
h.Write([]byte{0})
h.Write([]byte(s))
h.Write([]byte{0})
}
if !hasUserConfig {
// Frisch gejoincter Secondary oder komplett leere DB →
// leerer String signalisiert "kein Drift prüfen" im Status-Handler.
return "", nil
}
return hex.EncodeToString(h.Sum(nil))[:16], nil
}

View File

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

View File

@@ -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,
@@ -115,6 +122,16 @@ func (s *Store) Delete(ctx context.Context, id string) error {
return nil
}
// DeletePlaceholdersByFQDN removes all ha_nodes rows that share the given
// FQDN but do NOT have the specified keepID. Used to clean up pre-registered
// placeholder rows (both old "pre-{timestamp}" and new "prenode-{fqdn}"
// style) after a real autoRegister arrives via mTLS.
func (s *Store) DeletePlaceholdersByFQDN(ctx context.Context, fqdn, keepID string) error {
_, err := s.Pool.Exec(ctx,
`DELETE FROM ha_nodes WHERE fqdn = $1 AND id != $2`, fqdn, keepID)
return err
}
// EnsureSelfRegistered mints the node-id if needed, builds the row
// from setup.json + os.Hostname + node.conf, and upserts it. Called
// on edgeguard-api boot AFTER the DB pool is reachable.
@@ -171,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 {
@@ -99,6 +99,33 @@ func RestartService(name string) error {
return nil
}
// StopService runs `sudo -n systemctl stop <name>.service`.
func StopService(name string) error {
cmd := exec.Command("sudo", "-n", "/usr/bin/systemctl", "stop", name+".service")
if out, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("sudo systemctl stop %s.service: %w (output: %s)", name, err, strings.TrimSpace(string(out)))
}
return nil
}
// EnableService runs `sudo -n systemctl enable <name>.service` (boot-persistent).
func EnableService(name string) error {
cmd := exec.Command("sudo", "-n", "/usr/bin/systemctl", "enable", name+".service")
if out, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("sudo systemctl enable %s.service: %w (output: %s)", name, err, strings.TrimSpace(string(out)))
}
return nil
}
// DisableService runs `sudo -n systemctl disable <name>.service`.
func DisableService(name string) error {
cmd := exec.Command("sudo", "-n", "/usr/bin/systemctl", "disable", name+".service")
if out, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("sudo systemctl disable %s.service: %w (output: %s)", name, err, strings.TrimSpace(string(out)))
}
return nil
}
// EtcEdgeguard is the on-target config root. Templated path used by
// all renderers — never let renderers hard-code their own.
const EtcEdgeguard = "/etc/edgeguard"

View File

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

View File

@@ -0,0 +1,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

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,21 @@
-- +goose Up
-- +goose StatementBegin
-- HINWEIS (korrigiert v1.2.89): Diese Migration war urspr. dafür gedacht,
-- network_interfaces und ip_addresses in die Cluster-Replikation aufzunehmen.
-- Das wurde NICHT umgesetzt und ist auch NICHT gewollt: beide Tabellen sind
-- node-spezifisch (jede Node hat eigene Mgmt-/Host-IPs) und stehen weiterhin
-- in cluster_replication.go localOnlyTables → sie werden bewusst NICHT
-- repliziert. Sie sind auch aus dem Drift-Hash (confighash.go) entfernt,
-- da sie sonst dauerhaften False-Positive-Drift erzeugen.
-- Diese Migration ist ein No-op / reiner Versions-Marker für goose.
SELECT 1;
-- +goose StatementEnd
-- +goose Down
-- +goose StatementBegin
SELECT 1;
-- +goose StatementEnd

View File

@@ -0,0 +1,25 @@
-- +goose Up
-- +goose StatementBegin
-- forward_proxy_settings — Singleton-Row für globale Squid-Einstellungen.
-- listen_addresses: Komma-separierte IPs auf denen Squid lauscht.
-- Leer = alle Interfaces (http_port 3128). Typisch: LAN/VLAN-Gateway-IPs.
CREATE TABLE IF NOT EXISTS forward_proxy_settings (
id INTEGER PRIMARY KEY DEFAULT 1,
listen_addresses TEXT NOT NULL DEFAULT '',
listen_port INTEGER NOT NULL DEFAULT 3128,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT forward_proxy_settings_singleton CHECK (id = 1)
);
INSERT INTO forward_proxy_settings (id) VALUES (1) ON CONFLICT DO NOTHING;
-- +goose StatementEnd
-- +goose Down
-- +goose StatementBegin
DROP TABLE IF EXISTS forward_proxy_settings;
-- +goose StatementEnd

View File

@@ -0,0 +1,37 @@
-- +goose Up
-- +goose StatementBegin
ALTER TABLE forward_proxy_settings
ADD COLUMN IF NOT EXISTS cache_mem_mb INTEGER NOT NULL DEFAULT 64,
ADD COLUMN IF NOT EXISTS cache_dir_mb INTEGER NOT NULL DEFAULT 100,
ADD COLUMN IF NOT EXISTS max_obj_size_mb INTEGER NOT NULL DEFAULT 4,
ADD COLUMN IF NOT EXISTS connect_timeout INTEGER NOT NULL DEFAULT 60,
ADD COLUMN IF NOT EXISTS read_timeout INTEGER NOT NULL DEFAULT 300,
ADD COLUMN IF NOT EXISTS request_timeout INTEGER NOT NULL DEFAULT 300;
ALTER TABLE dns_settings
ADD COLUMN IF NOT EXISTS prefetch BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN IF NOT EXISTS serve_expired BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN IF NOT EXISTS msg_cache_size_mb INTEGER NOT NULL DEFAULT 64,
ADD COLUMN IF NOT EXISTS rrset_cache_size_mb INTEGER NOT NULL DEFAULT 128;
-- +goose StatementEnd
-- +goose Down
-- +goose StatementBegin
ALTER TABLE forward_proxy_settings
DROP COLUMN IF EXISTS cache_mem_mb,
DROP COLUMN IF EXISTS cache_dir_mb,
DROP COLUMN IF EXISTS max_obj_size_mb,
DROP COLUMN IF EXISTS connect_timeout,
DROP COLUMN IF EXISTS read_timeout,
DROP COLUMN IF EXISTS request_timeout;
ALTER TABLE dns_settings
DROP COLUMN IF EXISTS prefetch,
DROP COLUMN IF EXISTS serve_expired,
DROP COLUMN IF EXISTS msg_cache_size_mb,
DROP COLUMN IF EXISTS rrset_cache_size_mb;
-- +goose StatementEnd

View File

@@ -0,0 +1,18 @@
-- +goose Up
-- Dual-path VRRP + Gateway-Tracking für Split-Brain-Schutz.
-- hb_* = zweite VRRP-Instanz (VI_HB) auf dediziertem Heartbeat-Interface.
-- gw_check_ip = Gateway-IP die von chk_gateway angepingt wird (weight -110).
ALTER TABLE cluster_settings
ADD COLUMN IF NOT EXISTS hb_interface VARCHAR,
ADD COLUMN IF NOT EXISTS hb_src_ip VARCHAR,
ADD COLUMN IF NOT EXISTS hb_peer_ip VARCHAR,
ADD COLUMN IF NOT EXISTS hb_router_id INTEGER NOT NULL DEFAULT 52,
ADD COLUMN IF NOT EXISTS gw_check_ip VARCHAR;
-- +goose Down
ALTER TABLE cluster_settings
DROP COLUMN IF EXISTS hb_interface,
DROP COLUMN IF EXISTS hb_src_ip,
DROP COLUMN IF EXISTS hb_peer_ip,
DROP COLUMN IF EXISTS hb_router_id,
DROP COLUMN IF EXISTS gw_check_ip;

View File

@@ -0,0 +1,9 @@
-- +goose Up
ALTER TABLE users
ADD COLUMN totp_secret TEXT,
ADD COLUMN totp_enabled BOOLEAN NOT NULL DEFAULT false;
-- +goose Down
ALTER TABLE users
DROP COLUMN totp_secret,
DROP COLUMN totp_enabled;

View File

@@ -0,0 +1,17 @@
-- +goose Up
ALTER TABLE firewall_rules
ADD COLUMN IF NOT EXISTS note TEXT,
ADD COLUMN IF NOT EXISTS labels TEXT[] NOT NULL DEFAULT '{}';
ALTER TABLE firewall_nat_rules
ADD COLUMN IF NOT EXISTS note TEXT,
ADD COLUMN IF NOT EXISTS labels TEXT[] NOT NULL DEFAULT '{}';
-- +goose Down
ALTER TABLE firewall_rules
DROP COLUMN IF EXISTS note,
DROP COLUMN IF EXISTS labels;
ALTER TABLE firewall_nat_rules
DROP COLUMN IF EXISTS note,
DROP COLUMN IF EXISTS labels;

View File

@@ -0,0 +1,12 @@
-- +goose Up
CREATE TABLE IF NOT EXISTS crowdsec_settings (
id INTEGER PRIMARY KEY DEFAULT 1 CHECK (id = 1),
enabled BOOLEAN NOT NULL DEFAULT false,
simulation_mode BOOLEAN NOT NULL DEFAULT false,
collections TEXT[] NOT NULL DEFAULT '{"crowdsecurity/linux","crowdsecurity/haproxy"}',
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
INSERT INTO crowdsec_settings (id) VALUES (1) ON CONFLICT DO NOTHING;
-- +goose Down
DROP TABLE IF EXISTS crowdsec_settings;

View File

@@ -0,0 +1,18 @@
-- +goose Up
CREATE TABLE IF NOT EXISTS waf_configs (
id SERIAL PRIMARY KEY,
domain_id BIGINT NOT NULL REFERENCES domains(id) ON DELETE CASCADE,
enabled BOOLEAN NOT NULL DEFAULT false,
mode TEXT NOT NULL DEFAULT 'detection'
CHECK (mode IN ('detection','blocking')),
paranoia_level INT NOT NULL DEFAULT 1
CHECK (paranoia_level BETWEEN 1 AND 4),
rule_exclusions TEXT[] NOT NULL DEFAULT '{}',
trusted_proxies TEXT[] NOT NULL DEFAULT '{}',
custom_rules TEXT NOT NULL DEFAULT '',
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT waf_configs_domain_unique UNIQUE (domain_id)
);
-- +goose Down
DROP TABLE IF EXISTS waf_configs;

View File

@@ -0,0 +1,20 @@
-- +goose Up
CREATE TABLE IF NOT EXISTS waf_alerts (
id BIGSERIAL PRIMARY KEY,
domain_id BIGINT REFERENCES domains(id) ON DELETE CASCADE,
hostname TEXT NOT NULL,
client_ip TEXT NOT NULL,
method TEXT NOT NULL,
uri TEXT NOT NULL,
rule_id INT NOT NULL DEFAULT 0,
rule_msg TEXT NOT NULL DEFAULT '',
severity TEXT NOT NULL DEFAULT '',
action TEXT NOT NULL, -- 'detected' | 'blocked'
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS waf_alerts_domain_created ON waf_alerts(domain_id, created_at DESC);
CREATE INDEX IF NOT EXISTS waf_alerts_created ON waf_alerts(created_at DESC);
-- +goose Down
DROP TABLE IF EXISTS waf_alerts;

View File

@@ -0,0 +1,6 @@
-- +goose Up
ALTER TABLE waf_configs
ADD COLUMN IF NOT EXISTS exclusion_notes JSONB NOT NULL DEFAULT '{}';
-- +goose Down
ALTER TABLE waf_configs DROP COLUMN IF EXISTS exclusion_notes;

View File

@@ -0,0 +1,35 @@
-- +goose Up
-- +goose StatementBegin
-- OIDC / Keycloak SSO — Singleton-Settings (analog forward_proxy_settings).
-- client_secret_enc: secrets.Box.Seal-Output (AES-256-GCM), NULL = nicht gesetzt.
-- Rolle kommt bewusst NICHT aus dem Token, daher keine group/role-claim-Spalten.
CREATE TABLE IF NOT EXISTS oidc_settings (
id INTEGER PRIMARY KEY DEFAULT 1,
enabled BOOLEAN NOT NULL DEFAULT false,
issuer_url TEXT NOT NULL DEFAULT '',
client_id TEXT NOT NULL DEFAULT '',
client_secret_enc BYTEA,
scopes TEXT NOT NULL DEFAULT 'openid email profile',
email_claim TEXT NOT NULL DEFAULT 'email',
button_label TEXT NOT NULL DEFAULT 'Sign in with SSO',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT oidc_settings_singleton CHECK (id = 1)
);
INSERT INTO oidc_settings (id) VALUES (1) ON CONFLICT DO NOTHING;
-- Opportunistisches Linking: beim ersten SSO-Login wird der OIDC-'sub'
-- gespeichert; weicht er später ab, wird der Login abgelehnt. Nullable,
-- kein Backfill (Match-Schlüssel bleibt die verifizierte E-Mail).
ALTER TABLE users ADD COLUMN IF NOT EXISTS oidc_subject TEXT;
-- +goose StatementEnd
-- +goose Down
-- +goose StatementBegin
ALTER TABLE users DROP COLUMN IF EXISTS oidc_subject;
DROP TABLE IF EXISTS oidc_settings;
-- +goose StatementEnd

View File

@@ -0,0 +1,62 @@
-- +goose Up
-- +goose StatementBegin
-- DHCP (Kea) — globale Singleton-Settings (node-lokal, wie dns_settings/
-- ntp_settings: ob DIESE Node DHCP betreibt). Subnets/Reservierungen sind
-- geteilte Config und werden repliziert.
CREATE TABLE IF NOT EXISTS dhcp_settings (
id INTEGER PRIMARY KEY DEFAULT 1,
enabled BOOLEAN NOT NULL DEFAULT false,
default_lease INTEGER NOT NULL DEFAULT 3600,
max_lease INTEGER NOT NULL DEFAULT 7200,
domain_name TEXT NOT NULL DEFAULT '',
dns_servers TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT dhcp_settings_singleton CHECK (id = 1)
);
INSERT INTO dhcp_settings (id) VALUES (1) ON CONFLICT DO NOTHING;
-- Subnets: an ein Interface per NAME gebunden (nicht per node-lokaler FK,
-- damit die Replikation nicht an divergierenden interface_id bricht).
CREATE TABLE IF NOT EXISTS dhcp_subnets (
id BIGSERIAL PRIMARY KEY,
name TEXT NOT NULL,
interface_name TEXT NOT NULL,
subnet_cidr TEXT NOT NULL,
pool_start TEXT NOT NULL DEFAULT '',
pool_end TEXT NOT NULL DEFAULT '',
gateway TEXT NOT NULL DEFAULT '',
dns_servers TEXT NOT NULL DEFAULT '',
lease_time INTEGER,
active BOOLEAN NOT NULL DEFAULT true,
description TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT dhcp_subnets_name_unique UNIQUE (name)
);
CREATE TABLE IF NOT EXISTS dhcp_reservations (
id BIGSERIAL PRIMARY KEY,
subnet_id BIGINT NOT NULL REFERENCES dhcp_subnets(id) ON DELETE CASCADE,
name TEXT NOT NULL DEFAULT '',
mac_address TEXT NOT NULL,
ip_address TEXT NOT NULL,
hostname TEXT NOT NULL DEFAULT '',
active BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT dhcp_reservations_subnet_mac_unique UNIQUE (subnet_id, mac_address)
);
CREATE INDEX IF NOT EXISTS idx_dhcp_reservations_subnet ON dhcp_reservations(subnet_id);
-- +goose StatementEnd
-- +goose Down
-- +goose StatementBegin
DROP TABLE IF EXISTS dhcp_reservations;
DROP TABLE IF EXISTS dhcp_subnets;
DROP TABLE IF EXISTS dhcp_settings;
-- +goose StatementEnd

View File

@@ -0,0 +1,51 @@
-- +goose Up
-- +goose StatementBegin
-- RADIUS (FreeRADIUS) — node-lokale Singleton-Settings (ob DIESE Node
-- RADIUS betreibt + Listen). Clients/Users sind geteilte Config (repliziert).
CREATE TABLE IF NOT EXISTS radius_settings (
id INTEGER PRIMARY KEY DEFAULT 1,
enabled BOOLEAN NOT NULL DEFAULT false,
listen_addresses TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT radius_settings_singleton CHECK (id = 1)
);
INSERT INTO radius_settings (id) VALUES (1) ON CONFLICT DO NOTHING;
-- NAS-Clients (Geräte, die RADIUS-Requests senden): IP/CIDR + Shared Secret
-- (verschlüsselt via secrets.Box).
CREATE TABLE IF NOT EXISTS radius_clients (
id BIGSERIAL PRIMARY KEY,
name TEXT NOT NULL,
ipaddr TEXT NOT NULL,
secret_enc BYTEA,
active BOOLEAN NOT NULL DEFAULT true,
description TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT radius_clients_name_unique UNIQUE (name)
);
-- Benutzer (PAP/CHAP): Name + Passwort (verschlüsselt; Cleartext nur zur
-- Render-Zeit in die freeradius-lesbare authorize-Datei).
CREATE TABLE IF NOT EXISTS radius_users (
id BIGSERIAL PRIMARY KEY,
username TEXT NOT NULL,
password_enc BYTEA,
active BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT radius_users_username_unique UNIQUE (username)
);
-- +goose StatementEnd
-- +goose Down
-- +goose StatementBegin
DROP TABLE IF EXISTS radius_users;
DROP TABLE IF EXISTS radius_clients;
DROP TABLE IF EXISTS radius_settings;
-- +goose StatementEnd

View File

@@ -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 +
@@ -22,6 +22,7 @@ import (
"context"
_ "embed"
"fmt"
"log/slog"
"net"
"os/exec"
"path/filepath"
@@ -138,6 +139,7 @@ type View struct {
type WGSiteMasqEntry struct {
Iface string // wg interface name, e.g. "wg7"
VPNNet string // network CIDR of the VPN subnet, e.g. "192.168.99.0/24"
L3 string // "ip" oder "ip6" — Familie von VPNNet
}
// AutoFWRule is one auto-emitted inbound rule. Proto is "tcp" or
@@ -148,10 +150,12 @@ type AutoFWRule struct {
Proto string
Port int
DstIP string
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
@@ -162,6 +166,10 @@ type RuleLeg struct {
DstIfaces []string
SrcAddrs []string
DstAddrs []string
// 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
Service ResolvedService // Proto="" → no service match (any)
}
@@ -195,6 +203,12 @@ type ResolvedNATRule struct {
DPortStart, DPortEnd int
TargetAddr string
TargetPortStart, TargetPortEnd int
// L3 ist "ip" oder "ip6" — Adressfamilie der Regel (aus SrcCIDR/
// DstCIDR/TargetAddr abgeleitet). TargetHost ist TargetAddr, bei
// IPv6 MIT Port in eckigen Klammern ("[2001:db8::1]") für korrekte
// nft-dnat-Syntax.
L3 string
TargetHost string
Comment string
}
@@ -255,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 {
@@ -280,26 +319,15 @@ func (g *Generator) loadView(ctx context.Context) (*View, error) {
if err != nil {
return nil, err
}
// Expand to one Leg per (rule × service); rules without a service
// produce one leg with empty Proto.
// Expand to one Leg per (rule × service × address-family). Rules
// without a service produce one leg-set with empty Proto.
for _, r := range rules {
if len(r.Services) == 0 {
view.Legs = append(view.Legs, RuleLeg{
RuleID: r.ID, Action: r.Action, Log: r.Log, Name: r.Name,
Comment: r.Comment,
SrcIfaces: r.SrcIfaces, DstIfaces: r.DstIfaces,
SrcAddrs: r.SrcAddrs, DstAddrs: r.DstAddrs,
})
view.Legs = append(view.Legs, expandFamilyLegs(r, ResolvedService{}, false)...)
continue
}
for _, svc := range r.Services {
view.Legs = append(view.Legs, RuleLeg{
RuleID: r.ID, Action: r.Action, Log: r.Log, Name: r.Name,
Comment: r.Comment,
SrcIfaces: r.SrcIfaces, DstIfaces: r.DstIfaces,
SrcAddrs: r.SrcAddrs, DstAddrs: r.DstAddrs,
Service: svc,
})
view.Legs = append(view.Legs, expandFamilyLegs(r, svc, true)...)
}
}
@@ -323,9 +351,14 @@ func (g *Generator) loadView(ctx context.Context) (*View, error) {
if wgRows.Scan(&name, &cidr) == nil {
view.WGServerIfaces = append(view.WGServerIfaces, name)
if _, ipNet, err := net.ParseCIDR(cidr); err == nil {
l3 := addrFamily(ipNet.String())
if l3 == "" {
l3 = "ip"
}
view.WGSiteMasq = append(view.WGSiteMasq, WGSiteMasqEntry{
Iface: name,
VPNNet: ipNet.String(),
L3: l3,
})
}
}
@@ -363,11 +396,24 @@ func (g *Generator) loadAutoRules(ctx context.Context) []AutoFWRule {
}
}
// Squid Forward-Proxy: wenn ≥1 aktive ACL → tcp 3128 inbound
// (squid bindet aktuell 0.0.0.0:3128, daher kein DstIP-Filter).
var aclCount int
if err := g.Pool.QueryRow(ctx, `SELECT count(*) FROM forward_proxy_acls WHERE active`).Scan(&aclCount); err == nil && aclCount > 0 {
out = append(out, AutoFWRule{Proto: "tcp", Port: 3128, Comment: "Forward-Proxy (Squid)"})
// Squid Forward-Proxy: lese Port + Listen-Adressen aus
// forward_proxy_settings. Für jede nicht-loopback IP eine
// Auto-Rule; leere Liste = alle Interfaces (generische Regel).
var squidAddrs string
var squidPort int
if err := g.Pool.QueryRow(ctx,
`SELECT listen_addresses, listen_port FROM forward_proxy_settings WHERE id=1`,
).Scan(&squidAddrs, &squidPort); err == nil && squidPort > 0 {
addrs := splitCSV(squidAddrs)
if len(addrs) == 0 {
out = append(out, AutoFWRule{Proto: "tcp", Port: squidPort, Comment: "Forward-Proxy (Squid)"})
} else {
for _, ip := range addrs {
if !isLoopback(ip) {
out = append(out, AutoFWRule{Proto: "tcp", Port: squidPort, DstIP: ip, Comment: "Forward-Proxy (Squid) auf " + ip})
}
}
}
}
// WireGuard server-mode: udp <listen_port> pro aktive iface.
@@ -398,7 +444,60 @@ func (g *Generator) loadAutoRules(ctx context.Context) []AutoFWRule {
}
}
return out
// DHCP (Kea): wenn auf DIESER Node aktiviert → udp/67 pro aktivem
// Subnet-Interface (gescopt auf die LAN-iface, NICHT global/WAN).
var dhcpEnabled bool
if err := g.Pool.QueryRow(ctx, `SELECT enabled FROM dhcp_settings WHERE id=1`).Scan(&dhcpEnabled); err == nil && dhcpEnabled {
rows, err := g.Pool.Query(ctx, `SELECT DISTINCT interface_name FROM dhcp_subnets WHERE active AND interface_name <> ''`)
if err == nil {
defer rows.Close()
for rows.Next() {
var iface string
if rows.Scan(&iface) == nil && iface != "" {
out = append(out, AutoFWRule{Proto: "udp", Port: 67, Iface: iface, Comment: "DHCP (Kea) auf " + iface})
}
}
}
}
// RADIUS (FreeRADIUS): wenn aktiviert → udp 1812 (auth) + 1813 (acct).
// Pro listen-IP, sonst global. FreeRADIUS verwirft unbekannte Clients selbst.
var radiusEnabled bool
var radiusListen string
if err := g.Pool.QueryRow(ctx, `SELECT enabled, listen_addresses FROM radius_settings WHERE id=1`).Scan(&radiusEnabled, &radiusListen); err == nil && radiusEnabled {
ips := splitCSV(radiusListen)
emit := func(ip string) {
out = append(out,
AutoFWRule{Proto: "udp", Port: 1812, DstIP: ip, Comment: "RADIUS-Auth (FreeRADIUS)"},
AutoFWRule{Proto: "udp", Port: 1813, DstIP: ip, Comment: "RADIUS-Acct (FreeRADIUS)"},
)
}
if len(ips) == 0 {
emit("")
} else {
for _, ip := range ips {
if !isLoopback(ip) && ip != "0.0.0.0" && ip != "::" {
emit(ip)
}
}
}
}
// Familien-Tag (ip/ip6) für DstIP-basierte Auto-Rules setzen; eine
// IPv6-Listen-Adresse muss `ip6 daddr` ergeben (sonst lehnt nft das
// gesamte Ruleset ab). Unparsebare DstIPs werden verworfen.
tagged := out[:0]
for _, r := range out {
if r.DstIP != "" {
fam := addrFamily(r.DstIP)
if fam == "" {
continue
}
r.L3 = fam
}
tagged = append(tagged, r)
}
return tagged
}
// splitCSV — wie in den Service-renderern.
@@ -422,6 +521,131 @@ func isLoopback(ip string) bool {
return strings.HasPrefix(ip, "127.")
}
// addrFamily klassifiziert einen nft-Adressausdruck (host, CIDR oder
// range "a-b") als "ip" (IPv4), "ip6" (IPv6) oder "" (unbestimmt, z.B.
// FQDN-Platzhalter). Adressen enthalten selbst kein '-', daher trennt der
// erste Bindestrich sicher eine Range in ihr erstes Element.
func addrFamily(expr string) string {
expr = strings.TrimSpace(expr)
if expr == "" {
return ""
}
if i := strings.IndexByte(expr, '-'); i > 0 {
expr = strings.TrimSpace(expr[:i])
}
if i := strings.IndexByte(expr, '/'); i > 0 {
expr = expr[:i]
}
ip := net.ParseIP(expr)
if ip == nil {
return ""
}
if ip.To4() != nil {
return "ip"
}
return "ip6"
}
// splitByFamily teilt eine Liste von nft-Adressausdrücken in v4 und v6.
// Unbestimmte (FQDN o.ä.) werden verworfen.
func splitByFamily(exprs []string) (v4, v6 []string) {
for _, e := range exprs {
switch addrFamily(e) {
case "ip":
v4 = append(v4, e)
case "ip6":
v6 = append(v6, e)
}
}
return v4, v6
}
// serviceL3: icmp ist v4-only, icmpv6 v6-only, tcp/udp/leer agnostic.
func serviceL3(svc ResolvedService) string {
switch svc.Proto {
case "icmp":
return "ip"
case "icmpv6":
return "ip6"
default:
return ""
}
}
// natFamily ermittelt die Adressfamilie einer NAT-Regel aus ihren
// Adressen. ok=false bei gemischten v4/v6-Adressen (ungültig → die Regel
// muss übersprungen werden, sonst bricht `nft -f` das gesamte Ruleset).
func natFamily(r ResolvedNATRule) (fam string, ok bool) {
for _, a := range []string{r.SrcCIDR, r.DstCIDR, r.TargetAddr} {
f := addrFamily(a)
if f == "" {
continue
}
if fam == "" {
fam = f
} else if fam != f {
return "", false
}
}
if fam == "" {
fam = "ip" // keine Adressen (reine iface/proto-Regel) → v4-Default
}
return fam, true
}
// expandFamilyLegs materialisiert die nft-Zeilen für eine Regel + optional
// einen Service, getrennt nach Adressfamilie. Adresslose Regeln ergeben eine
// einzige familienagnostische Zeile (unverändertes v4-Verhalten, greift
// zugleich für v6). Regeln mit Adressen werden pro Familie als separate
// Zeile emittiert — ein nft-Paket ist immer entweder v4 oder v6.
func expandFamilyLegs(r ResolvedRule, svc ResolvedService, hasSvc bool) []RuleLeg {
base := RuleLeg{
RuleID: r.ID, Action: r.Action, Log: r.Log, Name: r.Name, Comment: r.Comment,
SrcIfaces: r.SrcIfaces, DstIfaces: r.DstIfaces,
}
if hasSvc {
base.Service = svc
}
if len(r.SrcAddrs) == 0 && len(r.DstAddrs) == 0 {
// Kein Address-Match → eine Zeile, L3 leer. Die Proto-Render-Logik
// im Template setzt icmp/icmpv6 selbst familienkorrekt.
return []RuleLeg{base}
}
src4, src6 := splitByFamily(r.SrcAddrs)
dst4, dst6 := splitByFamily(r.DstAddrs)
svcFam := ""
if hasSvc {
svcFam = serviceL3(svc)
}
var legs []RuleLeg
for _, fam := range []string{"ip", "ip6"} {
if svcFam != "" && svcFam != fam {
continue // icmp nur auf v4, icmpv6 nur auf v6
}
srcF, dstF := src4, dst4
if fam == "ip6" {
srcF, dstF = src6, dst6
}
// Eine eingeschränkte Seite ohne Mitglied dieser Familie → die
// Zeile würde nichts (oder Falsches) matchen → überspringen.
if len(r.SrcAddrs) > 0 && len(srcF) == 0 {
continue
}
if len(r.DstAddrs) > 0 && len(dstF) == 0 {
continue
}
leg := base
leg.L3 = fam
leg.SrcAddrs = srcF
leg.DstAddrs = dstF
legs = append(legs, leg)
}
return legs
}
// addrObjMap is keyed by id; value is the nft expression for that
// object (e.g. "1.2.3.4", "10.0.0.0/24", "1.2.3.4-1.2.3.10").
type addrObjMap map[int64]string
@@ -680,6 +904,19 @@ ORDER BY priority DESC, id ASC`)
if outZone != nil {
r.OutIfaces = zoneIfaces[*outZone]
}
fam, ok := natFamily(r)
if !ok {
// Gemischte v4/v6-Adressen → ungültige NAT-Regel. Überspringen
// statt das gesamte Ruleset mit `nft -f` zu brechen.
slog.Warn("firewall: NAT-Regel mit gemischten v4/v6-Adressen übersprungen", "id", r.ID)
continue
}
r.L3 = fam
r.TargetHost = r.TargetAddr
if fam == "ip6" && r.TargetAddr != "" && r.TargetPortStart > 0 {
// nft braucht [v6]:port für dnat-Targets mit Port.
r.TargetHost = "[" + r.TargetAddr + "]"
}
out = append(out, r)
}
return out, rows.Err()

View File

@@ -0,0 +1,64 @@
package firewall
import (
"bytes"
"os"
"os/exec"
"strings"
"testing"
)
// TestTemplate_autoRuleIface prüft, dass eine Auto-Rule mit Iface als
// `iifname "<x>"`-gescopte Zeile rendert (DHCP udp/67 auf LAN) und dass
// DstIP-basierte Auto-Rules unverändert bleiben.
func TestTemplate_autoRuleIface(t *testing.T) {
view := &View{
AutoRules: []AutoFWRule{
{Proto: "udp", Port: 67, Iface: "eth1", Comment: "DHCP (Kea) auf eth1"},
{Proto: "udp", Port: 53, DstIP: "10.0.0.1", L3: "ip", Comment: "DNS"},
{Proto: "udp", Port: 53, DstIP: "2001:db8::1", L3: "ip6", Comment: "DNS v6"},
},
}
var buf bytes.Buffer
if err := tpl.Execute(&buf, view); err != nil {
t.Fatalf("template execute: %v", err)
}
out := buf.String()
if !strings.Contains(out, `iifname "eth1" udp dport 67 accept comment "auto: DHCP (Kea) auf eth1"`) {
t.Errorf("missing iface-scoped DHCP auto-rule\n----\n%s", out)
}
// v4-DstIP-Auto-Rule: ip daddr.
if !strings.Contains(out, `ip daddr 10.0.0.1 udp dport 53 accept`) {
t.Errorf("v4 DstIP auto-rule wrong\n----\n%s", out)
}
// Fix #5: v6-DstIP muss `ip6 daddr` ergeben (sonst bricht nft das Ruleset).
if !strings.Contains(out, `ip6 daddr 2001:db8::1 udp dport 53 accept`) {
t.Errorf("v6 DstIP auto-rule must use ip6 daddr\n----\n%s", out)
}
// Echte nft-Syntaxvalidierung (braucht root → via sudo, sonst skip).
nft, err := exec.LookPath("nft")
if err != nil {
t.Skip("nft not in PATH")
}
f, err := os.CreateTemp(t.TempDir(), "autorule-*.nft")
if err != nil {
t.Fatal(err)
}
_, _ = f.WriteString(out)
_ = f.Close()
var cmd *exec.Cmd
if os.Geteuid() == 0 {
cmd = exec.Command(nft, "-c", "-f", f.Name())
} else {
cmd = exec.Command("sudo", "-n", nft, "-c", "-f", f.Name())
}
if combined, err := cmd.CombinedOutput(); err != nil {
msg := string(combined)
if strings.Contains(msg, "Operation not permitted") || strings.Contains(msg, "password is required") {
t.Skipf("nft -c needs root: %s", strings.TrimSpace(msg))
}
t.Fatalf("nft -c rejected ruleset: %v\n%s\n----\n%s", err, combined, out)
}
}

View File

@@ -0,0 +1,142 @@
package firewall
import (
"context"
"os"
"os/exec"
"strings"
"testing"
"time"
"git.netcell-it.de/projekte/edgeguard-native/internal/database"
)
// TestE2E_IPv6Render fährt den ECHTEN Generator gegen eine Test-DB:
// alle Migrations + v4/v6-Seed + RenderToString + nft -c. Nur aktiv, wenn
// EG_FWTEST_DSN gesetzt ist (sonst Skip — `go test ./...` bleibt DB-frei).
func TestE2E_IPv6Render(t *testing.T) {
dsn := os.Getenv("EG_FWTEST_DSN")
if dsn == "" {
t.Skip("set EG_FWTEST_DSN to run the firewall end-to-end test")
}
ctx := context.Background()
// Retry: goose-Erst-Apply ist nicht concurrency-safe, wenn mehrere
// guarded Test-Pakete dieselbe frische DB parallel migrieren.
var mErr error
for i := 0; i < 3; i++ {
if mErr = database.Migrate(ctx, dsn); mErr == nil {
break
}
time.Sleep(700 * time.Millisecond)
}
if mErr != nil {
t.Fatalf("migrate: %v", mErr)
}
pool, err := database.Open(ctx, dsn)
if err != nil {
t.Fatalf("open: %v", err)
}
defer pool.Close()
for _, tbl := range []string{
"firewall_nat_rules", "firewall_rules",
"firewall_address_group_members", "firewall_address_groups",
"firewall_address_objects", "network_interfaces",
} {
if _, err := pool.Exec(ctx, "DELETE FROM "+tbl); err != nil {
t.Fatalf("clean %s: %v", tbl, err)
}
}
mustExec := func(sql string, args ...any) {
t.Helper()
if _, err := pool.Exec(ctx, sql, args...); err != nil {
t.Fatalf("seed failed (%s): %v", sql, err)
}
}
insID := func(sql string, args ...any) int64 {
t.Helper()
var id int64
if err := pool.QueryRow(ctx, sql, args...).Scan(&id); err != nil {
t.Fatalf("seed-id failed (%s): %v", sql, err)
}
return id
}
mustExec(`INSERT INTO network_interfaces (name,type,role) VALUES ('eth0','ethernet','wan'),('eth1','ethernet','lan')`)
v4net := insID(`INSERT INTO firewall_address_objects (name,kind,value) VALUES ('v4net','network','10.0.0.0/24') RETURNING id`)
v6net := insID(`INSERT INTO firewall_address_objects (name,kind,value) VALUES ('v6net','network','2001:db8:1::/64') RETURNING id`)
v6host := insID(`INSERT INTO firewall_address_objects (name,kind,value) VALUES ('v6host','host','2001:db8:2::5') RETURNING id`)
v6range := insID(`INSERT INTO firewall_address_objects (name,kind,value) VALUES ('v6range','range','2001:db8:3::1-2001:db8:3::9') RETURNING id`)
// Gemischte Gruppe (v4 + v6) → muss in zwei Familien-Zeilen splitten.
grp := insID(`INSERT INTO firewall_address_groups (name) VALUES ('mixed') RETURNING id`)
mustExec(`INSERT INTO firewall_address_group_members (group_id,object_id) VALUES ($1,$2),($1,$3)`, grp, v4net, v6net)
httpsSvc := insID(`INSERT INTO firewall_services (name,proto,port_start,port_end,builtin,description) VALUES ('t-https','tcp',443,443,false,'')
ON CONFLICT (name) DO UPDATE SET proto=excluded.proto RETURNING id`)
var pingV6 int64
_ = pool.QueryRow(ctx, `SELECT id FROM firewall_services WHERE proto='icmpv6' LIMIT 1`).Scan(&pingV6)
// (1) gemischte Gruppe + tcp443 → je eine ip- und ip6-Zeile.
mustExec(`INSERT INTO firewall_rules (name,action,src_zone,src_address_group_id,service_object_id) VALUES ('mixed-https','accept','any',$1,$2)`, grp, httpsSvc)
// (2) v6-host + icmpv6 → eine ip6-Zeile.
if pingV6 != 0 {
mustExec(`INSERT INTO firewall_rules (name,action,src_address_object_id,service_object_id) VALUES ('v6-ping','accept',$1,$2)`, v6host, pingV6)
}
// (3) v6-range src + v6net dst (kein Service).
mustExec(`INSERT INTO firewall_rules (name,action,src_address_object_id,dst_address_object_id) VALUES ('v6-range','drop',$1,$2)`, v6range, v6net)
// (a) v6-DNAT mit Port → dnat to [..]:port.
mustExec(`INSERT INTO firewall_nat_rules (name,kind,proto,match_dst_cidr,match_dport_start,target_addr,target_port_start) VALUES ('v6-dnat','dnat','tcp','2001:db8:9::/64',80,'2001:db8:9::2',8080)`)
// (b) v4-DNAT (Regression).
mustExec(`INSERT INTO firewall_nat_rules (name,kind,proto,match_dst_cidr,match_dport_start,target_addr,target_port_start) VALUES ('v4-dnat','dnat','tcp','1.2.3.4',80,'10.0.0.5',80)`)
// (c) gemischte Familie (v4 src, v6 target) → MUSS übersprungen werden.
mustExec(`INSERT INTO firewall_nat_rules (name,kind,proto,match_src_cidr,target_addr) VALUES ('mixed-snat','snat','any','10.0.0.0/24','2001:db8::99')`)
out, err := New(pool).RenderToString(ctx)
if err != nil {
t.Fatalf("render: %v", err)
}
for _, w := range []string{
"ip saddr { 10.0.0.0/24 }",
"ip6 saddr { 2001:db8:1::/64 }",
"ip6 nexthdr icmpv6",
"ip6 saddr { 2001:db8:3::1-2001:db8:3::9 }",
"dnat to [2001:db8:9::2]:8080",
"dnat to 10.0.0.5:80",
} {
if !strings.Contains(out, w) {
t.Errorf("rendered output missing %q\n----\n%s", w, out)
}
}
if strings.Contains(out, "2001:db8::99") {
t.Errorf("mixed-family NAT rule was not skipped\n----\n%s", out)
}
nft, err := exec.LookPath("nft")
if err != nil {
t.Skip("nft not in PATH — skipping syntax check")
}
f, err := os.CreateTemp(t.TempDir(), "e2e-*.nft")
if err != nil {
t.Fatal(err)
}
_, _ = f.WriteString(out)
_ = f.Close()
var cmd *exec.Cmd
if os.Geteuid() == 0 {
cmd = exec.Command(nft, "-c", "-f", f.Name())
} else {
cmd = exec.Command("sudo", "-n", nft, "-c", "-f", f.Name())
}
if combined, err := cmd.CombinedOutput(); err != nil {
msg := string(combined)
if strings.Contains(msg, "Operation not permitted") || strings.Contains(msg, "password is required") {
t.Skipf("nft -c needs root (no usable sudo): %s", strings.TrimSpace(msg))
}
t.Fatalf("nft -c rejected the real-rendered ruleset: %v\n%s\n----\n%s", err, combined, out)
}
}

View File

@@ -0,0 +1,183 @@
package firewall
import (
"bytes"
"os"
"os/exec"
"strings"
"testing"
)
func TestAddrFamily(t *testing.T) {
cases := map[string]string{
"1.2.3.4": "ip",
"10.0.0.0/24": "ip",
"1.2.3.4-1.2.3.10": "ip",
"2001:db8::1": "ip6",
"fd00::/64": "ip6",
"2001:db8::1-2001:db8::5": "ip6",
"example.com": "",
"": "",
}
for in, want := range cases {
if got := addrFamily(in); got != want {
t.Errorf("addrFamily(%q)=%q want %q", in, got, want)
}
}
}
func TestExpandFamilyLegs_splitsByFamily(t *testing.T) {
r := ResolvedRule{
ID: 1, Action: "accept",
SrcAddrs: []string{"10.0.0.0/24", "fd00::/64"},
DstAddrs: []string{"1.2.3.4", "2001:db8::1"},
}
legs := expandFamilyLegs(r, ResolvedService{}, false)
if len(legs) != 2 {
t.Fatalf("want 2 legs (v4+v6), got %d", len(legs))
}
var v4, v6 *RuleLeg
for i := range legs {
switch legs[i].L3 {
case "ip":
v4 = &legs[i]
case "ip6":
v6 = &legs[i]
}
}
if v4 == nil || v6 == nil {
t.Fatalf("missing family leg: %+v", legs)
}
if len(v4.SrcAddrs) != 1 || v4.SrcAddrs[0] != "10.0.0.0/24" || v4.DstAddrs[0] != "1.2.3.4" {
t.Errorf("v4 leg wrong: src=%v dst=%v", v4.SrcAddrs, v4.DstAddrs)
}
if len(v6.SrcAddrs) != 1 || v6.SrcAddrs[0] != "fd00::/64" || v6.DstAddrs[0] != "2001:db8::1" {
t.Errorf("v6 leg wrong: src=%v dst=%v", v6.SrcAddrs, v6.DstAddrs)
}
}
func TestExpandFamilyLegs_addresslessIsAgnostic(t *testing.T) {
legs := expandFamilyLegs(ResolvedRule{ID: 2, Action: "accept"}, ResolvedService{}, false)
if len(legs) != 1 || legs[0].L3 != "" {
t.Fatalf("addressless rule must be a single agnostic leg, got %d legs L3=%q", len(legs), legs[0].L3)
}
}
func TestExpandFamilyLegs_oneFamilyOnly(t *testing.T) {
// src nur v4, dst nur v4 → genau eine v4-Zeile (kein leerer v6-Leg).
r := ResolvedRule{ID: 3, Action: "drop", SrcAddrs: []string{"10.0.0.0/8"}}
legs := expandFamilyLegs(r, ResolvedService{}, false)
if len(legs) != 1 || legs[0].L3 != "ip" {
t.Fatalf("v4-only rule want 1 ip leg, got %+v", legs)
}
}
func TestExpandFamilyLegs_icmpFamilyMatch(t *testing.T) {
r6 := ResolvedRule{ID: 4, Action: "accept", SrcAddrs: []string{"fd00::/64"}}
if legs := expandFamilyLegs(r6, ResolvedService{Proto: "icmpv6"}, true); len(legs) != 1 || legs[0].L3 != "ip6" {
t.Fatalf("icmpv6+v6 want 1 ip6 leg, got %+v", legs)
}
if legs := expandFamilyLegs(r6, ResolvedService{Proto: "icmp"}, true); len(legs) != 0 {
t.Fatalf("icmp on v6-only addrs want 0 legs, got %+v", legs)
}
}
func TestNatFamily(t *testing.T) {
if _, ok := natFamily(ResolvedNATRule{SrcCIDR: "10.0.0.0/24", TargetAddr: "2001:db8::1"}); ok {
t.Error("mixed v4/v6 NAT must be rejected (ok=false)")
}
if fam, ok := natFamily(ResolvedNATRule{TargetAddr: "2001:db8::1"}); !ok || fam != "ip6" {
t.Errorf("v6 NAT: fam=%q ok=%v want ip6/true", fam, ok)
}
if fam, ok := natFamily(ResolvedNATRule{SrcCIDR: "10.0.0.0/24"}); !ok || fam != "ip" {
t.Errorf("v4 NAT: fam=%q ok=%v want ip/true", fam, ok)
}
if fam, ok := natFamily(ResolvedNATRule{}); !ok || fam != "ip" {
t.Errorf("addressless NAT: fam=%q ok=%v want ip/true (v4 default)", fam, ok)
}
}
// renderView ist ein gemischter v4/v6-View, der alle geänderten
// Template-Zweige berührt.
func renderView(t *testing.T) string {
t.Helper()
view := &View{
PeerIPv4: []string{"10.0.0.1"},
PeerIPv6: []string{"fd00::1"},
Legs: []RuleLeg{
{RuleID: 1, Action: "accept", L3: "ip", SrcAddrs: []string{"10.0.0.0/24"}, Service: ResolvedService{Proto: "tcp", PortStart: 443}},
{RuleID: 1, Action: "accept", L3: "ip6", SrcAddrs: []string{"fd00::/64"}, Service: ResolvedService{Proto: "tcp", PortStart: 443}},
{RuleID: 2, Action: "accept", Service: ResolvedService{Proto: "icmpv6"}}, // adresslos, 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},
{ID: 6, Kind: "snat", L3: "ip6", SrcCIDR: "fd00::/64", TargetAddr: "2001:db8::99"},
{ID: 7, Kind: "dnat", L3: "ip", DstCIDR: "1.2.3.4", Proto: "tcp", DPortStart: 80, TargetAddr: "10.0.0.5", TargetHost: "10.0.0.5", TargetPortStart: 80},
},
WGSiteMasq: []WGSiteMasqEntry{{Iface: "wg7", VPNNet: "fd00:99::/64", L3: "ip6"}},
}
var buf bytes.Buffer
if err := tpl.Execute(&buf, view); err != nil {
t.Fatalf("template execute: %v", err)
}
return buf.String()
}
func TestTemplate_v6AndV4Render(t *testing.T) {
out := renderView(t)
mustContain := []string{
"ip saddr { 10.0.0.0/24 }", // v4-Regel unverändert
"ip6 saddr { fd00::/64 }", // v6-Regel
"ip6 daddr 2001:db8::/64", // v6-DNAT-Match
"dnat to [fd00::2]:8080", // v6-DNAT-Target geklammert
"dnat to 10.0.0.5:80", // v4-DNAT-Target unverändert
"ip6 saddr fd00::/64 snat to 2001:db8::99",
`oifname "wg7" ip6 saddr fd00:99::/64 masquerade`,
}
for _, w := range mustContain {
if !strings.Contains(out, w) {
t.Errorf("output missing %q\n----\n%s", w, out)
}
}
// v6-Adressen dürfen NIEMALS in einem ip-saddr/daddr-Set landen.
if strings.Contains(out, "ip saddr { fd00") || strings.Contains(out, "ip daddr { fd00") ||
strings.Contains(out, "ip saddr { 2001") {
t.Errorf("v6 address leaked into IPv4 match\n----\n%s", out)
}
}
// TestTemplate_nftSyntax validiert das gerenderte Ruleset mit `nft -c -f`
// (Check-Modus, kein Apply). Wird übersprungen, wenn nft nicht installiert
// ist (z.B. CI ohne nft).
func TestTemplate_nftSyntax(t *testing.T) {
nft, err := exec.LookPath("nft")
if err != nil {
t.Skip("nft binary not available — skipping syntax check")
}
out := renderView(t)
f, err := os.CreateTemp(t.TempDir(), "ruleset-*.nft")
if err != nil {
t.Fatal(err)
}
if _, err := f.WriteString(out); err != nil {
t.Fatal(err)
}
_ = f.Close()
// `nft -c` liest die Kernel-Ruleset-Cache via netlink → braucht root.
// Als nicht-root via sudo -n versuchen; klappt das nicht, skip statt fail
// (auf den Nodes rendert/prüft edgeguard ohnehin als root).
var cmd *exec.Cmd
if os.Geteuid() == 0 {
cmd = exec.Command(nft, "-c", "-f", f.Name())
} else {
cmd = exec.Command("sudo", "-n", nft, "-c", "-f", f.Name())
}
combined, err := cmd.CombinedOutput()
if err != nil {
msg := string(combined)
if strings.Contains(msg, "Operation not permitted") || strings.Contains(msg, "a password is required") || strings.Contains(msg, "may not run sudo") {
t.Skipf("nft -c needs root (no usable sudo): %s", strings.TrimSpace(msg))
}
t.Fatalf("nft -c -f rejected the generated ruleset: %v\n%s\n----\n%s", err, combined, out)
}
}

View File

@@ -3,7 +3,8 @@
# Source: internal/firewall/firewall.go.
# Re-generate via `edgeguard-ctl render-config` or via API mutations.
flush ruleset
add table inet edgeguard
flush table inet edgeguard
table inet edgeguard {
set peer_ipv4 {
@@ -49,13 +50,25 @@ table inet edgeguard {
# Cluster-internal: peers reach edgeguard-api over mTLS on :8443
tcp dport 8443 ip saddr @peer_ipv4 accept
tcp dport 8443 ip6 saddr @peer_ipv6 accept
# Cluster-internal: PG Logical Replication (:5432) + KeyDB Active-Active (:6379)
tcp dport 5432 ip saddr @peer_ipv4 accept
tcp dport 5432 ip6 saddr @peer_ipv6 accept
tcp dport 6379 ip saddr @peer_ipv4 accept
tcp dport 6379 ip6 saddr @peer_ipv6 accept
# 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
# editiert diese nicht. Wenn der Service entfernt/disabled
# wird, ist die Rule beim nächsten Render weg.
{{range .AutoRules}}
{{if .DstIP}}ip daddr {{.DstIP}} {{end}}{{.Proto}} dport {{.Port}} accept comment "auto: {{.Comment}}"
{{if .Iface}}iifname "{{.Iface}}" {{end}}{{if .DstIP}}{{.L3}} daddr {{.DstIP}} {{end}}{{.Proto}} dport {{.Port}} accept comment "auto: {{.Comment}}"
{{end}}
# ── Operator-defined rules ──
@@ -65,7 +78,7 @@ table inet edgeguard {
die Comment-Zeile angehängt — sonst frisst nft die rule
als Teil des # Kommentars). */ -}}
{{""}}
{{if .SrcIfaces}}iifname { {{join .SrcIfaces ", "}} } {{end}}{{if .DstIfaces}}oifname { {{join .DstIfaces ", "}} } {{end}}{{if .SrcAddrs}}ip saddr { {{join .SrcAddrs ", "}} } {{end}}{{if .DstAddrs}}ip daddr { {{join .DstAddrs ", "}} } {{end}}{{with .Service}}{{if and (or (eq .Proto "tcp") (eq .Proto "udp")) .PortStart}}{{.Proto}} dport {{.PortStart}}{{if and .PortEnd (ne .PortEnd .PortStart)}}-{{.PortEnd}}{{end}} {{else if eq .Proto "icmp"}}ip protocol icmp {{else if eq .Proto "icmpv6"}}ip6 nexthdr icmpv6 {{end}}{{end}}{{if .Log}}log prefix "edgeguard:{{.RuleID}} " group 0 {{end}}counter {{.Action}} comment "egid:{{.RuleID}}"
{{if .SrcIfaces}}iifname { {{join .SrcIfaces ", "}} } {{end}}{{if .DstIfaces}}oifname { {{join .DstIfaces ", "}} } {{end}}{{if .SrcAddrs}}{{.L3}} saddr { {{join .SrcAddrs ", "}} } {{end}}{{if .DstAddrs}}{{.L3}} daddr { {{join .DstAddrs ", "}} } {{end}}{{with .Service}}{{if and (or (eq .Proto "tcp") (eq .Proto "udp")) .PortStart}}{{.Proto}} dport {{.PortStart}}{{if and .PortEnd (ne .PortEnd .PortStart)}}-{{.PortEnd}}{{end}} {{else if eq .Proto "icmp"}}ip protocol icmp {{else if eq .Proto "icmpv6"}}ip6 nexthdr icmpv6 {{end}}{{end}}{{if .Log}}log prefix "edgeguard:{{.RuleID}} " group 0 {{end}}counter {{.Action}} comment "egid:{{.RuleID}}"
{{end}}
# ── DEFAULT-DROP LOGGING ───────────────────────────────────────
@@ -95,7 +108,7 @@ table inet edgeguard {
# nach und erlauben new-state-Pakete von dort. Return-Pakete
# gehen via ct state established schon durch.
{{range .NATRules}}{{if or (eq .Kind "snat") (eq .Kind "masquerade")}}{{if .SrcCIDR}}
ip saddr {{.SrcCIDR}} ct state new accept comment "auto-forward for NAT rule {{.ID}}"
{{.L3}} saddr {{.SrcCIDR}} ct state new accept comment "auto-forward for NAT rule {{.ID}}"
{{end}}{{end}}{{end}}
# Auto-Forward für WireGuard-Server-Interfaces: Peer-to-Peer-
@@ -122,7 +135,7 @@ table inet edgeguard {
{{""}}
{{/* nft-Syntax: erst L3-match (ip saddr/daddr), DANN L4 (tcp/udp dport).
Sonst quittiert der parser '... unexpected ip' an dieser Stelle. */}}
{{if .InIfaces}}iifname { {{join .InIfaces ", "}} } {{end}}{{if .SrcCIDR}}ip saddr {{.SrcCIDR}} {{end}}{{if .DstCIDR}}ip daddr {{.DstCIDR}} {{end}}{{if and .Proto (ne .Proto "any")}}{{.Proto}} {{else}}meta l4proto { tcp, udp } {{end}}{{if .DPortStart}}dport {{.DPortStart}}{{if and .DPortEnd (ne .DPortEnd .DPortStart)}}-{{.DPortEnd}}{{end}} {{end}}{{if .TargetAddr}}dnat to {{.TargetAddr}}{{if .TargetPortStart}}:{{.TargetPortStart}}{{if and .TargetPortEnd (ne .TargetPortEnd .TargetPortStart)}}-{{.TargetPortEnd}}{{end}}{{end}}{{end}}
{{if .InIfaces}}iifname { {{join .InIfaces ", "}} } {{end}}{{if .SrcCIDR}}{{.L3}} saddr {{.SrcCIDR}} {{end}}{{if .DstCIDR}}{{.L3}} daddr {{.DstCIDR}} {{end}}{{if and .Proto (ne .Proto "any")}}{{.Proto}} {{else}}meta l4proto { tcp, udp } {{end}}{{if .DPortStart}}dport {{.DPortStart}}{{if and .DPortEnd (ne .DPortEnd .DPortStart)}}-{{.DPortEnd}}{{end}} {{end}}{{if .TargetAddr}}dnat to {{.TargetHost}}{{if .TargetPortStart}}:{{.TargetPortStart}}{{if and .TargetPortEnd (ne .TargetPortEnd .TargetPortStart)}}-{{.TargetPortEnd}}{{end}}{{end}}{{end}}
{{end}}{{end}}
}
@@ -146,16 +159,16 @@ table inet edgeguard {
# Masquerade schreibt die Source auf die lokale Tunnel-IP um; Return-Traffic
# findet so den Weg zurück durch den Tunnel.
{{range .WGSiteMasq}}
oifname "{{.Iface}}" ip saddr {{.VPNNet}} masquerade comment "auto: WireGuard site-to-site masquerade {{.Iface}}"
oifname "{{.Iface}}" {{.L3}} saddr {{.VPNNet}} masquerade comment "auto: WireGuard site-to-site masquerade {{.Iface}}"
{{end}}
{{range .NATRules}}{{if eq .Kind "snat"}}
# NAT {{.ID}} (snat{{if .Comment}} — {{.Comment}}{{end}})
{{""}}
{{if .OutIfaces}}oifname { {{join .OutIfaces ", "}} } {{end}}{{if .SrcCIDR}}ip saddr {{.SrcCIDR}} {{end}}{{if .TargetAddr}}snat to {{.TargetAddr}}{{end}}
{{if .OutIfaces}}oifname { {{join .OutIfaces ", "}} } {{end}}{{if .SrcCIDR}}{{.L3}} saddr {{.SrcCIDR}} {{end}}{{if .TargetAddr}}snat to {{.TargetAddr}}{{end}}
{{end}}{{if eq .Kind "masquerade"}}
# NAT {{.ID}} (masquerade{{if .Comment}} — {{.Comment}}{{end}})
{{""}}
{{if .OutIfaces}}oifname { {{join .OutIfaces ", "}} } {{end}}{{if .SrcCIDR}}ip saddr {{.SrcCIDR}} {{end}}masquerade
{{if .OutIfaces}}oifname { {{join .OutIfaces ", "}} } {{end}}{{if .SrcCIDR}}{{.L3}} saddr {{.SrcCIDR}} {{end}}masquerade
{{end}}{{end}}
}
}

View File

@@ -0,0 +1,157 @@
// Package freeradius renders the FreeRADIUS client + user files from the
// radius_* tables and manages the freeradius service lifecycle.
//
// Two files are rendered (mirrors the multi-file WireGuard renderer):
// - clients.conf — NAS clients (ipaddr + shared secret)
// - authorize — users file ("name" Cleartext-Password := "pw")
// Both managed under /etc/edgeguard/freeradius/ and symlinked from the
// distro paths by postinst. Shared secrets / passwords are decrypted via
// secrets.Box at render time. Service runs ONLY when radius_settings.enabled
// is true on this node (default off).
package freeradius
import (
"bytes"
"context"
"fmt"
"strings"
"github.com/jackc/pgx/v5/pgxpool"
"git.netcell-it.de/projekte/edgeguard-native/internal/configgen"
radiussvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/radius"
"git.netcell-it.de/projekte/edgeguard-native/internal/services/secrets"
)
const (
ConfDir = configgen.EtcEdgeguard + "/freeradius"
ClientsPath = ConfDir + "/clients.conf"
AuthorizePath = ConfDir + "/authorize"
serviceName = "freeradius"
)
type Generator struct {
Pool *pgxpool.Pool
Repo *radiussvc.Repo
Box *secrets.Box
SkipReload bool
}
func New(pool *pgxpool.Pool, box *secrets.Box) *Generator {
return &Generator{Pool: pool, Repo: radiussvc.New(pool, box), Box: box}
}
func (g *Generator) Name() string { return "freeradius" }
// confEscape escaped FreeRADIUS-double-quoted-Strings (Backslash + Quote)
// und strippt Steuerzeichen (CR/LF) als Defense-in-Depth gegen Zeilen-
// Injection — die Werte werden zwar schon im Handler validiert.
func confEscape(s string) string {
s = strings.ReplaceAll(s, "\r", "")
s = strings.ReplaceAll(s, "\n", "")
s = strings.ReplaceAll(s, `\`, `\\`)
s = strings.ReplaceAll(s, `"`, `\"`)
return s
}
// buildClients rendert clients.conf. mask=true ersetzt Secrets durch *** (Preview).
func (g *Generator) buildClients(ctx context.Context, mask bool) (string, error) {
clients, err := g.Repo.ListClients(ctx)
if err != nil {
return "", err
}
var b bytes.Buffer
b.WriteString("# Generated by edgeguard-api — DO NOT EDIT.\n\n")
for _, c := range clients {
if !c.Active {
continue
}
secret := "***"
if !mask {
pt, err := g.Box.Open(c.SecretEnc)
if err != nil {
return "", fmt.Errorf("open secret for client %s: %w", c.Name, err)
}
secret = string(pt)
}
fmt.Fprintf(&b, "client %s {\n ipaddr = %s\n secret = \"%s\"\n shortname = %s\n}\n\n",
c.Name, c.IPAddr, confEscape(secret), c.Name)
}
return b.String(), nil
}
// buildAuthorize rendert die Users-Datei. mask=true ersetzt Passwörter durch ***.
func (g *Generator) buildAuthorize(ctx context.Context, mask bool) (string, error) {
users, err := g.Repo.ListUsers(ctx)
if err != nil {
return "", err
}
var b bytes.Buffer
b.WriteString("# Generated by edgeguard-api — DO NOT EDIT.\n\n")
for _, u := range users {
if !u.Active {
continue
}
pw := "***"
if !mask {
pt, err := g.Box.Open(u.PasswordEnc)
if err != nil {
return "", fmt.Errorf("open password for user %s: %w", u.Username, err)
}
pw = string(pt)
}
fmt.Fprintf(&b, "\"%s\" Cleartext-Password := \"%s\"\n", confEscape(u.Username), confEscape(pw))
}
return b.String(), nil
}
// RenderToString liefert beide Dateien (Secrets maskiert) für die Preview.
func (g *Generator) RenderToString(ctx context.Context) (string, error) {
clients, err := g.buildClients(ctx, true)
if err != nil {
return "", err
}
authorize, err := g.buildAuthorize(ctx, true)
if err != nil {
return "", err
}
return "# ── clients.conf ──\n" + clients + "\n# ── authorize ──\n" + authorize, nil
}
func (g *Generator) Render(ctx context.Context) error {
settings, err := g.Repo.GetSettings(ctx)
if err != nil {
return fmt.Errorf("get radius settings: %w", err)
}
if !settings.Enabled {
if g.SkipReload {
return nil
}
_ = configgen.DisableService(serviceName)
_ = configgen.StopService(serviceName)
return nil
}
clients, err := g.buildClients(ctx, false)
if err != nil {
return err
}
authorize, err := g.buildAuthorize(ctx, false)
if err != nil {
return err
}
if err := configgen.AtomicWrite(ClientsPath, []byte(clients), 0o640); err != nil {
return fmt.Errorf("write clients.conf: %w", err)
}
if err := configgen.AtomicWrite(AuthorizePath, []byte(authorize), 0o640); err != nil {
return fmt.Errorf("write authorize: %w", err)
}
if g.SkipReload {
return nil
}
if err := configgen.EnableService(serviceName); err != nil {
return err
}
return configgen.RestartService(serviceName)
}

View File

@@ -0,0 +1,83 @@
package freeradius
import (
"context"
"os"
"strings"
"testing"
"time"
"git.netcell-it.de/projekte/edgeguard-native/internal/database"
radiussvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/radius"
"git.netcell-it.de/projekte/edgeguard-native/internal/services/secrets"
)
// Guarded integration test: set EG_FWTEST_DSN (sonst skip).
func TestRender_ClientsAndUsers(t *testing.T) {
dsn := os.Getenv("EG_FWTEST_DSN")
if dsn == "" {
t.Skip("set EG_FWTEST_DSN to run the freeradius renderer test")
}
ctx := context.Background()
var mErr error
for i := 0; i < 3; i++ {
if mErr = database.Migrate(ctx, dsn); mErr == nil {
break
}
time.Sleep(700 * time.Millisecond)
}
if mErr != nil {
t.Fatalf("migrate: %v", mErr)
}
pool, err := database.Open(ctx, dsn)
if err != nil {
t.Fatalf("open: %v", err)
}
defer pool.Close()
for _, q := range []string{`DELETE FROM radius_clients`, `DELETE FROM radius_users`} {
if _, err := pool.Exec(ctx, q); err != nil {
t.Fatalf("clean: %v", err)
}
}
box := secrets.New(t.TempDir() + "/master_key")
repo := radiussvc.New(pool, box)
if _, err := repo.CreateClient(ctx, "testnas", "10.0.0.0/24", `s3c"ret\x`, true, "lab"); err != nil {
t.Fatalf("create client: %v", err)
}
if _, err := repo.CreateUser(ctx, "alice", "alicepw", true); err != nil {
t.Fatalf("create user: %v", err)
}
g := New(pool, box)
clients, err := g.buildClients(ctx, false)
if err != nil {
t.Fatalf("buildClients: %v", err)
}
for _, want := range []string{
"client testnas {",
"ipaddr = 10.0.0.0/24",
`secret = "s3c\"ret\\x"`, // " und \ escaped → Secret-Roundtrip + Escaping
"shortname = testnas",
} {
if !strings.Contains(clients, want) {
t.Errorf("clients.conf missing %q\n----\n%s", want, clients)
}
}
authorize, err := g.buildAuthorize(ctx, false)
if err != nil {
t.Fatalf("buildAuthorize: %v", err)
}
if !strings.Contains(authorize, `"alice" Cleartext-Password := "alicepw"`) {
t.Errorf("authorize missing alice entry\n----\n%s", authorize)
}
// Maskierte Preview enthält keine echten Secrets.
masked, _ := g.buildClients(ctx, true)
if strings.Contains(masked, "s3c") {
t.Errorf("masked preview leaked secret:\n%s", masked)
}
}

View File

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

View File

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

@@ -1,13 +1,19 @@
package handlers
import (
"bytes"
"context"
"encoding/json"
"errors"
"io"
"log/slog"
"net/http"
"strings"
"time"
"github.com/gin-gonic/gin"
"git.netcell-it.de/projekte/edgeguard-native/internal/cluster/clustertls"
"git.netcell-it.de/projekte/edgeguard-native/internal/handlers/response"
"git.netcell-it.de/projekte/edgeguard-native/internal/services/audit"
"git.netcell-it.de/projekte/edgeguard-native/internal/services/session"
@@ -26,6 +32,7 @@ type AuthHandler struct {
Audit *audit.Repo
NodeID string
Users *usersvc.Repo // optional — nil on first boot before DB is ready
ClusterTLS *clustertls.Store // optional — enables auth federation on cluster nodes
}
func NewAuthHandler(s *setup.Store, sig *session.Signer) *AuthHandler {
@@ -46,15 +53,29 @@ func (h *AuthHandler) WithUsers(u *usersvc.Repo) *AuthHandler {
return h
}
// WithClusterTLS enables auth federation: when local auth fails on a
// cluster node, Login tries the primary via mTLS /agent/auth/check.
func (h *AuthHandler) WithClusterTLS(store *clustertls.Store) *AuthHandler {
h.ClusterTLS = store
return h
}
const totpPendingCookie = "edgeguard_totp_pending"
// Register mounts /auth/login + /logout (public) and /auth/me
// (gated by requireAuth, passed in as a per-route middleware).
func (h *AuthHandler) Register(rg *gin.RouterGroup, requireAuth gin.HandlerFunc) {
g := rg.Group("/auth")
g.POST("/login", h.Login)
g.POST("/logout", h.Logout)
g.POST("/totp-verify", h.TOTPVerify)
g.GET("/me", requireAuth, h.Me)
g.POST("/reset-password", h.ResetPassword)
g.POST("/change-password", requireAuth, h.ChangePassword)
// TOTP self-service (authenticated user manages own 2FA)
g.POST("/totp/setup", requireAuth, h.TOTPSetup)
g.POST("/totp/confirm", requireAuth, h.TOTPConfirm)
g.DELETE("/totp", requireAuth, h.TOTPDisable)
}
type loginRequest struct {
@@ -66,6 +87,7 @@ type loginResponse struct {
Actor string `json:"actor"`
Role string `json:"role"`
ExpiresAt time.Time `json:"expires_at"`
TOTPRequired bool `json:"totp_required,omitempty"`
}
func (h *AuthHandler) Login(c *gin.Context) {
@@ -86,39 +108,94 @@ func (h *AuthHandler) Login(c *gin.Context) {
email := strings.TrimSpace(req.Email)
actor, role := "", "admin"
remote := c.ClientIP()
var totpEnabled bool
var viaDB bool // true wenn Rolle/TOTP bereits aus der DB-Row stammen
// 1. Try DB users table first.
if h.Users != nil {
u, hash, dbErr := h.Users.FindByEmail(c.Request.Context(), email)
ai, dbErr := h.Users.FindForAuth(c.Request.Context(), email)
if dbErr == nil {
if !u.Active {
if !ai.Active {
if h.Audit != nil {
_ = h.Audit.Log(c.Request.Context(), email, "auth.login.failed",
email, gin.H{"reason": "account_disabled", "remote": remote}, h.NodeID)
}
response.Unauthorized(c, errors.New("account_disabled"))
return
}
if !usersvc.VerifyPassword(hash, req.Password) {
if !usersvc.VerifyPassword(ai.PasswordHash, req.Password) {
if h.Audit != nil {
_ = h.Audit.Log(c.Request.Context(), email, "auth.login.failed",
email, gin.H{"reason": "invalid_credentials", "remote": remote}, h.NodeID)
}
response.Unauthorized(c, errors.New("invalid_credentials"))
return
}
actor = u.Email
role = u.Role
h.Users.RecordLogin(c.Request.Context(), u.ID)
actor = ai.Email
role = ai.Role
totpEnabled = ai.TOTPEnabled
viaDB = true
h.Users.RecordLogin(c.Request.Context(), ai.ID)
}
}
// 2. Fallback: setup-store admin (backwards compat for pre-DB installs).
if actor == "" {
if !strings.EqualFold(st.AdminEmail, email) || !st.VerifyAdminPassword(req.Password) {
response.Unauthorized(c, errors.New("invalid_credentials"))
return
}
if actor == "" && st.AdminEmail != "" {
if strings.EqualFold(st.AdminEmail, email) && st.VerifyAdminPassword(req.Password) {
actor = st.AdminEmail
role = "admin"
// Auto-migrate: insert the setup-store admin into the DB so it
// shows up in user management from this point on.
if h.Users != nil {
_, _ = h.Users.Upsert(c.Request.Context(), st.AdminEmail, req.Password, "admin", true)
}
}
}
// 3. Auth federation: cluster nodes forward failed auth to the primary.
if actor == "" && st.IsClusterNode && st.PrimaryFQDN != "" && h.ClusterTLS != nil {
if a, r, err := h.checkWithPrimary(c.Request.Context(), st.PrimaryFQDN, email, req.Password); err == nil {
actor = a
role = r
} else {
slog.Debug("auth: primary auth check failed", "primary", st.PrimaryFQDN, "error", err)
}
}
if actor == "" {
if h.Audit != nil {
_ = h.Audit.Log(c.Request.Context(), email, "auth.login.failed",
email, gin.H{"reason": "invalid_credentials", "remote": remote}, h.NodeID)
}
response.Unauthorized(c, errors.New("invalid_credentials"))
return
}
// Bei Fallback (Setup-Store) / Federation (Primary) stammen role/TOTP
// NICHT aus der DB. Rolle + TOTP-Status autoritativ aus der lokalen
// (replizierten) users-Row ableiten — damit 2FA greift und die Rolle
// nie aus einer Remote-Payload kommt. Ist der User lokal (noch) nicht
// vorhanden (Replikations-Lag/DB aus), bleibt es beim Fallback-Wert.
if actor != "" && !viaDB && h.Users != nil {
if ai, err := h.Users.FindForAuth(c.Request.Context(), actor); err == nil {
role = ai.Role
totpEnabled = ai.TOTPEnabled
}
}
// TOTP gate: password OK but 2FA required → issue a short-lived pending
// cookie and tell the UI to show the TOTP input.
if totpEnabled {
pending, ptok, err := h.Signer.IssueWithRoleTTL(actor, "totp_pending", 2*time.Minute)
if err != nil {
response.Internal(c, err)
return
}
c.SetSameSite(http.SameSiteStrictMode)
c.SetCookie(totpPendingCookie, pending, int(2*time.Minute/time.Second), "/", "", true, true)
_ = ptok
response.OK(c, loginResponse{TOTPRequired: true})
return
}
raw, tok, err := h.Signer.IssueWithRole(actor, role)
if err != nil {
@@ -127,6 +204,10 @@ func (h *AuthHandler) Login(c *gin.Context) {
}
setSessionCookie(c, raw, tok.Exp)
if h.Audit != nil {
_ = h.Audit.Log(c.Request.Context(), actor, "auth.login.success",
actor, gin.H{"role": role, "remote": remote}, h.NodeID)
}
response.OK(c, loginResponse{
Actor: tok.Actor,
Role: tok.Role,
@@ -134,6 +215,146 @@ func (h *AuthHandler) Login(c *gin.Context) {
})
}
type totpVerifyRequest struct {
Code string `json:"code" binding:"required"`
}
// TOTPVerify completes the two-step login: verifies the TOTP code from the
// pending cookie and, on success, issues a full session JWT.
func (h *AuthHandler) TOTPVerify(c *gin.Context) {
var req totpVerifyRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, err)
return
}
pendingRaw, err := c.Cookie(totpPendingCookie)
if err != nil || pendingRaw == "" {
response.Unauthorized(c, errors.New("no_pending_totp"))
return
}
ptok, err := h.Signer.Verify(pendingRaw)
if err != nil || ptok.Role != "totp_pending" {
response.Unauthorized(c, errors.New("invalid_pending_token"))
return
}
if h.Users == nil {
response.Internal(c, errors.New("users repo unavailable"))
return
}
ai, err := h.Users.FindForAuth(c.Request.Context(), ptok.Actor)
if err != nil || !ai.TOTPEnabled || ai.TOTPSecret == nil {
response.Unauthorized(c, errors.New("totp_not_configured"))
return
}
if !usersvc.VerifyTOTP(*ai.TOTPSecret, req.Code) {
if h.Audit != nil {
_ = h.Audit.Log(c.Request.Context(), ptok.Actor, "auth.totp.failed",
ptok.Actor, gin.H{"remote": c.ClientIP()}, h.NodeID)
}
response.Unauthorized(c, errors.New("invalid_totp_code"))
return
}
// Clear pending cookie, issue full session.
c.SetSameSite(http.SameSiteStrictMode)
c.SetCookie(totpPendingCookie, "", -1, "/", "", true, true)
raw, tok, err := h.Signer.IssueWithRole(ptok.Actor, ai.Role)
if err != nil {
response.Internal(c, err)
return
}
setSessionCookie(c, raw, tok.Exp)
if h.Audit != nil {
_ = h.Audit.Log(c.Request.Context(), ptok.Actor, "auth.login.success",
ptok.Actor, gin.H{"role": ai.Role, "remote": c.ClientIP(), "totp": true}, h.NodeID)
}
response.OK(c, loginResponse{
Actor: tok.Actor,
Role: tok.Role,
ExpiresAt: time.Unix(tok.Exp, 0).UTC(),
})
}
// TOTPSetup generates a new TOTP secret for the authenticated user and returns
// the provisioning URI (renders as QR code in the UI). Secret is not saved yet.
func (h *AuthHandler) TOTPSetup(c *gin.Context) {
tok := CurrentToken(c)
if tok == nil {
response.Unauthorized(c, nil)
return
}
secret, uri, err := usersvc.GenerateTOTPSecret(tok.Actor)
if err != nil {
response.Internal(c, err)
return
}
response.OK(c, gin.H{"secret": secret, "uri": uri})
}
type totpConfirmRequest struct {
Secret string `json:"secret" binding:"required"`
Code string `json:"code" binding:"required"`
}
// TOTPConfirm verifies the code against the provisioned secret and, on success,
// enables TOTP for the user.
func (h *AuthHandler) TOTPConfirm(c *gin.Context) {
var req totpConfirmRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, err)
return
}
tok := CurrentToken(c)
if tok == nil || h.Users == nil {
response.Unauthorized(c, nil)
return
}
u, _, err := h.Users.FindByEmail(c.Request.Context(), tok.Actor)
if err != nil {
response.Internal(c, err)
return
}
if err := h.Users.ConfirmTOTP(c.Request.Context(), u.ID, req.Secret, req.Code); err != nil {
if err.Error() == "invalid_totp_code" {
response.Err(c, http.StatusUnprocessableEntity, err)
return
}
response.Internal(c, err)
return
}
if h.Audit != nil {
_ = h.Audit.Log(c.Request.Context(), tok.Actor, "auth.totp.enabled",
tok.Actor, nil, h.NodeID)
}
response.OK(c, gin.H{"ok": true})
}
// TOTPDisable disables TOTP for the authenticated user.
func (h *AuthHandler) TOTPDisable(c *gin.Context) {
tok := CurrentToken(c)
if tok == nil || h.Users == nil {
response.Unauthorized(c, nil)
return
}
u, _, err := h.Users.FindByEmail(c.Request.Context(), tok.Actor)
if err != nil {
response.Internal(c, err)
return
}
if err := h.Users.DisableTOTP(c.Request.Context(), u.ID); err != nil {
response.Internal(c, err)
return
}
if h.Audit != nil {
_ = h.Audit.Log(c.Request.Context(), tok.Actor, "auth.totp.disabled",
tok.Actor, nil, h.NodeID)
}
response.OK(c, gin.H{"ok": true})
}
func (h *AuthHandler) Logout(c *gin.Context) {
clearSessionCookie(c)
response.OK(c, gin.H{"logged_out": true})
@@ -194,12 +415,49 @@ type changePasswordRequest struct {
// braucht das hier das current_password als Confirmation — verhindert
// dass eine kompromittierte Session den Account übernimmt ohne dass
// das alte Passwort bekannt ist.
//
// Lookup-Reihenfolge: 1) DB users-Tabelle (alle multi-user-Accounts),
// 2) setup-store Admin-Fallback (Legacy / pre-DB). Beim Setup-Admin
// werden beide Stores synchron gehalten.
func (h *AuthHandler) ChangePassword(c *gin.Context) {
var req changePasswordRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, err)
return
}
tok := CurrentToken(c)
if tok == nil {
response.Unauthorized(c, nil)
return
}
// 1. DB-backed user (alle via User-Management erstellten Accounts).
if h.Users != nil {
u, hash, dbErr := h.Users.FindByEmail(c.Request.Context(), tok.Actor)
if dbErr == nil {
if !usersvc.VerifyPassword(hash, req.CurrentPassword) {
response.Unauthorized(c, errors.New("invalid_current_password"))
return
}
if err := h.Users.SetPassword(c.Request.Context(), u.ID, req.NewPassword); err != nil {
response.Internal(c, err)
return
}
// Setup-Store-Admin synchron halten, falls gleiche E-Mail.
if st, _ := h.Setup.Load(); st != nil && strings.EqualFold(st.AdminEmail, tok.Actor) {
_ = h.Setup.SetAdminPassword(req.NewPassword)
}
if h.Audit != nil {
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "auth.password.change",
tok.Actor, gin.H{"actor": actorOf(c)}, h.NodeID)
}
response.OK(c, gin.H{"ok": true})
return
}
}
// 2. Fallback: setup-store Admin (vor DB-Migration oder nicht migriert).
st, err := h.Setup.Load()
if err != nil {
response.Internal(c, err)
@@ -209,10 +467,6 @@ func (h *AuthHandler) ChangePassword(c *gin.Context) {
response.Err(c, http.StatusServiceUnavailable, errors.New("setup_required"))
return
}
// Authorisierte Session ist nicht automatisch der Admin (Phase 4
// admin_users-Tabelle könnte mehrere Rollen haben). v1: aktuell
// nur der eine Admin-User; trotzdem prüfen wir das current_password
// gegen die persistierte Hash.
if !st.VerifyAdminPassword(req.CurrentPassword) {
response.Unauthorized(c, errors.New("invalid_current_password"))
return
@@ -225,13 +479,51 @@ func (h *AuthHandler) ChangePassword(c *gin.Context) {
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "auth.password.change",
st.AdminEmail, gin.H{"actor": actorOf(c)}, h.NodeID)
}
// Neue Session ausstellen — alte Cookie zeigt auf ein Token das
// noch gültig ist; das ist OK für UX (kein erzwungener Logout),
// sicherheitsbewusster: clearSession + force re-login. Wir
// halten's hier ruhig.
response.OK(c, gin.H{"ok": true})
}
// checkWithPrimary verifies credentials against the primary node via mTLS.
// Returns actor+role on success, error on failure.
func (h *AuthHandler) checkWithPrimary(ctx context.Context, primaryFQDN, email, password string) (string, string, error) {
clientTLS, err := h.ClusterTLS.ClientTLSConfig()
if err != nil {
return "", "", err
}
tr := &http.Transport{TLSClientConfig: clientTLS, TLSHandshakeTimeout: 5 * time.Second}
client := &http.Client{Transport: tr, Timeout: 8 * time.Second}
body, _ := json.Marshal(map[string]string{"email": email, "password": password})
reqURL := "https://" + primaryFQDN + ":8443/agent/auth/check"
req, err := http.NewRequestWithContext(ctx, http.MethodPost, reqURL, bytes.NewReader(body))
if err != nil {
return "", "", err
}
req.Header.Set("Content-Type", "application/json")
resp, err := client.Do(req)
if err != nil {
return "", "", err
}
defer 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)))
}
var env struct {
Data struct {
Actor string `json:"actor"`
Role string `json:"role"`
} `json:"data"`
}
if err := json.Unmarshal(raw, &env); err != nil {
return "", "", err
}
if env.Data.Actor == "" {
return "", "", errors.New("primary returned empty actor")
}
return env.Data.Actor, env.Data.Role, nil
}
func setSessionCookie(c *gin.Context, raw string, expUnix int64) {
maxAge := int(time.Until(time.Unix(expUnix, 0)).Seconds())
if maxAge < 0 {

View File

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

View File

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

@@ -2,8 +2,15 @@ package handlers
import (
"context"
"crypto/x509"
"encoding/json"
"encoding/pem"
"fmt"
"log/slog"
"net/http"
"os"
"os/exec"
"strings"
"time"
"github.com/gin-gonic/gin"
@@ -14,6 +21,8 @@ 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"
)
// ClusterHandler exposes cluster-state endpoints. /status ist die
@@ -27,6 +36,7 @@ type ClusterHandler struct {
Store *cluster.Store
LocalID string
Aggregator *aggregator.Aggregator
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.
@@ -36,13 +46,26 @@ type ClusterHandler struct {
// PeerReloader: optional, gesetzt bei Phase 3.5. Nach Auto-Register
// triggert das den firewall-Render damit peer_ipv4 frisch ist.
PeerReloader PeerReloader
// Audit + NodeID: optional, gesetzt via WithAudit. Nötig für
// protokollierte, mutierende Aktionen wie den Replication-Repair.
Audit *audit.Repo
NodeID string
}
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
@@ -57,12 +80,29 @@ func (h *ClusterHandler) WithJoinFlow(store *clustertls.Store, tokens *jointoken
return h
}
// WithAudit setzt den Audit-Repo + NodeID für protokollierte Aktionen.
func (h *ClusterHandler) WithAudit(a *audit.Repo, nodeID string) *ClusterHandler {
h.Audit = a
h.NodeID = nodeID
return h
}
func (h *ClusterHandler) Register(rg *gin.RouterGroup) {
g := rg.Group("/cluster")
g.GET("/nodes", h.ListNodes)
g.GET("/status", h.Status)
g.GET("/system/load", h.SystemLoad)
g.DELETE("/nodes/:id", h.DeleteNode)
g.GET("/vip-settings", h.GetVIPSettings)
g.PUT("/vip-settings", h.UpdateVIPSettings)
g.POST("/rolling-update", h.RollingUpdate)
g.GET("/rolling-update/status", h.RollingUpdateStatus)
g.POST("/repair-replication", h.RepairReplication)
g.GET("/repair-replication/status", h.RepairReplicationStatus)
g.GET("/vip-status", h.VIPStatus)
g.POST("/vip-test", h.VIPTest)
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)
@@ -75,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
@@ -111,6 +151,85 @@ func (h *ClusterHandler) DeleteNode(c *gin.Context) {
response.NoContent(c)
}
// GetVIPSettings liest die cluster_settings-Singleton-Row (VIP/VRRP-Config).
func (h *ClusterHandler) GetVIPSettings(c *gin.Context) {
if h.Store == nil {
response.NotFound(c, simpleError("cluster store not available"))
return
}
var cs vipSettingsRow
row := h.Store.Pool.QueryRow(c.Request.Context(), `
SELECT vip_address, vip_interface, vip_auth_pass, vrrp_router_id,
hb_interface, hb_src_ip, hb_peer_ip, hb_router_id, gw_check_ip
FROM cluster_settings WHERE id = 1`)
if err := row.Scan(&cs.VIPAddress, &cs.VIPInterface, &cs.VIPAuthPass, &cs.VRRPRouterID,
&cs.HBInterface, &cs.HBSrcIP, &cs.HBPeerIP, &cs.HBRouterID, &cs.GWCheckIP); err != nil {
response.Internal(c, err)
return
}
response.OK(c, cs)
}
// UpdateVIPSettings speichert die VIP/VRRP-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) {
var req vipSettingsRow
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, err)
return
}
if h.Store == nil {
response.NotFound(c, simpleError("cluster store not available"))
return
}
_, err := h.Store.Pool.Exec(c.Request.Context(), `
UPDATE cluster_settings
SET vip_address=$1, vip_interface=$2, vip_auth_pass=$3, vrrp_router_id=$4,
hb_interface=$5, hb_src_ip=$6, hb_peer_ip=$7, hb_router_id=$8, gw_check_ip=$9,
updated_at=NOW()
WHERE id=1`,
nullIfEmpty(req.VIPAddress), nullIfEmpty(req.VIPInterface),
nullIfEmpty(req.VIPAuthPass), req.VRRPRouterID,
nullIfEmpty(req.HBInterface), nullIfEmpty(req.HBSrcIP),
nullIfEmpty(req.HBPeerIP), req.HBRouterID, nullIfEmpty(req.GWCheckIP))
if err != nil {
response.Internal(c, err)
return
}
slog.Info("cluster: VIP settings updated", "vip", req.VIPAddress, "actor", actorOf(c))
// Keepalived-Config asynchron neu rendern
if h.PeerReloader != nil {
go func() {
rctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
if err := h.PeerReloader(rctx); err != nil {
slog.Warn("cluster: keepalived render after VIP update failed", "error", err)
}
}()
}
response.NoContent(c)
}
type vipSettingsRow struct {
VIPAddress *string `json:"vip_address"`
VIPInterface *string `json:"vip_interface"`
VIPAuthPass *string `json:"vip_auth_pass"`
VRRPRouterID int `json:"vrrp_router_id"`
HBInterface *string `json:"hb_interface"`
HBSrcIP *string `json:"hb_src_ip"`
HBPeerIP *string `json:"hb_peer_ip"`
HBRouterID int `json:"hb_router_id"`
GWCheckIP *string `json:"gw_check_ip"`
}
func nullIfEmpty(s *string) *string {
if s == nil || *s == "" {
return nil
}
return s
}
// RegisterPublic mountet die public (unauth) Endpoints — joining Peers
// haben noch keine Session/Cert, deshalb läuft /issue-cert vor der
// requireAuth-Middleware. Aufrufer muss diesen Group auf /api/v1 setzen
@@ -134,6 +253,90 @@ func (h *ClusterHandler) RegisterPublic(rg *gin.RouterGroup) {
func (h *ClusterHandler) RegisterAgent(rg *gin.RouterGroup) {
g := rg.Group("/agent/cluster")
g.POST("/peers", h.AgentRegisterPeer)
g.GET("/identity", h.AgentIdentity)
g.GET("/pg-replication-info", h.AgentPGReplicationInfo)
g.GET("/master-key", h.AgentMasterKey)
g.GET("/version", h.AgentVersion)
g.POST("/trigger-update", h.AgentTriggerUpdate)
g.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)
g.POST("/repair-replication", h.AgentRepairReplication)
g.GET("/repair-replication/status", h.AgentRepairReplicationStatus)
}
// AgentIdentity gibt die eigene ha_nodes-Row zurück. Wird vom Primary
// genutzt um joining-Peers aktiv zu reconcilen wenn autoRegister (Push)
// fehlgeschlagen ist — Pull-Fallback.
func (h *ClusterHandler) AgentIdentity(c *gin.Context) {
if h.Store == nil || h.LocalID == "" {
response.NotFound(c, simpleError("node not registered"))
return
}
node, err := h.Store.Get(c.Request.Context(), h.LocalID)
if err != nil {
if err == cluster.ErrNotFound {
response.NotFound(c, simpleError("local node not in ha_nodes yet"))
return
}
response.Internal(c, err)
return
}
response.OK(c, node)
}
// AgentPGReplicationInfo gibt die Replication-Credentials für pg_basebackup
// zurück. Nur über den mTLS-Agent-Listener erreichbar. Liest das Passwort
// aus /var/lib/edgeguard/pg-replication-secret. Gibt 404 zurück wenn die
// Datei fehlt (cluster-init-replication noch nicht ausgeführt).
func (h *ClusterHandler) AgentPGReplicationInfo(c *gin.Context) {
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
}
// Host = eigene Public-IP aus ha_nodes (oder Fallback: FQDN)
host := ""
if h.Store != nil && h.LocalID != "" {
if node, err := h.Store.Get(c.Request.Context(), h.LocalID); err == nil {
if node.PublicIP != nil && *node.PublicIP != "" {
host = *node.PublicIP
}
if host == "" {
host = node.FQDN
}
}
}
response.OK(c, gin.H{
"host": host,
"port": 5432,
"user": "edgeguard_replicator",
"password": strings.TrimSpace(pass),
})
}
// AgentMasterKey gibt den Secrets-Master-Key zurück, damit cluster-setup-standby
// ihn auf dem Secondary synchronisieren kann. Nur über den mTLS-Agent-Listener
// erreichbar. Ohne gemeinsamen Master-Key können replizierte verschlüsselte
// Felder (WireGuard private keys, PSKs) auf dem Secondary nicht entschlüsselt werden.
func (h *ClusterHandler) AgentMasterKey(c *gin.Context) {
const keyPath = "/var/lib/edgeguard/.master_key"
data, err := os.ReadFile(keyPath)
if err != nil {
response.NotFound(c, simpleError("master key nicht gefunden"))
return
}
response.OK(c, gin.H{"key_hex": fmt.Sprintf("%x", data)})
}
func readFileString(path string) (string, error) {
b, err := os.ReadFile(path)
if err != nil {
return "", err
}
return string(b), nil
}
// PeerReloader: optionale Funktion die nach einem Auto-Register
@@ -147,6 +350,12 @@ func (h *ClusterHandler) WithPeerReloader(r PeerReloader) *ClusterHandler {
return h
}
// WithVersion: setzt die laufende Binary-Version für Rolling-Update-Coordination.
func (h *ClusterHandler) WithVersion(v string) *ClusterHandler {
h.Version = v
return h
}
func (h *ClusterHandler) ListNodes(c *gin.Context) {
nodes, err := h.Store.List(c.Request.Context())
if err != nil {
@@ -208,6 +417,23 @@ func (h *ClusterHandler) Status(c *gin.Context) {
if len(out.Peers) > 0 {
out.Mode = "cluster"
}
// Pull-Reconcile für joining-Peers: wenn ein Peer via Aggregator
// erreichbar ist aber noch mit Placeholder-ID in ha_nodes steht,
// holen wir seine echte Identity aktiv ab. Best-effort goroutine —
// blockiert die Status-Response nicht.
if h.Aggregator != nil && h.Store != nil {
var joining []models.HANode
for _, p := range out.Peers {
if p.Status == "joining" || p.Status == "pending" {
joining = append(joining, p)
}
}
if len(joining) > 0 {
go h.reconcileJoiningPeers(joining)
}
}
// Drift-Detection: jeder peer mit anderem config_hash als unser
// lokaler → Banner-Trigger im UI.
if localHash != nil && *localHash != "" {
@@ -289,29 +515,78 @@ func localSystemLoad() any {
// ── Phase 3.4: Cluster-Join Token Flow ────────────────────────────────
// GenerateJoinToken — Admin generiert einen one-shot Bootstrap-Token
// für einen neuen Peer. Token wird NUR EINMAL zurückgegeben; Server
// speichert keinen Klartext, beim Re-Use blockt der nonce-Tracker.
// für einen neuen Peer. Der FQDN des neuen Nodes wird vorab übergeben
// so dass er sofort in ha_nodes vorregistriert und im UI angezeigt werden
// kann. Token wird NUR EINMAL zurückgegeben.
func (h *ClusterHandler) GenerateJoinToken(c *gin.Context) {
var req struct {
NodeFQDN string `json:"node_fqdn"`
}
// Body optional — wenn leer, läuft der Flow ohne Pre-Register.
_ = c.ShouldBindJSON(&req)
// 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)
return
}
// Wir liefern auch die primary-fqdn + ca-fingerprint mit, damit
// das UI den Join-Befehl als kompletten curl/CLI-String anzeigen
// kann.
caCert, _, err := h.TLSStore.LoadCA()
caFP := ""
if err == nil && caCert != nil {
caFP = jointoken.CAFingerprint16(caCert.Raw)
}
// Pre-register: wenn ein FQDN übergeben wurde, schon jetzt in
// ha_nodes anlegen (status='pending') + Firewall-Reload. So ist
// der Node bekannt bevor er überhaupt antwortet, und @peer_ipv4
// wird beim issue-cert (wo wir die IP haben) nur noch updaten.
if req.NodeFQDN != "" && h.Store != nil {
go h.preRegisterByFQDN(req.NodeFQDN)
}
response.OK(c, gin.H{
"token": token,
"expires_at": exp.UTC().Format(time.RFC3339),
"ca_fingerprint": caFP,
"node_fqdn": req.NodeFQDN,
})
}
// preRegisterByFQDN legt einen ha_nodes-Eintrag mit status='pending' an
// bevor der Joiner überhaupt die Verbindung aufbaut. Idempotent dank
// ON CONFLICT. Kein Firewall-Reload hier — die IP ist noch unbekannt;
// das erledigt preRegisterJoiner wenn die issue-cert-Anfrage eintrifft.
func (h *ClusterHandler) preRegisterByFQDN(fqdn string) {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
nodeID := fmt.Sprintf("prenode-%s", strings.ReplaceAll(fqdn, ".", "-"))
n := models.HANode{
ID: nodeID,
Name: fqdn,
FQDN: fqdn,
APIURL: "https://" + fqdn + ":3443",
Role: "peer",
Status: "pending",
}
if _, err := h.Store.UpsertSelf(ctx, n); err != nil {
slog.Warn("cluster: pre-register by FQDN failed", "fqdn", fqdn, "error", err)
}
}
// IssueCert — Joining Peer POSTet seinen CSR + den Token. Wir verifizieren
// + konsumieren den Token, signieren den CSR mit unserer Cluster-CA und
// liefern {ca_cert, peer_cert} zurück. PUBLIC Endpoint — keine Session-
@@ -337,8 +612,8 @@ func (h *ClusterHandler) IssueCert(c *gin.Context) {
}
// consumedBy → Remote-IP. Audit-Trail wenn jemand Tokens stiehlt
// und vom falschen Host einlöst.
consumedBy := c.ClientIP()
if _, err := h.Tokens.Consume(c.Request.Context(), req.Token, consumedBy); err != nil {
clientIP := c.ClientIP()
if _, err := h.Tokens.Consume(c.Request.Context(), req.Token, clientIP); err != nil {
response.BadRequest(c, err)
return
}
@@ -353,12 +628,321 @@ func (h *ClusterHandler) IssueCert(c *gin.Context) {
response.Internal(c, err)
return
}
// Pre-register the joining node SYNCHRONOUSLY before returning the
// cert so that nftables @peer_ipv4 already contains the joiner's IP
// by the time they call autoRegister on port 8443. A goroutine here
// caused a race: cert returned → joiner calls autoRegister → nftables
// not updated yet → connection refused → status stays "joining".
if h.Store != nil && h.PeerReloader != nil {
h.preRegisterJoiner(c.Request.Context(), clientIP, req.CSR)
}
response.OK(c, issueCertResponse{
CACert: caPEM,
PeerCert: peerCert,
})
}
// preRegisterJoiner inserts a minimal ha_nodes row for the joining peer
// (using the CSR CN as FQDN and the HTTP client IP as public_ip), then
// triggers a firewall reload so @peer_ipv4 contains the new IP before
// the peer tries to call /agent/cluster/peers on port 8443.
// Uses a stable deterministic ID so re-joins are idempotent.
func (h *ClusterHandler) preRegisterJoiner(parent context.Context, clientIP, csrPEM string) {
ctx, cancel := context.WithTimeout(parent, 10*time.Second)
defer cancel()
fqdn := cnFromCSR(csrPEM)
if fqdn == "" {
fqdn = "joining-" + clientIP
}
nodeID := fmt.Sprintf("prenode-%s", strings.ReplaceAll(fqdn, ".", "-"))
n := models.HANode{
ID: nodeID,
Name: fqdn,
FQDN: fqdn,
APIURL: "https://" + fqdn + ":3443",
Role: "peer",
Status: "joining",
}
n.PublicIP = &clientIP
if _, err := h.Store.UpsertSelf(ctx, n); err != nil {
slog.Warn("cluster: pre-register joiner failed", "fqdn", fqdn, "ip", clientIP, "error", err)
return
}
if err := h.PeerReloader(ctx); err != nil {
slog.Warn("cluster: PeerReloader failed after pre-register", "error", err)
return
}
slog.Info("cluster: joiner pre-registered, firewall updated", "fqdn", fqdn, "ip", clientIP)
}
// ptrStr dereferences a *string safely for comparison; nil → "".
func ptrStr(s *string) string {
if s == nil {
return ""
}
return *s
}
// cnFromCSR extracts the Subject Common Name from a PEM-encoded CSR.
// Returns empty string on any parse error.
func cnFromCSR(csrPEM string) string {
block, _ := pem.Decode([]byte(csrPEM))
if block == nil {
return ""
}
csr, err := x509.ParseCertificateRequest(block.Bytes)
if err != nil {
return ""
}
return csr.Subject.CommonName
}
// reconcileJoiningPeers versucht für jeden Peer im Status "joining" oder
// "pending" die echte Node-ID via /agent/cluster/identity zu holen und
// ihn in ha_nodes mit der richtigen ID einzutragen. Self-Healing-
// Fallback wenn autoRegister (Push von joining-Peer zu Primary) wegen
// eines temporären Netzwerkproblems fehlgeschlagen ist.
//
// Die public_ip des Placeholder-Rows wird in der neuen Row übernommen
// damit @peer_ipv4 (nftables) korrekt bleibt.
func (h *ClusterHandler) reconcileJoiningPeers(placeholders []models.HANode) {
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
results := h.Aggregator.FanOut(ctx, placeholders, "/agent/cluster/identity", h.LocalID)
changed := false
for i, res := range results {
if !res.OK || len(res.Data) == 0 {
continue
}
var identity models.HANode
if err := json.Unmarshal(res.Data, &identity); err != nil || identity.ID == "" {
continue
}
placeholder := placeholders[i]
if identity.ID == placeholder.ID {
continue // ID bereits korrekt
}
// Echte ID gefunden — row mit realer ID anlegen, public_ip aus
// dem Placeholder-Row übernehmen damit nftables korrekt bleibt.
n := identity
n.Status = "online"
if n.PublicIP == nil {
n.PublicIP = placeholder.PublicIP
}
if n.InternalIP == nil {
n.InternalIP = placeholder.InternalIP
}
// Placeholder zuerst löschen: ha_nodes hat UNIQUE(fqdn). Ohne
// dieses Delete würde UpsertSelf (ON CONFLICT(id)) mit fqdn-
// unique-Violation scheitern.
_ = h.Store.DeletePlaceholdersByFQDN(ctx, n.FQDN, n.ID)
out, err := h.Store.UpsertSelf(ctx, n)
if err != nil {
slog.Warn("cluster: reconcile joining peer: upsert failed",
"fqdn", n.FQDN, "real_id", n.ID, "error", err)
continue
}
changed = true
slog.Info("cluster: joining peer reconciled via identity pull",
"id", out.ID, "fqdn", out.FQDN, "placeholder_id", placeholder.ID)
}
if changed && h.PeerReloader != nil {
rctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := h.PeerReloader(rctx); err != nil {
slog.Warn("cluster: PeerReloader failed after reconcile", "error", err)
}
}
}
// AgentVersion gibt die laufende Binary-Version zurück. Wird vom Rolling-
// Update-Orchestrator gepollt um zu erkennen wann der Secondary die neue
// Version hat.
func (h *ClusterHandler) AgentVersion(c *gin.Context) {
response.OK(c, gin.H{"version": h.Version})
}
// AgentTriggerUpdate startet den Upgrade-Prozess auf diesem Node via
// systemd-run (detached). Wird vom Primary via mTLS aufgerufen um den
// Secondary zuerst zu aktualisieren (Rolling-Update). Pattern identisch
// zu /system/upgrade — nutzt dieselbe Sudoers-Whitelist aus dem postinst.
func (h *ClusterHandler) AgentTriggerUpdate(c *gin.Context) {
const scriptPath = "/var/lib/edgeguard/upgrade.sh"
const script = `#!/bin/bash
set -e
sleep 2
export DEBIAN_FRONTEND=noninteractive
dpkg --configure -a || true
retry_apt() {
local attempt=0 max=3 wait_for=15
while [ $attempt -lt $max ]; do
attempt=$((attempt + 1))
apt-get update -qq || true
# --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
return 1
}
retry_apt
echo "[upgrade] complete"
rm -f /var/lib/edgeguard/upgrade.sh
`
if err := os.WriteFile(scriptPath, []byte(script), 0o755); err != nil {
response.Internal(c, err)
return
}
const unitName = "edgeguard-upgrade.service"
_ = exec.Command("sudo", "-n", "/usr/bin/systemctl", "reset-failed", unitName).Run()
cmd := exec.Command("sudo", "-n", "/usr/bin/systemd-run",
"--unit="+unitName,
"--description=EdgeGuard self-upgrade",
"--collect",
"bash", scriptPath)
if err := cmd.Run(); err != nil {
slog.Warn("cluster: AgentTriggerUpdate: systemd-run failed", "error", err)
response.Internal(c, err)
return
}
slog.Info("cluster: rolling update triggered on this node by primary mTLS call",
"client", c.ClientIP())
c.JSON(http.StatusAccepted, gin.H{"status": "upgrading"})
}
// ── 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
@@ -392,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() {
@@ -435,6 +1019,8 @@ type registerPeerRequest struct {
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
}
// AgentRegisterPeer: vom Joiner nach issue-cert via mTLS aufgerufen.
@@ -473,17 +1059,34 @@ func (h *ClusterHandler) AgentRegisterPeer(c *gin.Context) {
// Node, hier ist der „Self" der joining-Peer auf dieser Primary-Seite.
// Der Name passt nicht 100% semantisch, aber das SQL ist exakt das was
// wir brauchen.)
// Rolle aus dem Request (default "peer"). Ein joining-Peer sendet keine
// Rolle → "peer". Der Primary-Push sendet "primary", damit die vom
// Secondary ausgelieferte UI den Primary korrekt als primary zeigt.
// Cert-CN authentifiziert die FQDN; role ist node-lokal/Anzeige (echte
// Rollenerkennung läuft über pg_publication).
role := strings.TrimSpace(req.Role)
if role == "" {
role = "peer"
}
n := models.HANode{
ID: req.ID,
Name: req.Name,
FQDN: req.FQDN,
APIURL: req.APIURL,
Role: "peer",
Status: "joining",
Role: role,
Status: "online", // peer IS online — it just connected via mTLS
}
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
@@ -497,17 +1100,34 @@ func (h *ClusterHandler) AgentRegisterPeer(c *gin.Context) {
v := req.Version
n.Version = &v
}
if req.ConfigHash != nil {
n.ConfigHash = req.ConfigHash
}
// Placeholder zuerst löschen: ha_nodes hat UNIQUE(fqdn). Der INSERT
// in UpsertSelf verwendet ON CONFLICT(id) — greift NICHT bei fqdn-
// Konflikten. Ohne das Delete würde der INSERT mit "duplicate key on
// ha_nodes_fqdn_unique" scheitern und der Peer bliebe ewig "joining".
_ = h.Store.DeletePlaceholdersByFQDN(c.Request.Context(), req.FQDN, req.ID)
// Snapshot der aktuellen IPs VOR dem Upsert — zum Vergleich danach.
// Nur wenn sich public_ip oder internal_ip ändert, müssen wir nftables
// neu laden (@peer_ipv4-Set). Periodische Pushes vom Secondary (alle
// 5 min) ändern nur version/config_hash, nicht die IPs → kein Reset.
existing, _ := h.Store.Get(c.Request.Context(), req.ID)
out, err := h.Store.UpsertSelf(c.Request.Context(), n)
if err != nil {
response.Internal(c, err)
return
}
// Firewall-Reload damit peer_ipv4-Set die neue IP aufnimmt. Best-
// effort: Fehler loggen, Response weiter durchreichen — der Peer
// hat seine Identity erfolgreich registriert, Operator kann manuell
// nachrendern.
if h.PeerReloader != nil {
// Firewall-Reload nur wenn sich die Peer-IP geändert hat oder der
// Peer neu eingetragen wurde. Verhindert Counter-Reset alle 5 min
// durch den periodischen Secondary-Push (runPrimaryPush).
ipChanged := existing == nil ||
ptrStr(existing.PublicIP) != ptrStr(out.PublicIP) ||
ptrStr(existing.InternalIP) != ptrStr(out.InternalIP)
if ipChanged && h.PeerReloader != nil {
go func() {
rctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
@@ -517,8 +1137,55 @@ func (h *ClusterHandler) AgentRegisterPeer(c *gin.Context) {
}()
}
slog.Info("cluster: peer registered via mTLS",
// Bei neuem Peer / IP-Wechsel als Info loggen (relevantes Ereignis),
// sonst Debug — die periodischen 30s-Pushes (runPrimaryPush/runPeerPush)
// würden sonst das Log fluten.
logFn := slog.Debug
if ipChanged {
logFn = slog.Info
}
logFn("cluster: peer registered via mTLS",
"id", out.ID, "fqdn", out.FQDN, "role", out.Role, "status", out.Status,
"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

@@ -0,0 +1,152 @@
package handlers
import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
"log/slog"
"os"
"os/exec"
"path/filepath"
"strings"
"github.com/gin-gonic/gin"
"github.com/jackc/pgx/v5/pgxpool"
"git.netcell-it.de/projekte/edgeguard-native/internal/aggregator"
"git.netcell-it.de/projekte/edgeguard-native/internal/handlers/response"
"git.netcell-it.de/projekte/edgeguard-native/internal/models"
)
const tlsCertDir = "/etc/edgeguard/tls"
// AgentTLSCerts liefert alle .pem-Dateien aus /etc/edgeguard/tls/ als
// Base64-Map. Wird vom Secondary via mTLS aufgerufen um Zertifikate
// des Primary zu spiegeln.
func (h *ClusterHandler) AgentTLSCerts(c *gin.Context) {
entries, err := os.ReadDir(tlsCertDir)
if err != nil {
response.Internal(c, err)
return
}
certs := make(map[string]string, len(entries))
for _, e := range entries {
if e.IsDir() || !strings.HasSuffix(e.Name(), ".pem") {
continue
}
data, err := os.ReadFile(filepath.Join(tlsCertDir, e.Name()))
if err != nil {
continue
}
certs[e.Name()] = base64.StdEncoding.EncodeToString(data)
}
response.OK(c, gin.H{"certs": certs})
}
// SyncTLSCertsFromPrimary holt alle TLS-Zertifikate vom Primary via mTLS
// und schreibt geänderte Dateien nach /etc/edgeguard/tls/. Relädt HAProxy
// wenn mindestens ein Zertifikat aktualisiert wurde.
//
// Läuft auf dem Secondary bei jedem runSecondaryConfigRender-Tick —
// nicht hash-gated, da certbot-Renewals den config_hash nicht ändern.
func SyncTLSCertsFromPrimary(ctx context.Context, pool *pgxpool.Pool, agg *aggregator.Aggregator, localID string) error {
if agg == nil {
return nil
}
// Primary-Peer aus ha_nodes ermitteln
rows, err := pool.Query(ctx,
`SELECT id, fqdn, api_url FROM ha_nodes WHERE id != $1 LIMIT 1`, localID)
if err != nil {
return err
}
defer rows.Close()
var primary *models.HANode
for rows.Next() {
n := &models.HANode{}
if err := rows.Scan(&n.ID, &n.FQDN, &n.APIURL); err != nil {
continue
}
primary = n
}
if primary == nil {
return nil // kein Peer → Single-Node
}
results := agg.FanOut(ctx, []models.HANode{*primary}, "/agent/cluster/tls-certs", localID)
if len(results) == 0 || !results[0].OK {
return nil // Primary nicht erreichbar — nächster Tick
}
var payload struct {
Certs map[string]string `json:"certs"`
}
if err := json.Unmarshal(results[0].Data, &payload); err != nil {
return err
}
if err := os.MkdirAll(tlsCertDir, 0o750); err != nil {
return err
}
changed := false
for name, b64 := range payload.Certs {
data, err := base64.StdEncoding.DecodeString(b64)
if err != nil {
slog.Warn("cert-sync: base64 decode failed", "file", name, "error", err)
continue
}
path := filepath.Join(tlsCertDir, name)
existing, readErr := os.ReadFile(path)
if readErr == nil && bytes.Equal(existing, data) {
continue // unverändert
}
if err := os.WriteFile(path, data, 0o640); err != nil {
slog.Warn("cert-sync: write failed", "file", name, "error", err)
continue
}
changed = true
slog.Info("cert-sync: updated", "file", name)
}
// 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)
}
}
return nil
}

View File

@@ -0,0 +1,403 @@
package handlers
import (
"context"
"encoding/json"
"errors"
"fmt"
"log/slog"
"os"
"os/exec"
"regexp"
"strconv"
"strings"
"time"
"github.com/gin-gonic/gin"
"git.netcell-it.de/projekte/edgeguard-native/internal/handlers/response"
"git.netcell-it.de/projekte/edgeguard-native/internal/models"
)
// Replication-Repair ("Resync erzwingen") für das Config-Drift-Banner.
//
// Drift entsteht, wenn ein Peer einen anderen config_hash hat als dieser
// Node — entweder weil die Logical-Replication-Subscription gestört ist
// oder weil direkt in die DB des Subscribers geschrieben wurde. Die
// Reparatur baut die Subscription neu auf und kopiert alle geteilten
// Tabellen frisch vom Primary (einseitig: Primary = Source of Truth).
//
// Rollen-Erkennung: NICHT über ha_nodes.role/pg_role — die sind je Node
// lokal und unzuverlässig (jede Node markiert sich selbst, pg_role bleibt
// 'standalone' bis `promote`). Verlässlich ist die PUBLICATION: nur der
// Primary hat `edgeguard_shared` (pg_publication ist für jeden DB-User
// lesbar). Der Subscriber hat sie nicht → er ist das Resync-Ziel.
//
// Ablauf:
// - Klick auf dem Primary → Dispatch via mTLS an den Peer
// (POST /agent/cluster/repair-replication) mit der eigenen Adresse als
// primary_host; der Peer resynct von dort.
// - Klick direkt auf dem Subscriber → läuft lokal (Quelle = der Peer).
//
// Die eigentliche Arbeit läuft — analog zum Rolling-Update — in einer
// transienten systemd-Unit, die `edgeguard-ctl cluster-setup-standby
// <primary>` ausführt.
const (
repairUnitName = "edgeguard-repair-replication.service"
repairScriptPath = "/var/lib/edgeguard/repair-replication.sh"
repairAgentPath = "/agent/cluster/repair-replication"
repairPubName = "edgeguard_shared" // muss zu cmd/edgeguard-ctl egPubName passen
)
// validRepairHost erlaubt nur IPv4/IPv6/Hostnamen — der Wert landet in
// einem Bash-Script das als root läuft, also strikt validieren.
var validRepairHost = regexp.MustCompile(`^[A-Za-z0-9._:-]{1,253}$`)
// repairDispatchBody ist der Body des Agent-Dispatch: der Primary teilt
// dem Subscriber seine Adresse mit, von der resynct werden soll.
type repairDispatchBody struct {
PrimaryHost string `json:"primary_host"`
}
// RepairReplication ist der UI-Endpoint. Hat dieser Node die Publication
// (= Primary), wird der Resync an den Peer delegiert; sonst (Subscriber)
// läuft er lokal mit dem Peer als Quelle.
func (h *ClusterHandler) RepairReplication(c *gin.Context) {
if h.Store == nil {
response.Internal(c, errors.New("cluster store unavailable"))
return
}
ctx := c.Request.Context()
all, err := h.Store.List(ctx)
if err != nil {
response.Internal(c, err)
return
}
local := findNode(all, h.LocalID)
peer := findOtherPeer(all, h.LocalID)
if peer == nil {
response.BadRequest(c, errors.New("kein Peer-Node im Cluster — nichts zu resyncen"))
return
}
isPrimary, err := h.nodeHasPublication(ctx)
if err != nil {
// Primary/Subscriber-Status nicht ermittelbar → NICHT raten
// (sonst Resync auf dem falschen Node). Abbrechen.
response.Internal(c, fmt.Errorf("primary-status nicht ermittelbar: %w", err))
return
}
if isPrimary {
// Primary → an den Subscriber-Peer delegieren, mit eigener Adresse.
if h.Aggregator == nil {
response.BadRequest(c, errors.New("kein mTLS-Aggregator verfügbar — Resync nicht delegierbar"))
return
}
primaryHost := pickPrimaryHost(local)
if primaryHost == "" || !validRepairHost.MatchString(primaryHost) {
response.BadRequest(c, errors.New("eigene Primary-Adresse (Mgmt/Internal/Public-IP/FQDN) fehlt oder ist ungültig"))
return
}
body, _ := json.Marshal(repairDispatchBody{PrimaryHost: primaryHost})
res := h.Aggregator.PostPeerWithBody(ctx, *peer, repairAgentPath, body)
if !res.OK {
response.Internal(c, fmt.Errorf("resync auf %s anstoßen: %s", peer.FQDN, res.Err))
return
}
slog.Info("cluster: replication repair delegated", "target", peer.FQDN, "primary_host", primaryHost)
if h.Audit != nil {
_ = h.Audit.Log(ctx, actorOf(c), "cluster.repair-replication",
peer.FQDN, gin.H{"target": "peer", "peer": peer.FQDN, "primary_host": primaryHost}, h.NodeID)
}
response.Accepted(c, gin.H{"dispatched": true, "target": "peer", "peer_fqdn": peer.FQDN})
return
}
// Subscriber → lokal ausführen, Quelle = der Peer (Primary).
host := pickPrimaryHost(peer)
if err := h.startResync(ctx, host); err != nil {
response.BadRequest(c, err)
return
}
if h.Audit != nil {
_ = h.Audit.Log(ctx, actorOf(c), "cluster.repair-replication",
host, gin.H{"target": "local", "primary": host}, h.NodeID)
}
response.Accepted(c, gin.H{"dispatched": true, "target": "local", "primary": host})
}
// AgentRepairReplication wird vom Primary via mTLS auf dem Subscriber
// aufgerufen und startet dort den lokalen Resync von primary_host.
func (h *ClusterHandler) AgentRepairReplication(c *gin.Context) {
if h.Store == nil {
response.Internal(c, errors.New("cluster store unavailable"))
return
}
ctx := c.Request.Context()
var body repairDispatchBody
_ = c.ShouldBindJSON(&body) // best-effort; Fallback unten
host := strings.TrimSpace(body.PrimaryHost)
if host == "" {
// Fallback: Quelle aus ha_nodes (der andere Node).
if all, err := h.Store.List(ctx); err == nil {
host = pickPrimaryHost(findOtherPeer(all, h.LocalID))
}
}
if err := h.startResync(ctx, host); err != nil {
response.BadRequest(c, err)
return
}
slog.Info("cluster: replication repair triggered by peer", "primary", host, "node", h.LocalID)
if h.Audit != nil {
_ = h.Audit.Log(ctx, "cluster-peer", "cluster.repair-replication",
host, gin.H{"target": "local", "primary": host, "via": "agent"}, h.NodeID)
}
response.Accepted(c, gin.H{"dispatched": true, "primary": host})
}
// startResync schreibt das Repair-Script und startet die transiente
// systemd-Unit. Safety-Guard: läuft NIE auf dem Publication-Primary.
func (h *ClusterHandler) startResync(ctx context.Context, primaryHost string) error {
primaryHost = strings.TrimSpace(primaryHost)
if primaryHost == "" {
return errors.New("keine Primary-Adresse für den Resync ermittelbar")
}
if !validRepairHost.MatchString(primaryHost) {
return fmt.Errorf("ungültige Primary-Adresse: %q", primaryHost)
}
// Niemals auf dem Primary (Publication-Quelle) resyncen — würde die
// eigene Config mit sich selbst überschreiben bzw. ist sinnlos.
// Bei Statusfehler fail-closed (NICHT resyncen).
isPrimary, err := h.nodeHasPublication(ctx)
if err != nil {
return fmt.Errorf("publication-status nicht ermittelbar: %w", err)
}
if isPrimary {
return errors.New("dieser Node ist der Publication-Primary — Resync läuft nur auf einem Subscriber")
}
if st := repairUnitState(); st == "activating" || st == "active" {
return errors.New("resync läuft bereits")
}
script := fmt.Sprintf(`#!/bin/bash
set -uo pipefail
echo "[repair] resync der Logical-Replication-Subscription von Primary %[1]s"
/usr/bin/edgeguard-ctl cluster-setup-standby %[1]s
rc=$?
if [ "$rc" -ne 0 ]; then
echo "[repair] cluster-setup-standby fehlgeschlagen (rc=$rc)"
exit "$rc"
fi
echo "[repair] abgeschlossen — config_hash wird beim nächsten Cluster-Status neu berechnet"
rm -f %[2]s
`, primaryHost, repairScriptPath)
if err := os.WriteFile(repairScriptPath, []byte(script), 0o755); err != nil {
return fmt.Errorf("write repair script: %w", err)
}
_ = exec.Command("sudo", "-n", "/usr/bin/systemctl", "reset-failed", repairUnitName).Run()
cmd := exec.Command("sudo", "-n", "/usr/bin/systemd-run",
"--unit="+repairUnitName,
"--description=EdgeGuard replication repair",
"--collect",
"bash", repairScriptPath)
if err := cmd.Run(); err != nil {
return fmt.Errorf("systemd-run failed: %w", err)
}
slog.Info("cluster: replication repair dispatched (local)", "primary", primaryHost, "node", h.LocalID)
return nil
}
// nodeHasPublication prüft, ob dieser Node die Replikations-Publication
// besitzt — das verlässliche Primary-Signal. pg_publication ist für jeden
// DB-User lesbar (anders als pg_subscription).
func (h *ClusterHandler) nodeHasPublication(ctx context.Context) (bool, error) {
if h.Store == nil || h.Store.Pool == nil {
return false, errors.New("no db pool")
}
cctx, cancel := context.WithTimeout(ctx, 2*time.Second)
defer cancel()
var exists bool
if err := h.Store.Pool.QueryRow(cctx,
`SELECT EXISTS(SELECT 1 FROM pg_publication WHERE pubname = $1)`, repairPubName,
).Scan(&exists); err != nil {
return false, err
}
return exists, nil
}
// repairStatusResponse spiegelt den Zustand der transienten Repair-Unit.
type repairStatusResponse struct {
Phase string `json:"phase"` // idle | running | success | failed
State string `json:"state"`
Result string `json:"result"`
ExitCode int `json:"exit_code"`
StartedAt string `json:"started_at,omitempty"`
FinishedAt string `json:"finished_at,omitempty"`
Log []string `json:"log"`
}
// RepairReplicationStatus liest den Job-Zustand. Auf dem Primary wird der
// Status vom Subscriber-Peer geholt (dort läuft der Job); sonst lokal.
func (h *ClusterHandler) RepairReplicationStatus(c *gin.Context) {
ctx := c.Request.Context()
// Status-Poll: bei Fehler kein 500 — einfach lokalen Status liefern.
isPrimary, _ := h.nodeHasPublication(ctx)
if h.Store != nil && isPrimary && h.Aggregator != nil {
if all, err := h.Store.List(ctx); err == nil {
if peer := findOtherPeer(all, h.LocalID); peer != nil {
results := h.Aggregator.FanOut(ctx,
[]models.HANode{*peer}, repairAgentPath+"/status", h.LocalID)
if len(results) == 1 && results[0].OK && len(results[0].Data) > 0 {
c.Data(200, "application/json", wrapEnvelope(results[0].Data))
return
}
// Peer nicht erreichbar → idle statt Fehler, damit das
// UI-Polling nicht hart abbricht.
response.OK(c, repairStatusResponse{Phase: "idle", Log: []string{}})
return
}
}
}
response.OK(c, localRepairStatus())
}
// AgentRepairReplicationStatus liefert den lokalen Job-Zustand an den
// abfragenden Primary.
func (h *ClusterHandler) AgentRepairReplicationStatus(c *gin.Context) {
response.OK(c, localRepairStatus())
}
// wrapEnvelope verpackt eine bereits entpackte data-Payload wieder in die
// Standard-Envelope, damit das UI (isEnvelope) sie konsistent liest.
func wrapEnvelope(data []byte) []byte {
out := []byte(`{"data":`)
out = append(out, data...)
out = append(out, []byte(`,"error":null,"message":"ok"}`)...)
return out
}
// localRepairStatus liest den Zustand der lokalen Repair-Unit aus systemd
// (analog UpgradeStatus). Quelle der Wahrheit für Job-Ende ist die Unit.
func localRepairStatus() repairStatusResponse {
out := repairStatusResponse{Phase: "idle", Log: []string{}}
if data, err := exec.Command("systemctl", "show", repairUnitName,
"--no-page",
"-p", "ActiveState",
"-p", "Result",
"-p", "ExecMainStatus",
"-p", "ExecMainStartTimestamp",
"-p", "ExecMainExitTimestamp",
).CombinedOutput(); err == nil {
for _, line := range strings.Split(string(data), "\n") {
kv := strings.SplitN(strings.TrimSpace(line), "=", 2)
if len(kv) != 2 {
continue
}
switch kv[0] {
case "ActiveState":
out.State = kv[1]
case "Result":
out.Result = kv[1]
case "ExecMainStatus":
out.ExitCode, _ = strconv.Atoi(kv[1])
case "ExecMainStartTimestamp":
if t, err := time.Parse("Mon 2006-01-02 15:04:05 MST", kv[1]); err == nil {
out.StartedAt = t.UTC().Format(time.RFC3339)
}
case "ExecMainExitTimestamp":
if t, err := time.Parse("Mon 2006-01-02 15:04:05 MST", kv[1]); err == nil {
out.FinishedAt = t.UTC().Format(time.RFC3339)
}
}
}
}
switch out.State {
case "activating", "active", "deactivating":
out.Phase = "running"
case "failed":
out.Phase = "failed"
case "inactive":
if out.Result == "success" && out.ExitCode == 0 && out.FinishedAt != "" {
out.Phase = "success"
} else if out.Result != "" && out.Result != "success" {
out.Phase = "failed"
}
}
if data, err := exec.Command("journalctl",
"-u", repairUnitName,
"--no-pager", "-n", "100", "-o", "cat",
).CombinedOutput(); err == nil {
lines := strings.Split(strings.TrimRight(string(data), "\n"), "\n")
if len(lines) != 1 || (lines[0] != "" && !strings.HasPrefix(lines[0], "-- No entries")) {
out.Log = lines
}
}
return out
}
// findNode liefert die ha_nodes-Row mit der gegebenen ID.
func findNode(nodes []models.HANode, id string) *models.HANode {
for i := range nodes {
if nodes[i].ID == id {
return &nodes[i]
}
}
return nil
}
// findOtherPeer liefert den (einen) anderen Node im 2-Node-Cluster.
// Bevorzugt einen online erreichbaren Peer.
func findOtherPeer(nodes []models.HANode, localID string) *models.HANode {
var fallback *models.HANode
for i := range nodes {
n := &nodes[i]
if n.ID == localID {
continue
}
if n.Status == "online" {
return n
}
if fallback == nil {
fallback = n
}
}
return fallback
}
// pickPrimaryHost wählt die beste erreichbare Adresse eines Node:
// Mgmt-IP → Internal-IP → Public-IP → FQDN. Strippt eine etwaige
// CIDR-Maske (inet-Spalten können "10.0.0.5/32" liefern).
func pickPrimaryHost(n *models.HANode) string {
if n == nil {
return ""
}
for _, cand := range []*string{n.MgmtIP, n.InternalIP, n.PublicIP} {
if cand != nil {
if h := strings.TrimSpace(strings.SplitN(*cand, "/", 2)[0]); h != "" {
return h
}
}
}
return strings.TrimSpace(n.FQDN)
}
// repairUnitState gibt den ActiveState der Repair-Unit zurück ("" wenn
// unbekannt). Für den Doppelstart-Schutz.
func repairUnitState() string {
out, err := exec.Command("systemctl", "show", repairUnitName, "--no-page", "-p", "ActiveState").CombinedOutput()
if err != nil {
return ""
}
for _, line := range strings.Split(string(out), "\n") {
if kv := strings.SplitN(strings.TrimSpace(line), "=", 2); len(kv) == 2 && kv[0] == "ActiveState" {
return kv[1]
}
}
return ""
}

View File

@@ -0,0 +1,342 @@
package handlers
import (
"context"
"encoding/json"
"log/slog"
"net/http"
"os"
"os/exec"
"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"
"git.netcell-it.de/projekte/edgeguard-native/internal/models"
aptsvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/apt"
)
// ruStateMu serialisiert Lesen/Schreiben der Rolling-Update-State-Datei
// (HTTP-Handler + Hintergrund-Goroutine greifen gleichzeitig zu).
var ruStateMu sync.Mutex
const rollingUpdateStateFile = "/var/lib/edgeguard/rolling-update-state.json"
const (
phaseIdle = "idle"
phaseUpdatingSecondary = "updating-secondary"
phaseWaitingSecondary = "waiting-secondary"
phaseUpdatingPrimary = "updating-primary"
phaseDone = "done"
phaseFailed = "failed"
)
// FinishRollingUpdateIfPending wird beim API-Start aufgerufen.
// - "updating-primary": der Primary ist gerade erfolgreich neugestartet →
// Update abgeschlossen → "done".
// - "updating-secondary"/"waiting-secondary": die orchestrierende Goroutine
// lief in DIESEM (jetzt neu gestarteten) Prozess und ist mit ihm gestorben.
// Die Phase kann nicht weiterlaufen → auf "idle" zurücksetzen, sonst zeigt
// die UI ewig "Rolling Update läuft". (Vorher blieb so ein Stand hängen.)
func FinishRollingUpdateIfPending() {
st := readRollingUpdateState()
switch st.Phase {
case phaseUpdatingPrimary:
writeRollingUpdateState(RollingUpdateState{
Phase: phaseDone,
SecondaryID: st.SecondaryID,
SecondaryFQDN: st.SecondaryFQDN,
})
case phaseUpdatingSecondary, phaseWaitingSecondary:
writeRollingUpdateState(RollingUpdateState{Phase: phaseIdle})
}
}
// RollingUpdateState hält den Fortschritt des Rolling-Updates.
// Persistiert in rollingUpdateStateFile damit der Status über
// einen kurzen API-Neustart hinaus lesbar bleibt.
type RollingUpdateState struct {
Phase string `json:"phase"`
SecondaryID string `json:"secondary_id,omitempty"`
SecondaryFQDN string `json:"secondary_fqdn,omitempty"`
StartedAt time.Time `json:"started_at,omitempty"`
UpdatedAt time.Time `json:"updated_at"`
Error string `json:"error,omitempty"`
}
func readRollingUpdateState() RollingUpdateState {
ruStateMu.Lock()
defer ruStateMu.Unlock()
data, err := os.ReadFile(rollingUpdateStateFile)
if err != nil {
return RollingUpdateState{Phase: phaseIdle, UpdatedAt: time.Now()}
}
var s RollingUpdateState
if err := json.Unmarshal(data, &s); err != nil {
return RollingUpdateState{Phase: phaseIdle, UpdatedAt: time.Now()}
}
// Terminale Zustände altern aus (statt Mutation-on-GET): nach 10 min
// gilt done/failed als idle — so verliert kein paralleler Poller das
// Ergebnis und ein alter Stand bleibt nicht hängen.
if (s.Phase == phaseDone || s.Phase == phaseFailed) && !s.UpdatedAt.IsZero() &&
time.Since(s.UpdatedAt) > 10*time.Minute {
return RollingUpdateState{Phase: phaseIdle, UpdatedAt: time.Now()}
}
return s
}
func writeRollingUpdateState(s RollingUpdateState) {
s.UpdatedAt = time.Now()
data, err := json.Marshal(s)
if err != nil {
slog.Warn("rolling-update: failed to marshal state", "error", err)
return
}
ruStateMu.Lock()
defer ruStateMu.Unlock()
// AtomicWrite (temp+rename) → Leser sehen nie einen partiellen Stand.
if err := configgen.AtomicWrite(rollingUpdateStateFile, data, 0o600); err != nil {
slog.Warn("rolling-update: failed to write state file", "error", err)
}
}
// RollingUpdate startet den Rolling-Update-Prozess:
// 1. Secondary aktualisieren (via mTLS /agent/cluster/trigger-update)
// 2. Warten bis Secondary neue Version meldet
// 3. Primary (dieser Node) aktualisieren (wie /system/upgrade)
//
// Kein Cluster vorhanden → 409 zurück damit der Client auf /system/upgrade
// ausweichen kann. Wenn bereits ein Rolling-Update läuft → aktuellen State.
func (h *ClusterHandler) RollingUpdate(c *gin.Context) {
if h.Aggregator == nil || h.Store == nil {
c.JSON(http.StatusConflict, gin.H{"error": "no cluster — use /system/upgrade"})
return
}
st := readRollingUpdateState()
if st.Phase != phaseIdle && st.Phase != phaseFailed && st.Phase != phaseDone {
response.OK(c, st)
return
}
nodes, err := h.Store.List(c.Request.Context())
if err != nil {
response.Internal(c, err)
return
}
var secondary *models.HANode
for i := range nodes {
if nodes[i].ID != h.LocalID {
secondary = &nodes[i]
break
}
}
if secondary == nil {
c.JSON(http.StatusConflict, gin.H{"error": "no peer node — use /system/upgrade"})
return
}
newState := RollingUpdateState{
Phase: phaseUpdatingSecondary,
SecondaryID: secondary.ID,
SecondaryFQDN: secondary.FQDN,
StartedAt: time.Now(),
}
writeRollingUpdateState(newState)
slog.Info("rolling-update: started", "secondary", secondary.FQDN)
go h.runRollingUpdate(secondary)
c.JSON(http.StatusAccepted, newState)
}
// RollingUpdateStatus gibt den aktuellen Rolling-Update-State zurück.
// Read-only — terminale Zustände altern in readRollingUpdateState aus
// (kein Reset-on-GET mehr, das parallelen Pollern das "done" wegnahm).
func (h *ClusterHandler) RollingUpdateStatus(c *gin.Context) {
response.OK(c, readRollingUpdateState())
}
func (h *ClusterHandler) runRollingUpdate(secondary *models.HANode) {
ctx := context.Background()
// Zielversion = das verfügbare apt-Candidate (worauf wir hochziehen) und
// die aktuelle Secondary-Version als Baseline. Beides steuert, ob der
// Secondary überhaupt etwas zu tun hat.
candidate := rollingCandidateVersion(ctx)
baseline := secondaryVersion(ctx, h, secondary)
// Ist der Secondary bereits auf der Zielversion, gibt es nichts
// hochzuziehen — KEIN Trigger, KEIN Warten. Sonst würde auf einen
// Version-Flip gewartet, der nie kommt → 10-min-Timeout (der frühere Bug,
// wenn beide Nodes schon aktuell waren).
secondaryUpToDate := candidate != "" && baseline != "" && baseline == candidate
if secondaryUpToDate {
slog.Info("rolling-update: secondary already at target — skipping secondary step",
"version", candidate)
} else {
// 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 {
writeRollingUpdateState(RollingUpdateState{
Phase: phaseFailed,
SecondaryID: secondary.ID,
SecondaryFQDN: secondary.FQDN,
Error: "trigger-update failed: " + result.Err,
})
slog.Warn("rolling-update: secondary trigger failed", "error", result.Err)
return
}
// 2. Secondary-Version pollen — der Secondary restartet nach dem
// Upgrade, danach zeigt /agent/cluster/version eine neue Version.
writeRollingUpdateState(RollingUpdateState{
Phase: phaseWaitingSecondary,
SecondaryID: secondary.ID,
SecondaryFQDN: secondary.FQDN,
})
slog.Info("rolling-update: waiting for secondary version flip",
"baseline", baseline, "candidate", candidate)
// Kurze Wartezeit damit apt auf dem Secondary erst losläuft
time.Sleep(20 * time.Second)
deadline := time.Now().Add(10 * time.Minute)
versionFlipped := false
for time.Now().Before(deadline) {
results := h.Aggregator.FanOut(ctx, []models.HANode{*secondary}, "/agent/cluster/version", h.LocalID)
if len(results) > 0 && results[0].OK {
var ver struct {
Version string `json:"version"`
}
if err := json.Unmarshal(results[0].Data, &ver); err == nil {
slog.Info("rolling-update: secondary version", "version", ver.Version,
"baseline", baseline, "candidate", candidate)
// Erfolg = Secondary hat die Zielversion erreicht (candidate)
// ODER hat sich gegenüber der Baseline überhaupt bewegt
// (Fallback, wenn candidate nicht ermittelbar war).
if ver.Version != "" &&
((candidate != "" && ver.Version == candidate) || ver.Version != baseline) {
versionFlipped = true
break
}
}
}
time.Sleep(10 * time.Second)
}
if !versionFlipped {
writeRollingUpdateState(RollingUpdateState{
Phase: phaseFailed,
SecondaryID: secondary.ID,
SecondaryFQDN: secondary.FQDN,
Error: "timeout (10 min) waiting for secondary version flip",
})
slog.Warn("rolling-update: secondary version flip timeout")
return
}
}
// 3. Primary (uns selbst) aktualisieren — identisch zu /system/upgrade.
// Ist der Primary bereits auf der Zielversion (z. B. beide Nodes schon
// aktuell), gibt es nichts zu tun → direkt "done". Sonst liefe ein
// apt-Lauf ohne Paket-Wechsel → kein Restart → Phase hinge ewig in
// "updating-primary".
if candidate != "" && h.Version == candidate {
slog.Info("rolling-update: primary already at target — nothing to upgrade", "version", candidate)
writeRollingUpdateState(RollingUpdateState{
Phase: phaseDone,
SecondaryID: secondary.ID,
SecondaryFQDN: secondary.FQDN,
})
return
}
writeRollingUpdateState(RollingUpdateState{
Phase: phaseUpdatingPrimary,
SecondaryID: secondary.ID,
SecondaryFQDN: secondary.FQDN,
})
slog.Info("rolling-update: triggering primary self-upgrade")
const scriptPath = "/var/lib/edgeguard/upgrade.sh"
const script = `#!/bin/bash
set -e
sleep 2
export DEBIAN_FRONTEND=noninteractive
dpkg --configure -a || true
retry_apt() {
local attempt=0 max=3 wait_for=15
while [ $attempt -lt $max ]; do
attempt=$((attempt + 1))
apt-get update -qq || true
# --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
return 1
}
retry_apt
echo "[upgrade] complete"
rm -f /var/lib/edgeguard/upgrade.sh
`
if err := os.WriteFile(scriptPath, []byte(script), 0o755); err != nil {
writeRollingUpdateState(RollingUpdateState{
Phase: phaseFailed,
SecondaryID: secondary.ID,
SecondaryFQDN: secondary.FQDN,
Error: "write upgrade script: " + err.Error(),
})
return
}
const unitName = "edgeguard-upgrade.service"
_ = exec.Command("sudo", "-n", "/usr/bin/systemctl", "reset-failed", unitName).Run()
cmd := exec.Command("sudo", "-n", "/usr/bin/systemd-run",
"--unit="+unitName,
"--description=EdgeGuard self-upgrade",
"--collect",
"bash", scriptPath)
if err := cmd.Run(); err != nil {
writeRollingUpdateState(RollingUpdateState{
Phase: phaseFailed,
SecondaryID: secondary.ID,
SecondaryFQDN: secondary.FQDN,
Error: "systemd-run failed: " + err.Error(),
})
slog.Warn("rolling-update: primary systemd-run failed", "error", err)
return
}
// State bleibt "updating-primary" — der Primary restartet gleich.
// UI erkennt Version-Flip via /system/health und schließt den Flow.
slog.Info("rolling-update: primary upgrade dispatched, process will restart")
}
// rollingCandidateVersion liefert best-effort die verfügbare apt-Candidate-
// Version des Meta-Pakets "edgeguard" — also die Version, auf die das Rolling-
// Update hochzieht. Leerer String, wenn apt sie nicht ermitteln kann (dann
// fällt runRollingUpdate auf reine Baseline-Flip-Erkennung zurück).
func rollingCandidateVersion(ctx context.Context) string {
vers := aptsvc.PackageVersions(ctx, false)
return vers["edgeguard_available"]
}
// secondaryVersion holt best-effort die laufende Version des Peers via mTLS.
func secondaryVersion(ctx context.Context, h *ClusterHandler, secondary *models.HANode) string {
results := h.Aggregator.FanOut(ctx, []models.HANode{*secondary}, "/agent/cluster/version", h.LocalID)
if len(results) > 0 && results[0].OK {
var ver struct {
Version string `json:"version"`
}
if json.Unmarshal(results[0].Data, &ver) == nil {
return ver.Version
}
}
return ""
}

View File

@@ -0,0 +1,319 @@
package handlers
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"os/exec"
"strings"
"github.com/gin-gonic/gin"
"github.com/jackc/pgx/v5/pgxpool"
"git.netcell-it.de/projekte/edgeguard-native/internal/handlers/response"
"git.netcell-it.de/projekte/edgeguard-native/internal/models"
)
// vipInfo enthält die für einen VIP-Schwenk notwendigen Daten.
type vipInfo struct {
ID int64 `json:"id"`
Address string `json:"address"`
Prefix int `json:"prefix"`
Device string `json:"device"`
}
// VIPStatusEntry kombiniert einen VIP mit den Nodes die ihn gerade halten.
type VIPStatusEntry struct {
VIP vipInfo `json:"vip"`
ActiveOn []string `json:"active_on"` // FQDNs der Nodes mit diesem VIP
}
// AgentActiveIPs gibt alle aktiven IPv4-Adressen dieses Nodes zurück.
// Wird vom Primary genutzt um zu prüfen welcher Node welchen VIP hält.
func (h *ClusterHandler) AgentActiveIPs(c *gin.Context) {
ips, err := localActiveIPs()
if err != nil {
response.Internal(c, err)
return
}
response.OK(c, gin.H{"ips": ips})
}
// vipCmdRequest ist der Body für den AgentVIPCmd-Endpoint.
type vipCmdRequest struct {
Action string `json:"action"` // "add" | "del"
Address string `json:"address"` // z.B. "10.0.5.1"
Prefix int `json:"prefix"` // z.B. 24
Device string `json:"device"` // z.B. "vlan100"
}
// AgentVIPCmd führt `ip addr add/del` auf diesem Node aus.
// Wird vom Primary via mTLS für VIP-Schwenk-Tests aufgerufen.
func (h *ClusterHandler) AgentVIPCmd(c *gin.Context) {
var req vipCmdRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, err)
return
}
if req.Action != "add" && req.Action != "del" {
response.BadRequest(c, simpleError("action must be 'add' or 'del'"))
return
}
if req.Address == "" || req.Device == "" || req.Prefix <= 0 || req.Prefix > 128 {
response.BadRequest(c, simpleError("address, device, prefix required"))
return
}
if err := runVIPCmd(req.Action, req.Address, req.Prefix, req.Device); err != nil {
slog.Warn("cluster: agent vip-cmd failed",
"action", req.Action, "addr", req.Address, "dev", req.Device, "error", err)
response.Internal(c, err)
return
}
slog.Info("cluster: agent vip-cmd ok",
"action", req.Action, "addr", req.Address, "prefix", req.Prefix,
"dev", req.Device, "caller", c.ClientIP())
response.OK(c, gin.H{"ok": true})
}
// VIPStatus liest alle VIPs (is_vip=true) aus der DB und fragt alle Nodes
// welche davon sie gerade aktiv haben. Nur sinnvoll im Cluster-Modus.
func (h *ClusterHandler) VIPStatus(c *gin.Context) {
vips, err := loadVIPs(c.Request.Context(), h.Store.Pool)
if err != nil {
response.Internal(c, err)
return
}
nodeIPs := h.collectActiveIPs(c.Request.Context())
result := make([]VIPStatusEntry, 0, len(vips))
for _, v := range vips {
entry := VIPStatusEntry{VIP: v}
for fqdn, ips := range nodeIPs {
for _, ip := range ips {
if ip == v.Address {
entry.ActiveOn = append(entry.ActiveOn, fqdn)
break
}
}
}
result = append(result, entry)
}
response.OK(c, gin.H{"vips": result})
}
// vipTestRequest steuert einen VIP-Schwenk.
type vipTestRequest struct {
IPAddressID int64 `json:"ip_address_id"`
Action string `json:"action"` // "to_secondary" | "restore"
}
// vipTestStep beschreibt einen Schritt des Schwenk-Prozesses.
type vipTestStep struct {
Step string `json:"step"`
OK bool `json:"ok"`
Message string `json:"message,omitempty"`
}
// VIPTest schwenkt einen VIP vom Primary auf den Secondary ("to_secondary")
// oder zurück ("restore"). Nur vom Primary aufzurufen.
func (h *ClusterHandler) VIPTest(c *gin.Context) {
var req vipTestRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, err)
return
}
if req.Action != "to_secondary" && req.Action != "restore" {
response.BadRequest(c, simpleError("action must be 'to_secondary' or 'restore'"))
return
}
vips, err := loadVIPs(c.Request.Context(), h.Store.Pool)
if err != nil {
response.Internal(c, err)
return
}
var target *vipInfo
for i := range vips {
if vips[i].ID == req.IPAddressID {
target = &vips[i]
break
}
}
if target == nil {
response.NotFound(c, simpleError("VIP not found or not marked as VIP"))
return
}
all, err := h.Store.List(c.Request.Context())
if err != nil {
response.Internal(c, err)
return
}
var peer *models.HANode
for i := range all {
if all[i].ID != h.LocalID {
peer = &all[i]
break
}
}
if peer == nil {
response.BadRequest(c, simpleError("kein Secondary-Peer gefunden"))
return
}
var steps []vipTestStep
addrPfx := fmt.Sprintf("%s/%d", target.Address, target.Prefix)
if req.Action == "to_secondary" {
// 1. VIP auf Secondary via mTLS hinzufügen
steps = append(steps, h.peerVIPCmd(c.Request.Context(), *peer, target, "add",
fmt.Sprintf("add %s dev %s auf %s", addrPfx, target.Device, peer.FQDN)))
// 2. VIP vom Primary entfernen (nur wenn Secondary-Add erfolgreich)
if steps[0].OK {
steps = append(steps, localVIPStep(target, "del",
fmt.Sprintf("del %s dev %s lokal", addrPfx, target.Device)))
}
} else {
// 1. VIP auf Primary zurückholen
steps = append(steps, localVIPStep(target, "add",
fmt.Sprintf("add %s dev %s lokal", addrPfx, target.Device)))
// 2. VIP auf Secondary entfernen
steps = append(steps, h.peerVIPCmd(c.Request.Context(), *peer, target, "del",
fmt.Sprintf("del %s dev %s auf %s", addrPfx, target.Device, peer.FQDN)))
}
slog.Info("cluster: vip-test", "action", req.Action, "vip", target.Address,
"dev", target.Device, "peer", peer.FQDN, "actor", actorOf(c))
response.OK(c, gin.H{"steps": steps})
}
// ── Hilfsfunktionen ───────────────────────────────────────────────────────
func loadVIPs(ctx context.Context, pool *pgxpool.Pool) ([]vipInfo, error) {
rows, err := pool.Query(ctx, `
SELECT ia.id, ia.address, ia.prefix, ni.name
FROM ip_addresses ia
JOIN network_interfaces ni ON ni.id = ia.interface_id
WHERE ia.is_vip = true AND ia.active = true
ORDER BY ni.name, ia.address`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []vipInfo
for rows.Next() {
var v vipInfo
if err := rows.Scan(&v.ID, &v.Address, &v.Prefix, &v.Device); err != nil {
return nil, err
}
out = append(out, v)
}
return out, rows.Err()
}
// collectActiveIPs fragt alle Cluster-Nodes (lokal + Peers via mTLS) nach
// ihren aktiven IPv4-Adressen und gibt eine Map[fqdn][]ip zurück.
func (h *ClusterHandler) collectActiveIPs(ctx context.Context) map[string][]string {
result := make(map[string][]string)
if h.Store == nil {
return result
}
all, err := h.Store.List(ctx)
if err != nil {
return result
}
// Lokaler Node
if ips, err := localActiveIPs(); err == nil {
for _, n := range all {
if n.ID == h.LocalID {
result[n.FQDN] = ips
break
}
}
}
// Peers via mTLS-Aggregator
if h.Aggregator != nil {
var peers []models.HANode
for _, n := range all {
if n.ID != h.LocalID {
peers = append(peers, n)
}
}
if len(peers) > 0 {
peerResults := h.Aggregator.FanOut(ctx, peers, "/agent/cluster/active-ips", h.LocalID)
for _, pr := range peerResults {
if !pr.OK || len(pr.Data) == 0 {
continue
}
var payload struct {
IPs []string `json:"ips"`
}
if err := json.Unmarshal(pr.Data, &payload); err == nil {
result[pr.FQDN] = payload.IPs
}
}
}
}
return result
}
// localActiveIPs liest alle aktiven IPv4-Adressen des lokalen Nodes via `ip`.
func localActiveIPs() ([]string, error) {
out, err := exec.Command("ip", "-4", "-o", "addr", "show").Output()
if err != nil {
return nil, err
}
var ips []string
for _, line := range strings.Split(string(out), "\n") {
parts := strings.Fields(line)
for i, p := range parts {
if p == "inet" && i+1 < len(parts) {
addr := strings.SplitN(parts[i+1], "/", 2)[0]
ips = append(ips, addr)
}
}
}
return ips, nil
}
// peerVIPCmd ruft AgentVIPCmd auf dem Peer via mTLS auf.
func (h *ClusterHandler) peerVIPCmd(ctx context.Context, peer models.HANode, vip *vipInfo, action, stepLabel string) vipTestStep {
step := vipTestStep{Step: stepLabel}
if h.Aggregator == nil {
step.Message = "aggregator nicht verfügbar"
return step
}
body, _ := json.Marshal(vipCmdRequest{
Action: action,
Address: vip.Address,
Prefix: vip.Prefix,
Device: vip.Device,
})
res := h.Aggregator.PostPeerWithBody(ctx, peer, "/agent/cluster/vip-cmd", body)
step.OK = res.OK
if !res.OK {
step.Message = res.Err
}
return step
}
// localVIPStep führt ip addr add/del auf dem lokalen Node aus.
func localVIPStep(vip *vipInfo, action, stepLabel string) vipTestStep {
step := vipTestStep{Step: stepLabel}
if err := runVIPCmd(action, vip.Address, vip.Prefix, vip.Device); err != nil {
step.Message = err.Error()
return step
}
step.OK = true
return step
}
// runVIPCmd führt `sudo /usr/lib/edgeguard/vip-cmd.sh {action} {addr/prefix} {dev}` aus.
func runVIPCmd(action, address string, prefix int, device string) error {
addrPfx := fmt.Sprintf("%s/%d", address, prefix)
out, err := exec.Command("sudo", "-n", "/usr/lib/edgeguard/vip-cmd.sh", action, addrPfx, device).CombinedOutput()
if err != nil {
return fmt.Errorf("vip-cmd.sh %s %s %s: %s", action, addrPfx, device, strings.TrimSpace(string(out)))
}
return nil
}

View File

@@ -0,0 +1,285 @@
package handlers
import (
"errors"
"net/http"
"github.com/gin-gonic/gin"
crowdsec "git.netcell-it.de/projekte/edgeguard-native/internal/crowdsec"
"git.netcell-it.de/projekte/edgeguard-native/internal/handlers/response"
"git.netcell-it.de/projekte/edgeguard-native/internal/services/audit"
)
// CrowdSecHandler exposes the CrowdSec IDS/IPS management REST API:
//
// GET /crowdsec/status
// GET /crowdsec/decisions
// POST /crowdsec/decisions
// DELETE /crowdsec/decisions (?ip=<ip> or ?id=<id>)
// GET /crowdsec/alerts
// DELETE /crowdsec/alerts/:id
// GET /crowdsec/bouncers
// DELETE /crowdsec/bouncers/:name
// GET /crowdsec/machines
// DELETE /crowdsec/machines/:id
// GET /crowdsec/collections
// POST /crowdsec/collections/:name/install
// DELETE /crowdsec/collections/:name
type CrowdSecHandler struct {
Audit *audit.Repo
NodeID string
}
// NewCrowdSecHandler returns a CrowdSecHandler wired with audit and node-id.
func NewCrowdSecHandler(a *audit.Repo, nodeID string) *CrowdSecHandler {
return &CrowdSecHandler{Audit: a, NodeID: nodeID}
}
// Register mounts all CrowdSec routes onto the provided authenticated router
// group.
func (h *CrowdSecHandler) Register(rg *gin.RouterGroup) {
g := rg.Group("/crowdsec")
g.GET("/status", h.Status)
g.GET("/decisions", h.ListDecisions)
g.POST("/decisions", h.AddDecision)
g.DELETE("/decisions", h.DeleteDecision)
g.GET("/alerts", h.ListAlerts)
g.DELETE("/alerts/:id", h.DeleteAlert)
g.GET("/bouncers", h.ListBouncers)
g.DELETE("/bouncers/:name", h.DeleteBouncer)
g.GET("/machines", h.ListMachines)
g.DELETE("/machines/:id", h.DeleteMachine)
g.GET("/collections", h.ListCollections)
g.POST("/collections/:name/install", h.InstallCollection)
g.DELETE("/collections/:name", h.RemoveCollection)
}
// csNotInstalled responds with 503 when cscli is absent.
func csNotInstalled(c *gin.Context) {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "crowdsec not installed"})
}
// ---------- Status ----------------------------------------------------------
// Status returns live status of the CrowdSec agent + bouncer.
// Does NOT require cscli — uses systemctl for running-state checks.
func (h *CrowdSecHandler) Status(c *gin.Context) {
st := crowdsec.ServiceStatus(c.Request.Context())
response.OK(c, st)
}
// ---------- Decisions -------------------------------------------------------
// ListDecisions returns all active decisions.
func (h *CrowdSecHandler) ListDecisions(c *gin.Context) {
if !crowdsec.IsInstalled() {
csNotInstalled(c)
return
}
list, err := crowdsec.Decisions(c.Request.Context())
if err != nil {
response.Internal(c, err)
return
}
response.OK(c, gin.H{"decisions": list})
}
// addDecisionBody is the expected JSON body for POST /crowdsec/decisions.
type addDecisionBody struct {
IP string `json:"ip" binding:"required"`
Duration string `json:"duration" binding:"required"`
Reason string `json:"reason"`
Type string `json:"type"`
}
// AddDecision creates a new ban/captcha decision.
func (h *CrowdSecHandler) AddDecision(c *gin.Context) {
if !crowdsec.IsInstalled() {
csNotInstalled(c)
return
}
var body addDecisionBody
if err := c.ShouldBindJSON(&body); err != nil {
response.BadRequest(c, err)
return
}
if body.Reason == "" {
body.Reason = "manual ban"
}
if body.Type == "" {
body.Type = "ban"
}
if err := crowdsec.AddDecision(c.Request.Context(), body.IP, body.Duration, body.Reason, body.Type); err != nil {
response.Internal(c, err)
return
}
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "crowdsec.decision.add", body.IP,
gin.H{"duration": body.Duration, "type": body.Type, "reason": body.Reason}, h.NodeID)
response.Created(c, gin.H{"ip": body.IP, "duration": body.Duration, "type": body.Type})
}
// DeleteDecision removes a decision by IP (?ip=) or by ID (?id=).
func (h *CrowdSecHandler) DeleteDecision(c *gin.Context) {
if !crowdsec.IsInstalled() {
csNotInstalled(c)
return
}
ip := c.Query("ip")
id := c.Query("id")
if ip == "" && id == "" {
response.BadRequest(c, errors.New("query parameter 'ip' or 'id' required"))
return
}
var err error
var target string
if ip != "" {
err = crowdsec.DeleteDecisionByIP(c.Request.Context(), ip)
target = ip
} else {
err = crowdsec.DeleteDecisionByID(c.Request.Context(), id)
target = id
}
if err != nil {
response.Internal(c, err)
return
}
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "crowdsec.decision.delete", target, nil, h.NodeID)
response.OK(c, gin.H{"deleted": target})
}
// ---------- Alerts ----------------------------------------------------------
// ListAlerts returns recent CrowdSec alerts.
func (h *CrowdSecHandler) ListAlerts(c *gin.Context) {
if !crowdsec.IsInstalled() {
csNotInstalled(c)
return
}
list, err := crowdsec.Alerts(c.Request.Context(), 200)
if err != nil {
response.Internal(c, err)
return
}
response.OK(c, gin.H{"alerts": list})
}
// DeleteAlert discards a single alert.
func (h *CrowdSecHandler) DeleteAlert(c *gin.Context) {
if !crowdsec.IsInstalled() {
csNotInstalled(c)
return
}
id := c.Param("id")
if err := crowdsec.DeleteAlert(c.Request.Context(), id); err != nil {
response.Internal(c, err)
return
}
response.OK(c, gin.H{"deleted": id})
}
// ---------- Bouncers --------------------------------------------------------
// ListBouncers returns all registered bouncers.
func (h *CrowdSecHandler) ListBouncers(c *gin.Context) {
if !crowdsec.IsInstalled() {
csNotInstalled(c)
return
}
list, err := crowdsec.Bouncers(c.Request.Context())
if err != nil {
response.Internal(c, err)
return
}
response.OK(c, gin.H{"bouncers": list})
}
// DeleteBouncer removes a bouncer by name.
func (h *CrowdSecHandler) DeleteBouncer(c *gin.Context) {
if !crowdsec.IsInstalled() {
csNotInstalled(c)
return
}
name := c.Param("name")
if err := crowdsec.DeleteBouncer(c.Request.Context(), name); err != nil {
response.Internal(c, err)
return
}
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "crowdsec.bouncer.delete", name, nil, h.NodeID)
response.OK(c, gin.H{"deleted": name})
}
// ---------- Machines --------------------------------------------------------
// ListMachines returns all registered machines.
func (h *CrowdSecHandler) ListMachines(c *gin.Context) {
if !crowdsec.IsInstalled() {
csNotInstalled(c)
return
}
list, err := crowdsec.Machines(c.Request.Context())
if err != nil {
response.Internal(c, err)
return
}
response.OK(c, gin.H{"machines": list})
}
// DeleteMachine removes a machine by ID.
func (h *CrowdSecHandler) DeleteMachine(c *gin.Context) {
if !crowdsec.IsInstalled() {
csNotInstalled(c)
return
}
id := c.Param("id")
if err := crowdsec.DeleteMachine(c.Request.Context(), id); err != nil {
response.Internal(c, err)
return
}
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "crowdsec.machine.delete", id, nil, h.NodeID)
response.OK(c, gin.H{"deleted": id})
}
// ---------- Collections -----------------------------------------------------
// ListCollections returns all hub collections and their install status.
func (h *CrowdSecHandler) ListCollections(c *gin.Context) {
if !crowdsec.IsInstalled() {
csNotInstalled(c)
return
}
list, err := crowdsec.Collections(c.Request.Context())
if err != nil {
response.Internal(c, err)
return
}
response.OK(c, gin.H{"collections": list})
}
// InstallCollection installs a hub collection by name.
func (h *CrowdSecHandler) InstallCollection(c *gin.Context) {
if !crowdsec.IsInstalled() {
csNotInstalled(c)
return
}
name := c.Param("name")
if err := crowdsec.InstallCollection(c.Request.Context(), name); err != nil {
response.Internal(c, err)
return
}
response.Created(c, gin.H{"installed": name})
}
// RemoveCollection removes a hub collection by name.
func (h *CrowdSecHandler) RemoveCollection(c *gin.Context) {
if !crowdsec.IsInstalled() {
csNotInstalled(c)
return
}
name := c.Param("name")
if err := crowdsec.RemoveCollection(c.Request.Context(), name); err != nil {
response.Internal(c, err)
return
}
response.OK(c, gin.H{"removed": name})
}

346
internal/handlers/dhcp.go Normal file
View File

@@ -0,0 +1,346 @@
package handlers
import (
"context"
"errors"
"log/slog"
"net"
"strings"
"github.com/gin-gonic/gin"
"git.netcell-it.de/projekte/edgeguard-native/internal/handlers/response"
"git.netcell-it.de/projekte/edgeguard-native/internal/models"
"git.netcell-it.de/projekte/edgeguard-native/internal/services/audit"
dhcpsvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/dhcp"
)
// DHCPHandler exposes /api/v1/dhcp/{settings,subnets,reservations} for
// the Kea DHCPv4 server.
type DHCPHandler struct {
Repo *dhcpsvc.Repo
Audit *audit.Repo
NodeID string
Reloader func(ctx context.Context) error
}
func NewDHCPHandler(repo *dhcpsvc.Repo, a *audit.Repo, nodeID string, reloader func(context.Context) error) *DHCPHandler {
return &DHCPHandler{Repo: repo, Audit: a, NodeID: nodeID, Reloader: reloader}
}
func (h *DHCPHandler) reload(ctx context.Context, op string) {
if h.Reloader == nil {
return
}
if err := h.Reloader(ctx); err != nil {
slog.Warn("kea: reload after mutation failed", "op", op, "error", err)
}
}
func (h *DHCPHandler) Register(rg *gin.RouterGroup) {
g := rg.Group("/dhcp")
g.GET("/settings", h.GetSettings)
g.PUT("/settings", h.UpdateSettings)
s := g.Group("/subnets")
s.GET("", h.ListSubnets)
s.POST("", h.CreateSubnet)
s.GET("/:id", h.GetSubnet)
s.PUT("/:id", h.UpdateSubnet)
s.DELETE("/:id", h.DeleteSubnet)
s.GET("/:id/reservations", h.ListReservationsForSubnet)
s.POST("/:id/reservations", h.CreateReservation)
r := g.Group("/reservations")
r.GET("", h.ListAllReservations)
r.GET("/:id", h.GetReservation)
r.PUT("/:id", h.UpdateReservation)
r.DELETE("/:id", h.DeleteReservation)
}
// ── Settings ─────────────────────────────────────────────────────────
func (h *DHCPHandler) GetSettings(c *gin.Context) {
s, err := h.Repo.GetSettings(c.Request.Context())
if err != nil {
response.Internal(c, err)
return
}
response.OK(c, s)
}
func (h *DHCPHandler) UpdateSettings(c *gin.Context) {
var req models.DHCPSettings
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, err)
return
}
if req.DefaultLease <= 0 {
req.DefaultLease = 3600
}
if req.MaxLease < req.DefaultLease {
req.MaxLease = req.DefaultLease
}
if err := validateIPList(req.DNSServers); err != nil {
response.BadRequest(c, err)
return
}
out, err := h.Repo.UpdateSettings(c.Request.Context(), req)
if err != nil {
response.Internal(c, err)
return
}
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "dhcp.settings.update", "",
gin.H{"enabled": out.Enabled}, h.NodeID)
response.OK(c, out)
h.reload(c.Request.Context(), "settings.update")
}
// ── Subnets ──────────────────────────────────────────────────────────
func (h *DHCPHandler) ListSubnets(c *gin.Context) {
out, err := h.Repo.ListSubnets(c.Request.Context())
if err != nil {
response.Internal(c, err)
return
}
response.OK(c, gin.H{"subnets": out})
}
func (h *DHCPHandler) GetSubnet(c *gin.Context) {
id, ok := parseID(c)
if !ok {
return
}
s, err := h.Repo.GetSubnet(c.Request.Context(), id)
if err != nil {
h.subnetErr(c, err)
return
}
response.OK(c, s)
}
func (h *DHCPHandler) CreateSubnet(c *gin.Context) {
var req models.DHCPSubnet
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, err)
return
}
if err := h.validateSubnet(c, &req); err != nil {
response.BadRequest(c, err)
return
}
out, err := h.Repo.CreateSubnet(c.Request.Context(), req)
if err != nil {
response.Internal(c, err)
return
}
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "dhcp.subnet.create", out.Name, out, h.NodeID)
response.Created(c, out)
h.reload(c.Request.Context(), "subnet.create")
}
func (h *DHCPHandler) UpdateSubnet(c *gin.Context) {
id, ok := parseID(c)
if !ok {
return
}
var req models.DHCPSubnet
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, err)
return
}
if err := h.validateSubnet(c, &req); err != nil {
response.BadRequest(c, err)
return
}
out, err := h.Repo.UpdateSubnet(c.Request.Context(), id, req)
if err != nil {
h.subnetErr(c, err)
return
}
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "dhcp.subnet.update", out.Name, out, h.NodeID)
response.OK(c, out)
h.reload(c.Request.Context(), "subnet.update")
}
func (h *DHCPHandler) DeleteSubnet(c *gin.Context) {
id, ok := parseID(c)
if !ok {
return
}
if err := h.Repo.DeleteSubnet(c.Request.Context(), id); err != nil {
h.subnetErr(c, err)
return
}
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "dhcp.subnet.delete", "", gin.H{"id": id}, h.NodeID)
response.OK(c, gin.H{"ok": true})
h.reload(c.Request.Context(), "subnet.delete")
}
// ── Reservations ─────────────────────────────────────────────────────
func (h *DHCPHandler) ListAllReservations(c *gin.Context) {
out, err := h.Repo.ListAllReservations(c.Request.Context())
if err != nil {
response.Internal(c, err)
return
}
response.OK(c, gin.H{"reservations": out})
}
func (h *DHCPHandler) ListReservationsForSubnet(c *gin.Context) {
id, ok := parseID(c)
if !ok {
return
}
out, err := h.Repo.ListReservationsForSubnet(c.Request.Context(), id)
if err != nil {
response.Internal(c, err)
return
}
response.OK(c, gin.H{"reservations": out})
}
func (h *DHCPHandler) GetReservation(c *gin.Context) {
id, ok := parseID(c)
if !ok {
return
}
v, err := h.Repo.GetReservation(c.Request.Context(), id)
if err != nil {
h.resvErr(c, err)
return
}
response.OK(c, v)
}
func (h *DHCPHandler) CreateReservation(c *gin.Context) {
subnetID, ok := parseID(c)
if !ok {
return
}
var req models.DHCPReservation
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, err)
return
}
req.SubnetID = subnetID
if err := validateReservation(&req); err != nil {
response.BadRequest(c, err)
return
}
out, err := h.Repo.CreateReservation(c.Request.Context(), req)
if err != nil {
response.Internal(c, err)
return
}
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "dhcp.reservation.create", out.MACAddress, out, h.NodeID)
response.Created(c, out)
h.reload(c.Request.Context(), "reservation.create")
}
func (h *DHCPHandler) UpdateReservation(c *gin.Context) {
id, ok := parseID(c)
if !ok {
return
}
var req models.DHCPReservation
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, err)
return
}
if err := validateReservation(&req); err != nil {
response.BadRequest(c, err)
return
}
out, err := h.Repo.UpdateReservation(c.Request.Context(), id, req)
if err != nil {
h.resvErr(c, err)
return
}
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "dhcp.reservation.update", out.MACAddress, out, h.NodeID)
response.OK(c, out)
h.reload(c.Request.Context(), "reservation.update")
}
func (h *DHCPHandler) DeleteReservation(c *gin.Context) {
id, ok := parseID(c)
if !ok {
return
}
if err := h.Repo.DeleteReservation(c.Request.Context(), id); err != nil {
h.resvErr(c, err)
return
}
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "dhcp.reservation.delete", "", gin.H{"id": id}, h.NodeID)
response.OK(c, gin.H{"ok": true})
h.reload(c.Request.Context(), "reservation.delete")
}
// ── Validation + error mapping ───────────────────────────────────────
func (h *DHCPHandler) validateSubnet(c *gin.Context, s *models.DHCPSubnet) error {
s.Name = strings.TrimSpace(s.Name)
s.InterfaceName = strings.TrimSpace(s.InterfaceName)
if s.Name == "" {
return errors.New("name ist erforderlich")
}
if s.InterfaceName == "" {
return errors.New("interface_name ist erforderlich")
}
if exists, err := h.Repo.InterfaceExists(c.Request.Context(), s.InterfaceName); err == nil && !exists {
return errors.New("interface_name existiert nicht: " + s.InterfaceName)
}
if _, _, err := net.ParseCIDR(s.SubnetCIDR); err != nil {
return errors.New("subnet_cidr ist kein gültiges CIDR: " + s.SubnetCIDR)
}
if (s.PoolStart == "") != (s.PoolEnd == "") {
return errors.New("pool_start und pool_end müssen beide gesetzt sein (oder beide leer)")
}
for _, ip := range []string{s.PoolStart, s.PoolEnd, s.Gateway} {
if ip != "" && net.ParseIP(ip) == nil {
return errors.New("ungültige IP-Adresse: " + ip)
}
}
return validateIPList(s.DNSServers)
}
func validateReservation(r *models.DHCPReservation) error {
r.MACAddress = strings.TrimSpace(strings.ToLower(r.MACAddress))
r.IPAddress = strings.TrimSpace(r.IPAddress)
if _, err := net.ParseMAC(r.MACAddress); err != nil {
return errors.New("mac_address ist ungültig: " + r.MACAddress)
}
if net.ParseIP(r.IPAddress) == nil {
return errors.New("ip_address ist ungültig: " + r.IPAddress)
}
return nil
}
// validateIPList prüft eine optionale Komma-Liste von IPs.
func validateIPList(csv string) error {
for _, p := range strings.Split(csv, ",") {
p = strings.TrimSpace(p)
if p != "" && net.ParseIP(p) == nil {
return errors.New("ungültige IP in dns_servers: " + p)
}
}
return nil
}
func (h *DHCPHandler) subnetErr(c *gin.Context, err error) {
if errors.Is(err, dhcpsvc.ErrSubnetNotFound) {
response.NotFound(c, err)
return
}
response.Internal(c, err)
}
func (h *DHCPHandler) resvErr(c *gin.Context, err error) {
if errors.Is(err, dhcpsvc.ErrReservationNotFound) {
response.NotFound(c, err)
return
}
response.Internal(c, err)
}

View File

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

View File

@@ -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
@@ -138,6 +138,7 @@ func (h *FirewallHandler) Register(rg *gin.RouterGroup) {
rl.POST("", h.CreateRule)
rl.GET("/:id", h.GetRule)
rl.PUT("/:id", h.UpdateRule)
rl.PATCH("/:id", h.PatchRule)
rl.DELETE("/:id", h.DeleteRule)
nat := g.Group("/nat-rules")
@@ -145,6 +146,7 @@ func (h *FirewallHandler) Register(rg *gin.RouterGroup) {
nat.POST("", h.CreateNAT)
nat.GET("/:id", h.GetNAT)
nat.PUT("/:id", h.UpdateNAT)
nat.PATCH("/:id", h.PatchNAT)
nat.DELETE("/:id", h.DeleteNAT)
}
@@ -269,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++ {
@@ -350,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) {
@@ -377,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) {
@@ -395,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 ─────────────────────────────────────────────────────
@@ -438,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) {
@@ -461,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) {
@@ -479,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 ───────────────────────────────────────────────────────────
@@ -522,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) {
@@ -545,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) {
@@ -563,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 ─────────────────────────────────────────────────────
@@ -606,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) {
@@ -629,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) {
@@ -647,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 ──────────────────────────────────────────────────────────────
@@ -702,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) {
@@ -737,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) {
@@ -755,7 +771,50 @@ 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) {
id, ok := parseID(c)
if !ok {
return
}
var body struct {
Note *string `json:"note"`
Labels []string `json:"labels"`
}
if err := c.ShouldBindJSON(&body); err != nil {
response.BadRequest(c, err)
return
}
ctx := c.Request.Context()
if body.Note != nil {
if err := h.Rules.PatchNote(ctx, id, *body.Note); err != nil {
if errors.Is(err, firewall.ErrRuleNotFound) {
response.NotFound(c, err)
return
}
response.Internal(c, err)
return
}
}
if body.Labels != nil {
if err := h.Rules.PatchLabels(ctx, id, body.Labels); err != nil {
if errors.Is(err, firewall.ErrRuleNotFound) {
response.NotFound(c, err)
return
}
response.Internal(c, err)
return
}
}
out, err := h.Rules.Get(ctx, id)
if err != nil {
response.Internal(c, err)
return
}
response.OK(c, out)
}
// ── NAT Rules ──────────────────────────────────────────────────────────
@@ -806,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) {
@@ -837,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) {
@@ -855,7 +916,50 @@ 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) {
id, ok := parseID(c)
if !ok {
return
}
var body struct {
Note *string `json:"note"`
Labels []string `json:"labels"`
}
if err := c.ShouldBindJSON(&body); err != nil {
response.BadRequest(c, err)
return
}
ctx := c.Request.Context()
if body.Note != nil {
if err := h.NATRules.PatchNote(ctx, id, *body.Note); err != nil {
if errors.Is(err, firewall.ErrNATRuleNotFound) {
response.NotFound(c, err)
return
}
response.Internal(c, err)
return
}
}
if body.Labels != nil {
if err := h.NATRules.PatchLabels(ctx, id, body.Labels); err != nil {
if errors.Is(err, firewall.ErrNATRuleNotFound) {
response.NotFound(c, err)
return
}
response.Internal(c, err)
return
}
}
out, err := h.NATRules.Get(ctx, id)
if err != nil {
response.Internal(c, err)
return
}
response.OK(c, out)
}
// ── Validators ─────────────────────────────────────────────────────────

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

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

View File

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

View File

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

View File

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

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

View File

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

349
internal/handlers/oidc.go Normal file
View File

@@ -0,0 +1,349 @@
package handlers
import (
"crypto/rand"
"crypto/subtle"
"encoding/base64"
"encoding/json"
"errors"
"net/http"
"net/url"
"strings"
"time"
"github.com/gin-gonic/gin"
"golang.org/x/oauth2"
"git.netcell-it.de/projekte/edgeguard-native/internal/handlers/response"
"git.netcell-it.de/projekte/edgeguard-native/internal/services/audit"
oidcsvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/oidc"
"git.netcell-it.de/projekte/edgeguard-native/internal/services/session"
"git.netcell-it.de/projekte/edgeguard-native/internal/services/setup"
usersvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/users"
)
// OIDC / Keycloak SSO. Additiv zum lokalen Passwort-Login. Regeln:
// - kein Auto-Provisioning (E-Mail muss als User existieren),
// - Rolle kommt aus der DB-Row (nie aus dem Token),
// - lokaler Login + TOTP bleiben unangetastet.
//
// Flow-State (state/PKCE-verifier/nonce) liegt stateless in einem 5-min
// signierten HttpOnly-Cookie (SameSite=Lax, da der IdP-Redirect ein
// top-level cross-site GET ist). Nach Erfolg wird dieselbe Session wie
// beim lokalen Login ausgestellt (setSessionCookie + Signer).
const (
oidcFlowCookie = "edgeguard_oidc_flow"
oidcFlowTTL = 5 * time.Minute
)
type OIDCHandler struct {
Repo *oidcsvc.Repo
Auth oidcsvc.Authenticator
Users *usersvc.Repo
Signer *session.Signer
Setup *setup.Store
Audit *audit.Repo
NodeID string
}
func NewOIDCHandler(repo *oidcsvc.Repo, auth oidcsvc.Authenticator, users *usersvc.Repo, signer *session.Signer, setupStore *setup.Store) *OIDCHandler {
return &OIDCHandler{Repo: repo, Auth: auth, Users: users, Signer: signer, Setup: setupStore}
}
func (h *OIDCHandler) WithAudit(a *audit.Repo, nodeID string) *OIDCHandler {
h.Audit = a
h.NodeID = nodeID
return h
}
// RegisterPublic mountet die unauth. Endpoints (auf v1, hinter SetupGate).
func (h *OIDCHandler) RegisterPublic(rg *gin.RouterGroup) {
g := rg.Group("/auth/oidc")
g.GET("/settings", h.PublicSettings)
g.GET("/login", h.Login)
g.GET("/callback", h.Callback)
}
// RegisterAdmin mountet die Admin-Endpoints (auf authed: requireAuth +
// RequireAdminForMutations → GET für alle, PUT nur admin).
func (h *OIDCHandler) RegisterAdmin(rg *gin.RouterGroup) {
g := rg.Group("/oidc")
g.GET("/settings", h.GetSettings)
g.PUT("/settings", h.UpdateSettings)
}
// PublicSettings: nur, was die Login-Seite braucht.
func (h *OIDCHandler) PublicSettings(c *gin.Context) {
s, err := h.Repo.Get(c.Request.Context())
if err != nil {
// Kein Datensatz/kein DB → SSO einfach „aus".
response.OK(c, gin.H{"enabled": false, "button_label": ""})
return
}
response.OK(c, gin.H{"enabled": s.Enabled, "button_label": s.ButtonLabel})
}
// GetSettings: Admin-Sicht ohne Secret, mit secret_configured + redirect_uri.
func (h *OIDCHandler) GetSettings(c *gin.Context) {
s, err := h.Repo.Get(c.Request.Context())
if err != nil {
response.Internal(c, err)
return
}
hasSecret, _ := h.Repo.HasSecret(c.Request.Context())
response.OK(c, gin.H{
"enabled": s.Enabled,
"issuer_url": s.IssuerURL,
"client_id": s.ClientID,
"scopes": s.Scopes,
"email_claim": s.EmailClaim,
"button_label": s.ButtonLabel,
"secret_configured": hasSecret,
"redirect_uri": h.redirectURI(c),
})
}
type oidcUpdateBody struct {
Enabled bool `json:"enabled"`
IssuerURL string `json:"issuer_url"`
ClientID string `json:"client_id"`
ClientSecret *string `json:"client_secret"` // nil = unverändert, "" = löschen
Scopes string `json:"scopes"`
EmailClaim string `json:"email_claim"`
ButtonLabel string `json:"button_label"`
}
// UpdateSettings: PUT (admin via RequireAdminForMutations).
func (h *OIDCHandler) UpdateSettings(c *gin.Context) {
var body oidcUpdateBody
if err := c.ShouldBindJSON(&body); err != nil {
response.BadRequest(c, err)
return
}
body.IssuerURL = strings.TrimSpace(body.IssuerURL)
body.ClientID = strings.TrimSpace(body.ClientID)
if body.Scopes == "" {
body.Scopes = "openid email profile"
}
if body.EmailClaim == "" {
body.EmailClaim = "email"
}
if body.ButtonLabel == "" {
body.ButtonLabel = "Sign in with SSO"
}
if body.Enabled {
if body.IssuerURL == "" || body.ClientID == "" {
response.BadRequest(c, errors.New("issuer_url und client_id sind erforderlich, wenn OIDC aktiviert ist"))
return
}
if u, err := url.Parse(body.IssuerURL); err != nil || u.Scheme != "https" || u.Host == "" {
response.BadRequest(c, errors.New("issuer_url muss eine gültige https-URL sein"))
return
}
hasSecret, _ := h.Repo.HasSecret(c.Request.Context())
providing := body.ClientSecret != nil && *body.ClientSecret != ""
if !hasSecret && !providing {
response.BadRequest(c, errors.New("client_secret ist erforderlich (noch keins gespeichert)"))
return
}
}
if err := h.Repo.Update(c.Request.Context(), oidcsvc.UpdateInput{
Enabled: body.Enabled,
IssuerURL: body.IssuerURL,
ClientID: body.ClientID,
Scopes: body.Scopes,
EmailClaim: body.EmailClaim,
ButtonLabel: body.ButtonLabel,
ClientSecret: body.ClientSecret,
}); err != nil {
response.Internal(c, err)
return
}
h.audit(c, actorOf(c), "oidc.settings.updated", actorOf(c),
gin.H{"enabled": body.Enabled, "issuer": body.IssuerURL})
response.OK(c, gin.H{"ok": true})
}
// Login: 302 zum IdP. Setzt das signierte Flow-Cookie.
func (h *OIDCHandler) Login(c *gin.Context) {
ctx := c.Request.Context()
s, err := h.Repo.Get(ctx)
if err != nil || !s.Enabled {
h.fail(c, "disabled")
return
}
state, err1 := randToken(24)
nonce, err2 := randToken(24)
if err1 != nil || err2 != nil {
h.fail(c, "server")
return
}
verifier := oauth2.GenerateVerifier()
redirectURI := h.redirectURI(c)
authURL, err := h.Auth.AuthCodeURL(ctx, redirectURI, state, nonce, verifier)
if err != nil {
h.fail(c, "config")
return
}
blob, _ := json.Marshal(oidcFlow{State: state, Verifier: verifier, Nonce: nonce})
signed, err := h.Signer.SignBlob(blob, oidcFlowTTL)
if err != nil {
h.fail(c, "server")
return
}
h.setFlowCookie(c, signed)
c.Redirect(http.StatusFound, authURL)
}
// Callback: verifiziert Flow + Token, mappt auf DB-User, stellt Session aus.
func (h *OIDCHandler) Callback(c *gin.Context) {
ctx := c.Request.Context()
// Flow-Cookie lesen + sofort entwerten (single-use).
rawFlow, _ := c.Cookie(oidcFlowCookie)
h.clearFlowCookie(c)
if rawFlow == "" {
h.fail(c, "expired")
return
}
payload, err := h.Signer.VerifyBlob(rawFlow)
if err != nil {
h.fail(c, "expired")
return
}
var flow oidcFlow
if json.Unmarshal(payload, &flow) != nil {
h.fail(c, "expired")
return
}
if c.Query("error") != "" {
h.fail(c, "denied")
return
}
if subtle.ConstantTimeCompare([]byte(c.Query("state")), []byte(flow.State)) != 1 {
h.fail(c, "state")
return
}
code := c.Query("code")
if code == "" {
h.fail(c, "exchange")
return
}
claims, err := h.Auth.Exchange(ctx, h.redirectURI(c), code, flow.Verifier)
if err != nil {
h.fail(c, "token")
return
}
if subtle.ConstantTimeCompare([]byte(claims.Nonce), []byte(flow.Nonce)) != 1 {
h.fail(c, "nonce")
return
}
if !claims.EmailVerified || claims.Email == "" {
h.audit(c, claims.Email, "auth.login.failed", claims.Email,
gin.H{"via": "oidc", "reason": "email_unverified", "remote": c.ClientIP()})
h.fail(c, "unverified")
return
}
u, _, err := h.Users.FindByEmail(ctx, claims.Email)
if err != nil {
reason := "oidc_no_account"
if !errors.Is(err, usersvc.ErrNotFound) {
reason = "server"
}
h.audit(c, claims.Email, "auth.login.failed", claims.Email,
gin.H{"via": "oidc", "reason": reason, "remote": c.ClientIP()})
h.fail(c, map[bool]string{true: "no_account", false: "server"}[reason == "oidc_no_account"])
return
}
if !u.Active {
h.audit(c, u.Email, "auth.login.failed", u.Email,
gin.H{"via": "oidc", "reason": "account_disabled", "remote": c.ClientIP()})
h.fail(c, "disabled")
return
}
// Opportunistisches sub-Linking + Schutz gegen E-Mail-Reassignment.
if stored, err := h.Users.GetOIDCSubject(ctx, u.ID); err == nil {
if stored != "" && stored != claims.Subject {
h.audit(c, u.Email, "auth.login.failed", u.Email,
gin.H{"via": "oidc", "reason": "subject_mismatch", "remote": c.ClientIP()})
h.fail(c, "subject_mismatch")
return
}
if stored == "" {
_ = h.Users.SetOIDCSubject(ctx, u.ID, claims.Subject)
}
}
h.Users.RecordLogin(ctx, u.ID)
// Rolle STRIKT aus der DB-Row (nie aus Claims).
raw, tok, err := h.Signer.IssueWithRole(u.Email, u.Role)
if err != nil {
h.fail(c, "server")
return
}
setSessionCookie(c, raw, tok.Exp)
h.audit(c, u.Email, "auth.login.success", u.Email,
gin.H{"via": "oidc", "role": u.Role, "remote": c.ClientIP()})
c.Redirect(http.StatusFound, "/dashboard")
}
// ── Helpers ──────────────────────────────────────────────────────────
type oidcFlow struct {
State string `json:"s"`
Verifier string `json:"v"`
Nonce string `json:"n"`
}
// redirectURI = https://<FQDN>/api/v1/auth/oidc/callback (FQDN aus setup.json,
// Fallback Request-Host). Muss im IdP als Redirect-URI registriert sein.
func (h *OIDCHandler) redirectURI(c *gin.Context) string {
host := ""
if h.Setup != nil {
if st, err := h.Setup.Load(); err == nil && st != nil {
host = strings.TrimSpace(st.FQDN)
}
}
if host == "" {
host = c.Request.Host
}
return "https://" + host + "/api/v1/auth/oidc/callback"
}
func (h *OIDCHandler) fail(c *gin.Context, reason string) {
c.Redirect(http.StatusFound, "/login?sso_error="+url.QueryEscape(reason))
}
func (h *OIDCHandler) audit(c *gin.Context, actor, action, subject string, detail any) {
if h.Audit != nil {
_ = h.Audit.Log(c.Request.Context(), actor, action, subject, detail, h.NodeID)
}
}
func (h *OIDCHandler) setFlowCookie(c *gin.Context, raw string) {
c.SetSameSite(http.SameSiteLaxMode)
c.SetCookie(oidcFlowCookie, raw, int(oidcFlowTTL.Seconds()), "/", "", true, true)
}
func (h *OIDCHandler) clearFlowCookie(c *gin.Context) {
c.SetSameSite(http.SameSiteLaxMode)
c.SetCookie(oidcFlowCookie, "", -1, "/", "", true, true)
}
func randToken(n int) (string, error) {
b := make([]byte, n)
if _, err := rand.Read(b); err != nil {
return "", err
}
return base64.RawURLEncoding.EncodeToString(b), nil
}

View File

@@ -0,0 +1,202 @@
package handlers
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"strings"
"testing"
"time"
"github.com/gin-gonic/gin"
"github.com/jackc/pgx/v5/pgxpool"
"git.netcell-it.de/projekte/edgeguard-native/internal/database"
oidcsvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/oidc"
"git.netcell-it.de/projekte/edgeguard-native/internal/services/session"
usersvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/users"
)
// mockAuth erfüllt oidcsvc.Authenticator und liefert vorgegebene Claims —
// kein echter IdP nötig.
type mockAuth struct {
claims *oidcsvc.Claims
err error
}
func (m *mockAuth) AuthCodeURL(_ context.Context, _, state, _, _ string) (string, error) {
return "https://idp.example/authorize?state=" + state, nil
}
func (m *mockAuth) Exchange(_ context.Context, _, _, _ string) (*oidcsvc.Claims, error) {
return m.claims, m.err
}
func oidcTestSetup(t *testing.T) (*usersvc.Repo, *pgxpool.Pool, *session.Signer) {
t.Helper()
dsn := os.Getenv("EG_FWTEST_DSN")
if dsn == "" {
t.Skip("set EG_FWTEST_DSN to run the oidc handler test")
}
ctx := context.Background()
// Retry: goose-Erst-Apply ist nicht concurrency-safe, wenn mehrere
// guarded Test-Pakete dieselbe frische DB parallel migrieren.
var mErr error
for i := 0; i < 3; i++ {
if mErr = database.Migrate(ctx, dsn); mErr == nil {
break
}
time.Sleep(700 * time.Millisecond)
}
if mErr != nil {
t.Fatalf("migrate: %v", mErr)
}
pool, err := database.Open(ctx, dsn)
if err != nil {
t.Fatalf("open: %v", err)
}
t.Cleanup(pool.Close)
return usersvc.New(pool), pool, session.NewSigner([]byte("0123456789abcdef0123456789abcdef"), nil, 0)
}
func seedUser(t *testing.T, repo *usersvc.Repo, pool *pgxpool.Pool, email, role string, active bool) {
t.Helper()
ctx := context.Background()
_, _ = pool.Exec(ctx, `DELETE FROM users WHERE email=$1`, email)
if _, err := repo.Create(ctx, email, "Sup3rSecret-pw-123", role, active); err != nil {
t.Fatalf("seed user: %v", err)
}
}
func runCallback(t *testing.T, h *OIDCHandler, flow oidcFlow, queryState, code string) *httptest.ResponseRecorder {
t.Helper()
gin.SetMode(gin.TestMode)
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
blob, _ := json.Marshal(flow)
signed, err := h.Signer.SignBlob(blob, oidcFlowTTL)
if err != nil {
t.Fatal(err)
}
req := httptest.NewRequestWithContext(context.Background(), http.MethodGet,
"/api/v1/auth/oidc/callback?state="+queryState+"&code="+code, nil)
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
}
func sessionCookie(rec *httptest.ResponseRecorder) string {
for _, ck := range rec.Result().Cookies() {
if ck.Name == cookieName && ck.Value != "" && ck.MaxAge >= 0 {
return ck.Value
}
}
return ""
}
func TestCallback_KnownActiveUser_RoleFromDB(t *testing.T) {
users, pool, signer := oidcTestSetup(t)
seedUser(t, users, pool, "sso-viewer@test.local", "viewer", true)
h := NewOIDCHandler(nil, &mockAuth{claims: &oidcsvc.Claims{
Email: "sso-viewer@test.local", EmailVerified: true, Subject: "sub-1", Nonce: "N",
}}, users, signer, nil)
rec := runCallback(t, h, oidcFlow{State: "S", Verifier: "v", Nonce: "N"}, "S", "code")
if loc := rec.Header().Get("Location"); loc != "/dashboard" {
t.Fatalf("expected redirect to /dashboard, got %q (body proves failure path)", loc)
}
raw := sessionCookie(rec)
if raw == "" {
t.Fatal("expected a session cookie to be set")
}
tok, err := signer.Verify(raw)
if err != nil {
t.Fatalf("session token invalid: %v", err)
}
// Kernbeweis: Rolle kommt aus der DB-Row (viewer), nicht aus Claims.
if tok.Role != "viewer" {
t.Errorf("token role = %q, want viewer (role must come from DB)", tok.Role)
}
if tok.Actor != "sso-viewer@test.local" {
t.Errorf("token actor = %q", tok.Actor)
}
}
func TestCallback_UnknownEmail_Rejected(t *testing.T) {
users, pool, signer := oidcTestSetup(t)
_, _ = pool.Exec(context.Background(), `DELETE FROM users WHERE email=$1`, "ghost@test.local")
h := NewOIDCHandler(nil, &mockAuth{claims: &oidcsvc.Claims{
Email: "ghost@test.local", EmailVerified: true, Subject: "x", Nonce: "N",
}}, users, signer, nil)
rec := runCallback(t, h, oidcFlow{State: "S", Nonce: "N"}, "S", "code")
if !strings.Contains(rec.Header().Get("Location"), "sso_error=no_account") {
t.Fatalf("expected sso_error=no_account, got %q", rec.Header().Get("Location"))
}
if sessionCookie(rec) != "" {
t.Fatal("no session cookie expected for unknown user")
}
}
func TestCallback_InactiveUser_Rejected(t *testing.T) {
users, pool, signer := oidcTestSetup(t)
seedUser(t, users, pool, "sso-disabled@test.local", "admin", false)
h := NewOIDCHandler(nil, &mockAuth{claims: &oidcsvc.Claims{
Email: "sso-disabled@test.local", EmailVerified: true, Subject: "x", Nonce: "N",
}}, users, signer, nil)
rec := runCallback(t, h, oidcFlow{State: "S", Nonce: "N"}, "S", "code")
if !strings.Contains(rec.Header().Get("Location"), "sso_error=disabled") {
t.Fatalf("expected sso_error=disabled, got %q", rec.Header().Get("Location"))
}
if sessionCookie(rec) != "" {
t.Fatal("no session cookie expected for inactive user")
}
}
func TestCallback_EmailUnverified_Rejected(t *testing.T) {
users, pool, signer := oidcTestSetup(t)
seedUser(t, users, pool, "sso-unverified@test.local", "admin", true)
h := NewOIDCHandler(nil, &mockAuth{claims: &oidcsvc.Claims{
Email: "sso-unverified@test.local", EmailVerified: false, Subject: "x", Nonce: "N",
}}, users, signer, nil)
rec := runCallback(t, h, oidcFlow{State: "S", Nonce: "N"}, "S", "code")
if !strings.Contains(rec.Header().Get("Location"), "sso_error=unverified") {
t.Fatalf("expected sso_error=unverified, got %q", rec.Header().Get("Location"))
}
if sessionCookie(rec) != "" {
t.Fatal("no session cookie expected for unverified email")
}
}
func TestCallback_NonceMismatch_Rejected(t *testing.T) {
users, pool, signer := oidcTestSetup(t)
seedUser(t, users, pool, "sso-nonce@test.local", "admin", true)
h := NewOIDCHandler(nil, &mockAuth{claims: &oidcsvc.Claims{
Email: "sso-nonce@test.local", EmailVerified: true, Subject: "x", Nonce: "WRONG",
}}, users, signer, nil)
rec := runCallback(t, h, oidcFlow{State: "S", Nonce: "N"}, "S", "code")
if !strings.Contains(rec.Header().Get("Location"), "sso_error=nonce") {
t.Fatalf("expected sso_error=nonce, got %q", rec.Header().Get("Location"))
}
}
func TestCallback_StateMismatch_Rejected(t *testing.T) {
users, _, signer := oidcTestSetup(t)
h := NewOIDCHandler(nil, &mockAuth{claims: &oidcsvc.Claims{}}, users, signer, nil)
rec := runCallback(t, h, oidcFlow{State: "S", Nonce: "N"}, "WRONG", "code")
if !strings.Contains(rec.Header().Get("Location"), "sso_error=state") {
t.Fatalf("expected sso_error=state, got %q", rec.Header().Get("Location"))
}
}

359
internal/handlers/radius.go Normal file
View File

@@ -0,0 +1,359 @@
package handlers
import (
"context"
"errors"
"log/slog"
"net"
"regexp"
"strings"
"github.com/gin-gonic/gin"
"git.netcell-it.de/projekte/edgeguard-native/internal/handlers/response"
"git.netcell-it.de/projekte/edgeguard-native/internal/models"
"git.netcell-it.de/projekte/edgeguard-native/internal/services/audit"
radiussvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/radius"
)
// RADIUSHandler exposes /api/v1/radius/{settings,clients,users} for the
// FreeRADIUS server (files-based PAP/CHAP).
type RADIUSHandler struct {
Repo *radiussvc.Repo
Audit *audit.Repo
NodeID string
Reloader func(ctx context.Context) error
}
func NewRADIUSHandler(repo *radiussvc.Repo, a *audit.Repo, nodeID string, reloader func(context.Context) error) *RADIUSHandler {
return &RADIUSHandler{Repo: repo, Audit: a, NodeID: nodeID, Reloader: reloader}
}
func (h *RADIUSHandler) reload(ctx context.Context, op string) {
if h.Reloader == nil {
return
}
if err := h.Reloader(ctx); err != nil {
slog.Warn("freeradius: reload after mutation failed", "op", op, "error", err)
}
}
func (h *RADIUSHandler) Register(rg *gin.RouterGroup) {
g := rg.Group("/radius")
g.GET("/settings", h.GetSettings)
g.PUT("/settings", h.UpdateSettings)
c := g.Group("/clients")
c.GET("", h.ListClients)
c.POST("", h.CreateClient)
c.GET("/:id", h.GetClient)
c.PUT("/:id", h.UpdateClient)
c.DELETE("/:id", h.DeleteClient)
u := g.Group("/users")
u.GET("", h.ListUsers)
u.POST("", h.CreateUser)
u.GET("/:id", h.GetUser)
u.PUT("/:id", h.UpdateUser)
u.DELETE("/:id", h.DeleteUser)
}
var validClientName = regexp.MustCompile(`^[A-Za-z0-9_.-]+$`)
// ── Settings ─────────────────────────────────────────────────────────
func (h *RADIUSHandler) GetSettings(c *gin.Context) {
s, err := h.Repo.GetSettings(c.Request.Context())
if err != nil {
response.Internal(c, err)
return
}
response.OK(c, s)
}
func (h *RADIUSHandler) UpdateSettings(c *gin.Context) {
var req models.RADIUSSettings
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, err)
return
}
if err := validateIPList(req.ListenAddresses); err != nil {
response.BadRequest(c, err)
return
}
out, err := h.Repo.UpdateSettings(c.Request.Context(), req)
if err != nil {
response.Internal(c, err)
return
}
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "radius.settings.update", "",
gin.H{"enabled": out.Enabled}, h.NodeID)
response.OK(c, out)
h.reload(c.Request.Context(), "settings.update")
}
// ── Clients ──────────────────────────────────────────────────────────
type clientView struct {
models.RADIUSClient
SecretConfigured bool `json:"secret_configured"`
}
func clientToView(c models.RADIUSClient) clientView {
return clientView{RADIUSClient: c, SecretConfigured: len(c.SecretEnc) > 0}
}
func (h *RADIUSHandler) ListClients(c *gin.Context) {
list, err := h.Repo.ListClients(c.Request.Context())
if err != nil {
response.Internal(c, err)
return
}
out := make([]clientView, 0, len(list))
for _, cl := range list {
out = append(out, clientToView(cl))
}
response.OK(c, gin.H{"clients": out})
}
func (h *RADIUSHandler) GetClient(c *gin.Context) {
id, ok := parseID(c)
if !ok {
return
}
cl, err := h.Repo.GetClient(c.Request.Context(), id)
if err != nil {
h.clientErr(c, err)
return
}
response.OK(c, clientToView(*cl))
}
type clientBody struct {
Name string `json:"name"`
IPAddr string `json:"ipaddr"`
Secret *string `json:"secret"` // create: required; update: nil=unchanged
Active bool `json:"active"`
Description string `json:"description"`
}
func (b *clientBody) validate(creating bool) error {
b.Name = strings.TrimSpace(b.Name)
b.IPAddr = strings.TrimSpace(b.IPAddr)
if !validClientName.MatchString(b.Name) {
return errors.New("name darf nur Buchstaben/Ziffern/._- enthalten")
}
if net.ParseIP(b.IPAddr) == nil {
if _, _, err := net.ParseCIDR(b.IPAddr); err != nil {
return errors.New("ipaddr ist keine gültige IP/CIDR: " + b.IPAddr)
}
}
if creating && b.Secret == nil {
return errors.New("secret ist erforderlich")
}
if b.Secret != nil {
if len(*b.Secret) < 6 {
return errors.New("secret muss mind. 6 Zeichen haben (leer löscht es nicht)")
}
if strings.ContainsAny(*b.Secret, "\r\n") {
return errors.New("secret darf keine Zeilenumbrüche enthalten")
}
}
return nil
}
func (h *RADIUSHandler) CreateClient(c *gin.Context) {
var b clientBody
if err := c.ShouldBindJSON(&b); err != nil {
response.BadRequest(c, err)
return
}
if err := b.validate(true); err != nil {
response.BadRequest(c, err)
return
}
out, err := h.Repo.CreateClient(c.Request.Context(), b.Name, b.IPAddr, *b.Secret, b.Active, b.Description)
if err != nil {
response.Internal(c, err)
return
}
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "radius.client.create", out.Name, gin.H{"ipaddr": out.IPAddr}, h.NodeID)
response.Created(c, clientToView(*out))
h.reload(c.Request.Context(), "client.create")
}
func (h *RADIUSHandler) UpdateClient(c *gin.Context) {
id, ok := parseID(c)
if !ok {
return
}
var b clientBody
if err := c.ShouldBindJSON(&b); err != nil {
response.BadRequest(c, err)
return
}
if err := b.validate(false); err != nil {
response.BadRequest(c, err)
return
}
out, err := h.Repo.UpdateClient(c.Request.Context(), id, b.Name, b.IPAddr, b.Secret, b.Active, b.Description)
if err != nil {
h.clientErr(c, err)
return
}
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "radius.client.update", out.Name, gin.H{"ipaddr": out.IPAddr}, h.NodeID)
response.OK(c, clientToView(*out))
h.reload(c.Request.Context(), "client.update")
}
func (h *RADIUSHandler) DeleteClient(c *gin.Context) {
id, ok := parseID(c)
if !ok {
return
}
if err := h.Repo.DeleteClient(c.Request.Context(), id); err != nil {
h.clientErr(c, err)
return
}
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "radius.client.delete", "", gin.H{"id": id}, h.NodeID)
response.OK(c, gin.H{"ok": true})
h.reload(c.Request.Context(), "client.delete")
}
// ── Users ────────────────────────────────────────────────────────────
type userView struct {
models.RADIUSUser
PasswordConfigured bool `json:"password_configured"`
}
func userToView(u models.RADIUSUser) userView {
return userView{RADIUSUser: u, PasswordConfigured: len(u.PasswordEnc) > 0}
}
func (h *RADIUSHandler) ListUsers(c *gin.Context) {
list, err := h.Repo.ListUsers(c.Request.Context())
if err != nil {
response.Internal(c, err)
return
}
out := make([]userView, 0, len(list))
for _, u := range list {
out = append(out, userToView(u))
}
response.OK(c, gin.H{"users": out})
}
func (h *RADIUSHandler) GetUser(c *gin.Context) {
id, ok := parseID(c)
if !ok {
return
}
u, err := h.Repo.GetUser(c.Request.Context(), id)
if err != nil {
h.userErr(c, err)
return
}
response.OK(c, userToView(*u))
}
type userBody struct {
Username string `json:"username"`
Password *string `json:"password"`
Active bool `json:"active"`
}
func (b *userBody) validate(creating bool) error {
b.Username = strings.TrimSpace(b.Username)
if b.Username == "" || strings.ContainsAny(b.Username, "\r\n") {
return errors.New("username ist erforderlich (ohne Zeilenumbrüche)")
}
if creating && (b.Password == nil || *b.Password == "") {
return errors.New("password ist erforderlich")
}
if b.Password != nil {
if *b.Password == "" {
return errors.New("password darf nicht leer sein (löscht es nicht)")
}
if strings.ContainsAny(*b.Password, "\r\n") {
return errors.New("password darf keine Zeilenumbrüche enthalten")
}
}
return nil
}
func (h *RADIUSHandler) CreateUser(c *gin.Context) {
var b userBody
if err := c.ShouldBindJSON(&b); err != nil {
response.BadRequest(c, err)
return
}
if err := b.validate(true); err != nil {
response.BadRequest(c, err)
return
}
out, err := h.Repo.CreateUser(c.Request.Context(), b.Username, *b.Password, b.Active)
if err != nil {
response.Internal(c, err)
return
}
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "radius.user.create", out.Username, nil, h.NodeID)
response.Created(c, userToView(*out))
h.reload(c.Request.Context(), "user.create")
}
func (h *RADIUSHandler) UpdateUser(c *gin.Context) {
id, ok := parseID(c)
if !ok {
return
}
var b userBody
if err := c.ShouldBindJSON(&b); err != nil {
response.BadRequest(c, err)
return
}
if err := b.validate(false); err != nil {
response.BadRequest(c, err)
return
}
out, err := h.Repo.UpdateUser(c.Request.Context(), id, b.Username, b.Password, b.Active)
if err != nil {
h.userErr(c, err)
return
}
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "radius.user.update", out.Username, nil, h.NodeID)
response.OK(c, userToView(*out))
h.reload(c.Request.Context(), "user.update")
}
func (h *RADIUSHandler) DeleteUser(c *gin.Context) {
id, ok := parseID(c)
if !ok {
return
}
if err := h.Repo.DeleteUser(c.Request.Context(), id); err != nil {
h.userErr(c, err)
return
}
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "radius.user.delete", "", gin.H{"id": id}, h.NodeID)
response.OK(c, gin.H{"ok": true})
h.reload(c.Request.Context(), "user.delete")
}
// ── error mapping ────────────────────────────────────────────────────
func (h *RADIUSHandler) clientErr(c *gin.Context, err error) {
if errors.Is(err, radiussvc.ErrClientNotFound) {
response.NotFound(c, err)
return
}
response.Internal(c, err)
}
func (h *RADIUSHandler) userErr(c *gin.Context, err error) {
if errors.Is(err, radiussvc.ErrUserNotFound) {
response.NotFound(c, err)
return
}
response.Internal(c, err)
}

View File

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

View File

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

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

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

@@ -3,11 +3,13 @@ package handlers
import (
"bufio"
stdcontext "context"
"errors"
"log/slog"
"net"
"net/http"
"os"
"os/exec"
"runtime"
"strconv"
"strings"
"syscall"
@@ -21,6 +23,7 @@ import (
aptsvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/apt"
"git.netcell-it.de/projekte/edgeguard-native/internal/services/audit"
"git.netcell-it.de/projekte/edgeguard-native/internal/services/setup"
usersvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/users"
)
// SystemHandler covers /system/health, /system/package-versions,
@@ -40,6 +43,12 @@ type SystemHandler struct {
// in main.go after generators are constructed. Read-only access —
// no writes, no reloads.
ConfigPreviewers map[string]func(stdcontext.Context) (string, error)
// ExtraReloaders: additional service renderers triggered by
// RenderConfigs. Keyed by service name (nftables, wireguard, etc.).
ExtraReloaders map[string]func(stdcontext.Context) error
// Users: optional — wired after DB pool opens. Used by AgentAuthCheck
// so cluster peers can verify credentials against this node's DB.
Users *usersvc.Repo
}
func NewSystemHandler(version string) *SystemHandler {
@@ -75,6 +84,20 @@ func (h *SystemHandler) WithConfigPreviewers(previewers map[string]func(stdconte
return h
}
// WithAllReloaders injectet alle Service-Reloader für RenderConfigs.
// Reihenfolge: haproxy ist bereits in HAProxyReloader; extras sind die
// restlichen Dienste (nftables, wireguard, squid, unbound, chrony).
func (h *SystemHandler) WithAllReloaders(extras map[string]func(stdcontext.Context) error) *SystemHandler {
h.ExtraReloaders = extras
return h
}
// WithUsers injectet das Users-Repo für AgentAuthCheck.
func (h *SystemHandler) WithUsers(u *usersvc.Repo) *SystemHandler {
h.Users = u
return h
}
func (h *SystemHandler) Register(rg *gin.RouterGroup) {
g := rg.Group("/system")
g.GET("/health", h.Health)
@@ -95,10 +118,12 @@ func (h *SystemHandler) Register(rg *gin.RouterGroup) {
g.POST("/haproxy-reload", h.HAProxyReload)
g.POST("/render-configs", h.RenderConfigs)
g.POST("/service-restart", h.ServiceRestart)
g.POST("/service-toggle", h.ServiceToggle)
g.GET("/upgrade-status", h.UpgradeStatus)
g.GET("/ipv6", h.IPv6)
g.POST("/ipv6", h.SetIPv6)
g.GET("/config-preview", h.ConfigPreview)
g.GET("/vip-status", h.VIPStatus)
}
// RegisterAgent mountet die read-only System-Endpoints auf der mTLS-
@@ -107,13 +132,55 @@ 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)
g.GET("/resources", h.Resources)
// Auth-Federation: Cluster-Nodes verifizieren Credentials gegen diesen
// Node via mTLS. Nur über den Agent-Listener (:8443) erreichbar.
rg.POST("/agent/auth/check", h.AgentAuthCheck)
}
// AgentAuthCheck verifies email+password against the local users table
// and setup.json. Called by cluster nodes over mTLS when local auth fails
// so users can log in on any cluster node with the primary's credentials.
func (h *SystemHandler) AgentAuthCheck(c *gin.Context) {
var req struct {
Email string `json:"email"`
Password string `json:"password"`
}
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, err)
return
}
email := strings.TrimSpace(strings.ToLower(req.Email))
if email == "" || req.Password == "" {
response.Unauthorized(c, errInvalidCreds)
return
}
// Check local users table first.
if h.Users != nil {
u, hash, err := h.Users.FindByEmail(c.Request.Context(), email)
if err == nil && u.Active && usersvc.VerifyPassword(hash, req.Password) {
response.OK(c, gin.H{"actor": u.Email, "role": u.Role})
return
}
}
// Fallback: setup.json admin.
if h.Setup != nil {
st, _ := h.Setup.Load()
if st != nil && strings.EqualFold(st.AdminEmail, email) && st.VerifyAdminPassword(req.Password) {
response.OK(c, gin.H{"actor": st.AdminEmail, "role": "admin"})
return
}
}
response.Unauthorized(c, errInvalidCreds)
}
var errInvalidCreds = errors.New("invalid_credentials")
// servicesToCheck is the curated list shown on the dashboard
// service-health-grid. Order matters (UI renders in this sequence).
// Each entry is a (label, systemd-unit) pair — label is what the
@@ -123,10 +190,16 @@ var servicesToCheck = []struct{ Label, Unit string }{
{"edgeguard-scheduler", "edgeguard-scheduler"},
{"haproxy", "haproxy"},
{"nftables", "nftables"},
{"keepalived", "keepalived"},
{"unbound", "unbound"},
{"chrony", "chrony"},
{"squid", "squid"},
{"kea-dhcp4", "kea-dhcp4-server"},
{"freeradius", "freeradius"},
{"postgresql", "postgresql"},
{"crowdsec", "crowdsec"},
{"crowdsec-firewall-bouncer", "crowdsec-firewall-bouncer"},
{"edgeguard-waf", "edgeguard-waf"},
}
type serviceStatus struct {
@@ -200,6 +273,7 @@ type resources struct {
LoadAvg1 float64 `json:"load_avg_1"`
LoadAvg5 float64 `json:"load_avg_5"`
LoadAvg15 float64 `json:"load_avg_15"`
NumCPUs int `json:"num_cpus"`
MemTotalKB int64 `json:"mem_total_kb"`
MemAvailKB int64 `json:"mem_avail_kb"`
MemUsedPct float64 `json:"mem_used_pct"`
@@ -223,7 +297,7 @@ func (h *SystemHandler) Resources(c *gin.Context) {
// gin.Context erstellen kann. Best-effort: fehlende Quellen lassen
// die jeweiligen Felder einfach auf 0.
func computeLocalSystemResources() resources {
r := resources{}
r := resources{NumCPUs: runtime.NumCPU()}
if data, err := os.ReadFile("/proc/loadavg"); err == nil {
f := strings.Fields(string(data))
if len(f) >= 3 {
@@ -304,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 {
@@ -356,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 {
@@ -392,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 {
@@ -439,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 {
@@ -508,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 {
@@ -564,31 +638,94 @@ func (h *SystemHandler) ServiceRestart(c *gin.Context) {
response.OK(c, gin.H{"ok": true, "service": svc})
}
// RenderConfigs erzwingt ein Re-Render aller Service-Configs aus dem
// aktuellen DB-State. Wenn der HAProxyReloader gesetzt ist, läuft der
// (rendert haproxy.cfg + reload). Praktisch wenn ein Operator denkt
// dass die generierte Config nicht mehr mit der DB übereinstimmt
// (Drift, Manual-Edit, etc.).
//
// v1 macht NUR haproxy — weitere Renderer (firewall, dns, ntp, wg,
// squid) sind per Handler an die jeweiligen Mutations-Endpoints
// gekoppelt; für die fehlt aktuell ein generisches "render all".
func (h *SystemHandler) RenderConfigs(c *gin.Context) {
if h.HAProxyReloader == nil {
response.Err(c, http.StatusServiceUnavailable, simpleErr("renderer not wired"))
// toggleAllowlist defines which services may be started/stopped via the UI.
var toggleAllowlist = map[string]bool{
"crowdsec": true,
"crowdsec-firewall-bouncer": true,
"edgeguard-waf": true,
"squid": true,
"unbound": true,
}
// ServiceToggle starts or stops (and enables/disables) a service.
// Body: {"service": "crowdsec", "enabled": true}
func (h *SystemHandler) ServiceToggle(c *gin.Context) {
var req struct {
Service string `json:"service" binding:"required"`
Enabled bool `json:"enabled"`
}
if err := c.ShouldBindJSON(&req); err != nil {
response.Err(c, http.StatusBadRequest, simpleErr("service and enabled required"))
return
}
ctx, cancel := stdcontext.WithTimeout(c.Request.Context(), 10*time.Second)
defer cancel()
if err := h.HAProxyReloader(ctx); err != nil {
response.Internal(c, err)
svc := strings.TrimSpace(req.Service)
if !toggleAllowlist[svc] {
response.Err(c, http.StatusBadRequest, simpleErr("service not in toggle allowlist: "+svc))
return
}
unit := svc + ".service"
action := "stop"
sysdAction := "disable"
if req.Enabled {
action = "start"
sysdAction = "enable"
}
if out, err := exec.Command("sudo", "-n", "/usr/bin/systemctl", sysdAction, unit).CombinedOutput(); err != nil {
response.Err(c, http.StatusInternalServerError, simpleErr(strings.TrimSpace(string(out))+": "+err.Error()))
return
}
if out, err := exec.Command("sudo", "-n", "/usr/bin/systemctl", action, unit).CombinedOutput(); err != nil {
response.Err(c, http.StatusInternalServerError, simpleErr(strings.TrimSpace(string(out))+": "+err.Error()))
return
}
if h.Audit != nil {
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "system.render_configs",
"", gin.H{}, h.NodeID)
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "system.service_toggle",
svc, gin.H{"service": svc, "enabled": req.Enabled}, h.NodeID)
}
response.OK(c, gin.H{"ok": true, "rendered": []string{"haproxy"}})
response.OK(c, gin.H{"ok": true, "service": svc, "enabled": req.Enabled})
}
// RenderConfigs erzwingt ein Re-Render aller Service-Configs aus dem
// aktuellen DB-State. Läuft haproxy + alle ExtraReloaders (nftables,
// wireguard, squid, unbound, chrony) durch. Fehler werden gesammelt
// und als partial-Antwort zurückgegeben — erfolgreich gerenderte
// Dienste stehen in "rendered", fehlgeschlagene in "errors".
func (h *SystemHandler) RenderConfigs(c *gin.Context) {
if h.HAProxyReloader == nil && len(h.ExtraReloaders) == 0 {
response.Err(c, http.StatusServiceUnavailable, simpleErr("renderer not wired"))
return
}
ctx, cancel := stdcontext.WithTimeout(c.Request.Context(), 30*time.Second)
defer cancel()
rendered := []string{}
errs := map[string]string{}
if h.HAProxyReloader != nil {
if err := h.HAProxyReloader(ctx); err != nil {
errs["haproxy"] = err.Error()
} else {
rendered = append(rendered, "haproxy")
}
}
// Defined order so the audit log is deterministic.
order := []string{"nftables", "wireguard", "squid", "unbound", "chrony"}
for _, name := range order {
fn, ok := h.ExtraReloaders[name]
if !ok {
continue
}
if err := fn(ctx); err != nil {
errs[name] = err.Error()
} else {
rendered = append(rendered, name)
}
}
if h.Audit != nil {
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "system.render_configs",
"", gin.H{"rendered": rendered, "errors": errs}, h.NodeID)
}
response.OK(c, gin.H{"ok": len(errs) == 0, "rendered": rendered, "errors": errs})
}
// UpgradeStatus liefert den Status des letzten Self-Upgrade-Versuchs.
@@ -653,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
}
}
@@ -725,10 +862,31 @@ func (h *SystemHandler) ConfigPreview(c *gin.Context) {
}
func (h *SystemHandler) Health(c *gin.Context) {
response.OK(c, gin.H{
resp := gin.H{
"status": "ok",
"version": h.Version,
})
}
if hn, err := os.Hostname(); err == nil {
resp["hostname"] = hn
}
if data, err := os.ReadFile("/proc/version"); err == nil {
// /proc/version: "Linux version 6.x.y (...)" — Kurzform: alles
// bis zum ersten '(' kürzen.
line := strings.TrimSpace(string(data))
if idx := strings.Index(line, " ("); idx > 0 {
line = strings.TrimSpace(line[:idx])
}
resp["kernel"] = line
}
if data, err := os.ReadFile("/etc/os-release"); err == nil {
for _, line := range strings.Split(string(data), "\n") {
if k, v, ok := strings.Cut(line, "="); ok && k == "PRETTY_NAME" {
resp["os"] = strings.Trim(v, `"`)
break
}
}
}
response.OK(c, resp)
}
// PackageVersions reports installed and available versions for the
@@ -762,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
@@ -782,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
@@ -831,6 +992,10 @@ rm -f /var/lib/edgeguard/upgrade.sh
_ = fallback.Process.Release()
}
if h.Audit != nil {
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "system.upgrade",
"", gin.H{"unit": unitName}, h.NodeID)
}
c.JSON(http.StatusAccepted, response.Envelope{
Data: gin.H{"status": "upgrading", "unit": unitName},
Error: nil,
@@ -970,6 +1135,87 @@ func classifyLinkType(ifc net.Interface) string {
return ""
}
// VIPStatus returns the VRRP state and active VIPs for this node.
// Uses net.Interfaces() (no shell-out) to check which VIPs from
// ip_addresses WHERE is_vip=true are currently assigned locally.
// MASTER = at least one VIP is locally present; BACKUP = none present.
func (h *SystemHandler) VIPStatus(c *gin.Context) {
type vipEntry struct {
Address string `json:"address"`
Prefix int `json:"prefix"`
Device string `json:"device"`
Active bool `json:"active"`
}
type vipStatus struct {
VRRPState string `json:"vrrp_state"`
KeepalivedActive bool `json:"keepalived_active"`
VIPs []vipEntry `json:"vips"`
}
ctx := c.Request.Context()
// keepalived service active?
kaOut, _ := exec.CommandContext(ctx, "systemctl", "is-active", "keepalived").Output()
kaActive := strings.TrimSpace(string(kaOut)) == "active"
// query VIPs from DB
var dbVIPs []vipEntry
if h.Pool != nil {
rows, err := h.Pool.Query(ctx,
`SELECT a.address, a.prefix, COALESCE(i.name,'') AS device
FROM ip_addresses a
LEFT JOIN network_interfaces i ON i.id = a.interface_id
WHERE a.is_vip = true AND a.active = true
ORDER BY a.address`)
if err == nil {
defer rows.Close()
for rows.Next() {
var e vipEntry
if err2 := rows.Scan(&e.Address, &e.Prefix, &e.Device); err2 == nil {
dbVIPs = append(dbVIPs, e)
}
}
}
}
// build set of locally assigned IPs
localIPs := make(map[string]bool)
if ifaces, err := net.Interfaces(); err == nil {
for _, ifc := range ifaces {
if addrs, err2 := ifc.Addrs(); err2 == nil {
for _, a := range addrs {
if ipnet, ok := a.(*net.IPNet); ok {
localIPs[ipnet.IP.String()] = true
}
}
}
}
}
anyActive := false
for i := range dbVIPs {
dbVIPs[i].Active = localIPs[dbVIPs[i].Address]
if dbVIPs[i].Active {
anyActive = true
}
}
state := "UNKNOWN"
if kaActive {
if anyActive {
state = "MASTER"
} else {
state = "BACKUP"
}
}
response.OK(c, vipStatus{
VRRPState: state,
KeepalivedActive: kaActive,
VIPs: dbVIPs,
})
}
func flagsToList(f net.Flags) []string {
var out []string
if f&net.FlagUp != 0 {
@@ -992,4 +1238,3 @@ func flagsToList(f net.Flags) []string {
}
return out
}

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