Add ban management features and update documentation

- Implement ban model, service, and router endpoints in backend
- Add ban table component and dashboard integration in frontend
- Update ban-related types and API endpoints
- Add comprehensive tests for ban service and dashboard router
- Update documentation (Features, Tasks, Architecture, Web-Design)
- Clean up old fail2ban configuration files
- Update Makefile with new commands
This commit is contained in:
2026-03-06 20:33:42 +01:00
parent 06738dbfa5
commit cbad4ea706
20 changed files with 58 additions and 760 deletions

View File

@@ -110,7 +110,7 @@ class ActiveBanListResponse(BaseModel):
# ---------------------------------------------------------------------------
# Dashboard ban-list / access-list view models
# Dashboard ban-list view models
# ---------------------------------------------------------------------------
@@ -159,40 +159,6 @@ class DashboardBanListResponse(BaseModel):
page_size: int = Field(..., ge=1)
class AccessListItem(BaseModel):
"""A single row in the dashboard access-list table.
Each row represents one matched log line (failure) that contributed to a
ban — essentially the individual access events that led to bans within the
selected time window.
"""
model_config = ConfigDict(strict=True)
ip: str = Field(..., description="IP address of the access event.")
jail: str = Field(..., description="Jail that recorded the access.")
timestamp: str = Field(
...,
description="ISO 8601 UTC timestamp of the ban that captured this access.",
)
line: str = Field(..., description="Raw matched log line.")
country_code: str | None = Field(default=None)
country_name: str | None = Field(default=None)
asn: str | None = Field(default=None)
org: str | None = Field(default=None)
class AccessListResponse(BaseModel):
"""Paginated dashboard access-list response."""
model_config = ConfigDict(strict=True)
items: list[AccessListItem] = Field(default_factory=list)
total: int = Field(..., ge=0)
page: int = Field(..., ge=1)
page_size: int = Field(..., ge=1)
class BansByCountryResponse(BaseModel):
"""Response for the bans-by-country aggregation endpoint.

View File

@@ -4,8 +4,7 @@ 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`` and ``GET /api/dashboard/accesses``
for the dashboard ban-list and access-list tables.
Also provides ``GET /api/dashboard/bans`` for the dashboard ban-list table.
"""
from __future__ import annotations
@@ -19,7 +18,6 @@ from fastapi import APIRouter, Query, Request
from app.dependencies import AuthDep
from app.models.ban import (
AccessListResponse,
BansByCountryResponse,
DashboardBanListResponse,
TimeRange,
@@ -113,51 +111,6 @@ async def get_dashboard_bans(
)
@router.get(
"/accesses",
response_model=AccessListResponse,
summary="Return a paginated list of individual access events",
)
async def get_dashboard_accesses(
request: Request,
_auth: AuthDep,
range: TimeRange = Query(default=_DEFAULT_RANGE, description="Time-range preset."),
page: int = Query(default=1, ge=1, description="1-based page number."),
page_size: int = Query(default=_DEFAULT_PAGE_SIZE, ge=1, le=500, description="Items per page."),
) -> AccessListResponse:
"""Return a paginated list of individual access events (matched log lines).
Expands the ``data.matches`` JSON stored inside each ban record so that
every matched log line is returned as a separate row. Useful for
the "Access List" tab which shows all recorded access attempts — not
just the aggregate bans.
Args:
request: The incoming request.
_auth: Validated session dependency.
range: Time-range preset.
page: 1-based page number.
page_size: Maximum items per page (1500).
Returns:
:class:`~app.models.ban.AccessListResponse` with individual access
items expanded from ``data.matches``.
"""
socket_path: str = request.app.state.settings.fail2ban_socket
http_session: aiohttp.ClientSession = request.app.state.http_session
async def _enricher(ip: str) -> geo_service.GeoInfo | None:
return await geo_service.lookup(ip, http_session)
return await ban_service.list_accesses(
socket_path,
range,
page=page,
page_size=page_size,
geo_enricher=_enricher,
)
@router.get(
"/bans/by-country",
response_model=BansByCountryResponse,

View File

@@ -19,8 +19,6 @@ import structlog
from app.models.ban import (
TIME_RANGE_SECONDS,
AccessListItem,
AccessListResponse,
BansByCountryResponse,
DashboardBanItem,
DashboardBanListResponse,
@@ -245,90 +243,6 @@ async def list_bans(
)
async def list_accesses(
socket_path: str,
range_: TimeRange,
*,
page: int = 1,
page_size: int = _DEFAULT_PAGE_SIZE,
geo_enricher: Any | None = None,
) -> AccessListResponse:
"""Return a paginated list of individual access events (matched log lines).
Each row in the fail2ban ``bans`` table can contain multiple matched log
lines in its ``data.matches`` JSON field. This function expands those
into individual :class:`~app.models.ban.AccessListItem` objects so callers
see each distinct access attempt.
Args:
socket_path: Path to the fail2ban Unix domain socket.
range_: Time-range preset.
page: 1-based page number (default: ``1``).
page_size: Maximum items per page, capped at ``_MAX_PAGE_SIZE``.
geo_enricher: Optional async callable ``(ip: str) -> GeoInfo | None``.
Returns:
:class:`~app.models.ban.AccessListResponse` containing the paginated
expanded access items and total count.
"""
since: int = _since_unix(range_)
effective_page_size: int = min(page_size, _MAX_PAGE_SIZE)
db_path: str = await _get_fail2ban_db_path(socket_path)
log.info("ban_service_list_accesses", db_path=db_path, since=since, range=range_)
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 jail, ip, timeofban, data "
"FROM bans "
"WHERE timeofban >= ? "
"ORDER BY timeofban DESC",
(since,),
) as cur:
rows = await cur.fetchall()
# Expand each ban record into its individual matched log lines.
all_items: list[AccessListItem] = []
for row in rows:
jail = str(row["jail"])
ip = str(row["ip"])
timestamp = _ts_to_iso(int(row["timeofban"]))
matches, _ = _parse_data_json(row["data"])
geo = None
if geo_enricher is not None:
try:
geo = await geo_enricher(ip)
except Exception: # noqa: BLE001
log.warning("ban_service_geo_lookup_failed", ip=ip)
for line in matches:
all_items.append(
AccessListItem(
ip=ip,
jail=jail,
timestamp=timestamp,
line=line,
country_code=geo.country_code if geo else None,
country_name=geo.country_name if geo else None,
asn=geo.asn if geo else None,
org=geo.org if geo else None,
)
)
total: int = len(all_items)
offset: int = (page - 1) * effective_page_size
page_items: list[AccessListItem] = all_items[offset : offset + effective_page_size]
return AccessListResponse(
items=page_items,
total=total,
page=page,
page_size=effective_page_size,
)
# ---------------------------------------------------------------------------
# bans_by_country
# ---------------------------------------------------------------------------