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

@@ -0,0 +1,141 @@
"""Blocklist response mappers.
Convert domain models (from blocklist_service) to response models (for HTTP API).
This is the mapping layer at the router boundary, ensuring the service layer
remains independent of HTTP response shapes.
"""
from __future__ import annotations
from app.models.blocklist import (
BlocklistSource,
ImportLogEntry,
ImportLogListResponse,
ImportRunResult,
ImportSourceResult,
PreviewResponse,
ScheduleConfig,
ScheduleFrequency,
ScheduleInfo,
)
from app.models.blocklist_domain import (
DomainBlocklistSource,
DomainImportLogEntry,
DomainImportLogList,
DomainImportRunResult,
DomainImportSourceResult,
DomainPreviewResult,
DomainScheduleConfig,
DomainScheduleFrequency,
DomainScheduleInfo,
)
from app.utils.pagination import create_pagination_metadata
def map_domain_blocklist_source_to_response(
domain: DomainBlocklistSource,
) -> BlocklistSource:
"""Convert domain blocklist source to response model."""
return BlocklistSource(
id=domain.id,
name=domain.name,
url=domain.url,
enabled=domain.enabled,
created_at=domain.created_at,
updated_at=domain.updated_at,
)
def map_domain_import_log_entry_to_response(
domain: DomainImportLogEntry,
) -> ImportLogEntry:
"""Convert domain import log entry to response model."""
return ImportLogEntry(
id=domain.id,
source_id=domain.source_id,
source_url=domain.source_url,
timestamp=domain.timestamp,
ips_imported=domain.ips_imported,
ips_skipped=domain.ips_skipped,
errors=domain.errors,
)
def map_domain_import_log_list_to_response(
domain_list: DomainImportLogList,
) -> ImportLogListResponse:
"""Convert domain import log list to response model."""
return ImportLogListResponse(
items=[map_domain_import_log_entry_to_response(i) for i in domain_list.items],
pagination=create_pagination_metadata(
domain_list.total, domain_list.page, domain_list.page_size
),
)
def map_domain_schedule_frequency_to_response(
domain: DomainScheduleFrequency,
) -> ScheduleFrequency:
"""Convert domain schedule frequency to response model."""
return ScheduleFrequency(domain.value)
def map_domain_schedule_config_to_response(
domain: DomainScheduleConfig,
) -> ScheduleConfig:
"""Convert domain schedule config to response model."""
return ScheduleConfig(
frequency=map_domain_schedule_frequency_to_response(domain.frequency),
interval_hours=domain.interval_hours,
hour=domain.hour,
minute=domain.minute,
day_of_week=domain.day_of_week,
)
def map_domain_schedule_info_to_response(domain: DomainScheduleInfo) -> ScheduleInfo:
"""Convert domain schedule info to response model."""
return ScheduleInfo(
config=map_domain_schedule_config_to_response(domain.config),
next_run_at=domain.next_run_at,
last_run_at=domain.last_run_at,
last_run_errors=domain.last_run_errors,
)
def map_domain_preview_result_to_response(domain: DomainPreviewResult) -> PreviewResponse:
"""Convert domain preview result to response model."""
return PreviewResponse(
entries=domain.entries,
total_lines=domain.total_lines,
valid_count=domain.valid_count,
skipped_count=domain.skipped_count,
)
def map_domain_import_source_result_to_response(
domain: DomainImportSourceResult,
) -> ImportSourceResult:
"""Convert domain import source result to response model."""
return ImportSourceResult(
source_id=domain.source_id,
source_url=domain.source_url,
ips_imported=domain.ips_imported,
ips_skipped=domain.ips_skipped,
error=domain.error,
)
def map_domain_import_run_result_to_response(
domain: DomainImportRunResult,
) -> ImportRunResult:
"""Convert domain import run result to response model."""
return ImportRunResult(
results=[
map_domain_import_source_result_to_response(r) for r in domain.results
],
total_imported=domain.total_imported,
total_skipped=domain.total_skipped,
errors_count=domain.errors_count,
)

View File

