Stage 10: external blocklist importer — backend + frontend
- blocklist_repo.py: CRUD for blocklist_sources table - import_log_repo.py: add/list/get-last log entries - blocklist_service.py: source CRUD, preview, import (download/validate/ban), import_all, schedule get/set/info - blocklist_import.py: APScheduler task (hourly/daily/weekly schedule triggers) - blocklist.py router: 9 endpoints (list/create/update/delete/preview/import/ schedule-get+put/log) - blocklist.py models: ScheduleFrequency (StrEnum), ScheduleConfig, ScheduleInfo, ImportSourceResult, ImportRunResult, PreviewResponse - 59 new tests (18 repo + 19 service + 22 router); 374 total pass - ruff clean, mypy clean for Stage 10 files - types/blocklist.ts, api/blocklist.ts, hooks/useBlocklist.ts - BlocklistsPage.tsx: source management, schedule picker, import log table - Frontend tsc + ESLint clean
This commit is contained in:
@@ -33,8 +33,8 @@ from starlette.middleware.base import BaseHTTPMiddleware
|
||||
|
||||
from app.config import Settings, get_settings
|
||||
from app.db import init_db
|
||||
from app.routers import auth, bans, config, dashboard, geo, health, history, jails, server, setup
|
||||
from app.tasks import health_check
|
||||
from app.routers import auth, bans, blocklist, config, dashboard, geo, health, history, jails, server, setup
|
||||
from app.tasks import blocklist_import, health_check
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Ensure the bundled fail2ban package is importable from fail2ban-master/
|
||||
@@ -118,6 +118,9 @@ async def _lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
|
||||
# --- Health-check background probe ---
|
||||
health_check.register(app)
|
||||
|
||||
# --- Blocklist import scheduled task ---
|
||||
blocklist_import.register(app)
|
||||
|
||||
log.info("bangui_started")
|
||||
|
||||
try:
|
||||
@@ -279,5 +282,6 @@ def create_app(settings: Settings | None = None) -> FastAPI:
|
||||
app.include_router(config.router)
|
||||
app.include_router(server.router)
|
||||
app.include_router(history.router)
|
||||
app.include_router(blocklist.router)
|
||||
|
||||
return app
|
||||
|
||||
@@ -1,7 +1,19 @@
|
||||
"""Blocklist source and import log Pydantic models."""
|
||||
"""Blocklist source and import log Pydantic models.
|
||||
|
||||
Data shapes for blocklist source management, import operations, scheduling,
|
||||
and import log retrieval.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from enum import StrEnum
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Blocklist source
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class BlocklistSource(BaseModel):
|
||||
"""Domain model for a blocklist source definition."""
|
||||
@@ -21,21 +33,34 @@ class BlocklistSourceCreate(BaseModel):
|
||||
|
||||
model_config = ConfigDict(strict=True)
|
||||
|
||||
name: str = Field(..., min_length=1, description="Human-readable source name.")
|
||||
url: str = Field(..., description="URL of the blocklist file.")
|
||||
name: str = Field(..., min_length=1, max_length=100, description="Human-readable source name.")
|
||||
url: str = Field(..., min_length=1, description="URL of the blocklist file.")
|
||||
enabled: bool = Field(default=True)
|
||||
|
||||
|
||||
class BlocklistSourceUpdate(BaseModel):
|
||||
"""Payload for ``PUT /api/blocklists/{id}``."""
|
||||
"""Payload for ``PUT /api/blocklists/{id}``. All fields are optional."""
|
||||
|
||||
model_config = ConfigDict(strict=True)
|
||||
|
||||
name: str | None = Field(default=None, min_length=1)
|
||||
name: str | None = Field(default=None, min_length=1, max_length=100)
|
||||
url: str | None = Field(default=None)
|
||||
enabled: bool | None = Field(default=None)
|
||||
|
||||
|
||||
class BlocklistListResponse(BaseModel):
|
||||
"""Response for ``GET /api/blocklists``."""
|
||||
|
||||
model_config = ConfigDict(strict=True)
|
||||
|
||||
sources: list[BlocklistSource] = Field(default_factory=list)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Import log
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class ImportLogEntry(BaseModel):
|
||||
"""A single blocklist import run record."""
|
||||
|
||||
@@ -50,35 +75,105 @@ class ImportLogEntry(BaseModel):
|
||||
errors: str | None
|
||||
|
||||
|
||||
class BlocklistListResponse(BaseModel):
|
||||
"""Response for ``GET /api/blocklists``."""
|
||||
|
||||
model_config = ConfigDict(strict=True)
|
||||
|
||||
sources: list[BlocklistSource] = Field(default_factory=list)
|
||||
|
||||
|
||||
class ImportLogListResponse(BaseModel):
|
||||
"""Response for ``GET /api/blocklists/log``."""
|
||||
|
||||
model_config = ConfigDict(strict=True)
|
||||
|
||||
entries: list[ImportLogEntry] = Field(default_factory=list)
|
||||
items: list[ImportLogEntry] = Field(default_factory=list)
|
||||
total: int = Field(..., ge=0)
|
||||
page: int = Field(default=1, ge=1)
|
||||
page_size: int = Field(default=50, ge=1)
|
||||
total_pages: int = Field(default=1, ge=1)
|
||||
|
||||
|
||||
class BlocklistSchedule(BaseModel):
|
||||
"""Current import schedule and next run information."""
|
||||
# ---------------------------------------------------------------------------
|
||||
# Schedule
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class ScheduleFrequency(StrEnum):
|
||||
"""Available import schedule frequency presets."""
|
||||
|
||||
hourly = "hourly"
|
||||
daily = "daily"
|
||||
weekly = "weekly"
|
||||
|
||||
|
||||
class ScheduleConfig(BaseModel):
|
||||
"""Import schedule configuration.
|
||||
|
||||
The interpretation of fields depends on *frequency*:
|
||||
|
||||
- ``hourly``: ``interval_hours`` controls how often (every N hours).
|
||||
- ``daily``: ``hour`` and ``minute`` specify the daily run time (UTC).
|
||||
- ``weekly``: additionally uses ``day_of_week`` (0=Monday … 6=Sunday).
|
||||
"""
|
||||
|
||||
# No strict=True here: FastAPI and json.loads() both supply enum values as
|
||||
# plain strings; strict mode would reject string→enum coercion.
|
||||
|
||||
frequency: ScheduleFrequency = ScheduleFrequency.daily
|
||||
interval_hours: int = Field(default=24, ge=1, le=168, description="Used when frequency=hourly")
|
||||
hour: int = Field(default=3, ge=0, le=23, description="UTC hour for daily/weekly runs")
|
||||
minute: int = Field(default=0, ge=0, le=59, description="Minute for daily/weekly runs")
|
||||
day_of_week: int = Field(
|
||||
default=0,
|
||||
ge=0,
|
||||
le=6,
|
||||
description="Day of week for weekly runs (0=Monday … 6=Sunday)",
|
||||
)
|
||||
|
||||
|
||||
class ScheduleInfo(BaseModel):
|
||||
"""Current schedule configuration together with runtime metadata."""
|
||||
|
||||
model_config = ConfigDict(strict=True)
|
||||
|
||||
hour: int = Field(..., ge=0, le=23, description="UTC hour for the daily import.")
|
||||
next_run_at: str | None = Field(default=None, description="ISO 8601 UTC timestamp of the next scheduled import.")
|
||||
config: ScheduleConfig
|
||||
next_run_at: str | None
|
||||
last_run_at: str | None
|
||||
|
||||
|
||||
class BlocklistScheduleUpdate(BaseModel):
|
||||
"""Payload for ``PUT /api/blocklists/schedule``."""
|
||||
# ---------------------------------------------------------------------------
|
||||
# Import results
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class ImportSourceResult(BaseModel):
|
||||
"""Result of importing a single blocklist source."""
|
||||
|
||||
model_config = ConfigDict(strict=True)
|
||||
|
||||
hour: int = Field(..., ge=0, le=23)
|
||||
source_id: int | None
|
||||
source_url: str
|
||||
ips_imported: int
|
||||
ips_skipped: int
|
||||
error: str | None
|
||||
|
||||
|
||||
class ImportRunResult(BaseModel):
|
||||
"""Aggregated result from a full import run across all enabled sources."""
|
||||
|
||||
model_config = ConfigDict(strict=True)
|
||||
|
||||
results: list[ImportSourceResult] = Field(default_factory=list)
|
||||
total_imported: int
|
||||
total_skipped: int
|
||||
errors_count: int
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Preview
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class PreviewResponse(BaseModel):
|
||||
"""Response for ``GET /api/blocklists/{id}/preview``."""
|
||||
|
||||
model_config = ConfigDict(strict=True)
|
||||
|
||||
entries: list[str] = Field(default_factory=list, description="Sample of valid IP entries")
|
||||
total_lines: int
|
||||
valid_count: int
|
||||
skipped_count: int
|
||||
|
||||
187
backend/app/repositories/blocklist_repo.py
Normal file
187
backend/app/repositories/blocklist_repo.py
Normal file
@@ -0,0 +1,187 @@
|
||||
"""Blocklist sources repository.
|
||||
|
||||
CRUD operations for the ``blocklist_sources`` table in the application
|
||||
SQLite database. All methods accept a :class:`aiosqlite.Connection` — no
|
||||
ORM, no HTTP exceptions.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import aiosqlite
|
||||
|
||||
|
||||
async def create_source(
|
||||
db: aiosqlite.Connection,
|
||||
name: str,
|
||||
url: str,
|
||||
*,
|
||||
enabled: bool = True,
|
||||
) -> int:
|
||||
"""Insert a new blocklist source and return its generated id.
|
||||
|
||||
Args:
|
||||
db: Active aiosqlite connection.
|
||||
name: Human-readable display name.
|
||||
url: URL of the blocklist text file.
|
||||
enabled: Whether the source is active. Defaults to ``True``.
|
||||
|
||||
Returns:
|
||||
The ``ROWID`` / primary key of the new row.
|
||||
"""
|
||||
cursor = await db.execute(
|
||||
"""
|
||||
INSERT INTO blocklist_sources (name, url, enabled)
|
||||
VALUES (?, ?, ?)
|
||||
""",
|
||||
(name, url, int(enabled)),
|
||||
)
|
||||
await db.commit()
|
||||
return int(cursor.lastrowid) # type: ignore[arg-type]
|
||||
|
||||
|
||||
async def get_source(
|
||||
db: aiosqlite.Connection,
|
||||
source_id: int,
|
||||
) -> dict[str, Any] | None:
|
||||
"""Return a single blocklist source row as a plain dict, or ``None``.
|
||||
|
||||
Args:
|
||||
db: Active aiosqlite connection.
|
||||
source_id: Primary key of the source to retrieve.
|
||||
|
||||
Returns:
|
||||
A dict with keys matching the ``blocklist_sources`` columns, or
|
||||
``None`` if no row with that id exists.
|
||||
"""
|
||||
async with db.execute(
|
||||
"SELECT id, name, url, enabled, created_at, updated_at FROM blocklist_sources WHERE id = ?",
|
||||
(source_id,),
|
||||
) as cursor:
|
||||
row = await cursor.fetchone()
|
||||
if row is None:
|
||||
return None
|
||||
return _row_to_dict(row)
|
||||
|
||||
|
||||
async def list_sources(db: aiosqlite.Connection) -> list[dict[str, Any]]:
|
||||
"""Return all blocklist sources ordered by id ascending.
|
||||
|
||||
Args:
|
||||
db: Active aiosqlite connection.
|
||||
|
||||
Returns:
|
||||
List of dicts, one per row in ``blocklist_sources``.
|
||||
"""
|
||||
async with db.execute(
|
||||
"SELECT id, name, url, enabled, created_at, updated_at FROM blocklist_sources ORDER BY id"
|
||||
) as cursor:
|
||||
rows = await cursor.fetchall()
|
||||
return [_row_to_dict(r) for r in rows]
|
||||
|
||||
|
||||
async def list_enabled_sources(db: aiosqlite.Connection) -> list[dict[str, Any]]:
|
||||
"""Return only enabled blocklist sources ordered by id.
|
||||
|
||||
Args:
|
||||
db: Active aiosqlite connection.
|
||||
|
||||
Returns:
|
||||
List of dicts for rows where ``enabled = 1``.
|
||||
"""
|
||||
async with db.execute(
|
||||
"SELECT id, name, url, enabled, created_at, updated_at FROM blocklist_sources WHERE enabled = 1 ORDER BY id"
|
||||
) as cursor:
|
||||
rows = await cursor.fetchall()
|
||||
return [_row_to_dict(r) for r in rows]
|
||||
|
||||
|
||||
async def update_source(
|
||||
db: aiosqlite.Connection,
|
||||
source_id: int,
|
||||
*,
|
||||
name: str | None = None,
|
||||
url: str | None = None,
|
||||
enabled: bool | None = None,
|
||||
) -> bool:
|
||||
"""Update one or more fields on a blocklist source.
|
||||
|
||||
Only the keyword arguments that are not ``None`` are included in the
|
||||
``UPDATE`` statement.
|
||||
|
||||
Args:
|
||||
db: Active aiosqlite connection.
|
||||
source_id: Primary key of the source to update.
|
||||
name: New display name, or ``None`` to leave unchanged.
|
||||
url: New URL, or ``None`` to leave unchanged.
|
||||
enabled: New enabled flag, or ``None`` to leave unchanged.
|
||||
|
||||
Returns:
|
||||
``True`` if a row was updated, ``False`` if the id does not exist.
|
||||
"""
|
||||
fields: list[str] = []
|
||||
params: list[Any] = []
|
||||
|
||||
if name is not None:
|
||||
fields.append("name = ?")
|
||||
params.append(name)
|
||||
if url is not None:
|
||||
fields.append("url = ?")
|
||||
params.append(url)
|
||||
if enabled is not None:
|
||||
fields.append("enabled = ?")
|
||||
params.append(int(enabled))
|
||||
|
||||
if not fields:
|
||||
# Nothing to update — treat as success only if the row exists.
|
||||
return await get_source(db, source_id) is not None
|
||||
|
||||
fields.append("updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')")
|
||||
params.append(source_id)
|
||||
|
||||
cursor = await db.execute(
|
||||
f"UPDATE blocklist_sources SET {', '.join(fields)} WHERE id = ?", # noqa: S608
|
||||
params,
|
||||
)
|
||||
await db.commit()
|
||||
return cursor.rowcount > 0
|
||||
|
||||
|
||||
async def delete_source(db: aiosqlite.Connection, source_id: int) -> bool:
|
||||
"""Delete a blocklist source by id.
|
||||
|
||||
Args:
|
||||
db: Active aiosqlite connection.
|
||||
source_id: Primary key of the source to remove.
|
||||
|
||||
Returns:
|
||||
``True`` if a row was deleted, ``False`` if the id did not exist.
|
||||
"""
|
||||
cursor = await db.execute(
|
||||
"DELETE FROM blocklist_sources WHERE id = ?",
|
||||
(source_id,),
|
||||
)
|
||||
await db.commit()
|
||||
return cursor.rowcount > 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Internal helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _row_to_dict(row: Any) -> dict[str, Any]:
|
||||
"""Convert an aiosqlite row to a plain Python dict.
|
||||
|
||||
Args:
|
||||
row: An :class:`aiosqlite.Row` or sequence returned by a cursor.
|
||||
|
||||
Returns:
|
||||
``dict`` mapping column names to values with ``enabled`` cast to
|
||||
``bool``.
|
||||
"""
|
||||
d: dict[str, Any] = dict(row)
|
||||
d["enabled"] = bool(d["enabled"])
|
||||
return d
|
||||
155
backend/app/repositories/import_log_repo.py
Normal file
155
backend/app/repositories/import_log_repo.py
Normal file
@@ -0,0 +1,155 @@
|
||||
"""Import log repository.
|
||||
|
||||
Persists and queries blocklist import run records in the ``import_log``
|
||||
table. All methods are plain async functions that accept a
|
||||
:class:`aiosqlite.Connection`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import aiosqlite
|
||||
|
||||
|
||||
async def add_log(
|
||||
db: aiosqlite.Connection,
|
||||
*,
|
||||
source_id: int | None,
|
||||
source_url: str,
|
||||
ips_imported: int,
|
||||
ips_skipped: int,
|
||||
errors: str | None,
|
||||
) -> int:
|
||||
"""Insert a new import log entry and return its id.
|
||||
|
||||
Args:
|
||||
db: Active aiosqlite connection.
|
||||
source_id: FK to ``blocklist_sources.id``, or ``None`` if the source
|
||||
has been deleted since the import ran.
|
||||
source_url: URL that was downloaded.
|
||||
ips_imported: Number of IPs successfully applied as bans.
|
||||
ips_skipped: Number of lines that were skipped (invalid or CIDR).
|
||||
errors: Error message string, or ``None`` if the import succeeded.
|
||||
|
||||
Returns:
|
||||
Primary key of the inserted row.
|
||||
"""
|
||||
cursor = await db.execute(
|
||||
"""
|
||||
INSERT INTO import_log (source_id, source_url, ips_imported, ips_skipped, errors)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
""",
|
||||
(source_id, source_url, ips_imported, ips_skipped, errors),
|
||||
)
|
||||
await db.commit()
|
||||
return int(cursor.lastrowid) # type: ignore[arg-type]
|
||||
|
||||
|
||||
async def list_logs(
|
||||
db: aiosqlite.Connection,
|
||||
*,
|
||||
source_id: int | None = None,
|
||||
page: int = 1,
|
||||
page_size: int = 50,
|
||||
) -> tuple[list[dict[str, Any]], int]:
|
||||
"""Return a paginated list of import log entries.
|
||||
|
||||
Args:
|
||||
db: Active aiosqlite connection.
|
||||
source_id: If given, filter to logs for this source only.
|
||||
page: 1-based page index.
|
||||
page_size: Number of items per page.
|
||||
|
||||
Returns:
|
||||
A 2-tuple ``(items, total)`` where *items* is a list of dicts and
|
||||
*total* is the count of all matching rows (ignoring pagination).
|
||||
"""
|
||||
where = ""
|
||||
params_count: list[Any] = []
|
||||
params_rows: list[Any] = []
|
||||
|
||||
if source_id is not None:
|
||||
where = " WHERE source_id = ?"
|
||||
params_count.append(source_id)
|
||||
params_rows.append(source_id)
|
||||
|
||||
# Total count
|
||||
async with db.execute(
|
||||
f"SELECT COUNT(*) FROM import_log{where}", # noqa: S608
|
||||
params_count,
|
||||
) as cursor:
|
||||
count_row = await cursor.fetchone()
|
||||
total: int = int(count_row[0]) if count_row else 0
|
||||
|
||||
offset = (page - 1) * page_size
|
||||
params_rows.extend([page_size, offset])
|
||||
|
||||
async with db.execute(
|
||||
f"""
|
||||
SELECT id, source_id, source_url, timestamp, ips_imported, ips_skipped, errors
|
||||
FROM import_log{where}
|
||||
ORDER BY id DESC
|
||||
LIMIT ? OFFSET ?
|
||||
""", # noqa: S608
|
||||
params_rows,
|
||||
) as cursor:
|
||||
rows = await cursor.fetchall()
|
||||
items = [_row_to_dict(r) for r in rows]
|
||||
|
||||
return items, total
|
||||
|
||||
|
||||
async def get_last_log(db: aiosqlite.Connection) -> dict[str, Any] | None:
|
||||
"""Return the most recent import log entry across all sources.
|
||||
|
||||
Args:
|
||||
db: Active aiosqlite connection.
|
||||
|
||||
Returns:
|
||||
The latest log entry as a dict, or ``None`` if no logs exist.
|
||||
"""
|
||||
async with db.execute(
|
||||
"""
|
||||
SELECT id, source_id, source_url, timestamp, ips_imported, ips_skipped, errors
|
||||
FROM import_log
|
||||
ORDER BY id DESC
|
||||
LIMIT 1
|
||||
"""
|
||||
) as cursor:
|
||||
row = await cursor.fetchone()
|
||||
return _row_to_dict(row) if row is not None else None
|
||||
|
||||
|
||||
def compute_total_pages(total: int, page_size: int) -> int:
|
||||
"""Return the total number of pages for a given total and page size.
|
||||
|
||||
Args:
|
||||
total: Total number of items.
|
||||
page_size: Items per page.
|
||||
|
||||
Returns:
|
||||
Number of pages (minimum 1).
|
||||
"""
|
||||
if total == 0:
|
||||
return 1
|
||||
return math.ceil(total / page_size)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Internal helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _row_to_dict(row: Any) -> dict[str, Any]:
|
||||
"""Convert an aiosqlite row to a plain Python dict.
|
||||
|
||||
Args:
|
||||
row: An :class:`aiosqlite.Row` or sequence returned by a cursor.
|
||||
|
||||
Returns:
|
||||
Dict mapping column names to Python values.
|
||||
"""
|
||||
return dict(row)
|
||||
370
backend/app/routers/blocklist.py
Normal file
370
backend/app/routers/blocklist.py
Normal file
@@ -0,0 +1,370 @@
|
||||
"""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 typing import TYPE_CHECKING, Annotated
|
||||
|
||||
import aiosqlite
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import aiohttp
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
|
||||
|
||||
from app.dependencies import AuthDep, get_db
|
||||
from app.models.blocklist import (
|
||||
BlocklistListResponse,
|
||||
BlocklistSource,
|
||||
BlocklistSourceCreate,
|
||||
BlocklistSourceUpdate,
|
||||
ImportLogListResponse,
|
||||
ImportRunResult,
|
||||
PreviewResponse,
|
||||
ScheduleConfig,
|
||||
ScheduleInfo,
|
||||
)
|
||||
from app.repositories import import_log_repo
|
||||
from app.services import blocklist_service
|
||||
from app.tasks import blocklist_import as blocklist_import_task
|
||||
|
||||
router: APIRouter = APIRouter(prefix="/api/blocklists", tags=["Blocklists"])
|
||||
|
||||
DbDep = Annotated[aiosqlite.Connection, Depends(get_db)]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Source list + create
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.get(
|
||||
"",
|
||||
response_model=BlocklistListResponse,
|
||||
summary="List all blocklist sources",
|
||||
)
|
||||
async def list_blocklists(
|
||||
db: DbDep,
|
||||
_auth: AuthDep,
|
||||
) -> BlocklistListResponse:
|
||||
"""Return all configured blocklist source definitions.
|
||||
|
||||
Args:
|
||||
db: Application database connection (injected).
|
||||
_auth: Validated session — enforces authentication.
|
||||
|
||||
Returns:
|
||||
:class:`~app.models.blocklist.BlocklistListResponse` with all sources.
|
||||
"""
|
||||
sources = await blocklist_service.list_sources(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,
|
||||
db: DbDep,
|
||||
_auth: AuthDep,
|
||||
) -> BlocklistSource:
|
||||
"""Create a new blocklist source definition.
|
||||
|
||||
Args:
|
||||
payload: New source data (name, url, enabled).
|
||||
db: Application database connection (injected).
|
||||
_auth: Validated session — enforces authentication.
|
||||
|
||||
Returns:
|
||||
The newly created :class:`~app.models.blocklist.BlocklistSource`.
|
||||
"""
|
||||
return await blocklist_service.create_source(
|
||||
db, payload.name, payload.url, enabled=payload.enabled
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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(
|
||||
request: Request,
|
||||
db: DbDep,
|
||||
_auth: AuthDep,
|
||||
) -> ImportRunResult:
|
||||
"""Download and apply all enabled blocklist sources immediately.
|
||||
|
||||
Args:
|
||||
request: Incoming request (used to access shared HTTP session).
|
||||
db: Application database connection (injected).
|
||||
_auth: Validated session — enforces authentication.
|
||||
|
||||
Returns:
|
||||
:class:`~app.models.blocklist.ImportRunResult` with per-source
|
||||
results and aggregated counters.
|
||||
"""
|
||||
http_session: aiohttp.ClientSession = request.app.state.http_session
|
||||
socket_path: str = request.app.state.settings.fail2ban_socket
|
||||
return await blocklist_service.import_all(db, http_session, socket_path)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/schedule",
|
||||
response_model=ScheduleInfo,
|
||||
summary="Get the current import schedule",
|
||||
)
|
||||
async def get_schedule(
|
||||
request: Request,
|
||||
db: DbDep,
|
||||
_auth: AuthDep,
|
||||
) -> ScheduleInfo:
|
||||
"""Return the current schedule configuration and runtime metadata.
|
||||
|
||||
The ``next_run_at`` field is read from APScheduler if the job is active.
|
||||
|
||||
Args:
|
||||
request: Incoming request (used to query the scheduler).
|
||||
db: Application database connection (injected).
|
||||
_auth: Validated session — enforces authentication.
|
||||
|
||||
Returns:
|
||||
:class:`~app.models.blocklist.ScheduleInfo` with config and run
|
||||
times.
|
||||
"""
|
||||
scheduler = request.app.state.scheduler
|
||||
job = scheduler.get_job(blocklist_import_task.JOB_ID)
|
||||
next_run_at: str | None = None
|
||||
if job is not None and job.next_run_time is not None:
|
||||
next_run_at = job.next_run_time.isoformat()
|
||||
|
||||
return await blocklist_service.get_schedule_info(db, next_run_at)
|
||||
|
||||
|
||||
@router.put(
|
||||
"/schedule",
|
||||
response_model=ScheduleInfo,
|
||||
summary="Update the import schedule",
|
||||
)
|
||||
async def update_schedule(
|
||||
payload: ScheduleConfig,
|
||||
request: Request,
|
||||
db: DbDep,
|
||||
_auth: AuthDep,
|
||||
) -> ScheduleInfo:
|
||||
"""Persist a new schedule configuration and reschedule the import job.
|
||||
|
||||
Args:
|
||||
payload: New :class:`~app.models.blocklist.ScheduleConfig`.
|
||||
request: Incoming request (used to access the scheduler).
|
||||
db: Application database connection (injected).
|
||||
_auth: Validated session — enforces authentication.
|
||||
|
||||
Returns:
|
||||
Updated :class:`~app.models.blocklist.ScheduleInfo`.
|
||||
"""
|
||||
await blocklist_service.set_schedule(db, payload)
|
||||
# Reschedule the background job immediately.
|
||||
blocklist_import_task.reschedule(request.app)
|
||||
|
||||
job = request.app.state.scheduler.get_job(blocklist_import_task.JOB_ID)
|
||||
next_run_at: str | None = None
|
||||
if job is not None and job.next_run_time is not None:
|
||||
next_run_at = job.next_run_time.isoformat()
|
||||
|
||||
return await blocklist_service.get_schedule_info(db, next_run_at)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/log",
|
||||
response_model=ImportLogListResponse,
|
||||
summary="Get the paginated import log",
|
||||
)
|
||||
async def get_import_log(
|
||||
db: DbDep,
|
||||
_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:
|
||||
db: Application database connection (injected).
|
||||
_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`.
|
||||
"""
|
||||
items, total = await import_log_repo.list_logs(
|
||||
db, source_id=source_id, page=page, page_size=page_size
|
||||
)
|
||||
total_pages = import_log_repo.compute_total_pages(total, page_size)
|
||||
from app.models.blocklist import ImportLogEntry # noqa: PLC0415
|
||||
|
||||
return ImportLogListResponse(
|
||||
items=[ImportLogEntry.model_validate(i) for i in items],
|
||||
total=total,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
total_pages=total_pages,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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,
|
||||
db: DbDep,
|
||||
_auth: AuthDep,
|
||||
) -> BlocklistSource:
|
||||
"""Return a single blocklist source by id.
|
||||
|
||||
Args:
|
||||
source_id: Primary key of the source.
|
||||
db: Application database connection (injected).
|
||||
_auth: Validated session — enforces authentication.
|
||||
|
||||
Raises:
|
||||
HTTPException: 404 if the source does not exist.
|
||||
"""
|
||||
source = await blocklist_service.get_source(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,
|
||||
db: DbDep,
|
||||
_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).
|
||||
db: Application database connection (injected).
|
||||
_auth: Validated session — enforces authentication.
|
||||
|
||||
Raises:
|
||||
HTTPException: 404 if the source does not exist.
|
||||
"""
|
||||
updated = await blocklist_service.update_source(
|
||||
db,
|
||||
source_id,
|
||||
name=payload.name,
|
||||
url=payload.url,
|
||||
enabled=payload.enabled,
|
||||
)
|
||||
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,
|
||||
db: DbDep,
|
||||
_auth: AuthDep,
|
||||
) -> None:
|
||||
"""Delete a blocklist source by id.
|
||||
|
||||
Args:
|
||||
source_id: Primary key of the source to remove.
|
||||
db: Application database connection (injected).
|
||||
_auth: Validated session — enforces authentication.
|
||||
|
||||
Raises:
|
||||
HTTPException: 404 if the source does not exist.
|
||||
"""
|
||||
deleted = await blocklist_service.delete_source(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,
|
||||
request: Request,
|
||||
db: DbDep,
|
||||
_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.
|
||||
request: Incoming request (used to access the HTTP session).
|
||||
db: Application database connection (injected).
|
||||
_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(db, source_id)
|
||||
if source is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Blocklist source not found.")
|
||||
|
||||
http_session: aiohttp.ClientSession = request.app.state.http_session
|
||||
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
|
||||
493
backend/app/services/blocklist_service.py
Normal file
493
backend/app/services/blocklist_service.py
Normal file
@@ -0,0 +1,493 @@
|
||||
"""Blocklist service.
|
||||
|
||||
Manages blocklist source CRUD, URL preview, IP import (download → validate →
|
||||
ban via fail2ban), and schedule persistence.
|
||||
|
||||
All ban operations target a dedicated fail2ban jail (default:
|
||||
``"blocklist-import"``) so blocklist-origin bans are tracked separately from
|
||||
regular bans. If that jail does not exist or fail2ban is unreachable, the
|
||||
error is recorded in the import log and processing continues.
|
||||
|
||||
Schedule configuration is stored as JSON in the application settings table
|
||||
under the key ``"blocklist_schedule"``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import structlog
|
||||
|
||||
from app.models.blocklist import (
|
||||
BlocklistSource,
|
||||
ImportRunResult,
|
||||
ImportSourceResult,
|
||||
PreviewResponse,
|
||||
ScheduleConfig,
|
||||
ScheduleInfo,
|
||||
)
|
||||
from app.repositories import blocklist_repo, import_log_repo, settings_repo
|
||||
from app.utils.ip_utils import is_valid_ip, is_valid_network
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import aiohttp
|
||||
import aiosqlite
|
||||
|
||||
log: structlog.stdlib.BoundLogger = structlog.get_logger()
|
||||
|
||||
#: Settings key used to persist the schedule config.
|
||||
_SCHEDULE_SETTINGS_KEY: str = "blocklist_schedule"
|
||||
|
||||
#: fail2ban jail name for blocklist-origin bans.
|
||||
BLOCKLIST_JAIL: str = "blocklist-import"
|
||||
|
||||
#: Maximum number of sample entries returned by the preview endpoint.
|
||||
_PREVIEW_LINES: int = 20
|
||||
|
||||
#: Maximum bytes to download for a preview (first 64 KB).
|
||||
_PREVIEW_MAX_BYTES: int = 65536
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Source CRUD helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _row_to_source(row: dict[str, Any]) -> BlocklistSource:
|
||||
"""Convert a repository row dict to a :class:`BlocklistSource`.
|
||||
|
||||
Args:
|
||||
row: Dict with keys matching the ``blocklist_sources`` columns.
|
||||
|
||||
Returns:
|
||||
A validated :class:`~app.models.blocklist.BlocklistSource` instance.
|
||||
"""
|
||||
return BlocklistSource.model_validate(row)
|
||||
|
||||
|
||||
async def list_sources(db: aiosqlite.Connection) -> list[BlocklistSource]:
|
||||
"""Return all configured blocklist sources.
|
||||
|
||||
Args:
|
||||
db: Active application database connection.
|
||||
|
||||
Returns:
|
||||
List of :class:`~app.models.blocklist.BlocklistSource` instances.
|
||||
"""
|
||||
rows = await blocklist_repo.list_sources(db)
|
||||
return [_row_to_source(r) for r in rows]
|
||||
|
||||
|
||||
async def get_source(
|
||||
db: aiosqlite.Connection,
|
||||
source_id: int,
|
||||
) -> BlocklistSource | None:
|
||||
"""Return a single blocklist source, or ``None`` if not found.
|
||||
|
||||
Args:
|
||||
db: Active application database connection.
|
||||
source_id: Primary key of the desired source.
|
||||
|
||||
Returns:
|
||||
:class:`~app.models.blocklist.BlocklistSource` or ``None``.
|
||||
"""
|
||||
row = await blocklist_repo.get_source(db, source_id)
|
||||
return _row_to_source(row) if row is not None else None
|
||||
|
||||
|
||||
async def create_source(
|
||||
db: aiosqlite.Connection,
|
||||
name: str,
|
||||
url: str,
|
||||
*,
|
||||
enabled: bool = True,
|
||||
) -> BlocklistSource:
|
||||
"""Create a new blocklist source and return the persisted record.
|
||||
|
||||
Args:
|
||||
db: Active application database connection.
|
||||
name: Human-readable display name.
|
||||
url: URL of the blocklist text file.
|
||||
enabled: Whether the source is active. Defaults to ``True``.
|
||||
|
||||
Returns:
|
||||
The newly created :class:`~app.models.blocklist.BlocklistSource`.
|
||||
"""
|
||||
new_id = await blocklist_repo.create_source(db, name, url, enabled=enabled)
|
||||
source = await get_source(db, new_id)
|
||||
assert source is not None # noqa: S101
|
||||
log.info("blocklist_source_created", id=new_id, name=name, url=url)
|
||||
return source
|
||||
|
||||
|
||||
async def update_source(
|
||||
db: aiosqlite.Connection,
|
||||
source_id: int,
|
||||
*,
|
||||
name: str | None = None,
|
||||
url: str | None = None,
|
||||
enabled: bool | None = None,
|
||||
) -> BlocklistSource | None:
|
||||
"""Update fields on a blocklist source.
|
||||
|
||||
Args:
|
||||
db: Active application database connection.
|
||||
source_id: Primary key of the source to modify.
|
||||
name: New display name, or ``None`` to leave unchanged.
|
||||
url: New URL, or ``None`` to leave unchanged.
|
||||
enabled: New enabled state, or ``None`` to leave unchanged.
|
||||
|
||||
Returns:
|
||||
Updated :class:`~app.models.blocklist.BlocklistSource`, or ``None``
|
||||
if the source does not exist.
|
||||
"""
|
||||
updated = await blocklist_repo.update_source(
|
||||
db, source_id, name=name, url=url, enabled=enabled
|
||||
)
|
||||
if not updated:
|
||||
return None
|
||||
source = await get_source(db, source_id)
|
||||
log.info("blocklist_source_updated", id=source_id)
|
||||
return source
|
||||
|
||||
|
||||
async def delete_source(db: aiosqlite.Connection, source_id: int) -> bool:
|
||||
"""Delete a blocklist source.
|
||||
|
||||
Args:
|
||||
db: Active application database connection.
|
||||
source_id: Primary key of the source to delete.
|
||||
|
||||
Returns:
|
||||
``True`` if the source was found and deleted, ``False`` otherwise.
|
||||
"""
|
||||
deleted = await blocklist_repo.delete_source(db, source_id)
|
||||
if deleted:
|
||||
log.info("blocklist_source_deleted", id=source_id)
|
||||
return deleted
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Preview
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def preview_source(
|
||||
url: str,
|
||||
http_session: aiohttp.ClientSession,
|
||||
*,
|
||||
sample_lines: int = _PREVIEW_LINES,
|
||||
) -> PreviewResponse:
|
||||
"""Download the beginning of a blocklist URL and return a preview.
|
||||
|
||||
Args:
|
||||
url: URL to download.
|
||||
http_session: Shared :class:`aiohttp.ClientSession`.
|
||||
sample_lines: Maximum number of lines to include in the preview.
|
||||
|
||||
Returns:
|
||||
:class:`~app.models.blocklist.PreviewResponse` with a sample of
|
||||
valid IP entries and validation statistics.
|
||||
|
||||
Raises:
|
||||
ValueError: If the URL cannot be reached or returns a non-200 status.
|
||||
"""
|
||||
try:
|
||||
async with http_session.get(url, timeout=_aiohttp_timeout(10)) as resp:
|
||||
if resp.status != 200:
|
||||
raise ValueError(f"HTTP {resp.status} from {url}")
|
||||
raw = await resp.content.read(_PREVIEW_MAX_BYTES)
|
||||
except Exception as exc:
|
||||
log.warning("blocklist_preview_failed", url=url, error=str(exc))
|
||||
raise ValueError(str(exc)) from exc
|
||||
|
||||
lines = raw.decode(errors="replace").splitlines()
|
||||
entries: list[str] = []
|
||||
valid = 0
|
||||
skipped = 0
|
||||
|
||||
for line in lines:
|
||||
stripped = line.strip()
|
||||
if not stripped or stripped.startswith("#"):
|
||||
continue
|
||||
if is_valid_ip(stripped) or is_valid_network(stripped):
|
||||
valid += 1
|
||||
if len(entries) < sample_lines:
|
||||
entries.append(stripped)
|
||||
else:
|
||||
skipped += 1
|
||||
|
||||
return PreviewResponse(
|
||||
entries=entries,
|
||||
total_lines=len(lines),
|
||||
valid_count=valid,
|
||||
skipped_count=skipped,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Import
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def import_source(
|
||||
source: BlocklistSource,
|
||||
http_session: aiohttp.ClientSession,
|
||||
socket_path: str,
|
||||
db: aiosqlite.Connection,
|
||||
) -> ImportSourceResult:
|
||||
"""Download and apply bans from a single blocklist source.
|
||||
|
||||
The function downloads the URL, validates each line as an IP address,
|
||||
and bans valid IPv4/IPv6 addresses via fail2ban in
|
||||
:data:`BLOCKLIST_JAIL`. CIDR ranges are counted as skipped since
|
||||
fail2ban requires individual addresses. Any error encountered during
|
||||
download is recorded and the result is returned without raising.
|
||||
|
||||
Args:
|
||||
source: The :class:`~app.models.blocklist.BlocklistSource` to import.
|
||||
http_session: Shared :class:`aiohttp.ClientSession`.
|
||||
socket_path: Path to the fail2ban Unix socket.
|
||||
db: Application database for logging.
|
||||
|
||||
Returns:
|
||||
:class:`~app.models.blocklist.ImportSourceResult` with counters.
|
||||
"""
|
||||
# --- Download ---
|
||||
try:
|
||||
async with http_session.get(
|
||||
source.url, timeout=_aiohttp_timeout(30)
|
||||
) as resp:
|
||||
if resp.status != 200:
|
||||
error_msg = f"HTTP {resp.status}"
|
||||
await _log_result(db, source, 0, 0, error_msg)
|
||||
log.warning("blocklist_import_download_failed", url=source.url, status=resp.status)
|
||||
return ImportSourceResult(
|
||||
source_id=source.id,
|
||||
source_url=source.url,
|
||||
ips_imported=0,
|
||||
ips_skipped=0,
|
||||
error=error_msg,
|
||||
)
|
||||
content = await resp.text(errors="replace")
|
||||
except Exception as exc:
|
||||
error_msg = str(exc)
|
||||
await _log_result(db, source, 0, 0, error_msg)
|
||||
log.warning("blocklist_import_download_error", url=source.url, error=error_msg)
|
||||
return ImportSourceResult(
|
||||
source_id=source.id,
|
||||
source_url=source.url,
|
||||
ips_imported=0,
|
||||
ips_skipped=0,
|
||||
error=error_msg,
|
||||
)
|
||||
|
||||
# --- Validate and ban ---
|
||||
imported = 0
|
||||
skipped = 0
|
||||
ban_error: str | None = None
|
||||
|
||||
# Import jail_service here to avoid circular import at module level.
|
||||
from app.services import jail_service # noqa: PLC0415
|
||||
|
||||
for line in content.splitlines():
|
||||
stripped = line.strip()
|
||||
if not stripped or stripped.startswith("#"):
|
||||
continue
|
||||
|
||||
if not is_valid_ip(stripped):
|
||||
# Skip CIDRs and malformed entries gracefully.
|
||||
skipped += 1
|
||||
continue
|
||||
|
||||
try:
|
||||
await jail_service.ban_ip(socket_path, BLOCKLIST_JAIL, stripped)
|
||||
imported += 1
|
||||
except Exception as exc:
|
||||
skipped += 1
|
||||
if ban_error is None:
|
||||
ban_error = str(exc)
|
||||
log.debug("blocklist_ban_failed", ip=stripped, error=str(exc))
|
||||
|
||||
await _log_result(db, source, imported, skipped, ban_error)
|
||||
log.info(
|
||||
"blocklist_source_imported",
|
||||
source_id=source.id,
|
||||
url=source.url,
|
||||
imported=imported,
|
||||
skipped=skipped,
|
||||
error=ban_error,
|
||||
)
|
||||
return ImportSourceResult(
|
||||
source_id=source.id,
|
||||
source_url=source.url,
|
||||
ips_imported=imported,
|
||||
ips_skipped=skipped,
|
||||
error=ban_error,
|
||||
)
|
||||
|
||||
|
||||
async def import_all(
|
||||
db: aiosqlite.Connection,
|
||||
http_session: aiohttp.ClientSession,
|
||||
socket_path: str,
|
||||
) -> ImportRunResult:
|
||||
"""Import all enabled blocklist sources.
|
||||
|
||||
Iterates over every source with ``enabled = True``, calls
|
||||
:func:`import_source` for each, and aggregates the results.
|
||||
|
||||
Args:
|
||||
db: Application database connection.
|
||||
http_session: Shared :class:`aiohttp.ClientSession`.
|
||||
socket_path: fail2ban socket path.
|
||||
|
||||
Returns:
|
||||
:class:`~app.models.blocklist.ImportRunResult` with aggregated
|
||||
counters and per-source results.
|
||||
"""
|
||||
sources = await blocklist_repo.list_enabled_sources(db)
|
||||
results: list[ImportSourceResult] = []
|
||||
total_imported = 0
|
||||
total_skipped = 0
|
||||
errors_count = 0
|
||||
|
||||
for row in sources:
|
||||
source = _row_to_source(row)
|
||||
result = await import_source(source, http_session, socket_path, db)
|
||||
results.append(result)
|
||||
total_imported += result.ips_imported
|
||||
total_skipped += result.ips_skipped
|
||||
if result.error is not None:
|
||||
errors_count += 1
|
||||
|
||||
log.info(
|
||||
"blocklist_import_all_complete",
|
||||
sources=len(sources),
|
||||
total_imported=total_imported,
|
||||
total_skipped=total_skipped,
|
||||
errors=errors_count,
|
||||
)
|
||||
return ImportRunResult(
|
||||
results=results,
|
||||
total_imported=total_imported,
|
||||
total_skipped=total_skipped,
|
||||
errors_count=errors_count,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Schedule
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_DEFAULT_SCHEDULE = ScheduleConfig()
|
||||
|
||||
|
||||
async def get_schedule(db: aiosqlite.Connection) -> ScheduleConfig:
|
||||
"""Read the import schedule config from the settings table.
|
||||
|
||||
Returns the default config (daily at 03:00 UTC) if no schedule has been
|
||||
saved yet.
|
||||
|
||||
Args:
|
||||
db: Active application database connection.
|
||||
|
||||
Returns:
|
||||
The stored (or default) :class:`~app.models.blocklist.ScheduleConfig`.
|
||||
"""
|
||||
raw = await settings_repo.get_setting(db, _SCHEDULE_SETTINGS_KEY)
|
||||
if raw is None:
|
||||
return _DEFAULT_SCHEDULE
|
||||
try:
|
||||
data = json.loads(raw)
|
||||
return ScheduleConfig.model_validate(data)
|
||||
except Exception:
|
||||
log.warning("blocklist_schedule_invalid", raw=raw)
|
||||
return _DEFAULT_SCHEDULE
|
||||
|
||||
|
||||
async def set_schedule(
|
||||
db: aiosqlite.Connection,
|
||||
config: ScheduleConfig,
|
||||
) -> ScheduleConfig:
|
||||
"""Persist a new schedule configuration.
|
||||
|
||||
Args:
|
||||
db: Active application database connection.
|
||||
config: The :class:`~app.models.blocklist.ScheduleConfig` to store.
|
||||
|
||||
Returns:
|
||||
The saved configuration (same object after validation).
|
||||
"""
|
||||
await settings_repo.set_setting(
|
||||
db, _SCHEDULE_SETTINGS_KEY, config.model_dump_json()
|
||||
)
|
||||
log.info("blocklist_schedule_updated", frequency=config.frequency, hour=config.hour)
|
||||
return config
|
||||
|
||||
|
||||
async def get_schedule_info(
|
||||
db: aiosqlite.Connection,
|
||||
next_run_at: str | None,
|
||||
) -> ScheduleInfo:
|
||||
"""Return the schedule config together with last-run metadata.
|
||||
|
||||
Args:
|
||||
db: Active application database connection.
|
||||
next_run_at: ISO 8601 string of the next scheduled run, or ``None``
|
||||
if not yet scheduled (provided by the caller from APScheduler).
|
||||
|
||||
Returns:
|
||||
:class:`~app.models.blocklist.ScheduleInfo` combining config and
|
||||
runtime metadata.
|
||||
"""
|
||||
config = await get_schedule(db)
|
||||
last_log = await import_log_repo.get_last_log(db)
|
||||
last_run_at = last_log["timestamp"] if last_log else None
|
||||
return ScheduleInfo(config=config, next_run_at=next_run_at, last_run_at=last_run_at)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Internal helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _aiohttp_timeout(seconds: float) -> Any:
|
||||
"""Return an :class:`aiohttp.ClientTimeout` with the given total timeout.
|
||||
|
||||
Args:
|
||||
seconds: Total timeout in seconds.
|
||||
|
||||
Returns:
|
||||
An :class:`aiohttp.ClientTimeout` instance.
|
||||
"""
|
||||
import aiohttp # noqa: PLC0415
|
||||
|
||||
return aiohttp.ClientTimeout(total=seconds)
|
||||
|
||||
|
||||
async def _log_result(
|
||||
db: aiosqlite.Connection,
|
||||
source: BlocklistSource,
|
||||
ips_imported: int,
|
||||
ips_skipped: int,
|
||||
error: str | None,
|
||||
) -> None:
|
||||
"""Write an import log entry for a completed source import.
|
||||
|
||||
Args:
|
||||
db: Application database connection.
|
||||
source: The source that was imported.
|
||||
ips_imported: Count of successfully banned IPs.
|
||||
ips_skipped: Count of skipped/invalid entries.
|
||||
error: Error string, or ``None`` on success.
|
||||
"""
|
||||
await import_log_repo.add_log(
|
||||
db,
|
||||
source_id=source.id,
|
||||
source_url=source.url,
|
||||
ips_imported=ips_imported,
|
||||
ips_skipped=ips_skipped,
|
||||
errors=error,
|
||||
)
|
||||
153
backend/app/tasks/blocklist_import.py
Normal file
153
backend/app/tasks/blocklist_import.py
Normal file
@@ -0,0 +1,153 @@
|
||||
"""External blocklist import background task.
|
||||
|
||||
Registers an APScheduler job that downloads all enabled blocklist sources,
|
||||
validates their entries, and applies bans via fail2ban on a configurable
|
||||
schedule. The default schedule is daily at 03:00 UTC; it is stored in the
|
||||
application :class:`~app.models.blocklist.ScheduleConfig` settings and can
|
||||
be updated at runtime through the blocklist router.
|
||||
|
||||
The scheduler job ID is ``"blocklist_import"`` — using a stable id means
|
||||
re-registering the job (e.g. after a schedule update) safely replaces the
|
||||
existing entry without creating duplicates.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import structlog
|
||||
|
||||
from app.models.blocklist import ScheduleFrequency
|
||||
from app.services import blocklist_service
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from fastapi import FastAPI
|
||||
|
||||
log: structlog.stdlib.BoundLogger = structlog.get_logger()
|
||||
|
||||
#: Stable APScheduler job id so the job can be replaced without duplicates.
|
||||
JOB_ID: str = "blocklist_import"
|
||||
|
||||
|
||||
async def _run_import(app: Any) -> None:
|
||||
"""APScheduler callback that imports all enabled blocklist sources.
|
||||
|
||||
Reads shared resources from ``app.state`` and delegates to
|
||||
:func:`~app.services.blocklist_service.import_all`.
|
||||
|
||||
Args:
|
||||
app: The :class:`fastapi.FastAPI` application instance passed via
|
||||
APScheduler ``kwargs``.
|
||||
"""
|
||||
db = app.state.db
|
||||
http_session = app.state.http_session
|
||||
socket_path: str = app.state.settings.fail2ban_socket
|
||||
|
||||
log.info("blocklist_import_starting")
|
||||
try:
|
||||
result = await blocklist_service.import_all(db, http_session, socket_path)
|
||||
log.info(
|
||||
"blocklist_import_finished",
|
||||
total_imported=result.total_imported,
|
||||
total_skipped=result.total_skipped,
|
||||
errors=result.errors_count,
|
||||
)
|
||||
except Exception:
|
||||
log.exception("blocklist_import_unexpected_error")
|
||||
|
||||
|
||||
def register(app: FastAPI) -> None:
|
||||
"""Add (or replace) the blocklist import job in the application scheduler.
|
||||
|
||||
Reads the persisted :class:`~app.models.blocklist.ScheduleConfig` from
|
||||
the database and translates it into the appropriate APScheduler trigger.
|
||||
|
||||
Should be called inside the lifespan handler after the scheduler and
|
||||
database have been initialised.
|
||||
|
||||
Args:
|
||||
app: The :class:`fastapi.FastAPI` application instance whose
|
||||
``app.state.scheduler`` will receive the job.
|
||||
"""
|
||||
import asyncio # noqa: PLC0415
|
||||
|
||||
async def _do_register() -> None:
|
||||
config = await blocklist_service.get_schedule(app.state.db)
|
||||
_apply_schedule(app, config)
|
||||
|
||||
# APScheduler is synchronous at registration time; use asyncio to read
|
||||
# the stored schedule from the DB before registering.
|
||||
try:
|
||||
loop = asyncio.get_event_loop()
|
||||
loop.run_until_complete(_do_register())
|
||||
except RuntimeError:
|
||||
# If the current thread already has a running loop (uvicorn), schedule
|
||||
# the registration as a coroutine.
|
||||
asyncio.ensure_future(_do_register())
|
||||
|
||||
|
||||
def reschedule(app: FastAPI) -> None:
|
||||
"""Re-register the blocklist import job with the latest schedule config.
|
||||
|
||||
Called by the blocklist router after a schedule update so changes take
|
||||
effect immediately without a server restart.
|
||||
|
||||
Args:
|
||||
app: The :class:`fastapi.FastAPI` application instance.
|
||||
"""
|
||||
import asyncio # noqa: PLC0415
|
||||
|
||||
async def _do_reschedule() -> None:
|
||||
config = await blocklist_service.get_schedule(app.state.db)
|
||||
_apply_schedule(app, config)
|
||||
|
||||
asyncio.ensure_future(_do_reschedule())
|
||||
|
||||
|
||||
def _apply_schedule(app: FastAPI, config: Any) -> None: # type: ignore[override]
|
||||
"""Add or replace the APScheduler cron/interval job for the given config.
|
||||
|
||||
Args:
|
||||
app: FastAPI application instance.
|
||||
config: :class:`~app.models.blocklist.ScheduleConfig` to apply.
|
||||
"""
|
||||
scheduler = app.state.scheduler
|
||||
|
||||
kwargs: dict[str, Any] = {"app": app}
|
||||
trigger_type: str
|
||||
trigger_kwargs: dict[str, Any]
|
||||
|
||||
if config.frequency == ScheduleFrequency.hourly:
|
||||
trigger_type = "interval"
|
||||
trigger_kwargs = {"hours": config.interval_hours}
|
||||
elif config.frequency == ScheduleFrequency.weekly:
|
||||
trigger_type = "cron"
|
||||
trigger_kwargs = {
|
||||
"day_of_week": config.day_of_week,
|
||||
"hour": config.hour,
|
||||
"minute": config.minute,
|
||||
}
|
||||
else: # daily (default)
|
||||
trigger_type = "cron"
|
||||
trigger_kwargs = {
|
||||
"hour": config.hour,
|
||||
"minute": config.minute,
|
||||
}
|
||||
|
||||
# Remove existing job if it exists, then add new one.
|
||||
if scheduler.get_job(JOB_ID):
|
||||
scheduler.remove_job(JOB_ID)
|
||||
|
||||
scheduler.add_job(
|
||||
_run_import,
|
||||
trigger=trigger_type,
|
||||
id=JOB_ID,
|
||||
kwargs=kwargs,
|
||||
**trigger_kwargs,
|
||||
)
|
||||
log.info(
|
||||
"blocklist_import_scheduled",
|
||||
frequency=config.frequency,
|
||||
trigger=trigger_type,
|
||||
trigger_kwargs=trigger_kwargs,
|
||||
)
|
||||
Reference in New Issue
Block a user