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

@@ -29,19 +29,17 @@ if TYPE_CHECKING:
from app.exceptions import ConfigOperationError, ConfigValidationError, JailNotFoundError
from app.models.config import (
AddLogPathRequest,
BantimeEscalation,
GlobalConfigResponse,
GlobalConfigUpdate,
JailConfig,
JailConfigListResponse,
JailConfigResponse,
JailConfigUpdate,
LogPreviewRequest,
LogPreviewResponse,
MapColorThresholdsResponse,
MapColorThresholdsUpdate,
RegexTestRequest,
RegexTestResponse,
)
from app.models.config_domain import (
DomainBantimeEscalation,
DomainGlobalConfig,
DomainJailConfig,
DomainJailConfigList,
)
from app.services.log_service import preview_log as util_preview_log
from app.services.log_service import test_regex as util_test_regex
@@ -120,7 +118,7 @@ def _validate_regex(pattern: str) -> str | None:
# ---------------------------------------------------------------------------
async def get_jail_config(socket_path: str, name: str) -> JailConfigResponse:
async def get_jail_config(socket_path: str, name: str) -> DomainJailConfig:
"""Return the editable configuration for a single jail.
Args:
@@ -128,7 +126,7 @@ async def get_jail_config(socket_path: str, name: str) -> JailConfigResponse:
name: Jail name.
Returns:
:class:`~app.models.config.JailConfigResponse`.
:class:`~app.models.config_domain.DomainJailConfig`.
Raises:
JailNotFoundError: If *name* is not a known jail.
@@ -164,7 +162,7 @@ async def get_jail_config(socket_path: str, name: str) -> JailConfigResponse:
bt_rndtime_raw: str | int | None = await _safe_get_typed(client, ["get", name, "bantime.rndtime"], None)
bt_overalljails_raw: bool = await _safe_get_typed(client, ["get", name, "bantime.overalljails"], False)
bantime_escalation = BantimeEscalation(
bantime_escalation = DomainBantimeEscalation(
increment=bool(bt_increment_raw),
factor=float(bt_factor_raw) if bt_factor_raw is not None else None,
formula=str(bt_formula_raw) if bt_formula_raw else None,
@@ -174,7 +172,7 @@ async def get_jail_config(socket_path: str, name: str) -> JailConfigResponse:
overall_jails=bool(bt_overalljails_raw),
)
jail_cfg = JailConfig(
jail_cfg = DomainJailConfig(
name=name,
ban_time=int(bantime_raw or 600),
find_time=int(findtime_raw or 600),
@@ -192,17 +190,17 @@ async def get_jail_config(socket_path: str, name: str) -> JailConfigResponse:
)
log.info("jail_config_fetched", jail=name)
return JailConfigResponse(jail=jail_cfg)
return jail_cfg
async def list_jail_configs(socket_path: str) -> JailConfigListResponse:
async def list_jail_configs(socket_path: str) -> DomainJailConfigList:
"""Return configuration for all active jails.
Args:
socket_path: Path to the fail2ban Unix domain socket.
Returns:
:class:`~app.models.config.JailConfigListResponse`.
:class:`~app.models.config_domain.DomainJailConfigList`.
Raises:
~app.utils.fail2ban_client.Fail2BanConnectionError: Socket unreachable.
@@ -218,16 +216,15 @@ async def list_jail_configs(socket_path: str) -> JailConfigListResponse:
)
if not jail_names:
return JailConfigListResponse(items=[], total=0)
return DomainJailConfigList(items=[], total=0)
responses: list[JailConfigResponse] = await asyncio.gather(
jail_configs: list[DomainJailConfig] = await asyncio.gather(
*[get_jail_config(socket_path, name) for name in jail_names],
return_exceptions=False,
)
jails = [r.jail for r in responses]
log.info("jail_configs_listed", count=len(jails))
return JailConfigListResponse(items=jails, total=len(jails))
log.info("jail_configs_listed", count=len(jail_configs))
return DomainJailConfigList(items=jail_configs, total=len(jail_configs))
# ---------------------------------------------------------------------------
@@ -379,14 +376,14 @@ async def _replace_regex_list(
# ---------------------------------------------------------------------------
async def get_global_config(socket_path: str) -> GlobalConfigResponse:
async def get_global_config(socket_path: str) -> DomainGlobalConfig:
"""Return fail2ban global configuration settings.
Args:
socket_path: Path to the fail2ban Unix domain socket.
Returns:
:class:`~app.models.config.GlobalConfigResponse`.
:class:`~app.models.config_domain.DomainGlobalConfig`.
Raises:
~app.utils.fail2ban_client.Fail2BanConnectionError: Socket unreachable.
@@ -405,7 +402,7 @@ async def get_global_config(socket_path: str) -> GlobalConfigResponse:
_safe_get_typed(client, ["get", "dbmaxmatches"], 10),
)
return GlobalConfigResponse(
return DomainGlobalConfig(
log_level=str(log_level_raw or "INFO").upper(),
log_target=str(log_target_raw or "STDOUT"),
db_purge_age=int(db_purge_age_raw or 86400),