@@ -0,0 +1,156 @@
"""Config response mappers.
Convert domain models (from config_service) to response models (for HTTP API).
This is the mapping layer at the router boundary, ensuring the service layer
remains independent of HTTP response shapes.
"""
from __future__ import annotations
from app.models.config import (
BantimeEscalation,
Fail2BanLogResponse,
FilterConfig,
FilterListResponse,
GlobalConfigResponse,
JailConfig,
JailConfigListResponse,
LogPreviewResponse,
MapColorThresholdsResponse,
RegexTestResponse,
ServiceStatusResponse,
)
from app.models.config_domain import (
DomainBantimeEscalation,
DomainFilterConfig,
DomainFilterList,
DomainGlobalConfig,
DomainJailConfig,
DomainJailConfigList,
DomainMapColorThresholds,
DomainRegexTest,
DomainServiceStatus,
)
from app.utils.pagination import create_pagination_metadata
def _map_domain_bantime_escalation(domain: DomainBantimeEscalation) -> BantimeEscalation:
"""Convert domain bantime escalation to response model."""
return BantimeEscalation(
increment=domain.increment,
factor=domain.factor,
formula=domain.formula,
multipliers=domain.multipliers,
max_time=domain.max_time,
rnd_time=domain.rnd_time,
overall_jails=domain.overall_jails,
)
def map_domain_jail_config_to_response(domain: DomainJailConfig) -> JailConfig:
"""Convert domain jail config to response model."""
return JailConfig(
name=domain.name,
ban_time=domain.ban_time,
max_retry=domain.max_retry,
find_time=domain.find_time,
fail_regex=domain.fail_regex,
ignore_regex=domain.ignore_regex,
log_paths=domain.log_paths,
date_pattern=domain.date_pattern,
log_encoding=domain.log_encoding,
backend=domain.backend,
use_dns=domain.use_dns,
prefregex=domain.prefregex,
actions=domain.actions,
bantime_escalation=(
_map_domain_bantime_escalation(domain.bantime_escalation)
if domain.bantime_escalation
else None
),
)
def map_domain_jail_config_list_to_response(
domain_list: DomainJailConfigList,
) -> JailConfigListResponse:
"""Convert domain jail config list to response model."""
return JailConfigListResponse(
items=[map_domain_jail_config_to_response(c) for c in domain_list.items],
total=domain_list.total,
)
def map_domain_global_config_to_response(domain: DomainGlobalConfig) -> GlobalConfigResponse:
"""Convert domain global config to response model."""
return GlobalConfigResponse(
log_level=domain.log_level,
log_target=domain.log_target,
db_purge_age=domain.db_purge_age,
db_max_matches=domain.db_max_matches,
)
def map_domain_service_status_to_response(
domain: DomainServiceStatus,
) -> ServiceStatusResponse:
"""Convert domain service status to response model."""
return ServiceStatusResponse(
online=domain.online,
version=domain.version or "",
jail_count=domain.jail_count,
total_bans=domain.total_bans,
total_failures=domain.total_failures,
log_level=domain.log_level or "UNKNOWN",
log_target=domain.log_target or "UNKNOWN",
)
def map_domain_map_color_thresholds_to_response(
domain: DomainMapColorThresholds,
) -> MapColorThresholdsResponse:
"""Convert domain map color thresholds to response model."""
return MapColorThresholdsResponse(
threshold_high=domain.threshold_high,
threshold_medium=domain.threshold_medium,
threshold_low=domain.threshold_low,
)
def map_domain_regex_test_to_response(domain: DomainRegexTest) -> RegexTestResponse:
"""Convert domain regex test to response model."""
return RegexTestResponse(
matched=domain.matched,
groups=domain.groups,
error=domain.error,
)
def map_domain_filter_config_to_response(domain: DomainFilterConfig) -> FilterConfig:
"""Convert domain filter config to response model."""
return FilterConfig(
name=domain.name,
filename=domain.filename,
before=domain.before,
after=domain.after,
variables=domain.variables or {},
prefregex=domain.prefregex,
failregex=domain.failregex or [],
ignoreregex=domain.ignoreregex or [],
maxlines=domain.maxlines,
datepattern=domain.datepattern,
journalmatch=domain.journalmatch,
active=domain.active,
used_by_jails=domain.used_by_jails or [],
source_file=domain.source_file,
has_local_override=domain.has_local_override,
)
def map_domain_filter_list_to_response(domain_list: DomainFilterList) -> FilterListResponse:
"""Convert domain filter list to response model."""
return FilterListResponse(
items=[map_domain_filter_config_to_response(f) for f in domain_list.items],
total=domain_list.total,
)

