feat: Task 4 — paginated banned-IPs section on jail detail page

Backend:
- Add JailBannedIpsResponse Pydantic model (ban.py)
- Add get_jail_banned_ips() service: server-side pagination, optional
  IP substring search, geo enrichment on page slice only (jail_service.py)
- Add GET /api/jails/{name}/banned endpoint with page/page_size/search
  query params, 400/404/502 error handling (routers/jails.py)
- 23 new tests: 13 service tests + 10 router tests (all passing)

Frontend:
- Add JailBannedIpsResponse TS interface (types/jail.ts)
- Add jailBanned endpoint helper (api/endpoints.ts)
- Add fetchJailBannedIps() API function (api/jails.ts)
- Add BannedIpsSection component: Fluent UI DataGrid, debounced search
  (300 ms), prev/next pagination, page-size dropdown, per-row unban
  button, loading spinner, empty state, error MessageBar (BannedIpsSection.tsx)
- Mount BannedIpsSection in JailDetailPage between stats and patterns
- 12 new Vitest tests for BannedIpsSection (all passing)
This commit is contained in:
2026-03-14 16:28:43 +01:00
parent 0966f347c4
commit baf45c6c62
12 changed files with 1333 additions and 3 deletions

View File

@@ -266,7 +266,7 @@ Build a multi-layered safety net that:
## Task 4 — Jail Detail Page: Paginated "Currently Banned IPs" List ## 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) **References:** [Features.md § 5 — Jail Management](Features.md), [Architekture.md § 2](Architekture.md)
### Problem ### Problem

View File

@@ -306,3 +306,30 @@ class BansByJailResponse(BaseModel):
description="Jails ordered by ban count descending.", description="Jails ordered by ban count descending.",
) )
total: int = Field(..., ge=0, description="Total ban count in the selected window.") 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.")

View File

