"""Ban management Pydantic models. Request, response, and domain models used by the ban router and service. """ from typing import Literal from pydantic import Field, field_validator from app.models.response import BanGuiBaseModel, CollectionResponse, PaginatedListResponse class BanRequest(BanGuiBaseModel): """Payload for ``POST /api/bans`` (ban an IP).""" ip: str = Field(..., description="IP address to ban.") jail: str = Field(..., description="Jail in which to apply the ban.") class UnbanRequest(BanGuiBaseModel): """Payload for ``DELETE /api/bans`` (unban an IP).""" ip: str = Field(..., description="IP address to unban.") jail: str | None = Field( default=None, description="Jail to remove the ban from. ``null`` means all jails.", ) unban_all: bool = Field( default=False, description="When ``true`` the IP is unbanned from every jail.", ) #: Discriminator literal for the origin of a ban. BanOrigin = Literal["blocklist", "selfblock"] #: Jail name used by the blocklist import service. BLOCKLIST_JAIL: str = "blocklist-import" def _derive_origin(jail: str) -> BanOrigin: """Derive the ban origin from the jail name. Args: jail: The jail that issued the ban. Returns: ``"blocklist"`` when the jail is the dedicated blocklist-import jail, ``"selfblock"`` otherwise. """ return "blocklist" if jail == BLOCKLIST_JAIL else "selfblock" class Ban(BanGuiBaseModel): """Domain model representing a single active or historical ban record.""" 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.") expires_at: str | None = Field( default=None, description="ISO 8601 UTC expiry timestamp, or ``null`` if permanent.", ) ban_count: int = Field(..., ge=1, description="Number of times this IP was banned.") country: str | None = Field( default=None, description="ISO 3166-1 alpha-2 country code resolved from the IP.", ) origin: BanOrigin = Field( ..., description="Whether this ban came from a blocklist import or fail2ban itself.", ) @field_validator("country") @classmethod def _normalize_empty_country(cls, v: str | None) -> str | None: """Coerce empty strings to None for country. Geo enrichment may produce an empty string instead of None for unresolved IPs, which breaks frontend truthiness checks. """ if v == "": return None return v class BanResponse(BanGuiBaseModel): """Response containing a single ban record.""" ban: Ban class BanListResponse(PaginatedListResponse[Ban]): """Paginated list of ban records. Request: `GET /api/bans` with optional pagination and filter parameters. Response: Paginated collection of ban records with total count. Note: Unlike most list endpoints, this endpoint uses `page` and `page_size` for pagination. When using this response, ensure the router provides these fields. """ pass class ActiveBan(BanGuiBaseModel): """A currently active ban entry returned by ``GET /api/bans/active``.""" ip: str = Field(..., description="Banned IP address.") jail: str = Field(..., description="Jail holding the ban.") banned_at: str | None = Field(default=None, description="ISO 8601 UTC start of the ban.") expires_at: str | None = Field( default=None, description="ISO 8601 UTC expiry, or ``null`` if permanent.", ) ban_count: int = Field(default=1, ge=1, description="Running ban count for this IP.") country: str | None = Field(default=None, description="ISO 3166-1 alpha-2 country code.") @field_validator("country") @classmethod def _normalize_empty_country(cls, v: str | None) -> str | None: """Coerce empty strings to None for country. Geo enrichment may produce an empty string instead of None for unresolved IPs, which breaks frontend truthiness checks. """ if v == "": return None return v class ActiveBanListResponse(CollectionResponse[ActiveBan]): """List of all currently active bans across all jails. Request: `GET /api/bans/active` with optional filter parameters. Response: Non-paginated collection of currently active bans with total count. Note: This endpoint does not support pagination. All matching bans are returned. For paginated results, use individual jail endpoints or the dashboard ban-list view. """ pass class UnbanAllResponse(BanGuiBaseModel): """Response for ``DELETE /api/bans/all``.""" message: str = Field(..., description="Human-readable summary of the operation.") count: int = Field(..., ge=0, description="Number of IPs that were unbanned.") # --------------------------------------------------------------------------- # Dashboard ban-list view models # --------------------------------------------------------------------------- class DashboardBanItem(BanGuiBaseModel): """A single row in the dashboard ban-list table. Populated from the fail2ban database and enriched with geo 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.") service: str | None = Field( default=None, description="First matched log line — used as context for the ban.", ) 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.", ) ban_count: int = Field(..., ge=1, description="How many times this IP was banned.") origin: BanOrigin = Field( ..., description="Whether this ban came from a blocklist import or fail2ban itself.", ) @field_validator("country_code") @classmethod def _normalize_empty_country_code(cls, v: str | None) -> str | None: """Coerce empty strings to None for country_code. The geo enrichment layer may produce an empty string instead of None for unresolved IPs. Frontend type narrowing uses truthiness, so an empty string would slip through ``if (ban.country_code)`` checks and appear as a falsy-but-not-null value — breaking UI rendering. """ if v == "": return None return v class DashboardBanListResponse(PaginatedListResponse[DashboardBanItem]): """Paginated dashboard ban-list response. Request: `GET /api/dashboard/bans` with time range, page, and filter parameters. Response: Paginated collection of dashboard ban items with geo-enrichment. """ pass class BansByCountryResponse(BanGuiBaseModel): """Response for the bans-by-country aggregation endpoint. Contains a per-country ban count, a human-readable country name map, and the full (un-paginated) ban list for the selected time window so the frontend can render both the world map and its companion table from a single request. """ countries: dict[str, int] = Field( default_factory=dict, description="ISO 3166-1 alpha-2 country code → ban count.", ) country_names: dict[str, str] = Field( default_factory=dict, description="ISO 3166-1 alpha-2 country code → human-readable country name.", ) bans: list[DashboardBanItem] = Field( default_factory=list, description="All bans in the selected time window (up to the server limit).", ) total: int = Field(..., ge=0, description="Total ban count in the window.") # --------------------------------------------------------------------------- # Trend endpoint models class BanTrendBucket(BanGuiBaseModel): """A single time bucket in the ban trend series.""" timestamp: str = Field(..., description="ISO 8601 UTC start of the bucket.") count: int = Field(..., ge=0, description="Number of bans that started in this bucket.") class BanTrendResponse(BanGuiBaseModel): """Response for the ``GET /api/dashboard/bans/trend`` endpoint.""" buckets: list[BanTrendBucket] = Field( default_factory=list, description="Time-ordered list of ban-count buckets covering the full window.", ) bucket_size: str = Field( ..., description="Human-readable bucket size label (e.g. '1h', '6h', '1d', '7d').", ) # --------------------------------------------------------------------------- # By-jail endpoint models # --------------------------------------------------------------------------- class JailBanCount(BanGuiBaseModel): """A single jail entry in the bans-by-jail aggregation.""" jail: str = Field(..., description="Jail name.") count: int = Field(..., ge=0, description="Number of bans recorded in this jail.") class BansByJailResponse(BanGuiBaseModel): """Response for the ``GET /api/dashboard/bans/by-jail`` endpoint.""" jails: list[JailBanCount] = Field( default_factory=list, description="Jails ordered by ban count descending.", ) total: int = Field(..., ge=0, description="Total ban count in the selected window.") # --------------------------------------------------------------------------- # Jail-specific paginated bans # --------------------------------------------------------------------------- class JailBannedIpsResponse(PaginatedListResponse[ActiveBan]): """Paginated response for ``GET /api/jails/{name}/banned``. Contains only the current page of active ban entries for a single jail, geo-enriched exclusively for the page slice to avoid rate-limit issues. Request: `GET /api/jails/{name}/banned` with page and page_size parameters. Response: Paginated collection of active bans for the specified jail. """ pass