View File

@@ -0,0 +1,23 @@
"""Health response mappers.
Convert domain models (from health_service) to response models (for HTTP API).
This is the mapping layer at the router boundary, ensuring the service layer
remains independent of HTTP response shapes.
"""
from __future__ import annotations
from app.models.health_domain import DomainServerStatus
from app.models.server import ServerStatus
def map_domain_server_status_to_response(domain: DomainServerStatus) -> ServerStatus:
"""Convert domain server status to response model."""
return ServerStatus(
online=domain.online,
version=domain.version,
active_jails=domain.active_jails,
total_bans=domain.total_bans,
total_failures=domain.total_failures,
)

View File

@@ -0,0 +1,81 @@
"""History response mappers.
Convert domain models (from history_service) to response models (for HTTP API).
This is the mapping layer at the router boundary, ensuring the service layer
remains independent of HTTP response shapes.
"""
from __future__ import annotations
from app.models.history import (
HistoryBanItem,
HistoryListResponse,
IpDetailResponse,
IpTimelineEvent,
)
from app.models.history_domain import (
DomainHistoryBanItem,
DomainHistoryList,
DomainIpDetail,
DomainIpTimelineEvent,
)
from app.utils.pagination import create_pagination_metadata
def map_domain_history_ban_item_to_response(
domain: DomainHistoryBanItem,
) -> HistoryBanItem:
"""Convert domain history ban item to response model."""
return HistoryBanItem(
ip=domain.ip,
jail=domain.jail,
banned_at=domain.banned_at,
ban_count=domain.ban_count,
failures=domain.failures,
matches=domain.matches or [],
country_code=domain.country_code,
country_name=domain.country_name,
asn=domain.asn,
org=domain.org,
)
def map_domain_history_list_to_response(domain: DomainHistoryList) -> HistoryListResponse:
"""Convert domain history list to response model."""
return HistoryListResponse(
items=[map_domain_history_ban_item_to_response(i) for i in domain.items],
pagination=create_pagination_metadata(
domain.total, domain.page, domain.page_size
),
)
def map_domain_ip_timeline_event_to_response(
domain: DomainIpTimelineEvent,
) -> IpTimelineEvent:
"""Convert domain IP timeline event to response model."""
return IpTimelineEvent(
jail=domain.jail,
banned_at=domain.banned_at,
ban_count=domain.ban_count,
failures=domain.failures,
matches=domain.matches or [],
)
def map_domain_ip_detail_to_response(domain: DomainIpDetail) -> IpDetailResponse:
"""Convert domain IP detail to response model."""
return IpDetailResponse(
ip=domain.ip,
total_bans=domain.total_bans,
total_failures=domain.total_failures,
last_ban_at=domain.last_ban_at,
country_code=domain.country_code,
country_name=domain.country_name,
asn=domain.asn,
org=domain.org,
timeline=[
map_domain_ip_timeline_event_to_response(t) for t in (domain.timeline or [])
],
)

View File

