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>
293 lines
9.7 KiB
Python
293 lines
9.7 KiB
Python
"""Dashboard router.
|
||
|
||
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`` for the dashboard ban-list table,
|
||
``GET /api/dashboard/bans/by-country`` for country aggregation,
|
||
``GET /api/dashboard/bans/trend`` for time-bucketed ban counts, and
|
||
``GET /api/dashboard/bans/by-jail`` for per-jail ban counts.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
from typing import Literal
|
||
|
||
from fastapi import APIRouter, Query
|
||
|
||
from app import __version__
|
||
from app.dependencies import (
|
||
AuthDep,
|
||
BanServiceContextDep,
|
||
Fail2BanSocketDep,
|
||
GeoCacheDep,
|
||
HttpSessionDep,
|
||
ServerStatusDep,
|
||
)
|
||
from app.models.ban import (
|
||
BanOrigin,
|
||
BansByCountryResponse,
|
||
BansByJailResponse,
|
||
BanTrendResponse,
|
||
DashboardBanListResponse,
|
||
TimeRange,
|
||
)
|
||
from app.models.server import ServerStatus, ServerStatusResponse
|
||
from app.services import ban_service
|
||
from app.utils.constants import DEFAULT_PAGE_SIZE
|
||
|
||
router: APIRouter = APIRouter(prefix="/api/dashboard", tags=["Dashboard"])
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Default pagination constants
|
||
# ---------------------------------------------------------------------------
|
||
|
||
_DEFAULT_RANGE: TimeRange = "24h"
|
||
|
||
|
||
@router.get(
|
||
"/status",
|
||
response_model=ServerStatusResponse,
|
||
summary="Return the cached fail2ban server status",
|
||
)
|
||
async def get_server_status(
|
||
server_status: ServerStatusDep,
|
||
_auth: AuthDep,
|
||
) -> ServerStatusResponse:
|
||
"""Return the most recent fail2ban health snapshot.
|
||
|
||
The snapshot is populated by a background task that runs every 30 seconds.
|
||
If the task has not yet executed a placeholder ``online=False`` status is
|
||
returned so the response is always well-formed.
|
||
|
||
Args:
|
||
server_status: Cached fail2ban server health snapshot (injected).
|
||
_auth: Validated session — enforces authentication on this endpoint.
|
||
|
||
Returns:
|
||
:class:`~app.models.server.ServerStatusResponse` containing the
|
||
current health snapshot.
|
||
"""
|
||
cached: ServerStatus = server_status
|
||
cached.version = __version__
|
||
return ServerStatusResponse(status=cached)
|
||
|
||
|
||
@router.get(
|
||
"/bans",
|
||
response_model=DashboardBanListResponse,
|
||
summary="Return a paginated list of recent bans",
|
||
)
|
||
async def get_dashboard_bans(
|
||
_auth: AuthDep,
|
||
ban_ctx: BanServiceContextDep,
|
||
socket_path: Fail2BanSocketDep,
|
||
http_session: HttpSessionDep,
|
||
geo_cache: GeoCacheDep,
|
||
range: TimeRange = Query(default=_DEFAULT_RANGE, description="Time-range preset."),
|
||
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."),
|
||
origin: BanOrigin | None = Query(
|
||
default=None,
|
||
description="Filter by ban origin: 'blocklist' or 'selfblock'. Omit for all.",
|
||
),
|
||
) -> DashboardBanListResponse:
|
||
"""Return a paginated list of bans within the selected time window.
|
||
|
||
Reads from the fail2ban database and enriches each entry with
|
||
geolocation data (country, ASN, organisation) from the ip-api.com
|
||
free API. Results are sorted newest-first. Geo lookups are served
|
||
from the in-memory cache only; no database writes occur during this
|
||
GET request.
|
||
|
||
Args:
|
||
_auth: Validated session dependency.
|
||
ban_ctx: Ban service context containing db and repository.
|
||
socket_path: Path to fail2ban Unix domain socket.
|
||
http_session: Shared HTTP session for geolocation.
|
||
geo_cache: Geolocation cache instance.
|
||
range: Time-range preset — ``"24h"``, ``"7d"``, ``"30d"``, or
|
||
``"365d"``.
|
||
page: 1-based page number.
|
||
page_size: Maximum items per page (1–500).
|
||
origin: Optional filter by ban origin.
|
||
|
||
Returns:
|
||
:class:`~app.models.ban.DashboardBanListResponse` with paginated
|
||
ban items and the total count for the selected window.
|
||
"""
|
||
return await ban_service.list_bans(
|
||
socket_path,
|
||
range,
|
||
source=source,
|
||
page=page,
|
||
page_size=page_size,
|
||
http_session=http_session,
|
||
app_db=ban_ctx.db,
|
||
geo_cache=geo_cache,
|
||
origin=origin,
|
||
)
|
||
|
||
|
||
@router.get(
|
||
"/bans/by-country",
|
||
response_model=BansByCountryResponse,
|
||
summary="Return ban counts aggregated by country",
|
||
)
|
||
async def get_bans_by_country(
|
||
_auth: AuthDep,
|
||
ban_ctx: BanServiceContextDep,
|
||
socket_path: Fail2BanSocketDep,
|
||
http_session: HttpSessionDep,
|
||
geo_cache: GeoCacheDep,
|
||
range: TimeRange = Query(default=_DEFAULT_RANGE, description="Time-range preset."),
|
||
source: Literal["fail2ban", "archive"] = Query(
|
||
default="fail2ban",
|
||
description="Data source: 'fail2ban' or 'archive'.",
|
||
),
|
||
origin: BanOrigin | None = Query(
|
||
default=None,
|
||
description="Filter by ban origin: 'blocklist' or 'selfblock'. Omit for all.",
|
||
),
|
||
country_code: str | None = Query(
|
||
default=None,
|
||
description="ISO alpha-2 country code to filter companion rows.",
|
||
),
|
||
) -> BansByCountryResponse:
|
||
"""Return ban counts aggregated by ISO country code.
|
||
|
||
Uses SQL aggregation (``GROUP BY ip``) and batch geo-resolution to handle
|
||
10 000+ banned IPs efficiently. Returns a ``{country_code: count}`` map
|
||
and the 200 most recent raw ban rows for the companion access table. Geo
|
||
lookups are served from the in-memory cache only; no database writes occur
|
||
during this GET request.
|
||
|
||
Args:
|
||
_auth: Validated session dependency.
|
||
ban_ctx: Ban service context containing db and repository.
|
||
socket_path: Path to fail2ban Unix domain socket.
|
||
http_session: Shared HTTP session for geolocation.
|
||
geo_cache: Geolocation cache instance.
|
||
range: Time-range preset.
|
||
origin: Optional filter by ban origin.
|
||
|
||
Returns:
|
||
:class:`~app.models.ban.BansByCountryResponse` with per-country
|
||
aggregation and the companion ban list.
|
||
"""
|
||
return await ban_service.bans_by_country(
|
||
socket_path,
|
||
range,
|
||
source=source,
|
||
http_session=http_session,
|
||
geo_cache_lookup=geo_cache.lookup_cached_only,
|
||
geo_cache=geo_cache,
|
||
app_db=ban_ctx.db,
|
||
origin=origin,
|
||
country_code=country_code,
|
||
)
|
||
|
||
|
||
@router.get(
|
||
"/bans/trend",
|
||
response_model=BanTrendResponse,
|
||
summary="Return ban counts aggregated into time buckets",
|
||
)
|
||
async def get_ban_trend(
|
||
_auth: AuthDep,
|
||
ban_ctx: BanServiceContextDep,
|
||
socket_path: Fail2BanSocketDep,
|
||
range: TimeRange = Query(default=_DEFAULT_RANGE, description="Time-range preset."),
|
||
source: Literal["fail2ban", "archive"] = Query(
|
||
default="fail2ban",
|
||
description="Data source: 'fail2ban' or 'archive'.",
|
||
),
|
||
origin: BanOrigin | None = Query(
|
||
default=None,
|
||
description="Filter by ban origin: 'blocklist' or 'selfblock'. Omit for all.",
|
||
),
|
||
) -> BanTrendResponse:
|
||
"""Return ban counts grouped into equal-width time buckets.
|
||
|
||
Each bucket represents a contiguous time interval within the selected
|
||
window. All buckets are returned — empty buckets (zero bans) are
|
||
included so the frontend always receives a complete, gap-free series
|
||
suitable for rendering a continuous area or line chart.
|
||
|
||
Bucket sizes:
|
||
|
||
* ``24h`` → 1-hour buckets (24 total)
|
||
* ``7d`` → 6-hour buckets (28 total)
|
||
* ``30d`` → 1-day buckets (30 total)
|
||
* ``365d`` → 7-day buckets (~53 total)
|
||
|
||
Args:
|
||
_auth: Validated session dependency.
|
||
ban_ctx: Ban service context containing db and repository.
|
||
socket_path: Path to fail2ban Unix domain socket.
|
||
range: Time-range preset.
|
||
origin: Optional filter by ban origin.
|
||
|
||
Returns:
|
||
:class:`~app.models.ban.BanTrendResponse` with the ordered bucket
|
||
list and the bucket-size label.
|
||
"""
|
||
return await ban_service.ban_trend(
|
||
socket_path,
|
||
range,
|
||
source=source,
|
||
app_db=ban_ctx.db,
|
||
origin=origin,
|
||
)
|
||
|
||
|
||
@router.get(
|
||
"/bans/by-jail",
|
||
response_model=BansByJailResponse,
|
||
summary="Return ban counts aggregated by jail",
|
||
)
|
||
async def get_bans_by_jail(
|
||
_auth: AuthDep,
|
||
ban_ctx: BanServiceContextDep,
|
||
socket_path: Fail2BanSocketDep,
|
||
range: TimeRange = Query(default=_DEFAULT_RANGE, description="Time-range preset."),
|
||
source: Literal["fail2ban", "archive"] = Query(
|
||
default="fail2ban",
|
||
description="Data source: 'fail2ban' or 'archive'.",
|
||
),
|
||
origin: BanOrigin | None = Query(
|
||
default=None,
|
||
description="Filter by ban origin: 'blocklist' or 'selfblock'. Omit for all.",
|
||
),
|
||
) -> BansByJailResponse:
|
||
"""Return ban counts grouped by jail name for the selected time window.
|
||
|
||
Queries the fail2ban database and returns a list of jails sorted by
|
||
ban count descending. This endpoint is intended for the dashboard jail
|
||
distribution bar chart.
|
||
|
||
Args:
|
||
_auth: Validated session dependency.
|
||
ban_ctx: Ban service context containing db and repository.
|
||
socket_path: Path to fail2ban Unix domain socket.
|
||
range: Time-range preset — ``"24h"``, ``"7d"``, ``"30d"``, or
|
||
``"365d"``.
|
||
origin: Optional filter by ban origin.
|
||
|
||
Returns:
|
||
:class:`~app.models.ban.BansByJailResponse` with per-jail counts
|
||
sorted descending and the total for the selected window.
|
||
"""
|
||
return await ban_service.bans_by_jail(
|
||
socket_path,
|
||
range,
|
||
source=source,
|
||
app_db=ban_ctx.db,
|
||
origin=origin,
|
||
)
|