Stage 9: ban history — backend service, router, frontend history page

- history.py models: HistoryBanItem, HistoryListResponse, IpTimelineEvent, IpDetailResponse
- history_service.py: list_history() with dynamic WHERE clauses (range/jail/ip
  prefix/all-time), get_ip_detail() with timeline aggregation
- history.py router: GET /api/history + GET /api/history/{ip} (404 for unknown)
- Fixed latent bug in ban_service._parse_data_json: json.loads('null') -> None
  -> AttributeError; now checks isinstance(parsed, dict) before assigning obj
- 317 tests pass (27 new), ruff + mypy clean (46 files)
- types/history.ts, api/history.ts, hooks/useHistory.ts created
- HistoryPage.tsx: filter bar (time range/jail/IP), DataGrid table,
  high-ban-count row highlighting, per-IP IpDetailView with timeline,
  pagination
- Frontend tsc + ESLint clean (0 errors/warnings)
- Tasks.md Stage 9 marked done
This commit is contained in:
2026-03-01 15:09:22 +01:00
parent 54313fd3e0
commit b8f3a1c562
12 changed files with 2050 additions and 50 deletions

View File

@@ -33,7 +33,7 @@ from starlette.middleware.base import BaseHTTPMiddleware
from app.config import Settings, get_settings
from app.db import init_db
from app.routers import auth, bans, config, dashboard, geo, health, jails, server, setup
from app.routers import auth, bans, config, dashboard, geo, health, history, jails, server, setup
from app.tasks import health_check
# ---------------------------------------------------------------------------
@@ -278,5 +278,6 @@ def create_app(settings: Settings | None = None) -> FastAPI:
app.include_router(geo.router)
app.include_router(config.router)
app.include_router(server.router)
app.include_router(history.router)
return app

View File

@@ -1,45 +1,142 @@
"""Ban history Pydantic models."""
"""Ban history Pydantic models.
Request, response, and domain models used by the history router and service.
"""
from __future__ import annotations
from pydantic import BaseModel, ConfigDict, Field
from app.models.ban import TimeRange
class HistoryEntry(BaseModel):
"""A single historical ban record from the fail2ban database."""
__all__ = [
"HistoryBanItem",
"HistoryListResponse",
"IpDetailResponse",
"IpTimelineEvent",
"TimeRange",
]
class HistoryBanItem(BaseModel):
"""A single row in the history ban-list table.
Populated from the fail2ban database and optionally enriched with
geolocation data.
"""
model_config = ConfigDict(strict=True)
ip: str
jail: str
ip: str = Field(..., description="Banned IP address.")
jail: str = Field(..., description="Jail that issued the ban.")
banned_at: str = Field(..., description="ISO 8601 UTC timestamp of the ban.")
released_at: str | None = Field(default=None, description="ISO 8601 UTC timestamp when the ban expired.")
ban_count: int = Field(..., ge=1, description="Total number of times this IP was banned.")
country: str | None = None
matched_lines: list[str] = Field(default_factory=list)
class IpTimeline(BaseModel):
"""Per-IP ban history timeline."""
model_config = ConfigDict(strict=True)
ip: str
total_bans: int = Field(..., ge=0)
total_failures: int = Field(..., ge=0)
events: list[HistoryEntry] = Field(default_factory=list)
ban_count: int = Field(..., ge=1, description="How many times this IP was banned.")
failures: int = Field(
default=0,
ge=0,
description="Total failure count extracted from the ``data`` column.",
)
matches: list[str] = Field(
default_factory=list,
description="Matched log lines stored in the ``data`` column.",
)
country_code: str | None = Field(
default=None,
description="ISO 3166-1 alpha-2 country code, or ``null`` if unknown.",
)
country_name: str | None = Field(
default=None,
description="Human-readable country name, or ``null`` if unknown.",
)
asn: str | None = Field(
default=None,
description="Autonomous System Number string (e.g. ``'AS3320'``).",
)
org: str | None = Field(
default=None,
description="Organisation name associated with the IP.",
)
class HistoryListResponse(BaseModel):
"""Paginated response for ``GET /api/history``."""
"""Paginated history ban-list response."""
model_config = ConfigDict(strict=True)
entries: list[HistoryEntry] = Field(default_factory=list)
total: int = Field(..., ge=0)
items: list[HistoryBanItem] = Field(default_factory=list)
total: int = Field(..., ge=0, description="Total matching records.")
page: int = Field(..., ge=1)
page_size: int = Field(..., ge=1)
class IpHistoryResponse(BaseModel):
"""Response for ``GET /api/history/{ip}``."""
# ---------------------------------------------------------------------------
# Per-IP timeline
# ---------------------------------------------------------------------------
class IpTimelineEvent(BaseModel):
"""A single ban event in a per-IP timeline.
Represents one row from the fail2ban ``bans`` table for a specific IP.
"""
model_config = ConfigDict(strict=True)
timeline: IpTimeline
jail: str = Field(..., description="Jail that triggered this ban.")
banned_at: str = Field(..., description="ISO 8601 UTC timestamp of the ban.")
ban_count: int = Field(
...,
ge=1,
description="Running ban counter for this IP at the time of this event.",
)
failures: int = Field(
default=0,
ge=0,
description="Failure count at the time of the ban.",
)
matches: list[str] = Field(
default_factory=list,
description="Matched log lines that triggered the ban.",
)
class IpDetailResponse(BaseModel):
"""Full historical record for a single IP address.
Contains aggregated totals and a chronological timeline of all ban events
recorded in the fail2ban database for the given IP.
"""
model_config = ConfigDict(strict=True)
ip: str = Field(..., description="The IP address.")
total_bans: int = Field(..., ge=0, description="Total number of ban records.")
total_failures: int = Field(
...,
ge=0,
description="Sum of all failure counts across all ban events.",
)
last_ban_at: str | None = Field(
default=None,
description="ISO 8601 UTC timestamp of the most recent ban, or ``null``.",
)
country_code: str | None = Field(
default=None,
description="ISO 3166-1 alpha-2 country code, or ``null`` if unknown.",
)
country_name: str | None = Field(
default=None,
description="Human-readable country name, or ``null`` if unknown.",
)
asn: str | None = Field(
default=None,
description="Autonomous System Number string.",
)
org: str | None = Field(
default=None,
description="Organisation name associated with the IP.",
)
timeline: list[IpTimelineEvent] = Field(
default_factory=list,
description="All ban events for this IP, ordered newest-first.",
)

