36 Commits

Author SHA1 Message Date
noroot
bb19562bc1 chore(release): v1.3.29 stable 2026-09-11 10:33:34 +02:00
noroot
a31c94f9b8 fix(ha): squid/unbound liefen auf dem Standby nicht — ip_nonlocal_bind
squid und unbound lauschen auf den VLAN-Gateway-VIPs. Bei
ip_nonlocal_bind=0 kann ein Node diese Adressen nur binden, waehrend er
die VIP haelt. Bootet ein Node als Standby, scheitert der Start deshalb
mit "FATAL: Unable to open HTTP Socket" und die Unit bleibt dauerhaft
`failed` — systemd versucht es nicht erneut, keepalived-master.sh startet
sie erst bei VIP-Uebernahme (Kaltstart im Umschaltmoment).

Zwei Probleme daran: der Dauer-`failed`-Zustand ist nicht von einem
echten Ausfall zu unterscheiden, und beim Failover kommen die Dienste
erst nach dem Start hoch statt sofort bereit zu stehen.

Mit nonlocal_bind laufen beide auf beiden Nodes durch. Traffic bekommt
weiterhin nur der Node, der die VIP per ARP haelt — die VIP-Wahl selbst
ist nicht betroffen, keepalived-check.sh prueft ausschliesslich
edgeguard-api, haproxy und den :443-Bind.

Zusaetzlich repariert der postinst gezielt Units, die enabled UND failed
sind (also genau den obigen Fall); laufende oder bewusst deaktivierte
Dienste bleiben unangetastet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 10:32:54 +02:00
noroot
2c72a82a91 chore(release): v1.3.28 stable 2026-09-11 10:24:22 +02:00
noroot
ed419c1f5f fix(ui): Cluster-Karte las sich als Widerspruch zur VIP-Karte
Die Dashboard-Cluster-Karte zeigte ha_nodes.role als nacktes "primary".
Das ist die DB-/Cluster-Rolle (wohin Schreibzugriffe gehen); sie wandert
bewusst NICHT mit der VIP und aendert sich nur durch `edgeguard-ctl
promote`. Direkt daneben steht aber die VIP/VRRP-Karte mit "BACKUP" —
waehrend eines Failovers (z.B. Node-Reboot) sah der Operator also
gleichzeitig "primary" und "BACKUP" und musste raten, was stimmt.

Die Daten waren korrekt, nur das Label mehrdeutig: jetzt "DB-Primary"
statt "primary", plus Tooltip der den Unterschied zur VRRP-Rolle
benennt. Auf der Cluster-Seite bleibt es unveraendert — dort steht die
Spalte direkt neben pg_role, der Kontext erklaert sich dort selbst.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 10:23:36 +02:00
noroot
95f2238588 chore(release): v1.3.27 stable 2026-09-11 09:54:32 +02:00
noroot
de83936fce chore(deps): Go 1.27.1, alle Module und Frontend-Pakete aktualisiert
Backend:
  - Go-Toolchain 1.26.4/1.26.6 -> 1.27.1
  - alle 9 veralteten direkten Module auf latest (gin 1.10->1.12,
    pgx 5.9->5.11, go-oidc 3.18->3.21, minio 7.1->7.3, goose 3.27->3.28,
    haproxy-go 0.0.8->0.1.1, sftp, x/crypto 0.57, x/oauth2 0.37)
  - quic-go 0.59.0 -> 0.59.1: GO-2026-5676 (HTTP/3 QPACK Trailer Memory
    Exhaustion) kam mit dem Modul-Update rein und wurde vom govulncheck-
    Gate gefangen.

Toolchain-Falle im Makefile (der eigentliche Knackpunkt): golangci-lint
und govulncheck verweigern die Arbeit, sobald go.mod eine neuere Go-Version
zielt als die, mit der sie selbst gebaut wurden — govulncheck meldete dann
"package requires newer Go version" fuer JEDES Paket, statt zu scannen. Ein
gruenes Ergebnis waere hier also nicht "keine Lücken", sondern "gar nicht
geprüft" gewesen. Beide Targets pinnen jetzt GOTOOLCHAIN auf die go.mod-
Version und installieren neu, sobald die Build-Go-Version der vorhandenen
Binary abweicht. Der bisherige `command -v`-Check hat eine veraltete Binary
nie erneuert.

Frontend: antd 6.3->6.6.3, React 19.2->19.3, axios 1.16->1.20,
tanstack-query 5.100->5.102, react-router 7.15->7.18, zustand, icons,
i18next 25->26, react-i18next 16->17, eslint 9->10.

