Files
BanGUI/backend/app/routers/blocklist.py
Lukas 0d5882b32f Fix HIGH priority issues: unbounded queries, rate limiting, health checks
Issue #3 - Unbounded Query Results (OOM):
- get_all_archived_history() now uses keyset pagination with bounded max_rows (50k default)
- Added 'id' field to records from get_archived_history() and get_archived_history_keyset()
- Protocol signature updated with page_size, max_rows, last_ban_id params

Issue #7 - Docker Health Check Fails:
- Added curl to Dockerfile.backend runtime image
- HEALTHCHECK now uses 'curl -f http://localhost:8000/api/health'
- compose.prod.yml: increased start_period to 40s, timeout to 10s
- Frontend healthcheck proxies to backend /api/health

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-01 21:47:36 +02:00

378 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, Query, status
from app.dependencies import (
AuthDep,
BlocklistServiceContextDep,
Fail2BanSocketDep,
GeoCacheDep,
HttpSessionDep,
SchedulerDep,
SettingsDep,
)
from app.exceptions import BadRequestError, BlocklistSourceNotFoundError
from app.mappers import blocklist_mappers
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
from app.utils.constants import DEFAULT_PAGE_SIZE
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 BadRequestError(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, description="1-based page number."),
page_size: int = Query(
default=DEFAULT_PAGE_SIZE, ge=1, le=500, description="Items per page (max 500)."
),
) -> 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 BlocklistSourceNotFoundError(source_id)
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 BadRequestError(str(exc)) from exc
if updated is None:
raise BlocklistSourceNotFoundError(source_id)
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 BlocklistSourceNotFoundError(source_id)
@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 BlocklistSourceNotFoundError(source_id)
try:
domain_result = await blocklist_service.preview_source(source.url, http_session)
return blocklist_mappers.map_domain_preview_result_to_response(domain_result)
except ValueError as exc:
raise BadRequestError(f"Could not fetch blocklist: {exc}") from exc