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:
@@ -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),
|
||||
|
||||
@@ -27,12 +27,11 @@ from app.exceptions import (
|
||||
)
|
||||
from app.models.config import (
|
||||
AssignFilterRequest,
|
||||
FilterConfig,
|
||||
FilterConfigUpdate,
|
||||
FilterCreateRequest,
|
||||
FilterListResponse,
|
||||
FilterUpdateRequest,
|
||||
)
|
||||
from app.models.config_domain import DomainFilterConfig, DomainFilterList
|
||||
from app.utils import conffile_parser
|
||||
from app.utils.async_utils import run_blocking
|
||||
from app.utils.config_file_utils import (
|
||||
@@ -308,12 +307,12 @@ def _write_filter_local_sync(filter_d: Path, name: str, content: str) -> None:
|
||||
async def list_filters(
|
||||
config_dir: str,
|
||||
socket_path: str,
|
||||
) -> FilterListResponse:
|
||||
) -> DomainFilterList:
|
||||
"""Return all available filters from ``filter.d/`` with active/inactive status.
|
||||
|
||||
Scans ``{config_dir}/filter.d/`` for ``.conf`` files, merges any
|
||||
corresponding ``.local`` overrides, parses each file into a
|
||||
:class:`~app.models.config.FilterConfig`, and cross-references with the
|
||||
:class:`~app.models.config_domain.DomainFilterConfig`, and cross-references with the
|
||||
currently running jails to determine which filters are active.
|
||||
|
||||
A filter is considered *active* when its base name matches the ``filter``
|
||||
@@ -324,7 +323,7 @@ async def list_filters(
|
||||
socket_path: Path to the fail2ban Unix domain socket.
|
||||
|
||||
Returns:
|
||||
:class:`~app.models.config.FilterListResponse` with all filters
|
||||
:class:`~app.models.config_domain.DomainFilterList` with all filters
|
||||
sorted alphabetically, active ones carrying non-empty
|
||||
``used_by_jails`` lists.
|
||||
"""
|
||||
@@ -342,12 +341,12 @@ async def list_filters(
|
||||
|
||||
filter_to_jails = _build_filter_to_jails_map(all_jails, active_names)
|
||||
|
||||
filters: list[FilterConfig] = []
|
||||
filters: list[DomainFilterConfig] = []
|
||||
for name, filename, content, has_local, source_path in raw_filters:
|
||||
cfg = conffile_parser.parse_filter_file(content, name=name, filename=filename)
|
||||
used_by = sorted(filter_to_jails.get(name, []))
|
||||
filters.append(
|
||||
FilterConfig(
|
||||
DomainFilterConfig(
|
||||
name=cfg.name,
|
||||
filename=cfg.filename,
|
||||
before=cfg.before,
|
||||
@@ -367,7 +366,7 @@ async def list_filters(
|
||||
)
|
||||
|
||||
log.info("filters_listed", total=len(filters), active=sum(1 for f in filters if f.active))
|
||||
return FilterListResponse(filters=filters, total=len(filters))
|
||||
return DomainFilterList(filters=filters, total=len(filters))
|
||||
|
||||
|
||||
async def get_filter(
|
||||
|
||||
@@ -16,7 +16,7 @@ from typing import TypeVar, cast
|
||||
import structlog
|
||||
|
||||
from app import __version__
|
||||
from app.models.config import ServiceStatusResponse
|
||||
from app.models.config_domain import DomainServiceStatus
|
||||
from app.models.server import ServerStatus
|
||||
from app.utils.constants import FAIL2BAN_SOCKET_TIMEOUT_FAST
|
||||
from app.utils.fail2ban_client import (
|
||||
@@ -69,7 +69,7 @@ async def _safe_get_typed(
|
||||
async def get_service_status(
|
||||
socket_path: str,
|
||||
probe_fn: Callable[[str], Awaitable[ServerStatus]] | None = None,
|
||||
) -> ServiceStatusResponse:
|
||||
) -> DomainServiceStatus:
|
||||
"""Return fail2ban service health status with log configuration.
|
||||
|
||||
Delegates to an injectable *probe_fn* (defaults to
|
||||
@@ -80,7 +80,7 @@ async def get_service_status(
|
||||
probe_fn: Optional probe function.
|
||||
|
||||
Returns:
|
||||
:class:`~app.models.config.ServiceStatusResponse`.
|
||||
:class:`~app.models.config_domain.DomainServiceStatus`.
|
||||
"""
|
||||
if probe_fn is None:
|
||||
raise ValueError(
|
||||
@@ -110,7 +110,7 @@ async def get_service_status(
|
||||
jail_count=server_status.active_jails,
|
||||
)
|
||||
|
||||
return ServiceStatusResponse(
|
||||
return DomainServiceStatus(
|
||||
online=server_status.online,
|
||||
version=__version__,
|
||||
jail_count=server_status.active_jails,
|
||||
|
||||
@@ -25,17 +25,16 @@ if TYPE_CHECKING:
|
||||
from app.repositories.protocols import HistoryArchiveRepository
|
||||
from app.services.protocols import Fail2BanMetadataService
|
||||
|
||||
from app.models.history import (
|
||||
HistoryBanItem,
|
||||
HistoryListResponse,
|
||||
IpDetailResponse,
|
||||
IpTimelineEvent,
|
||||
from app.models.history_domain import (
|
||||
DomainHistoryBanItem,
|
||||
DomainHistoryList,
|
||||
DomainIpDetail,
|
||||
DomainIpTimelineEvent,
|
||||
)
|
||||
from app.repositories import fail2ban_db_repo
|
||||
from app.repositories import history_archive_repo as default_history_archive_repo
|
||||
from app.utils.constants import DEFAULT_PAGE_SIZE, MAX_PAGE_SIZE
|
||||
from app.utils.fail2ban_db_utils import parse_data_json, ts_to_iso
|
||||
from app.utils.pagination import create_pagination_metadata
|
||||
from app.utils.time_utils import since_unix
|
||||
|
||||
log: structlog.stdlib.BoundLogger = structlog.get_logger()
|
||||
@@ -190,7 +189,7 @@ async def list_history(
|
||||
db: aiosqlite.Connection | None = None,
|
||||
history_archive_repo: HistoryArchiveRepository = default_history_archive_repo,
|
||||
fail2ban_metadata_service: Fail2BanMetadataService | None = None,
|
||||
) -> HistoryListResponse:
|
||||
) -> DomainHistoryList:
|
||||
"""Return a paginated list of historical ban records with optional filters.
|
||||
|
||||
Queries the fail2ban ``bans`` table applying the requested filters and
|
||||
@@ -214,7 +213,7 @@ async def list_history(
|
||||
If not provided, uses the default singleton (lazy import).
|
||||
|
||||
Returns:
|
||||
:class:`~app.models.history.HistoryListResponse` with paginated items
|
||||
:class:`~app.models.history_domain.DomainHistoryList` with paginated items
|
||||
and the total matching count.
|
||||
"""
|
||||
effective_page_size: int = min(page_size, MAX_PAGE_SIZE)
|
||||
@@ -237,7 +236,7 @@ async def list_history(
|
||||
page=page,
|
||||
)
|
||||
|
||||
items: list[HistoryBanItem] = []
|
||||
items: list[DomainHistoryBanItem] = []
|
||||
total: int
|
||||
|
||||
if source == "archive":
|
||||
@@ -281,7 +280,7 @@ async def list_history(
|
||||
log.warning("history_service_geo_lookup_failed", ip=ip)
|
||||
|
||||
items.append(
|
||||
HistoryBanItem(
|
||||
DomainHistoryBanItem(
|
||||
ip=ip,
|
||||
jail=jail_name,
|
||||
banned_at=banned_at,
|
||||
@@ -332,7 +331,7 @@ async def list_history(
|
||||
log.warning("history_service_geo_lookup_failed", ip=ip)
|
||||
|
||||
items.append(
|
||||
HistoryBanItem(
|
||||
DomainHistoryBanItem(
|
||||
ip=ip,
|
||||
jail=jail_name,
|
||||
banned_at=banned_at,
|
||||
@@ -346,9 +345,11 @@ async def list_history(
|
||||
)
|
||||
)
|
||||
|
||||
return HistoryListResponse(
|
||||
return DomainHistoryList(
|
||||
items=items,
|
||||
pagination=create_pagination_metadata(total, page, effective_page_size),
|
||||
total=total,
|
||||
page=page,
|
||||
page_size=effective_page_size,
|
||||
)
|
||||
|
||||
|
||||
@@ -359,7 +360,7 @@ async def get_ip_detail(
|
||||
http_session: aiohttp.ClientSession | None = None,
|
||||
geo_enricher: GeoEnricher | None = None,
|
||||
fail2ban_metadata_service: Fail2BanMetadataService | None = None,
|
||||
) -> IpDetailResponse | None:
|
||||
) -> DomainIpDetail | None:
|
||||
"""Return the full historical record for a single IP address.
|
||||
|
||||
Fetches all ban events for *ip* from the fail2ban database, ordered
|
||||
@@ -376,7 +377,7 @@ async def get_ip_detail(
|
||||
If not provided, uses the default singleton (lazy import).
|
||||
|
||||
Returns:
|
||||
:class:`~app.models.history.IpDetailResponse` if any records exist
|
||||
:class:`~app.models.history_domain.DomainIpDetail` if any records exist
|
||||
for *ip*, or ``None`` if the IP has no history in the database.
|
||||
"""
|
||||
if fail2ban_metadata_service is None:
|
||||
@@ -390,7 +391,7 @@ async def get_ip_detail(
|
||||
if not rows:
|
||||
return None
|
||||
|
||||
timeline: list[IpTimelineEvent] = []
|
||||
timeline: list[DomainIpTimelineEvent] = []
|
||||
total_failures: int = 0
|
||||
|
||||
for row in rows:
|
||||
@@ -400,7 +401,7 @@ async def get_ip_detail(
|
||||
matches, failures = parse_data_json(row.data)
|
||||
total_failures += failures
|
||||
timeline.append(
|
||||
IpTimelineEvent(
|
||||
DomainIpTimelineEvent(
|
||||
jail=jail_name,
|
||||
banned_at=banned_at,
|
||||
ban_count=ban_count,
|
||||
@@ -430,7 +431,7 @@ async def get_ip_detail(
|
||||
except Exception: # noqa: BLE001
|
||||
log.warning("history_service_geo_lookup_failed_detail", ip=ip)
|
||||
|
||||
return IpDetailResponse(
|
||||
return DomainIpDetail(
|
||||
ip=ip,
|
||||
total_bans=len(timeline),
|
||||
total_failures=total_failures,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -26,12 +26,14 @@ if TYPE_CHECKING:
|
||||
ScheduleConfig,
|
||||
ScheduleInfo,
|
||||
)
|
||||
from app.models.config_domain import (
|
||||
DomainGlobalConfig,
|
||||
DomainJailConfig,
|
||||
DomainJailConfigList,
|
||||
)
|
||||
from app.models.config import (
|
||||
AddLogPathRequest,
|
||||
GlobalConfigResponse,
|
||||
GlobalConfigUpdate,
|
||||
JailConfigListResponse,
|
||||
JailConfigResponse,
|
||||
JailConfigUpdate,
|
||||
LogPreviewRequest,
|
||||
LogPreviewResponse,
|
||||
@@ -40,9 +42,9 @@ if TYPE_CHECKING:
|
||||
RegexTestResponse,
|
||||
)
|
||||
from app.models.geo import GeoEnricher, GeoInfo
|
||||
from app.models.history import HistoryListResponse, IpDetailResponse
|
||||
from app.models.jail import JailDetailResponse, JailListResponse
|
||||
from app.models.server import ServerSettingsResponse, ServerSettingsUpdate, ServerStatus
|
||||
from app.models.history_domain import DomainHistoryList, DomainIpDetail
|
||||
from app.models.jail_domain import DomainJailBannedIps, DomainJailDetail, DomainJailList
|
||||
from app.models.server_domain import DomainServerSettingsResult
|
||||
from app.services.geo_cache import GeoCache
|
||||
|
||||
|
||||
@@ -81,10 +83,10 @@ class AuthService(Protocol):
|
||||
class JailService(Protocol):
|
||||
"""Protocol for jail management service operations."""
|
||||
|
||||
async def list_jails(self, socket_path: str) -> JailListResponse:
|
||||
async def list_jails(self, socket_path: str) -> DomainJailList:
|
||||
...
|
||||
|
||||
async def get_jail(self, socket_path: str, name: str) -> JailDetailResponse:
|
||||
async def get_jail(self, socket_path: str, name: str) -> DomainJailDetail:
|
||||
...
|
||||
|
||||
async def reload_all(self, socket_path: str) -> None:
|
||||
@@ -125,7 +127,7 @@ class JailService(Protocol):
|
||||
geo_batch_lookup: object,
|
||||
http_session: object,
|
||||
app_db: aiosqlite.Connection,
|
||||
) -> JailBannedIpsResponse:
|
||||
) -> DomainJailBannedIps:
|
||||
...
|
||||
|
||||
async def lookup_ip(
|
||||
@@ -233,10 +235,10 @@ class BlocklistService(Protocol):
|
||||
|
||||
@runtime_checkable
|
||||
class ConfigService(Protocol):
|
||||
async def get_jail_config(self, socket_path: str, name: str) -> JailConfigResponse:
|
||||
async def get_jail_config(self, socket_path: str, name: str) -> DomainJailConfig:
|
||||
...
|
||||
|
||||
async def list_jail_configs(self, socket_path: str) -> JailConfigListResponse:
|
||||
async def list_jail_configs(self, socket_path: str) -> DomainJailConfigList:
|
||||
...
|
||||
|
||||
async def update_jail_config(
|
||||
@@ -247,7 +249,7 @@ class ConfigService(Protocol):
|
||||
) -> None:
|
||||
...
|
||||
|
||||
async def get_global_config(self, socket_path: str) -> GlobalConfigResponse:
|
||||
async def get_global_config(self, socket_path: str) -> DomainGlobalConfig:
|
||||
...
|
||||
|
||||
async def update_global_config(
|
||||
@@ -305,7 +307,7 @@ class HistoryService(Protocol):
|
||||
http_session: aiohttp.ClientSession | None = None,
|
||||
geo_enricher: GeoEnricher | None = None,
|
||||
db: aiosqlite.Connection | None = None,
|
||||
) -> HistoryListResponse:
|
||||
) -> DomainHistoryList:
|
||||
...
|
||||
|
||||
async def get_ip_detail(
|
||||
@@ -315,7 +317,7 @@ class HistoryService(Protocol):
|
||||
*,
|
||||
http_session: aiohttp.ClientSession | None = None,
|
||||
geo_enricher: GeoEnricher | None = None,
|
||||
) -> IpDetailResponse | None:
|
||||
) -> DomainIpDetail | None:
|
||||
...
|
||||
|
||||
|
||||
@@ -394,7 +396,7 @@ class HealthProbe(Protocol):
|
||||
|
||||
@runtime_checkable
|
||||
class ServerService(Protocol):
|
||||
async def get_settings(self, socket_path: str) -> ServerSettingsResponse:
|
||||
async def get_settings(self, socket_path: str) -> DomainServerSettingsResult:
|
||||
...
|
||||
|
||||
async def update_settings(
|
||||
|
||||
@@ -15,7 +15,8 @@ from typing import cast
|
||||
import structlog
|
||||
|
||||
from app.exceptions import ServerOperationError
|
||||
from app.models.server import ServerSettings, ServerSettingsResponse, ServerSettingsUpdate
|
||||
from app.models.server_domain import DomainServerSettings, DomainServerSettingsResult
|
||||
from app.models.server import ServerSettingsUpdate
|
||||
from app.utils.constants import FAIL2BAN_SOCKET_TIMEOUT
|
||||
from app.utils.fail2ban_client import Fail2BanClient, Fail2BanCommand, Fail2BanResponse
|
||||
from app.utils.fail2ban_response import ok
|
||||
@@ -87,7 +88,7 @@ async def _safe_get(
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def get_settings(socket_path: str) -> ServerSettingsResponse:
|
||||
async def get_settings(socket_path: str) -> DomainServerSettingsResult:
|
||||
"""Return current fail2ban server-level settings.
|
||||
|
||||
Fetches log level, log target, syslog socket, database file path, purge
|
||||
@@ -97,7 +98,7 @@ async def get_settings(socket_path: str) -> ServerSettingsResponse:
|
||||
socket_path: Path to the fail2ban Unix domain socket.
|
||||
|
||||
Returns:
|
||||
:class:`~app.models.server.ServerSettingsResponse`.
|
||||
:class:`~app.models.server_domain.DomainServerSettingsResult`.
|
||||
|
||||
Raises:
|
||||
~app.utils.fail2ban_client.Fail2BanConnectionError: Socket unreachable.
|
||||
@@ -129,7 +130,7 @@ async def get_settings(socket_path: str) -> ServerSettingsResponse:
|
||||
db_purge_age = _to_int(db_purge_age_raw, 86400)
|
||||
db_max_matches = _to_int(db_max_matches_raw, 10)
|
||||
|
||||
settings = ServerSettings(
|
||||
settings = DomainServerSettings(
|
||||
log_level=log_level,
|
||||
log_target=log_target,
|
||||
syslog_socket=syslog_socket,
|
||||
@@ -143,7 +144,7 @@ async def get_settings(socket_path: str) -> ServerSettingsResponse:
|
||||
}
|
||||
|
||||
log.info("server_settings_fetched", db_purge_age=db_purge_age, warnings=warnings)
|
||||
return ServerSettingsResponse(settings=settings, warnings=warnings)
|
||||
return DomainServerSettingsResult(settings=settings, warnings=warnings)
|
||||
|
||||
|
||||
async def update_settings(socket_path: str, update: ServerSettingsUpdate) -> None:
|
||||
|
||||
Reference in New Issue
Block a user