TypeScript bewusst auf 6.0.3 statt 7.0.2: typescript-eslint unterstuetzt
auch in der neuesten Release (8.70.0) nur `typescript <6.1.0`. Mit TS 7
baut das UI zwar, aber ESLint bricht komplett ab ("typescript-eslint does
not support TS 7.0") — das Frontend-Linting waere damit still weg. 6.0.3
ist die neueste Version innerhalb der unterstuetzten Range.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

View File

@@ -65,7 +65,10 @@ test-race:
CGO_ENABLED=1 $(GO) test $(GOFLAGS) -race ./...
GOBIN := $(shell $(GO) env GOPATH)/bin
GOLANGCI_VERSION := v2.12.2
GOLANGCI_VERSION := v2.13.2
# Ziel-Go-Version aus go.mod — golangci-lint MUSS mit genau dieser Toolchain
# gebaut sein (siehe golangci-Target).
GO_VERSION := $(shell awk '/^go /{print $$2; exit}' go.mod)
lint:
$(GO) vet ./...
@@ -74,15 +77,33 @@ lint:
# golangci-lint — HARTER Gate. Tool wird bei Bedarf auf pinned Version
# installiert; bricht ab, sobald ein Finding auftaucht (Bestand ist 0,
# Rollout abgeschlossen — siehe .golangci.yml).
# Befund 2026-09-11: golangci-lint verweigert den Dienst ("the Go language
# version used to build golangci-lint is lower than the targeted Go version"),
# sobald go.mod eine neuere Go-Version zielt als die, mit der der Linter
# gebaut wurde — und ein `go install` ohne GOTOOLCHAIN baut ihn mit der in
# SEINER go.mod geforderten (älteren) Version. Deshalb die Projekt-Toolchain
# pinnen. Der frühere `command -v`-Check hat ausserdem eine bereits
# installierte, veraltete Binary nie erneuert; jetzt entscheidet die
# tatsächliche Version + Build-Go-Version der Binary über die Neuinstallation.
golangci:
@command -v golangci-lint >/dev/null 2>&1 || GOFLAGS= $(GO) install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@$(GOLANGCI_VERSION)
@if ! "$(GOBIN)/golangci-lint" version 2>/dev/null | grep -q "has version $(patsubst v%,%,$(GOLANGCI_VERSION)) built with go$(GO_VERSION) "; then \
echo " -> installing golangci-lint $(GOLANGCI_VERSION) (built with go$(GO_VERSION))"; \
GOFLAGS= GOTOOLCHAIN=go$(GO_VERSION) $(GO) install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@$(GOLANGCI_VERSION); \
fi
@PATH="$(GOBIN):$$PATH" golangci-lint run --timeout 6m
# govulncheck — Go-Vuln-DB-Scan. HARTER Release-Gate: bricht ab, wenn der
# Code eine bekannte Vulnerability tatsächlich aufruft. Tool wird bei Bedarf
# automatisch installiert.
# Gleiche Toolchain-Falle wie bei golangci-lint (Befund 2026-09-11): ein
# `go install` ohne GOTOOLCHAIN baut govulncheck mit einer aelteren Go-Version,
# die dann "package requires newer Go version" fuer jedes Paket meldet statt zu
# scannen. Reinstall, sobald die Build-Go-Version der Binary abweicht.
vulncheck:
@command -v govulncheck >/dev/null 2>&1 || GOFLAGS= $(GO) install golang.org/x/vuln/cmd/govulncheck@latest
@if ! "$(GOBIN)/govulncheck" -version 2>/dev/null | grep -q "^Go: go$(GO_VERSION)$$"; then \
echo " -> installing govulncheck (built with go$(GO_VERSION))"; \
GOFLAGS= GOTOOLCHAIN=go$(GO_VERSION) $(GO) install golang.org/x/vuln/cmd/govulncheck@latest; \
fi
@PATH="$(GOBIN):$$PATH" govulncheck ./...
# Go-Quality-Baseline — läuft automatisch vor jedem Release (deb/publish).
@@ -104,7 +125,7 @@ ui:
@echo " -> management-ui (vite build, version $(VERSION))"
@cd management-ui && \
if [ -x "$$(command -v bun)" ]; then bun install --silent && bun run build; \
else npm install --silent && npm run build; fi
else npm install --include=dev --silent && npm run build; fi
deb-amd64: release-check build-linux-amd64 ui
@./scripts/apt-repo/build-package.sh amd64 $(VERSION)
@@ -114,17 +135,18 @@ deb-arm64: release-check build-linux-arm64 ui
deb: deb-amd64 deb-arm64
GITEA_DEB_URL := https://git.netcell-it.de/api/packages/projekte/debian/pool/trixie/main/upload
# Direktes `make publish` bleibt als Handnotbremse erhalten, veröffentlicht
# aber immer nach stable — für Testing-Releases + das Stable-Promotion-
# Gate (verify_channel_debs, Version-Bump, Git-Tag) scripts/release.sh nutzen.
publish-amd64: deb-amd64
@./scripts/apt-repo/publish.sh $(VERSION) amd64
@./scripts/apt-repo/publish.sh $(VERSION) amd64 stable
@echo " -> cleanup-old (keep last $${KEEP:-10})"
@./scripts/apt-repo/cleanup-old.sh
@./scripts/apt-repo/cleanup-old.sh stable
publish-arm64: deb-arm64
@./scripts/apt-repo/publish.sh $(VERSION) arm64
@./scripts/apt-repo/publish.sh $(VERSION) arm64 stable
@echo " -> cleanup-old (keep last $${KEEP:-10})"
@./scripts/apt-repo/cleanup-old.sh
@./scripts/apt-repo/cleanup-old.sh stable
publish: publish-amd64 publish-arm64

View File

@@ -1 +1 @@
1.3.3
1.3.29

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

71
go.mod
View File

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

176
go.sum
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -160,7 +160,7 @@ spoe-agent edgeguard-waf-agent
use-backend spoe-edgeguard-waf
spoe-message edgeguard-waf-req
args src=src method=method uri=url ver=req.ver headers=req.hdrs host=req.hdr(host)
args src=src method=method uri=url ver=req.ver headers=req.hdrs host=req.hdr(host) body=req.body
event on-frontend-http-request
`

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -5,31 +5,31 @@
"": {
"name": "edgeguard-management-ui",
"dependencies": {
"@ant-design/icons": "^6.1.0",
"@tanstack/react-query": "^5.95.2",
"antd": "^6.3.4",
"axios": "^1.13.6",
"i18next": "^25.10.9",
"@ant-design/icons": "^6.3.4",
"@tanstack/react-query": "^5.102.8",
"antd": "^6.6.3",
"axios": "^1.20.0",
"i18next": "^26.4.2",
"i18next-browser-languagedetector": "^8.2.1",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"react-i18next": "^16.6.6",
"react-router-dom": "^7.13.2",
"zustand": "^5.0.12",
"react": "^19.3.0",
"react-dom": "^19.3.0",
"react-i18next": "^17.0.13",
"react-router-dom": "^7.18.3",
"zustand": "^5.0.15",
},
"devDependencies": {
"@eslint/js": "^9.39.4",
"@types/node": "^24.12.2",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1",
"eslint": "^9.39.4",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.4.0",
"typescript": "~5.9.3",
"typescript-eslint": "^8.57.0",
"vite": "^8.0.1",
"@eslint/js": "^10.0.1",
"@types/node": "^24.13.4",
"@types/react": "^19.3.0",
"@types/react-dom": "^19.3.0",
"@vitejs/plugin-react": "^6.1.1",
"eslint": "^10.10.0",
"eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.6",
"globals": "^17.12.0",
"typescript": "6.0.3",
"typescript-eslint": "^8.70.0",
"vite": "^8.3.0",
},
},
},
@@ -42,73 +42,69 @@
"@ant-design/fast-color": ["@ant-design/fast-color@3.0.1", "", {}, "sha512-esKJegpW4nckh0o6kV3Tkb7NPIZYbPnnFxmQDUmL08ukXZAvV85TZBr70eGuke/CIArLaP6aw8lt9KILjnWuOw=="],
"@ant-design/icons": ["@ant-design/icons@6.2.2", "", { "dependencies": { "@ant-design/colors": "^8.0.1", "@ant-design/icons-svg": "^4.4.2", "@rc-component/util": "^1.10.1", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=16.0.0", "react-dom": ">=16.0.0" } }, "sha512-zlJtE7AMbG12TeYVPhtBXwNpFInNy8mjLzcIm+0BPw16/b8ODG87YJ1G37VIF5VFscdgfsf6EweAFPTobu/3iQ=="],
"@ant-design/icons": ["@ant-design/icons@6.3.4", "", { "dependencies": { "@ant-design/colors": "^8.0.1", "@ant-design/icons-svg": "^4.6.0", "@rc-component/util": "^1.11.0", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=16.0.0", "react-dom": ">=16.0.0" } }, "sha512-kDoUlppczVyCUTFthF6cHmPzMBYqNZQhu9j6EyIX/YIdM9j9CZNZoJUjzVw7/1v1VO8WwVMmPp15r/uwIAS0AQ=="],
"@ant-design/icons-svg": ["@ant-design/icons-svg@4.4.2", "", {}, "sha512-vHbT+zJEVzllwP+CM+ul7reTEfBR0vgxFe7+lREAsAA7YGsYpboiq2sQNeQeRvh09GfQgs/GyFEvZpJ9cLXpXA=="],
"@ant-design/icons-svg": ["@ant-design/icons-svg@4.6.0", "", {}, "sha512-PRomU725ABMf/lnQp5HiB7my1kjEbFY0D10N4lXYxK6TIB1gKjIVD5MRThpDaezLgw1D774J8eOeeDB0M6wHrQ=="],
"@ant-design/react-slick": ["@ant-design/react-slick@2.0.0", "", { "dependencies": { "@babel/runtime": "^7.28.4", "clsx": "^2.1.1", "json2mq": "^0.2.0", "throttle-debounce": "^5.0.0" }, "peerDependencies": { "react": "^0.14.0 || ^15.0.1 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^0.14.0 || ^15.0.1 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-HMS9sRoEmZey8LsE/Yo6+klhlzU12PisjrVcydW3So7RdklyEd2qehyU6a7Yp+OYN72mgsYs3NFCyP2lCPFVqg=="],
"@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="],
"@babel/code-frame": ["@babel/code-frame@7.29.7", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw=="],
"@babel/compat-data": ["@babel/compat-data@7.29.3", "", {}, "sha512-LIVqM46zQWZhj17qA8wb4nW/ixr2y1Nw+r1etiAWgRM6U1IqP+LNhL1yg440jYZR72jCWcWbLWzIosH+uP1fqg=="],
"@babel/compat-data": ["@babel/compat-data@7.29.7", "", {}, "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg=="],
"@babel/core": ["@babel/core@7.29.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-compilation-targets": "^7.28.6", "@babel/helper-module-transforms": "^7.28.6", "@babel/helpers": "^7.28.6", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/traverse": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA=="],
"@babel/core": ["@babel/core@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", "@babel/helper-compilation-targets": "^7.29.7", "@babel/helper-module-transforms": "^7.29.7", "@babel/helpers": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/template": "^7.29.7", "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA=="],
"@babel/generator": ["@babel/generator@7.29.1", "", { "dependencies": { "@babel/parser": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw=="],
"@babel/generator": ["@babel/generator@7.29.8", "", { "dependencies": { "@babel/parser": "^7.29.8", "@babel/types": "^7.29.8", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg=="],
"@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.28.6", "", { "dependencies": { "@babel/compat-data": "^7.28.6", "@babel/helper-validator-option": "^7.27.1", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA=="],
"@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.29.7", "", { "dependencies": { "@babel/compat-data": "^7.29.7", "@babel/helper-validator-option": "^7.29.7", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g=="],
"@babel/helper-globals": ["@babel/helper-globals@7.28.0", "", {}, "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw=="],
"@babel/helper-globals": ["@babel/helper-globals@7.29.7", "", {}, "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA=="],
"@babel/helper-module-imports": ["@babel/helper-module-imports@7.28.6", "", { "dependencies": { "@babel/traverse": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw=="],
"@babel/helper-module-imports": ["@babel/helper-module-imports@7.29.7", "", { "dependencies": { "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g=="],
"@babel/helper-module-transforms": ["@babel/helper-module-transforms@7.28.6", "", { "dependencies": { "@babel/helper-module-imports": "^7.28.6", "@babel/helper-validator-identifier": "^7.28.5", "@babel/traverse": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA=="],
"@babel/helper-module-transforms": ["@babel/helper-module-transforms@7.29.7", "", { "dependencies": { "@babel/helper-module-imports": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7", "@babel/traverse": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg=="],
"@babel/helper-string-parser": ["@babel/helper-string-parser@7.27.1", "", {}, "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA=="],
"@babel/helper-string-parser": ["@babel/helper-string-parser@7.29.7", "", {}, "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw=="],
"@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="],
"@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="],
"@babel/helper-validator-option": ["@babel/helper-validator-option@7.27.1", "", {}, "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg=="],
"@babel/helper-validator-option": ["@babel/helper-validator-option@7.29.7", "", {}, "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw=="],
"@babel/helpers": ["@babel/helpers@7.29.2", "", { "dependencies": { "@babel/template": "^7.28.6", "@babel/types": "^7.29.0" } }, "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw=="],
"@babel/helpers": ["@babel/helpers@7.29.7", "", { "dependencies": { "@babel/template": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg=="],
"@babel/parser": ["@babel/parser@7.29.3", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA=="],
"@babel/parser": ["@babel/parser@7.29.8", "", { "dependencies": { "@babel/types": "^7.29.8" }, "bin": "./bin/babel-parser.js" }, "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA=="],
"@babel/runtime": ["@babel/runtime@7.29.2", "", {}, "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g=="],
"@babel/runtime": ["@babel/runtime@7.29.7", "", {}, "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw=="],
"@babel/template": ["@babel/template@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/parser": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ=="],
"@babel/template": ["@babel/template@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg=="],
"@babel/traverse": ["@babel/traverse@7.29.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/types": "^7.29.0", "debug": "^4.3.1" } }, "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA=="],
"@babel/traverse": ["@babel/traverse@7.29.8", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.8", "@babel/helper-globals": "^7.29.7", "@babel/parser": "^7.29.8", "@babel/template": "^7.29.7", "@babel/types": "^7.29.8", "debug": "^4.3.1" } }, "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg=="],
"@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="],
"@babel/types": ["@babel/types@7.29.8", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg=="],
"@emnapi/core": ["@emnapi/core@1.10.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" } }, "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw=="],
"@cacheable/memory": ["@cacheable/memory@2.2.0", "", { "dependencies": { "@cacheable/utils": "^2.5.0", "@keyv/bigmap": "^1.3.1", "hookified": "^1.15.1", "keyv": "^5.6.0" } }, "sha512-CTLKqLItRCEixEAewD3/j9DB3/o96gpTPD4eJ1v+DGOlxZRZncRQkGYqqnAGCscYd6RNeXfGeiuCphsPtqyIfQ=="],
"@emnapi/runtime": ["@emnapi/runtime@1.10.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA=="],
"@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w=="],
"@cacheable/utils": ["@cacheable/utils@2.5.0", "", { "dependencies": { "hashery": "^1.5.1", "keyv": "^5.6.0" } }, "sha512-buipgOVDkkPXNR5+xBpDw7Zk2n1EvU7qBJCNUcL7rhQ//kfpOXPAvQ511Os0vpLYJ1pZnvudNytkQt2hst3wqA=="],
"@emotion/hash": ["@emotion/hash@0.8.0", "", {}, "sha512-kBJtf7PH6aWwZ6fka3zQ0p6SBYzx4fl1LoZXE2RrnYST9Xljm7WfKJrU4g/Xr3Beg72MLrp1AWNUmuYJTL7Cow=="],
"@emotion/unitless": ["@emotion/unitless@0.7.5", "", {}, "sha512-OWORNpfjMsSSUBVrRBVGECkhWcULOAJz9ZW8uK9qgxD+87M7jHRcvh/A96XXNhXTLmKcoYSQtBEX7lHMO7YRwg=="],
"@eslint-community/eslint-utils": ["@eslint-community/eslint-utils@4.9.1", "", { "dependencies": { "eslint-visitor-keys": "^3.4.3" }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ=="],
"@eslint-community/eslint-utils": ["@eslint-community/eslint-utils@4.10.1", "", { "dependencies": { "eslint-visitor-keys": "^3.4.3" }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, "sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg=="],
"@eslint-community/regexpp": ["@eslint-community/regexpp@4.12.2", "", {}, "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew=="],
"@eslint/config-array": ["@eslint/config-array@0.21.2", "", { "dependencies": { "@eslint/object-schema": "^2.1.7", "debug": "^4.3.1", "minimatch": "^3.1.5" } }, "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw=="],
"@eslint/config-array": ["@eslint/config-array@0.23.5", "", { "dependencies": { "@eslint/object-schema": "^3.0.5", "debug": "^4.3.1", "minimatch": "^10.2.4" } }, "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA=="],
"@eslint/config-helpers": ["@eslint/config-helpers@0.4.2", "", { "dependencies": { "@eslint/core": "^0.17.0" } }, "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw=="],
"@eslint/config-helpers": ["@eslint/config-helpers@0.7.0", "", { "dependencies": { "@eslint/core": "^1.2.1" } }, "sha512-DObd/KKUsU+FaFv4PLxSRenpXfQWmPXXP3pPZ6/K1PCrMu2vQpMDMuQe/BqYeoLcz8ro0bVDF1RxOJgfVEdhUw=="],
"@eslint/core": ["@eslint/core@0.17.0", "", { "dependencies": { "@types/json-schema": "^7.0.15" } }, "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ=="],
"@eslint/core": ["@eslint/core@1.2.1", "", { "dependencies": { "@types/json-schema": "^7.0.15" } }, "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ=="],
"@eslint/eslintrc": ["@eslint/eslintrc@3.3.5", "", { "dependencies": { "ajv": "^6.14.0", "debug": "^4.3.2", "espree": "^10.0.1", "globals": "^14.0.0", "ignore": "^5.2.0", "import-fresh": "^3.2.1", "js-yaml": "^4.1.1", "minimatch": "^3.1.5", "strip-json-comments": "^3.1.1" } }, "sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg=="],
"@eslint/js": ["@eslint/js@10.0.1", "", { "peerDependencies": { "eslint": "^10.0.0" }, "optionalPeers": ["eslint"] }, "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA=="],
"@eslint/js": ["@eslint/js@9.39.4", "", {}, "sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw=="],
"@eslint/object-schema": ["@eslint/object-schema@3.0.5", "", {}, "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw=="],
"@eslint/object-schema": ["@eslint/object-schema@2.1.7", "", {}, "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA=="],
"@eslint/plugin-kit": ["@eslint/plugin-kit@0.4.1", "", { "dependencies": { "@eslint/core": "^0.17.0", "levn": "^0.4.1" } }, "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA=="],
"@eslint/plugin-kit": ["@eslint/plugin-kit@0.7.3", "", { "dependencies": { "@eslint/core": "^1.2.1", "levn": "^0.4.1" } }, "sha512-IkO+/KEUvwbVpiURZg+P7zF74z5Jxe0UgJxVni+RtoHQ6IZieXaO02kmadomap/q+l6bc/jdPGGqTjhuZnuz1Q=="],
"@humanfs/core": ["@humanfs/core@0.19.2", "", { "dependencies": { "@humanfs/types": "^0.15.0" } }, "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA=="],
@@ -126,17 +122,19 @@
"@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="],
"@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="],
"@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.6.0", "", {}, "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw=="],
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
"@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.4", "", { "dependencies": { "@tybys/wasm-util": "^0.10.1" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" } }, "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow=="],
"@keyv/bigmap": ["@keyv/bigmap@1.3.1", "", { "dependencies": { "hashery": "^1.4.0", "hookified": "^1.15.0" }, "peerDependencies": { "keyv": "^5.6.0" } }, "sha512-WbzE9sdmQtKy8vrNPa9BRnwZh5UF4s1KTmSK0KUVLo3eff5BlQNNWDnFOouNpKfPKDnms9xynJjsMYjMaT/aFQ=="],
"@oxc-project/types": ["@oxc-project/types@0.128.0", "", {}, "sha512-huv1Y/LzBJkBVHt3OlC7u0zHBW9qXf1FdD7sGmc1rXc2P1mTwHssYv7jyGx5KAACSCH+9B3Bhn6Z9luHRvf7pQ=="],
"@keyv/serialize": ["@keyv/serialize@1.1.1", "", {}, "sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA=="],
"@rc-component/async-validator": ["@rc-component/async-validator@5.1.0", "", { "dependencies": { "@babel/runtime": "^7.24.4" } }, "sha512-n4HcR5siNUXRX23nDizbZBQPO0ZM/5oTtmKZ6/eqL0L2bo747cklFdZGRN2f+c9qWGICwDzrhW0H7tE9PptdcA=="],
"@oxc-project/types": ["@oxc-project/types@0.149.0", "", {}, "sha512-Efcc+iF0j3Bf67YjEqIqWXbX5XddXoK/Mw4K1/JuXwRCZ8N16VR7iT23nlCc9XrveFVh/E5Rqs2StT0V8v9LdA=="],
"@rc-component/cascader": ["@rc-component/cascader@1.14.0", "", { "dependencies": { "@rc-component/select": "~1.6.0", "@rc-component/tree": "~1.2.0", "@rc-component/util": "^1.4.0", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=18.0.0", "react-dom": ">=18.0.0" } }, "sha512-Ip9356xwZUR2nbW5PRVGif4B/bDve4pLa/N+PGbvBaTnjbvmN4PFMBGQSmlDlzKP1ovxaYMvwF/dI9lXNLT4iQ=="],
"@rc-component/async-validator": ["@rc-component/async-validator@6.0.0", "", { "dependencies": { "@babel/runtime": "^7.24.4" } }, "sha512-D3AGQwdyE58gmvx6waVSXJ80JGO+IY5L2O8HDnSOex7JNlzB3GuN/4hyHNTdhy2qtOhkpbIjmeAN3tL993wKbA=="],
"@rc-component/cascader": ["@rc-component/cascader@1.22.0", "", { "dependencies": { "@rc-component/select": "~1.10.0", "@rc-component/tree": "~1.4.0", "@rc-component/util": "^1.11.1", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=18.0.0", "react-dom": ">=18.0.0" } }, "sha512-SffrA57aS9oub3VuI7ajPhJTPtaNxngSvtRhD40Rd8dwJ5vfWPSrVanWgeepdWFGBt7EHftIK5RUU0u3rCTwWw=="],
"@rc-component/checkbox": ["@rc-component/checkbox@2.0.0", "", { "dependencies": { "@rc-component/util": "^1.3.0", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-3CXGPpAR9gsPKeO2N78HAPOzU30UdemD6HGJoWVJOpa6WleaGB5kzZj3v6bdTZab31YuWgY/RxV3VKPctn0DwQ=="],
@@ -144,45 +142,47 @@
"@rc-component/color-picker": ["@rc-component/color-picker@3.1.1", "", { "dependencies": { "@ant-design/fast-color": "^3.0.1", "@rc-component/util": "^1.3.0", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-OHaCHLHszCegdXmIq2ZRIZBN/EtpT6Wm8SG/gpzLATHbVKc/avvuKi+zlOuk05FTWvgaMmpxAko44uRJ3M+2pg=="],
"@rc-component/context": ["@rc-component/context@2.0.1", "", { "dependencies": { "@rc-component/util": "^1.3.0" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-HyZbYm47s/YqtP6pKXNMjPEMaukyg7P0qVfgMLzr7YiFNMHbK2fKTAGzms9ykfGHSfyf75nBbgWw+hHkp+VImw=="],
"@rc-component/context": ["@rc-component/context@2.0.2", "", { "dependencies": { "@rc-component/util": "^1.11.0" }, "peerDependencies": { "react": ">=18.0.0", "react-dom": ">=18.0.0" } }, "sha512-uiGpAlblCNlziHPwj4S4Iy/oemeuz/hR03mbiEjTCXwsqOIN3BOzsRMyDwpyO5Fm0vIEEJRUf9ZtbRLbhksuTA=="],
"@rc-component/dialog": ["@rc-component/dialog@1.8.4", "", { "dependencies": { "@rc-component/motion": "^1.1.3", "@rc-component/portal": "^2.1.0", "@rc-component/util": "^1.9.0", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=18.0.0", "react-dom": ">=18.0.0" } }, "sha512-Ay6PM7phkTkquplG8fWfUGFZ2GTLx9diTl4f0d8Eqxd7W1u1KjE9AQooFQHOHnhZf0Ya3z51+5EKCWHmt/dNEw=="],
"@rc-component/dialog": ["@rc-component/dialog@1.10.0", "", { "dependencies": { "@rc-component/motion": "^1.3.3", "@rc-component/portal": "^2.1.0", "@rc-component/util": "^1.9.0", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=18.0.0", "react-dom": ">=18.0.0" } }, "sha512-eDukNlz9vNszAGv7i3zKXdxEd3wgVmNxuJijYt8zvTh17QwTu8KK/bdURRd/lU4qaMzhO1HKKmMrwOnkaw0BvQ=="],
"@rc-component/drawer": ["@rc-component/drawer@1.4.2", "", { "dependencies": { "@rc-component/motion": "^1.1.4", "@rc-component/portal": "^2.1.3", "@rc-component/util": "^1.9.0", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=18.0.0", "react-dom": ">=18.0.0" } }, "sha512-1ib+fZEp6FBu+YvcIktm+nCQ+Q+qIpwpoaJH6opGr4ofh2QMq+qdr5DLC4oCf5qf3pcWX9lUWPYX652k4ini8Q=="],
"@rc-component/dropdown": ["@rc-component/dropdown@1.0.2", "", { "dependencies": { "@rc-component/trigger": "^3.0.0", "@rc-component/util": "^1.2.1", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=16.11.0", "react-dom": ">=16.11.0" } }, "sha512-6PY2ecUSYhDPhkNHHb4wfeAya04WhpmUSKzdR60G+kMNVUCX2vjT/AgTS0Lz0I/K6xrPMJ3enQbwVpeN3sHCgg=="],
"@rc-component/dropdown": ["@rc-component/dropdown@1.0.3", "", { "dependencies": { "@rc-component/trigger": "^3.0.0", "@rc-component/util": "^1.11.1", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=16.11.0", "react-dom": ">=16.11.0" } }, "sha512-YTST/N6kpqpDz3IMuM/PSSZnrDpSOA6dgHv12gPA90ZTSLv2CoqkZ0+9NtwTY6BeO7dstPblSic2QJg7dSFy/g=="],
"@rc-component/form": ["@rc-component/form@1.8.1", "", { "dependencies": { "@rc-component/async-validator": "^5.1.0", "@rc-component/util": "^1.6.2", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-8O7TB55Fi2mWIGvSnwZjk8jFqVNYyKDAswglwGShcbndxqzKz4cHwNtNaLjZlAeRge9wcB0LL8IWsC/Bl18raQ=="],
"@rc-component/form": ["@rc-component/form@1.8.6", "", { "dependencies": { "@rc-component/async-validator": "^6.0.0", "@rc-component/util": "^1.11.1", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-1EXVsSKPZC6kGrIqxVck7kAWM6T65b6et/n5wIcyiaIa41VrDshD0nVLHoBDEIZr2ATQsOP6icb4XQkNFMLBAw=="],
"@rc-component/image": ["@rc-component/image@1.9.0", "", { "dependencies": { "@rc-component/motion": "^1.0.0", "@rc-component/portal": "^2.1.2", "@rc-component/util": "^1.10.1", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-khF7w7xkBH5B1bsBcI1FSUZdkyd1aqpl2eYyILCqCzzQH3XdfehGUaZTnptyaJJfs09/R5hv9jXWyazOMFIClQ=="],
"@rc-component/image": ["@rc-component/image@1.10.0", "", { "dependencies": { "@rc-component/motion": "^1.0.0", "@rc-component/portal": "^2.1.2", "@rc-component/util": "^1.11.1", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-BjeZCRQ+hw+4WAhvrw8rJvy5fckA2xpf/X2XQEOABUHvLTNB9inB98X3Mp54jYQ7g10DfWERQWHXeC4ylxp1Uw=="],
"@rc-component/input": ["@rc-component/input@1.1.2", "", { "dependencies": { "@rc-component/util": "^1.4.0", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=16.0.0", "react-dom": ">=16.0.0" } }, "sha512-Q61IMR47piUBudgixJ30CciKIy9b1H95qe7GgEKOmSJVJXvFRWJllJfQry9tif+MX2cWFXWJf/RXz4kaCeq/Fg=="],
"@rc-component/input": ["@rc-component/input@1.3.1", "", { "dependencies": { "@rc-component/resize-observer": "^1.1.1", "@rc-component/util": "^1.11.1", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=16.0.0", "react-dom": ">=16.0.0" } }, "sha512-iFvTUT9W+JC/MSin2aGAk8NqsVlTzcExNC9DZariON1IWirju9NoNeEk47an4Q8iHazkoVI/y1LnDi88+CPcig=="],
"@rc-component/input-number": ["@rc-component/input-number@1.6.2", "", { "dependencies": { "@rc-component/mini-decimal": "^1.0.1", "@rc-component/util": "^1.4.0", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-Gjcq7meZlCOiWN1t1xCC+7/s85humHVokTBI7PJgTfoyw5OWF74y3e6P8PHX104g9+b54jsodFIzyaj6p8LI9w=="],
"@rc-component/mentions": ["@rc-component/mentions@1.6.0", "", { "dependencies": { "@rc-component/input": "~1.1.0", "@rc-component/menu": "~1.2.0", "@rc-component/textarea": "~1.1.0", "@rc-component/trigger": "^3.0.0", "@rc-component/util": "^1.3.0", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-KIkQNP6habNuTsLhUv0UGEOwG67tlmE7KNIJoQZZNggEZl5lQJTytFDb69sl5CK3TDdISCTjKP3nGEBKgT61CQ=="],
"@rc-component/listy": ["@rc-component/listy@1.2.3", "", { "dependencies": { "@rc-component/motion": "^1.1.4", "@rc-component/portal": "^2.0.0", "@rc-component/resize-observer": "^1.0.0", "@rc-component/util": "^1.3.1", "@rc-component/virtual-list": "^1.4.0", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=18.0.0", "react-dom": ">=18.0.0" } }, "sha512-IXiMjV5s0rczLBlfh7G5nB4M3365mrEeedjwKtf5I+Ns3PqRUsebR2h5u8CeFarsVfLUPC2I5p0h09TNoOWyvQ=="],
"@rc-component/menu": ["@rc-component/menu@1.2.0", "", { "dependencies": { "@rc-component/motion": "^1.1.4", "@rc-component/overflow": "^1.0.0", "@rc-component/trigger": "^3.0.0", "@rc-component/util": "^1.3.0", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-VWwDuhvYHSnTGj4n6bV3ISrLACcPAzdPOq3d0BzkeiM5cve8BEYfvkEhNoM0PLzv51jpcejeyrLXeMVIJ+QJlg=="],
"@rc-component/mentions": ["@rc-component/mentions@1.12.0", "", { "dependencies": { "@rc-component/input": "~1.3.1", "@rc-component/menu": "~1.5.0", "@rc-component/trigger": "^3.0.0", "@rc-component/util": "^1.11.1", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-v5MBx8zDcqCT+uybJgKpcx3Fgrvj0m3u+A/15RlSBaMx12lLoJZTTYggC3pbYYfohcKujWWUyBz6O6Gp2vGiNA=="],
"@rc-component/mini-decimal": ["@rc-component/mini-decimal@1.1.3", "", { "dependencies": { "@babel/runtime": "^7.18.0" } }, "sha512-bk/FJ09fLf+NLODMAFll6CfYrHPBioTedhW6lxDBuuWucJEqFUd4l/D/5JgIi3dina6sYahB8iuPAZTNz2pMxw=="],
"@rc-component/menu": ["@rc-component/menu@1.5.0", "", { "dependencies": { "@rc-component/motion": "^1.1.4", "@rc-component/overflow": "^1.0.0", "@rc-component/trigger": "^3.0.0", "@rc-component/util": "^1.11.1", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=18.0.0", "react-dom": ">=18.0.0" } }, "sha512-RjvzPsZkwVEg3xu+/jDDqDeUCBJAnWylONFlGeQgg5enlNAnlpybqhaMGE/UPEIvr6hL90PGhjFBguJqj0Qb2Q=="],
"@rc-component/motion": ["@rc-component/motion@1.3.2", "", { "dependencies": { "@rc-component/util": "^1.2.0", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-itfd+GztzJYAb04Z4RkEub1TbJAfZc2Iuy8p44U44xD1F5+fNYFKI3897ijlbIyfvXkTmMm+KGcjkQQGMHywEQ=="],
"@rc-component/mini-decimal": ["@rc-component/mini-decimal@1.1.4", "", { "dependencies": { "@babel/runtime": "^7.18.0" } }, "sha512-xiuXcaCwyOWpD8a8scdExFl+bntNphAW8XeenL1ig2en0AAZY0Pcp4pC0dI22qJ+NvxKn9RoNIoRdqYU3BLH4w=="],
"@rc-component/motion": ["@rc-component/motion@1.3.3", "", { "dependencies": { "@rc-component/util": "^1.11.0", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-Xh3IszxvlSv3/PLYFyC2UZi9LNB83yOnkB/LNmRzaypZLvkhqUIPS7MQpGZcCMWrNsXV2p6YTSWbSGvFpEle9A=="],
"@rc-component/mutate-observer": ["@rc-component/mutate-observer@2.0.1", "", { "dependencies": { "@rc-component/util": "^1.2.0" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-AyarjoLU5YlxuValRi+w8JRH2Z84TBbFO2RoGWz9d8bSu0FqT8DtugH3xC3BV7mUwlmROFauyWuXFuq4IFbH+w=="],
"@rc-component/notification": ["@rc-component/notification@1.2.0", "", { "dependencies": { "@rc-component/motion": "^1.1.4", "@rc-component/util": "^1.2.1", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-OX3J+zVU7rvoJCikjrfW7qOUp7zlDeFBK2eA3SFbGSkDqo63Sl4Ss8A04kFP+fxHSxMDIS9jYVEZtU1FNCFuBA=="],
"@rc-component/notification": ["@rc-component/notification@2.0.8", "", { "dependencies": { "@rc-component/motion": "^1.1.4", "@rc-component/util": "^1.11.0", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=18.0.0", "react-dom": ">=18.0.0" } }, "sha512-MTRA3b8eHE14gh5R9nlXzbWZEDEBFVkEHj/FI1VOXw2zrSnSxqbuuU3drsq+k53e6MHwtegoFay1tnNYhtl61Q=="],
"@rc-component/overflow": ["@rc-component/overflow@1.0.1", "", { "dependencies": { "@babel/runtime": "^7.11.1", "@rc-component/resize-observer": "^1.0.1", "@rc-component/util": "^1.4.0", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-syfmgAABaHCnCDzPwHZ/2tuvIcpOO3jefYZMmfkN+pmo8HKTzsfhS57vxo4ksPdN0By+uWVJhJWNFozNBxi2eA=="],
"@rc-component/pagination": ["@rc-component/pagination@1.2.0", "", { "dependencies": { "@rc-component/util": "^1.3.0", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-YcpUFE8dMLfSo6OARJlK6DbHHvrxz7pMGPGmC/caZSJJz6HRKHC1RPP001PRHCvG9Z/veD039uOQmazVuLJzlw=="],
"@rc-component/pagination": ["@rc-component/pagination@1.4.0", "", { "dependencies": { "@rc-component/util": "^1.11.1", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-CW1g7P9V8u+e8JQdUsl2RWg+GCsoee0mtJjZUCCxn/vb3jzOwDKm6hAdwddHCVBfWJ58eGUBZz3IvnU8rRktjw=="],
"@rc-component/picker": ["@rc-component/picker@1.9.1", "", { "dependencies": { "@rc-component/overflow": "^1.0.0", "@rc-component/resize-observer": "^1.0.0", "@rc-component/trigger": "^3.6.15", "@rc-component/util": "^1.3.0", "clsx": "^2.1.1" }, "peerDependencies": { "date-fns": ">= 2.x", "dayjs": ">= 1.x", "luxon": ">= 3.x", "moment": ">= 2.x", "react": ">=16.9.0", "react-dom": ">=16.9.0" }, "optionalPeers": ["date-fns", "dayjs", "luxon", "moment"] }, "sha512-9FBYYsvH3HMLICaPDA/1Th5FLaDkFa7qAtangIdlhKb3ZALaR745e9PsOhheJb6asS4QXc12ffiAcjdkZ4C5/g=="],
"@rc-component/picker": ["@rc-component/picker@1.12.2", "", { "dependencies": { "@rc-component/overflow": "^1.0.0", "@rc-component/resize-observer": "^1.0.0", "@rc-component/trigger": "^3.6.15", "@rc-component/util": "^1.13.0", "clsx": "^2.1.1" }, "peerDependencies": { "date-fns": ">= 2.x", "dayjs": ">= 1.x", "luxon": ">= 3.x", "moment": ">= 2.x", "react": ">=16.9.0", "react-dom": ">=16.9.0" }, "optionalPeers": ["date-fns", "dayjs", "luxon", "moment"] }, "sha512-ZB1A+tzTQkgtR4flPrAMvxIAOelIIOOOsp22fU2Nad6PbZcJacnyGJUJqUH7ZS9mwxeUwurf1pLMKXrVsK1tlA=="],
"@rc-component/portal": ["@rc-component/portal@2.2.0", "", { "dependencies": { "@rc-component/util": "^1.2.1", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=18.0.0", "react-dom": ">=18.0.0" } }, "sha512-oc6FlA+uXCMiwArHsJyHcIkX4q6uKyndrPol2eWX8YPkAnztHOPsFIRtmWG4BMlGE5h7YIRE3NiaJ5VS8Lb1QQ=="],
"@rc-component/portal": ["@rc-component/portal@2.2.1", "", { "dependencies": { "@rc-component/util": "^1.11.0", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=18.0.0", "react-dom": ">=18.0.0" } }, "sha512-ck+r1kW/JSv0wxPji3KN2ss9K6Z0qqwusw/mf/0JobXhZ8hC2ejZwCJObW/SvDi0uhA0VzmCnx0CaCci95tcmA=="],
"@rc-component/progress": ["@rc-component/progress@1.0.2", "", { "dependencies": { "@rc-component/util": "^1.2.1", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-WZUnH9eGxH1+xodZKqdrHke59uyGZSWgj5HBM5Kwk5BrTMuAORO7VJ2IP5Qbm9aH3n9x3IcesqHHR0NWPBC7fQ=="],
"@rc-component/progress": ["@rc-component/progress@1.0.3", "", { "dependencies": { "@rc-component/util": "^1.11.1", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-Q1nPLIvKr95el/q76yaBh5nv1hqCsWaHQb8U7vXEDMefEhHgM7n3CPoN+o4LHnTyANQHmMFgoS/naAT4Bhxuaw=="],
"@rc-component/qrcode": ["@rc-component/qrcode@1.1.1", "", { "dependencies": { "@babel/runtime": "^7.24.7" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-LfLGNymzKdUPjXUbRP+xOhIWY4jQ+YMj5MmWAcgcAq1Ij8XP7tRmAXqyuv96XvLUBE/5cA8hLFl9eO1JQMujrA=="],
"@rc-component/qrcode": ["@rc-component/qrcode@2.0.0", "", { "dependencies": { "@babel/runtime": "^7.24.7" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-aAv3QhPP1xyafuTZOxub6a54pCeBnN3IwQkpETrBtthq4BL5IgxnCbuoBWPDpdLw1y1j6BgBUCAKV92+yX06Dw=="],
"@rc-component/rate": ["@rc-component/rate@1.0.1", "", { "dependencies": { "@rc-component/util": "^1.3.0", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-bkXxeBqDpl5IOC7yL7GcSYjQx9G8H+6kLYQnNZWeBYq2OYIv1MONd6mqKTjnnJYpV0cQIU2z3atdW0j1kttpTw=="],
@@ -190,150 +190,138 @@
"@rc-component/segmented": ["@rc-component/segmented@1.3.0", "", { "dependencies": { "@babel/runtime": "^7.11.1", "@rc-component/motion": "^1.1.4", "@rc-component/util": "^1.3.0", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=16.0.0", "react-dom": ">=16.0.0" } }, "sha512-5J/bJ01mbDnoA6P/FW8SxUvKn+OgUSTZJPzCNnTBntG50tzoP7DydGhqxp7ggZXZls7me3mc2EQDXakU3iTVFg=="],
"@rc-component/select": ["@rc-component/select@1.6.15", "", { "dependencies": { "@rc-component/overflow": "^1.0.0", "@rc-component/trigger": "^3.0.0", "@rc-component/util": "^1.3.0", "@rc-component/virtual-list": "^1.0.1", "clsx": "^2.1.1" }, "peerDependencies": { "react": "*", "react-dom": "*" } }, "sha512-SyVCWnqxCQZZcQvQJ/CxSjx2bGma6ds/HtnpkIfZVnt6RoEgbqUmHgD6vrzNarNXwbLXerwVzWwq8F3d1sst7g=="],
"@rc-component/select": ["@rc-component/select@1.10.1", "", { "dependencies": { "@rc-component/overflow": "^1.0.0", "@rc-component/trigger": "^3.0.0", "@rc-component/util": "^1.11.1", "@rc-component/virtual-list": "^1.2.0", "clsx": "^2.1.1" }, "peerDependencies": { "react": "*", "react-dom": "*" } }, "sha512-H+yQsl+qED9NilQ3g6zdpsMwUgwVjrcMTkNHAWRVU/MoNCYgTbDgU+MIMgZDK+rVdd2JUfI/MkysMcZZ0cyQKw=="],
"@rc-component/slider": ["@rc-component/slider@1.0.1", "", { "dependencies": { "@rc-component/util": "^1.3.0", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-uDhEPU1z3WDfCJhaL9jfd2ha/Eqpdfxsn0Zb0Xcq1NGQAman0TWaR37OWp2vVXEOdV2y0njSILTMpTfPV1454g=="],
"@rc-component/slider": ["@rc-component/slider@1.1.1", "", { "dependencies": { "@rc-component/util": "^1.3.0", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-LSzgWGYDgeCDgR4r1XlU29gbYws6HpLnvJd/uMhLeW/vQgxldeR+Wb4uzHDCHiYEbr1bnEHWdjkPxjJRHxuiig=="],
"@rc-component/steps": ["@rc-component/steps@1.2.2", "", { "dependencies": { "@rc-component/util": "^1.2.1", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-/yVIZ00gDYYPHSY0JP+M+s3ZvuXLu2f9rEjQqiUDs7EcYsUYrpJ/1bLj9aI9R7MBR3fu/NGh6RM9u2qGfqp+Nw=="],
"@rc-component/steps": ["@rc-component/steps@1.2.3", "", { "dependencies": { "@rc-component/util": "^1.11.1", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-/b9gHcvDGjTDWJZW7+1kKFvzs0jrEeQ8mFvvIgX+h7xNT0FsOYODJYKjyJR4fPbvMBCfCOEqaBK66HBxov5BQA=="],
"@rc-component/switch": ["@rc-component/switch@1.0.3", "", { "dependencies": { "@rc-component/util": "^1.3.0", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-Jgi+EbOBquje/XNdofr7xbJQZPYJP+BlPfR0h+WN4zFkdtB2EWqEfvkXJWeipflwjWip0/17rNbxEAqs8hVHfw=="],
"@rc-component/table": ["@rc-component/table@1.9.1", "", { "dependencies": { "@rc-component/context": "^2.0.1", "@rc-component/resize-observer": "^1.0.0", "@rc-component/util": "^1.1.0", "@rc-component/virtual-list": "^1.0.1", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=18.0.0", "react-dom": ">=18.0.0" } }, "sha512-FVI5ZS/GdB3BcgexfCYKi3iHhZS3Fr59EtsxORszYGrfpH1eWr33eDNSYkVfLI6tfJ7vftJDd9D5apfFWqkdJg=="],
"@rc-component/table": ["@rc-component/table@1.11.1", "", { "dependencies": { "@rc-component/context": "^2.0.1", "@rc-component/resize-observer": "^1.0.0", "@rc-component/util": "^1.11.1", "@rc-component/virtual-list": "^1.0.1", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=18.0.0", "react-dom": ">=18.0.0" } }, "sha512-OWdS6DMmeWb7bJBGqPxYZpQbzBlBiXZUu2sqo6Ii7Sjs9GeK1IsrXrWk26SL2c6KEseabswdxrRj7WUm9LdECw=="],
"@rc-component/tabs": ["@rc-component/tabs@1.7.0", "", { "dependencies": { "@rc-component/dropdown": "~1.0.0", "@rc-component/menu": "~1.2.0", "@rc-component/motion": "^1.1.3", "@rc-component/resize-observer": "^1.0.0", "@rc-component/util": "^1.3.0", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-J48cs2iBi7Ho3nptBxxIqizEliUC+ExE23faspUQKGQ550vaBlv3aGF8Epv/UB1vFWeoJDTW/dNzgIU0Qj5i/w=="],
"@rc-component/tabs": ["@rc-component/tabs@1.13.0", "", { "dependencies": { "@rc-component/dropdown": "~1.0.0", "@rc-component/menu": "~1.5.0", "@rc-component/motion": "^1.1.3", "@rc-component/resize-observer": "^1.0.0", "@rc-component/util": "^1.11.1", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-3FXr+9ZII8tFLWuDWrgjRaxZd2mazkKJAGRFEfVmp4r1qnBglOlxQ78l54Td1aj6/iFaaEReKSAF2HToztU5uw=="],
"@rc-component/textarea": ["@rc-component/textarea@1.1.2", "", { "dependencies": { "@rc-component/input": "~1.1.0", "@rc-component/resize-observer": "^1.0.0", "@rc-component/util": "^1.3.0", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-9rMUEODWZDMovfScIEHXWlVZuPljZ2pd1LKNjslJVitn4SldEzq5vO1CL3yy3Dnib6zZal2r2DPtjy84VVpF6A=="],
"@rc-component/tooltip": ["@rc-component/tooltip@1.5.2", "", { "dependencies": { "@rc-component/trigger": "^3.10.0", "@rc-component/util": "^1.11.1", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=18.0.0", "react-dom": ">=18.0.0" } }, "sha512-VLq4BclU3LgRe46g2hTr2bdsM8tFYs7dVwpuD88rPNNg4lCGe8mBSS4FiRzYzS+ffnZ46y4SI8KYKo9K7qBnhw=="],
"@rc-component/tooltip": ["@rc-component/tooltip@1.4.0", "", { "dependencies": { "@rc-component/trigger": "^3.7.1", "@rc-component/util": "^1.3.0", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=18.0.0", "react-dom": ">=18.0.0" } }, "sha512-8Rx5DCctIlLI4raR0I0xHjVTf1aF48+gKCNeAAo5bmF5VoR5YED+A/XEqzXv9KKqrJDRcd3Wndpxh2hyzrTtSg=="],
"@rc-component/tour": ["@rc-component/tour@2.4.0", "", { "dependencies": { "@rc-component/portal": "^2.2.0", "@rc-component/trigger": "^3.0.0", "@rc-component/util": "^1.7.0", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-aui4r4TqmTzwaBgcQxHYep8kM8PTjZFufjokObpy35KfFeZ0k9ArquWFZqegQlH24P14t+F0qO0mGTgzlav1yg=="],
"@rc-component/tour": ["@rc-component/tour@2.3.0", "", { "dependencies": { "@rc-component/portal": "^2.2.0", "@rc-component/trigger": "^3.0.0", "@rc-component/util": "^1.7.0", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-K04K9r32kUC+auBSQfr+Fss4SpSIS9JGe56oq/ALAX0p+i2ylYOI1MgR83yBY7v96eO6ZFXcM/igCQmubps0Ow=="],
"@rc-component/tree": ["@rc-component/tree@1.4.0", "", { "dependencies": { "@rc-component/motion": "^1.0.0", "@rc-component/util": "^1.11.1", "@rc-component/virtual-list": "^1.2.0", "clsx": "^2.1.1" }, "peerDependencies": { "react": "*", "react-dom": "*" } }, "sha512-dGsJGDJQedA0BqqVgj3F8BvHXTSZijyhTXdbAdkcx8lynzZkty/CV3Z3LOm/fxz+BCfl3dfGiAQpb7Q5XNvl0Q=="],
"@rc-component/tree": ["@rc-component/tree@1.2.4", "", { "dependencies": { "@rc-component/motion": "^1.0.0", "@rc-component/util": "^1.8.1", "@rc-component/virtual-list": "^1.0.1", "clsx": "^2.1.1" }, "peerDependencies": { "react": "*", "react-dom": "*" } }, "sha512-5Gli43+m4R7NhpYYz3Z61I6LOw9yI6CNChxgVtvrO6xB1qML7iE6QMLVMB3+FTjo2yF6uFdAHtqWPECz/zbX5w=="],
"@rc-component/tree-select": ["@rc-component/tree-select@1.16.1", "", { "dependencies": { "@rc-component/select": "~1.10.0", "@rc-component/tree": "~1.4.0", "@rc-component/util": "^1.11.1", "clsx": "^2.1.1" }, "peerDependencies": { "react": "*", "react-dom": "*" } }, "sha512-a1Oi6EJhqAhdOxxupdJi6fP0RPHMKn5TcfkX2+llaQ4lF4nwfH7b6SCHcnsybaa2s+pk1yZYwVyeOYkDnEBRdg=="],
"@rc-component/tree-select": ["@rc-component/tree-select@1.8.0", "", { "dependencies": { "@rc-component/select": "~1.6.0", "@rc-component/tree": "~1.2.0", "@rc-component/util": "^1.4.0", "clsx": "^2.1.1" }, "peerDependencies": { "react": "*", "react-dom": "*" } }, "sha512-iYsPq3nuLYvGqdvFAW+l+I9ASRIOVbMXyA8FGZg2lGym/GwkaWeJGzI4eJ7c9IOEhRj0oyfIN4S92Fl3J05mjQ=="],
"@rc-component/trigger": ["@rc-component/trigger@3.10.1", "", { "dependencies": { "@rc-component/motion": "^1.3.3", "@rc-component/portal": "^2.2.1", "@rc-component/resize-observer": "^1.1.2", "@rc-component/util": "^1.11.1", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=18.0.0", "react-dom": ">=18.0.0" } }, "sha512-mXlDN0IXdtV8Yqqm8195ECCyrbmfvvfKvwVvSlH0+qvKD6BUF8gRhEjSy0FOcD1+CcDRHgTiX99LoxfQrmh3Cw=="],
"@rc-component/trigger": ["@rc-component/trigger@3.9.0", "", { "dependencies": { "@rc-component/motion": "^1.1.4", "@rc-component/portal": "^2.2.0", "@rc-component/resize-observer": "^1.1.1", "@rc-component/util": "^1.2.1", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=18.0.0", "react-dom": ">=18.0.0" } }, "sha512-X8btpwfrT27AgrZVOz4swclhEHTZcqaHeQMXXBgveagOiakTa36uObXbdwerXffgV8G9dH1fAAE0DHtVQs8EHg=="],
"@rc-component/upload": ["@rc-component/upload@1.1.1", "", { "dependencies": { "@rc-component/util": "^1.11.1", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-GvYWSKeaJTOxxC5p6+nOSadzfvXA1h8C/iHFPFZX+szH3JUXrvs+DLiW8YUTBgvMh8m63mJeHrlYlJzAlg+pDA=="],
"@rc-component/upload": ["@rc-component/upload@1.1.0", "", { "dependencies": { "@rc-component/util": "^1.3.0", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-LIBV90mAnUE6VK5N4QvForoxZc4XqEYZimcp7fk+lkE4XwHHyJWxpIXQQwMU8hJM+YwBbsoZkGksL1sISWHQxw=="],
"@rc-component/util": ["@rc-component/util@1.13.0", "", { "dependencies": { "is-mobile": "^5.0.0", "react-is": "^19.2.7" }, "peerDependencies": { "react": ">=18.0.0", "react-dom": ">=18.0.0" } }, "sha512-IXf2zBfZrbQGT+G8LxnoICDBGnMHIPIiq5HCRn3EDfKUx28x1rnbZBj46ULJf4sM8ImTG1SepqfNBdxZziqQ4w=="],
"@rc-component/util": ["@rc-component/util@1.10.1", "", { "dependencies": { "is-mobile": "^5.0.0", "react-is": "^18.2.0" }, "peerDependencies": { "react": ">=18.0.0", "react-dom": ">=18.0.0" } }, "sha512-q++9S6rUa5Idb/xIBNz6jtvumw5+O5YV5V0g4iK9mn9jWs4oGJheE3ZN1kAnE723AXyaD8v95yeOASmdk8Jnng=="],
"@rc-component/virtual-list": ["@rc-component/virtual-list@1.5.1", "", { "dependencies": { "@babel/runtime": "^8.0.0", "@rc-component/resize-observer": "^1.0.1", "@rc-component/util": "^1.4.0", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=18.0.0", "react-dom": ">=18.0.0" } }, "sha512-boqHxdtyWC88u8quYgEO49bcBy5fzRiOcnBge+N4nLzs2k8hUQ/yw7JE9dM6yCBE4jSm5YSHVCVMS+suBuJGKA=="],
"@rc-component/virtual-list": ["@rc-component/virtual-list@1.0.2", "", { "dependencies": { "@babel/runtime": "^7.20.0", "@rc-component/resize-observer": "^1.0.1", "@rc-component/util": "^1.4.0", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-uvTol/mH74FYsn5loDGJxo+7kjkO4i+y4j87Re1pxJBs0FaeuMuLRzQRGaXwnMcV1CxpZLi2Z56Rerj2M00fjQ=="],
"@rolldown/binding-android-arm-eabi": ["@rolldown/binding-android-arm-eabi@1.2.8", "", { "os": "android", "cpu": "arm" }, "sha512-tN5aztYkKCte4i5SIrrz5yK/HMjEuCqCSCJa418jOV8tZ1cBY3YF2otxB1ktPxzsLA1BeTqwapK0bfjxNvHJVw=="],
"@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.0.0-rc.18", "", { "os": "android", "cpu": "arm64" }, "sha512-lIDyUAfD7U3+BWKzdxMbJcsYHuqXqmGz40aeRqvuAm3y5TkJSYTBW2RDrn65DJFPQqVjUAUqq5uz8urzQ8aBdQ=="],
"@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.2.8", "", { "os": "android", "cpu": "arm64" }, "sha512-dIYTWl9XprMUiQFoc55KUyk/oS8SKYH3zFl0LTR7RT0Xj4hgSVyuJcroH8JUu8RcpF8fTB6E0aOwCkZoYPcDSQ=="],
"@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.0.0-rc.18", "", { "os": "darwin", "cpu": "arm64" }, "sha512-apJq2ktnGp27nSInMR5Vcj8kY6xJzDAvfdIFlpDcAK/w4cDO58qVoi1YQsES/SKiFNge/6e4CUzgjfHduYqWpQ=="],
"@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.2.8", "", { "os": "darwin", "cpu": "arm64" }, "sha512-PCSDQGXD2IyTEFrcgPyBM8jJuGmrbCMuoIOXdbEGVemruKACXoLQJrb+A45Z0L5t1RQkdfJprAYPkikbh7dzdA=="],
"@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.0.0-rc.18", "", { "os": "darwin", "cpu": "x64" }, "sha512-5Ofot8xbs+pxRHJqm9/9N/4sTQOvdrwEsmPE9pdLEEoAbdZtG6F2LMDfO1sp6ZAtXJuJV/21ew2srq3W8NXB5g=="],
"@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.2.8", "", { "os": "darwin", "cpu": "x64" }, "sha512-Uk7lRsGhPFHVX/sAUC6D5H9Ol30dFHd6iquokll2th3LpdJ3F5CzQB+7DHn0Ri2mG+U7k2zXiPHDrwZenXhwSA=="],
"@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.0.0-rc.18", "", { "os": "freebsd", "cpu": "x64" }, "sha512-7h8eeOTT1eyqJyx64BFCnWZpNm486hGWt2sqeLLgDxA0xI1oGZ9H7gK1S85uNGmBhkdPwa/6reTxfFFKvIsebw=="],
"@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.2.8", "", { "os": "freebsd", "cpu": "x64" }, "sha512-DjszaTEVogPqA5bYzsEeqDCQxbcp2fexQwKcRspYji2yzR68fCf+e4fx6kBSRDwX5/brZaHw/hWS9+A/+/w9sQ=="],
"@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.18", "", { "os": "linux", "cpu": "arm" }, "sha512-eRcm/HVt9U/JFu5RKAEKwGQYtDCKWLiaH6wOnsSEp6NMBb/3Os8LgHZlNyzMpFVNmiiMFlfb2zEnebfzJrHFmg=="],
"@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.2.8", "", { "os": "linux", "cpu": "arm" }, "sha512-zmwa7FTmdzB6aaEEuuls18H6Ap5JmJPSoPTuXixeJZV6tG40SyLkApQtz1g8ptZtiEKqj9OM0oNLPh1AgvE31Q=="],
"@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.0.0-rc.18", "", { "os": "linux", "cpu": "arm64" }, "sha512-SOrT/cT4ukTmgnrEz/Hg3m7LBnuCLW9psDeMKrimRWY4I8DmnO7Lco8W2vtqPmMkbVu8iJ+g4GFLVLLOVjJ9DQ=="],
"@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.2.8", "", { "os": "linux", "cpu": "arm64" }, "sha512-KdYQDPHwJVnbFwdTGMgxsI9SqblBlz6STGM+w1We/d5B8OWWidYH0MwkU/uA1wM5fIpO2MkOVxXrNzzuZhw9ew=="],
"@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.0.0-rc.18", "", { "os": "linux", "cpu": "arm64" }, "sha512-QWjdxN1HJCpBTAcZ5N5F7wju3gVPzRzSpmGzx7na0c/1qpN9CFil+xt+l9lV/1M6/gqHSNXCiqPfwhVJPeLnug=="],
"@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.2.8", "", { "os": "linux", "cpu": "arm64" }, "sha512-jFJTifHnNPY+yzOoNZQfSIysrVyXzEQPhPnOUjmD1bcQGHH6s7c8cViKWar8YplQImE5N9JRqMCLrM2CdxOrZA=="],
"@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.18", "", { "os": "linux", "cpu": "ppc64" }, "sha512-ugCOyj7a4d9h3q9B+wXmf6g3a68UsjGh6dob5DHevHGMwDUbhsYNbSPxJsENcIttJZ9jv7qGM2UesLw5jqIhdg=="],
"@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.2.8", "", { "os": "linux", "cpu": "ppc64" }, "sha512-FhiOziBDWPBjbcmRzfLyIJnaP7AVMFXT7YCXPjXxj7wKU3vx24RjrCNN/zjvVa+N2vVoHJwCoUBvsrN/DG3zIA=="],
"@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.0.0-rc.18", "", { "os": "linux", "cpu": "s390x" }, "sha512-kKWRhbsotpXkGbcd5dllUWg5gEXcDAa8u5YnP9AV5DYNbvJHGzzuwv7dpmhc8NqKMJldl0a+x76IHbspEpEmdA=="],
"@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.2.8", "", { "os": "linux", "cpu": "s390x" }, "sha512-WnHfADMzOV2Y55wlx1hzzQnar/wDt/VdvWSD99r18Mz9ylNieIGOkRx3UV21h7m/eJvjySYJkO26VvGNFkwsIQ=="],
"@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.0.0-rc.18", "", { "os": "linux", "cpu": "x64" }, "sha512-uCo8ElcCIAMyYAZyuIZ81oFkhTSIllNvUCHCAlbhlN4ji3uC28h7IIdlXyIvGO7HsuqnV9p3rD/bpH7XhIyhRw=="],
"@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.2.8", "", { "os": "linux", "cpu": "x64" }, "sha512-H9tRr5ibfXFVLxbPOseVewewFpl28zcEdjRDt2FTUZU7odxP0gEv1ki4/kGmcGOh78oRwZuuQllGLZ9zTJp84g=="],
"@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.0.0-rc.18", "", { "os": "linux", "cpu": "x64" }, "sha512-XNOQZtuE6yUIvx4rwGemwh8kpL1xvU41FXy/s9K7T/3JVcqGzo3NfKM2HrbrGgfPYGFW42f07Wk++aOC6B9NWA=="],
"@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.2.8", "", { "os": "linux", "cpu": "x64" }, "sha512-UefiqfM3D6IVNlZ8tSGs9+Ejjud2T+oxO0IHADU45Y+lyEjD2dVFyZHbkfX0LUb5Zugo/oIv1eCO/KVYhgYJYA=="],
"@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.0.0-rc.18", "", { "os": "none", "cpu": "arm64" }, "sha512-tSn/kzrfa7tNOXr7sEacDBN4YsIqTyLqh45IO0nHDwtpKIDNDJr+VFojt+4klSpChxB29JLyduSsE0MKEwa65A=="],
"@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.2.8", "", { "os": "none", "cpu": "arm64" }, "sha512-637Ke4kWSy6rp9cxQ9gMOXlxPgIw/c1beASV4M//3+9I4uwBVOOl74G+e3zyU3u19U7RkRl/HuewixZ/Z6+Rjg=="],
"@rolldown/binding-wasm32-wasi": ["@rolldown/binding-wasm32-wasi@1.0.0-rc.18", "", { "dependencies": { "@emnapi/core": "1.10.0", "@emnapi/runtime": "1.10.0", "@napi-rs/wasm-runtime": "^1.1.4" }, "cpu": "none" }, "sha512-+J9YGmc+czgqlhYmwun3S3O0FIZhsH8ep2456xwjAdIOmuJxM7xz4P4PtrxU+Bz17a/5bqPA8o3HAAoX0teUdg=="],
"@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.2.8", "", { "os": "win32", "cpu": "arm64" }, "sha512-xWBkPOF1Q9k/Gv1nQXnVdLxKu74jXppuOM4Z3mnypVUJJJwLsMl7hNJGRAUJoG8A5MgOI1ACKM+wBFxSJzKy4A=="],
"@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.0.0-rc.18", "", { "os": "win32", "cpu": "arm64" }, "sha512-zsu47DgU0FQzSwi6sU9dZoEdUv7pc1AptSEz/Z8HBg54sV0Pbs3N0+CrIbTsgiu6EyoaNN9CHboqbLaz9lhOyQ=="],
"@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.2.8", "", { "os": "win32", "cpu": "x64" }, "sha512-uz2ZvfgXbxqNwijjjbxrnvALwpyODDcgc1T1N8N3rf/DXKQmaFwmB4LX4yyjggpwN2obdQLb2rgirX5ffCWYng=="],
"@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.0.0-rc.18", "", { "os": "win32", "cpu": "x64" }, "sha512-7H+3yqGgmnlDTRRhw/xpYY9J1kf4GC681nVc4GqKhExZTDrVVrV2tsOR9kso0fvgBdcTCcQShx4SLLoHgaLwhg=="],
"@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.1", "", {}, "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw=="],
"@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.7", "", {}, "sha512-qujRfC8sFVInYSPPMLQByRh7zhwkGFS4+tyMQ83srV1qrxL4g8E2tyxVVyxd0+8QeBM1mIk9KbWxkegRr76XzA=="],
"@tanstack/query-core": ["@tanstack/query-core@5.102.8", "", {}, "sha512-ZNjkJ33CqvPNec/6lZBnHqLc3EVGPZ9ySLhYahU9TcuRFdmwXewuj0c4hwSWcGHqEUwcSrKeZ+oGcvPBqXcQcg=="],
"@tanstack/query-core": ["@tanstack/query-core@5.100.9", "", {}, "sha512-SJSFw1S8+kQ0+knv/XGfrbocWoAlT7vDKsSImtLx3ZPQmEcR46hkDjLSvynSy25N8Ms4tIEini1FuBd5k7IscQ=="],
"@tanstack/react-query": ["@tanstack/react-query@5.102.8", "", { "dependencies": { "@tanstack/query-core": "5.102.8" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-TYBea4OuXWD7MhaSHq069TWbFe7rcwWN6kzT7JF0OKi1K6c1gTv2IzD6A6ExJsCMozdkqBWeuIUZmu4KQg0O5A=="],
"@tanstack/react-query": ["@tanstack/react-query@5.100.9", "", { "dependencies": { "@tanstack/query-core": "5.100.9" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-Oa44XkaI3kCNN6ME0KByU3xT3SEUNOMfZpHxL6+wFoTm+OeUFYHKdeYVe0aOXlRDm/f15sgLwEt2HDorIdW8+A=="],
"@tybys/wasm-util": ["@tybys/wasm-util@0.10.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg=="],
"@types/esrecurse": ["@types/esrecurse@4.3.1", "", {}, "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw=="],
"@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="],
"@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="],
"@types/node": ["@types/node@24.12.3", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-8oljBDGun9cIsZRJR6fkihn0TSXJI0UDOOhncYaERq6M0JMDoPLxyscwruJcb4GKS6dvK/d8xebYBg27h/duaQ=="],
"@types/node": ["@types/node@24.13.4", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-YJ7EqCstVTzIr0fMr7qul/977en+pQHrfmuKIo6Zr9i75Be21dr3MovcfvGtyvi2HAUrRerWps5sMO9I7WaxDw=="],
"@types/react": ["@types/react@19.2.14", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w=="],
"@types/react": ["@types/react@19.3.0", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg=="],
"@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="],
"@types/react-dom": ["@types/react-dom@19.3.0", "", { "peerDependencies": { "@types/react": "^19.3.0" } }, "sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q=="],
"@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.59.2", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.59.2", "@typescript-eslint/type-utils": "8.59.2", "@typescript-eslint/utils": "8.59.2", "@typescript-eslint/visitor-keys": "8.59.2", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.59.2", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-j/bwmkBvHUtPNxzuWe5z6BEk3q54YRyGlBXkSsmfoih7zNrBvl5A9A98anlp/7JbyZcWIJ8KXo/3Tq/DjFLtuQ=="],
"@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.70.0", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.70.0", "@typescript-eslint/type-utils": "8.70.0", "@typescript-eslint/utils": "8.70.0", "@typescript-eslint/visitor-keys": "8.70.0", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.70.0", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-/v8HZt6RlyIZxB3ntehELOcUcfxKPVGWXnQdJuHRmzrqgF8nQypcC/oxGW+Ot4VGKDq81XugPKxx0n5PBtf9PA=="],
"@typescript-eslint/parser": ["@typescript-eslint/parser@8.59.2", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.59.2", "@typescript-eslint/types": "8.59.2", "@typescript-eslint/typescript-estree": "8.59.2", "@typescript-eslint/visitor-keys": "8.59.2", "debug": "^4.4.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-plR3pp6D+SSUn1HM7xvSkx12/DhoHInI2YF35KAcVFNZvlC0gtrWqx7Qq1oH2Ssgi0vlFRCTbP+DZc7B9+TtsQ=="],
"@typescript-eslint/parser": ["@typescript-eslint/parser@8.70.0", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.70.0", "@typescript-eslint/types": "8.70.0", "@typescript-eslint/typescript-estree": "8.70.0", "@typescript-eslint/visitor-keys": "8.70.0", "debug": "^4.4.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-zYvrmj9Yxd63UGaXw+kdt6A0F0s0qveJyuatIM77bYC2DE4pgmg7a50u8LR7PRtXd0x+h+Tl3eXabGm06SWd3Q=="],
"@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.59.2", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.59.2", "@typescript-eslint/types": "^8.59.2", "debug": "^4.4.3" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-+2hqvEkeyf/0FBor67duF0Ll7Ot8jyKzDQOSrxazF/danillRq2DwR9dLptsXpoZQqxE1UisSmoZewrlPas9Vw=="],
"@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.70.0", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.70.0", "@typescript-eslint/types": "^8.70.0", "debug": "^4.4.3" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-hFHbTNqhU9G+2eKFXCBVb1tjFT/LceiJ4+HfLO4pTpDI0KHi6iajpcFFkaSQ9gXmCh7n82A0PthaayEdN6mspQ=="],
"@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.59.2", "", { "dependencies": { "@typescript-eslint/types": "8.59.2", "@typescript-eslint/visitor-keys": "8.59.2" } }, "sha512-JzfyEpEtOU89CcFSwyNS3mu4MLvLSXqnmX05+aKBDM+TdR5jzcGOEBwxwGNxrEQ7p/z6kK2WyioCGBf2zZBnvg=="],
"@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.70.0", "", { "dependencies": { "@typescript-eslint/types": "8.70.0", "@typescript-eslint/visitor-keys": "8.70.0" } }, "sha512-8nP3Kwh5hlgZ4FicGvmznAmJe8UL4sdU8tLukrPaMuQmDuk4Y8xYfzu/aYZW4xT2JCgc7H/TpDI5cGlxcWJSqQ=="],
"@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.59.2", "", { "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-BKK4alN7oi4C/zv4VqHQ+uRU+lTa6JGIZ7s1juw7b3RHo9OfKB+bKX3u0iVZetdsUCBBkSbdWbarJbmN0fTeSw=="],
"@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.70.0", "", { "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-adnkeeNq9Sq1sUf4+FRVc0KdgYghzsgFpZSQVZVvY0LCuUuN0FnQgyGzCJeC4fW1cdXseBAjU2EOqUIjbNcZUw=="],
"@typescript-eslint/type-utils": ["@typescript-eslint/type-utils@8.59.2", "", { "dependencies": { "@typescript-eslint/types": "8.59.2", "@typescript-eslint/typescript-estree": "8.59.2", "@typescript-eslint/utils": "8.59.2", "debug": "^4.4.3", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-nhqaj1nmTdVVl/BP5omXNRGO38jn5iosis2vbdmupF2txCf8ylWT8lx+JlvMYYVqzGVKtjojUFoQ3JRWK+mfzQ=="],
"@typescript-eslint/type-utils": ["@typescript-eslint/type-utils@8.70.0", "", { "dependencies": { "@typescript-eslint/types": "8.70.0", "@typescript-eslint/typescript-estree": "8.70.0", "@typescript-eslint/utils": "8.70.0", "debug": "^4.4.3", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-NUMKIhYVaVIVLnRL9CRt+VVcuLgSHUCpXn4/+K8wql+vdInUzvx8BjUO1oJ7cG9shjFJKtF8F8Hh2kCh3/KBVw=="],
"@typescript-eslint/types": ["@typescript-eslint/types@8.59.2", "", {}, "sha512-e82GVOE8Ps3E++Egvb6Y3Dw0S10u8NkQ9KXmtRhCWJJ8kDhOJTvtMAWnFL16kB1583goCWXsr0NieKCZMs2/0Q=="],
"@typescript-eslint/types": ["@typescript-eslint/types@8.70.0", "", {}, "sha512-asTOIYhDg4zdzOScCyaytrsV3cR6B4ecPQlXw/dJIm7J/MZTtCtfVII9JD8Geh4jTCrK/Xe6cg5UevoleMcoJQ=="],
"@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.59.2", "", { "dependencies": { "@typescript-eslint/project-service": "8.59.2", "@typescript-eslint/tsconfig-utils": "8.59.2", "@typescript-eslint/types": "8.59.2", "@typescript-eslint/visitor-keys": "8.59.2", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", "tinyglobby": "^0.2.15", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-o0XPGNwcWw+FIwStOWn+BwBuEmL6QXP0rsvAFg7ET1dey1Nr6Wb1ac8p5HEsK0ygO/6mUxlk+YWQD9xcb/nnXg=="],
"@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.70.0", "", { "dependencies": { "@typescript-eslint/project-service": "8.70.0", "@typescript-eslint/tsconfig-utils": "8.70.0", "@typescript-eslint/types": "8.70.0", "@typescript-eslint/visitor-keys": "8.70.0", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", "tinyglobby": "^0.2.15", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-d9NmHMPEKQ7QCLLm1jI3zmoQBwT5KwFYjXBJ9ymZfKCUU+5rmTRykKAFvH5Qn/ZCds3CEAFS9OC9M/jkl0X2bA=="],
"@typescript-eslint/utils": ["@typescript-eslint/utils@8.59.2", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/scope-manager": "8.59.2", "@typescript-eslint/types": "8.59.2", "@typescript-eslint/typescript-estree": "8.59.2" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-Juw3EinkXqjaffxz6roowvV7GZT/kET5vSKKZT6upl5TXdWkLkYmNPXwDDL2Vkt2DPn0nODIS4egC/0AGxKo/Q=="],
"@typescript-eslint/utils": ["@typescript-eslint/utils@8.70.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/scope-manager": "8.70.0", "@typescript-eslint/types": "8.70.0", "@typescript-eslint/typescript-estree": "8.70.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-oZmtKJz/4fufZ2p3+Cn3ijEojcdfR+1zYDH2xKYrEly0dR/Q/1xUPRCOlKGxod78nWlU2UnDe09GZ3TaknBFGA=="],
"@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.59.2", "", { "dependencies": { "@typescript-eslint/types": "8.59.2", "eslint-visitor-keys": "^5.0.0" } }, "sha512-NwjLUnGy8/Zfx23fl50tRC8rYaYnM52xNRYFAXvmiil9yh1+K6aRVQMnzW6gQB/1DLgWt977lYQn7C+wtgXZiA=="],
"@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.70.0", "", { "dependencies": { "@typescript-eslint/types": "8.70.0", "eslint-visitor-keys": "^5.0.0" } }, "sha512-BoC8PiO4Hkdo0TVJh9Ntxr5MxPDI7/oFsrygN5ADelFSeXG/qgNuucIGA+L5Z6JpPTE/uRfcTWtscjbUaufepQ=="],
"@vitejs/plugin-react": ["@vitejs/plugin-react@6.0.1", "", { "dependencies": { "@rolldown/pluginutils": "1.0.0-rc.7" }, "peerDependencies": { "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", "babel-plugin-react-compiler": "^1.0.0", "vite": "^8.0.0" }, "optionalPeers": ["@rolldown/plugin-babel", "babel-plugin-react-compiler"] }, "sha512-l9X/E3cDb+xY3SWzlG1MOGt2usfEHGMNIaegaUGFsLkb3RCn/k8/TOXBcab+OndDI4TBtktT8/9BwwW8Vi9KUQ=="],
"@vitejs/plugin-react": ["@vitejs/plugin-react@6.1.1", "", { "dependencies": { "@rolldown/pluginutils": "^1.0.1" }, "peerDependencies": { "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", "babel-plugin-react-compiler": "^1.0.0", "oxc-transform-react": "^0.145.0", "vite": "^8.0.0" }, "optionalPeers": ["@rolldown/plugin-babel", "babel-plugin-react-compiler", "oxc-transform-react"] }, "sha512-yxLaQV9gkhS8ezJqCM6+ndU7mDY6gqAg75NQ+0IjwEI8IYOmQCgkRwHKVSfWXW076DsqMo0Dk+0FK1U+M5RgFw=="],
"acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="],
"acorn": ["acorn@8.18.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ=="],
"acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="],
"agent-base": ["agent-base@6.0.2", "", { "dependencies": { "debug": "4" } }, "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ=="],
"ajv": ["ajv@6.15.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw=="],
"ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
"antd": ["antd@6.3.7", "", { "dependencies": { "@ant-design/colors": "^8.0.1", "@ant-design/cssinjs": "^2.1.2", "@ant-design/cssinjs-utils": "^2.1.2", "@ant-design/fast-color": "^3.0.1", "@ant-design/icons": "^6.1.1", "@ant-design/react-slick": "~2.0.0", "@babel/runtime": "^7.28.4", "@rc-component/cascader": "~1.14.0", "@rc-component/checkbox": "~2.0.0", "@rc-component/collapse": "~1.2.0", "@rc-component/color-picker": "~3.1.1", "@rc-component/dialog": "~1.8.4", "@rc-component/drawer": "~1.4.2", "@rc-component/dropdown": "~1.0.2", "@rc-component/form": "~1.8.1", "@rc-component/image": "~1.9.0", "@rc-component/input": "~1.1.2", "@rc-component/input-number": "~1.6.2", "@rc-component/mentions": "~1.6.0", "@rc-component/menu": "~1.2.0", "@rc-component/motion": "^1.3.2", "@rc-component/mutate-observer": "^2.0.1", "@rc-component/notification": "~1.2.0", "@rc-component/pagination": "~1.2.0", "@rc-component/picker": "~1.9.1", "@rc-component/progress": "~1.0.2", "@rc-component/qrcode": "~1.1.1", "@rc-component/rate": "~1.0.1", "@rc-component/resize-observer": "^1.1.2", "@rc-component/segmented": "~1.3.0", "@rc-component/select": "~1.6.15", "@rc-component/slider": "~1.0.1", "@rc-component/steps": "~1.2.2", "@rc-component/switch": "~1.0.3", "@rc-component/table": "~1.9.1", "@rc-component/tabs": "~1.7.0", "@rc-component/textarea": "~1.1.2", "@rc-component/tooltip": "~1.4.0", "@rc-component/tour": "~2.3.0", "@rc-component/tree": "~1.2.4", "@rc-component/tree-select": "~1.8.0", "@rc-component/trigger": "^3.9.0", "@rc-component/upload": "~1.1.0", "@rc-component/util": "^1.10.1", "clsx": "^2.1.1", "dayjs": "^1.11.11", "scroll-into-view-if-needed": "^3.1.0", "throttle-debounce": "^5.0.2" }, "peerDependencies": { "react": ">=18.0.0", "react-dom": ">=18.0.0" } }, "sha512-WTHi4bHVNKpYXLHESzU0Tts7rRNQeL84Bph9dfI3Qw7mHbTulExDcYKNHny5CTXcrBBOpraXbU9miBAwUR5vaw=="],
"argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
"antd": ["antd@6.6.3", "", { "dependencies": { "@ant-design/colors": "^8.0.1", "@ant-design/cssinjs": "^2.1.2", "@ant-design/cssinjs-utils": "^2.1.2", "@ant-design/fast-color": "^3.0.1", "@ant-design/icons": "^6.3.4", "@ant-design/react-slick": "~2.0.0", "@babel/runtime": "^7.29.2", "@rc-component/cascader": "~1.22.0", "@rc-component/checkbox": "~2.0.0", "@rc-component/collapse": "~1.2.0", "@rc-component/color-picker": "~3.1.1", "@rc-component/dialog": "~1.10.0", "@rc-component/drawer": "~1.4.2", "@rc-component/dropdown": "~1.0.3", "@rc-component/form": "~1.8.6", "@rc-component/image": "~1.10.0", "@rc-component/input": "~1.3.1", "@rc-component/input-number": "~1.6.2", "@rc-component/listy": "~1.2.3", "@rc-component/mentions": "~1.12.0", "@rc-component/menu": "~1.5.0", "@rc-component/motion": "^1.3.3", "@rc-component/mutate-observer": "^2.0.1", "@rc-component/notification": "~2.0.8", "@rc-component/pagination": "~1.4.0", "@rc-component/picker": "~1.12.2", "@rc-component/progress": "~1.0.3", "@rc-component/qrcode": "~2.0.0", "@rc-component/rate": "~1.0.1", "@rc-component/resize-observer": "^1.1.2", "@rc-component/segmented": "~1.3.0", "@rc-component/select": "~1.10.1", "@rc-component/slider": "~1.1.1", "@rc-component/steps": "~1.2.3", "@rc-component/switch": "~1.0.3", "@rc-component/table": "~1.11.1", "@rc-component/tabs": "~1.13.0", "@rc-component/tooltip": "~1.5.2", "@rc-component/tour": "~2.4.0", "@rc-component/tree": "~1.4.0", "@rc-component/tree-select": "~1.16.1", "@rc-component/trigger": "^3.10.1", "@rc-component/upload": "~1.1.1", "@rc-component/util": "^1.13.0", "clsx": "^2.1.1", "dayjs": "^1.11.11", "scroll-into-view-if-needed": "^3.1.0", "throttle-debounce": "^5.0.2" }, "peerDependencies": { "react": ">=18.0.0", "react-dom": ">=18.0.0" } }, "sha512-USnOxcOhWEQc65pHapSiCrZOijWef783XnqYZv8fuYIcYVoFxNxl6TsnsUhmoJyZheD/XwAx9CyO+hlyekDCQA=="],
"asynckit": ["asynckit@0.4.0", "", {}, "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="],
"axios": ["axios@1.16.0", "", { "dependencies": { "follow-redirects": "^1.16.0", "form-data": "^4.0.5", "proxy-from-env": "^2.1.0" } }, "sha512-6hp5CwvTPlN2A31g5dxnwAX0orzM7pmCRDLnZSX772mv8WDqICwFjowHuPs04Mc8deIld1+ejhtaMn5vp6b+1w=="],
"axios": ["axios@1.20.0", "", { "dependencies": { "follow-redirects": "^1.16.0", "form-data": "^4.0.6", "https-proxy-agent": "^5.0.1", "proxy-from-env": "^2.1.0" } }, "sha512-r8aOh8j9cGKpgQAqpzrUHnSIc6a59Y3Xf/cv8sy1DrHCkZHzQGEuoq1tARk6qSyDdtQGSDgpb9kFlruzPvrgwg=="],
"balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
"balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="],
"baseline-browser-mapping": ["baseline-browser-mapping@2.10.28", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-Ic44hnOtFIgravCunj1ifSoQPSUrkNiJuH9Mf6jr2jjoA74icqV8wU0KuadXeOR8zuIJMOoTv0GuQjZ9ZYNMeA=="],
"baseline-browser-mapping": ["baseline-browser-mapping@2.11.22", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-pWc4w51fBFd7mav43/zKRC+RI6f4yfzQoVlfvE8dECePyfkn1bzLp01Fj0QACcyCZyFhiEMyD2qScfKRWgWibA=="],
"brace-expansion": ["brace-expansion@1.1.14", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g=="],
"brace-expansion": ["brace-expansion@5.0.9", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg=="],
"browserslist": ["browserslist@4.28.2", "", { "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", "electron-to-chromium": "^1.5.328", "node-releases": "^2.0.36", "update-browserslist-db": "^1.2.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg=="],
"browserslist": ["browserslist@4.28.9", "", { "dependencies": { "baseline-browser-mapping": "^2.11.20", "caniuse-lite": "^1.0.30001810", "electron-to-chromium": "^1.5.420", "node-releases": "^2.0.54", "update-browserslist-db": "^1.3.2" }, "bin": { "browserslist": "cli.js" } }, "sha512-EWazOblFYUvlGZcfGhPUPmYh3nikUxBVb+y9MJun5f3hBi812X+8MSQTujLBtgK3cf51fJWbWfOjyeO954d+Eg=="],
"cacheable": ["cacheable@2.5.0", "", { "dependencies": { "@cacheable/memory": "^2.2.0", "@cacheable/utils": "^2.5.0", "hookified": "^1.15.0", "keyv": "^5.6.0", "qified": "^0.10.1" } }, "sha512-60cyAOytib/OzBw1JNSoSV/boK1AtHryDIjvVBk7XbN4ugfkM3+Sry7fEjNgPMGgOjuaZPAp8ruZ0Cxafwyq9g=="],
"call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="],
"callsites": ["callsites@3.1.0", "", {}, "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="],
"caniuse-lite": ["caniuse-lite@1.0.30001792", "", {}, "sha512-hVLMUZFgR4JJ6ACt1uEESvQN1/dBVqPAKY0hgrV70eN3391K6juAfTjKZLKvOMsx8PxA7gsY1/tLMMTcfFLLpw=="],
"chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
"caniuse-lite": ["caniuse-lite@1.0.30001810", "", {}, "sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg=="],
"clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
"color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
"color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
"combined-stream": ["combined-stream@1.0.8", "", { "dependencies": { "delayed-stream": "~1.0.0" } }, "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg=="],
"compute-scroll-into-view": ["compute-scroll-into-view@3.1.1", "", {}, "sha512-VRhuHOLoKYOy4UbilLbUzbYg93XLjv2PncJC50EuTWPA3gaja1UjBsUP/D/9/juV3vQFr6XBEzn9KCAHdUvOHw=="],
"concat-map": ["concat-map@0.0.1", "", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="],
"convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="],
"cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="],
@@ -342,7 +330,7 @@
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
"dayjs": ["dayjs@1.11.20", "", {}, "sha512-YbwwqR/uYpeoP4pu043q+LTDLFBLApUP6VxRihdfNTqu4ubqMlGDLd6ErXhEgsyvY0K6nCs7nggYumAN+9uEuQ=="],
"dayjs": ["dayjs@1.11.23", "", {}, "sha512-QDTCU0M0MxR3hQfnlDJfwekQiaanm1ubOD231u73WBckQ/fsamwRLiE2GBz6D3a/xF1NgfiDLJjXBa1hYOYTtQ=="],
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
@@ -354,13 +342,13 @@
"dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
"electron-to-chromium": ["electron-to-chromium@1.5.353", "", {}, "sha512-kOrWphBi8TOZyiJZqsgqIle0lw+tzmnQK83pV9dZUd01Nm2POECSyFQMAuarzZdYqQW7FH9RaYOuaRo3h+bQ3w=="],
"electron-to-chromium": ["electron-to-chromium@1.5.427", "", {}, "sha512-n14zb3FdsChZ2BNobqNHAJMcP3ifFv4paox2LvCrfVAQcqGiSURgbJl+PfMpHVCNFkStnNc+RRVtPBTVW5PDgw=="],
"es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="],
"es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="],
"es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="],
"es-object-atoms": ["es-object-atoms@1.1.2", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw=="],
"es-set-tostringtag": ["es-set-tostringtag@2.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA=="],
@@ -368,17 +356,17 @@
"escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="],
"eslint": ["eslint@9.39.4", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", "@eslint/config-array": "^0.21.2", "@eslint/config-helpers": "^0.4.2", "@eslint/core": "^0.17.0", "@eslint/eslintrc": "^3.3.5", "@eslint/js": "9.39.4", "@eslint/plugin-kit": "^0.4.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "ajv": "^6.14.0", "chalk": "^4.0.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^8.4.0", "eslint-visitor-keys": "^4.2.1", "espree": "^10.4.0", "esquery": "^1.5.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "lodash.merge": "^4.6.2", "minimatch": "^3.1.5", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ=="],
"eslint": ["eslint@10.10.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.2", "@eslint/config-array": "^0.23.5", "@eslint/config-helpers": "^0.7.0", "@eslint/core": "^1.2.1", "@eslint/plugin-kit": "^0.7.3", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "ajv": "^6.14.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^9.1.2", "eslint-visitor-keys": "^5.0.1", "espree": "^11.2.0", "esquery": "^1.7.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "11.1.5 || >11.1.6 <12", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "minimatch": "^10.2.5", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-NPXn6r5zl4uET1DAVPaOwzX3rut4c0wcmw3dWJAfOsTM5+TogXo0DDjz8pwm/hL8cyVNpHqeK4JpN0NjnyFFNw=="],
"eslint-plugin-react-hooks": ["eslint-plugin-react-hooks@7.1.1", "", { "dependencies": { "@babel/core": "^7.24.4", "@babel/parser": "^7.24.4", "hermes-parser": "^0.25.1", "zod": "^3.25.0 || ^4.0.0", "zod-validation-error": "^3.5.0 || ^4.0.0" }, "peerDependencies": { "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0" } }, "sha512-f2I7Gw6JbvCexzIInuSbZpfdQ44D7iqdWX01FKLvrPgqxoE7oMj8clOfto8U6vYiz4yd5oKu39rRSVOe1zRu0g=="],
"eslint-plugin-react-refresh": ["eslint-plugin-react-refresh@0.5.2", "", { "peerDependencies": { "eslint": "^9 || ^10" } }, "sha512-hmgTH57GfzoTFjVN0yBwTggnsVUF2tcqi7RJZHqi9lIezSs4eFyAMktA68YD4r5kNw1mxyY4dmkyoFDb3FIqrA=="],
"eslint-plugin-react-refresh": ["eslint-plugin-react-refresh@0.5.6", "", { "peerDependencies": { "eslint": "^9 || ^10" } }, "sha512-uZnh24On2bk478AkaDAKcpRiYhS3qFSaSNvpXxV6/Ek/BBDICkWGG7MnBSfjnIlCVBei5vsLlIeQYxbF22+Udg=="],
"eslint-scope": ["eslint-scope@8.4.0", "", { "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^5.2.0" } }, "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg=="],
"eslint-scope": ["eslint-scope@9.1.2", "", { "dependencies": { "@types/esrecurse": "^4.3.1", "@types/estree": "^1.0.8", "esrecurse": "^4.3.0", "estraverse": "^5.2.0" } }, "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ=="],
"eslint-visitor-keys": ["eslint-visitor-keys@4.2.1", "", {}, "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ=="],
"eslint-visitor-keys": ["eslint-visitor-keys@5.0.1", "", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="],
"espree": ["espree@10.4.0", "", { "dependencies": { "acorn": "^8.15.0", "acorn-jsx": "^5.3.2", "eslint-visitor-keys": "^4.2.1" } }, "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ=="],
"espree": ["espree@11.2.0", "", { "dependencies": { "acorn": "^8.16.0", "acorn-jsx": "^5.3.2", "eslint-visitor-keys": "^5.0.1" } }, "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw=="],
"esquery": ["esquery@1.7.0", "", { "dependencies": { "estraverse": "^5.1.0" } }, "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g=="],
@@ -396,17 +384,17 @@
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
"file-entry-cache": ["file-entry-cache@8.0.0", "", { "dependencies": { "flat-cache": "^4.0.0" } }, "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ=="],
"file-entry-cache": ["file-entry-cache@11.1.5", "", { "dependencies": { "flat-cache": "^6.1.23" } }, "sha512-+PFTHITI08JIGhnNpGNI8T8inUpgZfk3GNEqfT9R2zZV2iFXg3CvqzSl/uEhs7TSGujYRELEANyDvS8Fj7+S7Q=="],
"find-up": ["find-up@5.0.0", "", { "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" } }, "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng=="],
"flat-cache": ["flat-cache@4.0.1", "", { "dependencies": { "flatted": "^3.2.9", "keyv": "^4.5.4" } }, "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw=="],
"flat-cache": ["flat-cache@6.1.23", "", { "dependencies": { "cacheable": "^2.5.0", "flatted": "^3.4.2", "hookified": "^1.15.0" } }, "sha512-f++BY9pTk+983xK1FLzlLpmM0i0z+jHmx3QESGkURMXujQZz1k5wzwX6hjnQ8goaD0B+sYnDK1yZ6MTyZfUaqA=="],
"flatted": ["flatted@3.4.2", "", {}, "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA=="],
"flatted": ["flatted@3.4.4", "", {}, "sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q=="],
"follow-redirects": ["follow-redirects@1.16.0", "", {}, "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw=="],
"form-data": ["form-data@4.0.5", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.2", "mime-types": "^2.1.12" } }, "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w=="],
"form-data": ["form-data@4.0.6", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.4", "mime-types": "^2.1.35" } }, "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ=="],
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
@@ -420,32 +408,34 @@
"glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="],
"globals": ["globals@17.6.0", "", {}, "sha512-sepffkT8stwnIYbsMBpoCHJuJM5l98FUF2AnE07hfvE0m/qp3R586hw4jF4uadbhvg1ooIdzuu7CsfD2jzCaNA=="],
"globals": ["globals@17.12.0", "", {}, "sha512-cezEd/DTyyht9cvSSURyygXPfy04GtWO/5e6ZPvH7fCtjKz9PYOmuawphw1Ctd1f6C+5JypXfGD7ahNMXvevBA=="],
"gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="],
"has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="],
"has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="],
"has-tostringtag": ["has-tostringtag@1.0.2", "", { "dependencies": { "has-symbols": "^1.0.3" } }, "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw=="],
"hasown": ["hasown@2.0.3", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg=="],
"hashery": ["hashery@1.5.1", "", { "dependencies": { "hookified": "^1.15.0" } }, "sha512-iZyKG96/JwPz1N55vj2Ie2vXbhu440zfUfJvSwEqEbeLluk7NnapfGqa7LH0mOsnDxTF85Mx8/dyR6HfqcbmbQ=="],
"hasown": ["hasown@2.0.4", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A=="],
"hermes-estree": ["hermes-estree@0.25.1", "", {}, "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw=="],
"hermes-parser": ["hermes-parser@0.25.1", "", { "dependencies": { "hermes-estree": "0.25.1" } }, "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA=="],
"html-parse-stringify": ["html-parse-stringify@3.0.1", "", { "dependencies": { "void-elements": "3.1.0" } }, "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg=="],
"hookified": ["hookified@1.15.1", "", {}, "sha512-MvG/clsADq1GPM2KGo2nyfaWVyn9naPiXrqIe4jYjXNZQt238kWyOGrsyc/DmRAQ+Re6yeo6yX/yoNCG5KAEVg=="],
"i18next": ["i18next@25.10.10", "", { "dependencies": { "@babel/runtime": "^7.29.2" }, "peerDependencies": { "typescript": "^5 || ^6" }, "optionalPeers": ["typescript"] }, "sha512-cqUW2Z3EkRx7NqSyywjkgCLK7KLCL6IFVFcONG7nVYIJ3ekZ1/N5jUsihHV6Bq37NfhgtczxJcxduELtjTwkuQ=="],
"html-parse-stringify": ["html-parse-stringify@4.0.1", "", {}, "sha512-0zHsZJrK7S3K2aucXWL6ycoYJ/iNtIcFHC/nYQgFklPtrv5LpJctIiSCroWZWeuoXvuyFdzp6KzjJQ+OT5MfFw=="],
"https-proxy-agent": ["https-proxy-agent@5.0.1", "", { "dependencies": { "agent-base": "6", "debug": "4" } }, "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA=="],
"i18next": ["i18next@26.4.2", "", { "peerDependencies": { "typescript": "^5 || ^6 || ^7" }, "optionalPeers": ["typescript"] }, "sha512-RX+R0VLg13IbvRuJSxnqykUFS9vQZTl8wYpWPCIUDWVrSGjsQywB5Y+pjzrkboxGAuYfJZVH1InFTdgBdxq6ug=="],
"i18next-browser-languagedetector": ["i18next-browser-languagedetector@8.2.1", "", { "dependencies": { "@babel/runtime": "^7.23.2" } }, "sha512-bZg8+4bdmaOiApD7N7BPT9W8MLZG+nPTOFlLiJiT8uzKXFjhxw4v2ierCXOwB5sFDMtuA5G4kgYZ0AznZxQ/cw=="],
"ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="],
"import-fresh": ["import-fresh@3.3.1", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="],
"imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="],
"is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="],
@@ -458,12 +448,8 @@
"js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
"js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="],
"jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="],
"json-buffer": ["json-buffer@3.0.1", "", {}, "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ=="],
"json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="],
"json-stable-stringify-without-jsonify": ["json-stable-stringify-without-jsonify@1.0.1", "", {}, "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw=="],
@@ -472,38 +458,36 @@
"json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="],
"keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="],
"keyv": ["keyv@5.6.0", "", { "dependencies": { "@keyv/serialize": "^1.1.1" } }, "sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw=="],
"levn": ["levn@0.4.1", "", { "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="],
"lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="],
"lightningcss": ["lightningcss@1.33.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.33.0", "lightningcss-darwin-arm64": "1.33.0", "lightningcss-darwin-x64": "1.33.0", "lightningcss-freebsd-x64": "1.33.0", "lightningcss-linux-arm-gnueabihf": "1.33.0", "lightningcss-linux-arm64-gnu": "1.33.0", "lightningcss-linux-arm64-musl": "1.33.0", "lightningcss-linux-x64-gnu": "1.33.0", "lightningcss-linux-x64-musl": "1.33.0", "lightningcss-win32-arm64-msvc": "1.33.0", "lightningcss-win32-x64-msvc": "1.33.0" } }, "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA=="],
"lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="],
"lightningcss-android-arm64": ["lightningcss-android-arm64@1.33.0", "", { "os": "android", "cpu": "arm64" }, "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg=="],
"lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.32.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ=="],
"lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.33.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg=="],
"lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.32.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w=="],
"lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.33.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ=="],
"lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.32.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig=="],
"lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.33.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg=="],
"lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.32.0", "", { "os": "linux", "cpu": "arm" }, "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw=="],
"lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.33.0", "", { "os": "linux", "cpu": "arm" }, "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ=="],
"lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ=="],
"lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.33.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg=="],
"lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg=="],
"lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.33.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ=="],
"lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA=="],
"lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.33.0", "", { "os": "linux", "cpu": "x64" }, "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg=="],
"lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg=="],
"lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.33.0", "", { "os": "linux", "cpu": "x64" }, "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw=="],
"lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.32.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw=="],
"lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.33.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA=="],
"lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.32.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q=="],
"lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.33.0", "", { "os": "win32", "cpu": "x64" }, "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA=="],
"locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="],
"lodash.merge": ["lodash.merge@4.6.2", "", {}, "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ=="],
"lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
"math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
@@ -512,15 +496,15 @@
"mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
"minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="],
"minimatch": ["minimatch@10.2.6", "", { "dependencies": { "brace-expansion": "^5.0.8" } }, "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A=="],
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
"nanoid": ["nanoid@3.3.12", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ=="],
"nanoid": ["nanoid@3.3.19", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug=="],
"natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="],
"node-releases": ["node-releases@2.0.38", "", {}, "sha512-3qT/88Y3FbH/Kx4szpQQ4HzUbVrHPKTLVpVocKiLfoYvw9XSGOX2FmD2d6DrXbVYyAQTF2HeF6My8jmzx7/CRw=="],
"node-releases": ["node-releases@2.0.55", "", {}, "sha512-mIrE/Cw9y+9Au6dS5vDKDhQza9YvG6w+ZrS6X+ZzA7yFW/soAeaups4Qzn1bL6g5FVy8WtP79+0j82oPIbqRjQ=="],
"optionator": ["optionator@0.9.4", "", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="],
@@ -528,17 +512,15 @@
"p-locate": ["p-locate@5.0.0", "", { "dependencies": { "p-limit": "^3.0.2" } }, "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw=="],
"parent-module": ["parent-module@1.0.1", "", { "dependencies": { "callsites": "^3.0.0" } }, "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g=="],
"path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="],
"path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
"picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="],
"picomatch": ["picomatch@4.0.7", "", {}, "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA=="],
"postcss": ["postcss@8.5.14", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg=="],
"postcss": ["postcss@8.5.28", "", { "dependencies": { "nanoid": "^3.3.18", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A=="],
"prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="],
@@ -546,23 +528,23 @@
"punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
"react": ["react@19.2.6", "", {}, "sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q=="],
"qified": ["qified@0.10.1", "", { "dependencies": { "hookified": "^2.1.1" } }, "sha512-+Owyggi9IxT1ePKGafcI87ubSmxol6smwJ+RAHDQlx9+9cPwFWDiKFFCPuWhr9ignlGpZ9vDQLw67N4dcTVFEA=="],
"react-dom": ["react-dom@19.2.6", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.6" } }, "sha512-0prMI+hvBbPjsWnxDLxlCGyM8PN6UuWjEUCYmZhO67xIV9Xasa/r/vDnq+Xyq4Lo27g8QSbO5YzARu0D1Sps3g=="],
"react": ["react@19.3.0", "", {}, "sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog=="],
"react-i18next": ["react-i18next@16.6.6", "", { "dependencies": { "@babel/runtime": "^7.29.2", "html-parse-stringify": "^3.0.1", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "i18next": ">= 25.10.9", "react": ">= 16.8.0", "typescript": "^5 || ^6" }, "optionalPeers": ["typescript"] }, "sha512-ZgL2HUoW34UKUkOV7uSQFE1CDnRPD+tCR3ywSuWH7u2iapnz86U8Bi3Vrs620qNDzCf1F47NxglCEkchCTDOHw=="],
"react-dom": ["react-dom@19.3.0", "", { "dependencies": { "scheduler": "^0.28.0" }, "peerDependencies": { "react": "^19.3.0" } }, "sha512-JDk8dgif51OjFoDE70+OT9ICyYr+69HlmihNwp1+Nsfbna3t5sIiCa9ZJktDmQ4/1b/rn26hIAR2uYXDMr5r0Q=="],
"react-is": ["react-is@18.3.1", "", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="],
"react-i18next": ["react-i18next@17.0.13", "", { "dependencies": { "@babel/runtime": "^7.29.7", "html-parse-stringify": "^4.0.1", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "i18next": ">= 26.2.0", "react": ">= 16.8.0", "react-dom": "*", "react-native": "*", "typescript": "^5 || ^6 || ^7" }, "optionalPeers": ["react-dom", "react-native", "typescript"] }, "sha512-Cc1PscmblIHA1kljTqDwrcVMI21ydgmUzw0UAeQBe7pAOgfuRLfzXze4EUBQoeDiICzFIXXhHFoZxuetNg5D0Q=="],
"react-router": ["react-router@7.15.0", "", { "dependencies": { "cookie": "^1.0.1", "set-cookie-parser": "^2.6.0" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" }, "optionalPeers": ["react-dom"] }, "sha512-HW9vYwuM8f4yx66Izy8xfrzCM+SBJluoZcCbww9A1TySax11S5Vgw6fi3ZjMONw9J4gQwngL7PzkyIpJJpJ7RQ=="],
"react-is": ["react-is@19.3.0", "", {}, "sha512-UpMYezM4v5/18F28aC66AEsjXIgE02kyEMH6yLdgLXu/UTfa1Ntwck/nNLrbqJsEXW7gPb0coNO9FQse9WTovA=="],
"react-router-dom": ["react-router-dom@7.15.0", "", { "dependencies": { "react-router": "7.15.0" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" } }, "sha512-VcrVg64Fo8nwBvDscajG8gRTLIuTC6N50nb22l2HOOV4PTOHgoGp8mUjy9wLiHYoYTSYI36tUnXZgasSRFZorQ=="],
"react-router": ["react-router@7.18.3", "", { "dependencies": { "cookie": "^1.0.1", "set-cookie-parser": "^2.6.0" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" }, "optionalPeers": ["react-dom"] }, "sha512-gyXgtdr5uACJ5b1Q4udzjVV+tb/rlHIMJKuJ0e89R4Kzgz47z/rgP0dIKxktqIEUhDHluGTPJJH/wRha7CyqsA=="],
"resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="],
"react-router-dom": ["react-router-dom@7.18.3", "", { "dependencies": { "react-router": "7.18.3" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" } }, "sha512-ytVbyBBM7vMfRCam25r0WMhSVSom909A8p+8m0/f1w853dz/xfFu6etAT2SEbVoSnI+ZoPRDqIsQXVT89gp7kg=="],
"rolldown": ["rolldown@1.0.0-rc.18", "", { "dependencies": { "@oxc-project/types": "=0.128.0", "@rolldown/pluginutils": "1.0.0-rc.18" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.0.0-rc.18", "@rolldown/binding-darwin-arm64": "1.0.0-rc.18", "@rolldown/binding-darwin-x64": "1.0.0-rc.18", "@rolldown/binding-freebsd-x64": "1.0.0-rc.18", "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.18", "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.18", "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.18", "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.18", "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.18", "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.18", "@rolldown/binding-linux-x64-musl": "1.0.0-rc.18", "@rolldown/binding-openharmony-arm64": "1.0.0-rc.18", "@rolldown/binding-wasm32-wasi": "1.0.0-rc.18", "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.18", "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.18" }, "bin": { "rolldown": "bin/cli.mjs" } }, "sha512-phmyKBpuBdRYDf4hgyynGAYn/rDDe+iZXKVJ7WX5b1zQzpLkP5oJRPGsfJuHdzPMlyyEO/4sPW6yfSx2gf7lVg=="],
"rolldown": ["rolldown@1.2.8", "", { "dependencies": { "@oxc-project/types": "=0.149.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm-eabi": "1.2.8", "@rolldown/binding-android-arm64": "1.2.8", "@rolldown/binding-darwin-arm64": "1.2.8", "@rolldown/binding-darwin-x64": "1.2.8", "@rolldown/binding-freebsd-x64": "1.2.8", "@rolldown/binding-linux-arm-gnueabihf": "1.2.8", "@rolldown/binding-linux-arm64-gnu": "1.2.8", "@rolldown/binding-linux-arm64-musl": "1.2.8", "@rolldown/binding-linux-ppc64-gnu": "1.2.8", "@rolldown/binding-linux-s390x-gnu": "1.2.8", "@rolldown/binding-linux-x64-gnu": "1.2.8", "@rolldown/binding-linux-x64-musl": "1.2.8", "@rolldown/binding-openharmony-arm64": "1.2.8", "@rolldown/binding-win32-arm64-msvc": "1.2.8", "@rolldown/binding-win32-x64-msvc": "1.2.8" }, "bin": { "rolldown": "./bin/cli.mjs" } }, "sha512-Z67nTmhZe7anqnM/EjI392w5i/ANUinjip7QYsOyN37oayduxt3ksdX0hf5OOamkAd53BiIHfbfSzfUmzKFQqQ=="],
"scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
"scheduler": ["scheduler@0.28.0", "", {}, "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw=="],
"scroll-into-view-if-needed": ["scroll-into-view-if-needed@3.1.0", "", { "dependencies": { "compute-scroll-into-view": "^3.0.2" } }, "sha512-49oNpRjWRvnU8NyGVmUaYG4jtTkNonFZI86MmGRDqBphEK2EXT9gdEUoQPZhuBM8yWHxCWbobltqYO5M4XrUvQ=="],
@@ -578,37 +560,29 @@
"string-convert": ["string-convert@0.2.1", "", {}, "sha512-u/1tdPl4yQnPBjnVrmdLo9gtuLvELKsAoRapekWggdiQNvvvum+jYF329d84NAa660KQw7pB2n36KrIKVoXa3A=="],
"strip-json-comments": ["strip-json-comments@3.1.1", "", {}, "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig=="],
"stylis": ["stylis@4.4.0", "", {}, "sha512-5Z9ZpRzfuH6l/UAvCPAPUo3665Nk2wLaZU3x+TLHKVzIz33+sbJqbtrYoC3KD4/uVOr2Zp+L0LySezP9OHV9yA=="],
"supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
"throttle-debounce": ["throttle-debounce@5.0.2", "", {}, "sha512-B71/4oyj61iNH0KeCamLuE2rmKuTO5byTOSVwECM5FA7TiAiAW+UqTKZ9ERueC4qvgSttUhdmq1mXC3kJqGX7A=="],
"tinyglobby": ["tinyglobby@0.2.16", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg=="],
"tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="],
"ts-api-utils": ["ts-api-utils@2.5.0", "", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA=="],
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"type-check": ["type-check@0.4.0", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="],
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"typescript": ["typescript@6.0.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="],
"typescript-eslint": ["typescript-eslint@8.59.2", "", { "dependencies": { "@typescript-eslint/eslint-plugin": "8.59.2", "@typescript-eslint/parser": "8.59.2", "@typescript-eslint/typescript-estree": "8.59.2", "@typescript-eslint/utils": "8.59.2" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-pJw051uomb3ZeCzGTpRb8RbEqB5Y4WWet8gl/GcTlU35BSx0PVdZ86/bqkQCyKKuraVQEK7r6kBHQXF+fBhkoQ=="],
"typescript-eslint": ["typescript-eslint@8.70.0", "", { "dependencies": { "@typescript-eslint/eslint-plugin": "8.70.0", "@typescript-eslint/parser": "8.70.0", "@typescript-eslint/typescript-estree": "8.70.0", "@typescript-eslint/utils": "8.70.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-P/W5cz70/cQAuKfY3xwQMWWTV7BvJ0mAQmi+9mBcsVPaBUpd6Ohpa+fECv9rBFrQcig86jAiNBFNWUqnTjr4pw=="],
"undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
"undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
"update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="],
"update-browserslist-db": ["update-browserslist-db@1.3.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-pJ2sYawQS0R/WI928Gj5GlPhTGzbMelq0+4INtSYNDV9ErKJcX6xjGWkoG/VnB3dpUm00zALaqkrUD77pO5TDQ=="],
"uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="],
"use-sync-external-store": ["use-sync-external-store@1.6.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w=="],
"use-sync-external-store": ["use-sync-external-store@1.7.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-6L+EeigHMQhdaIPNIFUKwfWJSwWFQ8gJbJ2DLOs5sDIegTwR9fRxvnM3uciHKjIZhFz+KAv2emhWMRvDmMcY8A=="],
"vite": ["vite@8.0.11", "", { "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", "postcss": "^8.5.14", "rolldown": "1.0.0-rc.18", "tinyglobby": "^0.2.16" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.1.18", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-Jz1mxtUBR5xTT65VOdJZUUeoyLtqljmFkiUXhPTLZka3RDc9vpi/xXkyrnsdRcm2lIi3l3GPMnAidTsEGIj3Ow=="],
"void-elements": ["void-elements@3.1.0", "", {}, "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w=="],
"vite": ["vite@8.3.0", "", { "dependencies": { "lightningcss": "^1.33.0", "picomatch": "^4.0.7", "postcss": "^8.5.28", "rolldown": "~1.2.6", "tinyglobby": "^0.2.17" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.7.1", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-lhZBVvEHefgE+HQZC9O7EBJgCU/nVzFNl7vkS4RE0APtWLP02/8QVIkQtzBxPquh7lq5/78NHipTj7ODQ6XuyQ=="],
"which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
@@ -618,28 +592,20 @@
"yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="],
"zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="],
"zod": ["zod@4.6.2", "", {}, "sha512-lh5RCAGFa1Cm2hjtNwLQhSs/AsqdWnTQaBER9fEwN/88pSh7KOtJavtBx/0VlkN/uFd61SwYmljLMDAsHlvzBQ=="],
"zod-validation-error": ["zod-validation-error@4.0.2", "", { "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ=="],
"zustand": ["zustand@5.0.13", "", { "peerDependencies": { "@types/react": ">=18.0.0", "immer": ">=9.0.6", "react": ">=18.0.0", "use-sync-external-store": ">=1.2.0" }, "optionalPeers": ["@types/react", "immer", "react", "use-sync-external-store"] }, "sha512-efI2tVaVQPqtOh114loML/Z80Y4NP3yc+Ff0fYiZJPauNeWZeIp/bRFD7I9bfmCOYBh/PHxlglQ9+wvlwnPikQ=="],
"zustand": ["zustand@5.0.15", "", { "peerDependencies": { "@types/react": ">=18.0.0", "immer": ">=9.0.6", "react": ">=18.0.0", "use-sync-external-store": ">=1.2.0" }, "optionalPeers": ["@types/react", "immer", "react", "use-sync-external-store"] }, "sha512-MpSEjRiBkA9crSYeOUH32rJC7SVqAbm0Fqcqge/bUi2PPoLcBWKOsG+C8mevmpr8TwXHBVkChbbJiyvkE+i/3A=="],
"@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="],
"@eslint/eslintrc/globals": ["globals@14.0.0", "", {}, "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ=="],
"@rc-component/virtual-list/@babel/runtime": ["@babel/runtime@8.0.5", "", {}, "sha512-7NK+Lz3spQ52XsUGTxIEVU4jYN2/dIaX8sTxRFAUtYmttYZnVh3aehiihv+/Gb7+duMNHl2OrFcBQRyOHScxpg=="],
"@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="],
"@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.9", "", {}, "sha512-brTTsvFRt5C1gGHtPst/281UjPD5t9fBqbgoMPlVWy11ZLTPfu7HxK4ZYqO9H7o/yC9rSTCI85EaQ4OoY12qYw=="],
"@typescript-eslint/typescript-estree/minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="],
"@typescript-eslint/typescript-estree/semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="],
"@typescript-eslint/typescript-estree/semver": ["semver@7.8.0", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA=="],
"@typescript-eslint/visitor-keys/eslint-visitor-keys": ["eslint-visitor-keys@5.0.1", "", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="],
"rolldown/@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.18", "", {}, "sha512-CUY5Mnhe64xQBGZEEXQ5WyZwsc1JU3vAZLIxtrsBt3LO6UOb+C8GunVKqe9sT8NeWb4lqSaoJtp2xo6GxT1MNw=="],
"@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@5.0.6", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g=="],
"@typescript-eslint/typescript-estree/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="],
"qified/hookified": ["hookified@2.2.0", "", {}, "sha512-p/LgFzRN5FeoD3DLS6bkUapeye6E4SI6yJs6KetENd18S+FBthqYq2amJUWpt5z0EQwwHemidjY5OqJGEKm5uA=="],
}
}

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

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

View File

@@ -10,30 +10,30 @@
"preview": "vite preview"
},
"dependencies": {
"@ant-design/icons": "^6.1.0",
"@tanstack/react-query": "^5.95.2",
"antd": "^6.3.4",
"axios": "^1.13.6",
"i18next": "^25.10.9",
"@ant-design/icons": "^6.3.4",
"@tanstack/react-query": "^5.102.8",
"antd": "^6.6.3",
"axios": "^1.20.0",
"i18next": "^26.4.2",
"i18next-browser-languagedetector": "^8.2.1",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"react-i18next": "^16.6.6",
"react-router-dom": "^7.13.2",
"zustand": "^5.0.12"
"react": "^19.3.0",
"react-dom": "^19.3.0",
"react-i18next": "^17.0.13",
"react-router-dom": "^7.18.3",
"zustand": "^5.0.15"
},
"devDependencies": {
"@eslint/js": "^9.39.4",
"@types/node": "^24.12.2",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1",
"eslint": "^9.39.4",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.4.0",
"typescript": "~5.9.3",
"typescript-eslint": "^8.57.0",
"vite": "^8.0.1"
"@eslint/js": "^10.0.1",
"@types/node": "^24.13.4",
"@types/react": "^19.3.0",
"@types/react-dom": "^19.3.0",
"@vitejs/plugin-react": "^6.1.1",
"eslint": "^10.10.0",
"eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.6",
"globals": "^17.12.0",
"typescript": "6.0.3",
"typescript-eslint": "^8.70.0",
"vite": "^8.3.0"
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -201,7 +201,7 @@ export default function DashboardPage() {
})
const recentAlerts = useQuery({
queryKey: ['alerts', 'events', 'recent'],
queryFn: () => fetchList<AlertEvent>('/alerts/events?limit=10', 'events'),
queryFn: () => fetchList<AlertEvent>('/alerts/events?limit=10&open=true', 'events'),
refetchInterval: 60_000,
})
const services = useQuery({
@@ -303,6 +303,13 @@ export default function DashboardPage() {
return down
})()
// Auf dem keepalived-BACKUP-Node erreicht die lokale HAProxy die Backend-
// Subnetze nicht (die VLAN-Gateway-VIPs liegen beim Master) → sie sieht
// ALLE Backends als down. Das ist strukturell erwartet, kein Ausfall:
// der Master bedient den Traffic. Deshalb den roten Down-Alarm auf dem
// Standby durch einen ruhigen Hinweis ersetzen.
const isBackup = vipStatus.data?.vrrp_state === 'BACKUP'
const alerts = recentAlerts.data ?? []
const nCritical = alerts.filter(e => e.severity === 'critical' || e.severity === 'error').length
const nWarning = alerts.filter(e => e.severity === 'warning').length
@@ -361,18 +368,25 @@ export default function DashboardPage() {
</span>
</Space>
}
action={<Link to="/alerts" style={{ fontSize: 12 }}>{t('dashboard.alertsCard.viewAll')} </Link>}
action={<Link to="/alerts?tab=events" style={{ fontSize: 12 }}>{t('dashboard.alertsCard.viewAll')} </Link>}
/>
)}
{/* ─ Operational alerts ─────────────────────────────── */}
{downBackends.length > 0 && (
{downBackends.length > 0 && !isBackup && (
<Alert
type="error" showIcon className="mb-12"
message={t('dashboard.downBackendsAlert', { count: downBackends.length })}
description={<Space wrap size={4}>{downBackends.map(n => <Link key={n} to="/backends">{n}</Link>)}</Space>}
/>
)}
{downBackends.length > 0 && isBackup && (
<Alert
type="info" showIcon className="mb-12"
message={t('dashboard.backupNodeBackends')}
description={t('dashboard.backupNodeBackendsDesc')}
/>
)}
{maintenanceDomains.length > 0 && (
<Alert
type="warning" showIcon className="mb-12"
@@ -663,7 +677,18 @@ function ClusterStatusCard({ nodes, status }: ClusterStatusCardProps) {
padding: '4px 0', borderBottom: '1px solid #F1F5F9', fontSize: 12,
}}>
<code style={{ color: '#334155' }}>{n.fqdn}</code>
<Tag color={n.role === 'primary' ? 'green' : 'default'} style={{ margin: 0 }}>{n.role}</Tag>
{/* ha_nodes.role ist die DB-/Cluster-Rolle (wohin Schreibzugriffe
gehen) und wandert bewusst NICHT mit der VIP — sie aendert
sich nur durch `edgeguard-ctl promote`. Ein nacktes "primary"
hier las sich neben der VIP-Karte ("BACKUP") wie ein
Widerspruch, deshalb explizit als DB-Rolle beschriftet. */}
<Tooltip title={t('dashboard.clusterCard.roleHint')}>
<Tag color={n.role === 'primary' ? 'green' : 'default'} style={{ margin: 0 }}>
{n.role === 'primary'
? t('dashboard.clusterCard.roleDbPrimary')
: t('dashboard.clusterCard.rolePeer')}
</Tag>
</Tooltip>
</div>
))}
</Space>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

193
scripts/release.sh Executable file
View File

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