From f1f7df74f7eaaa9ab6b0ac100bb663098350bdc9 Mon Sep 17 00:00:00 2001 From: Debian Date: Thu, 6 Aug 2026 22:23:30 +0200 Subject: [PATCH] =?UTF-8?q?feat(crowdsec):=20UI-Schalter=20=E2=80=9Evertra?= =?UTF-8?q?uensw=C3=BCrdiges=20Admin-Panel"=20=E2=86=92=20gerenderte=20Cro?= =?UTF-8?q?wdSec-Whitelist=20=E2=80=94=20v1.3.19?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- VERSION | 2 +- cmd/edgeguard-api/main.go | 13 ++- cmd/edgeguard-ctl/render.go | 5 +- internal/crowdsec/whitelist.go | 107 ++++++++++++++++++ internal/crowdsec/whitelist_test.go | 35 ++++++ .../0048_domains_crowdsec_trusted.sql | 18 +++ internal/models/domain.go | 3 + internal/services/domains/domains.go | 22 ++-- management-ui/src/i18n/locales/de/common.json | 4 +- management-ui/src/i18n/locales/en/common.json | 4 +- management-ui/src/pages/Domains/Detail.tsx | 8 ++ .../debian/edgeguard-api/DEBIAN/postinst | 13 ++- 12 files changed, 219 insertions(+), 15 deletions(-) create mode 100644 internal/crowdsec/whitelist.go create mode 100644 internal/crowdsec/whitelist_test.go create mode 100644 internal/database/migrations/0048_domains_crowdsec_trusted.sql diff --git a/VERSION b/VERSION index 672b322..32b1dc7 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.3.18 \ No newline at end of file +1.3.19 \ No newline at end of file diff --git a/cmd/edgeguard-api/main.go b/cmd/edgeguard-api/main.go index 33e5fa5..08ccdb1 100644 --- a/cmd/edgeguard-api/main.go +++ b/cmd/edgeguard-api/main.go @@ -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) diff --git a/cmd/edgeguard-ctl/render.go b/cmd/edgeguard-ctl/render.go index c7403a3..1e4789b 100644 --- a/cmd/edgeguard-ctl/render.go +++ b/cmd/edgeguard-ctl/render.go @@ -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) } diff --git a/internal/crowdsec/whitelist.go b/internal/crowdsec/whitelist.go new file mode 100644 index 0000000..50e89da --- /dev/null +++ b/internal/crowdsec/whitelist.go @@ -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() +} diff --git a/internal/crowdsec/whitelist_test.go b/internal/crowdsec/whitelist_test.go new file mode 100644 index 0000000..506d816 --- /dev/null +++ b/internal/crowdsec/whitelist_test.go @@ -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) + } + }) +} diff --git a/internal/database/migrations/0048_domains_crowdsec_trusted.sql b/internal/database/migrations/0048_domains_crowdsec_trusted.sql new file mode 100644 index 0000000..8c70bfe --- /dev/null +++ b/internal/database/migrations/0048_domains_crowdsec_trusted.sql @@ -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 diff --git a/internal/models/domain.go b/internal/models/domain.go index 8e175b6..e069d23 100644 --- a/internal/models/domain.go +++ b/internal/models/domain.go @@ -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"` } diff --git a/internal/services/domains/domains.go b/internal/services/domains/domains.go index 758ab98..702f1e0 100644 --- a/internal/services/domains/domains.go +++ b/internal/services/domains/domains.go @@ -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 } diff --git a/management-ui/src/i18n/locales/de/common.json b/management-ui/src/i18n/locales/de/common.json index 9638860..2bda3b3 100644 --- a/management-ui/src/i18n/locales/de/common.json +++ b/management-ui/src/i18n/locales/de/common.json @@ -567,7 +567,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", diff --git a/management-ui/src/i18n/locales/en/common.json b/management-ui/src/i18n/locales/en/common.json index fc57163..9bfcf64 100644 --- a/management-ui/src/i18n/locales/en/common.json +++ b/management-ui/src/i18n/locales/en/common.json @@ -567,7 +567,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", diff --git a/management-ui/src/pages/Domains/Detail.tsx b/management-ui/src/pages/Domains/Detail.tsx index 32a5ec4..49fe998 100644 --- a/management-ui/src/pages/Domains/Detail.tsx +++ b/management-ui/src/pages/Domains/Detail.tsx @@ -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() { + + + + diff --git a/packaging/debian/edgeguard-api/DEBIAN/postinst b/packaging/debian/edgeguard-api/DEBIAN/postinst index 9674bcf..b6093a1 100755 --- a/packaging/debian/edgeguard-api/DEBIAN/postinst +++ b/packaging/debian/edgeguard-api/DEBIAN/postinst @@ -203,7 +203,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 @@ -868,6 +870,15 @@ whitelist: 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 fi # ── Render initial service configs ───────────────────────────