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:
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.
|
||||
# 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 {
|
||||
|
||||
@@ -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()
|
||||
|
||||
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})
|
||||
}
|
||||
Reference in New Issue
Block a user