42 Commits

Author SHA1 Message Date
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
118 changed files with 3877 additions and 545 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

@@ -131,8 +131,11 @@ HAProxy terminiert TLS auf `:443`, routet per Host-Header an Backends und fällt
```bash ```bash
make build # Host-Architektur (amd64) make build # Host-Architektur (amd64)
make test # go test ./... make test # go test ./...
make lint # golangci-lint make test-race # go test -race ./... (Race-Detector)
make deb # amd64 + arm64 .deb 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 publish # deb + Upload Gitea Package Registry
make install-local # direkt auf Dev-Server installieren (kein .deb) make install-local # direkt auf Dev-Server installieren (kein .deb)
@@ -140,6 +143,15 @@ make install-local # direkt auf Dev-Server installieren (kein .deb)
cd management-ui && bun install && bun run build 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 ## Dev-Server Quickstart

View File

@@ -10,7 +10,7 @@ LDFLAGS := -s -w -X main.version=$(VERSION)
GOFLAGS := -trimpath -mod=readonly GOFLAGS := -trimpath -mod=readonly
export CGO_ENABLED ?= 0 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 \ build-linux-amd64 build-linux-arm64 \
deb deb-amd64 deb-arm64 \ deb deb-amd64 deb-arm64 \
publish publish-amd64 publish-arm64 publish publish-amd64 publish-arm64
@@ -61,9 +61,41 @@ build-linux-arm64:
test: test:
$(GO) test $(GOFLAGS) ./... $(GO) test $(GOFLAGS) ./...
test-race:
CGO_ENABLED=1 $(GO) test $(GOFLAGS) -race ./...
GOBIN := $(shell $(GO) env GOPATH)/bin
GOLANGCI_VERSION := v2.12.2
lint: lint:
$(GO) vet ./... $(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).
golangci:
@command -v golangci-lint >/dev/null 2>&1 || GOFLAGS= $(GO) install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@$(GOLANGCI_VERSION)
@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.
vulncheck:
@command -v govulncheck >/dev/null 2>&1 || GOFLAGS= $(GO) install golang.org/x/vuln/cmd/govulncheck@latest
@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: tidy:
$(GO) mod tidy $(GO) mod tidy
@@ -72,27 +104,28 @@ ui:
@echo " -> management-ui (vite build, version $(VERSION))" @echo " -> management-ui (vite build, version $(VERSION))"
@cd management-ui && \ @cd management-ui && \
if [ -x "$$(command -v bun)" ]; then bun install --silent && bun run build; \ 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) @./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) @./scripts/apt-repo/build-package.sh arm64 $(VERSION)
deb: deb-amd64 deb-arm64 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 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})" @echo " -> cleanup-old (keep last $${KEEP:-10})"
@./scripts/apt-repo/cleanup-old.sh @./scripts/apt-repo/cleanup-old.sh stable
publish-arm64: deb-arm64 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})" @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 publish: publish-amd64 publish-arm64

View File

@@ -1 +1 @@
1.2.100 1.3.25

View File

