1102 lines
37 KiB
Python
1102 lines
37 KiB
Python
"""Ban service.
|
|
|
|
Queries the fail2ban SQLite database for ban history. The fail2ban database
|
|
path is obtained at runtime by sending ``get dbfile`` to the fail2ban daemon
|
|
via the Unix domain socket.
|
|
|
|
All database I/O is performed through aiosqlite opened in **read-only** mode
|
|
so BanGUI never modifies or locks the fail2ban database.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import contextlib
|
|
import ipaddress
|
|
from typing import TYPE_CHECKING, Any, cast
|
|
|
|
import aiohttp
|
|
import structlog
|
|
|
|
from app.exceptions import JailNotFoundError, JailOperationError
|
|
from app.models._common import (
|
|
BUCKET_SECONDS,
|
|
BUCKET_SIZE_LABEL,
|
|
TimeRange,
|
|
bucket_count,
|
|
)
|
|
from app.models.ban import (
|
|
BLOCKLIST_JAIL,
|
|
BanOrigin,
|
|
_derive_origin,
|
|
)
|
|
from app.models.ban_domain import (
|
|
DomainActiveBan,
|
|
DomainActiveBanList,
|
|
DomainBansByCountry,
|
|
DomainBansByJail,
|
|
DomainBanTrend,
|
|
DomainBanTrendBucket,
|
|
DomainDashboardBanItem,
|
|
DomainDashboardBanList,
|
|
DomainJailBanCount,
|
|
)
|
|
from app.repositories import fail2ban_db_repo
|
|
from app.repositories import history_archive_repo as default_history_archive_repo
|
|
from app.utils.async_utils import logged_task
|
|
from app.utils.constants import (
|
|
DEFAULT_PAGE_SIZE,
|
|
FAIL2BAN_SOCKET_TIMEOUT,
|
|
)
|
|
from app.utils.fail2ban_client import (
|
|
Fail2BanClient,
|
|
)
|
|
from app.utils.fail2ban_db_utils import parse_data_json, ts_to_iso
|
|
from app.utils.fail2ban_response import (
|
|
is_not_found_error,
|
|
ok,
|
|
to_dict,
|
|
)
|
|
from app.utils.ip_utils import normalise_ip
|
|
from app.utils.time_utils import since_unix
|
|
|
|
if TYPE_CHECKING:
|
|
from collections.abc import Awaitable
|
|
|
|
import aiosqlite
|
|
|
|
from app.models.geo import GeoCacheLookup, GeoEnricher, GeoInfo
|
|
from app.repositories.protocols import HistoryArchiveRepository
|
|
from app.services.geo_cache import GeoCache
|
|
|
|
log: structlog.stdlib.BoundLogger = structlog.get_logger()
|
|
|
|
|
|
async def get_fail2ban_db_path(socket_path: str) -> str:
|
|
"""Return the fail2ban database path using the shared metadata cache."""
|
|
from app.services.fail2ban_metadata_service import ( # noqa: PLC0415
|
|
default_fail2ban_metadata_service,
|
|
)
|
|
|
|
return await default_fail2ban_metadata_service.get_db_path(socket_path)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Constants
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
async def ban_ip(socket_path: str, jail: str, ip: str) -> None:
|
|
"""Ban an IP address in the specified jail.
|
|
|
|
Error contract: ABORT_ON_ERROR. Raises JailNotFoundError or JailOperationError.
|
|
Router converts to HTTP 404 or 409.
|
|
"""
|
|
try:
|
|
ipaddress.ip_address(ip)
|
|
except ValueError as exc:
|
|
raise ValueError(f"Invalid IP address: {ip!r}") from exc
|
|
|
|
normalized = normalise_ip(ip)
|
|
client = Fail2BanClient(socket_path=socket_path, timeout=FAIL2BAN_SOCKET_TIMEOUT)
|
|
|
|
try:
|
|
ok(await client.send(["set", jail, "banip", normalized]))
|
|
except ValueError as exc:
|
|
if is_not_found_error(exc):
|
|
raise JailNotFoundError(jail) from exc
|
|
raise JailOperationError(str(exc)) from exc
|
|
|
|
|
|
async def unban_ip(socket_path: str, ip: str, jail: str | None = None) -> None:
|
|
"""Unban an IP address from a specific jail or all jails."""
|
|
try:
|
|
ipaddress.ip_address(ip)
|
|
except ValueError as exc:
|
|
raise ValueError(f"Invalid IP address: {ip!r}") from exc
|
|
|
|
normalized = normalise_ip(ip)
|
|
client = Fail2BanClient(socket_path=socket_path, timeout=FAIL2BAN_SOCKET_TIMEOUT)
|
|
|
|
if jail is None:
|
|
ok(await client.send(["unban", normalized]))
|
|
return
|
|
|
|
try:
|
|
ok(await client.send(["set", jail, "unbanip", normalized]))
|
|
except ValueError as exc:
|
|
if is_not_found_error(exc):
|
|
raise JailNotFoundError(jail) from exc
|
|
raise JailOperationError(str(exc)) from exc
|
|
|
|
|
|
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 _parse_ban_entry(entry: str, jail: str) -> DomainActiveBan | None:
|
|
"""Parse a ban entry from ``get <jail> banip --with-time`` output."""
|
|
from datetime import UTC, datetime
|
|
|
|
try:
|
|
parts = entry.split("\t", 1)
|
|
ip = parts[0].strip()
|
|
|
|
ipaddress.ip_address(ip)
|
|
|
|
if len(parts) < 2:
|
|
return DomainActiveBan(
|
|
ip=ip,
|
|
jail=jail,
|
|
banned_at=None,
|
|
expires_at=None,
|
|
ban_count=1,
|
|
country=None,
|
|
)
|
|
|
|
time_part = parts[1].strip()
|
|
plus_idx = time_part.find(" + ")
|
|
if plus_idx == -1:
|
|
banned_at_str = time_part.strip()
|
|
expires_at_str: str | None = None
|
|
else:
|
|
banned_at_str = time_part[:plus_idx].strip()
|
|
remainder = time_part[plus_idx + 3 :]
|
|
eq_idx = remainder.find(" = ")
|
|
expires_at_str = remainder[eq_idx + 3 :].strip() if eq_idx != -1 else None
|
|
|
|
_date_fmt = "%Y-%m-%d %H:%M:%S"
|
|
|
|
def _to_iso(ts: str) -> str:
|
|
dt = datetime.strptime(ts, _date_fmt).replace(tzinfo=UTC)
|
|
return dt.isoformat()
|
|
|
|
banned_at_iso: str | None = None
|
|
expires_at_iso: str | None = None
|
|
|
|
with contextlib.suppress(ValueError):
|
|
banned_at_iso = _to_iso(banned_at_str)
|
|
|
|
with contextlib.suppress(ValueError):
|
|
if expires_at_str:
|
|
expires_at_iso = _to_iso(expires_at_str)
|
|
|
|
return DomainActiveBan(
|
|
ip=ip,
|
|
jail=jail,
|
|
banned_at=banned_at_iso,
|
|
expires_at=expires_at_iso,
|
|
ban_count=1,
|
|
country=None,
|
|
)
|
|
except (ValueError, IndexError, AttributeError) as exc:
|
|
log.debug("ban_entry_parse_error", entry=entry, jail=jail, error=str(exc))
|
|
return None
|
|
|
|
|
|
async def _enrich_bans(
|
|
bans: list[DomainActiveBan],
|
|
geo_enricher: GeoEnricher,
|
|
) -> list[DomainActiveBan]:
|
|
"""Enrich ban records with geo data asynchronously."""
|
|
geo_results: list[object | Exception] = await asyncio.gather(
|
|
*[cast("Awaitable[object]", geo_enricher(ban.ip)) for ban in bans],
|
|
return_exceptions=True,
|
|
)
|
|
enriched: list[DomainActiveBan] = []
|
|
for ban, geo in zip(bans, geo_results, strict=False):
|
|
if geo is not None and not isinstance(geo, Exception):
|
|
geo_info = cast("GeoInfo", geo)
|
|
# Create new instance with updated country
|
|
enriched.append(
|
|
DomainActiveBan(
|
|
ip=ban.ip,
|
|
jail=ban.jail,
|
|
banned_at=ban.banned_at,
|
|
expires_at=ban.expires_at,
|
|
ban_count=ban.ban_count,
|
|
country=geo_info.country_code,
|
|
)
|
|
)
|
|
else:
|
|
enriched.append(ban)
|
|
return enriched
|
|
|
|
|
|
async def get_active_bans(
|
|
socket_path: str,
|
|
geo_cache: GeoCache | None = None,
|
|
geo_enricher: GeoEnricher | None = None,
|
|
http_session: aiohttp.ClientSession | None = None,
|
|
app_db: aiosqlite.Connection | None = None,
|
|
) -> DomainActiveBanList:
|
|
"""Return all currently banned IPs across every jail.
|
|
|
|
For each jail the ``get <jail> banip --with-time`` command is used
|
|
to retrieve ban start and expiry times alongside the IP address.
|
|
|
|
Geo enrichment strategy (highest priority first):
|
|
|
|
1. When *http_session* is provided the entire set of banned IPs is resolved
|
|
in a single :meth:`GeoCache.lookup_batch` call (up to
|
|
100 IPs per HTTP request). This is far more efficient than concurrent
|
|
per-IP lookups and stays within ip-api.com rate limits.
|
|
2. When only *geo_enricher* is provided (legacy / test path) each IP is
|
|
resolved individually via the supplied async callable.
|
|
|
|
Args:
|
|
socket_path: Path to the fail2ban Unix domain socket.
|
|
geo_enricher: Optional async callable ``(ip) -> GeoInfo | None``.
|
|
Used to enrich each ban entry with country and ASN data.
|
|
Ignored when *http_session* is provided.
|
|
http_session: Optional shared :class:`aiohttp.ClientSession`. When
|
|
provided, :meth:`GeoCache.lookup_batch` is used
|
|
for efficient bulk geo resolution.
|
|
app_db: Optional BanGUI application database connection used to
|
|
persist newly resolved geo entries across restarts. Only
|
|
meaningful when *http_session* is provided.
|
|
|
|
Returns:
|
|
:class:`~app.models.ban_domain.DomainActiveBanList` with all active bans.
|
|
|
|
Raises:
|
|
~app.utils.fail2ban_client.Fail2BanConnectionError: If the socket
|
|
cannot be reached.
|
|
"""
|
|
|
|
client = Fail2BanClient(socket_path=socket_path, timeout=FAIL2BAN_SOCKET_TIMEOUT)
|
|
|
|
global_status = to_dict(ok(await client.send(["status"])))
|
|
jail_list_raw: str = str(global_status.get("Jail list", "") or "").strip()
|
|
jail_names: list[str] = (
|
|
[j.strip() for j in jail_list_raw.split(",") if j.strip()]
|
|
if jail_list_raw
|
|
else []
|
|
)
|
|
|
|
if not jail_names:
|
|
return DomainActiveBanList(bans=[], total=0)
|
|
|
|
results: list[object | Exception] = await asyncio.gather(
|
|
*[client.send(["get", jn, "banip", "--with-time"]) for jn in jail_names],
|
|
return_exceptions=True,
|
|
)
|
|
|
|
bans: list[DomainActiveBan] = []
|
|
for jail_name, raw_result in zip(jail_names, results, strict=False):
|
|
if isinstance(raw_result, Exception):
|
|
log.warning(
|
|
"active_bans_fetch_error",
|
|
jail=jail_name,
|
|
error=str(raw_result),
|
|
)
|
|
continue
|
|
|
|
try:
|
|
ban_list: list[str] = cast("list[str]", ok(raw_result)) or []
|
|
except (TypeError, ValueError) as exc:
|
|
log.warning(
|
|
"active_bans_parse_error",
|
|
jail=jail_name,
|
|
error=str(exc),
|
|
)
|
|
continue
|
|
|
|
for entry in ban_list:
|
|
ban = _parse_ban_entry(str(entry), jail_name)
|
|
if ban is not None:
|
|
bans.append(ban)
|
|
|
|
if http_session is not None and bans and geo_cache is not None:
|
|
all_ips: list[str] = [ban.ip for ban in bans]
|
|
try:
|
|
geo_map = await geo_cache.lookup_batch(all_ips, http_session, db=app_db)
|
|
except (TimeoutError, aiohttp.ClientError, OSError):
|
|
log.warning("active_bans_batch_geo_failed")
|
|
geo_map = {}
|
|
enriched: list[DomainActiveBan] = []
|
|
for ban in bans:
|
|
geo = geo_map.get(ban.ip)
|
|
if geo is not None:
|
|
enriched.append(ban.model_copy(update={"country": geo.country_code}))
|
|
else:
|
|
enriched.append(ban)
|
|
bans = enriched
|
|
elif geo_enricher is not None:
|
|
bans = await _enrich_bans(bans, geo_enricher)
|
|
|
|
log.info("active_bans_fetched", total=len(bans))
|
|
return DomainActiveBanList(bans=bans, total=len(bans))
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Public API
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
async def list_bans(
|
|
socket_path: str,
|
|
range_: TimeRange,
|
|
*,
|
|
source: str = "fail2ban",
|
|
page: int = 1,
|
|
page_size: int = DEFAULT_PAGE_SIZE,
|
|
max_page_size: int = 500,
|
|
http_session: aiohttp.ClientSession | None = None,
|
|
app_db: aiosqlite.Connection | None = None,
|
|
geo_cache: GeoCache | None = None,
|
|
geo_enricher: GeoEnricher | None = None,
|
|
history_archive_repo: HistoryArchiveRepository = default_history_archive_repo,
|
|
origin: BanOrigin | None = None,
|
|
) -> DomainDashboardBanList:
|
|
"""Return a paginated list of bans within the selected time window.
|
|
|
|
Queries the fail2ban database ``bans`` table for records whose
|
|
``timeofban`` falls within the specified *range_*. Results are ordered
|
|
newest-first.
|
|
|
|
Geo enrichment strategy (highest priority first):
|
|
|
|
1. When *http_session* is provided the entire page of IPs is resolved in
|
|
one :meth:`GeoCache.lookup_batch` call (up to 100 IPs
|
|
per HTTP request). This avoids the 45 req/min rate limit of the
|
|
single-IP endpoint and is the preferred production path.
|
|
2. When only *geo_enricher* is provided (legacy / test path) each IP is
|
|
resolved individually via the supplied async callable.
|
|
|
|
Args:
|
|
socket_path: Path to the fail2ban Unix domain socket.
|
|
range_: Time-range preset (``"24h"``, ``"7d"``, ``"30d"``, or
|
|
``"365d"``).
|
|
page: 1-based page number (default: ``1``).
|
|
page_size: Maximum items per page, capped at ``max_page_size``
|
|
(default: ``100``).
|
|
max_page_size: Deployment-configured maximum page size (default: ``500``).
|
|
http_session: Optional shared :class:`aiohttp.ClientSession`. When
|
|
provided, :meth:`GeoCache.lookup_batch` is used
|
|
for efficient bulk geo resolution.
|
|
app_db: Optional BanGUI application database used to persist newly
|
|
resolved geo entries and to read back cached results.
|
|
geo_enricher: Optional async callable ``(ip: str) -> GeoInfo | None``.
|
|
Used as a fallback when *http_session* is ``None`` (e.g. tests).
|
|
origin: Optional origin filter — ``"blocklist"`` restricts results to
|
|
the ``blocklist-import`` jail, ``"selfblock"`` excludes it.
|
|
|
|
Returns:
|
|
:class:`~app.models.ban_domain.DomainDashboardBanList` containing the
|
|
paginated items and total count.
|
|
"""
|
|
|
|
since: int = since_unix(range_)
|
|
effective_page_size: int = min(page_size, max_page_size)
|
|
offset: int = (page - 1) * effective_page_size
|
|
|
|
if source not in ("fail2ban", "archive"):
|
|
raise ValueError(f"Unsupported source: {source!r}")
|
|
|
|
if source == "archive":
|
|
if app_db is None:
|
|
raise ValueError("app_db must be provided when source is 'archive'")
|
|
|
|
rows, total = await history_archive_repo.get_archived_history(
|
|
db=app_db,
|
|
since=since,
|
|
origin=origin,
|
|
action="ban",
|
|
page=page,
|
|
page_size=effective_page_size,
|
|
)
|
|
else:
|
|
db_path: str = await get_fail2ban_db_path(socket_path)
|
|
log.info(
|
|
"ban_service_list_bans",
|
|
db_path=db_path,
|
|
since=since,
|
|
range=range_,
|
|
origin=origin,
|
|
)
|
|
|
|
rows, total = await fail2ban_db_repo.get_currently_banned(
|
|
db_path=db_path,
|
|
since=since,
|
|
origin=origin,
|
|
limit=effective_page_size,
|
|
offset=offset,
|
|
)
|
|
|
|
# Batch-resolve geo data for all IPs on this page in a single API call.
|
|
# This avoids hitting the 45 req/min single-IP rate limit when the
|
|
# page contains many bans (e.g. after a large blocklist import).
|
|
geo_map: dict[str, GeoInfo] = {}
|
|
if http_session is not None and rows and geo_cache is not None:
|
|
page_ips: list[str] = [r.ip for r in rows]
|
|
try:
|
|
geo_map = await geo_cache.lookup_batch(page_ips, http_session, db=app_db)
|
|
except (TimeoutError, aiohttp.ClientError, OSError):
|
|
log.warning("ban_service_batch_geo_failed_list_bans")
|
|
|
|
items: list[DomainDashboardBanItem] = []
|
|
for row in rows:
|
|
if source == "archive":
|
|
jail = str(row["jail"])
|
|
ip = str(row["ip"])
|
|
banned_at = ts_to_iso(int(row["timeofban"]))
|
|
ban_count = int(row["bancount"])
|
|
matches, _ = parse_data_json(row["data"])
|
|
else:
|
|
jail = row.jail
|
|
ip = row.ip
|
|
banned_at = ts_to_iso(row.timeofban)
|
|
ban_count = row.bancount
|
|
matches, _ = parse_data_json(row.data)
|
|
|
|
service: str | None = matches[0] if matches else None
|
|
|
|
country_code: str | None = None
|
|
country_name: str | None = None
|
|
asn: str | None = None
|
|
org: str | None = None
|
|
|
|
if geo_map:
|
|
geo = geo_map.get(ip)
|
|
if geo is not None:
|
|
country_code = geo.country_code
|
|
country_name = geo.country_name
|
|
asn = geo.asn
|
|
org = geo.org
|
|
elif geo_enricher is not None:
|
|
try:
|
|
geo = await geo_enricher(ip)
|
|
if geo is not None:
|
|
country_code = geo.country_code
|
|
country_name = geo.country_name
|
|
asn = geo.asn
|
|
org = geo.org
|
|
except (TimeoutError, aiohttp.ClientError, OSError):
|
|
log.warning("ban_service_geo_lookup_failed", ip=ip)
|
|
except Exception as exc:
|
|
log.error("ban_service_geo_lookup_unexpected_error", ip=ip, error=type(exc).__name__)
|
|
raise # Bubble programming errors to global handler
|
|
|
|
items.append(
|
|
DomainDashboardBanItem(
|
|
ip=ip,
|
|
jail=jail,
|
|
banned_at=banned_at,
|
|
service=service,
|
|
country_code=country_code,
|
|
country_name=country_name,
|
|
asn=asn,
|
|
org=org,
|
|
ban_count=ban_count,
|
|
origin=_derive_origin(jail),
|
|
)
|
|
)
|
|
|
|
return DomainDashboardBanList(
|
|
items=items,
|
|
total=total,
|
|
page=page,
|
|
page_size=effective_page_size,
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# bans_by_country
|
|
# ---------------------------------------------------------------------------
|
|
|
|
#: Maximum rows returned in the companion table alongside the map.
|
|
_MAX_COMPANION_BANS: int = 200
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# bans_by_country — implementation helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
async def _bans_by_country_load_data(
|
|
*,
|
|
source: str,
|
|
socket_path: str,
|
|
since: int,
|
|
origin: BanOrigin | None,
|
|
history_archive_repo: HistoryArchiveRepository,
|
|
app_db: aiosqlite.Connection | None,
|
|
) -> tuple[dict[str, int], int, list[str]]:
|
|
"""Load per-IP ban counts and total for the requested time window.
|
|
|
|
Returns:
|
|
Tuple of (agg_rows dict mapping ip->event_count, total_ban_count, unique_ip_list).
|
|
"""
|
|
if source == "archive":
|
|
if app_db is None:
|
|
raise ValueError("app_db must be provided when source is 'archive'")
|
|
|
|
ip_counts = await history_archive_repo.get_ip_ban_counts(
|
|
db=app_db,
|
|
since=since,
|
|
origin=origin,
|
|
action="ban",
|
|
)
|
|
|
|
agg_rows = {row["ip"]: int(row["event_count"]) for row in ip_counts}
|
|
total = sum(agg_rows.values())
|
|
unique_ips = list(agg_rows.keys())
|
|
else:
|
|
db_path: str = await get_fail2ban_db_path(socket_path)
|
|
log.info(
|
|
"ban_service_bans_by_country",
|
|
db_path=db_path,
|
|
since=since,
|
|
origin=origin,
|
|
)
|
|
|
|
_, total = await fail2ban_db_repo.get_currently_banned(
|
|
db_path=db_path,
|
|
since=since,
|
|
origin=origin,
|
|
limit=0,
|
|
offset=0,
|
|
)
|
|
|
|
agg_rows_list = await fail2ban_db_repo.get_ban_event_counts(
|
|
db_path=db_path,
|
|
since=since,
|
|
origin=origin,
|
|
)
|
|
|
|
agg_rows = {r.ip: r.event_count for r in agg_rows_list}
|
|
unique_ips = list(agg_rows.keys())
|
|
|
|
return agg_rows, total, unique_ips
|
|
|
|
|
|
async def _bans_by_country_resolve_geo(
|
|
unique_ips: list[str],
|
|
*,
|
|
http_session: aiohttp.ClientSession | None,
|
|
geo_cache_lookup: GeoCacheLookup | None,
|
|
geo_cache: GeoCache | None,
|
|
geo_enricher: GeoEnricher | None,
|
|
app_db: aiosqlite.Connection | None,
|
|
) -> dict[str, GeoInfo]:
|
|
"""Resolve geo information for a list of unique IPs.
|
|
|
|
Uses the geo cache when available; falls back to legacy enricher.
|
|
Uncached IPs are scheduled for background resolution to warm the cache.
|
|
"""
|
|
if not unique_ips:
|
|
return {}
|
|
|
|
geo_map: dict[str, GeoInfo] = {}
|
|
|
|
if http_session is not None and geo_cache_lookup is not None:
|
|
geo_map, uncached = geo_cache_lookup(unique_ips)
|
|
if uncached:
|
|
log.info(
|
|
"ban_service_geo_background_scheduled",
|
|
uncached=len(uncached),
|
|
cached=len(geo_map),
|
|
)
|
|
if geo_cache is not None:
|
|
asyncio.create_task(
|
|
logged_task(
|
|
geo_cache.lookup_batch(uncached, http_session, db=app_db),
|
|
"geo_bans_by_country",
|
|
),
|
|
name="geo_bans_by_country",
|
|
)
|
|
elif geo_enricher is not None:
|
|
async def _safe_lookup(ip: str) -> tuple[str, GeoInfo | None]:
|
|
try:
|
|
return ip, await geo_enricher(ip)
|
|
except (TimeoutError, aiohttp.ClientError, OSError):
|
|
log.warning("ban_service_geo_lookup_failed", ip=ip)
|
|
return ip, None
|
|
except Exception as exc:
|
|
log.error(
|
|
"ban_service_geo_lookup_unexpected_error",
|
|
ip=ip,
|
|
error=type(exc).__name__,
|
|
)
|
|
raise
|
|
|
|
results = await asyncio.gather(*(_safe_lookup(ip) for ip in unique_ips))
|
|
geo_map = {ip: geo for ip, geo in results if geo is not None}
|
|
|
|
return geo_map
|
|
|
|
|
|
async def _bans_by_country_load_companion(
|
|
*,
|
|
source: str,
|
|
country_code: str | None,
|
|
geo_map: dict[str, GeoInfo],
|
|
since: int,
|
|
origin: BanOrigin | None,
|
|
db_path: str | None,
|
|
app_db: aiosqlite.Connection | None,
|
|
history_archive_repo: HistoryArchiveRepository,
|
|
) -> tuple[list[dict[str, Any] | fail2ban_db_repo.BanRecord], list[str]]:
|
|
"""Load companion ban rows and matched IPs for the given country filter.
|
|
|
|
Returns:
|
|
Tuple of (companion_rows, matched_ips_for_country).
|
|
"""
|
|
if country_code is None:
|
|
if source == "archive":
|
|
rows, _ = await history_archive_repo.get_archived_history(
|
|
db=app_db,
|
|
since=since,
|
|
origin=origin,
|
|
action="ban",
|
|
page=1,
|
|
page_size=_MAX_COMPANION_BANS,
|
|
)
|
|
else:
|
|
rows, _ = await fail2ban_db_repo.get_currently_banned(
|
|
db_path=db_path,
|
|
since=since,
|
|
origin=origin,
|
|
limit=_MAX_COMPANION_BANS,
|
|
offset=0,
|
|
)
|
|
return rows, []
|
|
|
|
matched_ips = [
|
|
ip
|
|
for ip, geo in geo_map.items()
|
|
if geo is not None and geo.country_code == country_code
|
|
]
|
|
|
|
if not matched_ips:
|
|
return [], matched_ips
|
|
|
|
if source == "archive":
|
|
rows, _ = await history_archive_repo.get_archived_history(
|
|
db=app_db,
|
|
since=since,
|
|
origin=origin,
|
|
action="ban",
|
|
ip_filter=matched_ips,
|
|
page=1,
|
|
page_size=_MAX_COMPANION_BANS,
|
|
)
|
|
else:
|
|
rows, _ = await fail2ban_db_repo.get_currently_banned(
|
|
db_path=db_path,
|
|
since=since,
|
|
origin=origin,
|
|
ip_filter=matched_ips,
|
|
)
|
|
|
|
return rows, matched_ips
|
|
|
|
|
|
def _bans_by_country_aggregate(
|
|
agg_rows: dict[str, int],
|
|
geo_map: dict[str, GeoInfo],
|
|
source: str,
|
|
) -> tuple[dict[str, int], dict[str, str]]:
|
|
"""Aggregate ban counts by country code.
|
|
|
|
Returns:
|
|
Tuple of (countries dict mapping cc->count, country_names dict mapping cc->name).
|
|
"""
|
|
countries: dict[str, int] = {}
|
|
country_names: dict[str, str] = {}
|
|
|
|
for ip, event_count in agg_rows.items():
|
|
geo = geo_map.get(ip)
|
|
cc: str | None = geo.country_code if geo else None
|
|
cn: str | None = geo.country_name if geo else None
|
|
|
|
if cc:
|
|
countries[cc] = countries.get(cc, 0) + event_count
|
|
if cn and cc not in country_names:
|
|
country_names[cc] = cn
|
|
|
|
return countries, country_names
|
|
|
|
|
|
def _bans_by_country_build_ban_items(
|
|
companion_rows: list[dict[str, Any] | fail2ban_db_repo.BanRecord],
|
|
geo_map: dict[str, GeoInfo],
|
|
source: str,
|
|
) -> list[DomainDashboardBanItem]:
|
|
"""Build DomainDashboardBanItem list from raw companion rows."""
|
|
bans: list[DomainDashboardBanItem] = []
|
|
|
|
for companion_row in companion_rows:
|
|
if source == "archive":
|
|
ip = companion_row["ip"]
|
|
jail = companion_row["jail"]
|
|
banned_at = ts_to_iso(int(companion_row["timeofban"]))
|
|
ban_count = int(companion_row["bancount"])
|
|
service = None
|
|
else:
|
|
ip = companion_row.ip
|
|
jail = companion_row.jail
|
|
banned_at = ts_to_iso(companion_row.timeofban)
|
|
ban_count = companion_row.bancount
|
|
matches, _ = parse_data_json(companion_row.data)
|
|
service = matches[0] if matches else None
|
|
|
|
geo = geo_map.get(ip)
|
|
cc = geo.country_code if geo else None
|
|
cn = geo.country_name if geo else None
|
|
asn: str | None = geo.asn if geo else None
|
|
org: str | None = geo.org if geo else None
|
|
|
|
bans.append(
|
|
DomainDashboardBanItem(
|
|
ip=ip,
|
|
jail=jail,
|
|
banned_at=banned_at,
|
|
service=service,
|
|
country_code=cc,
|
|
country_name=cn,
|
|
asn=asn,
|
|
org=org,
|
|
ban_count=ban_count,
|
|
origin=_derive_origin(jail),
|
|
)
|
|
)
|
|
|
|
return bans
|
|
|
|
|
|
async def bans_by_country(
|
|
socket_path: str,
|
|
range_: TimeRange,
|
|
*,
|
|
source: str = "fail2ban",
|
|
http_session: aiohttp.ClientSession | None = None,
|
|
geo_cache_lookup: GeoCacheLookup | None = None,
|
|
geo_cache: GeoCache | None = None,
|
|
geo_enricher: GeoEnricher | None = None,
|
|
history_archive_repo: HistoryArchiveRepository = default_history_archive_repo,
|
|
app_db: aiosqlite.Connection | None = None,
|
|
origin: BanOrigin | None = None,
|
|
country_code: str | None = None,
|
|
) -> DomainBansByCountry:
|
|
"""Aggregate ban counts per country for the selected time window.
|
|
|
|
Uses a two-step strategy optimised for large datasets:
|
|
|
|
1. Queries the fail2ban DB with ``GROUP BY ip`` to get the per-IP ban
|
|
counts for all unique IPs in the window — no row-count cap.
|
|
2. Serves geo data from the in-memory cache only (non-blocking).
|
|
Any IPs not yet in the cache are scheduled for background resolution
|
|
via :func:`asyncio.create_task` so the response is returned immediately
|
|
and subsequent requests benefit from the warmed cache.
|
|
3. Returns a ``{country_code: count}`` aggregation and the 200 most
|
|
recent raw rows for the companion table.
|
|
|
|
Note:
|
|
On the very first request a large number of IPs may be uncached and
|
|
the country map will be sparse. The background task will resolve them
|
|
and the next request will return a complete map. This trade-off keeps
|
|
the endpoint fast regardless of dataset size.
|
|
|
|
Args:
|
|
socket_path: Path to the fail2ban Unix domain socket.
|
|
range_: Time-range preset.
|
|
http_session: Optional :class:`aiohttp.ClientSession` for background
|
|
geo lookups. When ``None``, only cached data is used.
|
|
geo_enricher: Legacy async ``(ip) -> GeoInfo | None`` callable;
|
|
used when *http_session* is ``None`` (e.g. tests).
|
|
app_db: Optional BanGUI application database used to persist newly
|
|
resolved geo entries across restarts.
|
|
origin: Optional origin filter — ``"blocklist"`` restricts results to
|
|
the ``blocklist-import`` jail, ``"selfblock"`` excludes it.
|
|
|
|
Returns:
|
|
:class:`~app.models.ban_domain.DomainBansByCountry` with per-country
|
|
aggregation and the companion ban list.
|
|
"""
|
|
|
|
since: int = since_unix(range_)
|
|
|
|
if source not in ("fail2ban", "archive"):
|
|
raise ValueError(f"Unsupported source: {source!r}")
|
|
|
|
# Step 1: Load per-IP ban counts and total.
|
|
db_path: str | None = None
|
|
if source == "fail2ban":
|
|
db_path = await get_fail2ban_db_path(socket_path)
|
|
|
|
agg_rows, total, unique_ips = await _bans_by_country_load_data(
|
|
source=source,
|
|
socket_path=socket_path,
|
|
since=since,
|
|
origin=origin,
|
|
history_archive_repo=history_archive_repo,
|
|
app_db=app_db,
|
|
)
|
|
|
|
# Step 2: Resolve geo for unique IPs (from cache or enricher).
|
|
geo_map = await _bans_by_country_resolve_geo(
|
|
unique_ips,
|
|
http_session=http_session,
|
|
geo_cache_lookup=geo_cache_lookup,
|
|
geo_cache=geo_cache,
|
|
geo_enricher=geo_enricher,
|
|
app_db=app_db,
|
|
)
|
|
|
|
# Step 3: Load companion ban rows (filtered by country if provided).
|
|
companion_rows, _ = await _bans_by_country_load_companion(
|
|
source=source,
|
|
country_code=country_code,
|
|
geo_map=geo_map,
|
|
since=since,
|
|
origin=origin,
|
|
db_path=db_path,
|
|
app_db=app_db,
|
|
history_archive_repo=history_archive_repo,
|
|
)
|
|
|
|
# Step 4: Aggregate counts by country.
|
|
countries, country_names = _bans_by_country_aggregate(agg_rows, geo_map, source)
|
|
|
|
# Step 5: Build companion ban items for the response.
|
|
bans = _bans_by_country_build_ban_items(companion_rows, geo_map, source)
|
|
|
|
return DomainBansByCountry(
|
|
countries=countries,
|
|
country_names=country_names,
|
|
items=bans,
|
|
total=total,
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# ban_trend
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
async def ban_trend(
|
|
socket_path: str,
|
|
range_: TimeRange,
|
|
*,
|
|
source: str = "fail2ban",
|
|
history_archive_repo: HistoryArchiveRepository = default_history_archive_repo,
|
|
app_db: aiosqlite.Connection | None = None,
|
|
origin: BanOrigin | None = None,
|
|
) -> DomainBanTrend:
|
|
"""Return ban counts aggregated into equal-width time buckets.
|
|
|
|
Queries the fail2ban database ``bans`` table and groups records by a
|
|
computed bucket index so the frontend can render a continuous time-series
|
|
chart. All buckets within the requested window are returned — buckets
|
|
that contain zero bans are included as zero-count entries so the
|
|
frontend always receives a complete, gap-free series.
|
|
|
|
Bucket sizes per time-range preset:
|
|
|
|
* ``24h`` → 1-hour buckets (24 total)
|
|
* ``7d`` → 6-hour buckets (28 total)
|
|
* ``30d`` → 1-day buckets (30 total)
|
|
* ``365d`` → 7-day buckets (~53 total)
|
|
|
|
Args:
|
|
socket_path: Path to the fail2ban Unix domain socket.
|
|
range_: Time-range preset (``"24h"``, ``"7d"``, ``"30d"``, or
|
|
``"365d"``).
|
|
origin: Optional origin filter — ``"blocklist"`` restricts to the
|
|
``blocklist-import`` jail, ``"selfblock"`` excludes it.
|
|
|
|
Returns:
|
|
:class:`~app.models.ban_domain.DomainBanTrend` with a full bucket list
|
|
and the human-readable bucket-size label.
|
|
"""
|
|
since: int = since_unix(range_)
|
|
bucket_secs: int = BUCKET_SECONDS[range_]
|
|
num_buckets: int = bucket_count(range_)
|
|
|
|
if source not in ("fail2ban", "archive"):
|
|
raise ValueError(f"Unsupported source: {source!r}")
|
|
|
|
if source == "archive":
|
|
if app_db is None:
|
|
raise ValueError("app_db must be provided when source is 'archive'")
|
|
|
|
# SQL aggregation — no row materialisation into Python memory.
|
|
counts = await history_archive_repo.get_ban_counts_by_bucket(
|
|
db=app_db,
|
|
since=since,
|
|
bucket_secs=bucket_secs,
|
|
num_buckets=num_buckets,
|
|
origin=origin,
|
|
action="ban",
|
|
)
|
|
|
|
log.info(
|
|
"ban_service_ban_trend",
|
|
source=source,
|
|
since=since,
|
|
range=range_,
|
|
origin=origin,
|
|
bucket_secs=bucket_secs,
|
|
num_buckets=num_buckets,
|
|
)
|
|
else:
|
|
db_path: str = await get_fail2ban_db_path(socket_path)
|
|
log.info(
|
|
"ban_service_ban_trend",
|
|
db_path=db_path,
|
|
since=since,
|
|
range=range_,
|
|
origin=origin,
|
|
bucket_secs=bucket_secs,
|
|
num_buckets=num_buckets,
|
|
)
|
|
|
|
counts = await fail2ban_db_repo.get_ban_counts_by_bucket(
|
|
db_path=db_path,
|
|
since=since,
|
|
bucket_secs=bucket_secs,
|
|
num_buckets=num_buckets,
|
|
origin=origin,
|
|
)
|
|
|
|
buckets: list[DomainBanTrendBucket] = [
|
|
DomainBanTrendBucket(
|
|
timestamp=ts_to_iso(since + i * bucket_secs),
|
|
count=counts[i],
|
|
)
|
|
for i in range(num_buckets)
|
|
]
|
|
|
|
return DomainBanTrend(
|
|
buckets=buckets,
|
|
bucket_size=BUCKET_SIZE_LABEL[range_],
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# bans_by_jail
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
async def bans_by_jail(
|
|
socket_path: str,
|
|
range_: TimeRange,
|
|
*,
|
|
source: str = "fail2ban",
|
|
history_archive_repo: HistoryArchiveRepository = default_history_archive_repo,
|
|
app_db: aiosqlite.Connection | None = None,
|
|
origin: BanOrigin | None = None,
|
|
) -> DomainBansByJail:
|
|
"""Return ban counts aggregated per jail for the selected time window.
|
|
|
|
Queries the fail2ban database ``bans`` table, groups records by jail
|
|
name, and returns them ordered by count descending. The origin filter
|
|
is applied when provided so callers can restrict results to blocklist-
|
|
imported bans or organic fail2ban bans.
|
|
|
|
Args:
|
|
socket_path: Path to the fail2ban Unix domain socket.
|
|
range_: Time-range preset (``"24h"``, ``"7d"``, ``"30d"``, or
|
|
``"365d"``).
|
|
origin: Optional origin filter — ``"blocklist"`` restricts to the
|
|
``blocklist-import`` jail, ``"selfblock"`` excludes it.
|
|
|
|
Returns:
|
|
:class:`~app.models.ban_domain.DomainBansByJail` with per-jail counts
|
|
sorted descending and the total ban count.
|
|
"""
|
|
since: int = since_unix(range_)
|
|
|
|
if source not in ("fail2ban", "archive"):
|
|
raise ValueError(f"Unsupported source: {source!r}")
|
|
|
|
if source == "archive":
|
|
if app_db is None:
|
|
raise ValueError("app_db must be provided when source is 'archive'")
|
|
|
|
# SQL aggregation — no row materialisation into Python memory.
|
|
total, jail_rows = await history_archive_repo.get_jail_ban_counts(
|
|
db=app_db,
|
|
since=since,
|
|
origin=origin,
|
|
action="ban",
|
|
)
|
|
|
|
jail_counts = [
|
|
DomainJailBanCount(jail=str(row["jail"]), count=int(row["event_count"]))
|
|
for row in jail_rows
|
|
]
|
|
|
|
log.debug(
|
|
"ban_service_bans_by_jail",
|
|
source=source,
|
|
since=since,
|
|
since_iso=ts_to_iso(since),
|
|
range=range_,
|
|
origin=origin,
|
|
)
|
|
else:
|
|
origin_clause, origin_params = _origin_sql_filter(origin)
|
|
|
|
db_path: str = await get_fail2ban_db_path(socket_path)
|
|
log.debug(
|
|
"ban_service_bans_by_jail",
|
|
db_path=db_path,
|
|
since=since,
|
|
since_iso=ts_to_iso(since),
|
|
range=range_,
|
|
origin=origin,
|
|
)
|
|
|
|
total, jail_counts_repo = await fail2ban_db_repo.get_bans_by_jail(
|
|
db_path=db_path,
|
|
since=since,
|
|
origin=origin,
|
|
)
|
|
|
|
# Convert repository models to domain models
|
|
jail_counts = [
|
|
DomainJailBanCount(jail=jc.jail, count=jc.count)
|
|
for jc in jail_counts_repo
|
|
]
|
|
|
|
# Diagnostic guard: if zero results were returned, check whether the table
|
|
# has *any* rows and log a warning with min/max timeofban so operators can
|
|
# diagnose timezone or filter mismatches from logs.
|
|
if total == 0:
|
|
table_row_count, min_timeofban, max_timeofban = await fail2ban_db_repo.get_bans_table_summary(db_path)
|
|
if table_row_count > 0:
|
|
log.warning(
|
|
"ban_service_bans_by_jail_empty_despite_data",
|
|
table_row_count=table_row_count,
|
|
min_timeofban=min_timeofban,
|
|
max_timeofban=max_timeofban,
|
|
since=since,
|
|
range=range_,
|
|
)
|
|
|
|
log.debug(
|
|
"ban_service_bans_by_jail_result",
|
|
total=total,
|
|
jail_count=len(jail_counts),
|
|
)
|
|
|
|
return DomainBansByJail(
|
|
jails=jail_counts,
|
|
total=total,
|
|
)
|