Files
BanGUI/backend/app/routers/history.py
Lukas b8f3a1c562 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
2026-03-01 15:09:22 +01:00

142 lines
4.2 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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