View File

@@ -0,0 +1,141 @@
"""History router.
Provides endpoints for forensic exploration of all historical ban records
stored in the fail2ban SQLite database.
Routes
------
``GET /api/history``
Paginated list of all historical bans, filterable by jail, IP prefix, and
time range.
``GET /api/history/{ip}``
Per-IP detail: complete ban timeline, aggregated totals, and geolocation.
"""
from __future__ import annotations
from typing import TYPE_CHECKING
if TYPE_CHECKING:
import aiohttp
from fastapi import APIRouter, HTTPException, Query, Request
from app.dependencies import AuthDep
from app.models.ban import TimeRange
from app.models.history import HistoryListResponse, IpDetailResponse
from app.services import geo_service, history_service
router: APIRouter = APIRouter(prefix="/api/history", tags=["History"])
_DEFAULT_PAGE_SIZE: int = 100
@router.get(
"",
response_model=HistoryListResponse,
summary="Return a paginated list of historical bans",
)
async def get_history(
request: Request,
_auth: AuthDep,
range: TimeRange | None = Query(
default=None,
description="Optional time-range filter. Omit for all-time.",
),
jail: str | None = Query(
default=None,
description="Restrict results to this jail name.",
),
ip: str | None = Query(
default=None,
description="Restrict results to IPs matching this prefix.",
),
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 (max 500).",
),
) -> HistoryListResponse:
"""Return a paginated list of historical bans with optional filters.
Queries the fail2ban database for all ban records, applying the requested
filters. Results are ordered newest-first and enriched with geolocation.
Args:
request: The incoming request (used to access ``app.state``).
_auth: Validated session — enforces authentication.
range: Optional time-range preset. ``None`` means all-time.
jail: Optional jail name filter (exact match).
ip: Optional IP prefix filter (prefix match).
page: 1-based page number.
page_size: Items per page (1500).
Returns:
:class:`~app.models.history.HistoryListResponse` with paginated items
and the total matching count.
"""
socket_path: str = request.app.state.settings.fail2ban_socket
http_session: aiohttp.ClientSession = request.app.state.http_session
async def _enricher(addr: str) -> geo_service.GeoInfo | None:
return await geo_service.lookup(addr, http_session)
return await history_service.list_history(
socket_path,
range_=range,
jail=jail,
ip_filter=ip,
page=page,
page_size=page_size,
geo_enricher=_enricher,
)
@router.get(
"/{ip}",
response_model=IpDetailResponse,
summary="Return the full ban history for a single IP address",
)
async def get_ip_history(
request: Request,
_auth: AuthDep,
ip: str,
) -> IpDetailResponse:
"""Return the complete historical record for a single IP address.
Fetches all ban events for the given IP from the fail2ban database and
aggregates them into a timeline. Returns ``404`` if the IP has no
recorded history.
Args:
request: The incoming request.
_auth: Validated session dependency.
ip: The IP address to look up.
Returns:
:class:`~app.models.history.IpDetailResponse` with aggregated totals
and a full ban timeline.
Raises:
HTTPException: 404 if the IP has no history in the database.
"""
socket_path: str = request.app.state.settings.fail2ban_socket
http_session: aiohttp.ClientSession = request.app.state.http_session
async def _enricher(addr: str) -> geo_service.GeoInfo | None:
return await geo_service.lookup(addr, http_session)
detail: IpDetailResponse | None = await history_service.get_ip_detail(
socket_path,
ip,
geo_enricher=_enricher,
)
if detail is None:
raise HTTPException(status_code=404, detail=f"No history found for IP {ip!r}.")
return detail

