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>
375 lines
12 KiB
Python
375 lines
12 KiB
Python
"""Blocklist router.
|
|
|
|
Manages external IP blocklist sources, triggers manual imports, and exposes
|
|
the import schedule and log:
|
|
|
|
* ``GET /api/blocklists`` — list all sources
|
|
* ``POST /api/blocklists`` — add a source
|
|
* ``GET /api/blocklists/import`` — (reserved; use POST)
|
|
* ``POST /api/blocklists/import`` — trigger a manual import now
|
|
* ``GET /api/blocklists/schedule`` — get current schedule + next run
|
|
* ``PUT /api/blocklists/schedule`` — update schedule
|
|
* ``GET /api/blocklists/log`` — paginated import log
|
|
* ``GET /api/blocklists/{id}`` — get a single source
|
|
* ``PUT /api/blocklists/{id}`` — edit a source
|
|
* ``DELETE /api/blocklists/{id}`` — remove a source
|
|
* ``GET /api/blocklists/{id}/preview`` — preview the blocklist contents
|
|
|
|
Note: static path segments (``/import``, ``/schedule``, ``/log``) are
|
|
registered *before* the ``/{id}`` routes so FastAPI resolves them correctly.
|
|
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from fastapi import APIRouter, HTTPException, Query, status
|
|
|
|
from app.dependencies import (
|
|
AuthDep,
|
|
BlocklistServiceContextDep,
|
|
Fail2BanSocketDep,
|
|
GeoCacheDep,
|
|
HttpSessionDep,
|
|
SchedulerDep,
|
|
SettingsDep,
|
|
)
|
|
from app.models.blocklist import (
|
|
BlocklistListResponse,
|
|
BlocklistSource,
|
|
BlocklistSourceCreate,
|
|
BlocklistSourceUpdate,
|
|
ImportLogListResponse,
|
|
ImportRunResult,
|
|
PreviewResponse,
|
|
ScheduleConfig,
|
|
ScheduleInfo,
|
|
)
|
|
from app.services import ban_service, blocklist_service
|
|
from app.tasks.blocklist_import import run_import_with_resources
|
|
|
|
router: APIRouter = APIRouter(prefix="/api/blocklists", tags=["Blocklists"])
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Source list + create
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@router.get(
|
|
"",
|
|
response_model=BlocklistListResponse,
|
|
summary="List all blocklist sources",
|
|
)
|
|
async def list_blocklists(
|
|
blocklist_ctx: BlocklistServiceContextDep,
|
|
_auth: AuthDep,
|
|
) -> BlocklistListResponse:
|
|
"""Return all configured blocklist source definitions.
|
|
|
|
Args:
|
|
blocklist_ctx: Blocklist service context containing db and repositories.
|
|
_auth: Validated session — enforces authentication.
|
|
|
|
Returns:
|
|
:class:`~app.models.blocklist.BlocklistListResponse` with all sources.
|
|
"""
|
|
sources = await blocklist_service.list_sources(blocklist_ctx.db)
|
|
return BlocklistListResponse(sources=sources)
|
|
|
|
|
|
@router.post(
|
|
"",
|
|
response_model=BlocklistSource,
|
|
status_code=status.HTTP_201_CREATED,
|
|
summary="Add a new blocklist source",
|
|
)
|
|
async def create_blocklist(
|
|
payload: BlocklistSourceCreate,
|
|
blocklist_ctx: BlocklistServiceContextDep,
|
|
_auth: AuthDep,
|
|
) -> BlocklistSource:
|
|
"""Create a new blocklist source definition.
|
|
|
|
Args:
|
|
payload: New source data (name, url, enabled).
|
|
blocklist_ctx: Blocklist service context containing db and repositories.
|
|
_auth: Validated session — enforces authentication.
|
|
|
|
Returns:
|
|
The newly created :class:`~app.models.blocklist.BlocklistSource`.
|
|
|
|
Raises:
|
|
HTTPException: 400 if URL validation fails.
|
|
"""
|
|
try:
|
|
return await blocklist_service.create_source(
|
|
blocklist_ctx.db, payload.name, str(payload.url), enabled=payload.enabled
|
|
)
|
|
except ValueError as exc:
|
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Static sub-paths — must be declared BEFORE /{id}
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@router.post(
|
|
"/import",
|
|
response_model=ImportRunResult,
|
|
summary="Trigger a manual blocklist import",
|
|
)
|
|
async def run_import_now(
|
|
http_session: HttpSessionDep,
|
|
blocklist_ctx: BlocklistServiceContextDep,
|
|
_auth: AuthDep,
|
|
socket_path: Fail2BanSocketDep,
|
|
geo_cache: GeoCacheDep,
|
|
) -> ImportRunResult:
|
|
"""Download and apply all enabled blocklist sources immediately.
|
|
|
|
Args:
|
|
http_session: Shared HTTP session (injected).
|
|
blocklist_ctx: Blocklist service context containing db and repositories.
|
|
_auth: Validated session — enforces authentication.
|
|
socket_path: Path to fail2ban Unix domain socket.
|
|
geo_cache: Geolocation cache instance.
|
|
|
|
Returns:
|
|
:class:`~app.models.blocklist.ImportRunResult` with per-source
|
|
results and aggregated counters.
|
|
"""
|
|
|
|
return await blocklist_service.import_all(
|
|
blocklist_ctx.db,
|
|
http_session,
|
|
socket_path,
|
|
geo_is_cached=geo_cache.is_cached,
|
|
geo_cache=geo_cache,
|
|
ban_ip=ban_service.ban_ip,
|
|
)
|
|
|
|
|
|
@router.get(
|
|
"/schedule",
|
|
response_model=ScheduleInfo,
|
|
summary="Get the current import schedule",
|
|
)
|
|
async def get_schedule(
|
|
blocklist_ctx: BlocklistServiceContextDep,
|
|
_auth: AuthDep,
|
|
scheduler: SchedulerDep,
|
|
) -> ScheduleInfo:
|
|
"""Return the current schedule configuration and runtime metadata.
|
|
|
|
The ``next_run_at`` field is read from APScheduler if the job is active.
|
|
|
|
Args:
|
|
blocklist_ctx: Blocklist service context containing db and repositories.
|
|
_auth: Validated session — enforces authentication.
|
|
scheduler: APScheduler instance.
|
|
|
|
Returns:
|
|
:class:`~app.models.blocklist.ScheduleInfo` with config and run
|
|
times.
|
|
"""
|
|
return await blocklist_service.get_schedule_info_with_runtime(blocklist_ctx.db, scheduler)
|
|
|
|
|
|
@router.put(
|
|
"/schedule",
|
|
response_model=ScheduleInfo,
|
|
summary="Update the import schedule",
|
|
)
|
|
async def update_schedule(
|
|
payload: ScheduleConfig,
|
|
blocklist_ctx: BlocklistServiceContextDep,
|
|
_auth: AuthDep,
|
|
scheduler: SchedulerDep,
|
|
http_session: HttpSessionDep,
|
|
settings: SettingsDep,
|
|
) -> ScheduleInfo:
|
|
"""Persist a new schedule configuration and reschedule the import job.
|
|
|
|
Args:
|
|
payload: New :class:`~app.models.blocklist.ScheduleConfig`.
|
|
blocklist_ctx: Blocklist service context containing db and repositories.
|
|
_auth: Validated session — enforces authentication.
|
|
scheduler: Shared APScheduler instance (injected).
|
|
http_session: Shared HTTP session used by the scheduler job.
|
|
settings: Current application settings used by the scheduler job.
|
|
|
|
Returns:
|
|
Updated :class:`~app.models.blocklist.ScheduleInfo`.
|
|
"""
|
|
return await blocklist_service.update_schedule(
|
|
blocklist_ctx.db,
|
|
scheduler,
|
|
http_session,
|
|
settings,
|
|
payload,
|
|
run_import_with_resources,
|
|
)
|
|
|
|
|
|
@router.get(
|
|
"/log",
|
|
response_model=ImportLogListResponse,
|
|
summary="Get the paginated import log",
|
|
)
|
|
async def get_import_log(
|
|
blocklist_ctx: BlocklistServiceContextDep,
|
|
_auth: AuthDep,
|
|
source_id: int | None = Query(default=None, description="Filter by source id"),
|
|
page: int = Query(default=1, ge=1),
|
|
page_size: int = Query(default=50, ge=1, le=200),
|
|
) -> ImportLogListResponse:
|
|
"""Return a paginated log of all import runs.
|
|
|
|
Args:
|
|
blocklist_ctx: Blocklist service context containing db and repositories.
|
|
_auth: Validated session — enforces authentication.
|
|
source_id: Optional filter — only show logs for this source.
|
|
page: 1-based page number.
|
|
page_size: Items per page.
|
|
|
|
Returns:
|
|
:class:`~app.models.blocklist.ImportLogListResponse`.
|
|
"""
|
|
return await blocklist_service.list_import_logs(
|
|
blocklist_ctx.db, source_id=source_id, page=page, page_size=page_size
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Single source CRUD — parameterised routes AFTER static sub-paths
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@router.get(
|
|
"/{source_id}",
|
|
response_model=BlocklistSource,
|
|
summary="Get a single blocklist source",
|
|
)
|
|
async def get_blocklist(
|
|
source_id: int,
|
|
blocklist_ctx: BlocklistServiceContextDep,
|
|
_auth: AuthDep,
|
|
) -> BlocklistSource:
|
|
"""Return a single blocklist source by id.
|
|
|
|
Args:
|
|
source_id: Primary key of the source.
|
|
blocklist_ctx: Blocklist service context containing db and repositories.
|
|
_auth: Validated session — enforces authentication.
|
|
|
|
Raises:
|
|
HTTPException: 404 if the source does not exist.
|
|
"""
|
|
source = await blocklist_service.get_source(blocklist_ctx.db, source_id)
|
|
if source is None:
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Blocklist source not found.")
|
|
return source
|
|
|
|
|
|
@router.put(
|
|
"/{source_id}",
|
|
response_model=BlocklistSource,
|
|
summary="Update a blocklist source",
|
|
)
|
|
async def update_blocklist(
|
|
source_id: int,
|
|
payload: BlocklistSourceUpdate,
|
|
blocklist_ctx: BlocklistServiceContextDep,
|
|
_auth: AuthDep,
|
|
) -> BlocklistSource:
|
|
"""Update one or more fields on a blocklist source.
|
|
|
|
Args:
|
|
source_id: Primary key of the source to update.
|
|
payload: Fields to update (all optional).
|
|
blocklist_ctx: Blocklist service context containing db and repositories.
|
|
_auth: Validated session — enforces authentication.
|
|
|
|
Raises:
|
|
HTTPException: 400 if URL validation fails.
|
|
HTTPException: 404 if the source does not exist.
|
|
"""
|
|
try:
|
|
updated = await blocklist_service.update_source(
|
|
blocklist_ctx.db,
|
|
source_id,
|
|
name=payload.name,
|
|
url=str(payload.url) if payload.url is not None else None,
|
|
enabled=payload.enabled,
|
|
)
|
|
except ValueError as exc:
|
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
|
|
if updated is None:
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Blocklist source not found.")
|
|
return updated
|
|
|
|
|
|
@router.delete(
|
|
"/{source_id}",
|
|
status_code=status.HTTP_204_NO_CONTENT,
|
|
summary="Delete a blocklist source",
|
|
)
|
|
async def delete_blocklist(
|
|
source_id: int,
|
|
blocklist_ctx: BlocklistServiceContextDep,
|
|
_auth: AuthDep,
|
|
) -> None:
|
|
"""Delete a blocklist source by id.
|
|
|
|
Args:
|
|
source_id: Primary key of the source to remove.
|
|
blocklist_ctx: Blocklist service context containing db and repositories.
|
|
_auth: Validated session — enforces authentication.
|
|
|
|
Raises:
|
|
HTTPException: 404 if the source does not exist.
|
|
"""
|
|
deleted = await blocklist_service.delete_source(blocklist_ctx.db, source_id)
|
|
if not deleted:
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Blocklist source not found.")
|
|
|
|
|
|
@router.get(
|
|
"/{source_id}/preview",
|
|
response_model=PreviewResponse,
|
|
summary="Preview the contents of a blocklist source",
|
|
)
|
|
async def preview_blocklist(
|
|
source_id: int,
|
|
http_session: HttpSessionDep,
|
|
blocklist_ctx: BlocklistServiceContextDep,
|
|
_auth: AuthDep,
|
|
) -> PreviewResponse:
|
|
"""Download and preview a sample of a blocklist source.
|
|
|
|
Returns the first :data:`~app.services.blocklist_service._PREVIEW_LINES`
|
|
valid IP entries together with validation statistics.
|
|
|
|
Args:
|
|
source_id: Primary key of the source to preview.
|
|
http_session: Shared HTTP session for downloading.
|
|
blocklist_ctx: Blocklist service context containing db and repositories.
|
|
_auth: Validated session — enforces authentication.
|
|
|
|
Raises:
|
|
HTTPException: 404 if the source does not exist.
|
|
HTTPException: 502 if the URL cannot be reached.
|
|
"""
|
|
source = await blocklist_service.get_source(blocklist_ctx.db, source_id)
|
|
if source is None:
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Blocklist source not found.")
|
|
|
|
try:
|
|
return await blocklist_service.preview_source(source.url, http_session)
|
|
except ValueError as exc:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_502_BAD_GATEWAY,
|
|
detail=f"Could not fetch blocklist: {exc}",
|
|
) from exc
|