Models in app/models/ are now pure data classes with no cross-layer dependencies. This ensures the models layer remains a true leaf node in the dependency graph. Changes: - Create app/models/_common.py with shared types (TimeRange, bucket_count, constants) - Move TimeRange and time-range constants from ban.py to _common.py - Update history.py, routers, and services to import from _common.py - Remove imports from app.config and app.utils from config.py models - Move field validators from models to router layer: - Add log_target validation in config_misc router - Add log_path validation in jail_config router - Update test_models.py to reflect validators moved to router layer - Update documentation (Architekture.md, Backend-Development.md) with model layering rules - Fix import ordering and type annotations in affected files Model layering rule: Models may only import from: ✓ Standard library and third-party packages (Pydantic, typing) ✓ Other models in app/models/ (sibling models) ✓ app.models.response (response envelopes) ✗ app.services, app.config, app.utils, or any application layer Validation requiring app-level state (settings, allowed directories) now happens at the router or service layer, not in model validators. Fixes: Models were not true leaf nodes due to circular imports and app-layer dependencies Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
133 lines
4.2 KiB
Python
133 lines
4.2 KiB
Python
"""Ban history Pydantic models.
|
|
|
|
Request, response, and domain models used by the history router and service.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from pydantic import Field
|
|
|
|
from app.models._common import TimeRange
|
|
from app.models.response import BanGuiBaseModel, PaginatedListResponse
|
|
|
|
__all__ = [
|
|
"HistoryBanItem",
|
|
"HistoryListResponse",
|
|
"IpDetailResponse",
|
|
"IpTimelineEvent",
|
|
"TimeRange",
|
|
]
|
|
|
|
class HistoryBanItem(BanGuiBaseModel):
|
|
"""A single row in the history ban-list table.
|
|
|
|
Populated from the fail2ban database and optionally enriched with
|
|
geolocation data.
|
|
"""
|
|
|
|
ip: str = Field(..., description="Banned IP address.")
|
|
jail: str = Field(..., description="Jail that issued the ban.")
|
|
banned_at: str = Field(..., description="ISO 8601 UTC timestamp of the ban.")
|
|
ban_count: int = Field(..., ge=1, description="How many times this IP was banned.")
|
|
failures: int = Field(
|
|
default=0,
|
|
ge=0,
|
|
description="Total failure count extracted from the ``data`` column.",
|
|
)
|
|
matches: list[str] = Field(
|
|
default_factory=list,
|
|
description="Matched log lines stored in the ``data`` column.",
|
|
)
|
|
country_code: str | None = Field(
|
|
default=None,
|
|
description="ISO 3166-1 alpha-2 country code, or ``null`` if unknown.",
|
|
)
|
|
country_name: str | None = Field(
|
|
default=None,
|
|
description="Human-readable country name, or ``null`` if unknown.",
|
|
)
|
|
asn: str | None = Field(
|
|
default=None,
|
|
description="Autonomous System Number string (e.g. ``'AS3320'``).",
|
|
)
|
|
org: str | None = Field(
|
|
default=None,
|
|
description="Organisation name associated with the IP.",
|
|
)
|
|
|
|
class HistoryListResponse(PaginatedListResponse[HistoryBanItem]):
|
|
"""Paginated history ban-list response.
|
|
|
|
Request: ``GET /api/history`` with optional time-range, jail, IP, and
|
|
origin filters plus pagination parameters.
|
|
Response: Paginated collection of historical ban records with geolocation.
|
|
"""
|
|
|
|
pass
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Per-IP timeline
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class IpTimelineEvent(BanGuiBaseModel):
|
|
"""A single ban event in a per-IP timeline.
|
|
|
|
Represents one row from the fail2ban ``bans`` table for a specific IP.
|
|
"""
|
|
|
|
jail: str = Field(..., description="Jail that triggered this ban.")
|
|
banned_at: str = Field(..., description="ISO 8601 UTC timestamp of the ban.")
|
|
ban_count: int = Field(
|
|
...,
|
|
ge=1,
|
|
description="Running ban counter for this IP at the time of this event.",
|
|
)
|
|
failures: int = Field(
|
|
default=0,
|
|
ge=0,
|
|
description="Failure count at the time of the ban.",
|
|
)
|
|
matches: list[str] = Field(
|
|
default_factory=list,
|
|
description="Matched log lines that triggered the ban.",
|
|
)
|
|
|
|
class IpDetailResponse(BanGuiBaseModel):
|
|
"""Full historical record for a single IP address.
|
|
|
|
Contains aggregated totals and a chronological timeline of all ban events
|
|
recorded in the fail2ban database for the given IP.
|
|
"""
|
|
|
|
ip: str = Field(..., description="The IP address.")
|
|
total_bans: int = Field(..., ge=0, description="Total number of ban records.")
|
|
total_failures: int = Field(
|
|
...,
|
|
ge=0,
|
|
description="Sum of all failure counts across all ban events.",
|
|
)
|
|
last_ban_at: str | None = Field(
|
|
default=None,
|
|
description="ISO 8601 UTC timestamp of the most recent ban, or ``null``.",
|
|
)
|
|
country_code: str | None = Field(
|
|
default=None,
|
|
description="ISO 3166-1 alpha-2 country code, or ``null`` if unknown.",
|
|
)
|
|
country_name: str | None = Field(
|
|
default=None,
|
|
description="Human-readable country name, or ``null`` if unknown.",
|
|
)
|
|
asn: str | None = Field(
|
|
default=None,
|
|
description="Autonomous System Number string.",
|
|
)
|
|
org: str | None = Field(
|
|
default=None,
|
|
description="Organisation name associated with the IP.",
|
|
)
|
|
timeline: list[IpTimelineEvent] = Field(
|
|
default_factory=list,
|
|
description="All ban events for this IP, ordered newest-first.",
|
|
)
|