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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user