diff --git a/agent/internal/agent/agent.go b/agent/internal/agent/agent.go
index da96c6f8..743a1c3c 100644
--- a/agent/internal/agent/agent.go
+++ b/agent/internal/agent/agent.go
@@ -92,6 +92,8 @@ type Agent struct {
currentBuildID string
IsProxy bool
serverlessGatewayRunning atomic.Bool
+ crowdSecHealth atomic.Pointer[health.CrowdSecHealth]
+ crowdSecHealthCollecting atomic.Bool
DisableDNS bool
}
diff --git a/agent/internal/agent/reporting.go b/agent/internal/agent/reporting.go
index 50201cf9..61dca53d 100644
--- a/agent/internal/agent/reporting.go
+++ b/agent/internal/agent/reporting.go
@@ -39,6 +39,9 @@ func (a *Agent) BuildStatusReport(includeResources bool) *agenthttp.StatusReport
Capabilities: a.agentCapabilities(),
},
}
+ if a.IsProxy {
+ report.CrowdSecHealth = a.crowdSecHealth.Load()
+ }
if includeResources {
report.Resources = GetSystemStats()
@@ -72,6 +75,9 @@ func (a *Agent) BuildStatusReport(includeResources bool) *agenthttp.StatusReport
}
report.NetworkHealth = health.CollectNetworkHealth("wg0")
report.ContainerHealth = health.CollectContainerHealth()
+ if a.IsProxy {
+ a.collectCrowdSecHealthAsync()
+ }
lastHealthCollect = time.Now()
log.Printf("[health] collected: cpu=%.1f%%, mem=%.1f%%, disk=%.1f%%, network=%v, containers=%d running",
systemStats.CpuUsagePercent, systemStats.MemoryUsagePercent,
@@ -132,6 +138,18 @@ func (a *Agent) BuildStatusReport(includeResources bool) *agenthttp.StatusReport
return report
}
+func (a *Agent) collectCrowdSecHealthAsync() {
+ if !a.crowdSecHealthCollecting.CompareAndSwap(false, true) {
+ return
+ }
+
+ go func() {
+ defer a.crowdSecHealthCollecting.Store(false)
+ a.crowdSecHealth.Store(health.CollectCrowdSecHealth())
+ a.RequestStatusReport("CrowdSec health collected")
+ }()
+}
+
func (a *Agent) agentCapabilities() []string {
if !a.IsProxy || !a.serverlessGatewayRunning.Load() {
return nil
diff --git a/agent/internal/agent/serverless_test.go b/agent/internal/agent/serverless_test.go
index 085313fe..214101a6 100644
--- a/agent/internal/agent/serverless_test.go
+++ b/agent/internal/agent/serverless_test.go
@@ -6,6 +6,7 @@ import (
"time"
"techulus/cloud-agent/internal/container"
+ "techulus/cloud-agent/internal/health"
agenthttp "techulus/cloud-agent/internal/http"
)
@@ -84,6 +85,27 @@ func TestBuildStatusReportAlwaysIncludesAgentHealth(t *testing.T) {
}
}
+func TestBuildStatusReportUsesCachedCrowdSecHealthForProxyOnly(t *testing.T) {
+ previousLastHealthCollect := lastHealthCollect
+ lastHealthCollect = time.Now()
+ t.Cleanup(func() {
+ lastHealthCollect = previousLastHealthCollect
+ })
+
+ snapshot := &health.CrowdSecHealth{CheckedAt: "2026-08-04T12:00:00Z"}
+ proxy := &Agent{IsProxy: true}
+ proxy.crowdSecHealth.Store(snapshot)
+ if got := proxy.BuildStatusReport(false).CrowdSecHealth; got != snapshot {
+ t.Fatalf("proxy CrowdSec health = %p, want cached snapshot %p", got, snapshot)
+ }
+
+ worker := &Agent{}
+ worker.crowdSecHealth.Store(snapshot)
+ if got := worker.BuildStatusReport(false).CrowdSecHealth; got != nil {
+ t.Fatalf("worker reported CrowdSec health: %+v", got)
+ }
+}
+
func TestPendingServerlessWakeDoesNotSuppressContainerReport(t *testing.T) {
agent := &Agent{
pendingServerlessSleep: map[string]serverlessTransitionGuard{},
diff --git a/agent/internal/health/health.go b/agent/internal/health/health.go
index f23ce9d7..8c8fd5e0 100644
--- a/agent/internal/health/health.go
+++ b/agent/internal/health/health.go
@@ -1,6 +1,10 @@
package health
import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "io"
"math"
"os"
"os/exec"
@@ -56,6 +60,64 @@ type AgentHealthInfo struct {
LastSyncAt string `json:"lastSyncAt"`
}
+type CrowdSecHealth struct {
+ CheckedAt string `json:"checkedAt"`
+ LAPI CrowdSecAvailability `json:"lapi"`
+ Metrics CrowdSecMetrics `json:"metrics"`
+ Bouncer CrowdSecBouncer `json:"bouncer"`
+ Decisions CrowdSecDecisionSnapshot `json:"decisions"`
+ Alerts CrowdSecAlertSnapshot `json:"alerts"`
+}
+
+type CrowdSecAvailability struct {
+ Available bool `json:"available"`
+}
+
+type CrowdSecMetrics struct {
+ Available bool `json:"available"`
+ Reads int64 `json:"reads"`
+ Parsed int64 `json:"parsed"`
+ Unparsed int64 `json:"unparsed"`
+}
+
+type CrowdSecBouncer struct {
+ Available bool `json:"available"`
+ Error string `json:"error,omitempty"`
+ Registered bool `json:"registered"`
+ Revoked bool `json:"revoked"`
+ LastPullAt string `json:"lastPullAt,omitempty"`
+}
+
+type CrowdSecDecision struct {
+ Scope string `json:"scope"`
+ Value string `json:"value"`
+ Action string `json:"action"`
+ Reason string `json:"reason"`
+ Origin string `json:"origin"`
+ ExpiresAt string `json:"expiresAt,omitempty"`
+}
+
+type CrowdSecDecisionSnapshot struct {
+ Available bool `json:"available"`
+ Truncated bool `json:"truncated"`
+ Records []CrowdSecDecision `json:"records"`
+}
+
+type CrowdSecAlert struct {
+ ID int64 `json:"id"`
+ DetectedAt string `json:"detectedAt"`
+ Scenario string `json:"scenario"`
+ SourceIP string `json:"sourceIp"`
+ Country string `json:"country"`
+ EventCount int64 `json:"eventCount"`
+}
+
+type CrowdSecAlertSnapshot struct {
+ Available bool `json:"available"`
+ Truncated bool `json:"truncated"`
+ Records []CrowdSecAlert `json:"records"`
+}
+
var (
agentProcessCPUMu sync.Mutex
agentProcessLastCPUTimes *cpu.TimesStat
@@ -270,3 +332,188 @@ func CollectContainerHealth() *ContainerHealth {
return health
}
+
+const crowdSecCollectionTimeout = 10 * time.Second
+
+func CollectCrowdSecHealth() *CrowdSecHealth {
+ health := &CrowdSecHealth{
+ CheckedAt: time.Now().UTC().Format(time.RFC3339Nano),
+ Decisions: CrowdSecDecisionSnapshot{Records: []CrowdSecDecision{}},
+ Alerts: CrowdSecAlertSnapshot{Records: []CrowdSecAlert{}},
+ }
+ ctx, cancel := context.WithTimeout(context.Background(), crowdSecCollectionTimeout)
+ defer cancel()
+
+ _, err := runCrowdSec(ctx, "lapi", "status")
+ health.LAPI.Available = err == nil
+
+ if output, err := runCrowdSec(ctx, "metrics", "-o", "json"); err == nil {
+ parseCrowdSecMetrics(output, &health.Metrics)
+ }
+ if output, err := runCrowdSec(ctx, "bouncers", "list", "-o", "json"); err != nil {
+ health.Bouncer.Error = "command_failed"
+ } else if !parseCrowdSecBouncer(output, &health.Bouncer) {
+ health.Bouncer.Error = "invalid_output"
+ }
+ if output, err := runCrowdSec(ctx, "decisions", "list", "--no-simu", "--limit", "51", "-o", "json"); err == nil {
+ parseCrowdSecDecisions(output, &health.Decisions)
+ }
+ if output, err := runCrowdSec(ctx, "alerts", "list", "--since", "24h", "--limit", "21", "-o", "json"); err == nil {
+ parseCrowdSecAlerts(output, &health.Alerts)
+ }
+ return health
+}
+
+type cappedBuffer struct {
+ bytes.Buffer
+}
+
+func (b *cappedBuffer) Write(p []byte) (int, error) {
+ const limit = 1024 * 1024
+ remaining := limit - b.Len()
+ if remaining <= 0 {
+ return len(p), nil
+ }
+ if len(p) > remaining {
+ _, _ = b.Buffer.Write(p[:remaining])
+ return len(p), nil
+ }
+ return b.Buffer.Write(p)
+}
+
+func runCrowdSec(ctx context.Context, args ...string) ([]byte, error) {
+ cmd := exec.CommandContext(ctx, "cscli", args...)
+ var output cappedBuffer
+ cmd.Stdout = &output
+ cmd.Stderr = io.Discard
+ err := cmd.Run()
+ return output.Bytes(), err
+}
+
+func parseCrowdSecMetrics(data []byte, result *CrowdSecMetrics) {
+ type acquisitionMetrics struct {
+ Reads int64 `json:"reads"`
+ Parsed int64 `json:"parsed"`
+ Unparsed int64 `json:"unparsed"`
+ }
+ var root struct {
+ Acquisition json.RawMessage `json:"acquisition"`
+ }
+ if json.Unmarshal(data, &root) != nil || len(root.Acquisition) == 0 {
+ return
+ }
+
+ var acquisitions map[string]acquisitionMetrics
+ if json.Unmarshal(root.Acquisition, &acquisitions) == nil {
+ for source, metrics := range acquisitions {
+ if strings.Contains(strings.ToLower(source), "traefik") {
+ result.Available = true
+ result.Reads += metrics.Reads
+ result.Parsed += metrics.Parsed
+ result.Unparsed += metrics.Unparsed
+ }
+ }
+ return
+ }
+
+ // Older CrowdSec releases exposed acquisition metrics as table-like rows.
+ var rows []struct {
+ Source string `json:"source"`
+ Name string `json:"name"`
+ Metrics struct {
+ Reads int64 `json:"lines_read"`
+ Parsed int64 `json:"lines_parsed"`
+ Unparsed int64 `json:"lines_unparsed"`
+ } `json:"metrics"`
+ }
+ if json.Unmarshal(root.Acquisition, &rows) != nil {
+ return
+ }
+ for _, acquisition := range rows {
+ if strings.Contains(strings.ToLower(acquisition.Source+" "+acquisition.Name), "traefik") {
+ result.Available = true
+ result.Reads += acquisition.Metrics.Reads
+ result.Parsed += acquisition.Metrics.Parsed
+ result.Unparsed += acquisition.Metrics.Unparsed
+ }
+ }
+}
+
+func parseCrowdSecBouncer(data []byte, result *CrowdSecBouncer) bool {
+ var bouncers []struct {
+ Name string `json:"name"`
+ Revoked bool `json:"revoked"`
+ LastPull *string `json:"last_pull"`
+ }
+ if json.Unmarshal(data, &bouncers) != nil {
+ return false
+ }
+ result.Available = true
+ for _, bouncer := range bouncers {
+ if bouncer.Name == "traefik-bouncer" {
+ result.Registered = true
+ result.Revoked = bouncer.Revoked
+ if bouncer.LastPull != nil {
+ result.LastPullAt = *bouncer.LastPull
+ }
+ break
+ }
+ }
+ return true
+}
+
+func parseCrowdSecDecisions(data []byte, result *CrowdSecDecisionSnapshot) {
+ var alerts []struct {
+ Decisions []struct {
+ Scope string `json:"scope"`
+ Value string `json:"value"`
+ Type string `json:"type"`
+ Scenario string `json:"scenario"`
+ Origin string `json:"origin"`
+ Until string `json:"until"`
+ ExpiresAt string `json:"expires_at"`
+ } `json:"decisions"`
+ }
+ if json.Unmarshal(data, &alerts) != nil {
+ return
+ }
+ result.Available = true
+ result.Truncated = len(alerts) >= 51
+ for _, alert := range alerts {
+ for _, decision := range alert.Decisions {
+ if len(result.Records) == 50 {
+ result.Truncated = true
+ return
+ }
+ expiresAt := decision.ExpiresAt
+ if expiresAt == "" {
+ expiresAt = decision.Until
+ }
+ result.Records = append(result.Records, CrowdSecDecision{Scope: decision.Scope, Value: decision.Value, Action: decision.Type, Reason: decision.Scenario, Origin: decision.Origin, ExpiresAt: expiresAt})
+ }
+ }
+}
+
+func parseCrowdSecAlerts(data []byte, result *CrowdSecAlertSnapshot) {
+ var alerts []struct {
+ ID int64 `json:"id"`
+ CreatedAt string `json:"created_at"`
+ Scenario string `json:"scenario"`
+ EventsCount int64 `json:"events_count"`
+ Source struct {
+ IP string `json:"ip"`
+ Country string `json:"cn"`
+ } `json:"source"`
+ }
+ if json.Unmarshal(data, &alerts) != nil {
+ return
+ }
+ result.Available = true
+ result.Truncated = len(alerts) > 20
+ for _, alert := range alerts {
+ if len(result.Records) == 20 {
+ break
+ }
+ result.Records = append(result.Records, CrowdSecAlert{ID: alert.ID, DetectedAt: alert.CreatedAt, Scenario: alert.Scenario, SourceIP: alert.Source.IP, Country: alert.Source.Country, EventCount: alert.EventsCount})
+ }
+}
diff --git a/agent/internal/health/health_test.go b/agent/internal/health/health_test.go
index 1e169e9a..a16ebcdb 100644
--- a/agent/internal/health/health_test.go
+++ b/agent/internal/health/health_test.go
@@ -1,7 +1,13 @@
package health
import (
+ "encoding/json"
+ "fmt"
+ "os"
+ "path/filepath"
+ "strings"
"testing"
+ "time"
"github.com/shirou/gopsutil/v3/cpu"
)
@@ -16,6 +22,141 @@ func TestCalculateAgentCPUUsagePercentNormalizesByCPUCount(t *testing.T) {
}
}
+func installCSCLIFixture(t *testing.T, script string) {
+ t.Helper()
+ dir := t.TempDir()
+ path := filepath.Join(dir, "cscli")
+ if err := os.WriteFile(path, []byte("#!/bin/sh\n"+script), 0700); err != nil {
+ t.Fatal(err)
+ }
+ t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH"))
+}
+
+func TestCollectCrowdSecHealthNormalizesSnapshot(t *testing.T) {
+ installCSCLIFixture(t, `
+case "$1 $2" in
+ "lapi status") exit 0 ;;
+ "metrics -o") cat <<'JSON'
+{"acquisition":{"file:/var/log/traefik/access.log":{"reads":12,"parsed":10,"unparsed":2},"journald":{"reads":99}}}
+JSON
+ ;;
+ "bouncers list") printf '%s' '[{"name":"traefik-bouncer","revoked":false,"last_pull":"2026-08-04T10:00:00Z"}]' ;;
+ "decisions list") printf '%s' '[{"decisions":[{"scope":"Ip","value":"203.0.113.7","type":"ban","scenario":"http-bad-user-agent","origin":"crowdsec","until":"2026-08-04T11:00:00Z","extra":"discard"}]}]' ;;
+ "alerts list") printf '%s' '[{"id":42,"created_at":"2026-08-04T09:00:00Z","scenario":"http-probing","events_count":3,"source":{"ip":"198.51.100.8","cn":"US"},"events":[{"raw":"discard"}]}]' ;;
+ *) exit 2 ;;
+esac
+`)
+
+ before := time.Now().UTC()
+ got := CollectCrowdSecHealth()
+ after := time.Now().UTC()
+ checkedAt, err := time.Parse(time.RFC3339Nano, got.CheckedAt)
+ if err != nil || checkedAt.Before(before) || checkedAt.After(after) {
+ t.Fatalf("checkedAt = %q, expected timestamp between %s and %s", got.CheckedAt, before, after)
+ }
+ if !got.LAPI.Available {
+ t.Fatal("LAPI should be available")
+ }
+ if !got.Metrics.Available || got.Metrics.Reads != 12 || got.Metrics.Parsed != 10 || got.Metrics.Unparsed != 2 {
+ t.Fatalf("metrics = %+v", got.Metrics)
+ }
+ if !got.Bouncer.Available || !got.Bouncer.Registered || got.Bouncer.Revoked || got.Bouncer.LastPullAt != "2026-08-04T10:00:00Z" || got.Bouncer.Error != "" {
+ t.Fatalf("bouncer = %+v", got.Bouncer)
+ }
+ if !got.Decisions.Available || len(got.Decisions.Records) != 1 {
+ t.Fatalf("decisions = %+v", got.Decisions)
+ }
+ decision := got.Decisions.Records[0]
+ if decision.Scope != "Ip" || decision.Value != "203.0.113.7" || decision.Action != "ban" || decision.Reason != "http-bad-user-agent" || decision.Origin != "crowdsec" || decision.ExpiresAt != "2026-08-04T11:00:00Z" {
+ t.Fatalf("decision = %+v", decision)
+ }
+ if !got.Alerts.Available || len(got.Alerts.Records) != 1 {
+ t.Fatalf("alerts = %+v", got.Alerts)
+ }
+ alert := got.Alerts.Records[0]
+ if alert.ID != 42 || alert.DetectedAt != "2026-08-04T09:00:00Z" || alert.Scenario != "http-probing" || alert.SourceIP != "198.51.100.8" || alert.Country != "US" || alert.EventCount != 3 {
+ t.Fatalf("alert = %+v", alert)
+ }
+}
+
+func TestCollectCrowdSecHealthFailuresAndPartialData(t *testing.T) {
+ installCSCLIFixture(t, `
+case "$1 $2" in
+ "lapi status") printf 'healthy-looking output'; exit 1 ;;
+ "metrics -o") printf '%s' '{"acquisition":[{"source":"journald","metrics":{"lines_read":1}}]}' ;;
+ "bouncers list") printf 'not-json' ;;
+ "decisions list") printf 'not-json' ;;
+ "alerts list") printf '%s' '[]' ;;
+esac
+`)
+ got := CollectCrowdSecHealth()
+ if got.LAPI.Available || got.Metrics.Available || got.Bouncer.Available || got.Bouncer.Error != "invalid_output" || got.Decisions.Available {
+ t.Fatalf("unexpected partial snapshot: %+v", got)
+ }
+ if !got.Alerts.Available || got.Alerts.Records == nil || len(got.Alerts.Records) != 0 {
+ t.Fatalf("empty alerts = %+v", got.Alerts)
+ }
+}
+
+func TestCrowdSecSnapshotsAreCappedAndTruncated(t *testing.T) {
+ decisions := make([]map[string]string, 51)
+ for i := range decisions {
+ decisions[i] = map[string]string{"scope": "Ip", "value": fmt.Sprintf("192.0.2.%d", i), "type": "ban"}
+ }
+ decisionJSON, _ := json.Marshal([]any{map[string]any{"decisions": decisions}})
+ decisionResult := CrowdSecDecisionSnapshot{Records: []CrowdSecDecision{}}
+ parseCrowdSecDecisions(decisionJSON, &decisionResult)
+ if !decisionResult.Available || !decisionResult.Truncated || len(decisionResult.Records) != 50 || decisionResult.Records[49].Value != "192.0.2.49" {
+ t.Fatalf("decisions cap = %+v", decisionResult)
+ }
+ duplicateAlerts := make([]map[string]any, 51)
+ for i := range duplicateAlerts {
+ duplicateAlerts[i] = map[string]any{"decisions": []map[string]string{}}
+ }
+ duplicateAlerts[0] = map[string]any{"decisions": []map[string]string{{"scope": "Ip", "value": "192.0.2.1", "type": "ban"}}}
+ duplicateJSON, _ := json.Marshal(duplicateAlerts)
+ decisionResult = CrowdSecDecisionSnapshot{Records: []CrowdSecDecision{}}
+ parseCrowdSecDecisions(duplicateJSON, &decisionResult)
+ if !decisionResult.Truncated || len(decisionResult.Records) != 1 {
+ t.Fatalf("outer alert limit should conservatively mark decisions truncated: %+v", decisionResult)
+ }
+
+ alerts := make([]map[string]any, 21)
+ for i := range alerts {
+ alerts[i] = map[string]any{"id": i + 1, "source": map[string]string{"ip": fmt.Sprintf("198.51.100.%d", i)}}
+ }
+ alertJSON, _ := json.Marshal(alerts)
+ alertResult := CrowdSecAlertSnapshot{Records: []CrowdSecAlert{}}
+ parseCrowdSecAlerts(alertJSON, &alertResult)
+ if !alertResult.Available || !alertResult.Truncated || len(alertResult.Records) != 20 || alertResult.Records[19].SourceIP != "198.51.100.19" {
+ t.Fatalf("alerts cap = %+v", alertResult)
+ }
+}
+
+func TestCrowdSecParsersRejectMalformedAndAcceptEmpty(t *testing.T) {
+ metrics := CrowdSecMetrics{}
+ parseCrowdSecMetrics([]byte(`{"acquisition":`), &metrics)
+ if metrics.Available {
+ t.Fatal("malformed metrics should be unavailable")
+ }
+ bouncer := CrowdSecBouncer{}
+ if !parseCrowdSecBouncer([]byte(`[]`), &bouncer) || !bouncer.Available || bouncer.Registered {
+ t.Fatalf("empty bouncers = %+v", bouncer)
+ }
+ bouncer = CrowdSecBouncer{}
+ if !parseCrowdSecBouncer([]byte(`[{"name":"traefik-bouncer","last_pull":null}]`), &bouncer) || !bouncer.Available || !bouncer.Registered || bouncer.LastPullAt != "" {
+ t.Fatalf("never-contacted bouncer = %+v", bouncer)
+ }
+ decisions := CrowdSecDecisionSnapshot{Records: []CrowdSecDecision{}}
+ parseCrowdSecDecisions([]byte(`[]`), &decisions)
+ if !decisions.Available || decisions.Records == nil {
+ t.Fatalf("empty decisions = %+v", decisions)
+ }
+ if strings.Contains(fmt.Sprintf("%+v", decisions), "secret") {
+ t.Fatal("unexpected raw data retained")
+ }
+}
+
func TestCalculateAgentCPUUsagePercentWarmupOrInvalidInputs(t *testing.T) {
current := &cpu.TimesStat{User: 12, System: 7}
diff --git a/agent/internal/http/client.go b/agent/internal/http/client.go
index 03c126ab..5cf21ede 100644
--- a/agent/internal/http/client.go
+++ b/agent/internal/http/client.go
@@ -353,6 +353,7 @@ type StatusReport struct {
RoutingSyncedRolloutIds []string `json:"routingSyncedRolloutIds,omitempty"`
NetworkHealth *health.NetworkHealth `json:"networkHealth,omitempty"`
ContainerHealth *health.ContainerHealth `json:"containerHealth,omitempty"`
+ CrowdSecHealth *health.CrowdSecHealth `json:"crowdsecHealth,omitempty"`
AgentHealth *AgentHealth `json:"agentHealth,omitempty"`
}
diff --git a/web/app/(dashboard)/dashboard/servers/[id]/layout.tsx b/web/app/(dashboard)/dashboard/servers/[id]/layout.tsx
index 136d50ef..5ae30bdd 100644
--- a/web/app/(dashboard)/dashboard/servers/[id]/layout.tsx
+++ b/web/app/(dashboard)/dashboard/servers/[id]/layout.tsx
@@ -25,7 +25,7 @@ export default async function ServerLayout({
{ label: server.name, href: `/dashboard/servers/${id}` },
]}
/>
-
+ {title} are unavailable from the latest check. +
+ ) : records.length === 0 ? ( ++ {kind === "decisions" + ? "No active blocks." + : "No threats detected in the last 24 hours."} +
+ ) : kind === "decisions" ? ( ++ Showing the newest reported records; additional results were + truncated. +
+ )} +| Target | +Action | +Reason | +Origin | +Expiry | +
|---|---|---|---|---|
|
+
+ {record.scope}
+
+ {record.value}
+ |
+ {record.action} | +{record.reason || "—"} | +{record.origin || "—"} | +
+ |
+
| Detected | +Scenario | +Source IP | +Country | +Events | +
|---|---|---|---|---|
|
+ |
+ {record.scenario || "—"} | +{record.sourceIp || "—"} | +{record.country || "—"} | ++ {record.eventCount} + | +