@@ -6,6 +6,7 @@ package main
import ( import (
"context" "context"
"crypto/rand" "crypto/rand"
"errors"
"log" "log"
"log/slog" "log/slog"
"net/http" "net/http"
@@ -17,54 +18,55 @@ import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/jackc/pgx/v5/pgxpool" "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"
"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" "git.netcell-it.de/projekte/edgeguard-native/internal/database"
firewallrender "git.netcell-it.de/projekte/edgeguard-native/internal/firewall" firewallrender "git.netcell-it.de/projekte/edgeguard-native/internal/firewall"
"git.netcell-it.de/projekte/edgeguard-native/internal/haproxy"
"git.netcell-it.de/projekte/edgeguard-native/internal/handlers"
"git.netcell-it.de/projekte/edgeguard-native/internal/license"
licsvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/license"
chronyrender "git.netcell-it.de/projekte/edgeguard-native/internal/chrony"
squidrender "git.netcell-it.de/projekte/edgeguard-native/internal/squid"
unboundrender "git.netcell-it.de/projekte/edgeguard-native/internal/unbound"
wgrender "git.netcell-it.de/projekte/edgeguard-native/internal/wireguard"
kearender "git.netcell-it.de/projekte/edgeguard-native/internal/kea"
radiusrender "git.netcell-it.de/projekte/edgeguard-native/internal/freeradius" radiusrender "git.netcell-it.de/projekte/edgeguard-native/internal/freeradius"
"git.netcell-it.de/projekte/edgeguard-native/internal/handlers"
"git.netcell-it.de/projekte/edgeguard-native/internal/handlers/response" "git.netcell-it.de/projekte/edgeguard-native/internal/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/acme"
"git.netcell-it.de/projekte/edgeguard-native/internal/services/alerts" "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/audit"
"git.netcell-it.de/projekte/edgeguard-native/internal/services/backends" "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/backendservers"
"git.netcell-it.de/projekte/edgeguard-native/internal/services/backup" "git.netcell-it.de/projekte/edgeguard-native/internal/services/backup"
backupremote "git.netcell-it.de/projekte/edgeguard-native/internal/services/backup/remote" backupremote "git.netcell-it.de/projekte/edgeguard-native/internal/services/backup/remote"
dnssvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/dns"
"git.netcell-it.de/projekte/edgeguard-native/internal/aggregator"
"git.netcell-it.de/projekte/edgeguard-native/internal/cluster/clustertls"
"git.netcell-it.de/projekte/edgeguard-native/internal/cluster/jointoken"
"git.netcell-it.de/projekte/edgeguard-native/internal/services/clusterjoin" "git.netcell-it.de/projekte/edgeguard-native/internal/services/clusterjoin"
aptsvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/apt" dhcpsvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/dhcp"
dnssvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/dns"
"git.netcell-it.de/projekte/edgeguard-native/internal/services/domainheaders" "git.netcell-it.de/projekte/edgeguard-native/internal/services/domainheaders"
"git.netcell-it.de/projekte/edgeguard-native/internal/services/domains" "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/firewall"
"git.netcell-it.de/projekte/edgeguard-native/internal/services/firewalllog" "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/forwardproxy"
"git.netcell-it.de/projekte/edgeguard-native/internal/services/ipaddresses" "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" "git.netcell-it.de/projekte/edgeguard-native/internal/services/networkifs"
ntpsvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/ntp" ntpsvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/ntp"
"git.netcell-it.de/projekte/edgeguard-native/internal/services/routingrules"
"git.netcell-it.de/projekte/edgeguard-native/internal/services/secrets"
"git.netcell-it.de/projekte/edgeguard-native/internal/services/staticroutes"
"git.netcell-it.de/projekte/edgeguard-native/internal/services/session"
"git.netcell-it.de/projekte/edgeguard-native/internal/services/setup"
"git.netcell-it.de/projekte/edgeguard-native/internal/services/tlscerts"
wgsvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/wireguard"
dhcpsvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/dhcp"
oidcsvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/oidc" oidcsvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/oidc"
radiussvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/radius" radiussvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/radius"
"git.netcell-it.de/projekte/edgeguard-native/internal/services/routingrules"
"git.netcell-it.de/projekte/edgeguard-native/internal/services/secrets"
"git.netcell-it.de/projekte/edgeguard-native/internal/services/session"
"git.netcell-it.de/projekte/edgeguard-native/internal/services/setup"
"git.netcell-it.de/projekte/edgeguard-native/internal/services/staticroutes"
"git.netcell-it.de/projekte/edgeguard-native/internal/services/syslogs"
"git.netcell-it.de/projekte/edgeguard-native/internal/services/tlscerts"
usersvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/users" usersvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/users"
wafsvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/waf" wafsvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/waf"
wgsvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/wireguard"
squidrender "git.netcell-it.de/projekte/edgeguard-native/internal/squid"
unboundrender "git.netcell-it.de/projekte/edgeguard-native/internal/unbound"
wgrender "git.netcell-it.de/projekte/edgeguard-native/internal/wireguard"
) )
var version = "1.2.35" var version = "1.2.35"
@@ -287,7 +289,7 @@ func main() {
// reload haproxy. Wird in Domains/Backends/RoutingRules-Handler // reload haproxy. Wird in Domains/Backends/RoutingRules-Handler
// injiziert, damit jede Änderung ohne expliziten render-config- // injiziert, damit jede Änderung ohne expliziten render-config-
// Aufruf live geht. Errors werden geloggt, nicht failed // 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 // Maintenance-Endpoints brauchen den Reloader — späte Wiring
// nachdem haproxyReloader-closure existiert. // nachdem haproxyReloader-closure existiert.
haproxyReloaderForLater := func(ctx context.Context) error { haproxyReloaderForLater := func(ctx context.Context) error {
@@ -296,18 +298,19 @@ func main() {
systemHdl.WithMaintenance(setupStore, haproxyReloaderForLater) systemHdl.WithMaintenance(setupStore, haproxyReloaderForLater)
// Audit-Wiring (Phase Polish): Settings + Auth-Mutationen // 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. // /var/lib/edgeguard/node-id.
systemHdl.WithAudit(auditRepo, nodeID) systemHdl.WithAudit(auditRepo, nodeID)
systemHdl.WithDB(pool) systemHdl.WithDB(pool)
systemHdl.WithConfigPreviewers(map[string]func(context.Context) (string, error){ systemHdl.WithConfigPreviewers(map[string]func(context.Context) (string, error){
"haproxy": haproxy.New(pool).RenderToString, "haproxy": haproxy.New(pool).RenderToString,
"nftables": firewallrender.New(pool).RenderToString, "nftables": firewallrender.New(pool).RenderToString,
"squid": squidrender.New(pool).RenderToString, "squid": squidrender.New(pool).RenderToString,
"unbound": unboundrender.New(pool).RenderToString, "unbound": unboundrender.New(pool).RenderToString,
"chrony": chronyrender.New(pool).RenderToString, "chrony": chronyrender.New(pool).RenderToString,
"wireguard": wgrender.New(pool, secretsBox).RenderToString, "wireguard": wgrender.New(pool, secretsBox).RenderToString,
}) "crowdsec-whitelist": crowdsec.NewWhitelistGenerator(pool).RenderToString,
})
setupHdl.WithAudit(auditRepo, nodeID) setupHdl.WithAudit(auditRepo, nodeID)
setupHdl.WithClusterSupport(clusterStore, func(ctx context.Context) error { setupHdl.WithClusterSupport(clusterStore, func(ctx context.Context) error {
return firewallrender.New(pool).Render(ctx) return firewallrender.New(pool).Render(ctx)
@@ -323,6 +326,14 @@ func main() {
return haproxy.New(pool).Render(ctx) 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 := v1.Group("")
authed.Use(requireAuth, handlers.RequireAdminForMutations()) authed.Use(requireAuth, handlers.RequireAdminForMutations())
setupHdl.RegisterAuthed(authed) setupHdl.RegisterAuthed(authed)
@@ -335,7 +346,7 @@ func main() {
WithAudit(auditRepo, nodeID) WithAudit(auditRepo, nodeID)
oidcHdl.RegisterPublic(v1) oidcHdl.RegisterPublic(v1)
oidcHdl.RegisterAdmin(authed) oidcHdl.RegisterAdmin(authed)
handlers.NewDomainsHandler(domainsRepo, routingRepo, domainHeadersRepo, auditRepo, nodeID, haproxyReloader).Register(authed) handlers.NewDomainsHandler(domainsRepo, routingRepo, domainHeadersRepo, auditRepo, nodeID, domainsReloader).Register(authed)
handlers.NewBackendsHandler(backendsRepo, auditRepo, nodeID, haproxyReloader).Register(authed) handlers.NewBackendsHandler(backendsRepo, auditRepo, nodeID, haproxyReloader).Register(authed)
handlers.NewBackendServersHandler(backendServersRepo, auditRepo, nodeID, haproxyReloader).Register(authed) handlers.NewBackendServersHandler(backendServersRepo, auditRepo, nodeID, haproxyReloader).Register(authed)
handlers.NewRoutingRulesHandler(routingRepo, auditRepo, nodeID, haproxyReloader).Register(authed) handlers.NewRoutingRulesHandler(routingRepo, auditRepo, nodeID, haproxyReloader).Register(authed)
@@ -411,7 +422,7 @@ func main() {
// services whose state feeds the auto-FW-rule generator (DNS // services whose state feeds the auto-FW-rule generator (DNS
// listen-IPs, Squid ACL count, WG listen-port, NTP serve-clients). // listen-IPs, Squid ACL count, WG listen-port, NTP serve-clients).
// Service-Reload-Errors propagieren; FW-Errors werden nur // 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 { withFW := func(svc func(context.Context) error) func(context.Context) error {
return func(ctx context.Context) error { return func(ctx context.Context) error {
if err := svc(ctx); err != nil { if err := svc(ctx); err != nil {
@@ -449,14 +460,14 @@ func main() {
handlers.NewForwardProxyHandler(fwdProxyRepo, auditRepo, nodeID, withFW(squidReloader)).Register(authed) handlers.NewForwardProxyHandler(fwdProxyRepo, auditRepo, nodeID, withFW(squidReloader)).Register(authed)
// Unbound DNS reload — re-render edgeguard.conf + restart // 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 { unboundReloader := func(ctx context.Context) error {
return unboundrender.New(pool).Render(ctx) return unboundrender.New(pool).Render(ctx)
} }
handlers.NewDNSHandler(dnsRepo, auditRepo, nodeID, withFW(unboundReloader)).Register(authed) handlers.NewDNSHandler(dnsRepo, auditRepo, nodeID, withFW(unboundReloader)).Register(authed)
// Chrony NTP reload — re-render edgeguard.conf + restart chrony. // 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 { chronyReloader := func(ctx context.Context) error {
return chronyrender.New(pool).Render(ctx) return chronyrender.New(pool).Render(ctx)
} }
@@ -502,7 +513,7 @@ func main() {
// Startup-Render nftables: stellt sicher dass Template-Änderungen // Startup-Render nftables: stellt sicher dass Template-Änderungen
// aus einem Update (z.B. neue WireGuard forward-Chain-Auto-Regel) // aus einem Update (z.B. neue WireGuard forward-Chain-Auto-Regel)
// sofort nach dem API-Restart aktiv werden — ohne dass der // 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. // idempotent und atomar; kein Dienst wird neu gestartet.
go func() { go func() {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
@@ -528,7 +539,17 @@ func main() {
handlers.FinishRollingUpdateIfPending() handlers.FinishRollingUpdateIfPending()
log.Printf("edgeguard-api %s listening on %s", version, addr) 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 { if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatalf("edgeguard-api: %v", err) log.Fatalf("edgeguard-api: %v", err)
} }
@@ -677,30 +698,6 @@ func openDBBestEffort() (*pgxpoolPool, error) {
// main.go on every platform — keeps the import block lean. // main.go on every platform — keeps the import block lean.
type pgxpoolPool = pgxpool.Pool 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 // randomEphemeralSecret is the fallback for dev environments where
// /var/lib/edgeguard isn't writable. Tokens issued with this secret // /var/lib/edgeguard isn't writable. Tokens issued with this secret
// die on restart — production reads/writes the persistent file via // die on restart — production reads/writes the persistent file via
@@ -859,7 +856,7 @@ func runPrimaryPush(ctx context.Context, pool *pgxpoolPool, nodeID, fqdn, versio
pCtx, cancel := context.WithTimeout(ctx, 15*time.Second) pCtx, cancel := context.WithTimeout(ctx, 15*time.Second)
defer cancel() defer cancel()
hash, _ := cluster.ComputeConfigHash(pCtx, pool) hash, _ := cluster.ComputeConfigHash(pCtx, pool)
if err := clusterjoin.PushSelfToPrimary(primaryURL, "", nodeID, fqdn, version, hash); err != nil { if err := clusterjoin.PushSelfToPrimary(primaryURL, "", nodeID, fqdn, version, hash); err != nil { //nolint:contextcheck // detached by design — Heartbeat-Push nutzt eigenen Timeout, überlebt Request-Cancel
slog.Warn("cluster: push-to-primary failed", "error", err) slog.Warn("cluster: push-to-primary failed", "error", err)
} else { } else {
slog.Debug("cluster: config_hash pushed to primary", "hash", hash) slog.Debug("cluster: config_hash pushed to primary", "hash", hash)
@@ -905,7 +902,7 @@ func runPeerPush(ctx context.Context, pool *pgxpoolPool, store *cluster.Store, n
if target == "" { if target == "" {
target = "https://" + p.FQDN target = "https://" + p.FQDN
} }
if err := clusterjoin.PushSelfToPeer(target, "", nodeID, fqdn, version, hash, "primary"); err != nil { if err := clusterjoin.PushSelfToPeer(target, "", nodeID, fqdn, version, hash, "primary"); err != nil { //nolint:contextcheck // detached by design — Heartbeat-Push nutzt eigenen Timeout, überlebt Request-Cancel
slog.Debug("cluster: push-to-peer failed", "peer", p.FQDN, "error", err) slog.Debug("cluster: push-to-peer failed", "peer", p.FQDN, "error", err)
} }
} }

View File

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

View File

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

View File

@@ -1,6 +1,7 @@
package main package main
import ( import (
"context"
"crypto/rand" "crypto/rand"
"crypto/tls" "crypto/tls"
"crypto/x509" "crypto/x509"
@@ -86,6 +87,7 @@ var localOnlyTables = []string{
"join_tokens_used", // Token-Tracking nur auf Primary relevant "join_tokens_used", // Token-Tracking nur auf Primary relevant
"audit_log", // Lokales Audit-Protokoll "audit_log", // Lokales Audit-Protokoll
"alert_events", // Lokale Laufzeit-Events "alert_events", // Lokale Laufzeit-Events
"waf_alerts", // Lokale WAF-Detection-Events (wie alert_events)
"backups", // Backup-Historie ist per-Node "backups", // Backup-Historie ist per-Node
"goose_db_version", // Migration-Tracking, internes Tool-State "goose_db_version", // Migration-Tracking, internes Tool-State
} }
@@ -319,9 +321,11 @@ host replication %s ::/0 scram-sha-256
if err != nil { if err != nil {
return fmt.Errorf("open: %w", err) return fmt.Errorf("open: %w", err)
} }
defer f.Close() if _, err = f.WriteString(entry); err != nil {
_, err = f.WriteString(entry) _ = f.Close()
return err return err
}
return f.Close()
} }
// cmdClusterSetupStandby richtet diesen Node als Logical-Replication- // cmdClusterSetupStandby richtet diesen Node als Logical-Replication-
@@ -497,11 +501,15 @@ func fetchReplicationCreds(host string, agentPort int, tlsDir string) (*pgReplic
} }
url := "https://" + net.JoinHostPort(host, strconv.Itoa(agentPort)) + "/agent/cluster/pg-replication-info" url := "https://" + net.JoinHostPort(host, strconv.Itoa(agentPort)) + "/agent/cluster/pg-replication-info"
resp, err := client.Get(url) req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, url, nil)
if err != nil { if err != nil {
return nil, fmt.Errorf("GET %s: %w", url, err) return nil, fmt.Errorf("GET %s: %w", url, err)
} }
defer resp.Body.Close() resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("GET %s: %w", url, err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK { if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("GET %s: HTTP %d", url, resp.StatusCode) return nil, fmt.Errorf("GET %s: HTTP %d", url, resp.StatusCode)
} }
@@ -543,11 +551,15 @@ func syncMasterKey(host string, agentPort int, tlsDir string) error {
}, },
} }
url := "https://" + net.JoinHostPort(host, strconv.Itoa(agentPort)) + "/agent/cluster/master-key" url := "https://" + net.JoinHostPort(host, strconv.Itoa(agentPort)) + "/agent/cluster/master-key"
resp, err := client.Get(url) req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, url, nil)
if err != nil { if err != nil {
return fmt.Errorf("GET %s: %w", url, err) return fmt.Errorf("GET %s: %w", url, err)
} }
defer resp.Body.Close() resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("GET %s: %w", url, err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK { if resp.StatusCode != http.StatusOK {
return fmt.Errorf("GET %s: HTTP %d", url, resp.StatusCode) return fmt.Errorf("GET %s: HTTP %d", url, resp.StatusCode)
} }

View File

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

View File

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

View File

@@ -8,13 +8,14 @@ import (
"time" "time"
"git.netcell-it.de/projekte/edgeguard-native/internal/chrony" "git.netcell-it.de/projekte/edgeguard-native/internal/chrony"
"git.netcell-it.de/projekte/edgeguard-native/internal/freeradius" "git.netcell-it.de/projekte/edgeguard-native/internal/cluster"
"git.netcell-it.de/projekte/edgeguard-native/internal/kea"
"git.netcell-it.de/projekte/edgeguard-native/internal/configgen" "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/database"
"git.netcell-it.de/projekte/edgeguard-native/internal/firewall" "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/haproxy"
"git.netcell-it.de/projekte/edgeguard-native/internal/cluster" "git.netcell-it.de/projekte/edgeguard-native/internal/kea"
"git.netcell-it.de/projekte/edgeguard-native/internal/keepalived" "git.netcell-it.de/projekte/edgeguard-native/internal/keepalived"
"git.netcell-it.de/projekte/edgeguard-native/internal/services/configorch" "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/services/secrets"
@@ -65,6 +66,7 @@ func cmdRenderConfig(args []string) int {
cn := chrony.New(pool) cn := chrony.New(pool)
ke := kea.New(pool) ke := kea.New(pool)
fr := freeradius.New(pool, secrets.New("")) fr := freeradius.New(pool, secrets.New(""))
cw := crowdsec.NewWhitelistGenerator(pool)
if skipReload { if skipReload {
hap.SkipReload = true hap.SkipReload = true
fw.SkipReload = true fw.SkipReload = true
@@ -74,6 +76,7 @@ func cmdRenderConfig(args []string) int {
cn.SkipReload = true cn.SkipReload = true
ke.SkipReload = true ke.SkipReload = true
fr.SkipReload = true fr.SkipReload = true
cw.SkipReload = true
} }
// keepalived: Node-ID aus node.conf für Prioritäts-Berechnung // keepalived: Node-ID aus node.conf für Prioritäts-Berechnung
@@ -82,13 +85,13 @@ func cmdRenderConfig(args []string) int {
ka = keepalived.New(pool, lc.NodeID) ka = keepalived.New(pool, lc.NodeID)
} }
gens := []configgen.Generator{hap, fw, sq, wg, ub, cn, ke, fr} gens := []configgen.Generator{hap, fw, sq, wg, ub, cn, ke, fr, cw}
if ka != nil { if ka != nil {
gens = append(gens, ka) gens = append(gens, ka)
} }
results, runErr := configorch.Run(ctx, gens, only) results, runErr := configorch.Run(ctx, gens, only)
fmt.Print(configorch.Summarise(results)) fmt.Print(configorch.Summarize(results))
if runErr != nil { if runErr != nil {
fmt.Fprintln(os.Stderr, "render-config aborted:", runErr) fmt.Fprintln(os.Stderr, "render-config aborted:", runErr)
return 1 return 1

View File

@@ -104,6 +104,12 @@ const (
auditCleanupInterval = 24 * time.Hour auditCleanupInterval = 24 * time.Hour
auditRetentionDays = 90 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 // backendDownCheckInterval — alle 2 Minuten HAProxy-Stats lesen und
// prüfen ob ein Backend komplett ausgefallen ist (alle Server DOWN). // prüfen ob ein Backend komplett ausgefallen ist (alle Server DOWN).
// Dedupe 12h pro Backend → kein Alert-Spam. Frischer Alert wenn das // Dedupe 12h pro Backend → kein Alert-Spam. Frischer Alert wenn das
@@ -129,7 +135,7 @@ const (
// ntpSyncCheckInterval — alle 10 Minuten chronyc tracking aufrufen. // ntpSyncCheckInterval — alle 10 Minuten chronyc tracking aufrufen.
// Keine Sync bedeutet: Uhr driftet → TLS-Cert-Prüfung schlägt fehl // Keine Sync bedeutet: Uhr driftet → TLS-Cert-Prüfung schlägt fehl
// wenn die Abweichung > Toleranz des Gegenstücks (i.d.R. ±1 min), // wenn die Abweichung > Toleranz des Gegenstücks (i.d.R. ±1 min),
// JWT-Ablauf inkonsistent, Cluster-Split-Brain möglich. Dedupe 1h // JWT-Ablauf inconsistent, Cluster-Split-Brain möglich. Dedupe 1h
// damit ein kurzer Upstream-Ausfall (Reboot, DHCP-Pause) keinen // damit ein kurzer Upstream-Ausfall (Reboot, DHCP-Pause) keinen
// Alert-Regen produziert. // Alert-Regen produziert.
ntpSyncCheckInterval = 10 * time.Minute ntpSyncCheckInterval = 10 * time.Minute
@@ -140,7 +146,7 @@ const (
// ist der Tunnel effektiv tot — Traffic droht lautlos. Dedupe 30min // ist der Tunnel effektiv tot — Traffic droht lautlos. Dedupe 30min
// pro Tunnel damit schnell wiederhergestellte Tunnels nur einmal feuern. // pro Tunnel damit schnell wiederhergestellte Tunnels nur einmal feuern.
wgTunnelCheckInterval = 5 * time.Minute wgTunnelCheckInterval = 5 * time.Minute
wgStaleSec = int64(5 * 60) // 5 Minuten ohne Handshake = tot wgStaleSec = int64(5 * 60) // 5 Minuten ohne Handshake = tot
) )
func main() { func main() {
@@ -185,7 +191,8 @@ func main() {
auditRepo := audit.New(pool) auditRepo := audit.New(pool)
alertDedupe := newDedupe(12 * time.Hour) 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) runRenewer(ctx, renewer, alertSvc, alertDedupe)
} }
runLicenseVerify(ctx, licClient, licKeyStore, licRepo, nodeID, alertSvc, alertDedupe) runLicenseVerify(ctx, licClient, licKeyStore, licRepo, nodeID, alertSvc, alertDedupe)
@@ -241,7 +248,7 @@ func main() {
ntpSyncTick := time.NewTicker(ntpSyncCheckInterval) ntpSyncTick := time.NewTicker(ntpSyncCheckInterval)
defer ntpSyncTick.Stop() defer ntpSyncTick.Stop()
// Kein Initial-Check bei Boot: chrony braucht nach dem Start // Kein Initial-Check bei Boot: chrony braucht nach dem Start
// einige Sekunden bis zur ersten Synchronisation — ein // einige Sekunden bis zur ersten Synchronization — ein
// sofortiger Check würde immer feuern. // sofortiger Check würde immer feuern.
wgTunnelTick := time.NewTicker(wgTunnelCheckInterval) wgTunnelTick := time.NewTicker(wgTunnelCheckInterval)
@@ -252,7 +259,13 @@ func main() {
for { for {
select { select {
case <-renewTick.C: 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) runRenewer(ctx, renewer, alertSvc, alertDedupe)
} }
runCertExpiryCheck(ctx, tlsRepo, alertSvc, alertDedupe) runCertExpiryCheck(ctx, tlsRepo, alertSvc, alertDedupe)
@@ -272,6 +285,7 @@ func main() {
runDiskCheck(ctx, alertSvc, alertDedupe) runDiskCheck(ctx, alertSvc, alertDedupe)
case <-auditTick.C: case <-auditTick.C:
runAuditCleanup(ctx, auditRepo, setupStore) runAuditCleanup(ctx, auditRepo, setupStore)
runAlertCleanup(ctx, alertSvc)
case <-backendDownTick.C: case <-backendDownTick.C:
runBackendDownCheck(ctx, pool, alertSvc, alertDedupe) runBackendDownCheck(ctx, pool, alertSvc, alertDedupe)
case <-memTick.C: case <-memTick.C:
@@ -317,6 +331,29 @@ func runAuditCleanup(ctx context.Context, r *audit.Repo, setupStore *setup.Store
} }
} }
// runAlertCleanup löscht alert_events älter als alertRetentionDays.
// Schutz vor unbounded growth — auf einer aktiven Box feuern backend.down/
// mem.high/cert.expiring über Monate tausende Rows (die Tabelle ist
// node-lokal, wird also nirgends sonst abgeräumt). Best-effort: Fehler
// werden nur geloggt.
func runAlertCleanup(ctx context.Context, a *alerts.Service) {
if a == nil {
return
}
cctx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
n, err := a.Cleanup(cctx, alertRetentionDays)
if err != nil {
slog.Warn("scheduler: alert cleanup failed",
"keep_days", alertRetentionDays, "error", err)
return
}
if n > 0 {
slog.Info("scheduler: alert cleanup",
"deleted", n, "keep_days", alertRetentionDays)
}
}
// runDiskCheck prüft die Belegung von / via statfs. Fire-Schwellen: // runDiskCheck prüft die Belegung von / via statfs. Fire-Schwellen:
// - >= 90% → Critical (error). Box ist akut gefährdet — beim // - >= 90% → Critical (error). Box ist akut gefährdet — beim
// nächsten Backup-Run oder größeren apt-Update droht "no space // nächsten Backup-Run oder größeren apt-Update droht "no space
@@ -567,7 +604,7 @@ func parseChronyTrackingForAlert(out string) (synced bool, stratum int, referenc
synced = true synced = true
} }
case "Stratum": case "Stratum":
fmt.Sscanf(val, "%d", &stratum) _, _ = fmt.Sscanf(val, "%d", &stratum)
if stratum > 0 && stratum < 16 { if stratum > 0 && stratum < 16 {
synced = true synced = true
} else if stratum == 0 || stratum >= 16 { } else if stratum == 0 || stratum >= 16 {
@@ -592,7 +629,7 @@ func runWGClientTunnelCheck(ctx context.Context, pool *pgxpool.Pool, a *alerts.S
// Alle aktiven Client-Interfaces aus DB laden. // Alle aktiven Client-Interfaces aus DB laden.
type wgIface struct{ name string } type wgIface struct{ name string }
rows, err := pool.Query(ctx, rows, err := pool.Query(ctx,
`SELECT name FROM wg_interfaces WHERE mode = 'client' AND active = true ORDER BY name`) `SELECT name FROM wireguard_interfaces WHERE mode = 'client' AND active = true ORDER BY name`)
if err != nil { if err != nil {
return return
} }
@@ -663,6 +700,56 @@ func runWGClientTunnelCheck(ctx context.Context, pool *pgxpool.Pool, a *alerts.S
var egBackendRE = regexp.MustCompile(`^eg_backend_(\d+)$`) 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 // runBackendDownCheck liest HAProxy-Stats via Admin-Socket und feuert
// einen Error-Alert für jedes Backend bei dem alle Server DOWN sind // 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. // (und mind. einer einen echten Health-Check hat). Dedupe 12h pro Backend.
@@ -670,19 +757,31 @@ func runBackendDownCheck(ctx context.Context, pool *pgxpool.Pool, a *alerts.Serv
if a == nil || d == nil { if a == nil || d == nil {
return return
} }
conn, err := net.DialTimeout("unix", "/run/haproxy/admin.sock", 2*time.Second) // Nur auf dem VIP-Master prüfen. Ein BACKUP-Node hält die VLAN-
// Gateway-VIPs nicht und kann die Backend-Subnetze gar nicht erreichen
// → jeder Health-Check läuft L4TOUT → Dauer-"backend.down"-Fehlalarm
// (Hauptquelle des alert_events-Spams). Der Master bedient den Traffic
// und sieht die echten Backend-States.
if !nodeHoldsVIP(ctx, pool) {
return
}
dialer := net.Dialer{Timeout: 2 * time.Second}
conn, err := dialer.DialContext(ctx, "unix", "/run/haproxy/admin.sock")
if err != nil { if err != nil {
// HAProxy läuft nicht oder Socket nicht erreichbar — kein Alert, // HAProxy läuft nicht oder Socket nicht erreichbar — kein Alert,
// das ist der Dienst selbst nicht der Scheduler. // das ist der Dienst selbst nicht der Scheduler.
return return
} }
defer conn.Close() defer func() { _ = conn.Close() }()
_ = conn.SetDeadline(time.Now().Add(3 * time.Second)) _ = conn.SetDeadline(time.Now().Add(3 * time.Second))
if _, err := conn.Write([]byte("show stat\n")); err != nil { if _, err := conn.Write([]byte("show stat\n")); err != nil {
return return
} }
type srvEntry struct{ status string; hasCheck bool } type srvEntry struct {
status string
hasCheck bool
}
byBackend := map[string][]srvEntry{} byBackend := map[string][]srvEntry{}
colIdx := map[string]int{} colIdx := map[string]int{}
scanner := bufio.NewScanner(conn) scanner := bufio.NewScanner(conn)
@@ -1016,7 +1115,7 @@ func runLicenseVerify(ctx context.Context, c *license.Client, ks *license.KeySto
slog.Debug("scheduler: license verify skipped — no key") slog.Debug("scheduler: license verify skipped — no key")
return 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 { if err != nil {
_ = repo.MarkError(ctx, key, err.Error()) _ = repo.MarkError(ctx, key, err.Error())
slog.Warn("scheduler: license verify failed", "error", err) slog.Warn("scheduler: license verify failed", "error", err)
@@ -1071,7 +1170,7 @@ func runRenewer(ctx context.Context, r *certrenewer.Service, a *alerts.Service,
_, _ = a.Fire(ctx, "cert.renew_failed", alerts.SeverityError, _, _ = a.Fire(ctx, "cert.renew_failed", alerts.SeverityError,
"Cert-Renewal fehlgeschlagen: "+domain, "Cert-Renewal fehlgeschlagen: "+domain,
"Let's Encrypt Erneuerung für "+domain+" ist fehlgeschlagen. "+ "Let's Encrypt Erneuerung für "+domain+" ist fehlgeschlagen. "+
"Prüfe ACME-Konfiguration und DNS-Erreichbarkeit. "+ "Prüfe ACME-Configuration und DNS-Erreichbarkeit. "+
"Nächster Versuch beim nächsten Renewer-Tick (alle 6h).") "Nächster Versuch beim nächsten Renewer-Tick (alle 6h).")
} }
} }

View File

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

18
go.mod
View File

@@ -1,6 +1,8 @@
module git.netcell-it.de/projekte/edgeguard-native module git.netcell-it.de/projekte/edgeguard-native
go 1.26.0 go 1.26.4
toolchain go1.26.6
require ( require (
github.com/corazawaf/coraza/v3 v3.7.0 github.com/corazawaf/coraza/v3 v3.7.0
@@ -16,7 +18,7 @@ require (
github.com/pquerna/otp v1.5.0 github.com/pquerna/otp v1.5.0
github.com/pressly/goose/v3 v3.27.1 github.com/pressly/goose/v3 v3.27.1
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
golang.org/x/crypto v0.51.0 golang.org/x/crypto v0.53.0
golang.org/x/oauth2 v0.36.0 golang.org/x/oauth2 v0.36.0
) )
@@ -78,12 +80,12 @@ require (
go.uber.org/multierr v1.11.0 // indirect go.uber.org/multierr v1.11.0 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/arch v0.8.0 // indirect golang.org/x/arch v0.8.0 // indirect
golang.org/x/mod v0.35.0 // indirect golang.org/x/mod v0.37.0 // indirect
golang.org/x/net v0.53.0 // indirect golang.org/x/net v0.56.0 // indirect
golang.org/x/sync v0.20.0 // indirect golang.org/x/sync v0.21.0 // indirect
golang.org/x/sys v0.44.0 // indirect golang.org/x/sys v0.46.0 // indirect
golang.org/x/text v0.37.0 // indirect golang.org/x/text v0.39.0 // indirect
golang.org/x/tools v0.44.0 // indirect golang.org/x/tools v0.47.0 // indirect
google.golang.org/protobuf v1.36.11 // indirect google.golang.org/protobuf v1.36.11 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect
rsc.io/binaryregexp v0.2.0 // indirect rsc.io/binaryregexp v0.2.0 // indirect

32
go.sum
View File

@@ -186,24 +186,24 @@ go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= golang.org/x/arch v0.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 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc=
golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys= 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.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8= golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM= golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU= golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA= golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs= golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ= golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4= golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc=
golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk= golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y=
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= golang.org/x/text v0.39.0 h1:UbZz4pLOvn600D6Oh6GGEI6VAmndrEBLv8/6BEXzyus=
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= golang.org/x/text v0.39.0/go.mod h1:3UwRclnC2g0TU9x8PZiyfOajCd1zaUNHF9cvqcQZ+ZM=
golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c= golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q=
golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI= golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=

View File

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

View File

@@ -13,14 +13,16 @@
{{end}} {{end}}
# ── Listen-Bind ──────────────────────────────────────────────── # ── Listen-Bind ────────────────────────────────────────────────
# Wenn nichts ausser localhost gebound ist, lassen wir bindaddress # KEIN bindaddress: chrony honoriert nur EINE bindaddress pro Adress-
# weg (chrony default = alle Interfaces). Sonst explizite bindaddress # familie — bei mehreren Listen-IPs (z. B. mehrere VLAN-/Cluster-VIPs)
# pro IP. Mit serve_clients=false wird port 0 → kein Listen-Socket # würde nur die letzte gebunden, alle anderen NTP-Clients liefen ins
# (= reiner Client). # 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}} {{if .Settings.ServeClients}}
{{- range .ListenAddresses}}
bindaddress {{.}}
{{- end}}
{{- range .AllowACLs}} {{- range .AllowACLs}}
allow {{.}} allow {{.}}
{{- end}} {{- end}}

View File

@@ -121,7 +121,7 @@ func splitCSV(s string) []string {
// filterNonLoopback wirft 127.x / ::1 raus — wenn NUR localhost im // filterNonLoopback wirft 127.x / ::1 raus — wenn NUR localhost im
// listen_addresses ist, lassen wir den bindaddress-Block weg und // listen_addresses ist, lassen wir den bindaddress-Block weg und
// chrony bindet auf alle Interfaces (default), was für eine reine // 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 { func filterNonLoopback(in []string) []string {
out := []string{} out := []string{}
for _, ip := range in { 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. // für Node-to-Node mTLS-Kommunikation.
// //
// Layout on disk: // 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/ca.crt (0644)
// /var/lib/edgeguard/cluster-tls/peer.crt (0644) — diese Node // /var/lib/edgeguard/cluster-tls/ca.key (0600, edgeguard:edgeguard)
// /var/lib/edgeguard/cluster-tls/peer.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: // 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. // 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 // ihn mit cluster-join-token zu /api/v1/cluster/issue-cert; Primary
// signiert via SignCSR und liefert peer.crt zurück. (Phase 3.4.) // 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. // idempotent gerufen; Listener auf :8443 kann sofort hochfahren.
// //
// Pattern 1:1 aus mail-gateway/internal/services/clustertls/clustertls.go, // 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. // InitCA generiert die Cluster-CA falls noch keine existiert. Idempotent.
// organisation landet im Subject — typischerweise die FQDN-Domain. // organization landet im Subject — typischerweise die FQDN-Domain.
func (s *Store) InitCA(organisation string, now func() time.Time) error { func (s *Store) InitCA(organization string, now func() time.Time) error {
if s.HasCA() { if s.HasCA() {
return nil return nil
} }
@@ -93,7 +94,7 @@ func (s *Store) InitCA(organisation string, now func() time.Time) error {
SerialNumber: serial, SerialNumber: serial,
Subject: pkix.Name{ Subject: pkix.Name{
CommonName: "EdgeGuard Cluster CA", CommonName: "EdgeGuard Cluster CA",
Organization: []string{organisation}, Organization: []string{organization},
}, },
NotBefore: now().UTC(), NotBefore: now().UTC(),
NotAfter: now().Add(caValidity).UTC(), NotAfter: now().Add(caValidity).UTC(),
@@ -325,12 +326,12 @@ func (s *Store) CACertPEM() (string, error) {
// CertInfo: zusammengefasste Cert-Metadata für UI-Status. days_remaining // CertInfo: zusammengefasste Cert-Metadata für UI-Status. days_remaining
// kann negativ sein wenn der Cert schon abgelaufen ist. // kann negativ sein wenn der Cert schon abgelaufen ist.
type CertInfo struct { type CertInfo struct {
CommonName string `json:"common_name"` CommonName string `json:"common_name"`
NotBefore time.Time `json:"not_before"` NotBefore time.Time `json:"not_before"`
NotAfter time.Time `json:"not_after"` NotAfter time.Time `json:"not_after"`
DaysRemaining int `json:"days_remaining"` DaysRemaining int `json:"days_remaining"`
IsCA bool `json:"is_ca"` IsCA bool `json:"is_ca"`
SerialHex string `json:"serial_hex"` SerialHex string `json:"serial_hex"`
} }
// PeerCertInfo liefert die Metadata des eigenen peer.crt. Wenn keiner // PeerCertInfo liefert die Metadata des eigenen peer.crt. Wenn keiner

View File

@@ -1,7 +1,7 @@
package cluster package cluster
// /etc/edgeguard/node.conf — node-lokale, NIEMALS zwischen 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 einzigartig machen:
// //
// NODE_ID eindeutige UUID (autogeneriert in EnsureNodeID; hier // NODE_ID eindeutige UUID (autogeneriert in EnsureNodeID; hier
@@ -54,7 +54,7 @@ func LoadLocalConfig(path string) (*LocalConfig, error) {
} }
return nil, err return nil, err
} }
defer f.Close() defer func() { _ = f.Close() }()
c := &LocalConfig{} c := &LocalConfig{}
sc := bufio.NewScanner(f) sc := bufio.NewScanner(f)
for sc.Scan() { for sc.Scan() {
@@ -91,7 +91,7 @@ func LoadLocalConfig(path string) (*LocalConfig, error) {
} }
// SaveLocalConfig schreibt die Datei atomic + 0644 root:root. // 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 { func SaveLocalConfig(path string, c *LocalConfig) error {
if path == "" { if path == "" {
path = DefaultLocalConfigPath path = DefaultLocalConfigPath

View File

@@ -23,7 +23,7 @@ import (
// //
// Name returns a stable identifier ("haproxy", "nftables", …) // Name returns a stable identifier ("haproxy", "nftables", …)
// used in CLI output and audit logs. Render does the actual write + // 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 { type Generator interface {
Name() string Name() string
Render(ctx context.Context) error 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) return fmt.Errorf("tempfile: %w", err)
} }
tmpPath := tmp.Name() 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 { if _, err := tmp.Write(data); err != nil {
tmp.Close() _ = tmp.Close()
return fmt.Errorf("write %s: %w", tmpPath, err) return fmt.Errorf("write %s: %w", tmpPath, err)
} }
if err := tmp.Sync(); err != nil { if err := tmp.Sync(); err != nil {
tmp.Close() _ = tmp.Close()
return fmt.Errorf("fsync %s: %w", tmpPath, err) return fmt.Errorf("fsync %s: %w", tmpPath, err)
} }
if err := tmp.Close(); err != nil { if err := tmp.Close(); err != nil {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -8,7 +8,7 @@ import "testing"
// startup, the API restart-looped, the cluster rolling-upgrade hung. // startup, the API restart-looped, the cluster rolling-upgrade hung.
// //
// Cheap assertion that runs as part of `go test ./...` — fails the // 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 // version never reaches the APT registry. Same logic also runs at
// service start via Migrate() and via `edgeguard-ctl migrate check` // service start via Migrate() and via `edgeguard-ctl migrate check`
// in postinst (defense in depth). // in postinst (defense in depth).

View File

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

View File

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

View File

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

View File

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

View File

@@ -55,6 +55,13 @@ table inet edgeguard {
tcp dport 5432 ip6 saddr @peer_ipv6 accept tcp dport 5432 ip6 saddr @peer_ipv6 accept
tcp dport 6379 ip saddr @peer_ipv4 accept tcp dport 6379 ip saddr @peer_ipv4 accept
tcp dport 6379 ip6 saddr @peer_ipv6 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/...) ── # ── Service-Auto-Rules (DNS/Squid/WG/...) ──
# Aus dem laufenden Service-State abgeleitet — Operator # Aus dem laufenden Service-State abgeleitet — Operator

View File

@@ -1,6 +1,7 @@
package handlers package handlers
import ( import (
"context"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"os" "os"
@@ -31,7 +32,7 @@ func TestACME_ServesExistingToken(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
rec := httptest.NewRecorder() 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) r.ServeHTTP(rec, req)
if rec.Code != http.StatusOK { if rec.Code != http.StatusOK {
@@ -45,7 +46,7 @@ func TestACME_ServesExistingToken(t *testing.T) {
func TestACME_MissingToken_Returns404(t *testing.T) { func TestACME_MissingToken_Returns404(t *testing.T) {
r, _ := setupACME(t) r, _ := setupACME(t)
rec := httptest.NewRecorder() 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) r.ServeHTTP(rec, req)
if rec.Code != http.StatusNotFound { if rec.Code != http.StatusNotFound {
t.Errorf("status: %d", rec.Code) t.Errorf("status: %d", rec.Code)
@@ -76,7 +77,7 @@ func TestACME_DirIsNotAFile(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
rec := httptest.NewRecorder() 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) r.ServeHTTP(rec, req)
if rec.Code != http.StatusNotFound { if rec.Code != http.StatusNotFound {
t.Errorf("expected 404 for directory, got %d", rec.Code) t.Errorf("expected 404 for directory, got %d", rec.Code)

View File

@@ -18,7 +18,10 @@ import (
// PUT /api/v1/alerts/channels/:id // PUT /api/v1/alerts/channels/:id
// DELETE /api/v1/alerts/channels/:id // DELETE /api/v1/alerts/channels/:id
// POST /api/v1/alerts/test — Test-Event in alle aktiven Channels // 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 { type AlertsHandler struct {
Service *alerts.Service Service *alerts.Service
Audit *audit.Repo Audit *audit.Repo
@@ -37,6 +40,9 @@ func (h *AlertsHandler) Register(rg *gin.RouterGroup) {
g.DELETE("/channels/:id", h.DeleteChannel) g.DELETE("/channels/:id", h.DeleteChannel)
g.POST("/test", h.TestFire) g.POST("/test", h.TestFire)
g.GET("/events", h.ListEvents) 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) { func (h *AlertsHandler) ListChannels(c *gin.Context) {
@@ -125,10 +131,64 @@ func (h *AlertsHandler) ListEvents(c *gin.Context) {
limit = n 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 { if err != nil {
response.Internal(c, err) response.Internal(c, err)
return return
} }
response.OK(c, gin.H{"events": out}) 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 { if err != nil {
return return
} }
defer conn.Close() defer func() { _ = conn.Close() }()
// Snapshot // Snapshot
if rows, err := h.Repo.ListRecent(c.Request.Context(), 50); err == nil { if rows, err := h.Repo.ListRecent(c.Request.Context(), 50); err == nil {

View File

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

View File

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

View File

@@ -21,6 +21,7 @@ import (
"git.netcell-it.de/projekte/edgeguard-native/internal/cluster/jointoken" "git.netcell-it.de/projekte/edgeguard-native/internal/cluster/jointoken"
"git.netcell-it.de/projekte/edgeguard-native/internal/handlers/response" "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/models"
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/audit"
) )
@@ -35,7 +36,7 @@ type ClusterHandler struct {
Store *cluster.Store Store *cluster.Store
LocalID string LocalID string
Aggregator *aggregator.Aggregator Aggregator *aggregator.Aggregator
Version string // laufende Binary-Version, für Rolling-Update-Koordination Version string // laufende Binary-Version, für Rolling-Update-Coordination
// TLSStore + Tokens: optional, gesetzt bei Phase 3.4. Erlauben das // TLSStore + Tokens: optional, gesetzt bei Phase 3.4. Erlauben das
// Generieren von Join-Tokens und das Issue-Cert für joining Peers. // Generieren von Join-Tokens und das Issue-Cert für joining Peers.
@@ -56,7 +57,7 @@ func NewClusterHandler(store *cluster.Store, localID string) *ClusterHandler {
return &ClusterHandler{Store: store, LocalID: localID} 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. // wird /cluster/system/load die Peers via mTLS abklappern.
func (h *ClusterHandler) WithAggregator(a *aggregator.Aggregator) *ClusterHandler { func (h *ClusterHandler) WithAggregator(a *aggregator.Aggregator) *ClusterHandler {
h.Aggregator = a h.Aggregator = a
@@ -92,6 +93,8 @@ func (h *ClusterHandler) Register(rg *gin.RouterGroup) {
g.GET("/repair-replication/status", h.RepairReplicationStatus) g.GET("/repair-replication/status", h.RepairReplicationStatus)
g.GET("/vip-status", h.VIPStatus) g.GET("/vip-status", h.VIPStatus)
g.POST("/vip-test", h.VIPTest) g.POST("/vip-test", h.VIPTest)
g.GET("/update-channel", h.UpdateChannel)
g.POST("/update-channel", h.SetUpdateChannel)
if h.TLSStore != nil { if h.TLSStore != nil {
g.GET("/cert-status", h.CertStatus) g.GET("/cert-status", h.CertStatus)
g.POST("/renew-self", h.RenewSelf) g.POST("/renew-self", h.RenewSelf)
@@ -104,7 +107,7 @@ func (h *ClusterHandler) Register(rg *gin.RouterGroup) {
// DeleteNode entfernt einen Peer aus ha_nodes. Verweigert für die // DeleteNode entfernt einen Peer aus ha_nodes. Verweigert für die
// lokale Node (LocalID) — die kannst du nicht via UI löschen, sonst // lokale Node (LocalID) — die kannst du nicht via UI löschen, sonst
// kommt der nächste Heartbeat-Tick die Row wieder anlegen oder // 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) // Nach erfolgreichem Delete triggert der PeerReloader (falls gesetzt)
// einen Firewall-Render — peer_ipv4-Set verliert die IP, der entfernte // einen Firewall-Render — peer_ipv4-Set verliert die IP, der entfernte
@@ -159,7 +162,7 @@ func (h *ClusterHandler) GetVIPSettings(c *gin.Context) {
response.OK(c, cs) response.OK(c, cs)
} }
// UpdateVIPSettings speichert die VIP/VRRP-Konfiguration und triggert // UpdateVIPSettings speichert die VIP/VRRP-Configuration und triggert
// einen Keepalived-Config-Render. Viewer-Schutz via RequireAdminForMutations- // einen Keepalived-Config-Render. Viewer-Schutz via RequireAdminForMutations-
// Middleware auf der authed-Group — kein Extra-Check nötig. // Middleware auf der authed-Group — kein Extra-Check nötig.
func (h *ClusterHandler) UpdateVIPSettings(c *gin.Context) { func (h *ClusterHandler) UpdateVIPSettings(c *gin.Context) {
@@ -247,6 +250,8 @@ func (h *ClusterHandler) RegisterAgent(rg *gin.RouterGroup) {
g.GET("/master-key", h.AgentMasterKey) g.GET("/master-key", h.AgentMasterKey)
g.GET("/version", h.AgentVersion) g.GET("/version", h.AgentVersion)
g.POST("/trigger-update", h.AgentTriggerUpdate) g.POST("/trigger-update", h.AgentTriggerUpdate)
g.POST("/set-channel", h.AgentSetChannel)
g.GET("/channel", h.AgentChannel)
g.GET("/active-ips", h.AgentActiveIPs) g.GET("/active-ips", h.AgentActiveIPs)
g.POST("/vip-cmd", h.AgentVIPCmd) g.POST("/vip-cmd", h.AgentVIPCmd)
g.GET("/tls-certs", h.AgentTLSCerts) g.GET("/tls-certs", h.AgentTLSCerts)
@@ -338,7 +343,7 @@ func (h *ClusterHandler) WithPeerReloader(r PeerReloader) *ClusterHandler {
return h return h
} }
// WithVersion: setzt die laufende Binary-Version für Rolling-Update-Koordination. // WithVersion: setzt die laufende Binary-Version für Rolling-Update-Coordination.
func (h *ClusterHandler) WithVersion(v string) *ClusterHandler { func (h *ClusterHandler) WithVersion(v string) *ClusterHandler {
h.Version = v h.Version = v
return h return h
@@ -359,7 +364,7 @@ type ClusterStatus struct {
LocalID string `json:"local_id"` LocalID string `json:"local_id"`
LocalNode *models.HANode `json:"local_node,omitempty"` LocalNode *models.HANode `json:"local_node,omitempty"`
Peers []models.HANode `json:"peers"` Peers []models.HANode `json:"peers"`
Mode string `json:"mode"` // "single-node" | "cluster" Mode string `json:"mode"` // "single-node" | "cluster"
Health string `json:"health"` // "ok" | "degraded" | "split-brain" Health string `json:"health"` // "ok" | "degraded" | "split-brain"
DriftFound bool `json:"drift_found"` DriftFound bool `json:"drift_found"`
UpdatedAt time.Time `json:"updated_at"` UpdatedAt time.Time `json:"updated_at"`
@@ -758,7 +763,10 @@ retry_apt() {
while [ $attempt -lt $max ]; do while [ $attempt -lt $max ]; do
attempt=$((attempt + 1)) attempt=$((attempt + 1))
apt-get update -qq || true 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 edgeguard-api edgeguard-ui edgeguard; then return 0; fi
[ $attempt -lt $max ] && sleep $wait_for && wait_for=$((wait_for * 2)) [ $attempt -lt $max ] && sleep $wait_for && wait_for=$((wait_for * 2))
done done
@@ -789,6 +797,131 @@ rm -f /var/lib/edgeguard/upgrade.sh
c.JSON(http.StatusAccepted, gin.H{"status": "upgrading"}) 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") var errInvalidJoinRequest = simpleError("missing token or csr")
type simpleError string type simpleError string
@@ -822,7 +955,7 @@ func (h *ClusterHandler) CertStatus(c *gin.Context) {
// //
// Nach Renew muss edgeguard-api restartet werden damit der Agent- // Nach Renew muss edgeguard-api restartet werden damit der Agent-
// Listener das neue Cert in seinen TLS-Config-Snapshot lädt — wir // 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. // stattdessen liefern wir einen Hinweis im Response.
func (h *ClusterHandler) RenewSelf(c *gin.Context) { func (h *ClusterHandler) RenewSelf(c *gin.Context) {
if !h.TLSStore.HasCA() { if !h.TLSStore.HasCA() {
@@ -857,16 +990,16 @@ func (h *ClusterHandler) RenewSelf(c *gin.Context) {
// die wir wirklich brauchen — sonst kann ein joining Peer beliebige // die wir wirklich brauchen — sonst kann ein joining Peer beliebige
// ha_nodes-Felder überschreiben. // ha_nodes-Felder überschreiben.
type registerPeerRequest struct { type registerPeerRequest struct {
ID string `json:"id"` // Joiner's eigene node-id ID string `json:"id"` // Joiner's eigene node-id
Name string `json:"name"` // hostname Name string `json:"name"` // hostname
FQDN string `json:"fqdn"` // sollte mit Client-Cert-CN matchen FQDN string `json:"fqdn"` // sollte mit Client-Cert-CN matchen
APIURL string `json:"api_url"` // https://<fqdn> APIURL string `json:"api_url"` // https://<fqdn>
PublicIP string `json:"public_ip"` // optional PublicIP string `json:"public_ip"` // optional
InternalIP string `json:"internal_ip"` // mTLS-Listener-IP (für peer_ipv4-Set) InternalIP string `json:"internal_ip"` // mTLS-Listener-IP (für peer_ipv4-Set)
MgmtIP string `json:"mgmt_ip"` // optional MgmtIP string `json:"mgmt_ip"` // optional
Version string `json:"version"` Version string `json:"version"`
ConfigHash *string `json:"config_hash"` // nil=absent (don't change), ""=no user config 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 Role string `json:"role"` // "" → "peer" (joining peer); "primary" beim Push des Primary
} }
// AgentRegisterPeer: vom Joiner nach issue-cert via mTLS aufgerufen. // AgentRegisterPeer: vom Joiner nach issue-cert via mTLS aufgerufen.
@@ -925,6 +1058,14 @@ func (h *ClusterHandler) AgentRegisterPeer(c *gin.Context) {
if req.PublicIP != "" { if req.PublicIP != "" {
v := req.PublicIP v := req.PublicIP
n.PublicIP = &v 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 != "" { if req.InternalIP != "" {
v := req.InternalIP v := req.InternalIP

View File

@@ -109,6 +109,40 @@ func SyncTLSCertsFromPrimary(ctx context.Context, pool *pgxpool.Pool, agg *aggre
slog.Info("cert-sync: updated", "file", name) 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 changed {
if err := exec.Command("sudo", "-n", "/usr/bin/systemctl", "reload", "haproxy.service").Run(); err != nil { if err := exec.Command("sudo", "-n", "/usr/bin/systemctl", "reload", "haproxy.service").Run(); err != nil {
slog.Warn("cert-sync: haproxy reload failed", "error", err) slog.Warn("cert-sync: haproxy reload failed", "error", err)

View File

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

View File

@@ -177,7 +177,7 @@ func (h *ClusterHandler) runRollingUpdate(secondary *models.HANode) {
slog.Info("rolling-update: secondary already at target — skipping secondary step", slog.Info("rolling-update: secondary already at target — skipping secondary step",
"version", candidate) "version", candidate)
} else { } else {
// 1. Secondary triggern // 1. Secondary triggering
slog.Info("rolling-update: posting trigger-update to secondary", "fqdn", secondary.FQDN) slog.Info("rolling-update: posting trigger-update to secondary", "fqdn", secondary.FQDN)
result := h.Aggregator.PostPeer(ctx, *secondary, "/agent/cluster/trigger-update") result := h.Aggregator.PostPeer(ctx, *secondary, "/agent/cluster/trigger-update")
if !result.OK { if !result.OK {
@@ -273,7 +273,10 @@ retry_apt() {
while [ $attempt -lt $max ]; do while [ $attempt -lt $max ]; do
attempt=$((attempt + 1)) attempt=$((attempt + 1))
apt-get update -qq || true 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 edgeguard-api edgeguard-ui edgeguard; then return 0; fi
[ $attempt -lt $max ] && sleep $wait_for && wait_for=$((wait_for * 2)) [ $attempt -lt $max ] && sleep $wait_for && wait_for=$((wait_for * 2))
done done

View File

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

View File

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

View File

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

View File

@@ -139,7 +139,7 @@ func (h *LicenseHandler) ClearKey(c *gin.Context) {
// result into the licenses table. On error, marks last_error in DB // result into the licenses table. On error, marks last_error in DB
// (status stays as before — grace). // (status stays as before — grace).
func (h *LicenseHandler) runVerifyAndPersist(ctx context.Context, key string) (*license.Result, error) { 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 { if err != nil {
_ = h.Repo.MarkError(ctx, key, err.Error()) _ = h.Repo.MarkError(ctx, key, err.Error())
slog.Warn("license: verify failed", "error", err) slog.Warn("license: verify failed", "error", err)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -81,6 +81,13 @@ frontend public_https
bind quic6@:443 ssl crt /etc/edgeguard/tls/ alpn h3 bind quic6@:443 ssl crt /etc/edgeguard/tls/ alpn h3
{{- end}} {{- end}}
{{- if .WAFEnabled}} {{- if .WAFEnabled}}
# WAF: Request-Body puffern, damit edgeguard-waf den Body inspizieren
# kann (POST/PUT-Payloads: Form-SQLi, JSON-Injection, Datei-Uploads).
# Bewusst NUR wenn mind. eine Domain WAF nutzt (.WAFEnabled) — sonst
# kein RAM-pro-Connection-Overhead (vgl. Kommentar am Body-Size-Cap).
# Puffer bis tune.bufsize (~16KB); größere Bodies werden zur Inspektion
# gekappt — typische Injection-Payloads sind klein.
option http-buffer-request
# WAF: SPOE-Filter — edgeguard-waf inspiziert jeden Request. # WAF: SPOE-Filter — edgeguard-waf inspiziert jeden Request.
# filter muss vor allen http-request/http-response-Direktiven stehen. # filter muss vor allen http-request/http-response-Direktiven stehen.
filter spoe engine edgeguard-waf config /etc/edgeguard/haproxy/coraza-spoe.cfg filter spoe engine edgeguard-waf config /etc/edgeguard/haproxy/coraza-spoe.cfg
@@ -113,6 +120,11 @@ frontend public_https
# www-Redirect: {{$d.RedirectFromHost}}{{$d.Name}} # www-Redirect: {{$d.RedirectFromHost}}{{$d.Name}}
http-request redirect prefix https://{{$d.Name}} code 301 if { hdr(host) -i {{$d.RedirectFromHost}} } http-request redirect prefix https://{{$d.Name}} code 301 if { hdr(host) -i {{$d.RedirectFromHost}} }
{{- end}} {{- end}}
{{- if $d.RedirectTo}}
# Domain-Redirect (301): {{$d.Name}}{{$d.RedirectTo}} (immer auf Ziel-Root,
# terminiert vor use_backend → diese Domain routet auf kein Backend).
http-request redirect location {{$d.RedirectTo}} code 301 if { hdr(host) -i {{$d.Name}} }
{{- end}}
{{- if $d.MaintenanceMode}} {{- if $d.MaintenanceMode}}
# Wartungs-Modus für {{$d.Name}} — alle Requests werden mit 503 beantwortet. # Wartungs-Modus für {{$d.Name}} — alle Requests werden mit 503 beantwortet.
http-request return status 503 content-type "text/plain; charset=utf-8" string "{{$d.MaintMessage}}" if { hdr(host) -i {{$d.Name}} } http-request return status 503 content-type "text/plain; charset=utf-8" string "{{$d.MaintMessage}}" if { hdr(host) -i {{$d.Name}} }
@@ -221,6 +233,11 @@ backend eg_backend_{{$b.ID}}
{{- if $b.WebSocket}} {{- if $b.WebSocket}}
timeout tunnel 1h timeout tunnel 1h
{{- end}} {{- end}}
{{- if $b.ServerTimeoutSeconds}}
# Override des defaults-`timeout server 60s` für langsame Upstreams
# (z. B. KI-Server mit gepufferter Antwort). Wert per Backend gepflegt.
timeout server {{$b.ServerTimeoutSeconds}}s
{{- end}}
{{- if $b.HealthCheckPath}} {{- if $b.HealthCheckPath}}
option httpchk option httpchk
http-check send meth GET uri {{$b.HealthCheckPath}} http-check send meth GET uri {{$b.HealthCheckPath}}

View File

@@ -160,7 +160,7 @@ spoe-agent edgeguard-waf-agent
use-backend spoe-edgeguard-waf use-backend spoe-edgeguard-waf
spoe-message edgeguard-waf-req spoe-message edgeguard-waf-req
args src=src method=method uri=url ver=req.ver headers=req.hdrs host=req.hdr(host) args src=src method=method uri=url ver=req.ver headers=req.hdrs host=req.hdr(host) body=req.body
event on-frontend-http-request event on-frontend-http-request
` `
@@ -212,6 +212,11 @@ type DomainView struct {
// to-www → Name="www.example.com" → "example.com" (strip www.-Prefix) // to-www → Name="www.example.com" → "example.com" (strip www.-Prefix)
RedirectFromHost string RedirectFromHost string
// RedirectTo: HAProxy-safe 301-Ziel-URL für eine Domain→Domain-Weiterleitung
// (z. B. "https://zkm.netcell-it.de"). Leer = kein Redirect. Schattet das
// gleichnamige Feld aus dem eingebetteten models.Domain (sanitisiert).
RedirectTo string
// ResponseHeaders: Custom-Headers die HAProxy auf jede Response für // ResponseHeaders: Custom-Headers die HAProxy auf jede Response für
// diese Domain setzt. Werte sind bereits HAProxy-safe escaped // diese Domain setzt. Werte sind bereits HAProxy-safe escaped
// (Quotes → ', Newlines → Space). // (Quotes → ', Newlines → Space).
@@ -313,6 +318,7 @@ func (g *Generator) loadView(ctx context.Context) (*View, error) {
HSTSHeader: buildHSTSHeader(d), HSTSHeader: buildHSTSHeader(d),
MaintMessage: buildMaintMessage(d), MaintMessage: buildMaintMessage(d),
RedirectFromHost: buildRedirectFromHost(d), RedirectFromHost: buildRedirectFromHost(d),
RedirectTo: buildRedirectTo(d),
ResponseHeaders: headersByDomain[d.ID], ResponseHeaders: headersByDomain[d.ID],
} }
if d.MaxBodyKB > 0 { if d.MaxBodyKB > 0 {
@@ -429,3 +435,23 @@ func buildRedirectFromHost(d models.Domain) string {
return "" return ""
} }
} }
// buildRedirectTo liefert die 301-Ziel-URL HAProxy-safe, oder "" wenn kein
// Redirect gesetzt ist bzw. die URL ungültig erscheint. Defensiv: nur
// http(s)-URLs ohne Whitespace/Steuerzeichen/Quotes — sonst würde die
// `redirect location <url>`-Zeile die HAProxy-Config sprengen. Im Zweifel
// lieber KEIN Redirect rendern als eine kaputte Config auszuliefern.
func buildRedirectTo(d models.Domain) string {
u := strings.TrimSpace(d.RedirectTo)
if u == "" {
return ""
}
lower := strings.ToLower(u)
if !strings.HasPrefix(lower, "http://") && !strings.HasPrefix(lower, "https://") {
return ""
}
if strings.ContainsAny(u, " \t\r\n\"'`\\{}") {
return ""
}
return u
}

View File

@@ -104,11 +104,13 @@ func TestRender_HSTSPerDomain(t *testing.T) {
t.Errorf("missing %q in per-domain HSTS output:\n%s", w, out) t.Errorf("missing %q in per-domain HSTS output:\n%s", w, out)
} }
} }
if strings.Contains(out, "Strict-Transport-Security \"\" if { hdr(host) -i b.example.com }") || // HSTS soll für die Domain ohne HSTSEnabled gar nicht erst gerendert
strings.Contains(out, "if { hdr(host) -i b.example.com }") && strings.Contains(out, "Strict-Transport-Security") && // werden: keine einzige HSTS-Zeile darf sich auf b.example.com beziehen.
strings.Contains(out, "b.example.com") && strings.Count(out, "Strict-Transport-Security") > 2 { for _, line := range strings.Split(out, "\n") {
// HSTS soll für Domain ohne HSTSEnabled gar nicht erst gerendert werden. if strings.Contains(line, "Strict-Transport-Security") &&
// (mgmt_https hat noch eins, plus die eine Zeile von a.example.com → 2 Vorkommen erwartet.) strings.Contains(line, "hdr(host) -i b.example.com") {
t.Errorf("unexpected HSTS ACL for HSTS-disabled domain b.example.com: %q", line)
}
} }
} }
@@ -170,6 +172,45 @@ func TestRender_WWWRedirectToWWW(t *testing.T) {
} }
} }
func TestRender_RedirectTo(t *testing.T) {
v := View{
Domains: []DomainView{
{
Domain: models.Domain{
ID: 1, Name: "kvs.netcell-it.de", Active: true,
RedirectTo: "https://zkm.netcell-it.de",
},
RedirectTo: "https://zkm.netcell-it.de",
},
},
}
out := renderView(t, v)
want := `http-request redirect location https://zkm.netcell-it.de code 301 if { hdr(host) -i kvs.netcell-it.de }`
if !strings.Contains(out, want) {
t.Errorf("missing domain→domain 301 redirect line:\n%s", out)
}
}
func TestBuildRedirectTo(t *testing.T) {
cases := []struct{ in, want string }{
{"https://zkm.netcell-it.de", "https://zkm.netcell-it.de"},
{" https://zkm.netcell-it.de ", "https://zkm.netcell-it.de"}, // getrimmt
{"http://x.de", "http://x.de"},
{"", ""},
{"zkm.netcell-it.de", ""}, // kein Schema
{"ftp://x.de", ""}, // falsches Schema
{"https://x .de", ""}, // Whitespace → unsafe
{"https://x\"de", ""}, // Quote → unsafe
{"javascript:alert(1)", ""}, // kein http(s)
}
for _, c := range cases {
got := buildRedirectTo(models.Domain{RedirectTo: c.in})
if got != c.want {
t.Errorf("buildRedirectTo(%q) = %q, want %q", c.in, got, c.want)
}
}
}
func TestBuildHSTSHeader(t *testing.T) { func TestBuildHSTSHeader(t *testing.T) {
cases := []struct { cases := []struct {
name string name string
@@ -513,6 +554,75 @@ func TestRender_WebSocketEmitsTunnelTimeout(t *testing.T) {
} }
} }
func TestRender_ServerTimeoutOverride(t *testing.T) {
tmo := 300
v := View{
Backends: []BackendView{
{
Backend: models.Backend{ID: 11, Name: "ai", Scheme: "http",
LBAlgorithm: "roundrobin", ServerTimeoutSeconds: &tmo, Active: true},
Servers: []models.BackendServer{
{BackendID: 11, Name: "ai-1", Address: "10.0.5.30", Port: 8000, Weight: 100, Active: true},
},
},
{
Backend: models.Backend{ID: 12, Name: "web", Scheme: "http",
LBAlgorithm: "roundrobin", Active: true},
Servers: []models.BackendServer{
{BackendID: 12, Name: "web-1", Address: "10.0.5.31", Port: 8080, Weight: 100, Active: true},
},
},
},
}
out := renderView(t, v)
idxAI := strings.Index(out, "backend eg_backend_11")
idxWeb := strings.Index(out, "backend eg_backend_12")
if idxAI < 0 || idxWeb < 0 {
t.Fatalf("backend sections missing in output:\n%s", out)
}
aiBlock := out[idxAI:idxWeb]
webBlock := out[idxWeb:]
// ai (nil-Override gesetzt) soll `timeout server 300s` bekommen …
if !strings.Contains(aiBlock, "timeout server 300s") {
t.Errorf("ai-Block sollte `timeout server 300s` enthalten:\n%s", aiBlock)
}
// … web (kein Override) soll KEINE eigene timeout-server-Zeile bekommen.
if strings.Contains(webBlock, "timeout server") {
t.Errorf("web-Block soll KEIN eigenes `timeout server` enthalten:\n%s", webBlock)
}
}
func TestRender_WAFBuffersRequestBody(t *testing.T) {
// Ohne WAF: kein Body-Buffering (kein RAM-Overhead).
off := renderView(t, View{WAFEnabled: false})
if strings.Contains(off, "option http-buffer-request") {
t.Errorf("ohne WAF darf kein `option http-buffer-request` gerendert werden:\n%s", off)
}
if strings.Contains(off, "filter spoe") {
t.Errorf("ohne WAF darf kein SPOE-Filter gerendert werden")
}
// Mit WAF: Body-Buffering VOR dem SPOE-Filter, damit req.body verfügbar ist.
on := renderView(t, View{WAFEnabled: true})
idxBuf := strings.Index(on, "option http-buffer-request")
idxFilter := strings.Index(on, "filter spoe engine edgeguard-waf")
if idxBuf < 0 {
t.Fatalf("mit WAF muss `option http-buffer-request` gerendert werden:\n%s", on)
}
if idxFilter < 0 {
t.Fatalf("mit WAF muss der SPOE-Filter gerendert werden")
}
if idxBuf > idxFilter {
t.Errorf("`option http-buffer-request` muss VOR dem SPOE-Filter stehen (buf=%d filter=%d)", idxBuf, idxFilter)
}
// Die SPOE-Message muss den Body an den Agent schicken, sonst kann
// Coraza ihn nicht inspizieren.
if !strings.Contains(spoeCfg, "body=req.body") {
t.Errorf("spoeCfg muss `body=req.body` an den WAF-Agent senden:\n%s", spoeCfg)
}
}
func TestRender_MultiServerPool(t *testing.T) { func TestRender_MultiServerPool(t *testing.T) {
v := View{ v := View{
Backends: []BackendView{ Backends: []BackendView{

View File

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

View File

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

View File

@@ -2,21 +2,32 @@ global_defs {
router_id {{ .RouterID }} router_id {{ .RouterID }}
script_user root script_user root
enable_script_security enable_script_security
# GARP: beim Master-Wechsel Gratuitous-ARP forciert senden (repeat) UND
# periodisch auffrischen (master_refresh) sonst lässt der Upstream-Switch
# die VIP-MAC altern und die Failover-IP wird nach Minuten unerreichbar.
# Ergänzt durch den Priming-Ping in keepalived-master.sh (Traffic AUS der VIP).
# (vrrp_garp_interval/gna NICHT setzen: keepalived 2.3.x lehnt 0 ab, Default passt.)
vrrp_garp_master_repeat 5
vrrp_garp_master_refresh 60
} }
{{/* KEIN weight: in einer vrrp_sync_group ignoriert keepalived gewichtete
Track-Scripts ("ignoring tracked script ... with weights due to SYNC
group") die Health-Checks wären wirkungslos. Ohne weight wirken sie
als binäre FAULT-Trigger: schlägt ein Check fall-mal in Folge fehl,
geht die Instanz (und via Sync-Group der ganze Node) in FAULT der
gesunde Peer übernimmt. Für einen 2-Node-Cluster die saubere Semantik. */}}
vrrp_script chk_edgeguard { vrrp_script chk_edgeguard {
script "/usr/lib/edgeguard/keepalived-check.sh" script "/usr/lib/edgeguard/keepalived-check.sh"
interval 2 interval 2
weight -50 fall 8
fall 3
rise 2 rise 2
} }
{{ if .GWCheckIP }} {{ if .GWCheckIP }}
vrrp_script chk_gateway { vrrp_script chk_gateway {
script "/usr/lib/edgeguard/keepalived-gw-check.sh {{ .GWCheckIP }}" script "/usr/lib/edgeguard/keepalived-gw-check.sh {{ .GWCheckIP }}"
interval 5 interval 5
weight -110 fall 5
fall 2
rise 2 rise 2
} }
{{ end }} {{ end }}
@@ -33,7 +44,8 @@ vrrp_instance VI_1 {
interface {{ .Interface }} interface {{ .Interface }}
virtual_router_id {{ .RouterID }} virtual_router_id {{ .RouterID }}
priority {{ .Priority }} priority {{ .Priority }}
advert_int 1 advert_int 2
{{ if .PreemptDelay }}preempt_delay {{ .PreemptDelay }}{{ else }}nopreempt{{ end }}
{{ if .SrcIP }} unicast_src_ip {{ .SrcIP }} {{ if .SrcIP }} unicast_src_ip {{ .SrcIP }}
unicast_peer { unicast_peer {
{{ .PeerIP }} {{ .PeerIP }}
@@ -59,7 +71,8 @@ vrrp_instance VI_HB {
interface {{ .HBInterface }} interface {{ .HBInterface }}
virtual_router_id {{ .HBRouterID }} virtual_router_id {{ .HBRouterID }}
priority {{ .Priority }} priority {{ .Priority }}
advert_int 1 advert_int 2
{{ if .PreemptDelay }}preempt_delay {{ .PreemptDelay }}{{ else }}nopreempt{{ end }}
{{ if .HBSrcIP }} unicast_src_ip {{ .HBSrcIP }} {{ if .HBSrcIP }} unicast_src_ip {{ .HBSrcIP }}
unicast_peer { unicast_peer {
{{ .HBPeerIP }} {{ .HBPeerIP }}

View File

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

View File

@@ -0,0 +1,135 @@
package keepalived
import (
"bytes"
"strings"
"testing"
"git.netcell-it.de/projekte/edgeguard-native/internal/models"
)
func render(t *testing.T, v View) string {
t.Helper()
var buf bytes.Buffer
if err := tpl.Execute(&buf, v); err != nil {
t.Fatalf("template execute: %v", err)
}
return buf.String()
}
func testView() View {
return View{
State: "BACKUP", Interface: "eth0", RouterID: 51, Priority: 200,
SrcIP: "89.163.205.6", PeerIP: "89.163.205.8", AuthPass: "edgeguard",
VIPs: []VIPEntry{{Address: "89.163.205.100", Prefix: 24, Device: "eth0"}},
HBInterface: "ens19", HBSrcIP: "169.254.0.1", HBPeerIP: "169.254.0.2", HBRouterID: 52,
GWCheckIP: "89.163.205.1", PreemptDelay: 120,
}
}
// VIP-Affinität zum PG-Primary (Incident-2026-08-03-Fix): mit PreemptDelay
// tragen BEIDE Instanzen `preempt_delay N` statt nopreempt, damit der
// bevorzugte Node die VIP nach Erholung zurückholt — aber erst nach N Sekunden
// Stabilität. Kein Node darf `state MASTER` starten (sonst kein sauberes
// Election). Preempt ist nur sicher, WEIL keepalived-check.sh haproxy-Bereit-
// schaft (aktiv + :443) mitprüft (siehe dortiger Kommentar).
func TestTemplatePreemptDelayOnBothInstances(t *testing.T) {
out := render(t, testView())
if c := strings.Count(out, "preempt_delay 120"); c != 2 {
t.Fatalf("erwarte preempt_delay 120 in VI_1 UND VI_HB (2×), gefunden: %d\n%s", c, out)
}
if strings.Contains(out, "nopreempt") {
t.Fatalf("bei PreemptDelay>0 darf KEIN nopreempt gerendert werden:\n%s", out)
}
if strings.Contains(out, "state MASTER") {
t.Fatalf("kein Node darf state MASTER starten:\n%s", out)
}
if c := strings.Count(out, "state BACKUP"); c != 2 {
t.Fatalf("erwarte state BACKUP in beiden Instanzen, gefunden: %d", c)
}
}
// Ohne PreemptDelay (==0) fällt das Template auf nopreempt zurück (Node bleibt
// Backup, keine VIP-Rückkehr) — der sichere Default, falls Preempt je aus soll.
func TestTemplateFallsBackToNopreempt(t *testing.T) {
v := testView()
v.PreemptDelay = 0
out := render(t, v)
if c := strings.Count(out, "nopreempt"); c != 2 {
t.Fatalf("erwarte nopreempt in beiden Instanzen (2×) bei PreemptDelay=0, gefunden: %d\n%s", c, out)
}
if strings.Contains(out, "preempt_delay") {
t.Fatalf("bei PreemptDelay=0 darf KEIN preempt_delay gerendert werden:\n%s", out)
}
}
// GARP muss forciert + periodic aufgefrischt werden, sonst altert die
// VIP-MAC am Upstream-Switch und die Failover-IP wird unerreichbar.
func TestTemplateGARPRefresh(t *testing.T) {
out := render(t, testView())
for _, want := range []string{"vrrp_garp_master_refresh", "vrrp_garp_master_repeat"} {
if !strings.Contains(out, want) {
t.Fatalf("global_defs sollte %q enthalten:\n%s", want, out)
}
}
}
// advert_int 2 (statt 1): Master-Down ~6s — reißt nicht bei kurzen
// VM-/Heartbeat-Hiccups (Flapping-Schutz im virtualisierten Cluster).
func TestTemplateAdvertInt(t *testing.T) {
out := render(t, testView())
if strings.Contains(out, "advert_int 1\n") {
t.Fatalf("advert_int sollte 2 sein (nicht 1):\n%s", out)
}
if c := strings.Count(out, "advert_int 2"); c != 2 {
t.Fatalf("erwarte advert_int 2 in beiden Instanzen, gefunden: %d", c)
}
}
// Track-Scripts dürfen KEIN weight haben: in einer vrrp_sync_group ignoriert
// keepalived gewichtete Scripts → Health-Checks wären wirkungslos. Ohne weight
// wirken sie als FAULT-Trigger.
func TestTemplateTrackScriptsUnweighted(t *testing.T) {
out := render(t, testView())
if strings.Contains(out, "weight") {
t.Fatalf("Track-Scripts dürfen kein weight tragen (Sync-Group ignoriert sie sonst):\n%s", out)
}
}
// gw-Check darf nicht zu zucken (fall 5, nicht fall 2) — ein kurzer Upstream-
// Blip soll keinen Failover erzwingen.
func TestTemplateGatewayCheckNotTwitchy(t *testing.T) {
out := render(t, testView())
if !strings.Contains(out, "fall 5") {
t.Fatalf("chk_gateway sollte fall 5 nutzen:\n%s", out)
}
}
// buildView: State immer BACKUP, Priorität aus pg_role.
func TestBuildViewStateAlwaysBackup(t *testing.T) {
g := &generator{localID: "n1"}
cs := &models.ClusterSettings{VRRPRouterID: 51}
pub := "89.163.205.6"
cases := []struct {
pgRole, role string
wantPrio int
}{
{"primary", "primary", 200},
{"standby", "primary", 100},
{"", "primary", 200},
{"", "", 100},
}
for _, c := range cases {
local := &models.HANode{ID: "n1", PGRole: c.pgRole, Role: c.role, PublicIP: &pub}
v := g.buildView(cs, nil, local, nil)
if v.State != "BACKUP" {
t.Errorf("pg_role=%q role=%q: State=%q, erwarte immer BACKUP", c.pgRole, c.role, v.State)
}
if v.Priority != c.wantPrio {
t.Errorf("pg_role=%q role=%q: Priority=%d, erwarte %d", c.pgRole, c.role, v.Priority, c.wantPrio)
}
if v.PreemptDelay != 120 {
t.Errorf("pg_role=%q role=%q: PreemptDelay=%d, erwarte 120", c.pgRole, c.role, v.PreemptDelay)
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -55,7 +55,7 @@ func PackageVersions(ctx context.Context, force bool) map[string]string {
defer cancel() defer cancel()
for _, pkg := range edgePackages { for _, pkg := range edgePackages {
installed, candidate := aptCachePolicy(policyCtx, pkg) installed, candidate := aptCachePolicy(policyCtx, pkg) //nolint:contextcheck // detached by design — Cache-Read überlebt Request-Cancel
out[pkg+"_installed"] = installed out[pkg+"_installed"] = installed
out[pkg+"_available"] = candidate out[pkg+"_available"] = candidate
} }
@@ -110,7 +110,7 @@ func refreshInternal(ctx context.Context, force bool) {
} }
// StartBackgroundRefresh: Fire-and-Forget-Goroutine die einmal beim // StartBackgroundRefresh: Fire-and-Forget-Goroutine die einmal beim
// Start + dann periodisch apt-get update fährt, damit der Apt-Cache // Start + dann periodic apt-get update fährt, damit der Apt-Cache
// auch ohne UI-Traffic frisch bleibt. So zeigt der Banner kurz nach // auch ohne UI-Traffic frisch bleibt. So zeigt der Banner kurz nach
// `make publish` ein verfügbares Update, statt 5 min auf die nächste // `make publish` ein verfügbares Update, statt 5 min auf die nächste
// UI-Poll-Welle zu warten. // UI-Poll-Welle zu warten.
@@ -237,3 +237,61 @@ func AutoUpdateEnabled() bool {
_, err := os.Stat(AutoUpdateConfPath) _, err := os.Stat(AutoUpdateConfPath)
return err == nil return err == nil
} }
// ── Update-Kanal (stable/testing) ──────────────────────────────────────
//
// Kanal-Modell 1:1 von enconf übernommen: Suite = OS-Codename (trixie),
// Komponente = Kanal. Kein eigenes Config-File — die sources.list-Zeile
// selbst ist die einzige Quelle der Wahrheit (siehe scripts/install.sh
// setup_repo(), das dieselbe Zeile beim Erstinstall schreibt).
// SourcesListPath: vom Installer angelegte apt-Quelle. Root-owned wie
// AutoUpdateConfPath — Schreibzugriff nur via sudo tee (Sudoers-Pin im
// postinst).
const SourcesListPath = "/etc/apt/sources.list.d/edgeguard.list"
const sourcesListTemplate = "deb [signed-by=/etc/apt/keyrings/nmg.asc] " +
"https://git.netcell-it.de/api/packages/projekte/debian trixie %s\n"
// Channel liest den aktuell konfigurierten Update-Kanal aus dem letzten
// Feld der deb-Zeile. Default "stable" wenn die Datei fehlt oder das
// letzte Feld kein bekannter Kanal ist (Fail-safe — nie stillschweigend
// "testing" annehmen).
func Channel() string {
data, err := os.ReadFile(SourcesListPath)
if err != nil {
return "stable"
}
for _, raw := range strings.Split(string(data), "\n") {
line := strings.TrimSpace(raw)
if !strings.HasPrefix(line, "deb ") {
continue
}
fields := strings.Fields(line)
if len(fields) == 0 {
continue
}
switch fields[len(fields)-1] {
case "stable", "testing":
return fields[len(fields)-1]
}
}
return "stable"
}
// SetChannel schreibt die sources.list-Zeile mit dem neuen Kanal und
// refresht den apt-Cache sofort — sonst zeigt der Update-Banner bis zum
// nächsten 5-min-Throttle-Fenster noch den alten Kanal-Stand.
func SetChannel(ctx context.Context, channel string) error {
if channel != "stable" && channel != "testing" {
return fmt.Errorf("apt: unknown channel %q (expected stable|testing)", channel)
}
body := fmt.Sprintf(sourcesListTemplate, channel)
cmd := exec.Command("sudo", "-n", "/usr/bin/tee", SourcesListPath)
cmd.Stdin = strings.NewReader(body)
if out, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("sudo tee %s: %w: %s", SourcesListPath, err, strings.TrimSpace(string(out)))
}
RefreshNow(ctx)
return nil
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -23,7 +23,7 @@ type Claims struct {
} }
// Authenticator ist der testbare Seam: Aufbau der Auth-URL und der // Authenticator ist der testbare Seam: Aufbau der Auth-URL und der
// Code-Exchange inkl. ID-Token-Verifikation + Claim-Extraktion. Der // Code-Exchange inkl. ID-Token-Verification + Claim-Extraktion. Der
// Handler hängt nur hieran, sodass Tests einen Fake injizieren können. // Handler hängt nur hieran, sodass Tests einen Fake injizieren können.
type Authenticator interface { type Authenticator interface {
// AuthCodeURL baut die Redirect-URL zum IdP (state + nonce + PKCE-Challenge). // AuthCodeURL baut die Redirect-URL zum IdP (state + nonce + PKCE-Challenge).

View File

@@ -1,4 +1,4 @@
// Package oidc kapselt die OIDC/Keycloak-SSO-Konfiguration (Singleton- // Package oidc kapselt die OIDC/Keycloak-SSO-Configuration (Singleton-
// Settings + verschlüsseltes Client-Secret) und einen lazy aufgebauten // Settings + verschlüsseltes Client-Secret) und einen lazy aufgebauten
// OIDC-Provider/Verifier. Login-Flow-State ist stateless (signiertes // OIDC-Provider/Verifier. Login-Flow-State ist stateless (signiertes
// Cookie im Handler), daher hält dieses Paket keinen Request-State. // Cookie im Handler), daher hält dieses Paket keinen Request-State.

View File

@@ -29,7 +29,7 @@ const masterKeyLen = 32
const DefaultMasterKeyPath = "/var/lib/edgeguard/.master_key" const DefaultMasterKeyPath = "/var/lib/edgeguard/.master_key"
// Box uses AES-256-GCM with a static master key to seal/unseal // Box uses AES-256-GCM with a static master key to seal/unseal
// values. Concurrency-safe; the cipher is initialised once. // values. Concurrency-safe; the cipher is initialized once.
type Box struct { type Box struct {
once sync.Once once sync.Once
aead cipher.AEAD aead cipher.AEAD

View File

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

View File

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

View File

@@ -22,7 +22,7 @@ func New(pool *pgxpool.Pool) *Repo { return &Repo{Pool: pool} }
const baseSelect = ` const baseSelect = `
SELECT id, domain_id, enabled, mode, paranoia_level, SELECT id, domain_id, enabled, mode, paranoia_level,
rule_exclusions, exclusion_notes, trusted_proxies, custom_rules, updated_at rule_exclusions, crs_plugins, app_profiles, exclusion_notes, trusted_proxies, custom_rules, updated_at
FROM waf_configs FROM waf_configs
` `
@@ -30,7 +30,7 @@ func scan(row pgx.Row) (*models.WafConfig, error) {
var c models.WafConfig var c models.WafConfig
err := row.Scan( err := row.Scan(
&c.ID, &c.DomainID, &c.Enabled, &c.Mode, &c.ParanoiaLevel, &c.ID, &c.DomainID, &c.Enabled, &c.Mode, &c.ParanoiaLevel,
&c.RuleExclusions, &c.ExclusionNotes, &c.TrustedProxies, &c.CustomRules, &c.UpdatedAt, &c.RuleExclusions, &c.CRSPlugins, &c.AppProfiles, &c.ExclusionNotes, &c.TrustedProxies, &c.CustomRules, &c.UpdatedAt,
) )
if err != nil { if err != nil {
return nil, err return nil, err
@@ -82,22 +82,24 @@ func (r *Repo) Upsert(ctx context.Context, c models.WafConfig) (*models.WafConfi
row := r.Pool.QueryRow(ctx, ` row := r.Pool.QueryRow(ctx, `
INSERT INTO waf_configs INSERT INTO waf_configs
(domain_id, enabled, mode, paranoia_level, (domain_id, enabled, mode, paranoia_level,
rule_exclusions, exclusion_notes, trusted_proxies, custom_rules, updated_at) rule_exclusions, crs_plugins, app_profiles, exclusion_notes, trusted_proxies, custom_rules, updated_at)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11)
ON CONFLICT (domain_id) DO UPDATE SET ON CONFLICT (domain_id) DO UPDATE SET
enabled = EXCLUDED.enabled, enabled = EXCLUDED.enabled,
mode = EXCLUDED.mode, mode = EXCLUDED.mode,
paranoia_level = EXCLUDED.paranoia_level, paranoia_level = EXCLUDED.paranoia_level,
rule_exclusions = EXCLUDED.rule_exclusions, rule_exclusions = EXCLUDED.rule_exclusions,
crs_plugins = EXCLUDED.crs_plugins,
app_profiles = EXCLUDED.app_profiles,
exclusion_notes = EXCLUDED.exclusion_notes, exclusion_notes = EXCLUDED.exclusion_notes,
trusted_proxies = EXCLUDED.trusted_proxies, trusted_proxies = EXCLUDED.trusted_proxies,
custom_rules = EXCLUDED.custom_rules, custom_rules = EXCLUDED.custom_rules,
updated_at = EXCLUDED.updated_at updated_at = EXCLUDED.updated_at
RETURNING id, domain_id, enabled, mode, paranoia_level, RETURNING id, domain_id, enabled, mode, paranoia_level,
rule_exclusions, exclusion_notes, trusted_proxies, custom_rules, updated_at rule_exclusions, crs_plugins, app_profiles, exclusion_notes, trusted_proxies, custom_rules, updated_at
`, `,
c.DomainID, c.Enabled, c.Mode, c.ParanoiaLevel, c.DomainID, c.Enabled, c.Mode, c.ParanoiaLevel,
c.RuleExclusions, c.ExclusionNotes, c.TrustedProxies, c.CustomRules, c.UpdatedAt, c.RuleExclusions, c.CRSPlugins, c.AppProfiles, c.ExclusionNotes, c.TrustedProxies, c.CustomRules, c.UpdatedAt,
) )
return scan(row) return scan(row)
} }
@@ -179,9 +181,14 @@ func (r *Repo) ListAlerts(ctx context.Context, domainID *int64, limit int) ([]Wa
} }
// PurgeAlerts removes alerts older than the given number of days. // PurgeAlerts removes alerts older than the given number of days.
//
// make_interval(days => $1) nimmt $1 als int — sauber typisiert. Der
// frühere ($1 || ' days')::interval-Ansatz erzwang $1 als text; pgx
// bekam aber einen int und scheiterte mit einem Encode-Fehler zur
// Laufzeit (gleiche Klasse wie der audit-Cleanup-Bug, v1.3.0).
func (r *Repo) PurgeAlerts(ctx context.Context, olderThanDays int) error { func (r *Repo) PurgeAlerts(ctx context.Context, olderThanDays int) error {
_, err := r.Pool.Exec(ctx, _, err := r.Pool.Exec(ctx,
`DELETE FROM waf_alerts WHERE created_at < NOW() - ($1 || ' days')::interval`, `DELETE FROM waf_alerts WHERE created_at < NOW() - make_interval(days => $1)`,
olderThanDays, olderThanDays,
) )
return err return err
@@ -195,11 +202,23 @@ type DomainConfigPair struct {
// ListAllWithDomain returns all WAF configs joined with their domain name. // ListAllWithDomain returns all WAF configs joined with their domain name.
// Used by the WAF agent to build the hostname→engine mapping. // Used by the WAF agent to build the hostname→engine mapping.
//
// Wichtig: crs_plugins UND app_profiles werden hier geladen — früher fehlte
// crs_plugins, dadurch waren die gewählten Built-in-CRS-Plugins im laufenden
// Agent nie aktiv. app_profiles (benutzerdefinierte Rule-ID-Bündel) werden hier
// in die effektiven rule_exclusions der Domain gemischt und ihr updated_at
// fließt in das effektive updated_at ein — so baut der Manager die Engine neu,
// sobald ein Profil bearbeitet wird (der Rebuild-Trigger hängt an updated_at).
func (r *Repo) ListAllWithDomain(ctx context.Context) ([]DomainConfigPair, error) { func (r *Repo) ListAllWithDomain(ctx context.Context) ([]DomainConfigPair, error) {
profiles, err := r.profilesByName(ctx)
if err != nil {
return nil, err
}
rows, err := r.Pool.Query(ctx, ` rows, err := r.Pool.Query(ctx, `
SELECT d.name, SELECT d.name,
w.id, w.domain_id, w.enabled, w.mode, w.paranoia_level, w.id, w.domain_id, w.enabled, w.mode, w.paranoia_level,
w.rule_exclusions, w.trusted_proxies, w.custom_rules, w.updated_at w.rule_exclusions, w.crs_plugins, w.app_profiles,
w.trusted_proxies, w.custom_rules, w.updated_at
FROM waf_configs w FROM waf_configs w
JOIN domains d ON d.id = w.domain_id JOIN domains d ON d.id = w.domain_id
WHERE d.active = true WHERE d.active = true
@@ -216,12 +235,49 @@ func (r *Repo) ListAllWithDomain(ctx context.Context) ([]DomainConfigPair, error
if err := rows.Scan( if err := rows.Scan(
&p.Hostname, &p.Hostname,
&c.ID, &c.DomainID, &c.Enabled, &c.Mode, &c.ParanoiaLevel, &c.ID, &c.DomainID, &c.Enabled, &c.Mode, &c.ParanoiaLevel,
&c.RuleExclusions, &c.TrustedProxies, &c.CustomRules, &c.UpdatedAt, &c.RuleExclusions, &c.CRSPlugins, &c.AppProfiles,
&c.TrustedProxies, &c.CustomRules, &c.UpdatedAt,
); err != nil { ); err != nil {
return nil, err return nil, err
} }
c.RuleExclusions, c.UpdatedAt = mergeProfileExclusions(c.RuleExclusions, c.UpdatedAt, c.AppProfiles, profiles)
p.Config = c p.Config = c
out = append(out, p) out = append(out, p)
} }
return out, rows.Err() return out, rows.Err()
} }
// mergeProfileExclusions vereint die domain-eigenen Ausnahmen mit denen aller
// zugewiesenen App-Profile (dedupliziert, stabile Reihenfolge) und hebt das
// effektive updated_at auf das Maximum aus Config + zugewiesenen Profilen an.
// Pure Funktion (leicht testbar, keine DB).
func mergeProfileExclusions(own []string, updatedAt time.Time, assigned []string, profiles map[string]models.WafAppProfile) ([]string, time.Time) {
if len(assigned) == 0 {
return own, updatedAt
}
seen := make(map[string]struct{}, len(own))
merged := make([]string, 0, len(own))
for _, id := range own {
if _, ok := seen[id]; !ok {
seen[id] = struct{}{}
merged = append(merged, id)
}
}
effUpdated := updatedAt
for _, name := range assigned {
prof, ok := profiles[name]
if !ok {
continue // unbekanntes/gelöschtes Profil defensiv ignorieren
}
if prof.UpdatedAt.After(effUpdated) {
effUpdated = prof.UpdatedAt
}
for _, id := range prof.RuleExclusions {
if _, ok := seen[id]; !ok {
seen[id] = struct{}{}
merged = append(merged, id)
}
}
}
return merged, effUpdated
}

View File

@@ -15,12 +15,12 @@ import (
"git.netcell-it.de/projekte/edgeguard-native/internal/services/secrets" "git.netcell-it.de/projekte/edgeguard-native/internal/services/secrets"
) )
// ImportResult summarises what an Import call did so the CLI can // ImportResult summarizes what an Import call did so the CLI can
// report it back to the operator. // report it back to the operator.
type ImportResult struct { type ImportResult struct {
IfacesAdded int `json:"ifaces_added"` IfacesAdded int `json:"ifaces_added"`
PeersAdded int `json:"peers_added"` PeersAdded int `json:"peers_added"`
Skipped []string `json:"skipped,omitempty"` // ifaces already present, with reason Skipped []string `json:"skipped,omitempty"` // ifaces already present, with reason
} }
// Importer takes existing /etc/wireguard/*.conf files and translates // Importer takes existing /etc/wireguard/*.conf files and translates
@@ -292,7 +292,7 @@ func parseWGConf(path string) (*parsedConf, error) {
if err != nil { if err != nil {
return nil, err return nil, err
} }
defer f.Close() defer func() { _ = f.Close() }()
var ( var (
out parsedConf out parsedConf

View File

@@ -36,10 +36,10 @@ func buildDirectives(cfg models.WafConfig, crsDir string) string {
sb.WriteString("SecRequestBodyAccess On\n") sb.WriteString("SecRequestBodyAccess On\n")
sb.WriteString("SecResponseBodyAccess Off\n") sb.WriteString("SecResponseBodyAccess Off\n")
sb.WriteString("SecRequestBodyLimit 13107200\n") // 12.5 MB sb.WriteString("SecRequestBodyLimit 13107200\n") // 12.5 MB
sb.WriteString("SecRequestBodyInMemoryLimit 131072\n") // 128 KB sb.WriteString("SecRequestBodyInMemoryLimit 131072\n") // 128 KB
sb.WriteString(fmt.Sprintf("SecRuleEngine %s\n", ruleEngineMode(cfg.Mode))) fmt.Fprintf(&sb, "SecRuleEngine %s\n", ruleEngineMode(cfg.Mode))
if crsDir != "" && crsAvailable(crsDir) { if crsDir != "" && crsAvailable(crsDir) {
// Paranoia level MUST be set before CRS rules are included. // Paranoia level MUST be set before CRS rules are included.
@@ -47,22 +47,31 @@ func buildDirectives(cfg models.WafConfig, crsDir string) string {
if pl < 1 || pl > 4 { if pl < 1 || pl > 4 {
pl = 1 pl = 1
} }
sb.WriteString(fmt.Sprintf( fmt.Fprintf(&sb, "SecAction \"id:900000,phase:1,nolog,pass,t:none,setvar:tx.paranoia_level=%d\"\n", pl)
"SecAction \"id:900000,phase:1,nolog,pass,t:none,setvar:tx.paranoia_level=%d\"\n", pl, includeIfExists(&sb, filepath.Join(crsDir, "crs-setup.conf"))
))
setupConf := filepath.Join(crsDir, "crs-setup.conf") // CRS-App-Exclusion-Plugins: config + before laufen VOR den CRS-Rules
if _, err := os.Stat(setupConf); err == nil { // (setzen Enable-Vars + pfad-scoped ctl:ruleRemoveById), after DANACH —
sb.WriteString(fmt.Sprintf("Include %s\n", setupConf)) // exakt nach OWASP-CRS-Plugin-Spec. Es werden NUR die für DIESE Domain
// gewählten Plugins inkludiert (per-Domain, nicht global).
plugins := resolveCRSPlugins(cfg.CRSPlugins)
for _, prefix := range plugins {
includeIfExists(&sb, filepath.Join(crsDir, "plugins", prefix+"-config.conf"))
includeIfExists(&sb, filepath.Join(crsDir, "plugins", prefix+"-before.conf"))
}
// rules/*.conf ist ein Glob (kein Stat) — crsAvailable() hat oben bereits
// bestätigt, dass mind. eine .conf existiert.
fmt.Fprintf(&sb, "Include %s\n", filepath.Join(crsDir, "rules", "*.conf"))
for _, prefix := range plugins {
includeIfExists(&sb, filepath.Join(crsDir, "plugins", prefix+"-after.conf"))
} }
rulesGlob := filepath.Join(crsDir, "rules", "*.conf")
sb.WriteString(fmt.Sprintf("Include %s\n", rulesGlob))
} }
// Rule exclusions (applied after CRS load so they override CRS). // Rule exclusions (applied after CRS load so they override CRS).
for _, id := range cfg.RuleExclusions { for _, id := range cfg.RuleExclusions {
id = strings.TrimSpace(id) id = strings.TrimSpace(id)
if id != "" { if id != "" {
sb.WriteString(fmt.Sprintf("SecRuleRemoveById %s\n", id)) fmt.Fprintf(&sb, "SecRuleRemoveById %s\n", id)
} }
} }
@@ -80,6 +89,35 @@ func buildDirectives(cfg models.WafConfig, crsDir string) string {
return sb.String() return sb.String()
} }
// KnownCRSPlugins mappt den kurzen Plugin-Namen (gespeichert in
// waf_configs.crs_plugins, im UI gewählt) auf sein Datei-Prefix in
// <crsDir>/plugins/. Nur diese werden paketiert (postinst) und akzeptiert.
var KnownCRSPlugins = map[string]string{
"nextcloud": "nextcloud-rule-exclusions",
"wordpress": "wordpress-rule-exclusions",
"drupal": "drupal-rule-exclusions",
}
// resolveCRSPlugins mappt gewählte Plugin-Namen auf ihre Datei-Prefixe und
// filtert unbekannte/leere raus — defensiv, nie ungültige Includes rendern.
func resolveCRSPlugins(names []string) []string {
out := make([]string, 0, len(names))
for _, n := range names {
if prefix, ok := KnownCRSPlugins[strings.TrimSpace(n)]; ok {
out = append(out, prefix)
}
}
return out
}
// includeIfExists rendert eine Include-Zeile nur, wenn die Datei existiert — so
// bricht ein gewähltes-aber-nicht-installiertes Plugin die Config nicht.
func includeIfExists(sb *strings.Builder, path string) {
if _, err := os.Stat(path); err == nil {
fmt.Fprintf(sb, "Include %s\n", path)
}
}
func ruleEngineMode(mode string) string { func ruleEngineMode(mode string) string {
switch mode { switch mode {
case "blocking": case "blocking":

View File

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

View File

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

View File

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

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