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:
@@ -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.
|
||||
|
||||
|
||||
@@ -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 (1–500).
|
||||
|
||||
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,
|
||||
|
||||
@@ -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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Tests for the dashboard router (GET /api/dashboard/status, GET /api/dashboard/bans, GET /api/dashboard/accesses)."""
|
||||
"""Tests for the dashboard router (GET /api/dashboard/status, GET /api/dashboard/bans)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -13,8 +13,6 @@ from app.config import Settings
|
||||
from app.db import init_db
|
||||
from app.main import create_app
|
||||
from app.models.ban import (
|
||||
AccessListItem,
|
||||
AccessListResponse,
|
||||
DashboardBanItem,
|
||||
DashboardBanListResponse,
|
||||
)
|
||||
@@ -228,24 +226,6 @@ def _make_ban_list_response(n: int = 2) -> DashboardBanListResponse:
|
||||
return DashboardBanListResponse(items=items, total=n, page=1, page_size=100)
|
||||
|
||||
|
||||
def _make_access_list_response(n: int = 2) -> AccessListResponse:
|
||||
"""Build a mock AccessListResponse with *n* items."""
|
||||
items = [
|
||||
AccessListItem(
|
||||
ip=f"5.6.7.{i}",
|
||||
jail="nginx",
|
||||
timestamp="2026-03-01T10:00:00+00:00",
|
||||
line=f"GET /admin HTTP/1.1 attempt {i}",
|
||||
country_code="US",
|
||||
country_name="United States",
|
||||
asn="AS15169",
|
||||
org="Google LLC",
|
||||
)
|
||||
for i in range(n)
|
||||
]
|
||||
return AccessListResponse(items=items, total=n, page=1, page_size=100)
|
||||
|
||||
|
||||
class TestDashboardBans:
|
||||
"""GET /api/dashboard/bans."""
|
||||
|
||||
@@ -334,62 +314,6 @@ class TestDashboardBans:
|
||||
assert "ban_count" in item
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Dashboard accesses endpoint
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestDashboardAccesses:
|
||||
"""GET /api/dashboard/accesses."""
|
||||
|
||||
async def test_returns_200_when_authenticated(
|
||||
self, dashboard_client: AsyncClient
|
||||
) -> None:
|
||||
"""Authenticated request returns HTTP 200."""
|
||||
with patch(
|
||||
"app.routers.dashboard.ban_service.list_accesses",
|
||||
new=AsyncMock(return_value=_make_access_list_response()),
|
||||
):
|
||||
response = await dashboard_client.get("/api/dashboard/accesses")
|
||||
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/accesses")
|
||||
assert response.status_code == 401
|
||||
|
||||
async def test_response_contains_access_items(
|
||||
self, dashboard_client: AsyncClient
|
||||
) -> None:
|
||||
"""Response body contains ``items`` with ``line`` fields."""
|
||||
with patch(
|
||||
"app.routers.dashboard.ban_service.list_accesses",
|
||||
new=AsyncMock(return_value=_make_access_list_response(2)),
|
||||
):
|
||||
response = await dashboard_client.get("/api/dashboard/accesses")
|
||||
|
||||
body = response.json()
|
||||
assert body["total"] == 2
|
||||
assert len(body["items"]) == 2
|
||||
assert "line" in body["items"][0]
|
||||
|
||||
async def test_default_range_is_24h(
|
||||
self, dashboard_client: AsyncClient
|
||||
) -> None:
|
||||
"""If no ``range`` param is provided the default ``24h`` preset is used."""
|
||||
mock_list = AsyncMock(return_value=_make_access_list_response())
|
||||
with patch(
|
||||
"app.routers.dashboard.ban_service.list_accesses", new=mock_list
|
||||
):
|
||||
await dashboard_client.get("/api/dashboard/accesses")
|
||||
|
||||
called_range = mock_list.call_args[0][1]
|
||||
assert called_range == "24h"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Bans by country endpoint
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Tests for ban_service.list_bans() and ban_service.list_accesses()."""
|
||||
"""Tests for ban_service.list_bans()."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -299,61 +299,3 @@ class TestListBansPagination:
|
||||
result = await ban_service.list_bans("/fake/sock", "7d", page_size=1)
|
||||
|
||||
assert result.total == 3 # All three bans are within 7d.
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# list_accesses
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestListAccesses:
|
||||
"""Verify ban_service.list_accesses()."""
|
||||
|
||||
async def test_expands_matches_into_rows(self, f2b_db_path: str) -> None:
|
||||
"""Each element in ``data.matches`` becomes a separate row."""
|
||||
with patch(
|
||||
"app.services.ban_service._get_fail2ban_db_path",
|
||||
new=AsyncMock(return_value=f2b_db_path),
|
||||
):
|
||||
result = await ban_service.list_accesses("/fake/sock", "24h")
|
||||
|
||||
# Two bans in last 24h: sshd (1 match) + nginx (1 match) = 2 rows.
|
||||
assert result.total == 2
|
||||
assert len(result.items) == 2
|
||||
|
||||
async def test_access_item_has_line_field(self, f2b_db_path: str) -> None:
|
||||
"""Each access item contains the raw matched log line."""
|
||||
with patch(
|
||||
"app.services.ban_service._get_fail2ban_db_path",
|
||||
new=AsyncMock(return_value=f2b_db_path),
|
||||
):
|
||||
result = await ban_service.list_accesses("/fake/sock", "24h")
|
||||
|
||||
for item in result.items:
|
||||
assert item.line
|
||||
|
||||
async def test_ban_with_no_matches_produces_no_access_rows(
|
||||
self, f2b_db_path: str
|
||||
) -> None:
|
||||
"""Bans with empty matches list do not contribute rows."""
|
||||
with patch(
|
||||
"app.services.ban_service._get_fail2ban_db_path",
|
||||
new=AsyncMock(return_value=f2b_db_path),
|
||||
):
|
||||
result = await ban_service.list_accesses("/fake/sock", "7d")
|
||||
|
||||
# Third ban (9.10.11.12) has no matches, so only 2 rows total.
|
||||
assert result.total == 2
|
||||
|
||||
async def test_empty_db_returns_zero_accesses(
|
||||
self, empty_f2b_db_path: str
|
||||
) -> None:
|
||||
"""Returns empty result when no bans exist."""
|
||||
with patch(
|
||||
"app.services.ban_service._get_fail2ban_db_path",
|
||||
new=AsyncMock(return_value=empty_f2b_db_path),
|
||||
):
|
||||
result = await ban_service.list_accesses("/fake/sock", "24h")
|
||||
|
||||
assert result.total == 0
|
||||
assert result.items == []
|
||||
|
||||
Reference in New Issue
Block a user