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>
This commit is contained in:
@@ -6,6 +6,7 @@ package main
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
|
"errors"
|
||||||
"log"
|
"log"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -22,6 +23,7 @@ import (
|
|||||||
"git.netcell-it.de/projekte/edgeguard-native/internal/cluster"
|
"git.netcell-it.de/projekte/edgeguard-native/internal/cluster"
|
||||||
"git.netcell-it.de/projekte/edgeguard-native/internal/cluster/clustertls"
|
"git.netcell-it.de/projekte/edgeguard-native/internal/cluster/clustertls"
|
||||||
"git.netcell-it.de/projekte/edgeguard-native/internal/cluster/jointoken"
|
"git.netcell-it.de/projekte/edgeguard-native/internal/cluster/jointoken"
|
||||||
|
"git.netcell-it.de/projekte/edgeguard-native/internal/crowdsec"
|
||||||
"git.netcell-it.de/projekte/edgeguard-native/internal/database"
|
"git.netcell-it.de/projekte/edgeguard-native/internal/database"
|
||||||
firewallrender "git.netcell-it.de/projekte/edgeguard-native/internal/firewall"
|
firewallrender "git.netcell-it.de/projekte/edgeguard-native/internal/firewall"
|
||||||
radiusrender "git.netcell-it.de/projekte/edgeguard-native/internal/freeradius"
|
radiusrender "git.netcell-it.de/projekte/edgeguard-native/internal/freeradius"
|
||||||
@@ -307,6 +309,7 @@ func main() {
|
|||||||
"unbound": unboundrender.New(pool).RenderToString,
|
"unbound": unboundrender.New(pool).RenderToString,
|
||||||
"chrony": chronyrender.New(pool).RenderToString,
|
"chrony": chronyrender.New(pool).RenderToString,
|
||||||
"wireguard": wgrender.New(pool, secretsBox).RenderToString,
|
"wireguard": wgrender.New(pool, secretsBox).RenderToString,
|
||||||
|
"crowdsec-whitelist": crowdsec.NewWhitelistGenerator(pool).RenderToString,
|
||||||
})
|
})
|
||||||
setupHdl.WithAudit(auditRepo, nodeID)
|
setupHdl.WithAudit(auditRepo, nodeID)
|
||||||
setupHdl.WithClusterSupport(clusterStore, func(ctx context.Context) error {
|
setupHdl.WithClusterSupport(clusterStore, func(ctx context.Context) error {
|
||||||
@@ -323,6 +326,14 @@ func main() {
|
|||||||
return haproxy.New(pool).Render(ctx)
|
return haproxy.New(pool).Render(ctx)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Domain-Mutationen rendern zusätzlich die CrowdSec-Admin-Whitelist neu
|
||||||
|
// (Flag crowdsec_trusted → host-genaue Ausnahme). No-op ohne CrowdSec.
|
||||||
|
// Beide laufen unabhängig; Fehler werden zusammengefasst (nur geloggt).
|
||||||
|
crowdsecWL := crowdsec.NewWhitelistGenerator(pool)
|
||||||
|
domainsReloader := func(ctx context.Context) error {
|
||||||
|
return errors.Join(haproxy.New(pool).Render(ctx), crowdsecWL.Render(ctx))
|
||||||
|
}
|
||||||
|
|
||||||
authed := v1.Group("")
|
authed := v1.Group("")
|
||||||
authed.Use(requireAuth, handlers.RequireAdminForMutations())
|
authed.Use(requireAuth, handlers.RequireAdminForMutations())
|
||||||
setupHdl.RegisterAuthed(authed)
|
setupHdl.RegisterAuthed(authed)
|
||||||
@@ -335,7 +346,7 @@ func main() {
|
|||||||
WithAudit(auditRepo, nodeID)
|
WithAudit(auditRepo, nodeID)
|
||||||
oidcHdl.RegisterPublic(v1)
|
oidcHdl.RegisterPublic(v1)
|
||||||
oidcHdl.RegisterAdmin(authed)
|
oidcHdl.RegisterAdmin(authed)
|
||||||
handlers.NewDomainsHandler(domainsRepo, routingRepo, domainHeadersRepo, auditRepo, nodeID, haproxyReloader).Register(authed)
|
handlers.NewDomainsHandler(domainsRepo, routingRepo, domainHeadersRepo, auditRepo, nodeID, domainsReloader).Register(authed)
|
||||||
handlers.NewBackendsHandler(backendsRepo, auditRepo, nodeID, haproxyReloader).Register(authed)
|
handlers.NewBackendsHandler(backendsRepo, auditRepo, nodeID, haproxyReloader).Register(authed)
|
||||||
handlers.NewBackendServersHandler(backendServersRepo, auditRepo, nodeID, haproxyReloader).Register(authed)
|
handlers.NewBackendServersHandler(backendServersRepo, auditRepo, nodeID, haproxyReloader).Register(authed)
|
||||||
handlers.NewRoutingRulesHandler(routingRepo, auditRepo, nodeID, haproxyReloader).Register(authed)
|
handlers.NewRoutingRulesHandler(routingRepo, auditRepo, nodeID, haproxyReloader).Register(authed)
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
"git.netcell-it.de/projekte/edgeguard-native/internal/chrony"
|
"git.netcell-it.de/projekte/edgeguard-native/internal/chrony"
|
||||||
"git.netcell-it.de/projekte/edgeguard-native/internal/cluster"
|
"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/configgen"
|
||||||
|
"git.netcell-it.de/projekte/edgeguard-native/internal/crowdsec"
|
||||||
"git.netcell-it.de/projekte/edgeguard-native/internal/database"
|
"git.netcell-it.de/projekte/edgeguard-native/internal/database"
|
||||||
"git.netcell-it.de/projekte/edgeguard-native/internal/firewall"
|
"git.netcell-it.de/projekte/edgeguard-native/internal/firewall"
|
||||||
"git.netcell-it.de/projekte/edgeguard-native/internal/freeradius"
|
"git.netcell-it.de/projekte/edgeguard-native/internal/freeradius"
|
||||||
@@ -65,6 +66,7 @@ func cmdRenderConfig(args []string) int {
|
|||||||
cn := chrony.New(pool)
|
cn := chrony.New(pool)
|
||||||
ke := kea.New(pool)
|
ke := kea.New(pool)
|
||||||
fr := freeradius.New(pool, secrets.New(""))
|
fr := freeradius.New(pool, secrets.New(""))
|
||||||
|
cw := crowdsec.NewWhitelistGenerator(pool)
|
||||||
if skipReload {
|
if skipReload {
|
||||||
hap.SkipReload = true
|
hap.SkipReload = true
|
||||||
fw.SkipReload = true
|
fw.SkipReload = true
|
||||||
@@ -74,6 +76,7 @@ func cmdRenderConfig(args []string) int {
|
|||||||
cn.SkipReload = true
|
cn.SkipReload = true
|
||||||
ke.SkipReload = true
|
ke.SkipReload = true
|
||||||
fr.SkipReload = true
|
fr.SkipReload = true
|
||||||
|
cw.SkipReload = true
|
||||||
}
|
}
|
||||||
|
|
||||||
// keepalived: Node-ID aus node.conf für Prioritäts-Berechnung
|
// keepalived: Node-ID aus node.conf für Prioritäts-Berechnung
|
||||||
@@ -82,7 +85,7 @@ func cmdRenderConfig(args []string) int {
|
|||||||
ka = keepalived.New(pool, lc.NodeID)
|
ka = keepalived.New(pool, lc.NodeID)
|
||||||
}
|
}
|
||||||
|
|
||||||
gens := []configgen.Generator{hap, fw, sq, wg, ub, cn, ke, fr}
|
gens := []configgen.Generator{hap, fw, sq, wg, ub, cn, ke, fr, cw}
|
||||||
if ka != nil {
|
if ka != nil {
|
||||||
gens = append(gens, ka)
|
gens = append(gens, ka)
|
||||||
}
|
}
|
||||||
|
|||||||
107
internal/crowdsec/whitelist.go
Normal file
107
internal/crowdsec/whitelist.go
Normal 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()
|
||||||
|
}
|
||||||
35
internal/crowdsec/whitelist_test.go
Normal file
35
internal/crowdsec/whitelist_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -20,6 +20,9 @@ type Domain struct {
|
|||||||
DisableH3 bool `gorm:"column:disable_h3" json:"disable_h3"`
|
DisableH3 bool `gorm:"column:disable_h3" json:"disable_h3"`
|
||||||
Notes *string `gorm:"column:notes" json:"notes,omitempty"`
|
Notes *string `gorm:"column:notes" json:"notes,omitempty"`
|
||||||
RedirectTo string `gorm:"column:redirect_to" json:"redirect_to"` // ""=aus; sonst 301-Ziel-URL (Domain→Domain)
|
RedirectTo string `gorm:"column:redirect_to" json:"redirect_to"` // ""=aus; sonst 301-Ziel-URL (Domain→Domain)
|
||||||
|
// CrowdSecTrusted: vertrauenswürdiges Admin-Panel → dessen Hostname wird in
|
||||||
|
// die CrowdSec-Whitelist gerendert (Admin-SPA-Traffic ist kein Crawl).
|
||||||
|
CrowdSecTrusted bool `gorm:"column:crowdsec_trusted" json:"crowdsec_trusted"`
|
||||||
CreatedAt time.Time `gorm:"column:created_at" json:"created_at"`
|
CreatedAt time.Time `gorm:"column:created_at" json:"created_at"`
|
||||||
UpdatedAt time.Time `gorm:"column:updated_at" json:"updated_at"`
|
UpdatedAt time.Time `gorm:"column:updated_at" json:"updated_at"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ SELECT id, name, active, primary_backend_id, http_to_https,
|
|||||||
hsts_enabled, hsts_max_age, hsts_subdomains, hsts_preload,
|
hsts_enabled, hsts_max_age, hsts_subdomains, hsts_preload,
|
||||||
maintenance_mode, maintenance_message, www_redirect,
|
maintenance_mode, maintenance_message, www_redirect,
|
||||||
rate_limit_rps, max_body_kb, disable_h3,
|
rate_limit_rps, max_body_kb, disable_h3,
|
||||||
notes, redirect_to, created_at, updated_at
|
notes, redirect_to, crowdsec_trusted, created_at, updated_at
|
||||||
FROM domains
|
FROM domains
|
||||||
`
|
`
|
||||||
|
|
||||||
@@ -65,17 +65,19 @@ func (r *Repo) Create(ctx context.Context, d models.Domain) (*models.Domain, err
|
|||||||
INSERT INTO domains (name, active, primary_backend_id, http_to_https,
|
INSERT INTO domains (name, active, primary_backend_id, http_to_https,
|
||||||
hsts_enabled, hsts_max_age, hsts_subdomains, hsts_preload,
|
hsts_enabled, hsts_max_age, hsts_subdomains, hsts_preload,
|
||||||
maintenance_mode, maintenance_message, www_redirect,
|
maintenance_mode, maintenance_message, www_redirect,
|
||||||
rate_limit_rps, max_body_kb, disable_h3, notes, redirect_to)
|
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)
|
crowdsec_trusted)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17)
|
||||||
RETURNING id, name, active, primary_backend_id, http_to_https,
|
RETURNING id, name, active, primary_backend_id, http_to_https,
|
||||||
hsts_enabled, hsts_max_age, hsts_subdomains, hsts_preload,
|
hsts_enabled, hsts_max_age, hsts_subdomains, hsts_preload,
|
||||||
maintenance_mode, maintenance_message, www_redirect,
|
maintenance_mode, maintenance_message, www_redirect,
|
||||||
rate_limit_rps, max_body_kb, disable_h3,
|
rate_limit_rps, max_body_kb, disable_h3,
|
||||||
notes, redirect_to, created_at, updated_at`,
|
notes, redirect_to, crowdsec_trusted, created_at, updated_at`,
|
||||||
d.Name, d.Active, d.PrimaryBackendID, d.HTTPToHTTPS,
|
d.Name, d.Active, d.PrimaryBackendID, d.HTTPToHTTPS,
|
||||||
d.HSTSEnabled, d.HSTSMaxAge, d.HSTSSubdomains, d.HSTSPreload,
|
d.HSTSEnabled, d.HSTSMaxAge, d.HSTSSubdomains, d.HSTSPreload,
|
||||||
d.MaintenanceMode, d.MaintenanceMessage, d.WWWRedirect,
|
d.MaintenanceMode, d.MaintenanceMessage, d.WWWRedirect,
|
||||||
d.RateLimitRPS, d.MaxBodyKB, d.DisableH3, d.Notes, d.RedirectTo)
|
d.RateLimitRPS, d.MaxBodyKB, d.DisableH3, d.Notes, d.RedirectTo,
|
||||||
|
d.CrowdSecTrusted)
|
||||||
return scanDomain(row)
|
return scanDomain(row)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -101,17 +103,19 @@ UPDATE domains SET
|
|||||||
disable_h3 = $14,
|
disable_h3 = $14,
|
||||||
notes = $15,
|
notes = $15,
|
||||||
redirect_to = $16,
|
redirect_to = $16,
|
||||||
|
crowdsec_trusted = $17,
|
||||||
updated_at = NOW()
|
updated_at = NOW()
|
||||||
WHERE id = $17
|
WHERE id = $18
|
||||||
RETURNING id, name, active, primary_backend_id, http_to_https,
|
RETURNING id, name, active, primary_backend_id, http_to_https,
|
||||||
hsts_enabled, hsts_max_age, hsts_subdomains, hsts_preload,
|
hsts_enabled, hsts_max_age, hsts_subdomains, hsts_preload,
|
||||||
maintenance_mode, maintenance_message, www_redirect,
|
maintenance_mode, maintenance_message, www_redirect,
|
||||||
rate_limit_rps, max_body_kb, disable_h3,
|
rate_limit_rps, max_body_kb, disable_h3,
|
||||||
notes, redirect_to, created_at, updated_at`,
|
notes, redirect_to, crowdsec_trusted, created_at, updated_at`,
|
||||||
d.Name, d.Active, d.PrimaryBackendID, d.HTTPToHTTPS,
|
d.Name, d.Active, d.PrimaryBackendID, d.HTTPToHTTPS,
|
||||||
d.HSTSEnabled, d.HSTSMaxAge, d.HSTSSubdomains, d.HSTSPreload,
|
d.HSTSEnabled, d.HSTSMaxAge, d.HSTSSubdomains, d.HSTSPreload,
|
||||||
d.MaintenanceMode, d.MaintenanceMessage, d.WWWRedirect,
|
d.MaintenanceMode, d.MaintenanceMessage, d.WWWRedirect,
|
||||||
d.RateLimitRPS, d.MaxBodyKB, d.DisableH3, d.Notes, d.RedirectTo, id)
|
d.RateLimitRPS, d.MaxBodyKB, d.DisableH3, d.Notes, d.RedirectTo,
|
||||||
|
d.CrowdSecTrusted, id)
|
||||||
out, err := scanDomain(row)
|
out, err := scanDomain(row)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, pgx.ErrNoRows) {
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
@@ -140,7 +144,7 @@ func scanDomain(row interface{ Scan(...any) error }) (*models.Domain, error) {
|
|||||||
&d.HSTSEnabled, &d.HSTSMaxAge, &d.HSTSSubdomains, &d.HSTSPreload,
|
&d.HSTSEnabled, &d.HSTSMaxAge, &d.HSTSSubdomains, &d.HSTSPreload,
|
||||||
&d.MaintenanceMode, &d.MaintenanceMessage, &d.WWWRedirect,
|
&d.MaintenanceMode, &d.MaintenanceMessage, &d.WWWRedirect,
|
||||||
&d.RateLimitRPS, &d.MaxBodyKB, &d.DisableH3,
|
&d.RateLimitRPS, &d.MaxBodyKB, &d.DisableH3,
|
||||||
&d.Notes, &d.RedirectTo, &d.CreatedAt, &d.UpdatedAt,
|
&d.Notes, &d.RedirectTo, &d.CrowdSecTrusted, &d.CreatedAt, &d.UpdatedAt,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.",
|
"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.",
|
"routingRulesEmpty": "Keine Routing-Regeln — alle Anfragen gehen an das Primary-Backend.",
|
||||||
"backendUp": "Backend UP",
|
"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": {
|
"backends": {
|
||||||
"title": "Backends",
|
"title": "Backends",
|
||||||
|
|||||||
@@ -567,7 +567,9 @@
|
|||||||
"routingRulesHint": "Path-prefix → backend mappings for this domain. Lowest priority number wins; unmatched requests go to the primary backend.",
|
"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.",
|
"routingRulesEmpty": "No routing rules — all requests go to the primary backend.",
|
||||||
"backendUp": "backend UP",
|
"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": {
|
"backends": {
|
||||||
"title": "Backends",
|
"title": "Backends",
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ interface Domain {
|
|||||||
redirect_to: string
|
redirect_to: string
|
||||||
rate_limit_rps: number; max_body_kb: number
|
rate_limit_rps: number; max_body_kb: number
|
||||||
disable_h3: boolean
|
disable_h3: boolean
|
||||||
|
crowdsec_trusted: boolean
|
||||||
notes?: string | null
|
notes?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -44,6 +45,7 @@ interface DomainFormValues {
|
|||||||
redirect_to: string
|
redirect_to: string
|
||||||
rate_limit_rps: number; max_body_kb: number
|
rate_limit_rps: number; max_body_kb: number
|
||||||
disable_h3: boolean
|
disable_h3: boolean
|
||||||
|
crowdsec_trusted: boolean
|
||||||
notes?: string
|
notes?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -278,6 +280,7 @@ export default function DomainDetailPage() {
|
|||||||
rate_limit_rps: domain.rate_limit_rps ?? 0,
|
rate_limit_rps: domain.rate_limit_rps ?? 0,
|
||||||
max_body_kb: domain.max_body_kb ?? 0,
|
max_body_kb: domain.max_body_kb ?? 0,
|
||||||
disable_h3: domain.disable_h3 ?? false,
|
disable_h3: domain.disable_h3 ?? false,
|
||||||
|
crowdsec_trusted: domain.crowdsec_trusted ?? false,
|
||||||
notes: domain.notes ?? '',
|
notes: domain.notes ?? '',
|
||||||
}}
|
}}
|
||||||
onFinish={(v) => update.mutate(v)}
|
onFinish={(v) => update.mutate(v)}
|
||||||
@@ -367,6 +370,11 @@ export default function DomainDetailPage() {
|
|||||||
<Switch />
|
<Switch />
|
||||||
</Form.Item>
|
</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">
|
<Form.Item label={t('domains.notes')} name="notes">
|
||||||
<Input.TextArea rows={2} />
|
<Input.TextArea rows={2} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|||||||
@@ -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 enable edgeguard-waf.service
|
||||||
edgeguard ALL=(root) NOPASSWD: /usr/bin/systemctl disable 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
|
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 start crowdsec.service
|
||||||
edgeguard ALL=(root) NOPASSWD: /usr/bin/systemctl stop crowdsec.service
|
edgeguard ALL=(root) NOPASSWD: /usr/bin/systemctl stop crowdsec.service
|
||||||
edgeguard ALL=(root) NOPASSWD: /usr/bin/systemctl enable crowdsec.service
|
edgeguard ALL=(root) NOPASSWD: /usr/bin/systemctl enable crowdsec.service
|
||||||
@@ -868,6 +870,15 @@ whitelist:
|
|||||||
WL
|
WL
|
||||||
systemctl reload crowdsec 2>/dev/null \
|
systemctl reload crowdsec 2>/dev/null \
|
||||||
|| systemctl restart crowdsec 2>/dev/null || true
|
|| 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
|
fi
|
||||||
|
|
||||||
# ── Render initial service configs ───────────────────────────
|
# ── Render initial service configs ───────────────────────────
|
||||||
|
|||||||
Reference in New Issue
Block a user