View File

@@ -124,7 +124,10 @@ def _parse_data_json(raw: Any) -> tuple[list[str], int]:
obj: dict[str, Any] = {}
if isinstance(raw, str):
try:
obj = json.loads(raw)
parsed: Any = json.loads(raw)
if isinstance(parsed, dict):
obj = parsed
# json.loads("null") → None, or other non-dict — treat as empty
except json.JSONDecodeError:
return [], 0
elif isinstance(raw, dict):

View File

@@ -0,0 +1,269 @@
"""History service.
Queries the fail2ban SQLite database for all historical ban records.
Supports filtering by jail, IP, and time range. For per-IP forensics the
service provides a full ban timeline with matched log lines and failure counts.
All database I/O uses aiosqlite in **read-only** mode so BanGUI never
modifies or locks the fail2ban database.
"""
from __future__ import annotations
from datetime import UTC, datetime
from typing import Any
import aiosqlite
import structlog
from app.models.ban import TIME_RANGE_SECONDS, TimeRange
from app.models.history import (
HistoryBanItem,
HistoryListResponse,
IpDetailResponse,
IpTimelineEvent,
)
from app.services.ban_service import _get_fail2ban_db_path, _parse_data_json, _ts_to_iso
log: structlog.stdlib.BoundLogger = structlog.get_logger()
# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------
_DEFAULT_PAGE_SIZE: int = 100
_MAX_PAGE_SIZE: int = 500
def _since_unix(range_: TimeRange) -> int:
"""Return the Unix timestamp for the start of the given time window.
Args:
range_: One of the supported time-range presets.
Returns:
Unix timestamp (seconds since epoch) equal to *now range_*.
"""
seconds: int = TIME_RANGE_SECONDS[range_]
return int(datetime.now(tz=UTC).timestamp()) - seconds
# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------
async def list_history(
socket_path: str,
*,
range_: TimeRange | None = None,
jail: str | None = None,
ip_filter: str | None = None,
page: int = 1,
page_size: int = _DEFAULT_PAGE_SIZE,
geo_enricher: Any | None = None,
) -> HistoryListResponse:
"""Return a paginated list of historical ban records with optional filters.
Queries the fail2ban ``bans`` table applying the requested filters and
returns a paginated list ordered newest-first. When *geo_enricher* is
supplied, each record is enriched with country and ASN data.
Args:
socket_path: Path to the fail2ban Unix domain socket.
range_: Time-range preset. ``None`` means all-time (no time filter).
jail: If given, restrict results to bans from this jail.
ip_filter: If given, restrict results to bans for this exact IP
(or a prefix — the query uses ``LIKE ip_filter%``).
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.history.HistoryListResponse` with paginated items
and the total matching count.
"""
effective_page_size: int = min(page_size, _MAX_PAGE_SIZE)
offset: int = (page - 1) * effective_page_size
# Build WHERE clauses dynamically.
wheres: list[str] = []
params: list[Any] = []
if range_ is not None:
since: int = _since_unix(range_)
wheres.append("timeofban >= ?")
params.append(since)
if jail is not None:
wheres.append("jail = ?")
params.append(jail)
if ip_filter is not None:
wheres.append("ip LIKE ?")
params.append(f"{ip_filter}%")
where_sql: str = ("WHERE " + " AND ".join(wheres)) if wheres else ""
db_path: str = await _get_fail2ban_db_path(socket_path)
log.info(
"history_service_list",
db_path=db_path,
range=range_,
jail=jail,
ip_filter=ip_filter,
page=page,
)
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(
f"SELECT COUNT(*) FROM bans {where_sql}", # noqa: S608
params,
) as cur:
count_row = await cur.fetchone()
total: int = int(count_row[0]) if count_row else 0
async with f2b_db.execute(
f"SELECT jail, ip, timeofban, bancount, data " # noqa: S608
f"FROM bans {where_sql} "
"ORDER BY timeofban DESC "
"LIMIT ? OFFSET ?",
[*params, effective_page_size, offset],
) as cur:
rows = await cur.fetchall()
items: list[HistoryBanItem] = []
for row in rows:
jail_name: str = str(row["jail"])
ip: str = str(row["ip"])
banned_at: str = _ts_to_iso(int(row["timeofban"]))
ban_count: int = int(row["bancount"])
matches, failures = _parse_data_json(row["data"])
country_code: str | None = None
country_name: str | None = None
asn: str | None = None
org: str | None = None
if geo_enricher is not None:
try:
geo = await geo_enricher(ip)
if geo is not None:
country_code = geo.country_code
country_name = geo.country_name
asn = geo.asn
org = geo.org
except Exception: # noqa: BLE001
log.warning("history_service_geo_lookup_failed", ip=ip)
items.append(
HistoryBanItem(
ip=ip,
jail=jail_name,
banned_at=banned_at,
ban_count=ban_count,
failures=failures,
matches=matches,
country_code=country_code,
country_name=country_name,
asn=asn,
org=org,
)
)
return HistoryListResponse(
items=items,
total=total,
page=page,
page_size=effective_page_size,
)
async def get_ip_detail(
socket_path: str,
ip: str,
*,
geo_enricher: Any | None = None,
) -> IpDetailResponse | None:
"""Return the full historical record for a single IP address.
Fetches all ban events for *ip* from the fail2ban database, ordered
newest-first. Aggregates total bans, total failures, and the timestamp of
the most recent ban. Optionally enriches with geolocation data.
Args:
socket_path: Path to the fail2ban Unix domain socket.
ip: The IP address to look up.
geo_enricher: Optional async callable ``(ip: str) -> GeoInfo | None``.
Returns:
:class:`~app.models.history.IpDetailResponse` if any records exist
for *ip*, or ``None`` if the IP has no history in the database.
"""
db_path: str = await _get_fail2ban_db_path(socket_path)
log.info("history_service_ip_detail", db_path=db_path, ip=ip)
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, bancount, data "
"FROM bans "
"WHERE ip = ? "
"ORDER BY timeofban DESC",
(ip,),
) as cur:
rows = await cur.fetchall()
if not rows:
return None
timeline: list[IpTimelineEvent] = []
total_failures: int = 0
for row in rows:
jail_name: str = str(row["jail"])
banned_at: str = _ts_to_iso(int(row["timeofban"]))
ban_count: int = int(row["bancount"])
matches, failures = _parse_data_json(row["data"])
total_failures += failures
timeline.append(
IpTimelineEvent(
jail=jail_name,
banned_at=banned_at,
ban_count=ban_count,
failures=failures,
matches=matches,
)
)
last_ban_at: str | None = timeline[0].banned_at if timeline else None
country_code: str | None = None
country_name: str | None = None
asn: str | None = None
org: str | None = None
if geo_enricher is not None:
try:
geo = await geo_enricher(ip)
if geo is not None:
country_code = geo.country_code
country_name = geo.country_name
asn = geo.asn
org = geo.org
except Exception: # noqa: BLE001
log.warning("history_service_geo_lookup_failed_detail", ip=ip)
return IpDetailResponse(
ip=ip,
total_bans=len(timeline),
total_failures=total_failures,
last_ban_at=last_ban_at,
country_code=country_code,
country_name=country_name,
asn=asn,
org=org,
timeline=timeline,
)