Fix HIGH priority issues: unbounded queries, rate limiting, health checks

Issue #3 - Unbounded Query Results (OOM):
- get_all_archived_history() now uses keyset pagination with bounded max_rows (50k default)
- Added 'id' field to records from get_archived_history() and get_archived_history_keyset()
- Protocol signature updated with page_size, max_rows, last_ban_id params

Issue #7 - Docker Health Check Fails:
- Added curl to Dockerfile.backend runtime image
- HEALTHCHECK now uses 'curl -f http://localhost:8000/api/health'
- compose.prod.yml: increased start_period to 40s, timeout to 10s
- Frontend healthcheck proxies to backend /api/health

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
2026-05-01 21:47:36 +02:00
parent 1830da496d
commit 0d5882b32f
39 changed files with 2067 additions and 339 deletions

View File

@@ -23,15 +23,17 @@ from typing import TYPE_CHECKING, cast
import structlog
from app.exceptions import JailNotFoundError, JailOperationError
from app.models.ban import ActiveBan, JailBannedIpsResponse
from app.models.ban_domain import DomainActiveBan
from app.models.config import BantimeEscalation
from app.models.geo import GeoDetail, IpLookupResponse
from app.models.jail import (
Jail,
JailDetailResponse,
JailListResponse,
JailStatus,
JailSummary,
from app.models.jail_domain import (
DomainJailBannedIps,
DomainBantimeEscalation,
DomainJail,
DomainJailDetail,
DomainJailList,
DomainJailStatus,
DomainJailSummary,
)
from app.utils.config_file_utils import start_daemon, wait_for_fail2ban
from app.utils.constants import FAIL2BAN_SOCKET_TIMEOUT
@@ -174,7 +176,7 @@ async def _check_backend_cmd_supported(
# ---------------------------------------------------------------------------
async def list_jails(socket_path: str, state: JailServiceState) -> JailListResponse:
async def list_jails(socket_path: str, state: JailServiceState) -> DomainJailList:
"""Return a summary list of all active fail2ban jails.
Queries the daemon for the global jail list and then fetches status
@@ -185,7 +187,7 @@ async def list_jails(socket_path: str, state: JailServiceState) -> JailListRespo
state: The jail service state holder for capability cache.
Returns:
:class:`~app.models.jail.JailListResponse` with all active jails.
:class:`~app.models.jail_domain.DomainJailList` with all active jails.
Raises:
~app.utils.fail2ban_client.Fail2BanConnectionError: If the socket
@@ -205,23 +207,23 @@ async def list_jails(socket_path: str, state: JailServiceState) -> JailListRespo
log.info("jail_list_fetched", count=len(jail_names))
if not jail_names:
return JailListResponse(items=[], total=0)
return DomainJailList(items=[], total=0)
# 2. Fetch summary data for every jail in parallel.
summaries: list[JailSummary] = await asyncio.gather(
summaries: list[DomainJailSummary] = await asyncio.gather(
*[_fetch_jail_summary(client, name, state) for name in jail_names],
return_exceptions=False,
)
return JailListResponse(items=list(summaries), total=len(summaries))
return DomainJailList(items=list(summaries), total=len(summaries))
async def _fetch_jail_summary(
client: Fail2BanClient,
name: str,
state: JailServiceState,
) -> JailSummary:
"""Fetch and build a :class:`~app.models.jail.JailSummary` for one jail.
) -> DomainJailSummary:
"""Fetch and build a :class:`~app.models.jail_domain.DomainJailSummary` for one jail.
Sends the ``status``, ``get ... bantime``, ``findtime``, ``maxretry``,
``backend``, and ``idle`` commands in parallel (if supported).
@@ -236,7 +238,7 @@ async def _fetch_jail_summary(
state: The jail service state holder for capability cache.
Returns:
A :class:`~app.models.jail.JailSummary` populated from the responses.
A :class:`~app.models.jail_domain.DomainJailSummary` populated from the responses.
"""
# Check whether optional backend/idle commands are supported.
# This probe happens once per session and is cached to avoid repeated
@@ -276,13 +278,13 @@ async def _fetch_jail_summary(
idle_raw: object | Exception = _r[5]
# Parse jail status (filter + actions).
jail_status: JailStatus | None = None
jail_status: DomainJailStatus | None = None
if not isinstance(status_raw, Exception):
try:
raw = to_dict(ok(status_raw))
filter_stats = to_dict(raw.get("Filter") or [])
action_stats = to_dict(raw.get("Actions") or [])
jail_status = JailStatus(
jail_status = DomainJailStatus(
currently_banned=int(str(action_stats.get("Currently banned", 0) or 0)),
total_banned=int(str(action_stats.get("Total banned", 0) or 0)),
currently_failed=int(str(filter_stats.get("Currently failed", 0) or 0)),
@@ -315,7 +317,7 @@ async def _fetch_jail_summary(
except (ValueError, TypeError):
return fallback
return JailSummary(
return DomainJailSummary(
name=name,
enabled=True,
running=True,
@@ -328,7 +330,7 @@ async def _fetch_jail_summary(
)
async def get_jail(socket_path: str, name: str) -> JailDetailResponse:
async def get_jail(socket_path: str, name: str) -> DomainJailDetail:
"""Return full detail for a single fail2ban jail.
Sends multiple ``get`` and ``status`` commands in parallel to build
@@ -339,7 +341,7 @@ async def get_jail(socket_path: str, name: str) -> JailDetailResponse:
name: Jail name.
Returns:
:class:`~app.models.jail.JailDetailResponse` with the full jail.
:class:`~app.models.jail_domain.DomainJailDetail` with the full jail.
Raises:
JailNotFoundError: If *name* is not a known jail.
@@ -360,7 +362,7 @@ async def get_jail(socket_path: str, name: str) -> JailDetailResponse:
filter_stats = to_dict(raw.get("Filter") or [])
action_stats = to_dict(raw.get("Actions") or [])
jail_status = JailStatus(
jail_status = DomainJailStatus(
currently_banned=int(str(action_stats.get("Currently banned", 0) or 0)),
total_banned=int(str(action_stats.get("Total banned", 0) or 0)),
currently_failed=int(str(filter_stats.get("Currently failed", 0) or 0)),
@@ -411,7 +413,7 @@ async def get_jail(socket_path: str, name: str) -> JailDetailResponse:
)
bt_increment: bool = bool(bt_increment_raw)
bantime_escalation = BantimeEscalation(
bantime_escalation = DomainBantimeEscalation(
increment=bt_increment,
factor=float(str(bt_factor_raw)) if bt_factor_raw is not None else None,
formula=str(bt_formula_raw) if bt_formula_raw else None,
@@ -421,7 +423,7 @@ async def get_jail(socket_path: str, name: str) -> JailDetailResponse:
overall_jails=bool(bt_overalljails_raw),
)
jail = Jail(
jail = DomainJail(
name=name,
enabled=True,
running=True,
@@ -442,7 +444,7 @@ async def get_jail(socket_path: str, name: str) -> JailDetailResponse:
)
log.info("jail_detail_fetched", jail=name)
return JailDetailResponse(jail=jail)
return DomainJailDetail(jail=jail, ignore_list=[], ignore_self=False)
# ---------------------------------------------------------------------------
@@ -630,7 +632,7 @@ async def restart_daemon(
def _parse_ban_entry(entry: str, jail: str) -> ActiveBan | None:
def _parse_ban_entry(entry: str, jail: str) -> DomainActiveBan | None:
"""Parse a ban entry from ``get <jail> banip --with-time`` output.
Expected format::
@@ -642,7 +644,7 @@ def _parse_ban_entry(entry: str, jail: str) -> ActiveBan | None:
jail: Jail name for the resulting record.
Returns:
An :class:`~app.models.ban.ActiveBan` or ``None`` if parsing fails.
A :class:`~app.models.jail_domain.DomainActiveBan` or ``None`` if parsing fails.
"""
from datetime import UTC, datetime
@@ -655,7 +657,7 @@ def _parse_ban_entry(entry: str, jail: str) -> ActiveBan | None:
if len(parts) < 2:
# Entry has no time info — return with unknown times.
return ActiveBan(
return DomainActiveBan(
ip=ip,
jail=jail,
banned_at=None,
@@ -693,7 +695,7 @@ def _parse_ban_entry(entry: str, jail: str) -> ActiveBan | None:
if expires_at_str:
expires_at_iso = _to_iso(expires_at_str)
return ActiveBan(
return DomainActiveBan(
ip=ip,
jail=jail,
banned_at=banned_at_iso,
@@ -720,7 +722,7 @@ async def get_jail_banned_ips(
geo_cache: GeoCache | None = None,
http_session: aiohttp.ClientSession | None = None,
app_db: aiosqlite.Connection | None = None,
) -> JailBannedIpsResponse:
) -> DomainJailBannedIps:
"""Return a paginated list of currently banned IPs for a single jail.
Fetches the full ban list from the fail2ban socket, applies an optional
@@ -738,7 +740,7 @@ async def get_jail_banned_ips(
app_db: Optional BanGUI application database for persistent geo cache.
Returns:
:class:`~app.models.ban.JailBannedIpsResponse` with the paginated bans.
:class:`~app.models.jail_domain.DomainJailBannedIps` with the paginated bans.
Raises:
JailNotFoundError: If *jail_name* is not a known active jail.
@@ -767,7 +769,7 @@ async def get_jail_banned_ips(
ban_list: list[str] = cast("list[str]", raw_result) or []
# Parse all entries.
all_bans: list[ActiveBan] = []
all_bans: list[DomainActiveBan] = []
for entry in ban_list:
ban = _parse_ban_entry(str(entry), jail_name)
if ban is not None:
@@ -792,11 +794,20 @@ async def get_jail_banned_ips(
except Exception: # noqa: BLE001
log.warning("jail_banned_ips_geo_failed", jail=jail_name)
geo_map = {}
enriched_page: list[ActiveBan] = []
enriched_page: list[DomainActiveBan] = []
for ban in page_bans:
geo = geo_map.get(ban.ip)
if geo is not None:
enriched_page.append(ban.model_copy(update={"country": geo.country_code}))
enriched_page.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.country_code,
)
)
else:
enriched_page.append(ban)
page_bans = enriched_page
@@ -808,20 +819,22 @@ async def get_jail_banned_ips(
page=page,
page_size=page_size,
)
return JailBannedIpsResponse(
return DomainJailBannedIps(
items=page_bans,
pagination=create_pagination_metadata(total, page, page_size),
total=total,
page=page,
page_size=page_size,
)
async def _enrich_bans(
bans: list[ActiveBan],
bans: list[DomainActiveBan],
geo_enricher: GeoEnricher,
) -> list[ActiveBan]:
) -> list[DomainActiveBan]:
"""Enrich ban records with geo data asynchronously.
Args:
bans: The list of :class:`~app.models.ban.ActiveBan` records to enrich.
bans: The list of :class:`~app.models.jail_domain.DomainActiveBan` records to enrich.
geo_enricher: Async callable ``(ip) → GeoInfo | None``.
Returns:
@@ -831,11 +844,20 @@ async def _enrich_bans(
*[cast("Awaitable[object]", geo_enricher(ban.ip)) for ban in bans],
return_exceptions=True,
)
enriched: list[ActiveBan] = []
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)
enriched.append(ban.model_copy(update={"country": geo_info.country_code}))
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