This commit enforces the repository boundary by eliminating direct database connection dependencies (DbDep) from all routers. Routers now depend on service context dependencies that combine the database connection with the related repositories. Changes: - Add 5 service context dependencies in dependencies.py: * SessionServiceContext: db + session_repo * BlocklistServiceContext: db + blocklist_repo + import_log_repo + settings_repo * SettingsServiceContext: db + settings_repo * BanServiceContext: db + fail2ban_db_repo * HistoryServiceContext: db + fail2ban_db_repo + history_archive_repo - Refactor all 9 routers (auth, bans, blocklist, config_misc, dashboard, geo, history, jails, setup) to use service contexts instead of DbDep. - Update Backend-Development.md with clear examples of the new pattern and documentation of available service contexts. Rationale: - Enforces the repository boundary through the dependency system - Makes database operations explicit and auditable - Improves testability by allowing service contexts to be mocked - Prevents accidental direct database access from routers The deprecated DbDep remains available for backward compatibility with services that have not yet been refactored, but routers can no longer import it. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
194 lines
6.0 KiB
Python
194 lines
6.0 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, HTTPException, Query, Request
|
||
|
||
from app.dependencies import (
|
||
AuthDep,
|
||
Fail2BanMetadataServiceDep,
|
||
Fail2BanSocketDep,
|
||
HistoryServiceContextDep,
|
||
HttpSessionDep,
|
||
)
|
||
from app.models.ban import BanOrigin, TimeRange
|
||
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/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.
|
||
"""
|
||
|
||
return 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,
|
||
)
|
||
|
||
|
||
@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:
|
||
|
||
return 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,
|
||
)
|
||
|
||
|
||
@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.
|
||
"""
|
||
|
||
detail: IpDetailResponse | None = await history_service.get_ip_detail(
|
||
socket_path,
|
||
ip,
|
||
http_session=http_session,
|
||
fail2ban_metadata_service=fail2ban_metadata_service,
|
||
)
|
||
|
||
if detail is None:
|
||
raise HTTPException(status_code=404, detail=f"No history found for IP {ip!r}.")
|
||
|
||
return detail
|