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:
@@ -48,6 +48,26 @@ class UnbanRequest(BaseModel):
|
||||
)
|
||||
|
||||
|
||||
#: Discriminator literal for the origin of a ban.
|
||||
BanOrigin = Literal["blocklist", "selfblock"]
|
||||
|
||||
#: Jail name used by the blocklist import service.
|
||||
BLOCKLIST_JAIL: str = "blocklist-import"
|
||||
|
||||
|
||||
def _derive_origin(jail: str) -> BanOrigin:
|
||||
"""Derive the ban origin from the jail name.
|
||||
|
||||
Args:
|
||||
jail: The jail that issued the ban.
|
||||
|
||||
Returns:
|
||||
``"blocklist"`` when the jail is the dedicated blocklist-import
|
||||
jail, ``"selfblock"`` otherwise.
|
||||
"""
|
||||
return "blocklist" if jail == BLOCKLIST_JAIL else "selfblock"
|
||||
|
||||
|
||||
class Ban(BaseModel):
|
||||
"""Domain model representing a single active or historical ban record."""
|
||||
|
||||
@@ -65,6 +85,10 @@ class Ban(BaseModel):
|
||||
default=None,
|
||||
description="ISO 3166-1 alpha-2 country code resolved from the IP.",
|
||||
)
|
||||
origin: BanOrigin = Field(
|
||||
...,
|
||||
description="Whether this ban came from a blocklist import or fail2ban itself.",
|
||||
)
|
||||
|
||||
|
||||
class BanResponse(BaseModel):
|
||||
@@ -146,6 +170,10 @@ class DashboardBanItem(BaseModel):
|
||||
description="Organisation name associated with the IP.",
|
||||
)
|
||||
ban_count: int = Field(..., ge=1, description="How many times this IP was banned.")
|
||||
origin: BanOrigin = Field(
|
||||
...,
|
||||
description="Whether this ban came from a blocklist import or fail2ban itself.",
|
||||
)
|
||||
|
||||
|
||||
class DashboardBanListResponse(BaseModel):
|
||||
|
||||
@@ -169,3 +169,36 @@ class LogPreviewResponse(BaseModel):
|
||||
total_lines: int = Field(..., ge=0)
|
||||
matched_count: int = Field(..., ge=0)
|
||||
regex_error: str | None = Field(default=None, description="Set if the regex failed to compile.")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Map color threshold models
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class MapColorThresholdsResponse(BaseModel):
|
||||
"""Response for ``GET /api/config/map-thresholds``."""
|
||||
|
||||
model_config = ConfigDict(strict=True)
|
||||
|
||||
threshold_high: int = Field(
|
||||
..., description="Ban count for red coloring."
|
||||
)
|
||||
threshold_medium: int = Field(
|
||||
..., description="Ban count for yellow coloring."
|
||||
)
|
||||
threshold_low: int = Field(
|
||||
..., description="Ban count for green coloring."
|
||||
)
|
||||
|
||||
|
||||
class MapColorThresholdsUpdate(BaseModel):
|
||||
"""Payload for ``PUT /api/config/map-thresholds``."""
|
||||
|
||||
model_config = ConfigDict(strict=True)
|
||||
|
||||
threshold_high: int = Field(..., gt=0, description="Ban count for red.")
|
||||
threshold_medium: int = Field(
|
||||
..., gt=0, description="Ban count for yellow."
|
||||
)
|
||||
threshold_low: int = Field(..., gt=0, description="Ban count for green.")
|
||||
|
||||
@@ -30,6 +30,8 @@ from app.models.config import (
|
||||
JailConfigUpdate,
|
||||
LogPreviewRequest,
|
||||
LogPreviewResponse,
|
||||
MapColorThresholdsResponse,
|
||||
MapColorThresholdsUpdate,
|
||||
RegexTestRequest,
|
||||
RegexTestResponse,
|
||||
)
|
||||
@@ -380,3 +382,83 @@ async def preview_log(
|
||||
:class:`~app.models.config.LogPreviewResponse` with per-line results.
|
||||
"""
|
||||
return await config_service.preview_log(body)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Map color thresholds
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.get(
|
||||
"/map-color-thresholds",
|
||||
response_model=MapColorThresholdsResponse,
|
||||
summary="Get map color threshold configuration",
|
||||
)
|
||||
async def get_map_color_thresholds(
|
||||
request: Request,
|
||||
_auth: AuthDep,
|
||||
) -> MapColorThresholdsResponse:
|
||||
"""Return the configured map color thresholds.
|
||||
|
||||
Args:
|
||||
request: FastAPI request object.
|
||||
_auth: Validated session.
|
||||
|
||||
Returns:
|
||||
:class:`~app.models.config.MapColorThresholdsResponse` with
|
||||
current thresholds.
|
||||
"""
|
||||
from app.services import setup_service
|
||||
|
||||
high, medium, low = await setup_service.get_map_color_thresholds(
|
||||
request.app.state.db
|
||||
)
|
||||
return MapColorThresholdsResponse(
|
||||
threshold_high=high,
|
||||
threshold_medium=medium,
|
||||
threshold_low=low,
|
||||
)
|
||||
|
||||
|
||||
@router.put(
|
||||
"/map-color-thresholds",
|
||||
response_model=MapColorThresholdsResponse,
|
||||
summary="Update map color threshold configuration",
|
||||
)
|
||||
async def update_map_color_thresholds(
|
||||
request: Request,
|
||||
_auth: AuthDep,
|
||||
body: MapColorThresholdsUpdate,
|
||||
) -> MapColorThresholdsResponse:
|
||||
"""Update the map color threshold configuration.
|
||||
|
||||
Args:
|
||||
request: FastAPI request object.
|
||||
_auth: Validated session.
|
||||
body: New threshold values.
|
||||
|
||||
Returns:
|
||||
:class:`~app.models.config.MapColorThresholdsResponse` with
|
||||
updated thresholds.
|
||||
|
||||
Raises:
|
||||
HTTPException: 400 if validation fails (thresholds not
|
||||
properly ordered).
|
||||
"""
|
||||
from app.services import setup_service
|
||||
|
||||
try:
|
||||
await setup_service.set_map_color_thresholds(
|
||||
request.app.state.db,
|
||||
threshold_high=body.threshold_high,
|
||||
threshold_medium=body.threshold_medium,
|
||||
threshold_low=body.threshold_low,
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise _bad_request(str(exc)) from exc
|
||||
|
||||
return MapColorThresholdsResponse(
|
||||
threshold_high=body.threshold_high,
|
||||
threshold_medium=body.threshold_medium,
|
||||
threshold_low=body.threshold_low,
|
||||
)
|
||||
|
||||
@@ -18,6 +18,7 @@ from fastapi import APIRouter, Query, Request
|
||||
|
||||
from app.dependencies import AuthDep
|
||||
from app.models.ban import (
|
||||
BanOrigin,
|
||||
BansByCountryResponse,
|
||||
DashboardBanListResponse,
|
||||
TimeRange,
|
||||
@@ -77,6 +78,10 @@ async def get_dashboard_bans(
|
||||
range: TimeRange = Query(default=_DEFAULT_RANGE, description="Time-range preset."),
|
||||
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."),
|
||||
origin: BanOrigin | None = Query(
|
||||
default=None,
|
||||
description="Filter by ban origin: 'blocklist' or 'selfblock'. Omit for all.",
|
||||
),
|
||||
) -> DashboardBanListResponse:
|
||||
"""Return a paginated list of bans within the selected time window.
|
||||
|
||||
@@ -91,6 +96,7 @@ async def get_dashboard_bans(
|
||||
``"365d"``.
|
||||
page: 1-based page number.
|
||||
page_size: Maximum items per page (1–500).
|
||||
origin: Optional filter by ban origin.
|
||||
|
||||
Returns:
|
||||
:class:`~app.models.ban.DashboardBanListResponse` with paginated
|
||||
@@ -108,6 +114,7 @@ async def get_dashboard_bans(
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
geo_enricher=_enricher,
|
||||
origin=origin,
|
||||
)
|
||||
|
||||
|
||||
@@ -120,6 +127,10 @@ async def get_bans_by_country(
|
||||
request: Request,
|
||||
_auth: AuthDep,
|
||||
range: TimeRange = Query(default=_DEFAULT_RANGE, description="Time-range preset."),
|
||||
origin: BanOrigin | None = Query(
|
||||
default=None,
|
||||
description="Filter by ban origin: 'blocklist' or 'selfblock'. Omit for all.",
|
||||
),
|
||||
) -> BansByCountryResponse:
|
||||
"""Return ban counts aggregated by ISO country code.
|
||||
|
||||
@@ -131,6 +142,7 @@ async def get_bans_by_country(
|
||||
request: The incoming request.
|
||||
_auth: Validated session dependency.
|
||||
range: Time-range preset.
|
||||
origin: Optional filter by ban origin.
|
||||
|
||||
Returns:
|
||||
:class:`~app.models.ban.BansByCountryResponse` with per-country
|
||||
@@ -146,5 +158,6 @@ async def get_bans_by_country(
|
||||
socket_path,
|
||||
range,
|
||||
geo_enricher=_enricher,
|
||||
origin=origin,
|
||||
)
|
||||
|
||||
|
||||
@@ -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"])),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user