feat(crowdsec): CrowdSec IDS/IPS Management — v1.2.61
- 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 <noreply@anthropic.com>
This commit is contained in:
@@ -382,6 +382,7 @@ func main() {
|
|||||||
return firewallrender.New(pool).Render(ctx)
|
return firewallrender.New(pool).Render(ctx)
|
||||||
}
|
}
|
||||||
handlers.NewFirewallHandler(fwZones, fwAddrObj, fwAddrGrp, fwSvc, fwSvcGrp, fwRules, fwNAT, auditRepo, nodeID, fwReloader, pool).Register(authed)
|
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
|
// withFW wraps a service-reloader so that AFTER the service is
|
||||||
// reloaded, the firewall is also re-rendered. Necessary for
|
// reloaded, the firewall is also re-rendered. Necessary for
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ SystemCallFilter=@system-service
|
|||||||
# direkt in den distro-Conf-Dir (chrony+unbound) bzw. legen Symlinks
|
# direkt in den distro-Conf-Dir (chrony+unbound) bzw. legen Symlinks
|
||||||
# nach /etc/edgeguard/wireguard (wg). Ohne diese Pfade scheitern alle
|
# nach /etc/edgeguard/wireguard (wg). Ohne diese Pfade scheitern alle
|
||||||
# UI-Mutationen an DNS/NTP/WireGuard-Settings still mit EROFS.
|
# 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]
|
[Install]
|
||||||
WantedBy=multi-user.target
|
WantedBy=multi-user.target
|
||||||
|
|||||||
428
internal/crowdsec/service.go
Normal file
428
internal/crowdsec/service.go
Normal file
@@ -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 <args...>` 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
|
||||||
|
}
|
||||||
12
internal/database/migrations/0036_crowdsec.sql
Normal file
12
internal/database/migrations/0036_crowdsec.sql
Normal file
@@ -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;
|
||||||
@@ -3,7 +3,8 @@
|
|||||||
# Source: internal/firewall/firewall.go.
|
# Source: internal/firewall/firewall.go.
|
||||||
# Re-generate via `edgeguard-ctl render-config` or via API mutations.
|
# Re-generate via `edgeguard-ctl render-config` or via API mutations.
|
||||||
|
|
||||||
flush ruleset
|
add table inet edgeguard
|
||||||
|
flush table inet edgeguard
|
||||||
|
|
||||||
table inet edgeguard {
|
table inet edgeguard {
|
||||||
set peer_ipv4 {
|
set peer_ipv4 {
|
||||||
|
|||||||
@@ -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)
|
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.
|
// cnFromCSR extracts the Subject Common Name from a PEM-encoded CSR.
|
||||||
// Returns empty string on any parse error.
|
// Returns empty string on any parse error.
|
||||||
func cnFromCSR(csrPEM string) string {
|
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".
|
// ha_nodes_fqdn_unique" scheitern und der Peer bliebe ewig "joining".
|
||||||
_ = h.Store.DeletePlaceholdersByFQDN(c.Request.Context(), req.FQDN, req.ID)
|
_ = 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)
|
out, err := h.Store.UpsertSelf(c.Request.Context(), n)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
response.Internal(c, err)
|
response.Internal(c, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Firewall-Reload damit peer_ipv4-Set die neue IP aufnimmt. Best-
|
// Firewall-Reload nur wenn sich die Peer-IP geändert hat oder der
|
||||||
// effort: Fehler loggen, Response weiter durchreichen — der Peer
|
// Peer neu eingetragen wurde. Verhindert Counter-Reset alle 5 min
|
||||||
// hat seine Identity erfolgreich registriert, Operator kann manuell
|
// durch den periodischen Secondary-Push (runPrimaryPush).
|
||||||
// nachrendern.
|
ipChanged := existing == nil ||
|
||||||
if h.PeerReloader != nil {
|
ptrStr(existing.PublicIP) != ptrStr(out.PublicIP) ||
|
||||||
|
ptrStr(existing.InternalIP) != ptrStr(out.InternalIP)
|
||||||
|
if ipChanged && h.PeerReloader != nil {
|
||||||
go func() {
|
go func() {
|
||||||
rctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
rctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|||||||
285
internal/handlers/crowdsec.go
Normal file
285
internal/handlers/crowdsec.go
Normal file
@@ -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=<ip> or ?id=<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})
|
||||||
|
}
|
||||||
@@ -37,6 +37,7 @@ const AlertsPage = lazy(() => import('./pages/Alerts'))
|
|||||||
const LicensePage = lazy(() => import('./pages/License'))
|
const LicensePage = lazy(() => import('./pages/License'))
|
||||||
const SettingsPage = lazy(() => import('./pages/Settings'))
|
const SettingsPage = lazy(() => import('./pages/Settings'))
|
||||||
const UsersPage = lazy(() => import('./pages/Users'))
|
const UsersPage = lazy(() => import('./pages/Users'))
|
||||||
|
const CrowdSecPage = lazy(() => import('./pages/CrowdSec'))
|
||||||
|
|
||||||
const queryClient = new QueryClient({
|
const queryClient = new QueryClient({
|
||||||
defaultOptions: {
|
defaultOptions: {
|
||||||
@@ -141,6 +142,7 @@ export default function App() {
|
|||||||
<Route path="/license" element={<LicensePage />} />
|
<Route path="/license" element={<LicensePage />} />
|
||||||
<Route path="/users" element={<UsersPage />} />
|
<Route path="/users" element={<UsersPage />} />
|
||||||
<Route path="/settings" element={<SettingsPage />} />
|
<Route path="/settings" element={<SettingsPage />} />
|
||||||
|
<Route path="/crowdsec" element={<CrowdSecPage />} />
|
||||||
</Route>
|
</Route>
|
||||||
|
|
||||||
<Route path="*" element={<Navigate to="/dashboard" replace />} />
|
<Route path="*" element={<Navigate to="/dashboard" replace />} />
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
FireOutlined,
|
FireOutlined,
|
||||||
GlobalOutlined,
|
GlobalOutlined,
|
||||||
NodeIndexOutlined,
|
NodeIndexOutlined,
|
||||||
|
RadarChartOutlined,
|
||||||
SafetyCertificateOutlined,
|
SafetyCertificateOutlined,
|
||||||
SettingOutlined,
|
SettingOutlined,
|
||||||
TeamOutlined,
|
TeamOutlined,
|
||||||
@@ -76,6 +77,7 @@ const NAV: NavSection[] = [
|
|||||||
{ path: '/firewall/live', labelKey: 'nav.firewallLive', icon: <EyeOutlined />, child: true },
|
{ path: '/firewall/live', labelKey: 'nav.firewallLive', icon: <EyeOutlined />, child: true },
|
||||||
{ path: '/vpn/wireguard', labelKey: 'nav.wireguard', icon: <ThunderboltOutlined /> },
|
{ path: '/vpn/wireguard', labelKey: 'nav.wireguard', icon: <ThunderboltOutlined /> },
|
||||||
{ path: '/forward-proxy', labelKey: 'nav.forwardProxy', icon: <CloudServerOutlined /> },
|
{ path: '/forward-proxy', labelKey: 'nav.forwardProxy', icon: <CloudServerOutlined /> },
|
||||||
|
{ path: '/crowdsec', labelKey: 'nav.crowdsec', icon: <RadarChartOutlined /> },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -27,6 +27,7 @@
|
|||||||
"alerts": "Alarme",
|
"alerts": "Alarme",
|
||||||
"license": "Lizenz",
|
"license": "Lizenz",
|
||||||
"settings": "Einstellungen",
|
"settings": "Einstellungen",
|
||||||
|
"crowdsec": "CrowdSec IDS",
|
||||||
"section": {
|
"section": {
|
||||||
"overview": "Übersicht",
|
"overview": "Übersicht",
|
||||||
"routing": "Routing",
|
"routing": "Routing",
|
||||||
@@ -1679,5 +1680,82 @@
|
|||||||
"next": "Weiter",
|
"next": "Weiter",
|
||||||
"showing": "Zeile {{from}}–{{to}}"
|
"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?"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,6 +27,7 @@
|
|||||||
"alerts": "Alerts",
|
"alerts": "Alerts",
|
||||||
"license": "License",
|
"license": "License",
|
||||||
"settings": "Settings",
|
"settings": "Settings",
|
||||||
|
"crowdsec": "CrowdSec IDS",
|
||||||
"section": {
|
"section": {
|
||||||
"overview": "Overview",
|
"overview": "Overview",
|
||||||
"routing": "Routing",
|
"routing": "Routing",
|
||||||
@@ -1679,5 +1680,82 @@
|
|||||||
"next": "Next",
|
"next": "Next",
|
||||||
"showing": "Row {{from}}–{{to}}"
|
"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}}?"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
533
management-ui/src/pages/CrowdSec/index.tsx
Normal file
533
management-ui/src/pages/CrowdSec/index.tsx
Normal file
@@ -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<CrowdSecStatus> {
|
||||||
|
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<Decision[]> {
|
||||||
|
const r = await apiClient.get('/crowdsec/decisions')
|
||||||
|
if (!isEnvelope(r.data)) return []
|
||||||
|
return (r.data.data as { decisions: Decision[] }).decisions ?? []
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchAlerts(): Promise<CSAlert[]> {
|
||||||
|
const r = await apiClient.get('/crowdsec/alerts')
|
||||||
|
if (!isEnvelope(r.data)) return []
|
||||||
|
return (r.data.data as { alerts: CSAlert[] }).alerts ?? []
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchBouncers(): Promise<Bouncer[]> {
|
||||||
|
const r = await apiClient.get('/crowdsec/bouncers')
|
||||||
|
if (!isEnvelope(r.data)) return []
|
||||||
|
return (r.data.data as { bouncers: Bouncer[] }).bouncers ?? []
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchMachines(): Promise<Machine[]> {
|
||||||
|
const r = await apiClient.get('/crowdsec/machines')
|
||||||
|
if (!isEnvelope(r.data)) return []
|
||||||
|
return (r.data.data as { machines: Machine[] }).machines ?? []
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchCollections(): Promise<HubItem[]> {
|
||||||
|
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 (
|
||||||
|
<div className="fw-kpi-strip">
|
||||||
|
<div className="fw-kpi-card">
|
||||||
|
<div className="fw-kpi-label">{t('cs.status.agent')}</div>
|
||||||
|
<div className="fw-kpi-value">
|
||||||
|
<Tag
|
||||||
|
icon={status?.agent_running ? <CheckCircleOutlined /> : <CloseCircleOutlined />}
|
||||||
|
color={agentColor}
|
||||||
|
>
|
||||||
|
{agentLabel}
|
||||||
|
</Tag>
|
||||||
|
</div>
|
||||||
|
{status?.version && (
|
||||||
|
<div className="fw-kpi-sub">{status.version}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="fw-kpi-card">
|
||||||
|
<div className="fw-kpi-label">{t('cs.status.bouncer')}</div>
|
||||||
|
<div className="fw-kpi-value">
|
||||||
|
<Tag
|
||||||
|
icon={status?.bouncer_running ? <CheckCircleOutlined /> : <CloseCircleOutlined />}
|
||||||
|
color={bouncerColor}
|
||||||
|
>
|
||||||
|
{bouncerLabel}
|
||||||
|
</Tag>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="fw-kpi-card">
|
||||||
|
<div className="fw-kpi-label">{t('cs.status.decisions')}</div>
|
||||||
|
<div className="fw-kpi-value">{status?.decision_count ?? '–'}</div>
|
||||||
|
<div className="fw-kpi-sub">{t('cs.tabs.decisions')}</div>
|
||||||
|
</div>
|
||||||
|
<div className="fw-kpi-card">
|
||||||
|
<div className="fw-kpi-label">{t('cs.status.alerts')}</div>
|
||||||
|
<div className="fw-kpi-value">{status?.alert_count ?? '–'}</div>
|
||||||
|
<div className="fw-kpi-sub">{t('cs.tabs.alerts')}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Decisions tab ---------------------------------------------------
|
||||||
|
|
||||||
|
function DecisionsTab() {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
const [banModalOpen, setBanModalOpen] = useState(false)
|
||||||
|
const [form] = Form.useForm<AddDecisionBody>()
|
||||||
|
|
||||||
|
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) => <Tag color={v === 'ban' ? 'red' : 'orange'}>{v}</Tag> },
|
||||||
|
{ 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) => (
|
||||||
|
<Popconfirm
|
||||||
|
title={t('cs.decision.confirmUnban', { ip: row.value })}
|
||||||
|
onConfirm={() => unban.mutate(row.id)}
|
||||||
|
okText={t('cs.decision.unban')}
|
||||||
|
okButtonProps={{ danger: true }}
|
||||||
|
>
|
||||||
|
<Button size="small" danger icon={<StopOutlined />}>
|
||||||
|
{t('cs.decision.unban')}
|
||||||
|
</Button>
|
||||||
|
</Popconfirm>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mt-2">
|
||||||
|
<div className="mb-2">
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
icon={<StopOutlined />}
|
||||||
|
onClick={() => setBanModalOpen(true)}
|
||||||
|
>
|
||||||
|
{t('cs.decision.banBtn')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<Table
|
||||||
|
size="small"
|
||||||
|
rowKey="id"
|
||||||
|
loading={isLoading}
|
||||||
|
dataSource={decisions ?? []}
|
||||||
|
columns={columns}
|
||||||
|
pagination={{ pageSize: 20 }}
|
||||||
|
/>
|
||||||
|
<Modal
|
||||||
|
title={t('cs.decision.banModal')}
|
||||||
|
open={banModalOpen}
|
||||||
|
onCancel={() => { setBanModalOpen(false); form.resetFields() }}
|
||||||
|
onOk={() => form.submit()}
|
||||||
|
okButtonProps={{ danger: true, loading: addDecision.isPending }}
|
||||||
|
okText={t('cs.decision.banBtn')}
|
||||||
|
>
|
||||||
|
<Form
|
||||||
|
form={form}
|
||||||
|
layout="vertical"
|
||||||
|
initialValues={{ duration: '24h', reason: 'manual ban', type: 'ban' }}
|
||||||
|
onFinish={(vals) => addDecision.mutate(vals)}
|
||||||
|
>
|
||||||
|
<Form.Item name="ip" label={t('cs.decision.ip')} rules={[{ required: true }]}>
|
||||||
|
<Input placeholder="1.2.3.4" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="duration" label={t('cs.decision.duration')} rules={[{ required: true }]}>
|
||||||
|
<Select>
|
||||||
|
<Select.Option value="1h">1h</Select.Option>
|
||||||
|
<Select.Option value="12h">12h</Select.Option>
|
||||||
|
<Select.Option value="24h">24h</Select.Option>
|
||||||
|
<Select.Option value="168h">7d</Select.Option>
|
||||||
|
<Select.Option value="720h">30d</Select.Option>
|
||||||
|
<Select.Option value="8760h">permanent (1y)</Select.Option>
|
||||||
|
</Select>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="reason" label={t('cs.decision.reason')}>
|
||||||
|
<Input />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="type" label={t('cs.decision.type')}>
|
||||||
|
<Select>
|
||||||
|
<Select.Option value="ban">ban</Select.Option>
|
||||||
|
<Select.Option value="captcha">captcha</Select.Option>
|
||||||
|
</Select>
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 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) => (
|
||||||
|
<Popconfirm
|
||||||
|
title={t('cs.alert.confirmDelete', { id: row.id })}
|
||||||
|
onConfirm={() => deleteAlert.mutate(row.id)}
|
||||||
|
okButtonProps={{ danger: true }}
|
||||||
|
>
|
||||||
|
<Button size="small" danger icon={<DeleteOutlined />}>
|
||||||
|
{t('cs.alert.delete')}
|
||||||
|
</Button>
|
||||||
|
</Popconfirm>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mt-2">
|
||||||
|
<Table
|
||||||
|
size="small"
|
||||||
|
rowKey="id"
|
||||||
|
loading={isLoading}
|
||||||
|
dataSource={alerts ?? []}
|
||||||
|
columns={columns}
|
||||||
|
pagination={{ pageSize: 20 }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 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
|
||||||
|
? <Tag color="green"><CheckCircleOutlined /> OK</Tag>
|
||||||
|
: <Tag color="red"><CloseCircleOutlined /> revoked</Tag> },
|
||||||
|
{ 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) => (
|
||||||
|
<Popconfirm
|
||||||
|
title={t('cs.bouncer.confirmDelete', { name: row.name })}
|
||||||
|
onConfirm={() => deleteBouncer.mutate(row.name)}
|
||||||
|
okButtonProps={{ danger: true }}
|
||||||
|
>
|
||||||
|
<Button size="small" danger icon={<DeleteOutlined />}>
|
||||||
|
{t('cs.bouncer.delete')}
|
||||||
|
</Button>
|
||||||
|
</Popconfirm>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mt-2">
|
||||||
|
<Table
|
||||||
|
size="small"
|
||||||
|
rowKey="name"
|
||||||
|
loading={isLoading}
|
||||||
|
dataSource={bouncers ?? []}
|
||||||
|
columns={columns}
|
||||||
|
pagination={{ pageSize: 20 }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 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
|
||||||
|
? <Tag color="green">ja</Tag>
|
||||||
|
: <Tag color="orange">nein</Tag> },
|
||||||
|
{ title: t('cs.machine.version'), dataIndex: 'version', key: 'version' },
|
||||||
|
{
|
||||||
|
title: '',
|
||||||
|
key: 'actions',
|
||||||
|
render: (_: unknown, row: Machine) => (
|
||||||
|
<Popconfirm
|
||||||
|
title={t('cs.machine.confirmDelete', { id: row.machineId })}
|
||||||
|
onConfirm={() => deleteMachine.mutate(row.machineId)}
|
||||||
|
okButtonProps={{ danger: true }}
|
||||||
|
>
|
||||||
|
<Button size="small" danger icon={<DeleteOutlined />}>
|
||||||
|
{t('cs.machine.delete')}
|
||||||
|
</Button>
|
||||||
|
</Popconfirm>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mt-2">
|
||||||
|
<Table
|
||||||
|
size="small"
|
||||||
|
rowKey="machineId"
|
||||||
|
loading={isLoading}
|
||||||
|
dataSource={machines ?? []}
|
||||||
|
columns={columns}
|
||||||
|
pagination={{ pageSize: 20 }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 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) => (
|
||||||
|
<Tag color={isEnabled(v) ? 'green' : 'default'}>
|
||||||
|
{isEnabled(v) ? t('cs.collection.enabled') : t('cs.collection.disabled')}
|
||||||
|
</Tag>
|
||||||
|
) },
|
||||||
|
{ 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) => (
|
||||||
|
<Space size={4}>
|
||||||
|
{!isEnabled(row.status) ? (
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
icon={<DownloadOutlined />}
|
||||||
|
loading={installCollection.isPending}
|
||||||
|
onClick={() => installCollection.mutate(row.name)}
|
||||||
|
>
|
||||||
|
{t('cs.collection.install')}
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Popconfirm
|
||||||
|
title={t('cs.collection.confirmRemove', { name: row.name })}
|
||||||
|
onConfirm={() => removeCollection.mutate(row.name)}
|
||||||
|
okButtonProps={{ danger: true }}
|
||||||
|
>
|
||||||
|
<Button size="small" danger icon={<DeleteOutlined />}>
|
||||||
|
{t('cs.collection.remove')}
|
||||||
|
</Button>
|
||||||
|
</Popconfirm>
|
||||||
|
)}
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mt-2">
|
||||||
|
<Table
|
||||||
|
size="small"
|
||||||
|
rowKey="name"
|
||||||
|
loading={isLoading}
|
||||||
|
dataSource={collections ?? []}
|
||||||
|
columns={columns}
|
||||||
|
pagination={{ pageSize: 50 }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 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: <DecisionsTab /> },
|
||||||
|
{ key: 'alerts', label: t('cs.tabs.alerts'), children: <AlertsTab /> },
|
||||||
|
{ key: 'bouncers', label: t('cs.tabs.bouncers'), children: <BouncersTab /> },
|
||||||
|
{ key: 'machines', label: t('cs.tabs.machines'), children: <MachinesTab /> },
|
||||||
|
{ key: 'collections', label: t('cs.tabs.collections'), children: <CollectionsTab /> },
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<PageHeader
|
||||||
|
icon={<RadarChartOutlined />}
|
||||||
|
title={t('cs.title')}
|
||||||
|
subtitle={t('cs.intro')}
|
||||||
|
/>
|
||||||
|
{status && !status.installed && (
|
||||||
|
<Alert
|
||||||
|
type="warning"
|
||||||
|
showIcon
|
||||||
|
message={t('cs.notInstalled')}
|
||||||
|
className="mb-2"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<StatusStrip status={status} />
|
||||||
|
<Tabs items={tabs} defaultActiveKey="decisions" type="card" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
79
management-ui/src/pages/CrowdSec/types.ts
Normal file
79
management-ui/src/pages/CrowdSec/types.ts
Normal file
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
edgeguard ALL=(root) NOPASSWD: /bin/systemctl reload-or-restart keepalived.service
|
||||||
# VIP-Schwenk-Test: dediziertes Script mit interner Input-Validierung.
|
# VIP-Schwenk-Test: dediziertes Script mit interner Input-Validierung.
|
||||||
edgeguard ALL=(root) NOPASSWD: /usr/lib/edgeguard/vip-cmd.sh
|
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
|
SUDOERS
|
||||||
|
|
||||||
# ── Distro-Conf-Includes für die per-Service Renderer ─────────
|
# ── Distro-Conf-Includes für die per-Service Renderer ─────────
|
||||||
@@ -703,6 +717,30 @@ END;
|
|||||||
$$;
|
$$;
|
||||||
EOSQL
|
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 ───────────────────────────
|
# ── Render initial service configs ───────────────────────────
|
||||||
# Writes /etc/edgeguard/haproxy/haproxy.cfg + nftables.d/
|
# Writes /etc/edgeguard/haproxy/haproxy.cfg + nftables.d/
|
||||||
# ruleset.nft from the (just-migrated, empty) PG state.
|
# ruleset.nft from the (just-migrated, empty) PG state.
|
||||||
|
|||||||
Reference in New Issue
Block a user