diff --git a/Docs/Tasks.md b/Docs/Tasks.md index 62a47ea..82085af 100644 --- a/Docs/Tasks.md +++ b/Docs/Tasks.md @@ -266,7 +266,7 @@ Build a multi-layered safety net that: ## Task 4 — Jail Detail Page: Paginated "Currently Banned IPs" List -**Status:** not started +**Status:** done **References:** [Features.md § 5 — Jail Management](Features.md), [Architekture.md § 2](Architekture.md) ### Problem diff --git a/backend/app/models/ban.py b/backend/app/models/ban.py index b286a36..ceae79f 100644 --- a/backend/app/models/ban.py +++ b/backend/app/models/ban.py @@ -306,3 +306,30 @@ class BansByJailResponse(BaseModel): description="Jails ordered by ban count descending.", ) total: int = Field(..., ge=0, description="Total ban count in the selected window.") + + +# --------------------------------------------------------------------------- +# Jail-specific paginated bans +# --------------------------------------------------------------------------- + + +class JailBannedIpsResponse(BaseModel): + """Paginated response for ``GET /api/jails/{name}/banned``. + + Contains only the current page of active ban entries for a single jail, + geo-enriched exclusively for the page slice to avoid rate-limit issues. + """ + + model_config = ConfigDict(strict=True) + + items: list[ActiveBan] = Field( + default_factory=list, + description="Active ban entries for the current page.", + ) + total: int = Field( + ..., + ge=0, + description="Total matching entries (after applying the search filter).", + ) + page: int = Field(..., ge=1, description="Current page number (1-based).") + page_size: int = Field(..., ge=1, description="Number of items per page.") diff --git a/backend/app/routers/jails.py b/backend/app/routers/jails.py index d6ded0f..e15265d 100644 --- a/backend/app/routers/jails.py +++ b/backend/app/routers/jails.py @@ -4,6 +4,7 @@ Provides CRUD and control operations for fail2ban jails: * ``GET /api/jails`` — list all jails * ``GET /api/jails/{name}`` — full detail for one jail +* ``GET /api/jails/{name}/banned`` — paginated currently-banned IPs for one jail * ``POST /api/jails/{name}/start`` — start a jail * ``POST /api/jails/{name}/stop`` — stop a jail * ``POST /api/jails/{name}/idle`` — toggle idle mode @@ -23,6 +24,7 @@ from typing import Annotated from fastapi import APIRouter, Body, HTTPException, Path, Request, status from app.dependencies import AuthDep +from app.models.ban import JailBannedIpsResponse from app.models.jail import ( IgnoreIpRequest, JailCommandResponse, @@ -540,3 +542,74 @@ async def toggle_ignore_self( raise _conflict(str(exc)) from exc except Fail2BanConnectionError as exc: raise _bad_gateway(exc) from exc + + +# --------------------------------------------------------------------------- +# Currently banned IPs (paginated) +# --------------------------------------------------------------------------- + + +@router.get( + "/{name}/banned", + response_model=JailBannedIpsResponse, + summary="Return paginated currently-banned IPs for a single jail", +) +async def get_jail_banned_ips( + request: Request, + _auth: AuthDep, + name: _NamePath, + page: int = 1, + page_size: int = 25, + search: str | None = None, +) -> JailBannedIpsResponse: + """Return a paginated list of IPs currently banned by a specific jail. + + The full ban list is fetched from the fail2ban socket, filtered by the + optional *search* substring, sliced to the requested page, and then + geo-enriched exclusively for that page slice. + + Args: + request: Incoming request (used to access ``app.state``). + _auth: Validated session — enforces authentication. + name: Jail name. + page: 1-based page number (default 1, min 1). + page_size: Items per page (default 25, max 100). + search: Optional case-insensitive substring filter on the IP address. + + Returns: + :class:`~app.models.ban.JailBannedIpsResponse` with the paginated bans. + + Raises: + HTTPException: 400 when *page* or *page_size* are out of range. + HTTPException: 404 when the jail does not exist. + HTTPException: 502 when fail2ban is unreachable. + """ + if page < 1: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="page must be >= 1.", + ) + if not (1 <= page_size <= 100): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="page_size must be between 1 and 100.", + ) + + socket_path: str = request.app.state.settings.fail2ban_socket + http_session = getattr(request.app.state, "http_session", None) + app_db = getattr(request.app.state, "db", None) + + try: + return await jail_service.get_jail_banned_ips( + socket_path=socket_path, + jail_name=name, + page=page, + page_size=page_size, + search=search, + http_session=http_session, + app_db=app_db, + ) + except JailNotFoundError: + raise _not_found(name) from None + except Fail2BanConnectionError as exc: + raise _bad_gateway(exc) from exc diff --git a/backend/app/services/jail_service.py b/backend/app/services/jail_service.py index 63521b7..d89d2d5 100644 --- a/backend/app/services/jail_service.py +++ b/backend/app/services/jail_service.py @@ -18,7 +18,7 @@ from typing import Any import structlog -from app.models.ban import ActiveBan, ActiveBanListResponse +from app.models.ban import ActiveBan, ActiveBanListResponse, JailBannedIpsResponse from app.models.config import BantimeEscalation from app.models.jail import ( Jail, @@ -862,6 +862,120 @@ def _parse_ban_entry(entry: str, jail: str) -> ActiveBan | None: return None +# --------------------------------------------------------------------------- +# Public API — Jail-specific paginated bans +# --------------------------------------------------------------------------- + +#: Maximum allowed page size for :func:`get_jail_banned_ips`. +_MAX_PAGE_SIZE: int = 100 + + +async def get_jail_banned_ips( + socket_path: str, + jail_name: str, + page: int = 1, + page_size: int = 25, + search: str | None = None, + http_session: Any | None = None, + app_db: Any | None = None, +) -> JailBannedIpsResponse: + """Return a paginated list of currently banned IPs for a single jail. + + Fetches the full ban list from the fail2ban socket, applies an optional + substring search filter on the IP, paginates server-side, and geo-enriches + **only** the current page slice to stay within rate limits. + + Args: + socket_path: Path to the fail2ban Unix domain socket. + jail_name: Name of the jail to query. + page: 1-based page number (default 1). + page_size: Items per page; clamped to :data:`_MAX_PAGE_SIZE` (default 25). + search: Optional case-insensitive substring filter applied to IP addresses. + http_session: Optional shared :class:`aiohttp.ClientSession` for geo + enrichment via :func:`~app.services.geo_service.lookup_batch`. + app_db: Optional BanGUI application database for persistent geo cache. + + Returns: + :class:`~app.models.ban.JailBannedIpsResponse` with the paginated bans. + + Raises: + JailNotFoundError: If *jail_name* is not a known active jail. + ~app.utils.fail2ban_client.Fail2BanConnectionError: If the socket is + unreachable. + """ + from app.services import geo_service # noqa: PLC0415 + + # Clamp page_size to the allowed maximum. + page_size = min(page_size, _MAX_PAGE_SIZE) + + client = Fail2BanClient(socket_path=socket_path, timeout=_SOCKET_TIMEOUT) + + # Verify the jail exists. + try: + _ok(await client.send(["status", jail_name, "short"])) + except ValueError as exc: + if _is_not_found_error(exc): + raise JailNotFoundError(jail_name) from exc + raise + + # Fetch the full ban list for this jail. + try: + raw_result = _ok(await client.send(["get", jail_name, "banip", "--with-time"])) + except (ValueError, TypeError): + raw_result = [] + + ban_list: list[str] = raw_result or [] + + # Parse all entries. + all_bans: list[ActiveBan] = [] + for entry in ban_list: + ban = _parse_ban_entry(str(entry), jail_name) + if ban is not None: + all_bans.append(ban) + + # Apply optional substring search filter (case-insensitive). + if search: + search_lower = search.lower() + all_bans = [b for b in all_bans if search_lower in b.ip.lower()] + + total = len(all_bans) + + # Slice the requested page. + start = (page - 1) * page_size + page_bans = all_bans[start : start + page_size] + + # Geo-enrich only the page slice. + if http_session is not None and page_bans: + page_ips = [b.ip for b in page_bans] + try: + geo_map = await geo_service.lookup_batch(page_ips, http_session, db=app_db) + except Exception: # noqa: BLE001 + log.warning("jail_banned_ips_geo_failed", jail=jail_name) + geo_map = {} + enriched_page: list[ActiveBan] = [] + for ban in page_bans: + geo = geo_map.get(ban.ip) + if geo is not None: + enriched_page.append(ban.model_copy(update={"country": geo.country_code})) + else: + enriched_page.append(ban) + page_bans = enriched_page + + log.info( + "jail_banned_ips_fetched", + jail=jail_name, + total=total, + page=page, + page_size=page_size, + ) + return JailBannedIpsResponse( + items=page_bans, + total=total, + page=page, + page_size=page_size, + ) + + async def _enrich_bans( bans: list[ActiveBan], geo_enricher: Any, diff --git a/backend/tests/test_routers/test_jails.py b/backend/tests/test_routers/test_jails.py index 94b47b4..4954e23 100644 --- a/backend/tests/test_routers/test_jails.py +++ b/backend/tests/test_routers/test_jails.py @@ -788,3 +788,146 @@ class TestFail2BanConnectionErrors: resp = await jails_client.post("/api/jails/sshd/reload") assert resp.status_code == 502 + + +# --------------------------------------------------------------------------- +# GET /api/jails/{name}/banned +# --------------------------------------------------------------------------- + + +class TestGetJailBannedIps: + """Tests for ``GET /api/jails/{name}/banned``.""" + + def _mock_response( + self, + *, + items: list[dict] | None = None, + total: int = 2, + page: int = 1, + page_size: int = 25, + ) -> "JailBannedIpsResponse": # type: ignore[name-defined] + from app.models.ban import ActiveBan, JailBannedIpsResponse + + ban_items = ( + [ + ActiveBan( + ip=item.get("ip", "1.2.3.4"), + jail="sshd", + banned_at=item.get("banned_at", "2025-01-01T10:00:00+00:00"), + expires_at=item.get("expires_at", "2025-01-01T10:10:00+00:00"), + ban_count=1, + country=item.get("country", None), + ) + for item in (items or [{"ip": "1.2.3.4"}, {"ip": "5.6.7.8"}]) + ] + ) + return JailBannedIpsResponse( + items=ban_items, total=total, page=page, page_size=page_size + ) + + async def test_200_returns_paginated_bans(self, jails_client: AsyncClient) -> None: + """GET /api/jails/sshd/banned returns 200 with a JailBannedIpsResponse.""" + with patch( + "app.routers.jails.jail_service.get_jail_banned_ips", + AsyncMock(return_value=self._mock_response()), + ): + resp = await jails_client.get("/api/jails/sshd/banned") + + assert resp.status_code == 200 + data = resp.json() + assert "items" in data + assert "total" in data + assert "page" in data + assert "page_size" in data + assert data["total"] == 2 + + async def test_200_with_search_parameter(self, jails_client: AsyncClient) -> None: + """GET /api/jails/sshd/banned?search=1.2.3 passes search to service.""" + mock_fn = AsyncMock(return_value=self._mock_response(items=[{"ip": "1.2.3.4"}], total=1)) + with patch("app.routers.jails.jail_service.get_jail_banned_ips", mock_fn): + resp = await jails_client.get("/api/jails/sshd/banned?search=1.2.3") + + assert resp.status_code == 200 + _args, call_kwargs = mock_fn.call_args + assert call_kwargs.get("search") == "1.2.3" + + async def test_200_with_page_and_page_size(self, jails_client: AsyncClient) -> None: + """GET /api/jails/sshd/banned?page=2&page_size=10 passes params to service.""" + mock_fn = AsyncMock( + return_value=self._mock_response(page=2, page_size=10, total=0, items=[]) + ) + with patch("app.routers.jails.jail_service.get_jail_banned_ips", mock_fn): + resp = await jails_client.get("/api/jails/sshd/banned?page=2&page_size=10") + + assert resp.status_code == 200 + _args, call_kwargs = mock_fn.call_args + assert call_kwargs.get("page") == 2 + assert call_kwargs.get("page_size") == 10 + + async def test_400_when_page_is_zero(self, jails_client: AsyncClient) -> None: + """GET /api/jails/sshd/banned?page=0 returns 400.""" + resp = await jails_client.get("/api/jails/sshd/banned?page=0") + assert resp.status_code == 400 + + async def test_400_when_page_size_exceeds_max(self, jails_client: AsyncClient) -> None: + """GET /api/jails/sshd/banned?page_size=200 returns 400.""" + resp = await jails_client.get("/api/jails/sshd/banned?page_size=200") + assert resp.status_code == 400 + + async def test_400_when_page_size_is_zero(self, jails_client: AsyncClient) -> None: + """GET /api/jails/sshd/banned?page_size=0 returns 400.""" + resp = await jails_client.get("/api/jails/sshd/banned?page_size=0") + assert resp.status_code == 400 + + async def test_404_for_unknown_jail(self, jails_client: AsyncClient) -> None: + """GET /api/jails/ghost/banned returns 404 when jail is unknown.""" + from app.services.jail_service import JailNotFoundError + + with patch( + "app.routers.jails.jail_service.get_jail_banned_ips", + AsyncMock(side_effect=JailNotFoundError("ghost")), + ): + resp = await jails_client.get("/api/jails/ghost/banned") + + assert resp.status_code == 404 + + async def test_502_when_fail2ban_unreachable(self, jails_client: AsyncClient) -> None: + """GET /api/jails/sshd/banned returns 502 when fail2ban is unreachable.""" + from app.utils.fail2ban_client import Fail2BanConnectionError + + with patch( + "app.routers.jails.jail_service.get_jail_banned_ips", + AsyncMock( + side_effect=Fail2BanConnectionError("socket dead", "/tmp/fake.sock") + ), + ): + resp = await jails_client.get("/api/jails/sshd/banned") + + assert resp.status_code == 502 + + async def test_response_items_have_expected_fields( + self, jails_client: AsyncClient + ) -> None: + """Response items contain ip, jail, banned_at, expires_at, ban_count, country.""" + with patch( + "app.routers.jails.jail_service.get_jail_banned_ips", + AsyncMock(return_value=self._mock_response()), + ): + resp = await jails_client.get("/api/jails/sshd/banned") + + item = resp.json()["items"][0] + assert "ip" in item + assert "jail" in item + assert "banned_at" in item + assert "expires_at" in item + assert "ban_count" in item + assert "country" in item + + async def test_401_when_unauthenticated(self, jails_client: AsyncClient) -> None: + """GET /api/jails/sshd/banned returns 401 without a session cookie.""" + resp = await AsyncClient( + transport=ASGITransport(app=jails_client._transport.app), # type: ignore[attr-defined] + base_url="http://test", + ).get("/api/jails/sshd/banned") + assert resp.status_code == 401 + diff --git a/backend/tests/test_services/test_jail_service.py b/backend/tests/test_services/test_jail_service.py index da290f4..10d1a9a 100644 --- a/backend/tests/test_services/test_jail_service.py +++ b/backend/tests/test_services/test_jail_service.py @@ -7,7 +7,7 @@ from unittest.mock import AsyncMock, patch import pytest -from app.models.ban import ActiveBanListResponse +from app.models.ban import ActiveBanListResponse, JailBannedIpsResponse from app.models.jail import JailDetailResponse, JailListResponse from app.services import jail_service from app.services.jail_service import JailNotFoundError, JailOperationError @@ -700,3 +700,201 @@ class TestUnbanAllIps: pytest.raises(Fail2BanConnectionError), ): await jail_service.unban_all_ips(_SOCKET) + + +# --------------------------------------------------------------------------- +# get_jail_banned_ips +# --------------------------------------------------------------------------- + +#: A raw ban entry string in the format produced by fail2ban --with-time. +_BAN_ENTRY_1 = "1.2.3.4\t2025-01-01 10:00:00 + 600 = 2025-01-01 10:10:00" +_BAN_ENTRY_2 = "5.6.7.8\t2025-01-01 11:00:00 + 600 = 2025-01-01 11:10:00" +_BAN_ENTRY_3 = "9.10.11.12\t2025-01-01 12:00:00 + 600 = 2025-01-01 12:10:00" + + +def _banned_ips_responses(jail: str = "sshd", entries: list[str] | None = None) -> dict[str, Any]: + """Build mock responses for get_jail_banned_ips tests.""" + if entries is None: + entries = [_BAN_ENTRY_1, _BAN_ENTRY_2] + return { + f"status|{jail}|short": _make_short_status(), + f"get|{jail}|banip|--with-time": (0, entries), + } + + +class TestGetJailBannedIps: + """Unit tests for :func:`~app.services.jail_service.get_jail_banned_ips`.""" + + async def test_returns_jail_banned_ips_response(self) -> None: + """get_jail_banned_ips returns a JailBannedIpsResponse.""" + with _patch_client(_banned_ips_responses()): + result = await jail_service.get_jail_banned_ips(_SOCKET, "sshd") + + assert isinstance(result, JailBannedIpsResponse) + + async def test_total_reflects_all_entries(self) -> None: + """total equals the number of parsed ban entries.""" + with _patch_client(_banned_ips_responses(entries=[_BAN_ENTRY_1, _BAN_ENTRY_2, _BAN_ENTRY_3])): + result = await jail_service.get_jail_banned_ips(_SOCKET, "sshd") + + assert result.total == 3 + + async def test_page_1_returns_first_n_items(self) -> None: + """page=1 with page_size=2 returns the first two entries.""" + with _patch_client( + _banned_ips_responses(entries=[_BAN_ENTRY_1, _BAN_ENTRY_2, _BAN_ENTRY_3]) + ): + result = await jail_service.get_jail_banned_ips( + _SOCKET, "sshd", page=1, page_size=2 + ) + + assert len(result.items) == 2 + assert result.items[0].ip == "1.2.3.4" + assert result.items[1].ip == "5.6.7.8" + assert result.total == 3 + + async def test_page_2_returns_remaining_items(self) -> None: + """page=2 with page_size=2 returns the third entry.""" + with _patch_client( + _banned_ips_responses(entries=[_BAN_ENTRY_1, _BAN_ENTRY_2, _BAN_ENTRY_3]) + ): + result = await jail_service.get_jail_banned_ips( + _SOCKET, "sshd", page=2, page_size=2 + ) + + assert len(result.items) == 1 + assert result.items[0].ip == "9.10.11.12" + + async def test_page_beyond_last_returns_empty_items(self) -> None: + """Requesting a page past the end returns an empty items list.""" + with _patch_client(_banned_ips_responses()): + result = await jail_service.get_jail_banned_ips( + _SOCKET, "sshd", page=99, page_size=25 + ) + + assert result.items == [] + assert result.total == 2 + + async def test_search_filter_narrows_results(self) -> None: + """search parameter filters entries by IP substring.""" + with _patch_client(_banned_ips_responses()): + result = await jail_service.get_jail_banned_ips( + _SOCKET, "sshd", search="1.2.3" + ) + + assert result.total == 1 + assert result.items[0].ip == "1.2.3.4" + + async def test_search_filter_case_insensitive(self) -> None: + """search filter is case-insensitive.""" + entries = ["192.168.0.1\t2025-01-01 10:00:00 + 600 = 2025-01-01 10:10:00"] + with _patch_client(_banned_ips_responses(entries=entries)): + result = await jail_service.get_jail_banned_ips( + _SOCKET, "sshd", search="192.168" + ) + + assert result.total == 1 + + async def test_search_no_match_returns_empty(self) -> None: + """search that matches nothing returns empty items and total=0.""" + with _patch_client(_banned_ips_responses()): + result = await jail_service.get_jail_banned_ips( + _SOCKET, "sshd", search="999.999" + ) + + assert result.total == 0 + assert result.items == [] + + async def test_empty_ban_list_returns_total_zero(self) -> None: + """get_jail_banned_ips handles an empty ban list gracefully.""" + responses = { + "status|sshd|short": _make_short_status(), + "get|sshd|banip|--with-time": (0, []), + } + with _patch_client(responses): + result = await jail_service.get_jail_banned_ips(_SOCKET, "sshd") + + assert result.total == 0 + assert result.items == [] + + async def test_page_size_clamped_to_max(self) -> None: + """page_size values above 100 are silently clamped to 100.""" + entries = [f"10.0.0.{i}\t2025-01-01 10:00:00 + 600 = 2025-01-01 10:10:00" for i in range(1, 101)] + responses = { + "status|sshd|short": _make_short_status(), + "get|sshd|banip|--with-time": (0, entries), + } + with _patch_client(responses): + result = await jail_service.get_jail_banned_ips( + _SOCKET, "sshd", page=1, page_size=200 + ) + + assert len(result.items) <= 100 + + async def test_geo_enrichment_called_for_page_slice_only(self) -> None: + """Geo enrichment is requested only for IPs in the current page.""" + from unittest.mock import MagicMock + + from app.services import geo_service + + http_session = MagicMock() + geo_enrichment_ips: list[list[str]] = [] + + async def _mock_lookup_batch( + ips: list[str], _session: Any, **_kw: Any + ) -> dict[str, Any]: + geo_enrichment_ips.append(list(ips)) + return {} + + with ( + _patch_client( + _banned_ips_responses(entries=[_BAN_ENTRY_1, _BAN_ENTRY_2, _BAN_ENTRY_3]) + ), + patch.object(geo_service, "lookup_batch", side_effect=_mock_lookup_batch), + ): + result = await jail_service.get_jail_banned_ips( + _SOCKET, + "sshd", + page=1, + page_size=2, + http_session=http_session, + ) + + # Only the 2-IP page slice should be passed to geo enrichment. + assert len(geo_enrichment_ips) == 1 + assert len(geo_enrichment_ips[0]) == 2 + assert result.total == 3 + + async def test_unknown_jail_raises_jail_not_found_error(self) -> None: + """get_jail_banned_ips raises JailNotFoundError for unknown jail.""" + responses = { + "status|ghost|short": (0, pytest.raises), # will be overridden + } + # Simulate fail2ban returning an "unknown jail" error. + class _FakeClient: + def __init__(self, **_kw: Any) -> None: + pass + + async def send(self, command: list[Any]) -> Any: + raise ValueError("Unknown jail: ghost") + + with ( + patch("app.services.jail_service.Fail2BanClient", _FakeClient), + pytest.raises(JailNotFoundError), + ): + await jail_service.get_jail_banned_ips(_SOCKET, "ghost") + + async def test_connection_error_propagates(self) -> None: + """get_jail_banned_ips propagates Fail2BanConnectionError.""" + + class _FailClient: + def __init__(self, **_kw: Any) -> None: + self.send = AsyncMock( + side_effect=Fail2BanConnectionError("no socket", _SOCKET) + ) + + with ( + patch("app.services.jail_service.Fail2BanClient", _FailClient), + pytest.raises(Fail2BanConnectionError), + ): + await jail_service.get_jail_banned_ips(_SOCKET, "sshd") diff --git a/frontend/src/api/endpoints.ts b/frontend/src/api/endpoints.ts index eeb4bce..2df2c13 100644 --- a/frontend/src/api/endpoints.ts +++ b/frontend/src/api/endpoints.ts @@ -38,6 +38,7 @@ export const ENDPOINTS = { // ------------------------------------------------------------------------- jails: "/jails", jail: (name: string): string => `/jails/${encodeURIComponent(name)}`, + jailBanned: (name: string): string => `/jails/${encodeURIComponent(name)}/banned`, jailStart: (name: string): string => `/jails/${encodeURIComponent(name)}/start`, jailStop: (name: string): string => `/jails/${encodeURIComponent(name)}/stop`, jailIdle: (name: string): string => `/jails/${encodeURIComponent(name)}/idle`, diff --git a/frontend/src/api/jails.ts b/frontend/src/api/jails.ts index dab9349..bc5db30 100644 --- a/frontend/src/api/jails.ts +++ b/frontend/src/api/jails.ts @@ -10,6 +10,7 @@ import { ENDPOINTS } from "./endpoints"; import type { ActiveBanListResponse, IpLookupResponse, + JailBannedIpsResponse, JailCommandResponse, JailDetailResponse, JailListResponse, @@ -224,3 +225,37 @@ export async function unbanAllBans(): Promise { export async function lookupIp(ip: string): Promise { return get(ENDPOINTS.geoLookup(ip)); } + +// --------------------------------------------------------------------------- +// Jail-specific paginated bans +// --------------------------------------------------------------------------- + +/** + * Fetch the currently banned IPs for a specific jail, paginated. + * + * Only the requested page is geo-enriched on the backend, so this call + * remains fast even when a jail has thousands of banned IPs. + * + * @param jailName - Jail name (e.g. `"sshd"`). + * @param page - 1-based page number (default 1). + * @param pageSize - Items per page; max 100 (default 25). + * @param search - Optional case-insensitive IP substring filter. + * @returns A {@link JailBannedIpsResponse} with paginated ban entries. + * @throws {ApiError} On non-2xx responses (404 if jail unknown, 502 if fail2ban down). + */ +export async function fetchJailBannedIps( + jailName: string, + page = 1, + pageSize = 25, + search?: string, +): Promise { + const params: Record = { + page: String(page), + page_size: String(pageSize), + }; + if (search !== undefined && search !== "") { + params.search = search; + } + const query = new URLSearchParams(params).toString(); + return get(`${ENDPOINTS.jailBanned(jailName)}?${query}`); +} diff --git a/frontend/src/components/jail/BannedIpsSection.tsx b/frontend/src/components/jail/BannedIpsSection.tsx new file mode 100644 index 0000000..62f6676 --- /dev/null +++ b/frontend/src/components/jail/BannedIpsSection.tsx @@ -0,0 +1,466 @@ +/** + * `BannedIpsSection` component. + * + * Displays a paginated table of IPs currently banned in a specific fail2ban + * jail. Supports server-side search filtering (debounced), page navigation, + * page-size selection, and per-row unban actions. + * + * Only the current page is geo-enriched by the backend, so the component + * remains fast even when a jail contains thousands of banned IPs. + */ + +import { useCallback, useEffect, useRef, useState } from "react"; +import { + Badge, + Button, + DataGrid, + DataGridBody, + DataGridCell, + DataGridHeader, + DataGridHeaderCell, + DataGridRow, + Dropdown, + Field, + Input, + MessageBar, + MessageBarBody, + Option, + Spinner, + Text, + Tooltip, + makeStyles, + tokens, + type TableColumnDefinition, + createTableColumn, +} from "@fluentui/react-components"; +import { + ArrowClockwiseRegular, + ChevronLeftRegular, + ChevronRightRegular, + DismissRegular, + SearchRegular, +} from "@fluentui/react-icons"; +import { fetchJailBannedIps, unbanIp } from "../../api/jails"; +import type { ActiveBan } from "../../types/jail"; +import { ApiError } from "../../api/client"; + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +/** Debounce delay in milliseconds for the search input. */ +const SEARCH_DEBOUNCE_MS = 300; + +/** Available page-size options. */ +const PAGE_SIZE_OPTIONS = [10, 25, 50, 100] as const; + +// --------------------------------------------------------------------------- +// Styles +// --------------------------------------------------------------------------- + +const useStyles = makeStyles({ + root: { + display: "flex", + flexDirection: "column", + gap: tokens.spacingVerticalS, + backgroundColor: tokens.colorNeutralBackground1, + borderRadius: tokens.borderRadiusMedium, + borderTopWidth: "1px", + borderTopStyle: "solid", + borderTopColor: tokens.colorNeutralStroke2, + borderRightWidth: "1px", + borderRightStyle: "solid", + borderRightColor: tokens.colorNeutralStroke2, + borderBottomWidth: "1px", + borderBottomStyle: "solid", + borderBottomColor: tokens.colorNeutralStroke2, + borderLeftWidth: "1px", + borderLeftStyle: "solid", + borderLeftColor: tokens.colorNeutralStroke2, + padding: tokens.spacingVerticalM, + }, + header: { + display: "flex", + alignItems: "center", + justifyContent: "space-between", + gap: tokens.spacingHorizontalM, + paddingBottom: tokens.spacingVerticalS, + borderBottomWidth: "1px", + borderBottomStyle: "solid", + borderBottomColor: tokens.colorNeutralStroke2, + }, + headerLeft: { + display: "flex", + alignItems: "center", + gap: tokens.spacingHorizontalM, + }, + toolbar: { + display: "flex", + alignItems: "flex-end", + gap: tokens.spacingHorizontalS, + flexWrap: "wrap", + }, + searchField: { + minWidth: "200px", + flexGrow: 1, + }, + tableWrapper: { + overflowX: "auto", + }, + centred: { + display: "flex", + justifyContent: "center", + alignItems: "center", + padding: tokens.spacingVerticalXXL, + }, + pagination: { + display: "flex", + alignItems: "center", + justifyContent: "flex-end", + gap: tokens.spacingHorizontalS, + paddingTop: tokens.spacingVerticalS, + flexWrap: "wrap", + }, + pageSizeWrapper: { + display: "flex", + alignItems: "center", + gap: tokens.spacingHorizontalXS, + }, + mono: { + fontFamily: "Consolas, 'Courier New', monospace", + fontSize: tokens.fontSizeBase200, + }, +}); + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** + * Format an ISO 8601 timestamp for compact display. + * + * @param iso - ISO 8601 string or `null`. + * @returns A locale time string, or `"—"` when `null`. + */ +function fmtTime(iso: string | null): string { + if (!iso) return "—"; + try { + return new Date(iso).toLocaleString(undefined, { + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + }); + } catch { + return iso; + } +} + +// --------------------------------------------------------------------------- +// Column definitions +// --------------------------------------------------------------------------- + +/** A row item augmented with an `onUnban` callback for the row action. */ +interface BanRow { + ban: ActiveBan; + onUnban: (ip: string) => void; +} + +const columns: TableColumnDefinition[] = [ + createTableColumn({ + columnId: "ip", + renderHeaderCell: () => "IP Address", + renderCell: ({ ban }) => ( + + {ban.ip} + + ), + }), + createTableColumn({ + columnId: "country", + renderHeaderCell: () => "Country", + renderCell: ({ ban }) => + ban.country ? ( + {ban.country} + ) : ( + + — + + ), + }), + createTableColumn({ + columnId: "banned_at", + renderHeaderCell: () => "Banned At", + renderCell: ({ ban }) => {fmtTime(ban.banned_at)}, + }), + createTableColumn({ + columnId: "expires_at", + renderHeaderCell: () => "Expires At", + renderCell: ({ ban }) => {fmtTime(ban.expires_at)}, + }), + createTableColumn({ + columnId: "actions", + renderHeaderCell: () => "", + renderCell: ({ ban, onUnban }) => ( + +