From 86c48a72a0a8eadb01e6d5b2171b74e181abfb66 Mon Sep 17 00:00:00 2001 From: Amp Date: Tue, 4 Aug 2026 09:10:46 +0000 Subject: [PATCH 1/3] feat: show CrowdSec security status Amp-Thread-ID: https://ampcode.com/threads/T-019fcb72-6a6a-7237-a2cb-c16d1e2fa6b0 Co-authored-by: Arjun Komath --- agent/internal/agent/reporting.go | 3 + agent/internal/health/health.go | 247 +++++++++++ agent/internal/health/health_test.go | 141 ++++++ agent/internal/http/client.go | 1 + .../dashboard/servers/[id]/layout.tsx | 2 +- .../dashboard/servers/[id]/security/page.tsx | 26 ++ .../server/server-security-page.tsx | 409 ++++++++++++++++++ web/components/server/server-tabs.tsx | 9 +- web/db/queries.ts | 2 + web/db/schema.ts | 47 ++ web/lib/agent-status.ts | 5 + web/tests/agent-status.test.ts | 68 ++- 12 files changed, 957 insertions(+), 3 deletions(-) create mode 100644 web/app/(dashboard)/dashboard/servers/[id]/security/page.tsx create mode 100644 web/components/server/server-security-page.tsx diff --git a/agent/internal/agent/reporting.go b/agent/internal/agent/reporting.go index 50201cf9..335b5041 100644 --- a/agent/internal/agent/reporting.go +++ b/agent/internal/agent/reporting.go @@ -72,6 +72,9 @@ func (a *Agent) BuildStatusReport(includeResources bool) *agenthttp.StatusReport } report.NetworkHealth = health.CollectNetworkHealth("wg0") report.ContainerHealth = health.CollectContainerHealth() + if a.IsProxy { + report.CrowdSecHealth = health.CollectCrowdSecHealth() + } lastHealthCollect = time.Now() log.Printf("[health] collected: cpu=%.1f%%, mem=%.1f%%, disk=%.1f%%, network=%v, containers=%d running", systemStats.CpuUsagePercent, systemStats.MemoryUsagePercent, 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}` }, ]} /> - + {children} ); diff --git a/web/app/(dashboard)/dashboard/servers/[id]/security/page.tsx b/web/app/(dashboard)/dashboard/servers/[id]/security/page.tsx new file mode 100644 index 00000000..cadf0389 --- /dev/null +++ b/web/app/(dashboard)/dashboard/servers/[id]/security/page.tsx @@ -0,0 +1,26 @@ +import { notFound } from "next/navigation"; +import { ServerSecurityPage } from "@/components/server/server-security-page"; +import { getServerDetails } from "@/db/queries"; + +export default async function SecurityPage({ + params, +}: { + params: Promise<{ id: string }>; +}) { + const { id } = await params; + const server = await getServerDetails(id); + + if (!server?.isProxy) { + notFound(); + } + + return ( +
+ +
+ ); +} diff --git a/web/components/server/server-security-page.tsx b/web/components/server/server-security-page.tsx new file mode 100644 index 00000000..c6745f78 --- /dev/null +++ b/web/components/server/server-security-page.tsx @@ -0,0 +1,409 @@ +"use client"; + +import { + type Activity, + AlertTriangle, + Ban, + CheckCircle2, + Clock3, + Database, + Radio, + ShieldCheck, + XCircle, +} from "lucide-react"; +import { useEffect, useState } from "react"; +import useSWR from "swr"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { StatusBadge } from "@/components/ui/status-badge"; +import type { + CrowdSecAlert, + CrowdSecDecision, + CrowdSecHealth, +} from "@/db/schema"; +import { formatDateTime, formatRelativeTime, getTimestamp } from "@/lib/date"; +import { fetcher } from "@/lib/fetcher"; + +type ServerStatus = "pending" | "online" | "offline" | "unknown"; +type HealthState = "healthy" | "degraded" | "stale" | "not-reported"; + +type ClusterHealthResponse = { + servers: Array<{ + id: string; + status: ServerStatus; + crowdsecHealth: CrowdSecHealth | null; + }>; +}; + +const STALE_AFTER_MS = 120_000; + +const statePresentation = { + healthy: { + label: "Healthy", + icon: CheckCircle2, + className: "text-emerald-600 dark:text-emerald-400", + }, + degraded: { + label: "Degraded", + icon: AlertTriangle, + className: "text-amber-600 dark:text-amber-400", + }, + stale: { + label: "Stale", + icon: Clock3, + className: "text-amber-600 dark:text-amber-400", + }, + "not-reported": { + label: "Not reported", + icon: XCircle, + className: "text-muted-foreground", + }, +} satisfies Record; + +function Status({ state }: { state: HealthState }) { + const presentation = statePresentation[state]; + return ( + + ); +} + +function isOlderThan(value: string | undefined, now: number) { + const timestamp = getTimestamp(value); + return !Number.isFinite(timestamp) || now - timestamp > STALE_AFTER_MS; +} + +function getOverallState( + status: ServerStatus, + health: CrowdSecHealth | null, + now: number, +): HealthState { + if (!health) return "not-reported"; + if (status !== "online" || isOlderThan(health.checkedAt, now)) return "stale"; + + const bouncerFailed = + !health.bouncer.available || + !health.bouncer.registered || + health.bouncer.revoked || + isOlderThan(health.bouncer.lastPullAt, now); + if ( + !health.lapi.available || + !health.metrics.available || + bouncerFailed || + !health.decisions.available || + !health.alerts.available + ) { + return "degraded"; + } + return "healthy"; +} + +function ComponentCard({ + title, + description, + available, + icon: Icon, + children, +}: { + title: string; + description: string; + available: boolean; + icon: typeof Activity; + children?: React.ReactNode; +}) { + return ( + + +
+ + + {description} +
+ +
+ {children && {children}} +
+ ); +} + +function DateValue({ value }: { value?: string }) { + if (!value) return Never; + return ( + + ); +} + +export function ServerSecurityPage({ + serverId, + initialServerStatus, + initialHealth, +}: { + serverId: string; + initialServerStatus: ServerStatus; + initialHealth: CrowdSecHealth | null; +}) { + const { data } = useSWR( + "/api/cluster-health", + fetcher, + { refreshInterval: 10_000 }, + ); + const liveServer = data?.servers.find((server) => server.id === serverId); + const status = liveServer?.status ?? initialServerStatus; + const health = liveServer?.crowdsecHealth ?? initialHealth; + const [now, setNow] = useState(null); + useEffect(() => { + const refreshNow = () => setNow(Date.now()); + refreshNow(); + const interval = window.setInterval(refreshNow, 10_000); + return () => window.clearInterval(interval); + }, []); + const currentTime = now ?? getTimestamp(health?.checkedAt, 0); + const overallState = getOverallState(status, health, currentTime); + const bouncerAvailable = Boolean( + health?.bouncer.available && + health.bouncer.registered && + !health.bouncer.revoked && + !isOlderThan(health.bouncer.lastPullAt, currentTime), + ); + const bouncerRegistration = health?.bouncer.revoked + ? "Revoked" + : health?.bouncer.registered + ? "Registered" + : "Missing"; + + return ( +
+ + +
+ + + + Threat detection and automated blocking for public traffic. + +
+ +
+ + Last checked: + +
+ +
+ + +
+ {[ + ["Read", health?.metrics.reads], + ["Parsed", health?.metrics.parsed], + ["Unparsed", health?.metrics.unparsed], + ].map(([label, value]) => ( +
+
{label}
+
+ {value ?? "—"} +
+
+ ))} +
+
+ +
+
+
Registration
+
{bouncerRegistration}
+
+
+
Last decision pull
+
+ +
+
+ {health?.bouncer.error && ( +
{health.bouncer.error}
+ )} +
+
+
+ + + +
+ ); +} + +type SecurityListProps = + | { + title: string; + description: string; + available: boolean; + truncated: boolean; + records: CrowdSecDecision[]; + kind: "decisions"; + } + | { + title: string; + description: string; + available: boolean; + truncated: boolean; + records: CrowdSecAlert[]; + kind: "alerts"; + }; + +function SecurityList(props: SecurityListProps) { + const { title, description, available, truncated, records, kind } = props; + return ( + + + {title} + {description} + + + {!available ? ( +

+ {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" ? ( + + ) : ( + + )} + {available && truncated && ( +

+ Showing the newest reported records; additional results were + truncated. +

+ )} +
+
+ ); +} + +function DecisionRows({ records }: { records: CrowdSecDecision[] }) { + return ( +
+ + + + + + + + + + + + + {records.map((record, index) => ( + + + + + + + + ))} + +
Active CrowdSec blocks
TargetActionReasonOriginExpiry
+ + {record.scope} + +
{record.value}
+
{record.action}{record.reason || "—"}{record.origin || "—"} + +
+
+ ); +} + +function AlertRows({ records }: { records: CrowdSecAlert[] }) { + return ( +
+ + + + + + + + + + + + + {records.map((record) => ( + + + + + + + + ))} + +
Recent CrowdSec threats
DetectedScenarioSource IPCountryEvents
+ + {record.scenario || "—"}{record.sourceIp || "—"}{record.country || "—"} + {record.eventCount} +
+
+ ); +} diff --git a/web/components/server/server-tabs.tsx b/web/components/server/server-tabs.tsx index 9f7ee806..4874983b 100644 --- a/web/components/server/server-tabs.tsx +++ b/web/components/server/server-tabs.tsx @@ -4,13 +4,20 @@ import Link from "next/link"; import { usePathname } from "next/navigation"; import { cn } from "@/lib/utils"; -export function ServerTabs({ serverId }: { serverId: string }) { +export function ServerTabs({ + serverId, + isProxy, +}: { + serverId: string; + isProxy: boolean; +}) { const pathname = usePathname(); const basePath = `/dashboard/servers/${serverId}`; const tabs = [ { name: "Overview", href: basePath }, { name: "Metrics", href: `${basePath}/metrics` }, { name: "Logs", href: `${basePath}/logs` }, + ...(isProxy ? [{ name: "Security", href: `${basePath}/security` }] : []), { name: "Settings", href: `${basePath}/settings` }, ]; diff --git a/web/db/queries.ts b/web/db/queries.ts index b3277037..9e61950a 100644 --- a/web/db/queries.ts +++ b/web/db/queries.ts @@ -149,6 +149,7 @@ export const getServerDetails = cache(async (id: string) => { networkHealth: servers.networkHealth, containerHealth: servers.containerHealth, agentHealth: servers.agentHealth, + crowdsecHealth: servers.crowdsecHealth, agentUpgradeTargetVersion: servers.agentUpgradeTargetVersion, agentUpgradeStatus: servers.agentUpgradeStatus, agentUpgradeStartedAt: servers.agentUpgradeStartedAt, @@ -170,6 +171,7 @@ export async function getClusterHealth() { networkHealth: servers.networkHealth, containerHealth: servers.containerHealth, agentHealth: servers.agentHealth, + crowdsecHealth: servers.crowdsecHealth, agentUpgradeTargetVersion: servers.agentUpgradeTargetVersion, agentUpgradeStatus: servers.agentUpgradeStatus, agentUpgradeStartedAt: servers.agentUpgradeStartedAt, diff --git a/web/db/schema.ts b/web/db/schema.ts index 1c48e43c..221eaeaa 100644 --- a/web/db/schema.ts +++ b/web/db/schema.ts @@ -352,6 +352,52 @@ export type AgentHealth = { capabilities?: string[]; }; +export type CrowdSecDecision = { + scope: string; + value: string; + action: string; + reason: string; + origin: string; + expiresAt?: string; +}; + +export type CrowdSecAlert = { + id: number; + detectedAt: string; + scenario: string; + sourceIp: string; + country: string; + eventCount: number; +}; + +export type CrowdSecHealth = { + checkedAt: string; + lapi: { available: boolean }; + metrics: { + available: boolean; + reads: number; + parsed: number; + unparsed: number; + }; + bouncer: { + available: boolean; + error?: string; + registered: boolean; + revoked: boolean; + lastPullAt?: string; + }; + decisions: { + available: boolean; + truncated: boolean; + records: CrowdSecDecision[]; + }; + alerts: { + available: boolean; + truncated: boolean; + records: CrowdSecAlert[]; + }; +}; + export type AgentUpgradeStatus = | "idle" | "queued" @@ -380,6 +426,7 @@ export const servers = pgTable("servers", { networkHealth: jsonb("network_health").$type(), containerHealth: jsonb("container_health").$type(), agentHealth: jsonb("agent_health").$type(), + crowdsecHealth: jsonb("crowdsec_health").$type(), agentUpgradeTargetVersion: text("agent_upgrade_target_version"), agentUpgradeStatus: text("agent_upgrade_status", { enum: ["idle", "queued", "upgrading", "succeeded", "failed"], diff --git a/web/lib/agent-status.ts b/web/lib/agent-status.ts index 77e49cef..d5cd6462 100644 --- a/web/lib/agent-status.ts +++ b/web/lib/agent-status.ts @@ -3,6 +3,7 @@ import { db } from "@/db"; import { type AgentHealth, type ContainerHealth, + type CrowdSecHealth, deployments, type NetworkHealth, rollouts, @@ -654,6 +655,7 @@ export type StatusReport = { networkHealth?: NetworkHealth; containerHealth?: ContainerHealth; agentHealth?: AgentHealth; + crowdsecHealth?: CrowdSecHealth; deploymentErrors?: DeploymentError[]; }; @@ -696,6 +698,9 @@ export async function applyStatusReport( if (report.containerHealth) { updateData.containerHealth = report.containerHealth; } + if (report.crowdsecHealth) { + updateData.crowdsecHealth = report.crowdsecHealth; + } if (report.agentHealth) { updateData.agentHealth = report.agentHealth; diff --git a/web/tests/agent-status.test.ts b/web/tests/agent-status.test.ts index e7553218..c7a0c54b 100644 --- a/web/tests/agent-status.test.ts +++ b/web/tests/agent-status.test.ts @@ -2,11 +2,15 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; const mocks = vi.hoisted(() => { const selectResults: unknown[][] = []; + const updateData: unknown[] = []; function createQuery(result: unknown[] = []) { const query = { from: vi.fn(() => query), - set: vi.fn(() => query), + set: vi.fn((data: unknown) => { + updateData.push(data); + return query; + }), where: vi.fn(() => query), returning: vi.fn(() => query), // biome-ignore lint/suspicious/noThenProperty: Drizzle query builders are awaitable. @@ -21,6 +25,7 @@ const mocks = vi.hoisted(() => { return { selectResults, + updateData, db: { select: vi.fn(() => createQuery(selectResults.shift() ?? [])), update: vi.fn(() => createQuery()), @@ -57,11 +62,72 @@ import { inngest } from "@/lib/inngest/client"; beforeEach(() => { mocks.selectResults.length = 0; + mocks.updateData.length = 0; mocks.db.select.mockClear(); mocks.db.update.mockClear(); mocks.db.delete.mockClear(); }); +describe("agent status CrowdSec health", () => { + it("persists a supplied snapshot unchanged and preserves it when omitted", async () => { + const crowdsecHealth = { + checkedAt: "2026-08-04T12:00:00Z", + lapi: { available: true }, + metrics: { + available: true, + reads: 120, + parsed: 115, + unparsed: 5, + }, + bouncer: { + available: true, + registered: true, + revoked: false, + lastPullAt: "2026-08-04T11:59:00Z", + }, + decisions: { + available: true, + truncated: false, + records: [ + { + scope: "Ip", + value: "192.0.2.1", + action: "ban", + reason: "test-scenario", + origin: "crowdsec", + expiresAt: "2026-08-04T13:00:00Z", + }, + ], + }, + alerts: { + available: true, + truncated: false, + records: [ + { + id: 42, + detectedAt: "2026-08-04T11:58:00Z", + scenario: "test-scenario", + sourceIp: "192.0.2.1", + country: "US", + eventCount: 3, + }, + ], + }, + }; + + await applyStatusReport("server_1", { containers: [], crowdsecHealth }); + + expect(mocks.updateData[0]).toEqual( + expect.objectContaining({ crowdsecHealth }), + ); + + mocks.updateData.length = 0; + await applyStatusReport("server_1", { containers: [] }); + + expect(mocks.updateData[0]).not.toHaveProperty("crowdsecHealth"); + }); +}); + describe("agent status serverless attachment", () => { it("does not attach reported containers to sleeping deployments", () => { expect(shouldAttachReportedContainer("pending")).toBe(true); From bde853095556c378454bd8cb747ad7ecb456c4e1 Mon Sep 17 00:00:00 2001 From: Amp Date: Tue, 4 Aug 2026 09:41:24 +0000 Subject: [PATCH 2/3] fix: isolate CrowdSec status polling Amp-Thread-ID: https://ampcode.com/threads/T-019fcb72-6a6a-7237-a2cb-c16d1e2fa6b0 Co-authored-by: Arjun Komath --- agent/internal/agent/agent.go | 2 ++ agent/internal/agent/reporting.go | 17 ++++++++- agent/internal/agent/serverless_test.go | 22 ++++++++++++ web/app/api/servers/[id]/security/route.ts | 28 +++++++++++++++ .../server/server-security-page.tsx | 35 ++++++++++++------- web/db/queries.ts | 1 - 6 files changed, 91 insertions(+), 14 deletions(-) create mode 100644 web/app/api/servers/[id]/security/route.ts 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 335b5041..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() @@ -73,7 +76,7 @@ func (a *Agent) BuildStatusReport(includeResources bool) *agenthttp.StatusReport report.NetworkHealth = health.CollectNetworkHealth("wg0") report.ContainerHealth = health.CollectContainerHealth() if a.IsProxy { - report.CrowdSecHealth = health.CollectCrowdSecHealth() + a.collectCrowdSecHealthAsync() } lastHealthCollect = time.Now() log.Printf("[health] collected: cpu=%.1f%%, mem=%.1f%%, disk=%.1f%%, network=%v, containers=%d running", @@ -135,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/web/app/api/servers/[id]/security/route.ts b/web/app/api/servers/[id]/security/route.ts new file mode 100644 index 00000000..ffa43404 --- /dev/null +++ b/web/app/api/servers/[id]/security/route.ts @@ -0,0 +1,28 @@ +import { headers } from "next/headers"; +import { getServerDetails } from "@/db/queries"; +import { auth } from "@/lib/auth"; + +export async function GET( + _request: Request, + { params }: { params: Promise<{ id: string }> }, +) { + const session = await auth.api.getSession({ + headers: await headers(), + }); + + if (!session) { + return new Response("Unauthorized", { status: 401 }); + } + + const { id } = await params; + const server = await getServerDetails(id); + + if (!server?.isProxy) { + return Response.json({ message: "Server not found" }, { status: 404 }); + } + + return Response.json({ + status: server.status, + crowdsecHealth: server.crowdsecHealth ?? null, + }); +} diff --git a/web/components/server/server-security-page.tsx b/web/components/server/server-security-page.tsx index c6745f78..c56039a9 100644 --- a/web/components/server/server-security-page.tsx +++ b/web/components/server/server-security-page.tsx @@ -32,12 +32,9 @@ import { fetcher } from "@/lib/fetcher"; type ServerStatus = "pending" | "online" | "offline" | "unknown"; type HealthState = "healthy" | "degraded" | "stale" | "not-reported"; -type ClusterHealthResponse = { - servers: Array<{ - id: string; - status: ServerStatus; - crowdsecHealth: CrowdSecHealth | null; - }>; +type SecurityStatusResponse = { + status: ServerStatus; + crowdsecHealth: CrowdSecHealth | null; }; const STALE_AFTER_MS = 120_000; @@ -154,6 +151,17 @@ function DateValue({ value }: { value?: string }) { ); } +function formatBouncerError(error: string) { + switch (error) { + case "command_failed": + return "CrowdSec status command failed"; + case "invalid_output": + return "CrowdSec returned an invalid status response"; + default: + return "CrowdSec bouncer status is unavailable"; + } +} + export function ServerSecurityPage({ serverId, initialServerStatus, @@ -163,14 +171,13 @@ export function ServerSecurityPage({ initialServerStatus: ServerStatus; initialHealth: CrowdSecHealth | null; }) { - const { data } = useSWR( - "/api/cluster-health", + const { data } = useSWR( + `/api/servers/${serverId}/security`, fetcher, { refreshInterval: 10_000 }, ); - const liveServer = data?.servers.find((server) => server.id === serverId); - const status = liveServer?.status ?? initialServerStatus; - const health = liveServer?.crowdsecHealth ?? initialHealth; + const status = data === undefined ? initialServerStatus : data.status; + const health = data === undefined ? initialHealth : data.crowdsecHealth; const [now, setNow] = useState(null); useEffect(() => { const refreshNow = () => setNow(Date.now()); @@ -178,6 +185,8 @@ export function ServerSecurityPage({ const interval = window.setInterval(refreshNow, 10_000); return () => window.clearInterval(interval); }, []); + // Match the snapshot on the server render; the client clock takes over after + // hydration so stale state advances without creating a hydration mismatch. const currentTime = now ?? getTimestamp(health?.checkedAt, 0); const overallState = getOverallState(status, health, currentTime); const bouncerAvailable = Boolean( @@ -258,7 +267,9 @@ export function ServerSecurityPage({ {health?.bouncer.error && ( -
{health.bouncer.error}
+
+ {formatBouncerError(health.bouncer.error)} +
)} diff --git a/web/db/queries.ts b/web/db/queries.ts index 9e61950a..cbfa248b 100644 --- a/web/db/queries.ts +++ b/web/db/queries.ts @@ -171,7 +171,6 @@ export async function getClusterHealth() { networkHealth: servers.networkHealth, containerHealth: servers.containerHealth, agentHealth: servers.agentHealth, - crowdsecHealth: servers.crowdsecHealth, agentUpgradeTargetVersion: servers.agentUpgradeTargetVersion, agentUpgradeStatus: servers.agentUpgradeStatus, agentUpgradeStartedAt: servers.agentUpgradeStartedAt, From cccaf86f3433baeef72212f7bbab5c76a898ad26 Mon Sep 17 00:00:00 2001 From: Amp Date: Tue, 4 Aug 2026 09:49:20 +0000 Subject: [PATCH 3/3] perf: trim cluster health payload Amp-Thread-ID: https://ampcode.com/threads/T-019fcb72-6a6a-7237-a2cb-c16d1e2fa6b0 Co-authored-by: Arjun Komath --- .../cluster/cluster-health-summary.tsx | 2 - web/db/queries.ts | 81 ++----------------- web/db/types.ts | 8 -- 3 files changed, 6 insertions(+), 85 deletions(-) diff --git a/web/components/cluster/cluster-health-summary.tsx b/web/components/cluster/cluster-health-summary.tsx index 9b0563ea..13474722 100644 --- a/web/components/cluster/cluster-health-summary.tsx +++ b/web/components/cluster/cluster-health-summary.tsx @@ -7,8 +7,6 @@ type ClusterHealthData = { summary: { totalServers: number; onlineServers: number; - avgCpuUsage: number; - avgMemoryUsage: number; networkHealthy: number; containerHealthy: number; }; diff --git a/web/db/queries.ts b/web/db/queries.ts index cbfa248b..410c79bc 100644 --- a/web/db/queries.ts +++ b/web/db/queries.ts @@ -17,7 +17,6 @@ import { services, settings, } from "@/db/schema"; -import type { HealthStats } from "@/db/types"; import type { ControlPlaneUpdateState, ControlPlaneUpgradeState, @@ -33,10 +32,6 @@ import { DEFAULT_SMTP_PORT, DEFAULT_SMTP_TIMEOUT, } from "@/lib/settings-keys"; -import { - type NodeMetricsSnapshot, - queryNodeMetricsSnapshots, -} from "@/lib/victoria-metrics"; export async function listProjects() { const [projectList, serviceCounts, onlineCounts, environmentCounts] = @@ -166,49 +161,14 @@ export async function getClusterHealth() { const allServers = await db .select({ id: servers.id, - name: servers.name, status: servers.status, networkHealth: servers.networkHealth, containerHealth: servers.containerHealth, agentHealth: servers.agentHealth, - agentUpgradeTargetVersion: servers.agentUpgradeTargetVersion, - agentUpgradeStatus: servers.agentUpgradeStatus, - agentUpgradeStartedAt: servers.agentUpgradeStartedAt, - agentUpgradeError: servers.agentUpgradeError, }) .from(servers); const onlineServers = allServers.filter((s) => s.status === "online"); - const metricsByServer = await queryNodeMetricsSnapshots( - onlineServers.map((server) => server.id), - ).catch((error) => { - console.error("[cluster-health] failed to query metrics:", error); - return new Map(); - }); - - const serversWithHealth = allServers.map((server) => ({ - ...server, - healthStats: metricSnapshotToHealthStats(metricsByServer.get(server.id)), - })); - const serversWithCurrentMetrics = serversWithHealth.filter( - (server) => server.status === "online" && server.healthStats, - ); - - let avgCpuUsage = 0; - let avgMemoryUsage = 0; - - if (serversWithCurrentMetrics.length > 0) { - const cpuSum = serversWithCurrentMetrics.reduce( - (sum, s) => sum + (s.healthStats?.cpuUsagePercent ?? 0), - 0, - ); - const memSum = serversWithCurrentMetrics.reduce( - (sum, s) => sum + (s.healthStats?.memoryUsagePercent ?? 0), - 0, - ); - avgCpuUsage = cpuSum / serversWithCurrentMetrics.length; - avgMemoryUsage = memSum / serversWithCurrentMetrics.length; - } const networkHealthy = onlineServers.filter( (s) => s.networkHealth?.tunnelUp, @@ -221,44 +181,15 @@ export async function getClusterHealth() { summary: { totalServers: allServers.length, onlineServers: onlineServers.length, - avgCpuUsage, - avgMemoryUsage, networkHealthy, containerHealthy, }, - servers: serversWithHealth, - }; -} - -export function metricSnapshotToHealthStats( - snapshot: - | { - cpuUsagePercent: number | null; - memoryUsagePercent: number | null; - memoryUsedBytes: number | null; - diskUsagePercent: number | null; - diskUsedBytes: number | null; - } - | null - | undefined, -): HealthStats | null { - if (!snapshot) return null; - if ( - snapshot.cpuUsagePercent === null && - snapshot.memoryUsagePercent === null && - snapshot.memoryUsedBytes === null && - snapshot.diskUsagePercent === null && - snapshot.diskUsedBytes === null - ) { - return null; - } - - return { - cpuUsagePercent: snapshot.cpuUsagePercent ?? 0, - memoryUsagePercent: snapshot.memoryUsagePercent ?? 0, - memoryUsedMb: Math.round((snapshot.memoryUsedBytes ?? 0) / 1024 / 1024), - diskUsagePercent: snapshot.diskUsagePercent ?? 0, - diskUsedGb: Math.round((snapshot.diskUsedBytes ?? 0) / 1024 / 1024 / 1024), + servers: allServers.map((server) => ({ + id: server.id, + networkHealth: server.networkHealth, + containerHealth: server.containerHealth, + agentHealth: server.agentHealth, + })), }; } diff --git a/web/db/types.ts b/web/db/types.ts index 1cc45813..49a9811b 100644 --- a/web/db/types.ts +++ b/web/db/types.ts @@ -41,14 +41,6 @@ export type DeploymentStatus = NonNullable; export type RolloutStatus = NonNullable; export type BuildStatus = NonNullable; -export type HealthStats = { - cpuUsagePercent: number; - memoryUsagePercent: number; - memoryUsedMb: number; - diskUsagePercent: number; - diskUsedGb: number; -}; - export type ServiceWithDetails = Service & { activeConfig?: DeployedConfig | null; currentSource: SourceConfig;