@@ -4,6 +4,7 @@ Provides CRUD and control operations for fail2ban jails:
* ``GET /api/jails`` — list all jails * ``GET /api/jails`` — list all jails
* ``GET /api/jails/{name}`` — full detail for one jail * ``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}/start`` — start a jail
* ``POST /api/jails/{name}/stop`` — stop a jail * ``POST /api/jails/{name}/stop`` — stop a jail
* ``POST /api/jails/{name}/idle`` — toggle idle mode * ``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 fastapi import APIRouter, Body, HTTPException, Path, Request, status
from app.dependencies import AuthDep from app.dependencies import AuthDep
from app.models.ban import JailBannedIpsResponse
from app.models.jail import ( from app.models.jail import (
IgnoreIpRequest, IgnoreIpRequest,
JailCommandResponse, JailCommandResponse,
@@ -540,3 +542,74 @@ async def toggle_ignore_self(
raise _conflict(str(exc)) from exc raise _conflict(str(exc)) from exc
except Fail2BanConnectionError as exc: except Fail2BanConnectionError as exc:
raise _bad_gateway(exc) from 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

View File

@@ -18,7 +18,7 @@ from typing import Any
import structlog 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.config import BantimeEscalation
from app.models.jail import ( from app.models.jail import (
Jail, Jail,
@@ -862,6 +862,120 @@ def _parse_ban_entry(entry: str, jail: str) -> ActiveBan | None:
return 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( async def _enrich_bans(
bans: list[ActiveBan], bans: list[ActiveBan],
geo_enricher: Any, geo_enricher: Any,

View File

@@ -788,3 +788,146 @@ class TestFail2BanConnectionErrors:
resp = await jails_client.post("/api/jails/sshd/reload") resp = await jails_client.post("/api/jails/sshd/reload")
assert resp.status_code == 502 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

View File

@@ -7,7 +7,7 @@ from unittest.mock import AsyncMock, patch
import pytest import pytest
from app.models.ban import ActiveBanListResponse from app.models.ban import ActiveBanListResponse, JailBannedIpsResponse
from app.models.jail import JailDetailResponse, JailListResponse from app.models.jail import JailDetailResponse, JailListResponse
from app.services import jail_service from app.services import jail_service
from app.services.jail_service import JailNotFoundError, JailOperationError from app.services.jail_service import JailNotFoundError, JailOperationError
@@ -700,3 +700,201 @@ class TestUnbanAllIps:
pytest.raises(Fail2BanConnectionError), pytest.raises(Fail2BanConnectionError),
): ):
await jail_service.unban_all_ips(_SOCKET) 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")

View File

@@ -38,6 +38,7 @@ export const ENDPOINTS = {
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
jails: "/jails", jails: "/jails",
jail: (name: string): string => `/jails/${encodeURIComponent(name)}`, jail: (name: string): string => `/jails/${encodeURIComponent(name)}`,
jailBanned: (name: string): string => `/jails/${encodeURIComponent(name)}/banned`,
jailStart: (name: string): string => `/jails/${encodeURIComponent(name)}/start`, jailStart: (name: string): string => `/jails/${encodeURIComponent(name)}/start`,
jailStop: (name: string): string => `/jails/${encodeURIComponent(name)}/stop`, jailStop: (name: string): string => `/jails/${encodeURIComponent(name)}/stop`,
jailIdle: (name: string): string => `/jails/${encodeURIComponent(name)}/idle`, jailIdle: (name: string): string => `/jails/${encodeURIComponent(name)}/idle`,

View File

@@ -10,6 +10,7 @@ import { ENDPOINTS } from "./endpoints";
import type { import type {
ActiveBanListResponse, ActiveBanListResponse,
IpLookupResponse, IpLookupResponse,
JailBannedIpsResponse,
JailCommandResponse, JailCommandResponse,
JailDetailResponse, JailDetailResponse,
JailListResponse, JailListResponse,
@@ -224,3 +225,37 @@ export async function unbanAllBans(): Promise<UnbanAllResponse> {
export async function lookupIp(ip: string): Promise<IpLookupResponse> { export async function lookupIp(ip: string): Promise<IpLookupResponse> {
return get<IpLookupResponse>(ENDPOINTS.geoLookup(ip)); return get<IpLookupResponse>(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<JailBannedIpsResponse> {
const params: Record<string, string> = {
page: String(page),
page_size: String(pageSize),
};
if (search !== undefined && search !== "") {
params.search = search;
}
const query = new URLSearchParams(params).toString();
return get<JailBannedIpsResponse>(`${ENDPOINTS.jailBanned(jailName)}?${query}`);
}

View File

@@ -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<BanRow>[] = [
createTableColumn<BanRow>({
columnId: "ip",
renderHeaderCell: () => "IP Address",
renderCell: ({ ban }) => (
<Text
style={{
fontFamily: "Consolas, 'Courier New', monospace",
fontSize: tokens.fontSizeBase200,
}}
>
{ban.ip}
</Text>
),
}),
createTableColumn<BanRow>({
columnId: "country",
renderHeaderCell: () => "Country",
renderCell: ({ ban }) =>
ban.country ? (
<Text size={200}>{ban.country}</Text>
) : (
<Text size={200} style={{ color: tokens.colorNeutralForeground4 }}>
</Text>
),
}),
createTableColumn<BanRow>({
columnId: "banned_at",
renderHeaderCell: () => "Banned At",
renderCell: ({ ban }) => <Text size={200}>{fmtTime(ban.banned_at)}</Text>,
}),
createTableColumn<BanRow>({
columnId: "expires_at",
renderHeaderCell: () => "Expires At",
renderCell: ({ ban }) => <Text size={200}>{fmtTime(ban.expires_at)}</Text>,
}),
createTableColumn<BanRow>({
columnId: "actions",
renderHeaderCell: () => "",
renderCell: ({ ban, onUnban }) => (
<Tooltip content={`Unban ${ban.ip}`} relationship="label">
<Button
size="small"
appearance="subtle"
icon={<DismissRegular />}
onClick={() => {
onUnban(ban.ip);
}}
aria-label={`Unban ${ban.ip}`}
/>
</Tooltip>
),
}),
];
// ---------------------------------------------------------------------------
// Props
// ---------------------------------------------------------------------------
/** Props for {@link BannedIpsSection}. */
export interface BannedIpsSectionProps {
/** The jail name whose banned IPs are displayed. */
jailName: string;
}
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
/**
* Paginated section showing currently banned IPs for a single jail.
*
* @param props - {@link BannedIpsSectionProps}
*/
export function BannedIpsSection({ jailName }: BannedIpsSectionProps): React.JSX.Element {
const styles = useStyles();
const [items, setItems] = useState<ActiveBan[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState<number>(25);
const [search, setSearch] = useState("");
const [debouncedSearch, setDebouncedSearch] = useState("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [opError, setOpError] = useState<string | null>(null);
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// Debounce the search input so we don't spam the backend on every keystroke.
useEffect(() => {
if (debounceRef.current !== null) {
clearTimeout(debounceRef.current);
}
debounceRef.current = setTimeout((): void => {
setDebouncedSearch(search);
setPage(1);
}, SEARCH_DEBOUNCE_MS);
return (): void => {
if (debounceRef.current !== null) clearTimeout(debounceRef.current);
};
}, [search]);
const load = useCallback(() => {
setLoading(true);
setError(null);
fetchJailBannedIps(jailName, page, pageSize, debouncedSearch || undefined)
.then((resp) => {
setItems(resp.items);
setTotal(resp.total);
})
.catch((err: unknown) => {
const msg =
err instanceof ApiError
? `${String(err.status)}: ${err.body}`
: err instanceof Error
? err.message
: String(err);
setError(msg);
})
.finally(() => {
setLoading(false);
});
}, [jailName, page, pageSize, debouncedSearch]);
useEffect(() => {
load();
}, [load]);
const handleUnban = (ip: string): void => {
setOpError(null);
unbanIp(ip, jailName)
.then(() => {
load();
})
.catch((err: unknown) => {
const msg =
err instanceof ApiError
? `${String(err.status)}: ${err.body}`
: err instanceof Error
? err.message
: String(err);
setOpError(msg);
});
};
const rows: BanRow[] = items.map((ban) => ({
ban,
onUnban: handleUnban,
}));
const totalPages = pageSize > 0 ? Math.ceil(total / pageSize) : 1;
return (
<div className={styles.root}>
{/* Section header */}
<div className={styles.header}>
<div className={styles.headerLeft}>
<Text as="h2" size={500} weight="semibold">
Currently Banned IPs
</Text>
<Badge appearance="tint">{String(total)}</Badge>
</div>
<Button
size="small"
appearance="subtle"
icon={<ArrowClockwiseRegular />}
onClick={load}
aria-label="Refresh banned IPs"
/>
</div>
{/* Toolbar */}
<div className={styles.toolbar}>
<div className={styles.searchField}>
<Field label="Search by IP">
<Input
aria-label="Search by IP"
contentBefore={<SearchRegular />}
placeholder="e.g. 192.168"
value={search}
onChange={(_, d) => {
setSearch(d.value);
}}
/>
</Field>
</div>
</div>
{/* Error bars */}
{error && (
<MessageBar intent="error">
<MessageBarBody>{error}</MessageBarBody>
</MessageBar>
)}
{opError && (
<MessageBar intent="error">
<MessageBarBody>{opError}</MessageBarBody>
</MessageBar>
)}
{/* Table */}
{loading ? (
<div className={styles.centred}>
<Spinner label="Loading banned IPs…" />
</div>
) : items.length === 0 ? (
<div className={styles.centred}>
<Text size={200} style={{ color: tokens.colorNeutralForeground3 }}>
No IPs currently banned in this jail.
</Text>
</div>
) : (
<div className={styles.tableWrapper}>
<DataGrid
items={rows}
columns={columns}
getRowId={(row: BanRow) => row.ban.ip}
focusMode="composite"
>
<DataGridHeader>
<DataGridRow>
{({ renderHeaderCell }) => (
<DataGridHeaderCell>{renderHeaderCell()}</DataGridHeaderCell>
)}
</DataGridRow>
</DataGridHeader>
<DataGridBody<BanRow>>
{({ item, rowId }) => (
<DataGridRow<BanRow> key={rowId}>
{({ renderCell }) => (
<DataGridCell>{renderCell(item)}</DataGridCell>
)}
</DataGridRow>
)}
</DataGridBody>
</DataGrid>
</div>
)}
{/* Pagination */}
{total > 0 && (
<div className={styles.pagination}>
<div className={styles.pageSizeWrapper}>
<Text size={200}>Rows per page:</Text>
<Dropdown
aria-label="Rows per page"
value={String(pageSize)}
selectedOptions={[String(pageSize)]}
onOptionSelect={(_, d) => {
const newSize = Number(d.optionValue);
if (!Number.isNaN(newSize)) {
setPageSize(newSize);
setPage(1);
}
}}
style={{ minWidth: "80px" }}
>
{PAGE_SIZE_OPTIONS.map((n) => (
<Option key={n} value={String(n)}>
{String(n)}
</Option>
))}
</Dropdown>
</div>
<Text size={200}>
{String((page - 1) * pageSize + 1)}
{String(Math.min(page * pageSize, total))} of {String(total)}
</Text>
<Button
size="small"
appearance="subtle"
icon={<ChevronLeftRegular />}
disabled={page <= 1}
onClick={() => {
setPage((p) => Math.max(1, p - 1));
}}
aria-label="Previous page"
/>
<Button
size="small"
appearance="subtle"
icon={<ChevronRightRegular />}
disabled={page >= totalPages}
onClick={() => {
setPage((p) => p + 1);
}}
aria-label="Next page"
/>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,251 @@
/**
* Tests for the `BannedIpsSection` component.
*
* Verifies:
* - Renders the section header and total count badge.
* - Shows a spinner while loading.
* - Renders a table with IP rows on success.
* - Shows an empty-state message when there are no banned IPs.
* - Displays an error message bar when the API call fails.
* - Search input re-fetches with the search parameter after debounce.
* - Unban button calls `unbanIp` and refreshes the list.
* - Pagination buttons are shown and change the page.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, waitFor, act, fireEvent } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { FluentProvider, webLightTheme } from "@fluentui/react-components";
import { BannedIpsSection } from "../BannedIpsSection";
import type { JailBannedIpsResponse } from "../../../types/jail";
// ---------------------------------------------------------------------------
// Module mocks
// ---------------------------------------------------------------------------
const { mockFetchJailBannedIps, mockUnbanIp } = vi.hoisted(() => ({
mockFetchJailBannedIps: vi.fn<
(
jailName: string,
page?: number,
pageSize?: number,
search?: string,
) => Promise<JailBannedIpsResponse>
>(),
mockUnbanIp: vi.fn<
(ip: string, jail?: string) => Promise<{ message: string; jail: string }>
>(),
}));
vi.mock("../../../api/jails", () => ({
fetchJailBannedIps: mockFetchJailBannedIps,
unbanIp: mockUnbanIp,
}));
// ---------------------------------------------------------------------------
// Fixtures
// ---------------------------------------------------------------------------
function makeBan(ip: string) {
return {
ip,
jail: "sshd",
banned_at: "2025-01-01T10:00:00+00:00",
expires_at: "2025-01-01T10:10:00+00:00",
ban_count: 1,
country: "US",
};
}
function makeResponse(
ips: string[] = ["1.2.3.4", "5.6.7.8"],
total = 2,
): JailBannedIpsResponse {
return {
items: ips.map(makeBan),
total,
page: 1,
page_size: 25,
};
}
const EMPTY_RESPONSE: JailBannedIpsResponse = {
items: [],
total: 0,
page: 1,
page_size: 25,
};
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function renderSection(jailName = "sshd") {
return render(
<FluentProvider theme={webLightTheme}>
<BannedIpsSection jailName={jailName} />
</FluentProvider>,
);
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe("BannedIpsSection", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.useRealTimers();
mockUnbanIp.mockResolvedValue({ message: "ok", jail: "sshd" });
});
it("renders section header with 'Currently Banned IPs' title", async () => {
mockFetchJailBannedIps.mockResolvedValue(makeResponse());
renderSection();
await waitFor(() => {
expect(screen.getByText("Currently Banned IPs")).toBeTruthy();
});
});
it("shows the total count badge", async () => {
mockFetchJailBannedIps.mockResolvedValue(makeResponse(["1.2.3.4", "5.6.7.8"], 2));
renderSection();
await waitFor(() => {
expect(screen.getByText("2")).toBeTruthy();
});
});
it("shows a spinner while loading", () => {
// Never resolves during this test so we see the spinner.
mockFetchJailBannedIps.mockReturnValue(new Promise(() => void 0));
renderSection();
expect(screen.getByText("Loading banned IPs…")).toBeTruthy();
});
it("renders IP rows when banned IPs exist", async () => {
mockFetchJailBannedIps.mockResolvedValue(makeResponse(["1.2.3.4", "5.6.7.8"]));
renderSection();
await waitFor(() => {
expect(screen.getByText("1.2.3.4")).toBeTruthy();
expect(screen.getByText("5.6.7.8")).toBeTruthy();
});
});
it("shows empty-state message when no IPs are banned", async () => {
mockFetchJailBannedIps.mockResolvedValue(EMPTY_RESPONSE);
renderSection();
await waitFor(() => {
expect(
screen.getByText("No IPs currently banned in this jail."),
).toBeTruthy();
});
});
it("shows an error message bar on API failure", async () => {
mockFetchJailBannedIps.mockRejectedValue(new Error("socket dead"));
renderSection();
await waitFor(() => {
expect(screen.getByText(/socket dead/i)).toBeTruthy();
});
});
it("calls fetchJailBannedIps with the jail name", async () => {
mockFetchJailBannedIps.mockResolvedValue(makeResponse());
renderSection("nginx");
await waitFor(() => {
expect(mockFetchJailBannedIps).toHaveBeenCalledWith(
"nginx",
expect.any(Number),
expect.any(Number),
undefined,
);
});
});
it("search input re-fetches after debounce with the search term", async () => {
vi.useFakeTimers();
mockFetchJailBannedIps.mockResolvedValue(makeResponse());
renderSection();
// Flush pending async work from the initial render (no timer advancement needed).
await act(async () => {});
mockFetchJailBannedIps.mockClear();
mockFetchJailBannedIps.mockResolvedValue(
makeResponse(["1.2.3.4"], 1),
);
// fireEvent is synchronous — avoids hanging with fake timers.
const input = screen.getByPlaceholderText("e.g. 192.168");
act(() => {
fireEvent.change(input, { target: { value: "1.2.3" } });
});
// Advance just past the 300ms debounce delay and flush promises.
await act(async () => {
await vi.advanceTimersByTimeAsync(350);
});
expect(mockFetchJailBannedIps).toHaveBeenLastCalledWith(
"sshd",
expect.any(Number),
expect.any(Number),
"1.2.3",
);
vi.useRealTimers();
});
it("calls unbanIp when the unban button is clicked", async () => {
mockFetchJailBannedIps.mockResolvedValue(makeResponse(["1.2.3.4"]));
renderSection();
await waitFor(() => {
expect(screen.getByText("1.2.3.4")).toBeTruthy();
});
const unbanBtn = screen.getByLabelText("Unban 1.2.3.4");
await userEvent.click(unbanBtn);
expect(mockUnbanIp).toHaveBeenCalledWith("1.2.3.4", "sshd");
});
it("refreshes list after successful unban", async () => {
mockFetchJailBannedIps
.mockResolvedValueOnce(makeResponse(["1.2.3.4"]))
.mockResolvedValue(EMPTY_RESPONSE);
mockUnbanIp.mockResolvedValue({ message: "ok", jail: "sshd" });
renderSection();
await waitFor(() => {
expect(screen.getByText("1.2.3.4")).toBeTruthy();
});
const unbanBtn = screen.getByLabelText("Unban 1.2.3.4");
await userEvent.click(unbanBtn);
await waitFor(() => {
expect(mockFetchJailBannedIps).toHaveBeenCalledTimes(2);
});
});
it("shows pagination controls when total > 0", async () => {
mockFetchJailBannedIps.mockResolvedValue(
makeResponse(["1.2.3.4", "5.6.7.8"], 50),
);
renderSection();
await waitFor(() => {
expect(screen.getByLabelText("Next page")).toBeTruthy();
expect(screen.getByLabelText("Previous page")).toBeTruthy();
});
});
it("previous page button is disabled on page 1", async () => {
mockFetchJailBannedIps.mockResolvedValue(
makeResponse(["1.2.3.4"], 50),
);
renderSection();
await waitFor(() => {
const prevBtn = screen.getByLabelText("Previous page");
expect(prevBtn).toHaveAttribute("disabled");
});
});
});

View File

@@ -41,6 +41,7 @@ import {
import { useJailDetail } from "../hooks/useJails"; import { useJailDetail } from "../hooks/useJails";
import type { Jail } from "../types/jail"; import type { Jail } from "../types/jail";
import { ApiError } from "../api/client"; import { ApiError } from "../api/client";
import { BannedIpsSection } from "../components/jail/BannedIpsSection";
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Styles // Styles
@@ -624,6 +625,7 @@ export function JailDetailPage(): React.JSX.Element {
</div> </div>
<JailInfoSection jail={jail} onRefresh={refresh} /> <JailInfoSection jail={jail} onRefresh={refresh} />
<BannedIpsSection jailName={name} />
<PatternsSection jail={jail} /> <PatternsSection jail={jail} />
<BantimeEscalationSection jail={jail} /> <BantimeEscalationSection jail={jail} />
<IgnoreListSection <IgnoreListSection

View File

@@ -198,6 +198,26 @@ export interface UnbanAllResponse {
count: number; count: number;
} }
// ---------------------------------------------------------------------------
// Jail-specific paginated bans
// ---------------------------------------------------------------------------
/**
* Paginated response from `GET /api/jails/{name}/banned`.
*
* Mirrors `JailBannedIpsResponse` from `backend/app/models/ban.py`.
*/
export interface JailBannedIpsResponse {
/** Active ban entries for the current page. */
items: ActiveBan[];
/** Total matching entries (after applying any search filter). */
total: number;
/** Current page number (1-based). */
page: number;
/** Number of items per page. */
page_size: number;
}
export interface GeoDetail { export interface GeoDetail {
/** ISO 3166-1 alpha-2 country code (e.g. `"DE"`), or `null`. */ /** ISO 3166-1 alpha-2 country code (e.g. `"DE"`), or `null`. */
country_code: string | null; country_code: string | null;