- 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>
414 lines
13 KiB
Python
414 lines
13 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, Depends, Query, Request, status
|
|
|
|
from app.dependencies import (
|
|
AuthDep,
|
|
BlocklistServiceContextDep,
|
|
Fail2BanSocketDep,
|
|
GeoCacheDep,
|
|
GlobalRateLimiterDep,
|
|
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, RATE_LIMIT_BLOCKLIST_IMPORT_REQUESTS
|
|
|
|
router: APIRouter = APIRouter(prefix="/api/v1/blocklists", tags=["Blocklists"])
|
|
|
|
# Rate limit bucket constants
|
|
_BLOCKLIST_IMPORT_BUCKET = "blocklist:import"
|
|
# 3600 seconds per hour
|
|
_HOUR = 3600
|
|
|
|
|
|
def _check_blocklist_import_rate_limit(
|
|
request: Request,
|
|
rate_limiter: GlobalRateLimiterDep,
|
|
) -> None:
|
|
"""Check rate limit for blocklist import operations."""
|
|
from app.utils.client_ip import get_client_ip
|
|
|
|
settings = request.app.state.settings
|
|
client_ip = get_client_ip(request, trusted_proxies=settings.trusted_proxies)
|
|
is_allowed, retry_after = rate_limiter.check_allowed_for_bucket(
|
|
_BLOCKLIST_IMPORT_BUCKET, client_ip, RATE_LIMIT_BLOCKLIST_IMPORT_REQUESTS, _HOUR
|
|
)
|
|
if not is_allowed:
|
|
from app.exceptions import RateLimitError
|
|
import structlog
|
|
|
|
log = structlog.get_logger()
|
|
log.warning(
|
|
"blocklist_import_rate_limit_exceeded",
|
|
client_ip=client_ip,
|
|
path=request.url.path,
|
|
retry_after=retry_after,
|
|
)
|
|
raise RateLimitError(
|
|
"Rate limit exceeded for blocklist import. Please try again later.",
|
|
retry_after_seconds=retry_after,
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 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",
|
|
dependencies=[Depends(_check_blocklist_import_rate_limit)],
|
|
)
|
|
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
|