- All backend routers moved to /api/v1/ prefix
- Frontend BASE_URL updated to /api/v1
- Setup redirect middleware updated to redirect to /api/v1/setup
- Health router path fixed: prefix=/api/v1/health, @router.get('')
- conftest.py: set server_status=online for test fixture
- Created Docs/API_VERSIONING.md with deprecation policy
- Updated Docs/Backend-Development.md with versioning section
- Updated Instructions.md curl examples
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
199 lines
6.3 KiB
Python
199 lines
6.3 KiB
Python
"""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 Literal
|
||
|
||
from fastapi import APIRouter, Query, Request
|
||
|
||
from app.dependencies import (
|
||
AuthDep,
|
||
Fail2BanMetadataServiceDep,
|
||
Fail2BanSocketDep,
|
||
HistoryServiceContextDep,
|
||
HttpSessionDep,
|
||
)
|
||
from app.exceptions import HistoryNotFoundError
|
||
from app.mappers import history_mappers
|
||
from app.models._common import TimeRange
|
||
from app.models.ban import BanOrigin
|
||
from app.models.history import HistoryListResponse, IpDetailResponse
|
||
from app.services import history_service
|
||
from app.utils.constants import DEFAULT_PAGE_SIZE
|
||
|
||
router: APIRouter = APIRouter(prefix="/api/v1/history", tags=["History"])
|
||
|
||
|
||
@router.get(
|
||
"",
|
||
response_model=HistoryListResponse,
|
||
summary="Return a paginated list of historical bans",
|
||
)
|
||
async def get_history(
|
||
request: Request,
|
||
_auth: AuthDep,
|
||
history_ctx: HistoryServiceContextDep,
|
||
socket_path: Fail2BanSocketDep,
|
||
http_session: HttpSessionDep,
|
||
fail2ban_metadata_service: Fail2BanMetadataServiceDep,
|
||
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.",
|
||
),
|
||
origin: BanOrigin | None = Query(
|
||
default=None,
|
||
description="Filter by ban origin: 'blocklist' or 'selfblock'. Omit for all.",
|
||
),
|
||
source: Literal["fail2ban", "archive"] = Query(
|
||
default="fail2ban",
|
||
description="Data source: 'fail2ban' or 'archive'.",
|
||
),
|
||
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.
|
||
history_ctx: History service context containing db and repositories.
|
||
socket_path: Path to fail2ban Unix domain socket.
|
||
http_session: Shared HTTP session for geolocation.
|
||
fail2ban_metadata_service: Fail2Ban metadata service.
|
||
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 (1–500).
|
||
|
||
Returns:
|
||
:class:`~app.models.history.HistoryListResponse` with paginated items
|
||
and the total matching count.
|
||
"""
|
||
|
||
domain_result = await history_service.list_history(
|
||
socket_path,
|
||
range_=range,
|
||
jail=jail,
|
||
ip_filter=ip,
|
||
origin=origin,
|
||
source=source,
|
||
page=page,
|
||
page_size=page_size,
|
||
http_session=http_session,
|
||
db=history_ctx.db,
|
||
fail2ban_metadata_service=fail2ban_metadata_service,
|
||
)
|
||
return history_mappers.map_domain_history_list_to_response(domain_result)
|
||
|
||
|
||
@router.get(
|
||
"/archive",
|
||
response_model=HistoryListResponse,
|
||
summary="Return a paginated list of archived historical bans",
|
||
)
|
||
async def get_history_archive(
|
||
request: Request,
|
||
_auth: AuthDep,
|
||
history_ctx: HistoryServiceContextDep,
|
||
socket_path: Fail2BanSocketDep,
|
||
http_session: HttpSessionDep,
|
||
fail2ban_metadata_service: Fail2BanMetadataServiceDep,
|
||
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:
|
||
|
||
domain_result = await history_service.list_history(
|
||
socket_path,
|
||
range_=range,
|
||
jail=jail,
|
||
ip_filter=ip,
|
||
source="archive",
|
||
page=page,
|
||
page_size=page_size,
|
||
http_session=http_session,
|
||
db=history_ctx.db,
|
||
fail2ban_metadata_service=fail2ban_metadata_service,
|
||
)
|
||
return history_mappers.map_domain_history_list_to_response(domain_result)
|
||
|
||
|
||
@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,
|
||
socket_path: Fail2BanSocketDep,
|
||
http_session: HttpSessionDep,
|
||
fail2ban_metadata_service: Fail2BanMetadataServiceDep,
|
||
) -> 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.
|
||
"""
|
||
|
||
domain_result = await history_service.get_ip_detail(
|
||
socket_path,
|
||
ip,
|
||
http_session=http_session,
|
||
fail2ban_metadata_service=fail2ban_metadata_service,
|
||
)
|
||
|
||
if domain_result is None:
|
||
raise HistoryNotFoundError(ip)
|
||
|
||
return history_mappers.map_domain_ip_detail_to_response(domain_result)
|