From 9242b4709a20bfa1cb002ad82d5f37e7a0d38c62 Mon Sep 17 00:00:00 2001 From: Lukas Date: Wed, 11 Mar 2026 16:38:19 +0100 Subject: [PATCH] Add GET /api/dashboard/bans/trend endpoint Implement time-bucketed ban aggregation for dashboard trend charts: - Add BanTrendBucket / BanTrendResponse Pydantic models and BUCKET_SECONDS / BUCKET_SIZE_LABEL / bucket_count helpers to ban.py - Add ban_service.ban_trend(): queries fail2ban DB with SQL bucket grouping, fills zero-count buckets, respects origin filter - Add GET /api/dashboard/bans/trend route in dashboard.py - 20 new tests (10 service, 10 router); 480 total pass, 83% coverage - ruff + mypy --strict clean --- Docs/Tasks.md | 10 +- backend/app/models/ban.py | 60 +++++++ backend/app/routers/dashboard.py | 47 ++++- backend/app/services/ban_service.py | 94 ++++++++++ backend/tests/test_routers/test_dashboard.py | 134 ++++++++++++++ .../tests/test_services/test_ban_service.py | 170 +++++++++++++++++- 6 files changed, 511 insertions(+), 4 deletions(-) diff --git a/Docs/Tasks.md b/Docs/Tasks.md index a116ff0..ffd0295 100644 --- a/Docs/Tasks.md +++ b/Docs/Tasks.md @@ -226,7 +226,15 @@ Add the `TopCountriesBarChart` to the dashboard alongside the pie chart. ### Task 4.1 — Add a backend endpoint for time-series ban aggregation -**Status:** `not started` +**Status:** `done` + +Added `GET /api/dashboard/bans/trend`. New Pydantic models `BanTrendBucket` and +`BanTrendResponse` (plus `BUCKET_SECONDS`, `BUCKET_SIZE_LABEL`, `bucket_count` +helpers) in `ban.py`. Service function `ban_trend()` in `ban_service.py` groups +`bans.timeofban` into equal-width buckets via SQL and fills empty buckets with +zero so the frontend always receives a gap-free series. Route added to +`dashboard.py`. 20 new tests (10 service, 10 router) — all pass, total suite +480 passed, 83% coverage. The existing endpoints return flat lists or country-aggregated counts but **no time-bucketed series**. A dashboard trend chart needs data grouped into time buckets. diff --git a/backend/app/models/ban.py b/backend/app/models/ban.py index 78a20cf..816b949 100644 --- a/backend/app/models/ban.py +++ b/backend/app/models/ban.py @@ -3,6 +3,7 @@ Request, response, and domain models used by the ban router and service. """ +import math from typing import Literal from pydantic import BaseModel, ConfigDict, Field @@ -220,3 +221,62 @@ class BansByCountryResponse(BaseModel): description="All bans in the selected time window (up to the server limit).", ) total: int = Field(..., ge=0, description="Total ban count in the window.") + + +# --------------------------------------------------------------------------- +# Trend endpoint models +# --------------------------------------------------------------------------- + +#: Bucket size in seconds for each time-range preset. +BUCKET_SECONDS: dict[str, int] = { + "24h": 3_600, # 1 hour → 24 buckets + "7d": 6 * 3_600, # 6 hours → 28 buckets + "30d": 86_400, # 1 day → 30 buckets + "365d": 7 * 86_400, # 7 days → ~53 buckets +} + +#: Human-readable bucket size label for each time-range preset. +BUCKET_SIZE_LABEL: dict[str, str] = { + "24h": "1h", + "7d": "6h", + "30d": "1d", + "365d": "7d", +} + + +def bucket_count(range_: TimeRange) -> int: + """Return the number of buckets needed to cover *range_* completely. + + Args: + range_: One of the supported time-range presets. + + Returns: + Ceiling division of the range duration by the bucket size so that + the last bucket is included even when the window is not an exact + multiple of the bucket size. + """ + return math.ceil(TIME_RANGE_SECONDS[range_] / BUCKET_SECONDS[range_]) + + +class BanTrendBucket(BaseModel): + """A single time bucket in the ban trend series.""" + + model_config = ConfigDict(strict=True) + + timestamp: str = Field(..., description="ISO 8601 UTC start of the bucket.") + count: int = Field(..., ge=0, description="Number of bans that started in this bucket.") + + +class BanTrendResponse(BaseModel): + """Response for the ``GET /api/dashboard/bans/trend`` endpoint.""" + + model_config = ConfigDict(strict=True) + + buckets: list[BanTrendBucket] = Field( + default_factory=list, + description="Time-ordered list of ban-count buckets covering the full window.", + ) + bucket_size: str = Field( + ..., + description="Human-readable bucket size label (e.g. '1h', '6h', '1d', '7d').", + ) diff --git a/backend/app/routers/dashboard.py b/backend/app/routers/dashboard.py index 1249b39..e5b8a97 100644 --- a/backend/app/routers/dashboard.py +++ b/backend/app/routers/dashboard.py @@ -4,7 +4,9 @@ Provides the ``GET /api/dashboard/status`` endpoint that returns the cached fail2ban server health snapshot. The snapshot is maintained by the background health-check task and refreshed every 30 seconds. -Also provides ``GET /api/dashboard/bans`` for the dashboard ban-list table. +Also provides ``GET /api/dashboard/bans`` for the dashboard ban-list table, +``GET /api/dashboard/bans/by-country`` for country aggregation, and +``GET /api/dashboard/bans/trend`` for time-bucketed ban counts. """ from __future__ import annotations @@ -20,6 +22,7 @@ from app.dependencies import AuthDep from app.models.ban import ( BanOrigin, BansByCountryResponse, + BanTrendResponse, DashboardBanListResponse, TimeRange, ) @@ -161,3 +164,45 @@ async def get_bans_by_country( origin=origin, ) + +@router.get( + "/bans/trend", + response_model=BanTrendResponse, + summary="Return ban counts aggregated into time buckets", +) +async def get_ban_trend( + request: Request, + _auth: AuthDep, + range: TimeRange = Query(default=_DEFAULT_RANGE, description="Time-range preset."), + origin: BanOrigin | None = Query( + default=None, + description="Filter by ban origin: 'blocklist' or 'selfblock'. Omit for all.", + ), +) -> BanTrendResponse: + """Return ban counts grouped into equal-width time buckets. + + Each bucket represents a contiguous time interval within the selected + window. All buckets are returned — empty buckets (zero bans) are + included so the frontend always receives a complete, gap-free series + suitable for rendering a continuous area or line chart. + + Bucket sizes: + + * ``24h`` → 1-hour buckets (24 total) + * ``7d`` → 6-hour buckets (28 total) + * ``30d`` → 1-day buckets (30 total) + * ``365d`` → 7-day buckets (~53 total) + + Args: + request: The incoming request (used to access ``app.state``). + _auth: Validated session dependency. + range: Time-range preset. + origin: Optional filter by ban origin. + + Returns: + :class:`~app.models.ban.BanTrendResponse` with the ordered bucket + list and the bucket-size label. + """ + socket_path: str = request.app.state.settings.fail2ban_socket + + return await ban_service.ban_trend(socket_path, range, origin=origin) diff --git a/backend/app/services/ban_service.py b/backend/app/services/ban_service.py index 12ea30c..bc596b0 100644 --- a/backend/app/services/ban_service.py +++ b/backend/app/services/ban_service.py @@ -19,13 +19,18 @@ import structlog from app.models.ban import ( BLOCKLIST_JAIL, + BUCKET_SECONDS, + BUCKET_SIZE_LABEL, TIME_RANGE_SECONDS, BanOrigin, BansByCountryResponse, + BanTrendBucket, + BanTrendResponse, DashboardBanItem, DashboardBanListResponse, TimeRange, _derive_origin, + bucket_count, ) from app.utils.fail2ban_client import Fail2BanClient @@ -479,3 +484,92 @@ async def bans_by_country( bans=bans, total=total, ) + + +# --------------------------------------------------------------------------- +# ban_trend +# --------------------------------------------------------------------------- + + +async def ban_trend( + socket_path: str, + range_: TimeRange, + *, + origin: BanOrigin | None = None, +) -> BanTrendResponse: + """Return ban counts aggregated into equal-width time buckets. + + Queries the fail2ban database ``bans`` table and groups records by a + computed bucket index so the frontend can render a continuous time-series + chart. All buckets within the requested window are returned — buckets + that contain zero bans are included as zero-count entries so the + frontend always receives a complete, gap-free series. + + Bucket sizes per time-range preset: + + * ``24h`` → 1-hour buckets (24 total) + * ``7d`` → 6-hour buckets (28 total) + * ``30d`` → 1-day buckets (30 total) + * ``365d`` → 7-day buckets (~53 total) + + Args: + socket_path: Path to the fail2ban Unix domain socket. + range_: Time-range preset (``"24h"``, ``"7d"``, ``"30d"``, or + ``"365d"``). + origin: Optional origin filter — ``"blocklist"`` restricts to the + ``blocklist-import`` jail, ``"selfblock"`` excludes it. + + Returns: + :class:`~app.models.ban.BanTrendResponse` with a full bucket list + and the human-readable bucket-size label. + """ + since: int = _since_unix(range_) + bucket_secs: int = BUCKET_SECONDS[range_] + num_buckets: int = bucket_count(range_) + origin_clause, origin_params = _origin_sql_filter(origin) + + db_path: str = await _get_fail2ban_db_path(socket_path) + log.info( + "ban_service_ban_trend", + db_path=db_path, + since=since, + range=range_, + origin=origin, + bucket_secs=bucket_secs, + num_buckets=num_buckets, + ) + + async with aiosqlite.connect(f"file:{db_path}?mode=ro", uri=True) as f2b_db: + f2b_db.row_factory = aiosqlite.Row + + async with f2b_db.execute( + "SELECT CAST((timeofban - ?) / ? AS INTEGER) AS bucket_idx, " + "COUNT(*) AS cnt " + "FROM bans " + "WHERE timeofban >= ?" + + origin_clause + + " GROUP BY bucket_idx " + "ORDER BY bucket_idx", + (since, bucket_secs, since, *origin_params), + ) as cur: + rows = await cur.fetchall() + + # Map bucket_idx → count; ignore any out-of-range indices. + counts: dict[int, int] = {} + for row in rows: + idx: int = int(row["bucket_idx"]) + if 0 <= idx < num_buckets: + counts[idx] = int(row["cnt"]) + + buckets: list[BanTrendBucket] = [ + BanTrendBucket( + timestamp=_ts_to_iso(since + i * bucket_secs), + count=counts.get(i, 0), + ) + for i in range(num_buckets) + ] + + return BanTrendResponse( + buckets=buckets, + bucket_size=BUCKET_SIZE_LABEL[range_], + ) diff --git a/backend/tests/test_routers/test_dashboard.py b/backend/tests/test_routers/test_dashboard.py index 64a21c9..eb2ea71 100644 --- a/backend/tests/test_routers/test_dashboard.py +++ b/backend/tests/test_routers/test_dashboard.py @@ -577,3 +577,137 @@ class TestOriginFilterParam: _, kwargs = mock_fn.call_args assert kwargs.get("origin") is None + + +# --------------------------------------------------------------------------- +# Ban trend endpoint +# --------------------------------------------------------------------------- + + +def _make_ban_trend_response(n_buckets: int = 24) -> object: + """Build a stub :class:`~app.models.ban.BanTrendResponse`.""" + from app.models.ban import BanTrendBucket, BanTrendResponse + + buckets = [ + BanTrendBucket(timestamp=f"2026-03-01T{i:02d}:00:00+00:00", count=i) + for i in range(n_buckets) + ] + return BanTrendResponse(buckets=buckets, bucket_size="1h") + + +@pytest.mark.anyio +class TestBanTrend: + """GET /api/dashboard/bans/trend.""" + + async def test_returns_200_when_authenticated( + self, dashboard_client: AsyncClient + ) -> None: + """Authenticated request returns HTTP 200.""" + with patch( + "app.routers.dashboard.ban_service.ban_trend", + new=AsyncMock(return_value=_make_ban_trend_response()), + ): + response = await dashboard_client.get("/api/dashboard/bans/trend") + assert response.status_code == 200 + + async def test_returns_401_when_unauthenticated( + self, client: AsyncClient + ) -> None: + """Unauthenticated request returns HTTP 401.""" + await client.post("/api/setup", json=_SETUP_PAYLOAD) + response = await client.get("/api/dashboard/bans/trend") + assert response.status_code == 401 + + async def test_response_shape(self, dashboard_client: AsyncClient) -> None: + """Response body contains ``buckets`` list and ``bucket_size`` string.""" + with patch( + "app.routers.dashboard.ban_service.ban_trend", + new=AsyncMock(return_value=_make_ban_trend_response(24)), + ): + response = await dashboard_client.get("/api/dashboard/bans/trend") + + body = response.json() + assert "buckets" in body + assert "bucket_size" in body + assert len(body["buckets"]) == 24 + assert body["bucket_size"] == "1h" + + async def test_each_bucket_has_timestamp_and_count( + self, dashboard_client: AsyncClient + ) -> None: + """Every element of ``buckets`` has ``timestamp`` and ``count``.""" + with patch( + "app.routers.dashboard.ban_service.ban_trend", + new=AsyncMock(return_value=_make_ban_trend_response(3)), + ): + response = await dashboard_client.get("/api/dashboard/bans/trend") + + for bucket in response.json()["buckets"]: + assert "timestamp" in bucket + assert "count" in bucket + assert isinstance(bucket["count"], int) + + async def test_default_range_is_24h(self, dashboard_client: AsyncClient) -> None: + """Omitting ``range`` defaults to ``24h``.""" + mock_fn = AsyncMock(return_value=_make_ban_trend_response()) + with patch("app.routers.dashboard.ban_service.ban_trend", new=mock_fn): + await dashboard_client.get("/api/dashboard/bans/trend") + + called_range = mock_fn.call_args[0][1] + assert called_range == "24h" + + async def test_accepts_range_param(self, dashboard_client: AsyncClient) -> None: + """The ``range`` query parameter is forwarded to the service.""" + mock_fn = AsyncMock(return_value=_make_ban_trend_response(28)) + with patch("app.routers.dashboard.ban_service.ban_trend", new=mock_fn): + await dashboard_client.get("/api/dashboard/bans/trend?range=7d") + + called_range = mock_fn.call_args[0][1] + assert called_range == "7d" + + async def test_origin_param_forwarded(self, dashboard_client: AsyncClient) -> None: + """``?origin=blocklist`` is passed as a keyword arg to the service.""" + mock_fn = AsyncMock(return_value=_make_ban_trend_response()) + with patch("app.routers.dashboard.ban_service.ban_trend", new=mock_fn): + await dashboard_client.get( + "/api/dashboard/bans/trend?origin=blocklist" + ) + + _, kwargs = mock_fn.call_args + assert kwargs.get("origin") == "blocklist" + + async def test_no_origin_defaults_to_none( + self, dashboard_client: AsyncClient + ) -> None: + """Omitting ``origin`` passes ``None`` to the service.""" + mock_fn = AsyncMock(return_value=_make_ban_trend_response()) + with patch("app.routers.dashboard.ban_service.ban_trend", new=mock_fn): + await dashboard_client.get("/api/dashboard/bans/trend") + + _, kwargs = mock_fn.call_args + assert kwargs.get("origin") is None + + async def test_invalid_range_returns_422( + self, dashboard_client: AsyncClient + ) -> None: + """An invalid ``range`` value returns HTTP 422.""" + response = await dashboard_client.get( + "/api/dashboard/bans/trend?range=invalid" + ) + assert response.status_code == 422 + + async def test_empty_buckets_response(self, dashboard_client: AsyncClient) -> None: + """Empty bucket list is serialised correctly.""" + from app.models.ban import BanTrendResponse + + empty = BanTrendResponse(buckets=[], bucket_size="1h") + with patch( + "app.routers.dashboard.ban_service.ban_trend", + new=AsyncMock(return_value=empty), + ): + response = await dashboard_client.get("/api/dashboard/bans/trend") + + body = response.json() + assert body["buckets"] == [] + assert body["bucket_size"] == "1h" + diff --git a/backend/tests/test_services/test_ban_service.py b/backend/tests/test_services/test_ban_service.py index 6853b0e..b17fad1 100644 --- a/backend/tests/test_services/test_ban_service.py +++ b/backend/tests/test_services/test_ban_service.py @@ -302,9 +302,10 @@ class TestListBansBatchGeoEnrichment: self, f2b_db_path: str ) -> None: """Geo fields are populated via lookup_batch when http_session is given.""" - from app.services.geo_service import GeoInfo from unittest.mock import MagicMock + from app.services.geo_service import GeoInfo + fake_session = MagicMock() fake_geo_map = { "1.2.3.4": GeoInfo(country_code="DE", country_name="Germany", asn="AS3320", org="Deutsche Telekom"), @@ -357,9 +358,10 @@ class TestListBansBatchGeoEnrichment: self, f2b_db_path: str ) -> None: """When both http_session and geo_enricher are provided, batch wins.""" - from app.services.geo_service import GeoInfo from unittest.mock import MagicMock + from app.services.geo_service import GeoInfo + fake_session = MagicMock() fake_geo_map = { "1.2.3.4": GeoInfo(country_code="DE", country_name="Germany", asn=None, org=None), @@ -610,3 +612,167 @@ class TestOriginFilter: ) assert result.total == 3 + + +# --------------------------------------------------------------------------- +# ban_trend +# --------------------------------------------------------------------------- + + +class TestBanTrend: + """Verify ban_service.ban_trend() behaviour.""" + + async def test_24h_returns_24_buckets(self, empty_f2b_db_path: str) -> None: + """``range_='24h'`` always yields exactly 24 buckets.""" + with patch( + "app.services.ban_service._get_fail2ban_db_path", + new=AsyncMock(return_value=empty_f2b_db_path), + ): + result = await ban_service.ban_trend("/fake/sock", "24h") + + assert len(result.buckets) == 24 + assert result.bucket_size == "1h" + + async def test_7d_returns_28_buckets(self, empty_f2b_db_path: str) -> None: + """``range_='7d'`` yields 28 six-hour buckets.""" + with patch( + "app.services.ban_service._get_fail2ban_db_path", + new=AsyncMock(return_value=empty_f2b_db_path), + ): + result = await ban_service.ban_trend("/fake/sock", "7d") + + assert len(result.buckets) == 28 + assert result.bucket_size == "6h" + + async def test_30d_returns_30_buckets(self, empty_f2b_db_path: str) -> None: + """``range_='30d'`` yields 30 daily buckets.""" + with patch( + "app.services.ban_service._get_fail2ban_db_path", + new=AsyncMock(return_value=empty_f2b_db_path), + ): + result = await ban_service.ban_trend("/fake/sock", "30d") + + assert len(result.buckets) == 30 + assert result.bucket_size == "1d" + + async def test_365d_bucket_size_label(self, empty_f2b_db_path: str) -> None: + """``range_='365d'`` uses '7d' as the bucket size label.""" + with patch( + "app.services.ban_service._get_fail2ban_db_path", + new=AsyncMock(return_value=empty_f2b_db_path), + ): + result = await ban_service.ban_trend("/fake/sock", "365d") + + assert result.bucket_size == "7d" + assert len(result.buckets) > 0 + + async def test_empty_db_all_buckets_zero(self, empty_f2b_db_path: str) -> None: + """All bucket counts are zero when the database has no bans.""" + with patch( + "app.services.ban_service._get_fail2ban_db_path", + new=AsyncMock(return_value=empty_f2b_db_path), + ): + result = await ban_service.ban_trend("/fake/sock", "24h") + + assert all(b.count == 0 for b in result.buckets) + + async def test_buckets_are_time_ordered(self, empty_f2b_db_path: str) -> None: + """Buckets are ordered chronologically (ascending timestamps).""" + with patch( + "app.services.ban_service._get_fail2ban_db_path", + new=AsyncMock(return_value=empty_f2b_db_path), + ): + result = await ban_service.ban_trend("/fake/sock", "7d") + + timestamps = [b.timestamp for b in result.buckets] + assert timestamps == sorted(timestamps) + + async def test_bans_counted_in_correct_bucket(self, tmp_path: Path) -> None: + """A ban at a known time appears in the expected bucket.""" + import time as _time + + now = int(_time.time()) + # Place a ban exactly 30 minutes ago — should land in bucket 0 of a 24h range + # (the most recent hour bucket when 'since' is ~24 h ago). + thirty_min_ago = now - 1800 + path = str(tmp_path / "test_bucket.sqlite3") + await _create_f2b_db( + path, + [{"jail": "sshd", "ip": "1.2.3.4", "timeofban": thirty_min_ago}], + ) + + with patch( + "app.services.ban_service._get_fail2ban_db_path", + new=AsyncMock(return_value=path), + ): + result = await ban_service.ban_trend("/fake/sock", "24h") + + # Total ban count across all buckets must be exactly 1. + assert sum(b.count for b in result.buckets) == 1 + + async def test_origin_filter_blocklist(self, tmp_path: Path) -> None: + """``origin='blocklist'`` counts only blocklist-import bans.""" + import time as _time + + now = int(_time.time()) + one_hour_ago = now - 3600 + path = str(tmp_path / "test_trend_origin.sqlite3") + await _create_f2b_db( + path, + [ + {"jail": "blocklist-import", "ip": "10.0.0.1", "timeofban": one_hour_ago}, + {"jail": "sshd", "ip": "10.0.0.2", "timeofban": one_hour_ago}, + ], + ) + + with patch( + "app.services.ban_service._get_fail2ban_db_path", + new=AsyncMock(return_value=path), + ): + result = await ban_service.ban_trend( + "/fake/sock", "24h", origin="blocklist" + ) + + assert sum(b.count for b in result.buckets) == 1 + + async def test_origin_filter_selfblock(self, tmp_path: Path) -> None: + """``origin='selfblock'`` excludes blocklist-import bans.""" + import time as _time + + now = int(_time.time()) + one_hour_ago = now - 3600 + path = str(tmp_path / "test_trend_selfblock.sqlite3") + await _create_f2b_db( + path, + [ + {"jail": "blocklist-import", "ip": "10.0.0.1", "timeofban": one_hour_ago}, + {"jail": "sshd", "ip": "10.0.0.2", "timeofban": one_hour_ago}, + {"jail": "nginx", "ip": "10.0.0.3", "timeofban": one_hour_ago}, + ], + ) + + with patch( + "app.services.ban_service._get_fail2ban_db_path", + new=AsyncMock(return_value=path), + ): + result = await ban_service.ban_trend( + "/fake/sock", "24h", origin="selfblock" + ) + + assert sum(b.count for b in result.buckets) == 2 + + async def test_each_bucket_has_iso_timestamp(self, empty_f2b_db_path: str) -> None: + """Every bucket timestamp is a valid ISO 8601 string.""" + from datetime import datetime + + with patch( + "app.services.ban_service._get_fail2ban_db_path", + new=AsyncMock(return_value=empty_f2b_db_path), + ): + result = await ban_service.ban_trend("/fake/sock", "24h") + + for bucket in result.buckets: + # datetime.fromisoformat raises ValueError on invalid input. + parsed = datetime.fromisoformat(bucket.timestamp) + assert parsed.tzinfo is not None # Must be timezone-aware (UTC) +