@@ -0,0 +1,133 @@
"""Jail response mappers.
Convert domain models (from jail_service) to response models (for HTTP API).
This is the mapping layer at the router boundary, ensuring the service layer
remains independent of HTTP response shapes.
"""
from __future__ import annotations
from app.models.ban import ActiveBan, JailBannedIpsResponse
from app.models.ban_domain import DomainActiveBan
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.pagination import create_pagination_metadata
def _map_domain_jail_status(domain: DomainJailStatus) -> JailStatus:
"""Convert domain jail status to response model."""
return JailStatus(
currently_banned=domain.currently_banned,
total_banned=domain.total_banned,
currently_failed=domain.currently_failed,
total_failed=domain.total_failed,
)
def _map_domain_bantime_escalation(domain: DomainBantimeEscalation) -> object:
"""Convert domain bantime escalation to response model."""
from app.models.config import BantimeEscalation
return BantimeEscalation(
increment=domain.increment,
factor=domain.factor,
formula=domain.formula,
multipliers=domain.multipliers,
max_time=domain.max_time,
rnd_time=domain.rnd_time,
overall_jails=domain.overall_jails,
)
def map_domain_jail_summary_to_response(domain: DomainJailSummary) -> JailSummary:
"""Convert domain jail summary to response model."""
return JailSummary(
name=domain.name,
enabled=domain.enabled,
running=domain.running,
idle=domain.idle,
backend=domain.backend,
find_time=domain.find_time,
ban_time=domain.ban_time,
max_retry=domain.max_retry,
status=_map_domain_jail_status(domain.status) if domain.status else None,
)
def map_domain_jail_list_to_response(domain_list: DomainJailList) -> JailListResponse:
"""Convert domain jail list to response model."""
return JailListResponse(
items=[map_domain_jail_summary_to_response(j) for j in domain_list.items],
total=domain_list.total,
)
def map_domain_jail_to_response(domain: DomainJail) -> Jail:
"""Convert domain jail to response model."""
return Jail(
name=domain.name,
enabled=domain.enabled,
running=domain.running,
idle=domain.idle,
backend=domain.backend,
log_paths=domain.log_paths,
fail_regex=domain.fail_regex,
ignore_regex=domain.ignore_regex,
ignore_ips=domain.ignore_ips,
date_pattern=domain.date_pattern,
log_encoding=domain.log_encoding,
find_time=domain.find_time,
ban_time=domain.ban_time,
max_retry=domain.max_retry,
actions=domain.actions,
bantime_escalation=(
_map_domain_bantime_escalation(domain.bantime_escalation)
if domain.bantime_escalation
else None
),
status=_map_domain_jail_status(domain.status) if domain.status else None,
)
def map_domain_jail_detail_to_response(domain: DomainJailDetail) -> JailDetailResponse:
"""Convert domain jail detail to response model."""
return JailDetailResponse(
jail=map_domain_jail_to_response(domain.jail),
ignore_list=domain.ignore_list,
ignore_self=domain.ignore_self,
)
def map_domain_jail_banned_ips_to_response(
domain: DomainJailBannedIps,
) -> JailBannedIpsResponse:
"""Convert domain jail banned IPs to response model."""
return JailBannedIpsResponse(
items=[
ActiveBan(
ip=ban.ip,
jail=ban.jail,
banned_at=ban.banned_at,
expires_at=ban.expires_at,
ban_count=ban.ban_count,
country=ban.country,
)
for ban in domain.items
],
pagination=create_pagination_metadata(domain.total, domain.page, domain.page_size),
)

View File

@@ -0,0 +1,37 @@
"""Server response mappers.
Convert domain models (from server_service) to response models (for HTTP API).
This is the mapping layer at the router boundary, ensuring the service layer
remains independent of HTTP response shapes.
"""
from __future__ import annotations
from app.models.server import ServerSettings, ServerSettingsResponse, ServerSettingsUpdate
from app.models.server_domain import DomainServerSettings, DomainServerSettingsResult
from app.utils.pagination import create_pagination_metadata
def map_domain_server_settings_to_response(
domain_settings: DomainServerSettings,
) -> ServerSettings:
"""Convert domain server settings to response model."""
return ServerSettings(
log_level=domain_settings.log_level,
log_target=domain_settings.log_target,
syslog_socket=domain_settings.syslog_socket,
db_path=domain_settings.db_path,
db_purge_age=domain_settings.db_purge_age,
db_max_matches=domain_settings.db_max_matches,
)
def map_domain_server_settings_result_to_response(
domain_result: DomainServerSettingsResult,
) -> ServerSettingsResponse:
"""Convert domain server settings result to response model."""
return ServerSettingsResponse(
settings=map_domain_server_settings_to_response(domain_result.settings),
warnings=domain_result.warnings,
)