feat(security): ops alerts, shared backoff, atomic quota and daily stats for CrowdSec
Gitea Actions Runner Test / test-job (push) Successful in 1s
CI / check (push) Successful in 29s
CI / tests-integration (push) Successful in 1m36s
CI / tests-unit (push) Successful in 1m40s
CI / tests-ui (push) Successful in 2m28s
CI / preflight (push) Skipped
CI / deploy (push) Successful in 2m3s
Gitea Actions Runner Test / test-job (push) Successful in 1s
CI / check (push) Successful in 29s
CI / tests-integration (push) Successful in 1m36s
CI / tests-unit (push) Successful in 1m40s
CI / tests-ui (push) Successful in 2m28s
CI / preflight (push) Skipped
CI / deploy (push) Successful in 2m3s
Add an alerting/stats layer over the existing CrowdSec integration: - New crowdsec-alerts.ts: cooldown-gated ops alerts (Redis NX lock, TTL from HEALTH_ALERT_COOLDOWN_MIN) fanning out through the app's sendAlert service. Raised for daily quota exhaustion, block bursts (5-min window past CROWDSEC_ALERT_BLOCK_BURST), and signal-push failures. - New crowdsec-stats.ts: daily counters (lookups/blocks/reports/report_fail) in Redis with a 14-day reader for the admin panel. - Shared 403/429 backoff: the pause marker now lives in Redis (crowdsec:backoff-until) so every instance honours it, not just the process that hit the limit. - Atomic quota reservation: INCR-before-call with self-rollback on overshoot, so concurrent instances can never slip calls past the daily ceiling. - Admin anti-DDoS page gains a last-14-days activity table next to the quota bar.
This commit is contained in:
1 parent
5e4fc9ab59
commit
301edd2c9a
9 files changed
+620
-28
No files matched your search
@@ -11,7 +11,10 @@ import {
|
||||
|
||||
// The signal-push watcher is tested against a deterministic in-memory Redis
|
||||
// fake (NX lock + token cache) and a mocked fetch that routes the CAPI paths.
|
||||
const state = vi.hoisted(() => ({ map: new Map<string, string>() }));
|
||||
const state = vi.hoisted(() => ({
|
||||
map: new Map<string, string>(),
|
||||
sendAlert: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/redis", () => ({
|
||||
redis: {
|
||||
@@ -31,6 +34,12 @@ vi.mock("@/lib/redis", () => ({
|
||||
for (const key of keys) state.map.delete(key);
|
||||
return keys.length;
|
||||
},
|
||||
incr: async (key: string) => {
|
||||
const next = (Number(state.map.get(key)) || 0) + 1;
|
||||
state.map.set(key, String(next));
|
||||
return next;
|
||||
},
|
||||
expire: async () => 1,
|
||||
},
|
||||
__esModule: true,
|
||||
}));
|
||||
@@ -44,6 +53,13 @@ vi.mock("@/lib/logger", () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/services/alert", () => ({
|
||||
sendAlert: state.sendAlert,
|
||||
ddosDetected: vi.fn(),
|
||||
}));
|
||||
|
||||
const tick = () => new Promise((resolve) => setTimeout(resolve, 20));
|
||||
|
||||
const CAPI = "https://capi.example.test/v3";
|
||||
const MACHINE = "m".repeat(48);
|
||||
const PASSWORD = "Strong!1P@ssw0rdStrong!1P@ssw0rd";
|
||||
@@ -111,6 +127,7 @@ describe("crowdsec-report", () => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.unstubAllEnvs();
|
||||
state.map.clear();
|
||||
state.sendAlert.mockReset();
|
||||
resetCrowdsecReportCache();
|
||||
fetchMock = vi.fn();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
@@ -256,12 +273,56 @@ describe("crowdsec-report", () => {
|
||||
});
|
||||
|
||||
await expect(reportCrowdsecSignal(signalInput())).resolves.toBeUndefined();
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
await tick();
|
||||
const last: CrowdsecReportStatus | null = await getLastCrowdsecReport();
|
||||
expect(last?.ok).toBe(false);
|
||||
expect(last?.message).toContain("signal push rejected");
|
||||
});
|
||||
|
||||
it("counts a failed push and raises a cooldown-gated ops alert", async () => {
|
||||
fetchMock.mockImplementation((url: string) => {
|
||||
const path = String(url).replace(CAPI, "");
|
||||
if (path === "/watchers/login") {
|
||||
return Promise.resolve(
|
||||
jsonResponse({
|
||||
token: "jwt-xyz",
|
||||
expire: new Date(Date.now() + 3_600_000).toISOString(),
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (path === "/signals") {
|
||||
return Promise.resolve(jsonResponse({ message: "boom" }, 500));
|
||||
}
|
||||
return Promise.resolve(jsonResponse({}));
|
||||
});
|
||||
|
||||
await reportCrowdsecSignal(signalInput("198.51.100.20"));
|
||||
await tick();
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
expect(state.map.get(`crowdsec:stat:report_fail:${today}`)).toBe("1");
|
||||
expect(state.sendAlert).toHaveBeenCalledTimes(1);
|
||||
const [input] = state.sendAlert.mock.calls[0];
|
||||
expect(input.type).toBe("ddos");
|
||||
expect(input.severity).toBe("warning");
|
||||
expect(input.context).toMatchObject({ ip: "198.51.100.20" });
|
||||
|
||||
// A second failed push inside the cooldown window stays silent.
|
||||
await reportCrowdsecSignal(signalInput("198.51.100.21"));
|
||||
await tick();
|
||||
expect(state.sendAlert).toHaveBeenCalledTimes(1);
|
||||
expect(state.map.get(`crowdsec:stat:report_fail:${today}`)).toBe("2");
|
||||
});
|
||||
|
||||
it("tallies successful pushes into the daily stats histogram", async () => {
|
||||
routeCapi();
|
||||
await reportCrowdsecSignal(signalInput("198.51.100.30"));
|
||||
await reportCrowdsecSignal(signalInput("198.51.100.31"));
|
||||
await tick();
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
expect(state.map.get(`crowdsec:stat:reports:${today}`)).toBe("2");
|
||||
expect(state.sendAlert).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("verifies the watcher channel end to end", async () => {
|
||||
routeCapi();
|
||||
const status = await verifyCrowdsecReporting();
|
||||
|
||||
Reference in new issue
Block a user