From 1740cb7ae26fa21f61c5f49a3f4cc24e601f9c47 Mon Sep 17 00:00:00 2001 From: Debian Date: Mon, 1 Jun 2026 20:25:48 +0200 Subject: [PATCH] =?UTF-8?q?feat(crowdsec):=20CrowdSec=20IDS/IPS=20Manageme?= =?UTF-8?q?nt=20=E2=80=94=20v1.2.61?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Backend: internal/crowdsec/service.go — vollständige cscli-Wrapper (Decisions, Alerts, Bouncers, Machines, Collections, ServiceStatus) - Handler: 12 REST-Endpoints mit Audit-Logging unter /crowdsec/* - Migration 0036: crowdsec_settings-Tabelle - postinst: CrowdSec-Auto-Install (crowdsec + crowdsec-firewall-bouncer-nftables) inkl. sudoers-Einträge für alle cscli-Operationen - systemd: /var/lib/crowdsec in ReadWritePaths - UI: CrowdSec-Page mit StatusStrip + 5 Tabs (Decisions, Alerts, Bouncers, Machines, Collections), Sidebar-Eintrag, i18n EN+DE - firewall: flush ruleset → flush table inet edgeguard (CrowdSec-nftables-Table bleibt bei Firewall-Render erhalten) - cluster: Firewall-Reload nur bei echter IP-Änderung, nicht bei jedem periodischen Secondary-Heartbeat (verhindert nftables-Counter-Reset) Co-Authored-By: Claude Sonnet 4.6 --- VERSION | 2 +- cmd/edgeguard-api/main.go | 1 + deploy/systemd/edgeguard-api.service | 2 +- internal/crowdsec/service.go | 428 ++++++++++++++ .../database/migrations/0036_crowdsec.sql | 12 + internal/firewall/ruleset.nft.tpl | 3 +- internal/handlers/cluster.go | 26 +- internal/handlers/crowdsec.go | 285 ++++++++++ management-ui/src/App.tsx | 2 + .../src/components/Layout/Sidebar.tsx | 2 + management-ui/src/i18n/locales/de/common.json | 78 +++ management-ui/src/i18n/locales/en/common.json | 78 +++ management-ui/src/pages/CrowdSec/index.tsx | 533 ++++++++++++++++++ management-ui/src/pages/CrowdSec/types.ts | 79 +++ .../debian/edgeguard-api/DEBIAN/postinst | 38 ++ 15 files changed, 1561 insertions(+), 8 deletions(-) create mode 100644 internal/crowdsec/service.go create mode 100644 internal/database/migrations/0036_crowdsec.sql create mode 100644 internal/handlers/crowdsec.go create mode 100644 management-ui/src/pages/CrowdSec/index.tsx create mode 100644 management-ui/src/pages/CrowdSec/types.ts diff --git a/VERSION b/VERSION index 3e23e56..7a94c73 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.2.54 +1.2.61 diff --git a/cmd/edgeguard-api/main.go b/cmd/edgeguard-api/main.go index 9827613..011c90f 100644 --- a/cmd/edgeguard-api/main.go +++ b/cmd/edgeguard-api/main.go @@ -382,6 +382,7 @@ func main() { return firewallrender.New(pool).Render(ctx) } handlers.NewFirewallHandler(fwZones, fwAddrObj, fwAddrGrp, fwSvc, fwSvcGrp, fwRules, fwNAT, auditRepo, nodeID, fwReloader, pool).Register(authed) + handlers.NewCrowdSecHandler(auditRepo, nodeID).Register(authed) // withFW wraps a service-reloader so that AFTER the service is // reloaded, the firewall is also re-rendered. Necessary for diff --git a/deploy/systemd/edgeguard-api.service b/deploy/systemd/edgeguard-api.service index 7679a39..7e3e88f 100644 --- a/deploy/systemd/edgeguard-api.service +++ b/deploy/systemd/edgeguard-api.service @@ -41,7 +41,7 @@ SystemCallFilter=@system-service # direkt in den distro-Conf-Dir (chrony+unbound) bzw. legen Symlinks # nach /etc/edgeguard/wireguard (wg). Ohne diese Pfade scheitern alle # UI-Mutationen an DNS/NTP/WireGuard-Settings still mit EROFS. -ReadWritePaths=/etc/edgeguard /var/lib/edgeguard /var/log/edgeguard /var/backups/edgeguard /var/lib/apt /var/cache/apt /etc/apt/apt.conf.d /etc/chrony/conf.d /etc/unbound/unbound.conf.d /etc/wireguard +ReadWritePaths=/etc/edgeguard /var/lib/edgeguard /var/log/edgeguard /var/backups/edgeguard /var/lib/apt /var/cache/apt /etc/apt/apt.conf.d /etc/chrony/conf.d /etc/unbound/unbound.conf.d /etc/wireguard /var/lib/crowdsec [Install] WantedBy=multi-user.target diff --git a/internal/crowdsec/service.go b/internal/crowdsec/service.go new file mode 100644 index 0000000..fdb59af --- /dev/null +++ b/internal/crowdsec/service.go @@ -0,0 +1,428 @@ +// Package crowdsec wraps sudo /usr/bin/cscli calls for the edgeguard +// management API. All list operations use -o json. Mutation operations +// (add/delete) use the appropriate cscli sub-commands. +// +// edgeguard runs as a non-root system user; every cscli call goes +// through sudo (allowed entries are in /etc/sudoers.d/edgeguard). +package crowdsec + +import ( + "bufio" + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "log/slog" + "os" + "os/exec" + "strings" +) + +// ErrNotInstalled is returned when /usr/bin/cscli is not found. +var ErrNotInstalled = errors.New("crowdsec not installed") + +// IsInstalled checks whether /usr/bin/cscli exists on this host. +func IsInstalled() bool { + _, err := os.Stat("/usr/bin/cscli") + return err == nil +} + +// ---------- Types ----------------------------------------------------------- + +// Decision represents a single IP decision (ban/captcha/etc.) in CrowdSec. +type Decision struct { + ID int64 `json:"id"` + Origin string `json:"origin"` + Type string `json:"type"` + Scope string `json:"scope"` + Value string `json:"value"` + Duration string `json:"duration"` + Reason string `json:"reason"` + Country string `json:"country,omitempty"` + AS string `json:"as,omitempty"` +} + +// Alert represents a CrowdSec alert with associated decisions. +type Alert struct { + ID int64 `json:"id"` + Scenario string `json:"scenario"` + EventsCount int `json:"events_count"` + Source AlertSource `json:"source"` + StartAt string `json:"start_at"` + StopAt string `json:"stop_at"` + Decisions []Decision `json:"decisions,omitempty"` +} + +// AlertSource holds the source IP/range info for an alert. +type AlertSource struct { + IP string `json:"ip"` + Country string `json:"cn,omitempty"` + ASName string `json:"as_name,omitempty"` + Range string `json:"range,omitempty"` + Scope string `json:"scope,omitempty"` + Value string `json:"value,omitempty"` +} + +// Bouncer represents a registered CrowdSec bouncer. +type Bouncer struct { + Name string `json:"name"` + IPAddress string `json:"ip_address,omitempty"` + Revoked bool `json:"revoked"` + LastPull string `json:"last_pull,omitempty"` + Type string `json:"type,omitempty"` + Version string `json:"version,omitempty"` + CreatedAt string `json:"created_at"` + AuthType string `json:"auth_type,omitempty"` +} + +// Machine represents a registered CrowdSec agent/machine. +type Machine struct { + MachineID string `json:"machineId"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` + LastPush string `json:"last_push,omitempty"` + IsValidated bool `json:"isValidated"` + Version string `json:"version,omitempty"` + Status string `json:"status,omitempty"` +} + +// HubItem represents a CrowdSec hub item (collection, parser, scenario, etc.). +type HubItem struct { + Name string `json:"name"` + Description string `json:"description,omitempty"` + Status string `json:"status"` + LocalVersion string `json:"local_version,omitempty"` + LocalPath string `json:"local_path,omitempty"` + Author string `json:"author,omitempty"` + Type string `json:"type,omitempty"` +} + +// Status summarises the runtime state of the CrowdSec stack on this node. +type Status struct { + Installed bool `json:"installed"` + AgentRunning bool `json:"agent_running"` + BouncerRunning bool `json:"bouncer_running"` + Version string `json:"version,omitempty"` + DecisionCount int `json:"decision_count"` + AlertCount int `json:"alert_count"` + BouncerCount int `json:"bouncer_count"` + MachineCount int `json:"machine_count"` +} + +// ---------- Helpers --------------------------------------------------------- + +// sudoCscli executes `sudo -n /usr/bin/cscli ` and returns stdout. +func sudoCscli(ctx context.Context, args ...string) ([]byte, error) { + full := append([]string{"-n", "/usr/bin/cscli"}, args...) + cmd := exec.CommandContext(ctx, "sudo", full...) + var out, errBuf bytes.Buffer + cmd.Stdout = &out + cmd.Stderr = &errBuf + if err := cmd.Run(); err != nil { + slog.Error("crowdsec: sudoCscli failed", "args", args, "error", err, "stderr", errBuf.String()) + return nil, err + } + if errBuf.Len() > 0 { + slog.Warn("crowdsec: sudoCscli stderr", "args", args, "stderr", errBuf.String()) + } + slog.Debug("crowdsec: sudoCscli ok", "args", args[0], "bytes", out.Len()) + return out.Bytes(), nil +} + +// systemctlActive returns true when the named unit is "active". +func systemctlActive(ctx context.Context, unit string) bool { + cmd := exec.CommandContext(ctx, "systemctl", "is-active", "--quiet", unit) + return cmd.Run() == nil +} + +// unmarshalSlice unmarshals JSON that may be "null" (cscli returns null +// instead of [] when no items exist). Returns an empty slice in that case. +func unmarshalSlice[T any](data []byte) ([]T, error) { + data = bytes.TrimSpace(data) + if bytes.Equal(data, []byte("null")) || len(data) == 0 { + return []T{}, nil + } + var result []T + if err := json.Unmarshal(data, &result); err != nil { + return nil, err + } + return result, nil +} + +// ---------- ServiceStatus --------------------------------------------------- + +// ServiceStatus returns a Status struct describing the current state of the +// CrowdSec agent and bouncer on this node. Does NOT need cscli installed — +// it uses systemctl for the running-state checks. Version is extracted via +// `cscli version` when available. +func ServiceStatus(ctx context.Context) Status { + st := Status{ + Installed: IsInstalled(), + AgentRunning: systemctlActive(ctx, "crowdsec"), + BouncerRunning: systemctlActive(ctx, "crowdsec-firewall-bouncer"), + } + + if st.Installed { + // Grab version from `sudo -n /usr/bin/cscli version` — first line only. + // Output is not JSON; it looks like "version: v1.6.3-..." + if out, err := sudoCscli(ctx, "version"); err == nil { + scanner := bufio.NewScanner(bytes.NewReader(out)) + if scanner.Scan() { + st.Version = strings.TrimSpace(scanner.Text()) + } + } + + // Best-effort counts — ignore errors (agent may be stopped). + if decisions, err := Decisions(ctx); err == nil { + st.DecisionCount = len(decisions) + } + if alerts, err := Alerts(ctx, 500); err == nil { + st.AlertCount = len(alerts) + } + if bouncers, err := Bouncers(ctx); err == nil { + st.BouncerCount = len(bouncers) + } + if machines, err := Machines(ctx); err == nil { + st.MachineCount = len(machines) + } + } + + return st +} + +// ---------- Decisions ------------------------------------------------------- + +// cscli decisions list -o json returns alert-level objects with nested +// decisions[] arrays. These intermediate types are used only for parsing. +type cscliDecisionRaw struct { + ID int64 `json:"id"` + Duration string `json:"duration"` + Origin string `json:"origin"` + Scope string `json:"scope"` + Type string `json:"type"` + Value string `json:"value"` +} + +type cscliAlertRaw struct { + Scenario string `json:"scenario"` + Decisions []cscliDecisionRaw `json:"decisions"` + Source struct { + IP string `json:"ip"` + CN string `json:"cn"` + ASName string `json:"as_name"` + } `json:"source"` +} + +// Decisions lists all active decisions by flattening the alert-level JSON +// that cscli emits (each alert contains a nested decisions[] array). +func Decisions(ctx context.Context) ([]Decision, error) { + if !IsInstalled() { + return nil, ErrNotInstalled + } + out, err := sudoCscli(ctx, "decisions", "list", "-o", "json") + if err != nil { + return nil, err + } + alerts, err := unmarshalSlice[cscliAlertRaw](out) + if err != nil { + return nil, err + } + var result []Decision + for _, a := range alerts { + for _, d := range a.Decisions { + result = append(result, Decision{ + ID: d.ID, + Origin: d.Origin, + Type: d.Type, + Scope: d.Scope, + Value: d.Value, + Duration: d.Duration, + Reason: a.Scenario, + Country: a.Source.CN, + AS: a.Source.ASName, + }) + } + } + if result == nil { + result = []Decision{} + } + return result, nil +} + +// AddDecision creates a new ban/captcha decision for the given IP. +func AddDecision(ctx context.Context, ip, duration, reason, typ string) error { + if !IsInstalled() { + return ErrNotInstalled + } + _, err := sudoCscli(ctx, "decisions", "add", + "--ip", ip, + "--duration", duration, + "--reason", reason, + "--type", typ, + ) + return err +} + +// DeleteDecisionByIP removes all decisions for a given IP address. +func DeleteDecisionByIP(ctx context.Context, ip string) error { + if !IsInstalled() { + return ErrNotInstalled + } + _, err := sudoCscli(ctx, "decisions", "delete", "--ip", ip) + return err +} + +// DeleteDecisionByID removes a single decision by its numeric ID. +func DeleteDecisionByID(ctx context.Context, id string) error { + if !IsInstalled() { + return ErrNotInstalled + } + _, err := sudoCscli(ctx, "decisions", "delete", "--id", id) + return err +} + +// ---------- Alerts ---------------------------------------------------------- + +// Alerts lists recent alerts (up to limit). +func Alerts(ctx context.Context, limit int) ([]Alert, error) { + if !IsInstalled() { + return nil, ErrNotInstalled + } + out, err := sudoCscli(ctx, "alerts", "list", "-o", "json", + "-l", fmt.Sprintf("%d", limit)) + if err != nil { + return nil, err + } + return unmarshalSlice[Alert](out) +} + +// DeleteAlert discards (deletes) a single alert by its ID. +func DeleteAlert(ctx context.Context, id string) error { + if !IsInstalled() { + return ErrNotInstalled + } + _, err := sudoCscli(ctx, "alerts", "delete", "--id", id) + return err +} + +// ---------- Bouncers -------------------------------------------------------- + +// Bouncers lists all registered bouncers. +func Bouncers(ctx context.Context) ([]Bouncer, error) { + if !IsInstalled() { + return nil, ErrNotInstalled + } + out, err := sudoCscli(ctx, "bouncers", "list", "-o", "json") + if err != nil { + return nil, err + } + return unmarshalSlice[Bouncer](out) +} + +// DeleteBouncer removes a bouncer by name. +func DeleteBouncer(ctx context.Context, name string) error { + if !IsInstalled() { + return ErrNotInstalled + } + _, err := sudoCscli(ctx, "bouncers", "delete", name) + return err +} + +// ---------- Machines -------------------------------------------------------- + +// cscliMachineRaw mirrors the actual cscli JSON with its mixed camelCase / +// snake_case field names. Only used inside Machines(). +type cscliMachineRaw struct { + MachineID string `json:"machineId"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` + LastPush string `json:"last_push"` + IsValidated bool `json:"isValidated"` + Version string `json:"version"` + Status string `json:"status"` +} + +// Machines lists all registered machines/agents. +func Machines(ctx context.Context) ([]Machine, error) { + if !IsInstalled() { + return nil, ErrNotInstalled + } + out, err := sudoCscli(ctx, "machines", "list", "-o", "json") + if err != nil { + return nil, err + } + raw, err := unmarshalSlice[cscliMachineRaw](out) + if err != nil { + return nil, err + } + result := make([]Machine, len(raw)) + for i, r := range raw { + result[i] = Machine{ + MachineID: r.MachineID, + CreatedAt: r.CreatedAt, + UpdatedAt: r.UpdatedAt, + LastPush: r.LastPush, + IsValidated: r.IsValidated, + Version: r.Version, + Status: r.Status, + } + } + return result, nil +} + +// DeleteMachine removes a machine by its machine ID. +func DeleteMachine(ctx context.Context, id string) error { + if !IsInstalled() { + return ErrNotInstalled + } + _, err := sudoCscli(ctx, "machines", "delete", "--machine-id", id) + return err +} + +// ---------- Collections ----------------------------------------------------- + +// Collections lists installed/available hub collections. +// cscli returns {"collections": [...]} (not a flat array) — we unwrap the key. +func Collections(ctx context.Context) ([]HubItem, error) { + if !IsInstalled() { + return nil, ErrNotInstalled + } + out, err := sudoCscli(ctx, "collections", "list", "-o", "json") + if err != nil { + return nil, err + } + out = bytes.TrimSpace(out) + if bytes.Equal(out, []byte("null")) || len(out) == 0 { + return []HubItem{}, nil + } + // cscli wraps collections in {"collections": [...]} + var wrapper struct { + Collections []HubItem `json:"collections"` + } + if err := json.Unmarshal(out, &wrapper); err != nil { + return nil, err + } + if wrapper.Collections == nil { + return []HubItem{}, nil + } + return wrapper.Collections, nil +} + +// InstallCollection installs a hub collection by name (--force to upgrade). +func InstallCollection(ctx context.Context, name string) error { + if !IsInstalled() { + return ErrNotInstalled + } + _, err := sudoCscli(ctx, "collections", "install", name, "--force") + return err +} + +// RemoveCollection removes a hub collection by name. +func RemoveCollection(ctx context.Context, name string) error { + if !IsInstalled() { + return ErrNotInstalled + } + _, err := sudoCscli(ctx, "collections", "remove", name) + return err +} diff --git a/internal/database/migrations/0036_crowdsec.sql b/internal/database/migrations/0036_crowdsec.sql new file mode 100644 index 0000000..9b6edad --- /dev/null +++ b/internal/database/migrations/0036_crowdsec.sql @@ -0,0 +1,12 @@ +-- +goose Up +CREATE TABLE IF NOT EXISTS crowdsec_settings ( + id INTEGER PRIMARY KEY DEFAULT 1 CHECK (id = 1), + enabled BOOLEAN NOT NULL DEFAULT false, + simulation_mode BOOLEAN NOT NULL DEFAULT false, + collections TEXT[] NOT NULL DEFAULT '{"crowdsecurity/linux","crowdsecurity/haproxy"}', + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +INSERT INTO crowdsec_settings (id) VALUES (1) ON CONFLICT DO NOTHING; + +-- +goose Down +DROP TABLE IF EXISTS crowdsec_settings; diff --git a/internal/firewall/ruleset.nft.tpl b/internal/firewall/ruleset.nft.tpl index e3d3cdb..faa1547 100644 --- a/internal/firewall/ruleset.nft.tpl +++ b/internal/firewall/ruleset.nft.tpl @@ -3,7 +3,8 @@ # Source: internal/firewall/firewall.go. # Re-generate via `edgeguard-ctl render-config` or via API mutations. -flush ruleset +add table inet edgeguard +flush table inet edgeguard table inet edgeguard { set peer_ipv4 { diff --git a/internal/handlers/cluster.go b/internal/handlers/cluster.go index 15ce07f..0214a37 100644 --- a/internal/handlers/cluster.go +++ b/internal/handlers/cluster.go @@ -637,6 +637,14 @@ func (h *ClusterHandler) preRegisterJoiner(parent context.Context, clientIP, csr slog.Info("cluster: joiner pre-registered, firewall updated", "fqdn", fqdn, "ip", clientIP) } +// ptrStr dereferences a *string safely for comparison; nil → "". +func ptrStr(s *string) string { + if s == nil { + return "" + } + return *s +} + // cnFromCSR extracts the Subject Common Name from a PEM-encoded CSR. // Returns empty string on any parse error. func cnFromCSR(csrPEM string) string { @@ -912,17 +920,25 @@ func (h *ClusterHandler) AgentRegisterPeer(c *gin.Context) { // ha_nodes_fqdn_unique" scheitern und der Peer bliebe ewig "joining". _ = h.Store.DeletePlaceholdersByFQDN(c.Request.Context(), req.FQDN, req.ID) + // Snapshot der aktuellen IPs VOR dem Upsert — zum Vergleich danach. + // Nur wenn sich public_ip oder internal_ip ändert, müssen wir nftables + // neu laden (@peer_ipv4-Set). Periodische Pushes vom Secondary (alle + // 5 min) ändern nur version/config_hash, nicht die IPs → kein Reset. + existing, _ := h.Store.Get(c.Request.Context(), req.ID) + out, err := h.Store.UpsertSelf(c.Request.Context(), n) if err != nil { response.Internal(c, err) return } - // Firewall-Reload damit peer_ipv4-Set die neue IP aufnimmt. Best- - // effort: Fehler loggen, Response weiter durchreichen — der Peer - // hat seine Identity erfolgreich registriert, Operator kann manuell - // nachrendern. - if h.PeerReloader != nil { + // Firewall-Reload nur wenn sich die Peer-IP geändert hat oder der + // Peer neu eingetragen wurde. Verhindert Counter-Reset alle 5 min + // durch den periodischen Secondary-Push (runPrimaryPush). + ipChanged := existing == nil || + ptrStr(existing.PublicIP) != ptrStr(out.PublicIP) || + ptrStr(existing.InternalIP) != ptrStr(out.InternalIP) + if ipChanged && h.PeerReloader != nil { go func() { rctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() diff --git a/internal/handlers/crowdsec.go b/internal/handlers/crowdsec.go new file mode 100644 index 0000000..3f3d4b7 --- /dev/null +++ b/internal/handlers/crowdsec.go @@ -0,0 +1,285 @@ +package handlers + +import ( + "errors" + "net/http" + + "github.com/gin-gonic/gin" + + crowdsec "git.netcell-it.de/projekte/edgeguard-native/internal/crowdsec" + "git.netcell-it.de/projekte/edgeguard-native/internal/handlers/response" + "git.netcell-it.de/projekte/edgeguard-native/internal/services/audit" +) + +// CrowdSecHandler exposes the CrowdSec IDS/IPS management REST API: +// +// GET /crowdsec/status +// GET /crowdsec/decisions +// POST /crowdsec/decisions +// DELETE /crowdsec/decisions (?ip= or ?id=) +// GET /crowdsec/alerts +// DELETE /crowdsec/alerts/:id +// GET /crowdsec/bouncers +// DELETE /crowdsec/bouncers/:name +// GET /crowdsec/machines +// DELETE /crowdsec/machines/:id +// GET /crowdsec/collections +// POST /crowdsec/collections/:name/install +// DELETE /crowdsec/collections/:name +type CrowdSecHandler struct { + Audit *audit.Repo + NodeID string +} + +// NewCrowdSecHandler returns a CrowdSecHandler wired with audit and node-id. +func NewCrowdSecHandler(a *audit.Repo, nodeID string) *CrowdSecHandler { + return &CrowdSecHandler{Audit: a, NodeID: nodeID} +} + +// Register mounts all CrowdSec routes onto the provided authenticated router +// group. +func (h *CrowdSecHandler) Register(rg *gin.RouterGroup) { + g := rg.Group("/crowdsec") + g.GET("/status", h.Status) + g.GET("/decisions", h.ListDecisions) + g.POST("/decisions", h.AddDecision) + g.DELETE("/decisions", h.DeleteDecision) + g.GET("/alerts", h.ListAlerts) + g.DELETE("/alerts/:id", h.DeleteAlert) + g.GET("/bouncers", h.ListBouncers) + g.DELETE("/bouncers/:name", h.DeleteBouncer) + g.GET("/machines", h.ListMachines) + g.DELETE("/machines/:id", h.DeleteMachine) + g.GET("/collections", h.ListCollections) + g.POST("/collections/:name/install", h.InstallCollection) + g.DELETE("/collections/:name", h.RemoveCollection) +} + +// csNotInstalled responds with 503 when cscli is absent. +func csNotInstalled(c *gin.Context) { + c.JSON(http.StatusServiceUnavailable, gin.H{"error": "crowdsec not installed"}) +} + +// ---------- Status ---------------------------------------------------------- + +// Status returns live status of the CrowdSec agent + bouncer. +// Does NOT require cscli — uses systemctl for running-state checks. +func (h *CrowdSecHandler) Status(c *gin.Context) { + st := crowdsec.ServiceStatus(c.Request.Context()) + response.OK(c, st) +} + +// ---------- Decisions ------------------------------------------------------- + +// ListDecisions returns all active decisions. +func (h *CrowdSecHandler) ListDecisions(c *gin.Context) { + if !crowdsec.IsInstalled() { + csNotInstalled(c) + return + } + list, err := crowdsec.Decisions(c.Request.Context()) + if err != nil { + response.Internal(c, err) + return + } + response.OK(c, gin.H{"decisions": list}) +} + +// addDecisionBody is the expected JSON body for POST /crowdsec/decisions. +type addDecisionBody struct { + IP string `json:"ip" binding:"required"` + Duration string `json:"duration" binding:"required"` + Reason string `json:"reason"` + Type string `json:"type"` +} + +// AddDecision creates a new ban/captcha decision. +func (h *CrowdSecHandler) AddDecision(c *gin.Context) { + if !crowdsec.IsInstalled() { + csNotInstalled(c) + return + } + var body addDecisionBody + if err := c.ShouldBindJSON(&body); err != nil { + response.BadRequest(c, err) + return + } + if body.Reason == "" { + body.Reason = "manual ban" + } + if body.Type == "" { + body.Type = "ban" + } + if err := crowdsec.AddDecision(c.Request.Context(), body.IP, body.Duration, body.Reason, body.Type); err != nil { + response.Internal(c, err) + return + } + _ = h.Audit.Log(c.Request.Context(), actorOf(c), "crowdsec.decision.add", body.IP, + gin.H{"duration": body.Duration, "type": body.Type, "reason": body.Reason}, h.NodeID) + response.Created(c, gin.H{"ip": body.IP, "duration": body.Duration, "type": body.Type}) +} + +// DeleteDecision removes a decision by IP (?ip=) or by ID (?id=). +func (h *CrowdSecHandler) DeleteDecision(c *gin.Context) { + if !crowdsec.IsInstalled() { + csNotInstalled(c) + return + } + ip := c.Query("ip") + id := c.Query("id") + if ip == "" && id == "" { + response.BadRequest(c, errors.New("query parameter 'ip' or 'id' required")) + return + } + var err error + var target string + if ip != "" { + err = crowdsec.DeleteDecisionByIP(c.Request.Context(), ip) + target = ip + } else { + err = crowdsec.DeleteDecisionByID(c.Request.Context(), id) + target = id + } + if err != nil { + response.Internal(c, err) + return + } + _ = h.Audit.Log(c.Request.Context(), actorOf(c), "crowdsec.decision.delete", target, nil, h.NodeID) + response.OK(c, gin.H{"deleted": target}) +} + +// ---------- Alerts ---------------------------------------------------------- + +// ListAlerts returns recent CrowdSec alerts. +func (h *CrowdSecHandler) ListAlerts(c *gin.Context) { + if !crowdsec.IsInstalled() { + csNotInstalled(c) + return + } + list, err := crowdsec.Alerts(c.Request.Context(), 200) + if err != nil { + response.Internal(c, err) + return + } + response.OK(c, gin.H{"alerts": list}) +} + +// DeleteAlert discards a single alert. +func (h *CrowdSecHandler) DeleteAlert(c *gin.Context) { + if !crowdsec.IsInstalled() { + csNotInstalled(c) + return + } + id := c.Param("id") + if err := crowdsec.DeleteAlert(c.Request.Context(), id); err != nil { + response.Internal(c, err) + return + } + response.OK(c, gin.H{"deleted": id}) +} + +// ---------- Bouncers -------------------------------------------------------- + +// ListBouncers returns all registered bouncers. +func (h *CrowdSecHandler) ListBouncers(c *gin.Context) { + if !crowdsec.IsInstalled() { + csNotInstalled(c) + return + } + list, err := crowdsec.Bouncers(c.Request.Context()) + if err != nil { + response.Internal(c, err) + return + } + response.OK(c, gin.H{"bouncers": list}) +} + +// DeleteBouncer removes a bouncer by name. +func (h *CrowdSecHandler) DeleteBouncer(c *gin.Context) { + if !crowdsec.IsInstalled() { + csNotInstalled(c) + return + } + name := c.Param("name") + if err := crowdsec.DeleteBouncer(c.Request.Context(), name); err != nil { + response.Internal(c, err) + return + } + _ = h.Audit.Log(c.Request.Context(), actorOf(c), "crowdsec.bouncer.delete", name, nil, h.NodeID) + response.OK(c, gin.H{"deleted": name}) +} + +// ---------- Machines -------------------------------------------------------- + +// ListMachines returns all registered machines. +func (h *CrowdSecHandler) ListMachines(c *gin.Context) { + if !crowdsec.IsInstalled() { + csNotInstalled(c) + return + } + list, err := crowdsec.Machines(c.Request.Context()) + if err != nil { + response.Internal(c, err) + return + } + response.OK(c, gin.H{"machines": list}) +} + +// DeleteMachine removes a machine by ID. +func (h *CrowdSecHandler) DeleteMachine(c *gin.Context) { + if !crowdsec.IsInstalled() { + csNotInstalled(c) + return + } + id := c.Param("id") + if err := crowdsec.DeleteMachine(c.Request.Context(), id); err != nil { + response.Internal(c, err) + return + } + _ = h.Audit.Log(c.Request.Context(), actorOf(c), "crowdsec.machine.delete", id, nil, h.NodeID) + response.OK(c, gin.H{"deleted": id}) +} + +// ---------- Collections ----------------------------------------------------- + +// ListCollections returns all hub collections and their install status. +func (h *CrowdSecHandler) ListCollections(c *gin.Context) { + if !crowdsec.IsInstalled() { + csNotInstalled(c) + return + } + list, err := crowdsec.Collections(c.Request.Context()) + if err != nil { + response.Internal(c, err) + return + } + response.OK(c, gin.H{"collections": list}) +} + +// InstallCollection installs a hub collection by name. +func (h *CrowdSecHandler) InstallCollection(c *gin.Context) { + if !crowdsec.IsInstalled() { + csNotInstalled(c) + return + } + name := c.Param("name") + if err := crowdsec.InstallCollection(c.Request.Context(), name); err != nil { + response.Internal(c, err) + return + } + response.Created(c, gin.H{"installed": name}) +} + +// RemoveCollection removes a hub collection by name. +func (h *CrowdSecHandler) RemoveCollection(c *gin.Context) { + if !crowdsec.IsInstalled() { + csNotInstalled(c) + return + } + name := c.Param("name") + if err := crowdsec.RemoveCollection(c.Request.Context(), name); err != nil { + response.Internal(c, err) + return + } + response.OK(c, gin.H{"removed": name}) +} diff --git a/management-ui/src/App.tsx b/management-ui/src/App.tsx index c7f8930..f6d5b7e 100644 --- a/management-ui/src/App.tsx +++ b/management-ui/src/App.tsx @@ -37,6 +37,7 @@ const AlertsPage = lazy(() => import('./pages/Alerts')) const LicensePage = lazy(() => import('./pages/License')) const SettingsPage = lazy(() => import('./pages/Settings')) const UsersPage = lazy(() => import('./pages/Users')) +const CrowdSecPage = lazy(() => import('./pages/CrowdSec')) const queryClient = new QueryClient({ defaultOptions: { @@ -141,6 +142,7 @@ export default function App() { } /> } /> } /> + } /> } /> diff --git a/management-ui/src/components/Layout/Sidebar.tsx b/management-ui/src/components/Layout/Sidebar.tsx index 96fabb5..2bd776e 100644 --- a/management-ui/src/components/Layout/Sidebar.tsx +++ b/management-ui/src/components/Layout/Sidebar.tsx @@ -19,6 +19,7 @@ import { FireOutlined, GlobalOutlined, NodeIndexOutlined, + RadarChartOutlined, SafetyCertificateOutlined, SettingOutlined, TeamOutlined, @@ -76,6 +77,7 @@ const NAV: NavSection[] = [ { path: '/firewall/live', labelKey: 'nav.firewallLive', icon: , child: true }, { path: '/vpn/wireguard', labelKey: 'nav.wireguard', icon: }, { path: '/forward-proxy', labelKey: 'nav.forwardProxy', icon: }, + { path: '/crowdsec', labelKey: 'nav.crowdsec', icon: }, ], }, { diff --git a/management-ui/src/i18n/locales/de/common.json b/management-ui/src/i18n/locales/de/common.json index 73f3ee5..00a741a 100644 --- a/management-ui/src/i18n/locales/de/common.json +++ b/management-ui/src/i18n/locales/de/common.json @@ -27,6 +27,7 @@ "alerts": "Alarme", "license": "Lizenz", "settings": "Einstellungen", + "crowdsec": "CrowdSec IDS", "section": { "overview": "Übersicht", "routing": "Routing", @@ -1679,5 +1680,82 @@ "next": "Weiter", "showing": "Zeile {{from}}–{{to}}" } + }, + "cs": { + "title": "CrowdSec IDS", + "intro": "Kollaborative Intrusion-Detection: Log-basierte Erkennung + Community-Blocklisten via nftables-Bouncer.", + "notInstalled": "CrowdSec ist nicht installiert. Bitte das Paket crowdsec + crowdsec-firewall-bouncer-nftables installieren.", + "status": { + "agent": "Agent", + "bouncer": "Bouncer", + "decisions": "Aktive Sperren", + "alerts": "Alarme", + "running": "Aktiv", + "stopped": "Gestoppt", + "notInstalled": "Nicht installiert" + }, + "tabs": { + "decisions": "Entscheidungen", + "alerts": "Alarme", + "bouncers": "Bouncers", + "machines": "Maschinen", + "collections": "Collections" + }, + "decision": { + "ip": "IP-Adresse", + "reason": "Grund", + "origin": "Herkunft", + "duration": "Dauer", + "country": "Land", + "as": "AS-Name", + "type": "Typ", + "unban": "Entsperren", + "banModal": "Manuell sperren", + "banBtn": "IP sperren", + "confirmUnban": "IP {{ip}} wirklich entsperren?", + "addSuccess": "IP {{ip}} wurde gesperrt.", + "deleteSuccess": "Sperre aufgehoben." + }, + "alert": { + "id": "ID", + "scenario": "Szenario", + "events": "Events", + "sourceIP": "Quell-IP", + "country": "Land", + "start": "Beginn", + "stop": "Ende", + "delete": "Verwerfen", + "confirmDelete": "Alarm #{{id}} wirklich verwerfen?" + }, + "bouncer": { + "name": "Name", + "ip": "IP", + "validKey": "Key gültig", + "version": "Version", + "lastPull": "Letzter Pull", + "type": "Typ", + "delete": "Entfernen", + "confirmDelete": "Bouncer {{name}} wirklich entfernen?" + }, + "machine": { + "id": "Machine-ID", + "created": "Angelegt", + "lastPush": "Letzter Push", + "validated": "Validiert", + "version": "Version", + "delete": "Entfernen", + "confirmDelete": "Maschine {{id}} wirklich entfernen?" + }, + "collection": { + "name": "Collection", + "status": "Status", + "version": "Version", + "author": "Author", + "install": "Installieren", + "remove": "Entfernen", + "enabled": "Installiert", + "disabled": "Nicht installiert", + "confirmRemove": "Collection {{name}} wirklich entfernen?" + } } } diff --git a/management-ui/src/i18n/locales/en/common.json b/management-ui/src/i18n/locales/en/common.json index 632764f..9a8008e 100644 --- a/management-ui/src/i18n/locales/en/common.json +++ b/management-ui/src/i18n/locales/en/common.json @@ -27,6 +27,7 @@ "alerts": "Alerts", "license": "License", "settings": "Settings", + "crowdsec": "CrowdSec IDS", "section": { "overview": "Overview", "routing": "Routing", @@ -1679,5 +1680,82 @@ "next": "Next", "showing": "Row {{from}}–{{to}}" } + }, + "cs": { + "title": "CrowdSec IDS", + "intro": "Collaborative intrusion detection: log-based detection + community blocklists via nftables bouncer.", + "notInstalled": "CrowdSec is not installed. Please install the crowdsec + crowdsec-firewall-bouncer-nftables packages.", + "status": { + "agent": "Agent", + "bouncer": "Bouncer", + "decisions": "Active bans", + "alerts": "Alerts", + "running": "Active", + "stopped": "Stopped", + "notInstalled": "Not installed" + }, + "tabs": { + "decisions": "Decisions", + "alerts": "Alerts", + "bouncers": "Bouncers", + "machines": "Machines", + "collections": "Collections" + }, + "decision": { + "ip": "IP address", + "reason": "Reason", + "origin": "Origin", + "duration": "Duration", + "country": "Country", + "as": "AS name", + "type": "Type", + "unban": "Unban", + "banModal": "Manual ban", + "banBtn": "Ban IP", + "confirmUnban": "Really unban IP {{ip}}?", + "addSuccess": "IP {{ip}} has been banned.", + "deleteSuccess": "Ban removed." + }, + "alert": { + "id": "ID", + "scenario": "Scenario", + "events": "Events", + "sourceIP": "Source IP", + "country": "Country", + "start": "Start", + "stop": "End", + "delete": "Dismiss", + "confirmDelete": "Really dismiss alert #{{id}}?" + }, + "bouncer": { + "name": "Name", + "ip": "IP", + "validKey": "Key valid", + "version": "Version", + "lastPull": "Last pull", + "type": "Type", + "delete": "Remove", + "confirmDelete": "Really remove bouncer {{name}}?" + }, + "machine": { + "id": "Machine ID", + "created": "Created", + "lastPush": "Last push", + "validated": "Validated", + "version": "Version", + "delete": "Remove", + "confirmDelete": "Really remove machine {{id}}?" + }, + "collection": { + "name": "Collection", + "status": "Status", + "version": "Version", + "author": "Author", + "install": "Install", + "remove": "Remove", + "enabled": "Installed", + "disabled": "Not installed", + "confirmRemove": "Really remove collection {{name}}?" + } } } diff --git a/management-ui/src/pages/CrowdSec/index.tsx b/management-ui/src/pages/CrowdSec/index.tsx new file mode 100644 index 0000000..e5f4d1d --- /dev/null +++ b/management-ui/src/pages/CrowdSec/index.tsx @@ -0,0 +1,533 @@ +import { useState } from 'react' +import { + Alert, + Button, + Form, + Input, + Modal, + Popconfirm, + Select, + Space, + Tag, + Tabs, + Table, +} from 'antd' +import { + CheckCircleOutlined, + CloseCircleOutlined, + DeleteOutlined, + DownloadOutlined, + RadarChartOutlined, + StopOutlined, +} from '@ant-design/icons' +import { useTranslation } from 'react-i18next' +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' + +import apiClient, { isEnvelope } from '../../api/client' +import PageHeader from '../../components/PageHeader' +import type { + AddDecisionBody, + Alert as CSAlert, + Bouncer, + CrowdSecStatus, + Decision, + HubItem, + Machine, +} from './types' + +// ---------- API helpers ----------------------------------------------------- + +async function fetchStatus(): Promise { + const r = await apiClient.get('/crowdsec/status') + if (isEnvelope(r.data)) return r.data.data as CrowdSecStatus + return r.data as CrowdSecStatus +} + +async function fetchDecisions(): Promise { + const r = await apiClient.get('/crowdsec/decisions') + if (!isEnvelope(r.data)) return [] + return (r.data.data as { decisions: Decision[] }).decisions ?? [] +} + +async function fetchAlerts(): Promise { + const r = await apiClient.get('/crowdsec/alerts') + if (!isEnvelope(r.data)) return [] + return (r.data.data as { alerts: CSAlert[] }).alerts ?? [] +} + +async function fetchBouncers(): Promise { + const r = await apiClient.get('/crowdsec/bouncers') + if (!isEnvelope(r.data)) return [] + return (r.data.data as { bouncers: Bouncer[] }).bouncers ?? [] +} + +async function fetchMachines(): Promise { + const r = await apiClient.get('/crowdsec/machines') + if (!isEnvelope(r.data)) return [] + return (r.data.data as { machines: Machine[] }).machines ?? [] +} + +async function fetchCollections(): Promise { + const r = await apiClient.get('/crowdsec/collections') + if (!isEnvelope(r.data)) return [] + return (r.data.data as { collections: HubItem[] }).collections ?? [] +} + +// ---------- Status strip ---------------------------------------------------- + +function StatusStrip({ status }: { status: CrowdSecStatus | undefined }) { + const { t } = useTranslation() + + const agentColor = status?.agent_running ? 'green' : 'red' + const bouncerColor = status?.bouncer_running ? 'green' : 'red' + const agentLabel = status?.agent_running ? t('cs.status.running') : t('cs.status.stopped') + const bouncerLabel = status?.bouncer_running ? t('cs.status.running') : t('cs.status.stopped') + + return ( +
+
+
{t('cs.status.agent')}
+
+ : } + color={agentColor} + > + {agentLabel} + +
+ {status?.version && ( +
{status.version}
+ )} +
+
+
{t('cs.status.bouncer')}
+
+ : } + color={bouncerColor} + > + {bouncerLabel} + +
+
+
+
{t('cs.status.decisions')}
+
{status?.decision_count ?? '–'}
+
{t('cs.tabs.decisions')}
+
+
+
{t('cs.status.alerts')}
+
{status?.alert_count ?? '–'}
+
{t('cs.tabs.alerts')}
+
+
+ ) +} + +// ---------- Decisions tab --------------------------------------------------- + +function DecisionsTab() { + const { t } = useTranslation() + const queryClient = useQueryClient() + const [banModalOpen, setBanModalOpen] = useState(false) + const [form] = Form.useForm() + + const { data: decisions, isLoading } = useQuery({ + queryKey: ['crowdsec', 'decisions'], + queryFn: fetchDecisions, + refetchInterval: 30_000, + }) + + const unban = useMutation({ + mutationFn: (id: number) => apiClient.delete(`/crowdsec/decisions?id=${id}`), + onSuccess: () => queryClient.invalidateQueries({ queryKey: ['crowdsec'] }), + }) + + const addDecision = useMutation({ + mutationFn: (body: AddDecisionBody) => apiClient.post('/crowdsec/decisions', body), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['crowdsec'] }) + setBanModalOpen(false) + form.resetFields() + }, + }) + + const columns = [ + { title: t('cs.decision.ip'), dataIndex: 'value', key: 'value' }, + { title: t('cs.decision.reason'), dataIndex: 'reason', key: 'reason' }, + { title: t('cs.decision.origin'), dataIndex: 'origin', key: 'origin' }, + { title: t('cs.decision.duration'), dataIndex: 'duration', key: 'duration' }, + { title: t('cs.decision.type'), dataIndex: 'type', key: 'type', + render: (v: string) => {v} }, + { title: t('cs.decision.country'), dataIndex: 'country', key: 'country' }, + { title: t('cs.decision.as'), dataIndex: 'as', key: 'as' }, + { + title: '', + key: 'actions', + render: (_: unknown, row: Decision) => ( + unban.mutate(row.id)} + okText={t('cs.decision.unban')} + okButtonProps={{ danger: true }} + > + + + ), + }, + ] + + return ( +
+
+ +
+ + { setBanModalOpen(false); form.resetFields() }} + onOk={() => form.submit()} + okButtonProps={{ danger: true, loading: addDecision.isPending }} + okText={t('cs.decision.banBtn')} + > +
addDecision.mutate(vals)} + > + + + + + + + + + + + + + +
+ + ) +} + +// ---------- Alerts tab ------------------------------------------------------ + +function AlertsTab() { + const { t } = useTranslation() + const queryClient = useQueryClient() + + const { data: alerts, isLoading } = useQuery({ + queryKey: ['crowdsec', 'alerts'], + queryFn: fetchAlerts, + refetchInterval: 30_000, + }) + + const deleteAlert = useMutation({ + mutationFn: (id: number) => apiClient.delete(`/crowdsec/alerts/${id}`), + onSuccess: () => queryClient.invalidateQueries({ queryKey: ['crowdsec'] }), + }) + + const columns = [ + { title: t('cs.alert.id'), dataIndex: 'id', key: 'id', width: 70 }, + { title: t('cs.alert.scenario'), dataIndex: 'scenario', key: 'scenario' }, + { title: t('cs.alert.events'), dataIndex: 'events_count', key: 'events_count', width: 80 }, + { title: t('cs.alert.sourceIP'), key: 'sourceIP', + render: (_: unknown, row: CSAlert) => row.source?.ip ?? row.source?.value ?? '–' }, + { title: t('cs.alert.country'), key: 'country', + render: (_: unknown, row: CSAlert) => row.source?.cn ?? '–' }, + { title: t('cs.alert.start'), dataIndex: 'start_at', key: 'start_at' }, + { title: t('cs.alert.stop'), dataIndex: 'stop_at', key: 'stop_at' }, + { + title: '', + key: 'actions', + render: (_: unknown, row: CSAlert) => ( + deleteAlert.mutate(row.id)} + okButtonProps={{ danger: true }} + > + + + ), + }, + ] + + return ( +
+
+ + ) +} + +// ---------- Bouncers tab ---------------------------------------------------- + +function BouncersTab() { + const { t } = useTranslation() + const queryClient = useQueryClient() + + const { data: bouncers, isLoading } = useQuery({ + queryKey: ['crowdsec', 'bouncers'], + queryFn: fetchBouncers, + refetchInterval: 30_000, + }) + + const deleteBouncer = useMutation({ + mutationFn: (name: string) => apiClient.delete(`/crowdsec/bouncers/${encodeURIComponent(name)}`), + onSuccess: () => queryClient.invalidateQueries({ queryKey: ['crowdsec'] }), + }) + + const columns = [ + { title: t('cs.bouncer.name'), dataIndex: 'name', key: 'name' }, + { title: t('cs.bouncer.ip'), dataIndex: 'ip_address', key: 'ip_address' }, + { title: t('cs.bouncer.validKey'), dataIndex: 'revoked', key: 'revoked', + render: (v: boolean) => !v + ? OK + : revoked }, + { title: t('cs.bouncer.version'), dataIndex: 'version', key: 'version' }, + { title: t('cs.bouncer.type'), dataIndex: 'type', key: 'type' }, + { title: t('cs.bouncer.lastPull'), dataIndex: 'last_pull', key: 'last_pull' }, + { + title: '', + key: 'actions', + render: (_: unknown, row: Bouncer) => ( + deleteBouncer.mutate(row.name)} + okButtonProps={{ danger: true }} + > + + + ), + }, + ] + + return ( +
+
+ + ) +} + +// ---------- Machines tab ---------------------------------------------------- + +function MachinesTab() { + const { t } = useTranslation() + const queryClient = useQueryClient() + + const { data: machines, isLoading } = useQuery({ + queryKey: ['crowdsec', 'machines'], + queryFn: fetchMachines, + refetchInterval: 30_000, + }) + + const deleteMachine = useMutation({ + mutationFn: (id: string) => apiClient.delete(`/crowdsec/machines/${encodeURIComponent(id)}`), + onSuccess: () => queryClient.invalidateQueries({ queryKey: ['crowdsec'] }), + }) + + const columns = [ + { title: t('cs.machine.id'), dataIndex: 'machineId', key: 'machineId' }, + { title: t('cs.machine.created'), dataIndex: 'created_at', key: 'created_at' }, + { title: t('cs.machine.lastPush'), dataIndex: 'last_push', key: 'last_push' }, + { title: t('cs.machine.validated'), dataIndex: 'isValidated', key: 'isValidated', + render: (v: boolean) => v + ? ja + : nein }, + { title: t('cs.machine.version'), dataIndex: 'version', key: 'version' }, + { + title: '', + key: 'actions', + render: (_: unknown, row: Machine) => ( + deleteMachine.mutate(row.machineId)} + okButtonProps={{ danger: true }} + > + + + ), + }, + ] + + return ( +
+
+ + ) +} + +// ---------- Collections tab ------------------------------------------------- + +function CollectionsTab() { + const { t } = useTranslation() + const queryClient = useQueryClient() + + const { data: collections, isLoading } = useQuery({ + queryKey: ['crowdsec', 'collections'], + queryFn: fetchCollections, + refetchInterval: 60_000, + }) + + const installCollection = useMutation({ + mutationFn: (name: string) => apiClient.post(`/crowdsec/collections/${encodeURIComponent(name)}/install`), + onSuccess: () => queryClient.invalidateQueries({ queryKey: ['crowdsec'] }), + }) + + const removeCollection = useMutation({ + mutationFn: (name: string) => apiClient.delete(`/crowdsec/collections/${encodeURIComponent(name)}`), + onSuccess: () => queryClient.invalidateQueries({ queryKey: ['crowdsec'] }), + }) + + const isEnabled = (status: string) => + status === 'enabled' || status === 'downloaded' + + const columns = [ + { title: t('cs.collection.name'), dataIndex: 'name', key: 'name' }, + { title: t('cs.collection.status'), dataIndex: 'status', key: 'status', + render: (v: string) => ( + + {isEnabled(v) ? t('cs.collection.enabled') : t('cs.collection.disabled')} + + ) }, + { title: t('cs.collection.version'), dataIndex: 'local_version', key: 'local_version' }, + { title: t('cs.collection.author'), dataIndex: 'author', key: 'author' }, + { + title: '', + key: 'actions', + render: (_: unknown, row: HubItem) => ( + + {!isEnabled(row.status) ? ( + + ) : ( + removeCollection.mutate(row.name)} + okButtonProps={{ danger: true }} + > + + + )} + + ), + }, + ] + + return ( +
+
+ + ) +} + +// ---------- Page ------------------------------------------------------------ + +export default function CrowdSecPage() { + const { t } = useTranslation() + + const { data: status } = useQuery({ + queryKey: ['crowdsec', 'status'], + queryFn: fetchStatus, + refetchInterval: 10_000, + }) + + const tabs = [ + { key: 'decisions', label: t('cs.tabs.decisions'), children: }, + { key: 'alerts', label: t('cs.tabs.alerts'), children: }, + { key: 'bouncers', label: t('cs.tabs.bouncers'), children: }, + { key: 'machines', label: t('cs.tabs.machines'), children: }, + { key: 'collections', label: t('cs.tabs.collections'), children: }, + ] + + return ( +
+ } + title={t('cs.title')} + subtitle={t('cs.intro')} + /> + {status && !status.installed && ( + + )} + + +
+ ) +} diff --git a/management-ui/src/pages/CrowdSec/types.ts b/management-ui/src/pages/CrowdSec/types.ts new file mode 100644 index 0000000..86b14fb --- /dev/null +++ b/management-ui/src/pages/CrowdSec/types.ts @@ -0,0 +1,79 @@ +export interface Decision { + id: number + origin: string + type: string + scope: string + value: string + duration: string + reason: string + country?: string + as?: string +} + +export interface AlertSource { + ip: string + cn?: string + as_name?: string + range?: string + scope?: string + value?: string +} + +export interface Alert { + id: number + scenario: string + events_count: number + source: AlertSource + start_at: string + stop_at: string + decisions?: Decision[] +} + +export interface Bouncer { + name: string + ip_address?: string + revoked: boolean + last_pull?: string + type?: string + version?: string + created_at: string + auth_type?: string +} + +export interface Machine { + machineId: string + created_at: string + updated_at: string + last_push?: string + isValidated: boolean + version?: string + status?: string +} + +export interface HubItem { + name: string + description?: string + status: string + local_version?: string + local_path?: string + author?: string + type?: string +} + +export interface CrowdSecStatus { + installed: boolean + agent_running: boolean + bouncer_running: boolean + version?: string + decision_count: number + alert_count: number + bouncer_count: number + machine_count: number +} + +export interface AddDecisionBody { + ip: string + duration: string + reason: string + type: string +} diff --git a/packaging/debian/edgeguard-api/DEBIAN/postinst b/packaging/debian/edgeguard-api/DEBIAN/postinst index 7a2f950..4320832 100755 --- a/packaging/debian/edgeguard-api/DEBIAN/postinst +++ b/packaging/debian/edgeguard-api/DEBIAN/postinst @@ -151,6 +151,20 @@ edgeguard ALL=(root) NOPASSWD: /usr/bin/systemctl reload-or-restart keepalived.s edgeguard ALL=(root) NOPASSWD: /bin/systemctl reload-or-restart keepalived.service # VIP-Schwenk-Test: dediziertes Script mit interner Input-Validierung. edgeguard ALL=(root) NOPASSWD: /usr/lib/edgeguard/vip-cmd.sh +# CrowdSec management +edgeguard ALL=(root) NOPASSWD: /usr/bin/cscli decisions list -o json +edgeguard ALL=(root) NOPASSWD: /usr/bin/cscli decisions add * +edgeguard ALL=(root) NOPASSWD: /usr/bin/cscli decisions delete * +edgeguard ALL=(root) NOPASSWD: /usr/bin/cscli alerts list * +edgeguard ALL=(root) NOPASSWD: /usr/bin/cscli alerts delete * +edgeguard ALL=(root) NOPASSWD: /usr/bin/cscli bouncers list -o json +edgeguard ALL=(root) NOPASSWD: /usr/bin/cscli bouncers delete * +edgeguard ALL=(root) NOPASSWD: /usr/bin/cscli machines list -o json +edgeguard ALL=(root) NOPASSWD: /usr/bin/cscli machines delete * +edgeguard ALL=(root) NOPASSWD: /usr/bin/cscli collections list -o json +edgeguard ALL=(root) NOPASSWD: /usr/bin/cscli collections install * +edgeguard ALL=(root) NOPASSWD: /usr/bin/cscli collections remove * +edgeguard ALL=(root) NOPASSWD: /usr/bin/cscli version SUDOERS # ── Distro-Conf-Includes für die per-Service Renderer ───────── @@ -703,6 +717,30 @@ END; $$; EOSQL + # ── CrowdSec IDS installation ───────────────────────────────────────── + # Install CrowdSec if not present. We use the official CrowdSec APT repo. + # Only done on initial install (not on upgrade) to avoid overwriting + # user configuration. + if [ "$1" = "configure" ] && [ -z "$2" ]; then + if ! command -v cscli >/dev/null 2>&1; then + echo "postinst: installing CrowdSec..." + # Add CrowdSec repo key + install -d -m 0755 /etc/apt/keyrings + curl -s https://packagecloud.io/crowdsec/crowdsec/gpgkey | \ + gpg --dearmor -o /etc/apt/keyrings/crowdsec_crowdsec-archive-keyring.gpg 2>/dev/null || true + # Add repo (bookworm compat for Trixie) + echo "deb [signed-by=/etc/apt/keyrings/crowdsec_crowdsec-archive-keyring.gpg] https://packagecloud.io/crowdsec/crowdsec/debian/ bookworm main" \ + > /etc/apt/sources.list.d/crowdsec.list + apt-get update -qq 2>/dev/null || true + DEBIAN_FRONTEND=noninteractive apt-get install -y -qq crowdsec crowdsec-firewall-bouncer-nftables 2>/dev/null || true + + # Install default collections + if command -v cscli >/dev/null 2>&1; then + cscli collections install crowdsecurity/linux crowdsecurity/haproxy 2>/dev/null || true + fi + fi + fi + # ── Render initial service configs ─────────────────────────── # Writes /etc/edgeguard/haproxy/haproxy.cfg + nftables.d/ # ruleset.nft from the (just-migrated, empty) PG state.