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:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user