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

@@ -34,6 +34,7 @@ from app.dependencies import (
SettingsDep,
)
from app.exceptions import BadRequestError, BlocklistSourceNotFoundError
from app.mappers import blocklist_mappers
from app.models.blocklist import (
BlocklistListResponse,
BlocklistSource,
@@ -370,6 +371,7 @@ async def preview_blocklist(
raise BlocklistSourceNotFoundError(source_id)
try:
return await blocklist_service.preview_source(source.url, http_session)
domain_result = await blocklist_service.preview_source(source.url, http_session)
return blocklist_mappers.map_domain_preview_result_to_response(domain_result)
except ValueError as exc:
raise BadRequestError(f"Could not fetch blocklist: {exc}") from exc

View File

@@ -27,6 +27,7 @@ from app.models.config import (
RegexTestResponse,
ServiceStatusResponse,
)
from app.mappers import config_mappers
from app.services import (
config_service,
jail_service,
@@ -94,7 +95,8 @@ async def get_global_config(
Raises:
HTTPException: 502 when fail2ban is unreachable.
"""
return await config_service.get_global_config(socket_path)
domain_result = await config_service.get_global_config(socket_path)
return config_mappers.map_domain_global_config_to_response(domain_result)
@router.put(
@@ -400,7 +402,8 @@ async def get_service_status(
"""
from app.services import health_service
return await health_service.get_service_status(
domain_result = await health_service.get_service_status(
socket_path,
probe_fn=health_service.probe,
)
return config_mappers.map_domain_service_status_to_response(domain_result)

View File

@@ -5,6 +5,7 @@ from typing import Annotated
from fastapi import APIRouter, Path, Query, Request, status
from app.dependencies import AuthDep, Fail2BanConfigDirDep, Fail2BanSocketDep
from app.mappers import config_mappers
from app.models.config import (
FilterConfig,
FilterCreateRequest,
@@ -50,10 +51,10 @@ async def list_filters(
:class:`~app.models.config.FilterListResponse` with all discovered
filters.
"""
result = await filter_config_service.list_filters(config_dir, socket_path)
domain_result = await filter_config_service.list_filters(config_dir, socket_path)
# Sort: active first (by name), then inactive (by name).
result.filters.sort(key=lambda f: (not f.active, f.name.lower()))
return result
domain_result.items.sort(key=lambda f: (not f.active, f.name.lower()))
return config_mappers.map_domain_filter_list_to_response(domain_result)

View File

@@ -27,6 +27,7 @@ from app.dependencies import (
HttpSessionDep,
)
from app.exceptions import HistoryNotFoundError
from app.mappers import history_mappers
from app.models._common import TimeRange
from app.models.ban import BanOrigin
from app.models.history import HistoryListResponse, IpDetailResponse
@@ -99,7 +100,7 @@ async def get_history(
and the total matching count.
"""
return await history_service.list_history(
domain_result = await history_service.list_history(
socket_path,
range_=range,
jail=jail,
@@ -112,6 +113,7 @@ async def get_history(
db=history_ctx.db,
fail2ban_metadata_service=fail2ban_metadata_service,
)
return history_mappers.map_domain_history_list_to_response(domain_result)
@router.get(
@@ -136,7 +138,7 @@ async def get_history_archive(
page_size: int = Query(default=DEFAULT_PAGE_SIZE, ge=1, le=500, description="Items per page (max 500)."),
) -> HistoryListResponse:
return await history_service.list_history(
domain_result = await history_service.list_history(
socket_path,
range_=range,
jail=jail,
@@ -148,6 +150,7 @@ async def get_history_archive(
db=history_ctx.db,
fail2ban_metadata_service=fail2ban_metadata_service,
)
return history_mappers.map_domain_history_list_to_response(domain_result)
@router.get(
@@ -182,14 +185,14 @@ async def get_ip_history(
HTTPException: 404 if the IP has no history in the database.
"""
detail: IpDetailResponse | None = await history_service.get_ip_detail(
domain_result = await history_service.get_ip_detail(
socket_path,
ip,
http_session=http_session,
fail2ban_metadata_service=fail2ban_metadata_service,
)
if detail is None:
if domain_result is None:
raise HistoryNotFoundError(ip)
return detail
return history_mappers.map_domain_ip_detail_to_response(domain_result)

View File

@@ -15,6 +15,7 @@ from app.dependencies import (
PendingRecoveryDep,
)
from app.exceptions import BadRequestError
from app.mappers import config_mappers
from app.models.config import (
ActivateJailRequest,
AddLogPathRequest,
@@ -68,7 +69,8 @@ async def get_jail_configs(
Returns:
:class:`~app.models.config.JailConfigListResponse`.
"""
return await config_service.list_jail_configs(socket_path)
domain_result = await config_service.list_jail_configs(socket_path)
return config_mappers.map_domain_jail_config_list_to_response(domain_result)
@@ -150,7 +152,8 @@ async def get_jail_config(
HTTPException: 404 when the jail does not exist.
HTTPException: 502 when fail2ban is unreachable.
"""
return await config_service.get_jail_config(socket_path, name)
domain_result = await config_service.get_jail_config(socket_path, name)
return config_mappers.map_domain_jail_config_to_response(domain_result)

View File

@@ -33,6 +33,7 @@ from app.dependencies import (
JailServiceStateDep,
)
from app.exceptions import BadRequestError
from app.mappers import jail_mappers
from app.models.ban import JailBannedIpsResponse
from app.models.jail import (
IgnoreIpRequest,
@@ -76,7 +77,8 @@ async def get_jails(
Returns:
:class:`~app.models.jail.JailListResponse` with all active jails.
"""
return await jail_service.list_jails(socket_path, state)
domain_result = await jail_service.list_jails(socket_path, state)
return jail_mappers.map_domain_jail_list_to_response(domain_result)
@router.get(
@@ -106,16 +108,16 @@ async def get_jail(
HTTPException: 404 when the jail does not exist.
HTTPException: 502 when fail2ban is unreachable.
"""
jail, ignore_list, ignore_self = await asyncio.gather(
jail_detail, ignore_list, ignore_self = await asyncio.gather(
jail_service.get_jail(socket_path, name),
jail_service.get_ignore_list(socket_path, name),
jail_service.get_ignore_self(socket_path, name),
)
return JailDetailResponse(
jail=jail,
ignore_list=ignore_list,
ignore_self=ignore_self,
# Merge ignore_list and ignore_self from dedicated service calls
jail_detail_with_ignore = jail_detail.model_copy(
update={"ignore_list": ignore_list, "ignore_self": ignore_self}
)
return jail_mappers.map_domain_jail_detail_to_response(jail_detail_with_ignore)
# ---------------------------------------------------------------------------
@@ -474,7 +476,7 @@ async def get_jail_banned_ips(
if not (1 <= page_size <= 100):
raise BadRequestError("page_size must be between 1 and 100.")
return await jail_service.get_jail_banned_ips(
domain_result = await jail_service.get_jail_banned_ips(
socket_path=socket_path,
jail_name=name,
page=page,
@@ -484,3 +486,4 @@ async def get_jail_banned_ips(
http_session=http_session,
app_db=ban_ctx.db,
)
return jail_mappers.map_domain_jail_banned_ips_to_response(domain_result)

View File

@@ -13,6 +13,7 @@ from __future__ import annotations
from fastapi import APIRouter, Request, status
from app.dependencies import AuthDep, Fail2BanSocketDep
from app.mappers import server_mappers
from app.models.server import ServerSettingsResponse, ServerSettingsUpdate
from app.services import server_service
@@ -49,7 +50,8 @@ async def get_server_settings(
Raises:
HTTPException: 502 when fail2ban is unreachable.
"""
return await server_service.get_settings(socket_path)
domain_result = await server_service.get_settings(socket_path)
return server_mappers.map_domain_server_settings_result_to_response(domain_result)
@router.put(