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() {