Add origin field and filter for ban sources (Tasks 1 & 2)

- Task 1: Mark imported blocklist IP addresses
  - Add BanOrigin type and _derive_origin() to ban.py model
  - Populate origin field in ban_service list_bans() and bans_by_country()
  - BanTable and MapPage companion table show origin badge column
  - Tests: origin derivation in test_ban_service.py and test_dashboard.py

- Task 2: Add origin filter to dashboard and world map
  - ban_service: _origin_sql_filter() helper; origin param on list_bans()
    and bans_by_country()
  - dashboard router: optional origin query param forwarded to service
  - Frontend: BanOriginFilter type + BAN_ORIGIN_FILTER_LABELS in ban.ts
  - fetchBans / fetchBansByCountry forward origin to API
  - useBans / useMapData accept and pass origin; page resets on change
  - BanTable accepts origin prop; DashboardPage adds segmented filter
  - MapPage adds origin Select next to time-range picker
  - Tests: origin filter assertions in test_ban_service and test_dashboard
This commit is contained in:
2026-03-07 20:03:43 +01:00
parent 706d2e1df8
commit 53d664de4f
28 changed files with 1637 additions and 103 deletions

View File

@@ -18,11 +18,14 @@ import aiosqlite
import structlog
from app.models.ban import (
BLOCKLIST_JAIL,
TIME_RANGE_SECONDS,
BanOrigin,
BansByCountryResponse,
DashboardBanItem,
DashboardBanListResponse,
TimeRange,
_derive_origin,
)
from app.utils.fail2ban_client import Fail2BanClient
@@ -41,6 +44,24 @@ _SOCKET_TIMEOUT: float = 5.0
# ---------------------------------------------------------------------------
def _origin_sql_filter(origin: BanOrigin | None) -> tuple[str, tuple[str, ...]]:
"""Return a SQL fragment and its parameters for the origin filter.
Args:
origin: ``"blocklist"`` to restrict to the blocklist-import jail,
``"selfblock"`` to exclude it, or ``None`` for no restriction.
Returns:
A ``(sql_fragment, params)`` pair — the fragment starts with ``" AND"``
so it can be appended directly to an existing WHERE clause.
"""
if origin == "blocklist":
return " AND jail = ?", (BLOCKLIST_JAIL,)
if origin == "selfblock":
return " AND jail != ?", (BLOCKLIST_JAIL,)
return "", ()
def _since_unix(range_: TimeRange) -> int:
"""Return the Unix timestamp representing the start of the time window.
@@ -148,6 +169,7 @@ async def list_bans(
page: int = 1,
page_size: int = _DEFAULT_PAGE_SIZE,
geo_enricher: Any | None = None,
origin: BanOrigin | None = None,
) -> DashboardBanListResponse:
"""Return a paginated list of bans within the selected time window.
@@ -164,6 +186,8 @@ async def list_bans(
(default: ``100``).
geo_enricher: Optional async callable ``(ip: str) -> GeoInfo | None``.
When supplied every result is enriched with country and ASN data.
origin: Optional origin filter — ``"blocklist"`` restricts results to
the ``blocklist-import`` jail, ``"selfblock"`` excludes it.
Returns:
:class:`~app.models.ban.DashboardBanListResponse` containing the
@@ -172,16 +196,23 @@ async def list_bans(
since: int = _since_unix(range_)
effective_page_size: int = min(page_size, _MAX_PAGE_SIZE)
offset: int = (page - 1) * effective_page_size
origin_clause, origin_params = _origin_sql_filter(origin)
db_path: str = await _get_fail2ban_db_path(socket_path)
log.info("ban_service_list_bans", db_path=db_path, since=since, range=range_)
log.info(
"ban_service_list_bans",
db_path=db_path,
since=since,
range=range_,
origin=origin,
)
async with aiosqlite.connect(f"file:{db_path}?mode=ro", uri=True) as f2b_db:
f2b_db.row_factory = aiosqlite.Row
async with f2b_db.execute(
"SELECT COUNT(*) FROM bans WHERE timeofban >= ?",
(since,),
"SELECT COUNT(*) FROM bans WHERE timeofban >= ?" + origin_clause,
(since, *origin_params),
) as cur:
count_row = await cur.fetchone()
total: int = int(count_row[0]) if count_row else 0
@@ -189,10 +220,11 @@ async def list_bans(
async with f2b_db.execute(
"SELECT jail, ip, timeofban, bancount, data "
"FROM bans "
"WHERE timeofban >= ? "
"ORDER BY timeofban DESC "
"WHERE timeofban >= ?"
+ origin_clause
+ " ORDER BY timeofban DESC "
"LIMIT ? OFFSET ?",
(since, effective_page_size, offset),
(since, *origin_params, effective_page_size, offset),
) as cur:
rows = await cur.fetchall()
@@ -232,6 +264,7 @@ async def list_bans(
asn=asn,
org=org,
ban_count=ban_count,
origin=_derive_origin(jail),
)
)
@@ -255,6 +288,7 @@ async def bans_by_country(
socket_path: str,
range_: TimeRange,
geo_enricher: Any | None = None,
origin: BanOrigin | None = None,
) -> BansByCountryResponse:
"""Aggregate ban counts per country for the selected time window.
@@ -266,6 +300,8 @@ async def bans_by_country(
socket_path: Path to the fail2ban Unix domain socket.
range_: Time-range preset.
geo_enricher: Optional async ``(ip) -> GeoInfo | None`` callable.
origin: Optional origin filter — ``"blocklist"`` restricts results to
the ``blocklist-import`` jail, ``"selfblock"`` excludes it.
Returns:
:class:`~app.models.ban.BansByCountryResponse` with per-country
@@ -274,15 +310,22 @@ async def bans_by_country(
import asyncio
since: int = _since_unix(range_)
origin_clause, origin_params = _origin_sql_filter(origin)
db_path: str = await _get_fail2ban_db_path(socket_path)
log.info("ban_service_bans_by_country", db_path=db_path, since=since, range=range_)
log.info(
"ban_service_bans_by_country",
db_path=db_path,
since=since,
range=range_,
origin=origin,
)
async with aiosqlite.connect(f"file:{db_path}?mode=ro", uri=True) as f2b_db:
f2b_db.row_factory = aiosqlite.Row
async with f2b_db.execute(
"SELECT COUNT(*) FROM bans WHERE timeofban >= ?",
(since,),
"SELECT COUNT(*) FROM bans WHERE timeofban >= ?" + origin_clause,
(since, *origin_params),
) as cur:
count_row = await cur.fetchone()
total: int = int(count_row[0]) if count_row else 0
@@ -290,10 +333,11 @@ async def bans_by_country(
async with f2b_db.execute(
"SELECT jail, ip, timeofban, bancount, data "
"FROM bans "
"WHERE timeofban >= ? "
"ORDER BY timeofban DESC "
"WHERE timeofban >= ?"
+ origin_clause
+ " ORDER BY timeofban DESC "
"LIMIT ?",
(since, _MAX_GEO_BANS),
(since, *origin_params, _MAX_GEO_BANS),
) as cur:
rows = await cur.fetchall()
@@ -336,6 +380,7 @@ async def bans_by_country(
asn=asn,
org=org,
ban_count=int(row["bancount"]),
origin=_derive_origin(str(row["jail"])),
)
)

View File

@@ -16,10 +16,13 @@ import asyncio
import contextlib
import re
from pathlib import Path
from typing import Any
from typing import TYPE_CHECKING, Any
import structlog
if TYPE_CHECKING:
import aiosqlite
from app.models.config import (
AddLogPathRequest,
GlobalConfigResponse,
@@ -31,9 +34,12 @@ from app.models.config import (
LogPreviewLine,
LogPreviewRequest,
LogPreviewResponse,
MapColorThresholdsResponse,
MapColorThresholdsUpdate,
RegexTestRequest,
RegexTestResponse,
)
from app.services import setup_service
from app.utils.fail2ban_client import Fail2BanClient
log: structlog.stdlib.BoundLogger = structlog.get_logger()
@@ -609,3 +615,46 @@ def _read_tail_lines(file_path: str, num_lines: int) -> list[str]:
if pos > 0 and len(raw_lines) > 1:
raw_lines = raw_lines[1:]
return [ln.decode("utf-8", errors="replace").rstrip() for ln in raw_lines[-num_lines:] if ln.strip()]
# ---------------------------------------------------------------------------
# Map color thresholds
# ---------------------------------------------------------------------------
async def get_map_color_thresholds(db: aiosqlite.Connection) -> MapColorThresholdsResponse:
"""Retrieve the current map color threshold configuration.
Args:
db: Active aiosqlite connection to the application database.
Returns:
A :class:`MapColorThresholdsResponse` containing the three threshold values.
"""
high, medium, low = await setup_service.get_map_color_thresholds(db)
return MapColorThresholdsResponse(
threshold_high=high,
threshold_medium=medium,
threshold_low=low,
)
async def update_map_color_thresholds(
db: aiosqlite.Connection,
update: MapColorThresholdsUpdate,
) -> None:
"""Update the map color threshold configuration.
Args:
db: Active aiosqlite connection to the application database.
update: The new threshold values.
Raises:
ValueError: If validation fails (thresholds must satisfy high > medium > low).
"""
await setup_service.set_map_color_thresholds(
db,
threshold_high=update.threshold_high,
threshold_medium=update.threshold_medium,
threshold_low=update.threshold_low,
)