No canonical snake_case/camelCase serialization policy

This commit is contained in:
2026-04-28 21:27:26 +02:00
parent b27765928a
commit ad21590f60
14 changed files with 186 additions and 475 deletions

View File

@@ -465,18 +465,14 @@ class BansByCountryResponse(BaseModel):
## 5. Pydantic Models ## 5. Pydantic Models
- Every model inherits from `pydantic.BaseModel`. ### Base Class
- Use `model_config = ConfigDict(strict=True)` where appropriate.
- Field names use **snake_case** in Python, export as **camelCase** to the frontend via alias generators if needed. Every model in `app/models/` **must** inherit from `BanGuiBaseModel` (defined in `app/models/response.py`), not from `pydantic.BaseModel` directly.
- Validate at the boundary — once data enters a Pydantic model it is trusted.
- Use `Field(...)` with descriptions for every field to keep auto-generated docs useful.
- Separate **request models**, **response models**, and **domain (internal) models** — do not reuse one model for all three.
```python ```python
from pydantic import BaseModel, Field from app.models.response import BanGuiBaseModel
from datetime import datetime
class BanResponse(BaseModel): class BanResponse(BanGuiBaseModel):
ip: str = Field(..., description="Banned IP address") ip: str = Field(..., description="Banned IP address")
jail: str = Field(..., description="Jail that issued the ban") jail: str = Field(..., description="Jail that issued the ban")
banned_at: datetime = Field(..., description="UTC timestamp of the ban") banned_at: datetime = Field(..., description="UTC timestamp of the ban")
@@ -484,6 +480,24 @@ class BanResponse(BaseModel):
ban_count: int = Field(..., ge=1, description="Number of times this IP was banned") ban_count: int = Field(..., ge=1, description="Number of times this IP was banned")
``` ```
`BanGuiBaseModel` sets `strict=True` and documents the naming policy. Do **not** override `model_config` on individual models unless you have a specific, documented reason.
### API Field Naming Policy — snake_case everywhere
All API field names use **`snake_case`** in Python, in the JSON wire format, and in the corresponding TypeScript interfaces. There is no `alias_generator` that converts to camelCase.
- ✅ Python field: `active_jails` → JSON key: `"active_jails"` → TypeScript property: `active_jails`
- ❌ Do **not** add a camelCase `alias_generator` to individual models.
- ❌ Do **not** mix field name conventions within a single API response.
This policy eliminates a whole class of frontendbackend contract bugs. If the naming policy ever needs to change (e.g. to emit camelCase), change `BanGuiBaseModel` once — all models update automatically.
### Other Model Rules
- Validate at the boundary — once data enters a Pydantic model it is trusted.
- Use `Field(...)` with descriptions for every field to keep auto-generated docs useful.
- Separate **request models**, **response models**, and **domain (internal) models** — do not reuse one model for all three.
### Using `Literal` Types for Constrained Strings ### Using `Literal` Types for Constrained Strings
When a field should only accept a small set of predefined values, use `Literal` to enforce this at the type level: When a field should only accept a small set of predefined values, use `Literal` to enforce this at the type level:

View File

@@ -1,24 +1,3 @@
## 24) API response wrapper shape is inconsistent
- Where found:
- [backend/app/routers/dashboard.py](backend/app/routers/dashboard.py)
- [backend/app/routers/jails.py](backend/app/routers/jails.py)
- [frontend/src/types](frontend/src/types)
- Why this is needed:
- Inconsistent payload envelopes increase frontend branching and integration bugs.
- Goal:
- Define and enforce a consistent response envelope policy.
- What to do:
- Standardize endpoint response forms.
- Align frontend typing and parsing strategy.
- Possible traps and issues:
- Breaking contract for existing clients.
- Docs changes needed:
- Add API response style guide.
- Doc references:
- [Docs/Backend-Development.md](Docs/Backend-Development.md)
---
## 25) No canonical snake_case/camelCase serialization policy ## 25) No canonical snake_case/camelCase serialization policy
- Where found: - Where found:
- [backend/app/models/server.py](backend/app/models/server.py) - [backend/app/models/server.py](backend/app/models/server.py)

View File

@@ -966,6 +966,15 @@ This pattern prevents **stale session flicker** — the brief moment when a user
| Directories | lowercase kebabcase or camelCase | `components/`, `hooks/` | | Directories | lowercase kebabcase or camelCase | `components/`, `hooks/` |
| Boolean props/variables | `is`/`has`/`should` prefix | `isLoading`, `hasError` | | Boolean props/variables | `is`/`has`/`should` prefix | `isLoading`, `hasError` |
### API Field Names — snake_case
All TypeScript interface properties that mirror backend API responses use **`snake_case`**, not `camelCase`. This matches the JSON wire format emitted by the backend without any transformation layer.
- ✅ `active_jails`, `total_bans`, `log_level`, `db_purge_age`
- ❌ `activeJails`, `totalBans`, `logLevel`, `dbPurgeAge` (do **not** use camelCase for API shapes)
This applies to all interfaces in `src/types/`. Internal component state, props, and hook return values use `camelCase` as normal TypeScript convention.
--- ---
## 9. Linting & Formatting ## 9. Linting & Formatting

View File

@@ -3,22 +3,20 @@
Request, response, and domain models used by the auth router and service. Request, response, and domain models used by the auth router and service.
""" """
from pydantic import BaseModel, ConfigDict, Field from pydantic import Field
from app.models.response import BanGuiBaseModel
class LoginRequest(BaseModel): class LoginRequest(BanGuiBaseModel):
"""Payload for ``POST /api/auth/login``.""" """Payload for ``POST /api/auth/login``."""
model_config = ConfigDict(strict=True)
password: str = Field( password: str = Field(
..., ...,
max_length=72, max_length=72,
description="Master password to authenticate with (max 72 bytes due to bcrypt truncation).", description="Master password to authenticate with (max 72 bytes due to bcrypt truncation).",
) )
class LoginResponse(BanGuiBaseModel):
class LoginResponse(BaseModel):
"""Successful login response. """Successful login response.
The session token is set as an ``HttpOnly`` ``SameSite=Lax`` cookie by the The session token is set as an ``HttpOnly`` ``SameSite=Lax`` cookie by the
@@ -30,24 +28,16 @@ class LoginResponse(BaseModel):
use ``POST /api/auth/token`` instead, which does not set a cookie. use ``POST /api/auth/token`` instead, which does not set a cookie.
""" """
model_config = ConfigDict(strict=True)
expires_at: str = Field(..., description="ISO 8601 UTC expiry timestamp.") expires_at: str = Field(..., description="ISO 8601 UTC expiry timestamp.")
class LogoutResponse(BanGuiBaseModel):
class LogoutResponse(BaseModel):
"""Response body for ``POST /api/auth/logout``.""" """Response body for ``POST /api/auth/logout``."""
model_config = ConfigDict(strict=True)
message: str = Field(default="Logged out successfully.") message: str = Field(default="Logged out successfully.")
class Session(BanGuiBaseModel):
class Session(BaseModel):
"""Internal domain model representing a persisted session record.""" """Internal domain model representing a persisted session record."""
model_config = ConfigDict(strict=True)
id: int = Field(..., description="Auto-incremented row ID.") id: int = Field(..., description="Auto-incremented row ID.")
token: str = Field(..., description="Opaque session token.") token: str = Field(..., description="Opaque session token.")
created_at: str = Field(..., description="ISO 8601 UTC creation timestamp.") created_at: str = Field(..., description="ISO 8601 UTC creation timestamp.")

View File

@@ -6,9 +6,10 @@ Request, response, and domain models used by the ban router and service.
import math import math
from typing import Literal from typing import Literal
from pydantic import BaseModel, ConfigDict, Field from pydantic import Field
from app.models.response import BanGuiBaseModel, CollectionResponse, PaginatedListResponse
from app.models.response import CollectionResponse, PaginatedListResponse
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Time-range selector # Time-range selector
@@ -25,21 +26,15 @@ TIME_RANGE_SECONDS: dict[str, int] = {
"365d": 365 * 24 * 3600, "365d": 365 * 24 * 3600,
} }
class BanRequest(BanGuiBaseModel):
class BanRequest(BaseModel):
"""Payload for ``POST /api/bans`` (ban an IP).""" """Payload for ``POST /api/bans`` (ban an IP)."""
model_config = ConfigDict(strict=True)
ip: str = Field(..., description="IP address to ban.") ip: str = Field(..., description="IP address to ban.")
jail: str = Field(..., description="Jail in which to apply the ban.") jail: str = Field(..., description="Jail in which to apply the ban.")
class UnbanRequest(BanGuiBaseModel):
class UnbanRequest(BaseModel):
"""Payload for ``DELETE /api/bans`` (unban an IP).""" """Payload for ``DELETE /api/bans`` (unban an IP)."""
model_config = ConfigDict(strict=True)
ip: str = Field(..., description="IP address to unban.") ip: str = Field(..., description="IP address to unban.")
jail: str | None = Field( jail: str | None = Field(
default=None, default=None,
@@ -50,14 +45,12 @@ class UnbanRequest(BaseModel):
description="When ``true`` the IP is unbanned from every jail.", description="When ``true`` the IP is unbanned from every jail.",
) )
#: Discriminator literal for the origin of a ban. #: Discriminator literal for the origin of a ban.
BanOrigin = Literal["blocklist", "selfblock"] BanOrigin = Literal["blocklist", "selfblock"]
#: Jail name used by the blocklist import service. #: Jail name used by the blocklist import service.
BLOCKLIST_JAIL: str = "blocklist-import" BLOCKLIST_JAIL: str = "blocklist-import"
def _derive_origin(jail: str) -> BanOrigin: def _derive_origin(jail: str) -> BanOrigin:
"""Derive the ban origin from the jail name. """Derive the ban origin from the jail name.
@@ -70,12 +63,9 @@ def _derive_origin(jail: str) -> BanOrigin:
""" """
return "blocklist" if jail == BLOCKLIST_JAIL else "selfblock" return "blocklist" if jail == BLOCKLIST_JAIL else "selfblock"
class Ban(BanGuiBaseModel):
class Ban(BaseModel):
"""Domain model representing a single active or historical ban record.""" """Domain model representing a single active or historical ban record."""
model_config = ConfigDict(strict=True)
ip: str = Field(..., description="Banned IP address.") ip: str = Field(..., description="Banned IP address.")
jail: str = Field(..., description="Jail that issued the ban.") jail: str = Field(..., description="Jail that issued the ban.")
banned_at: str = Field(..., description="ISO 8601 UTC timestamp of the ban.") banned_at: str = Field(..., description="ISO 8601 UTC timestamp of the ban.")
@@ -93,15 +83,11 @@ class Ban(BaseModel):
description="Whether this ban came from a blocklist import or fail2ban itself.", description="Whether this ban came from a blocklist import or fail2ban itself.",
) )
class BanResponse(BanGuiBaseModel):
class BanResponse(BaseModel):
"""Response containing a single ban record.""" """Response containing a single ban record."""
model_config = ConfigDict(strict=True)
ban: Ban ban: Ban
class BanListResponse(PaginatedListResponse[Ban]): class BanListResponse(PaginatedListResponse[Ban]):
"""Paginated list of ban records. """Paginated list of ban records.
@@ -114,12 +100,9 @@ class BanListResponse(PaginatedListResponse[Ban]):
pass pass
class ActiveBan(BanGuiBaseModel):
class ActiveBan(BaseModel):
"""A currently active ban entry returned by ``GET /api/bans/active``.""" """A currently active ban entry returned by ``GET /api/bans/active``."""
model_config = ConfigDict(strict=True)
ip: str = Field(..., description="Banned IP address.") ip: str = Field(..., description="Banned IP address.")
jail: str = Field(..., description="Jail holding the ban.") jail: str = Field(..., description="Jail holding the ban.")
banned_at: str | None = Field(default=None, description="ISO 8601 UTC start of the ban.") banned_at: str | None = Field(default=None, description="ISO 8601 UTC start of the ban.")
@@ -130,7 +113,6 @@ class ActiveBan(BaseModel):
ban_count: int = Field(default=1, ge=1, description="Running ban count for this IP.") 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.") country: str | None = Field(default=None, description="ISO 3166-1 alpha-2 country code.")
class ActiveBanListResponse(CollectionResponse[ActiveBan]): class ActiveBanListResponse(CollectionResponse[ActiveBan]):
"""List of all currently active bans across all jails. """List of all currently active bans across all jails.
@@ -143,29 +125,22 @@ class ActiveBanListResponse(CollectionResponse[ActiveBan]):
pass pass
class UnbanAllResponse(BanGuiBaseModel):
class UnbanAllResponse(BaseModel):
"""Response for ``DELETE /api/bans/all``.""" """Response for ``DELETE /api/bans/all``."""
model_config = ConfigDict(strict=True)
message: str = Field(..., description="Human-readable summary of the operation.") message: str = Field(..., description="Human-readable summary of the operation.")
count: int = Field(..., ge=0, description="Number of IPs that were unbanned.") count: int = Field(..., ge=0, description="Number of IPs that were unbanned.")
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Dashboard ban-list view models # Dashboard ban-list view models
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class DashboardBanItem(BanGuiBaseModel):
class DashboardBanItem(BaseModel):
"""A single row in the dashboard ban-list table. """A single row in the dashboard ban-list table.
Populated from the fail2ban database and enriched with geo data. Populated from the fail2ban database and enriched with geo data.
""" """
model_config = ConfigDict(strict=True)
ip: str = Field(..., description="Banned IP address.") ip: str = Field(..., description="Banned IP address.")
jail: str = Field(..., description="Jail that issued the ban.") jail: str = Field(..., description="Jail that issued the ban.")
banned_at: str = Field(..., description="ISO 8601 UTC timestamp of the ban.") banned_at: str = Field(..., description="ISO 8601 UTC timestamp of the ban.")
@@ -195,7 +170,6 @@ class DashboardBanItem(BaseModel):
description="Whether this ban came from a blocklist import or fail2ban itself.", description="Whether this ban came from a blocklist import or fail2ban itself.",
) )
class DashboardBanListResponse(PaginatedListResponse[DashboardBanItem]): class DashboardBanListResponse(PaginatedListResponse[DashboardBanItem]):
"""Paginated dashboard ban-list response. """Paginated dashboard ban-list response.
@@ -205,8 +179,7 @@ class DashboardBanListResponse(PaginatedListResponse[DashboardBanItem]):
pass pass
class BansByCountryResponse(BanGuiBaseModel):
class BansByCountryResponse(BaseModel):
"""Response for the bans-by-country aggregation endpoint. """Response for the bans-by-country aggregation endpoint.
Contains a per-country ban count, a human-readable country name map, and Contains a per-country ban count, a human-readable country name map, and
@@ -215,8 +188,6 @@ class BansByCountryResponse(BaseModel):
single request. single request.
""" """
model_config = ConfigDict(strict=True)
countries: dict[str, int] = Field( countries: dict[str, int] = Field(
default_factory=dict, default_factory=dict,
description="ISO 3166-1 alpha-2 country code → ban count.", description="ISO 3166-1 alpha-2 country code → ban count.",
@@ -231,7 +202,6 @@ class BansByCountryResponse(BaseModel):
) )
total: int = Field(..., ge=0, description="Total ban count in the window.") total: int = Field(..., ge=0, description="Total ban count in the window.")
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Trend endpoint models # Trend endpoint models
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -252,7 +222,6 @@ BUCKET_SIZE_LABEL: dict[str, str] = {
"365d": "7d", "365d": "7d",
} }
def bucket_count(range_: TimeRange) -> int: def bucket_count(range_: TimeRange) -> int:
"""Return the number of buckets needed to cover *range_* completely. """Return the number of buckets needed to cover *range_* completely.
@@ -266,21 +235,15 @@ def bucket_count(range_: TimeRange) -> int:
""" """
return math.ceil(TIME_RANGE_SECONDS[range_] / BUCKET_SECONDS[range_]) return math.ceil(TIME_RANGE_SECONDS[range_] / BUCKET_SECONDS[range_])
class BanTrendBucket(BanGuiBaseModel):
class BanTrendBucket(BaseModel):
"""A single time bucket in the ban trend series.""" """A single time bucket in the ban trend series."""
model_config = ConfigDict(strict=True)
timestamp: str = Field(..., description="ISO 8601 UTC start of the bucket.") 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.") count: int = Field(..., ge=0, description="Number of bans that started in this bucket.")
class BanTrendResponse(BanGuiBaseModel):
class BanTrendResponse(BaseModel):
"""Response for the ``GET /api/dashboard/bans/trend`` endpoint.""" """Response for the ``GET /api/dashboard/bans/trend`` endpoint."""
model_config = ConfigDict(strict=True)
buckets: list[BanTrendBucket] = Field( buckets: list[BanTrendBucket] = Field(
default_factory=list, default_factory=list,
description="Time-ordered list of ban-count buckets covering the full window.", description="Time-ordered list of ban-count buckets covering the full window.",
@@ -290,38 +253,29 @@ class BanTrendResponse(BaseModel):
description="Human-readable bucket size label (e.g. '1h', '6h', '1d', '7d').", description="Human-readable bucket size label (e.g. '1h', '6h', '1d', '7d').",
) )
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# By-jail endpoint models # By-jail endpoint models
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class JailBanCount(BanGuiBaseModel):
class JailBanCount(BaseModel):
"""A single jail entry in the bans-by-jail aggregation.""" """A single jail entry in the bans-by-jail aggregation."""
model_config = ConfigDict(strict=True)
jail: str = Field(..., description="Jail name.") jail: str = Field(..., description="Jail name.")
count: int = Field(..., ge=0, description="Number of bans recorded in this jail.") count: int = Field(..., ge=0, description="Number of bans recorded in this jail.")
class BansByJailResponse(BanGuiBaseModel):
class BansByJailResponse(BaseModel):
"""Response for the ``GET /api/dashboard/bans/by-jail`` endpoint.""" """Response for the ``GET /api/dashboard/bans/by-jail`` endpoint."""
model_config = ConfigDict(strict=True)
jails: list[JailBanCount] = Field( jails: list[JailBanCount] = Field(
default_factory=list, default_factory=list,
description="Jails ordered by ban count descending.", description="Jails ordered by ban count descending.",
) )
total: int = Field(..., ge=0, description="Total ban count in the selected window.") total: int = Field(..., ge=0, description="Total ban count in the selected window.")
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Jail-specific paginated bans # Jail-specific paginated bans
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class JailBannedIpsResponse(PaginatedListResponse[ActiveBan]): class JailBannedIpsResponse(PaginatedListResponse[ActiveBan]):
"""Paginated response for ``GET /api/jails/{name}/banned``. """Paginated response for ``GET /api/jails/{name}/banned``.

View File

@@ -8,18 +8,18 @@ from __future__ import annotations
from enum import StrEnum from enum import StrEnum
from pydantic import AnyHttpUrl, BaseModel, ConfigDict, Field from pydantic import AnyHttpUrl, Field
from app.models.response import BanGuiBaseModel
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Blocklist source # Blocklist source
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class BlocklistSource(BanGuiBaseModel):
class BlocklistSource(BaseModel):
"""Domain model for a blocklist source definition.""" """Domain model for a blocklist source definition."""
model_config = ConfigDict(strict=True)
id: int id: int
name: str name: str
url: str url: str
@@ -27,8 +27,7 @@ class BlocklistSource(BaseModel):
created_at: str created_at: str
updated_at: str updated_at: str
class BlocklistSourceCreate(BanGuiBaseModel):
class BlocklistSourceCreate(BaseModel):
"""Payload for ``POST /api/blocklists``. """Payload for ``POST /api/blocklists``.
URL must use http/https scheme. The hostname must resolve to a public IP URL must use http/https scheme. The hostname must resolve to a public IP
@@ -36,44 +35,32 @@ class BlocklistSourceCreate(BaseModel):
asynchronously in the service layer. asynchronously in the service layer.
""" """
model_config = ConfigDict(strict=True)
name: str = Field(..., min_length=1, max_length=100, description="Human-readable source name.") name: str = Field(..., min_length=1, max_length=100, description="Human-readable source name.")
url: AnyHttpUrl = Field(..., description="URL of the blocklist file (http/https only).") url: AnyHttpUrl = Field(..., description="URL of the blocklist file (http/https only).")
enabled: bool = Field(default=True) enabled: bool = Field(default=True)
class BlocklistSourceUpdate(BanGuiBaseModel):
class BlocklistSourceUpdate(BaseModel):
"""Payload for ``PUT /api/blocklists/{id}``. All fields are optional. """Payload for ``PUT /api/blocklists/{id}``. All fields are optional.
If URL is provided, it must use http/https scheme. If URL is provided, it must use http/https scheme.
""" """
model_config = ConfigDict(strict=True)
name: str | None = Field(default=None, min_length=1, max_length=100) name: str | None = Field(default=None, min_length=1, max_length=100)
url: AnyHttpUrl | None = Field(default=None) url: AnyHttpUrl | None = Field(default=None)
enabled: bool | None = Field(default=None) enabled: bool | None = Field(default=None)
class BlocklistListResponse(BanGuiBaseModel):
class BlocklistListResponse(BaseModel):
"""Response for ``GET /api/blocklists``.""" """Response for ``GET /api/blocklists``."""
model_config = ConfigDict(strict=True)
sources: list[BlocklistSource] = Field(default_factory=list) sources: list[BlocklistSource] = Field(default_factory=list)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Import log # Import log
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class ImportLogEntry(BanGuiBaseModel):
class ImportLogEntry(BaseModel):
"""A single blocklist import run record.""" """A single blocklist import run record."""
model_config = ConfigDict(strict=True)
id: int id: int
source_id: int | None source_id: int | None
source_url: str source_url: str
@@ -82,24 +69,19 @@ class ImportLogEntry(BaseModel):
ips_skipped: int ips_skipped: int
errors: str | None errors: str | None
class ImportLogListResponse(BanGuiBaseModel):
class ImportLogListResponse(BaseModel):
"""Response for ``GET /api/blocklists/log``.""" """Response for ``GET /api/blocklists/log``."""
model_config = ConfigDict(strict=True)
items: list[ImportLogEntry] = Field(default_factory=list) items: list[ImportLogEntry] = Field(default_factory=list)
total: int = Field(..., ge=0) total: int = Field(..., ge=0)
page: int = Field(default=1, ge=1) page: int = Field(default=1, ge=1)
page_size: int = Field(default=50, ge=1) page_size: int = Field(default=50, ge=1)
total_pages: int = Field(default=1, ge=1) total_pages: int = Field(default=1, ge=1)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Schedule # Schedule
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class ScheduleFrequency(StrEnum): class ScheduleFrequency(StrEnum):
"""Available import schedule frequency presets.""" """Available import schedule frequency presets."""
@@ -107,8 +89,7 @@ class ScheduleFrequency(StrEnum):
daily = "daily" daily = "daily"
weekly = "weekly" weekly = "weekly"
class ScheduleConfig(BanGuiBaseModel):
class ScheduleConfig(BaseModel):
"""Import schedule configuration. """Import schedule configuration.
The interpretation of fields depends on *frequency*: The interpretation of fields depends on *frequency*:
@@ -132,57 +113,43 @@ class ScheduleConfig(BaseModel):
description="Day of week for weekly runs (0=Monday … 6=Sunday)", description="Day of week for weekly runs (0=Monday … 6=Sunday)",
) )
class ScheduleInfo(BanGuiBaseModel):
class ScheduleInfo(BaseModel):
"""Current schedule configuration together with runtime metadata.""" """Current schedule configuration together with runtime metadata."""
model_config = ConfigDict(strict=True)
config: ScheduleConfig config: ScheduleConfig
next_run_at: str | None next_run_at: str | None
last_run_at: str | None last_run_at: str | None
last_run_errors: bool | None = None last_run_errors: bool | None = None
"""``True`` if the most recent import had errors, ``False`` if clean, ``None`` if never run.""" """``True`` if the most recent import had errors, ``False`` if clean, ``None`` if never run."""
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Import results # Import results
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class ImportSourceResult(BanGuiBaseModel):
class ImportSourceResult(BaseModel):
"""Result of importing a single blocklist source.""" """Result of importing a single blocklist source."""
model_config = ConfigDict(strict=True)
source_id: int | None source_id: int | None
source_url: str source_url: str
ips_imported: int ips_imported: int
ips_skipped: int ips_skipped: int
error: str | None error: str | None
class ImportRunResult(BanGuiBaseModel):
class ImportRunResult(BaseModel):
"""Aggregated result from a full import run across all enabled sources.""" """Aggregated result from a full import run across all enabled sources."""
model_config = ConfigDict(strict=True)
results: list[ImportSourceResult] = Field(default_factory=list) results: list[ImportSourceResult] = Field(default_factory=list)
total_imported: int total_imported: int
total_skipped: int total_skipped: int
errors_count: int errors_count: int
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Preview # Preview
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class PreviewResponse(BanGuiBaseModel):
class PreviewResponse(BaseModel):
"""Response for ``GET /api/blocklists/{id}/preview``.""" """Response for ``GET /api/blocklists/{id}/preview``."""
model_config = ConfigDict(strict=True)
entries: list[str] = Field(default_factory=list, description="Sample of valid IP entries") entries: list[str] = Field(default_factory=list, description="Sample of valid IP entries")
total_lines: int total_lines: int
valid_count: int valid_count: int

View File

@@ -7,10 +7,10 @@ import datetime
from pathlib import Path from pathlib import Path
from typing import Literal from typing import Literal
from pydantic import BaseModel, ConfigDict, Field, field_validator from pydantic import Field, field_validator
from app.config import get_settings from app.config import get_settings
from app.models.response import CollectionResponse from app.models.response import BanGuiBaseModel, CollectionResponse
from app.utils.path_utils import validate_log_path from app.utils.path_utils import validate_log_path
DNSMode = Literal["yes", "warn", "no", "raw"] DNSMode = Literal["yes", "warn", "no", "raw"]
@@ -23,12 +23,9 @@ LogTarget = Literal["STDOUT", "STDERR", "SYSLOG"]
# Ban-time escalation # Ban-time escalation
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class BantimeEscalation(BanGuiBaseModel):
class BantimeEscalation(BaseModel):
"""Incremental ban-time escalation configuration for a jail.""" """Incremental ban-time escalation configuration for a jail."""
model_config = ConfigDict(strict=True)
increment: bool = Field( increment: bool = Field(
default=False, default=False,
description="Whether incremental banning is enabled.", description="Whether incremental banning is enabled.",
@@ -58,12 +55,9 @@ class BantimeEscalation(BaseModel):
description="Count repeat offences across all jails, not just the current one.", description="Count repeat offences across all jails, not just the current one.",
) )
class BantimeEscalationUpdate(BanGuiBaseModel):
class BantimeEscalationUpdate(BaseModel):
"""Partial update payload for ban-time escalation settings.""" """Partial update payload for ban-time escalation settings."""
model_config = ConfigDict(strict=True)
increment: bool | None = Field(default=None) increment: bool | None = Field(default=None)
factor: float | None = Field(default=None) factor: float | None = Field(default=None)
formula: str | None = Field(default=None) formula: str | None = Field(default=None)
@@ -72,17 +66,13 @@ class BantimeEscalationUpdate(BaseModel):
rnd_time: int | None = Field(default=None) rnd_time: int | None = Field(default=None)
overall_jails: bool | None = Field(default=None) overall_jails: bool | None = Field(default=None)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Jail configuration models # Jail configuration models
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class JailConfig(BanGuiBaseModel):
class JailConfig(BaseModel):
"""Configuration snapshot of a single jail (editable fields).""" """Configuration snapshot of a single jail (editable fields)."""
model_config = ConfigDict(strict=True)
name: str = Field(..., description="Jail name as configured in fail2ban.") name: str = Field(..., description="Jail name as configured in fail2ban.")
ban_time: int = Field(..., description="Ban duration in seconds. -1 for permanent.") ban_time: int = Field(..., description="Ban duration in seconds. -1 for permanent.")
max_retry: int = Field(..., ge=1, description="Number of failures before a ban is issued.") max_retry: int = Field(..., ge=1, description="Number of failures before a ban is issued.")
@@ -101,15 +91,11 @@ class JailConfig(BaseModel):
description="Incremental ban-time escalation settings, or None if not configured.", description="Incremental ban-time escalation settings, or None if not configured.",
) )
class JailConfigResponse(BanGuiBaseModel):
class JailConfigResponse(BaseModel):
"""Response for ``GET /api/config/jails/{name}``.""" """Response for ``GET /api/config/jails/{name}``."""
model_config = ConfigDict(strict=True)
jail: JailConfig jail: JailConfig
class JailConfigListResponse(CollectionResponse[JailConfig]): class JailConfigListResponse(CollectionResponse[JailConfig]):
"""Response for ``GET /api/config/jails``. """Response for ``GET /api/config/jails``.
@@ -118,12 +104,9 @@ class JailConfigListResponse(CollectionResponse[JailConfig]):
pass pass
class JailConfigUpdate(BanGuiBaseModel):
class JailConfigUpdate(BaseModel):
"""Payload for ``PUT /api/config/jails/{name}``.""" """Payload for ``PUT /api/config/jails/{name}``."""
model_config = ConfigDict(strict=True)
ban_time: int | None = Field(default=None, description="Ban duration in seconds. -1 for permanent.") ban_time: int | None = Field(default=None, description="Ban duration in seconds. -1 for permanent.")
max_retry: int | None = Field(default=None, ge=1) max_retry: int | None = Field(default=None, ge=1)
find_time: int | None = Field(default=None, ge=1) find_time: int | None = Field(default=None, ge=1)
@@ -140,26 +123,19 @@ class JailConfigUpdate(BaseModel):
description="Incremental ban-time escalation settings to update.", description="Incremental ban-time escalation settings to update.",
) )
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Regex tester models # Regex tester models
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class RegexTestRequest(BanGuiBaseModel):
class RegexTestRequest(BaseModel):
"""Payload for ``POST /api/config/regex-test``.""" """Payload for ``POST /api/config/regex-test``."""
model_config = ConfigDict(strict=True)
log_line: str = Field(..., description="Sample log line to test against.") log_line: str = Field(..., description="Sample log line to test against.")
fail_regex: str = Field(..., description="Regex pattern to match.") fail_regex: str = Field(..., description="Regex pattern to match.")
class RegexTestResponse(BanGuiBaseModel):
class RegexTestResponse(BaseModel):
"""Result of a regex test.""" """Result of a regex test."""
model_config = ConfigDict(strict=True)
matched: bool = Field(..., description="Whether the pattern matched the log line.") matched: bool = Field(..., description="Whether the pattern matched the log line.")
groups: list[str] = Field( groups: list[str] = Field(
default_factory=list, default_factory=list,
@@ -170,17 +146,13 @@ class RegexTestResponse(BaseModel):
description="Compilation error message if the regex is invalid.", description="Compilation error message if the regex is invalid.",
) )
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Global config models # Global config models
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class GlobalConfigResponse(BanGuiBaseModel):
class GlobalConfigResponse(BaseModel):
"""Response for ``GET /api/config/global``.""" """Response for ``GET /api/config/global``."""
model_config = ConfigDict(strict=True)
log_level: LogLevel log_level: LogLevel
log_target: str = Field(..., description="Log target: STDOUT, STDERR, SYSLOG, or a validated file path.") log_target: str = Field(..., description="Log target: STDOUT, STDERR, SYSLOG, or a validated file path.")
db_purge_age: int = Field(..., description="Seconds after which ban records are purged from the fail2ban DB.") db_purge_age: int = Field(..., description="Seconds after which ban records are purged from the fail2ban DB.")
@@ -222,12 +194,9 @@ class GlobalConfigResponse(BaseModel):
f"Log target {value!r} is outside allowed directories: {allowed_dirs_str}" f"Log target {value!r} is outside allowed directories: {allowed_dirs_str}"
) )
class GlobalConfigUpdate(BanGuiBaseModel):
class GlobalConfigUpdate(BaseModel):
"""Payload for ``PUT /api/config/global``.""" """Payload for ``PUT /api/config/global``."""
model_config = ConfigDict(strict=True)
log_level: LogLevel | None = Field( log_level: LogLevel | None = Field(
default=None, default=None,
description="Log level: CRITICAL, ERROR, WARNING, NOTICE, INFO, or DEBUG.", description="Log level: CRITICAL, ERROR, WARNING, NOTICE, INFO, or DEBUG.",
@@ -278,17 +247,13 @@ class GlobalConfigUpdate(BaseModel):
f"Log target {value!r} is outside allowed directories: {allowed_dirs_str}" f"Log target {value!r} is outside allowed directories: {allowed_dirs_str}"
) )
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Log observation / preview models # Log observation / preview models
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class AddLogPathRequest(BanGuiBaseModel):
class AddLogPathRequest(BaseModel):
"""Payload for ``POST /api/config/jails/{name}/logpath``.""" """Payload for ``POST /api/config/jails/{name}/logpath``."""
model_config = ConfigDict(strict=True)
log_path: str = Field(..., description="Absolute path to the log file to monitor.") log_path: str = Field(..., description="Absolute path to the log file to monitor.")
tail: bool = Field( tail: bool = Field(
default=True, default=True,
@@ -311,49 +276,35 @@ class AddLogPathRequest(BaseModel):
""" """
return validate_log_path(value) return validate_log_path(value)
class LogPreviewRequest(BanGuiBaseModel):
class LogPreviewRequest(BaseModel):
"""Payload for ``POST /api/config/preview-log``.""" """Payload for ``POST /api/config/preview-log``."""
model_config = ConfigDict(strict=True)
log_path: str = Field(..., description="Absolute path to the log file to preview.") log_path: str = Field(..., description="Absolute path to the log file to preview.")
fail_regex: str = Field(..., description="Regex pattern to test against log lines.") fail_regex: str = Field(..., description="Regex pattern to test against log lines.")
num_lines: int = Field(default=200, ge=1, le=5000, description="Number of lines to read from the end of the file.") num_lines: int = Field(default=200, ge=1, le=5000, description="Number of lines to read from the end of the file.")
class LogPreviewLine(BanGuiBaseModel):
class LogPreviewLine(BaseModel):
"""A single log line with match information.""" """A single log line with match information."""
model_config = ConfigDict(strict=True)
line: str line: str
matched: bool matched: bool
groups: list[str] = Field(default_factory=list) groups: list[str] = Field(default_factory=list)
class LogPreviewResponse(BanGuiBaseModel):
class LogPreviewResponse(BaseModel):
"""Response for ``POST /api/config/preview-log``.""" """Response for ``POST /api/config/preview-log``."""
model_config = ConfigDict(strict=True)
lines: list[LogPreviewLine] = Field(default_factory=list) lines: list[LogPreviewLine] = Field(default_factory=list)
total_lines: int = Field(..., ge=0) total_lines: int = Field(..., ge=0)
matched_count: int = Field(..., ge=0) matched_count: int = Field(..., ge=0)
regex_error: str | None = Field(default=None, description="Set if the regex failed to compile.") regex_error: str | None = Field(default=None, description="Set if the regex failed to compile.")
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Map color threshold models # Map color threshold models
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class MapColorThresholdsResponse(BanGuiBaseModel):
class MapColorThresholdsResponse(BaseModel):
"""Response for ``GET /api/config/map-thresholds``.""" """Response for ``GET /api/config/map-thresholds``."""
model_config = ConfigDict(strict=True)
threshold_high: int = Field( threshold_high: int = Field(
..., description="Ban count for red coloring." ..., description="Ban count for red coloring."
) )
@@ -364,25 +315,20 @@ class MapColorThresholdsResponse(BaseModel):
..., description="Ban count for green coloring." ..., description="Ban count for green coloring."
) )
class MapColorThresholdsUpdate(BanGuiBaseModel):
class MapColorThresholdsUpdate(BaseModel):
"""Payload for ``PUT /api/config/map-thresholds``.""" """Payload for ``PUT /api/config/map-thresholds``."""
model_config = ConfigDict(strict=True)
threshold_high: int = Field(..., gt=0, description="Ban count for red.") threshold_high: int = Field(..., gt=0, description="Ban count for red.")
threshold_medium: int = Field( threshold_medium: int = Field(
..., gt=0, description="Ban count for yellow." ..., gt=0, description="Ban count for yellow."
) )
threshold_low: int = Field(..., gt=0, description="Ban count for green.") threshold_low: int = Field(..., gt=0, description="Ban count for green.")
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Parsed filter file models # Parsed filter file models
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class FilterConfig(BanGuiBaseModel):
class FilterConfig(BaseModel):
"""Structured representation of a ``filter.d/*.conf`` file. """Structured representation of a ``filter.d/*.conf`` file.
The ``active``, ``used_by_jails``, ``source_file``, and The ``active``, ``used_by_jails``, ``source_file``, and
@@ -393,8 +339,6 @@ class FilterConfig(BaseModel):
these fields carry their default values. these fields carry their default values.
""" """
model_config = ConfigDict(strict=True)
name: str = Field(..., description="Filter base name, e.g. ``sshd``.") name: str = Field(..., description="Filter base name, e.g. ``sshd``.")
filename: str = Field(..., description="Actual filename, e.g. ``sshd.conf``.") filename: str = Field(..., description="Actual filename, e.g. ``sshd.conf``.")
# [INCLUDES] # [INCLUDES]
@@ -458,15 +402,12 @@ class FilterConfig(BaseModel):
), ),
) )
class FilterConfigUpdate(BanGuiBaseModel):
class FilterConfigUpdate(BaseModel):
"""Partial update payload for a parsed filter file. """Partial update payload for a parsed filter file.
Only explicitly set (non-``None``) fields are written back. Only explicitly set (non-``None``) fields are written back.
""" """
model_config = ConfigDict(strict=True)
before: str | None = Field(default=None) before: str | None = Field(default=None)
after: str | None = Field(default=None) after: str | None = Field(default=None)
variables: dict[str, str] | None = Field(default=None) variables: dict[str, str] | None = Field(default=None)
@@ -477,8 +418,7 @@ class FilterConfigUpdate(BaseModel):
datepattern: str | None = Field(default=None) datepattern: str | None = Field(default=None)
journalmatch: str | None = Field(default=None) journalmatch: str | None = Field(default=None)
class FilterUpdateRequest(BanGuiBaseModel):
class FilterUpdateRequest(BaseModel):
"""Payload for ``PUT /api/config/filters/{name}``. """Payload for ``PUT /api/config/filters/{name}``.
Accepts only the user-editable ``[Definition]`` fields. Fields left as Accepts only the user-editable ``[Definition]`` fields. Fields left as
@@ -486,8 +426,6 @@ class FilterUpdateRequest(BaseModel):
preserved. preserved.
""" """
model_config = ConfigDict(strict=True)
failregex: list[str] | None = Field( failregex: list[str] | None = Field(
default=None, default=None,
description="Updated failure-detection regex patterns. ``None`` = keep existing.", description="Updated failure-detection regex patterns. ``None`` = keep existing.",
@@ -505,15 +443,12 @@ class FilterUpdateRequest(BaseModel):
description="Systemd journal match expression. ``None`` = keep existing.", description="Systemd journal match expression. ``None`` = keep existing.",
) )
class FilterCreateRequest(BanGuiBaseModel):
class FilterCreateRequest(BaseModel):
"""Payload for ``POST /api/config/filters``. """Payload for ``POST /api/config/filters``.
Creates a new user-defined filter at ``filter.d/{name}.local``. Creates a new user-defined filter at ``filter.d/{name}.local``.
""" """
model_config = ConfigDict(strict=True)
name: str = Field( name: str = Field(
..., ...,
description="Filter base name (e.g. ``my-custom-filter``). Must not already exist in ``filter.d/``.", description="Filter base name (e.g. ``my-custom-filter``). Must not already exist in ``filter.d/``.",
@@ -539,23 +474,17 @@ class FilterCreateRequest(BaseModel):
description="Systemd journal match expression.", description="Systemd journal match expression.",
) )
class AssignFilterRequest(BanGuiBaseModel):
class AssignFilterRequest(BaseModel):
"""Payload for ``POST /api/config/jails/{jail_name}/filter``.""" """Payload for ``POST /api/config/jails/{jail_name}/filter``."""
model_config = ConfigDict(strict=True)
filter_name: str = Field( filter_name: str = Field(
..., ...,
description="Filter base name to assign to the jail (e.g. ``sshd``).", description="Filter base name to assign to the jail (e.g. ``sshd``).",
) )
class FilterListResponse(BanGuiBaseModel):
class FilterListResponse(BaseModel):
"""Response for ``GET /api/config/filters``.""" """Response for ``GET /api/config/filters``."""
model_config = ConfigDict(strict=True)
filters: list[FilterConfig] = Field( filters: list[FilterConfig] = Field(
default_factory=list, default_factory=list,
description=( description=(
@@ -565,17 +494,13 @@ class FilterListResponse(BaseModel):
) )
total: int = Field(..., ge=0, description="Total number of filters found.") total: int = Field(..., ge=0, description="Total number of filters found.")
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Parsed action file models # Parsed action file models
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class ActionConfig(BanGuiBaseModel):
class ActionConfig(BaseModel):
"""Structured representation of an ``action.d/*.conf`` file.""" """Structured representation of an ``action.d/*.conf`` file."""
model_config = ConfigDict(strict=True)
name: str = Field(..., description="Action base name, e.g. ``iptables``.") name: str = Field(..., description="Action base name, e.g. ``iptables``.")
filename: str = Field(..., description="Actual filename, e.g. ``iptables.conf``.") filename: str = Field(..., description="Actual filename, e.g. ``iptables.conf``.")
# [INCLUDES] # [INCLUDES]
@@ -644,12 +569,9 @@ class ActionConfig(BaseModel):
), ),
) )
class ActionConfigUpdate(BanGuiBaseModel):
class ActionConfigUpdate(BaseModel):
"""Partial update payload for a parsed action file.""" """Partial update payload for a parsed action file."""
model_config = ConfigDict(strict=True)
before: str | None = Field(default=None) before: str | None = Field(default=None)
after: str | None = Field(default=None) after: str | None = Field(default=None)
actionstart: str | None = Field(default=None) actionstart: str | None = Field(default=None)
@@ -661,12 +583,9 @@ class ActionConfigUpdate(BaseModel):
definition_vars: dict[str, str] | None = Field(default=None) definition_vars: dict[str, str] | None = Field(default=None)
init_vars: dict[str, str] | None = Field(default=None) init_vars: dict[str, str] | None = Field(default=None)
class ActionListResponse(BanGuiBaseModel):
class ActionListResponse(BaseModel):
"""Response for ``GET /api/config/actions``.""" """Response for ``GET /api/config/actions``."""
model_config = ConfigDict(strict=True)
actions: list[ActionConfig] = Field( actions: list[ActionConfig] = Field(
default_factory=list, default_factory=list,
description=( description=(
@@ -676,16 +595,13 @@ class ActionListResponse(BaseModel):
) )
total: int = Field(..., ge=0, description="Total number of actions found.") total: int = Field(..., ge=0, description="Total number of actions found.")
class ActionUpdateRequest(BanGuiBaseModel):
class ActionUpdateRequest(BaseModel):
"""Payload for ``PUT /api/config/actions/{name}``. """Payload for ``PUT /api/config/actions/{name}``.
Accepts only the user-editable ``[Definition]`` lifecycle fields and Accepts only the user-editable ``[Definition]`` lifecycle fields and
``[Init]`` parameters. Fields left as ``None`` are not changed. ``[Init]`` parameters. Fields left as ``None`` are not changed.
""" """
model_config = ConfigDict(strict=True)
actionstart: str | None = Field( actionstart: str | None = Field(
default=None, default=None,
description="Updated ``actionstart`` command. ``None`` = keep existing.", description="Updated ``actionstart`` command. ``None`` = keep existing.",
@@ -719,15 +635,12 @@ class ActionUpdateRequest(BaseModel):
description="``[Init]`` parameters to set. ``None`` = keep existing.", description="``[Init]`` parameters to set. ``None`` = keep existing.",
) )
class ActionCreateRequest(BanGuiBaseModel):
class ActionCreateRequest(BaseModel):
"""Payload for ``POST /api/config/actions``. """Payload for ``POST /api/config/actions``.
Creates a new user-defined action at ``action.d/{name}.local``. Creates a new user-defined action at ``action.d/{name}.local``.
""" """
model_config = ConfigDict(strict=True)
name: str = Field( name: str = Field(
..., ...,
description="Action base name (e.g. ``my-custom-action``). Must not already exist.", description="Action base name (e.g. ``my-custom-action``). Must not already exist.",
@@ -747,12 +660,9 @@ class ActionCreateRequest(BaseModel):
description="``[Init]`` runtime parameters.", description="``[Init]`` runtime parameters.",
) )
class AssignActionRequest(BanGuiBaseModel):
class AssignActionRequest(BaseModel):
"""Payload for ``POST /api/config/jails/{jail_name}/action``.""" """Payload for ``POST /api/config/jails/{jail_name}/action``."""
model_config = ConfigDict(strict=True)
action_name: str = Field( action_name: str = Field(
..., ...,
description="Action base name to add to the jail (e.g. ``iptables-multiport``).", description="Action base name to add to the jail (e.g. ``iptables-multiport``).",
@@ -765,17 +675,13 @@ class AssignActionRequest(BaseModel):
), ),
) )
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Jail file config models (Task 6.1) # Jail file config models (Task 6.1)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class JailSectionConfig(BanGuiBaseModel):
class JailSectionConfig(BaseModel):
"""Settings within a single [jailname] section of a jail.d file.""" """Settings within a single [jailname] section of a jail.d file."""
model_config = ConfigDict(strict=True)
enabled: bool | None = Field(default=None, description="Whether this jail is enabled.") enabled: bool | None = Field(default=None, description="Whether this jail is enabled.")
port: str | None = Field(default=None, description="Port(s) to monitor (e.g. 'ssh' or '22,2222').") port: str | None = Field(default=None, description="Port(s) to monitor (e.g. 'ssh' or '22,2222').")
filter: str | None = Field(default=None, description="Filter name to use (e.g. 'sshd').") filter: str | None = Field(default=None, description="Filter name to use (e.g. 'sshd').")
@@ -787,36 +693,28 @@ class JailSectionConfig(BaseModel):
backend: BackendType | None = Field(default=None, description="Log monitoring backend.") backend: BackendType | None = Field(default=None, description="Log monitoring backend.")
extra: dict[str, str] = Field(default_factory=dict, description="Additional settings not captured by named fields.") extra: dict[str, str] = Field(default_factory=dict, description="Additional settings not captured by named fields.")
class JailFileConfig(BanGuiBaseModel):
class JailFileConfig(BaseModel):
"""Structured representation of a jail.d/*.conf file.""" """Structured representation of a jail.d/*.conf file."""
model_config = ConfigDict(strict=True)
filename: str = Field(..., description="Filename including extension (e.g. 'sshd.conf').") filename: str = Field(..., description="Filename including extension (e.g. 'sshd.conf').")
jails: dict[str, JailSectionConfig] = Field( jails: dict[str, JailSectionConfig] = Field(
default_factory=dict, default_factory=dict,
description="Mapping of jail name → settings for each [section] in the file.", description="Mapping of jail name → settings for each [section] in the file.",
) )
class JailFileConfigUpdate(BanGuiBaseModel):
class JailFileConfigUpdate(BaseModel):
"""Partial update payload for a jail.d file.""" """Partial update payload for a jail.d file."""
model_config = ConfigDict(strict=True)
jails: dict[str, JailSectionConfig] | None = Field( jails: dict[str, JailSectionConfig] | None = Field(
default=None, default=None,
description="Jail section updates. Only jails present in this dict are updated.", description="Jail section updates. Only jails present in this dict are updated.",
) )
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Inactive jail models (Stage 1) # Inactive jail models (Stage 1)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class InactiveJail(BanGuiBaseModel):
class InactiveJail(BaseModel):
"""A jail defined in fail2ban config files that is not currently active. """A jail defined in fail2ban config files that is not currently active.
A jail is considered inactive when its ``enabled`` key is ``false`` (or A jail is considered inactive when its ``enabled`` key is ``false`` (or
@@ -825,8 +723,6 @@ class InactiveJail(BaseModel):
running. running.
""" """
model_config = ConfigDict(strict=True)
name: str = Field(..., description="Jail name from the config section header.") name: str = Field(..., description="Jail name from the config section header.")
filter: str = Field( filter: str = Field(
..., ...,
@@ -920,7 +816,6 @@ class InactiveJail(BaseModel):
), ),
) )
class InactiveJailListResponse(CollectionResponse[InactiveJail]): class InactiveJailListResponse(CollectionResponse[InactiveJail]):
"""Response for ``GET /api/config/jails/inactive``. """Response for ``GET /api/config/jails/inactive``.
@@ -929,8 +824,7 @@ class InactiveJailListResponse(CollectionResponse[InactiveJail]):
pass pass
class ActivateJailRequest(BanGuiBaseModel):
class ActivateJailRequest(BaseModel):
"""Optional override values when activating an inactive jail. """Optional override values when activating an inactive jail.
All fields are optional. Omitted fields are not written to the All fields are optional. Omitted fields are not written to the
@@ -938,8 +832,6 @@ class ActivateJailRequest(BaseModel):
values. values.
""" """
model_config = ConfigDict(strict=True)
bantime: str | None = Field( bantime: str | None = Field(
default=None, default=None,
description="Override ban duration, e.g. ``1h`` or ``3600``.", description="Override ban duration, e.g. ``1h`` or ``3600``.",
@@ -962,12 +854,9 @@ class ActivateJailRequest(BaseModel):
description="Override log file paths.", description="Override log file paths.",
) )
class JailActivationResponse(BanGuiBaseModel):
class JailActivationResponse(BaseModel):
"""Response for jail activation and deactivation endpoints.""" """Response for jail activation and deactivation endpoints."""
model_config = ConfigDict(strict=True)
name: str = Field(..., description="Name of the affected jail.") name: str = Field(..., description="Name of the affected jail.")
active: bool = Field( active: bool = Field(
..., ...,
@@ -996,29 +885,22 @@ class JailActivationResponse(BaseModel):
), ),
) )
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Jail validation models (Task 3) # Jail validation models (Task 3)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class JailValidationIssue(BanGuiBaseModel):
class JailValidationIssue(BaseModel):
"""A single issue found during pre-activation validation of a jail config.""" """A single issue found during pre-activation validation of a jail config."""
model_config = ConfigDict(strict=True)
field: str = Field( field: str = Field(
..., ...,
description="Config field associated with this issue, e.g. 'filter', 'failregex', 'logpath'.", description="Config field associated with this issue, e.g. 'filter', 'failregex', 'logpath'.",
) )
message: str = Field(..., description="Human-readable description of the issue.") message: str = Field(..., description="Human-readable description of the issue.")
class JailValidationResult(BanGuiBaseModel):
class JailValidationResult(BaseModel):
"""Result of pre-activation validation of a single jail configuration.""" """Result of pre-activation validation of a single jail configuration."""
model_config = ConfigDict(strict=True)
jail_name: str = Field(..., description="Name of the validated jail.") jail_name: str = Field(..., description="Name of the validated jail.")
valid: bool = Field(..., description="True when no issues were found.") valid: bool = Field(..., description="True when no issues were found.")
issues: list[JailValidationIssue] = Field( issues: list[JailValidationIssue] = Field(
@@ -1026,17 +908,13 @@ class JailValidationResult(BaseModel):
description="Validation issues found. Empty when valid=True.", description="Validation issues found. Empty when valid=True.",
) )
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Rollback response model (Task 3) # Rollback response model (Task 3)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class RollbackResponse(BanGuiBaseModel):
class RollbackResponse(BaseModel):
"""Response for ``POST /api/config/jails/{name}/rollback``.""" """Response for ``POST /api/config/jails/{name}/rollback``."""
model_config = ConfigDict(strict=True)
jail_name: str = Field(..., description="Name of the jail that was disabled.") jail_name: str = Field(..., description="Name of the jail that was disabled.")
disabled: bool = Field( disabled: bool = Field(
..., ...,
@@ -1053,17 +931,13 @@ class RollbackResponse(BaseModel):
) )
message: str = Field(..., description="Human-readable result message.") message: str = Field(..., description="Human-readable result message.")
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Pending recovery model (Task 3) # Pending recovery model (Task 3)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class PendingRecovery(BanGuiBaseModel):
class PendingRecovery(BaseModel):
"""Records a probable activation-caused fail2ban crash pending user action.""" """Records a probable activation-caused fail2ban crash pending user action."""
model_config = ConfigDict(strict=True)
jail_name: str = Field( jail_name: str = Field(
..., ...,
description="Name of the jail whose activation likely caused the crash.", description="Name of the jail whose activation likely caused the crash.",
@@ -1081,29 +955,22 @@ class PendingRecovery(BaseModel):
description="Whether fail2ban has been successfully restarted.", description="Whether fail2ban has been successfully restarted.",
) )
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# fail2ban log viewer models # fail2ban log viewer models
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class Fail2BanLogResponse(BanGuiBaseModel):
class Fail2BanLogResponse(BaseModel):
"""Response for ``GET /api/config/fail2ban-log``.""" """Response for ``GET /api/config/fail2ban-log``."""
model_config = ConfigDict(strict=True)
log_path: str = Field(..., description="Resolved absolute path of the log file being read.") log_path: str = Field(..., description="Resolved absolute path of the log file being read.")
lines: list[str] = Field(default_factory=list, description="Log lines returned (tail, optionally filtered).") lines: list[str] = Field(default_factory=list, description="Log lines returned (tail, optionally filtered).")
total_lines: int = Field(..., ge=0, description="Total number of lines in the file before filtering.") total_lines: int = Field(..., ge=0, description="Total number of lines in the file before filtering.")
log_level: str = Field(..., description="Current fail2ban log level.") log_level: str = Field(..., description="Current fail2ban log level.")
log_target: str = Field(..., description="Current fail2ban log target (file path or special value).") log_target: str = Field(..., description="Current fail2ban log target (file path or special value).")
class ServiceStatusResponse(BanGuiBaseModel):
class ServiceStatusResponse(BaseModel):
"""Response for ``GET /api/config/service-status``.""" """Response for ``GET /api/config/service-status``."""
model_config = ConfigDict(strict=True)
online: bool = Field(..., description="Whether fail2ban is reachable via its socket.") online: bool = Field(..., description="Whether fail2ban is reachable via its socket.")
version: str | None = Field(default=None, description="BanGUI application version (or None when offline).") version: str | None = Field(default=None, description="BanGUI application version (or None when offline).")
jail_count: int = Field(default=0, ge=0, description="Number of currently active jails.") jail_count: int = Field(default=0, ge=0, description="Number of currently active jails.")

View File

@@ -4,18 +4,18 @@ Covers jail config files (``jail.d/``), filter definitions (``filter.d/``),
and action definitions (``action.d/``). and action definitions (``action.d/``).
""" """
from pydantic import BaseModel, ConfigDict, Field from pydantic import Field
from app.models.response import BanGuiBaseModel
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Jail config file models (Task 4a) # Jail config file models (Task 4a)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class JailConfigFile(BanGuiBaseModel):
class JailConfigFile(BaseModel):
"""Metadata for a single jail configuration file in ``jail.d/``.""" """Metadata for a single jail configuration file in ``jail.d/``."""
model_config = ConfigDict(strict=True)
name: str = Field(..., description="Jail name (file stem, e.g. ``sshd``).") name: str = Field(..., description="Jail name (file stem, e.g. ``sshd``).")
filename: str = Field(..., description="Actual filename (e.g. ``sshd.conf``).") filename: str = Field(..., description="Actual filename (e.g. ``sshd.conf``).")
enabled: bool = Field( enabled: bool = Field(
@@ -26,81 +26,56 @@ class JailConfigFile(BaseModel):
), ),
) )
class JailConfigFilesResponse(BanGuiBaseModel):
class JailConfigFilesResponse(BaseModel):
"""Response for ``GET /api/config/jail-files``.""" """Response for ``GET /api/config/jail-files``."""
model_config = ConfigDict(strict=True)
files: list[JailConfigFile] = Field(default_factory=list) files: list[JailConfigFile] = Field(default_factory=list)
total: int = Field(..., ge=0) total: int = Field(..., ge=0)
class JailConfigFileContent(BanGuiBaseModel):
class JailConfigFileContent(BaseModel):
"""Single jail config file with its raw content.""" """Single jail config file with its raw content."""
model_config = ConfigDict(strict=True)
name: str = Field(..., description="Jail name (file stem).") name: str = Field(..., description="Jail name (file stem).")
filename: str = Field(..., description="Actual filename.") filename: str = Field(..., description="Actual filename.")
enabled: bool = Field(..., description="Whether the jail is enabled.") enabled: bool = Field(..., description="Whether the jail is enabled.")
content: str = Field(..., description="Raw file content.") content: str = Field(..., description="Raw file content.")
class JailConfigFileEnabledUpdate(BanGuiBaseModel):
class JailConfigFileEnabledUpdate(BaseModel):
"""Payload for ``PUT /api/config/jail-files/{filename}/enabled``.""" """Payload for ``PUT /api/config/jail-files/{filename}/enabled``."""
model_config = ConfigDict(strict=True)
enabled: bool = Field(..., description="New enabled state for this jail.") enabled: bool = Field(..., description="New enabled state for this jail.")
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Generic conf-file entry (shared by filter.d and action.d) # Generic conf-file entry (shared by filter.d and action.d)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class ConfFileEntry(BanGuiBaseModel):
class ConfFileEntry(BaseModel):
"""Metadata for a single ``.conf`` or ``.local`` file.""" """Metadata for a single ``.conf`` or ``.local`` file."""
model_config = ConfigDict(strict=True)
name: str = Field(..., description="Base name without extension (e.g. ``sshd``).") name: str = Field(..., description="Base name without extension (e.g. ``sshd``).")
filename: str = Field(..., description="Actual filename (e.g. ``sshd.conf``).") filename: str = Field(..., description="Actual filename (e.g. ``sshd.conf``).")
class ConfFilesResponse(BanGuiBaseModel):
class ConfFilesResponse(BaseModel):
"""Response for list endpoints (``GET /api/config/filters`` and ``GET /api/config/actions``).""" """Response for list endpoints (``GET /api/config/filters`` and ``GET /api/config/actions``)."""
model_config = ConfigDict(strict=True)
files: list[ConfFileEntry] = Field(default_factory=list) files: list[ConfFileEntry] = Field(default_factory=list)
total: int = Field(..., ge=0) total: int = Field(..., ge=0)
class ConfFileContent(BanGuiBaseModel):
class ConfFileContent(BaseModel):
"""A conf file with its raw text content.""" """A conf file with its raw text content."""
model_config = ConfigDict(strict=True)
name: str = Field(..., description="Base name without extension.") name: str = Field(..., description="Base name without extension.")
filename: str = Field(..., description="Actual filename.") filename: str = Field(..., description="Actual filename.")
content: str = Field(..., description="Raw file content.") content: str = Field(..., description="Raw file content.")
class ConfFileUpdateRequest(BanGuiBaseModel):
class ConfFileUpdateRequest(BaseModel):
"""Payload for ``PUT /api/config/filters/{name}`` and ``PUT /api/config/actions/{name}``.""" """Payload for ``PUT /api/config/filters/{name}`` and ``PUT /api/config/actions/{name}``."""
model_config = ConfigDict(strict=True)
content: str = Field(..., description="New raw file content (must not exceed 512 KB).") content: str = Field(..., description="New raw file content (must not exceed 512 KB).")
class ConfFileCreateRequest(BanGuiBaseModel):
class ConfFileCreateRequest(BaseModel):
"""Payload for ``POST /api/config/filters`` and ``POST /api/config/actions``.""" """Payload for ``POST /api/config/filters`` and ``POST /api/config/actions``."""
model_config = ConfigDict(strict=True)
name: str = Field( name: str = Field(
..., ...,
description="New file base name (without extension). Must contain only " description="New file base name (without extension). Must contain only "

View File

@@ -9,21 +9,20 @@ from collections.abc import Awaitable, Callable
from dataclasses import dataclass from dataclasses import dataclass
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from pydantic import BaseModel, ConfigDict, Field from pydantic import Field
from app.models.response import BanGuiBaseModel
if TYPE_CHECKING: if TYPE_CHECKING:
import aiohttp import aiohttp
import aiosqlite import aiosqlite
class GeoDetail(BanGuiBaseModel):
class GeoDetail(BaseModel):
"""Enriched geolocation data for an IP address. """Enriched geolocation data for an IP address.
Populated from the ip-api.com free API. Populated from the ip-api.com free API.
""" """
model_config = ConfigDict(strict=True)
country_code: str | None = Field( country_code: str | None = Field(
default=None, default=None,
description="ISO 3166-1 alpha-2 country code.", description="ISO 3166-1 alpha-2 country code.",
@@ -41,15 +40,12 @@ class GeoDetail(BaseModel):
description="Organisation associated with the ASN.", description="Organisation associated with the ASN.",
) )
class GeoCacheEntry(BanGuiBaseModel):
class GeoCacheEntry(BaseModel):
"""A single cached geolocation entry for an IP address. """A single cached geolocation entry for an IP address.
Represents a row from the ``geo_cache`` table in the application database. Represents a row from the ``geo_cache`` table in the application database.
""" """
model_config = ConfigDict(strict=True)
ip: str = Field(..., description="IP address (IPv4 or IPv6).") ip: str = Field(..., description="IP address (IPv4 or IPv6).")
country_code: str | None = Field( country_code: str | None = Field(
default=None, default=None,
@@ -68,43 +64,34 @@ class GeoCacheEntry(BaseModel):
description="Organisation associated with the ASN.", description="Organisation associated with the ASN.",
) )
class GeoCacheStatsResponse(BanGuiBaseModel):
class GeoCacheStatsResponse(BaseModel):
"""Response for ``GET /api/geo/stats``. """Response for ``GET /api/geo/stats``.
Exposes diagnostic counters of the geo cache subsystem so operators Exposes diagnostic counters of the geo cache subsystem so operators
can assess resolution health from the UI or CLI. can assess resolution health from the UI or CLI.
""" """
model_config = ConfigDict(strict=True)
cache_size: int = Field(..., description="Number of positive entries in the in-memory cache.") cache_size: int = Field(..., description="Number of positive entries in the in-memory cache.")
unresolved: int = Field(..., description="Number of geo_cache rows with country_code IS NULL.") unresolved: int = Field(..., description="Number of geo_cache rows with country_code IS NULL.")
neg_cache_size: int = Field(..., description="Number of entries in the in-memory negative cache.") neg_cache_size: int = Field(..., description="Number of entries in the in-memory negative cache.")
dirty_size: int = Field(..., description="Number of newly resolved entries not yet flushed to disk.") dirty_size: int = Field(..., description="Number of newly resolved entries not yet flushed to disk.")
class GeoReResolveResponse(BanGuiBaseModel):
class GeoReResolveResponse(BaseModel):
"""Response for ``POST /api/geo/re-resolve``. """Response for ``POST /api/geo/re-resolve``.
Reports how many previously unresolved IPs were retried and how many Reports how many previously unresolved IPs were retried and how many
gained a resolved country code after the re-resolve operation. gained a resolved country code after the re-resolve operation.
""" """
model_config = ConfigDict(strict=True)
resolved: int = Field(..., description="Number of IPs successfully resolved.") resolved: int = Field(..., description="Number of IPs successfully resolved.")
total: int = Field(..., description="Number of IPs retried.") total: int = Field(..., description="Number of IPs retried.")
class IpLookupResponse(BanGuiBaseModel):
class IpLookupResponse(BaseModel):
"""Response for ``GET /api/geo/lookup/{ip}``. """Response for ``GET /api/geo/lookup/{ip}``.
Aggregates current ban status and geographical information for an IP. Aggregates current ban status and geographical information for an IP.
""" """
model_config = ConfigDict(strict=True)
ip: str = Field(..., description="The queried IP address.") ip: str = Field(..., description="The queried IP address.")
currently_banned_in: list[str] = Field( currently_banned_in: list[str] = Field(
default_factory=list, default_factory=list,
@@ -115,12 +102,10 @@ class IpLookupResponse(BaseModel):
description="Enriched geographical and network information.", description="Enriched geographical and network information.",
) )
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# shared service types # shared service types
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@dataclass @dataclass
class GeoInfo: class GeoInfo:
"""Geo resolution result used throughout backend services.""" """Geo resolution result used throughout backend services."""
@@ -130,7 +115,6 @@ class GeoInfo:
asn: str | None asn: str | None
org: str | None org: str | None
GeoEnricher = Callable[[str], Awaitable[GeoInfo | None]] GeoEnricher = Callable[[str], Awaitable[GeoInfo | None]]
GeoBatchLookup = Callable[ GeoBatchLookup = Callable[
[list[str], "aiohttp.ClientSession", "aiosqlite.Connection | None"], [list[str], "aiohttp.ClientSession", "aiosqlite.Connection | None"],

View File

@@ -5,7 +5,9 @@ Request, response, and domain models used by the history router and service.
from __future__ import annotations from __future__ import annotations
from pydantic import BaseModel, ConfigDict, Field from pydantic import Field
from app.models.response import BanGuiBaseModel
from app.models.ban import TimeRange from app.models.ban import TimeRange
@@ -17,16 +19,13 @@ __all__ = [
"TimeRange", "TimeRange",
] ]
class HistoryBanItem(BanGuiBaseModel):
class HistoryBanItem(BaseModel):
"""A single row in the history ban-list table. """A single row in the history ban-list table.
Populated from the fail2ban database and optionally enriched with Populated from the fail2ban database and optionally enriched with
geolocation data. geolocation data.
""" """
model_config = ConfigDict(strict=True)
ip: str = Field(..., description="Banned IP address.") ip: str = Field(..., description="Banned IP address.")
jail: str = Field(..., description="Jail that issued the ban.") jail: str = Field(..., description="Jail that issued the ban.")
banned_at: str = Field(..., description="ISO 8601 UTC timestamp of the ban.") banned_at: str = Field(..., description="ISO 8601 UTC timestamp of the ban.")
@@ -57,31 +56,24 @@ class HistoryBanItem(BaseModel):
description="Organisation name associated with the IP.", description="Organisation name associated with the IP.",
) )
class HistoryListResponse(BanGuiBaseModel):
class HistoryListResponse(BaseModel):
"""Paginated history ban-list response.""" """Paginated history ban-list response."""
model_config = ConfigDict(strict=True)
items: list[HistoryBanItem] = Field(default_factory=list) items: list[HistoryBanItem] = Field(default_factory=list)
total: int = Field(..., ge=0, description="Total matching records.") total: int = Field(..., ge=0, description="Total matching records.")
page: int = Field(..., ge=1) page: int = Field(..., ge=1)
page_size: int = Field(..., ge=1) page_size: int = Field(..., ge=1)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Per-IP timeline # Per-IP timeline
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class IpTimelineEvent(BanGuiBaseModel):
class IpTimelineEvent(BaseModel):
"""A single ban event in a per-IP timeline. """A single ban event in a per-IP timeline.
Represents one row from the fail2ban ``bans`` table for a specific IP. Represents one row from the fail2ban ``bans`` table for a specific IP.
""" """
model_config = ConfigDict(strict=True)
jail: str = Field(..., description="Jail that triggered this ban.") jail: str = Field(..., description="Jail that triggered this ban.")
banned_at: str = Field(..., description="ISO 8601 UTC timestamp of the ban.") banned_at: str = Field(..., description="ISO 8601 UTC timestamp of the ban.")
ban_count: int = Field( ban_count: int = Field(
@@ -99,16 +91,13 @@ class IpTimelineEvent(BaseModel):
description="Matched log lines that triggered the ban.", description="Matched log lines that triggered the ban.",
) )
class IpDetailResponse(BanGuiBaseModel):
class IpDetailResponse(BaseModel):
"""Full historical record for a single IP address. """Full historical record for a single IP address.
Contains aggregated totals and a chronological timeline of all ban events Contains aggregated totals and a chronological timeline of all ban events
recorded in the fail2ban database for the given IP. recorded in the fail2ban database for the given IP.
""" """
model_config = ConfigDict(strict=True)
ip: str = Field(..., description="The IP address.") ip: str = Field(..., description="The IP address.")
total_bans: int = Field(..., ge=0, description="Total number of ban records.") total_bans: int = Field(..., ge=0, description="Total number of ban records.")
total_failures: int = Field( total_failures: int = Field(

View File

@@ -3,28 +3,22 @@
Request, response, and domain models used by the jails router and service. Request, response, and domain models used by the jails router and service.
""" """
from pydantic import BaseModel, ConfigDict, Field from pydantic import Field
from app.models.config import BantimeEscalation from app.models.config import BantimeEscalation
from app.models.response import CommandResponse, CollectionResponse from app.models.response import BanGuiBaseModel, CommandResponse, CollectionResponse
class JailStatus(BanGuiBaseModel):
class JailStatus(BaseModel):
"""Runtime metrics for a single jail.""" """Runtime metrics for a single jail."""
model_config = ConfigDict(strict=True)
currently_banned: int = Field(..., ge=0) currently_banned: int = Field(..., ge=0)
total_banned: int = Field(..., ge=0) total_banned: int = Field(..., ge=0)
currently_failed: int = Field(..., ge=0) currently_failed: int = Field(..., ge=0)
total_failed: int = Field(..., ge=0) total_failed: int = Field(..., ge=0)
class Jail(BanGuiBaseModel):
class Jail(BaseModel):
"""Domain model for a single fail2ban jail with its full configuration.""" """Domain model for a single fail2ban jail with its full configuration."""
model_config = ConfigDict(strict=True)
name: str = Field(..., description="Jail name as configured in fail2ban.") name: str = Field(..., description="Jail name as configured in fail2ban.")
enabled: bool = Field(..., description="Whether the jail is currently active.") enabled: bool = Field(..., description="Whether the jail is currently active.")
running: bool = Field(..., description="Whether the jail backend is running.") running: bool = Field(..., description="Whether the jail backend is running.")
@@ -46,12 +40,9 @@ class Jail(BaseModel):
) )
status: JailStatus | None = Field(default=None, description="Runtime counters.") status: JailStatus | None = Field(default=None, description="Runtime counters.")
class JailSummary(BanGuiBaseModel):
class JailSummary(BaseModel):
"""Lightweight jail entry for the overview list.""" """Lightweight jail entry for the overview list."""
model_config = ConfigDict(strict=True)
name: str name: str
enabled: bool enabled: bool
running: bool running: bool
@@ -62,7 +53,6 @@ class JailSummary(BaseModel):
max_retry: int max_retry: int
status: JailStatus | None = None status: JailStatus | None = None
class JailListResponse(CollectionResponse[JailSummary]): class JailListResponse(CollectionResponse[JailSummary]):
"""Response for ``GET /api/jails``. """Response for ``GET /api/jails``.
@@ -71,7 +61,6 @@ class JailListResponse(CollectionResponse[JailSummary]):
pass pass
class IgnoreListResponse(CollectionResponse[str]): class IgnoreListResponse(CollectionResponse[str]):
"""Response for ``GET /api/jails/{name}/ignoreip``. """Response for ``GET /api/jails/{name}/ignoreip``.
@@ -80,16 +69,13 @@ class IgnoreListResponse(CollectionResponse[str]):
pass pass
class JailDetailResponse(BanGuiBaseModel):
class JailDetailResponse(BaseModel):
"""Response for ``GET /api/jails/{name}``. """Response for ``GET /api/jails/{name}``.
Includes the primary jail object together with supplemental metadata Includes the primary jail object together with supplemental metadata
required by the UI. required by the UI.
""" """
model_config = ConfigDict(strict=True)
jail: Jail jail: Jail
ignore_list: list[str] = Field( ignore_list: list[str] = Field(
default_factory=list, default_factory=list,
@@ -100,7 +86,6 @@ class JailDetailResponse(BaseModel):
description="Whether the jail ignores the server's own IP addresses.", description="Whether the jail ignores the server's own IP addresses.",
) )
class JailCommandResponse(CommandResponse): class JailCommandResponse(CommandResponse):
"""Generic response for jail control commands (start, stop, reload, idle). """Generic response for jail control commands (start, stop, reload, idle).
@@ -109,10 +94,7 @@ class JailCommandResponse(CommandResponse):
jail: str = Field(..., description="Target jail name, or '*' for operations on all jails.") jail: str = Field(..., description="Target jail name, or '*' for operations on all jails.")
class IgnoreIpRequest(BanGuiBaseModel):
class IgnoreIpRequest(BaseModel):
"""Payload for adding an IP or network to a jail's ignore list.""" """Payload for adding an IP or network to a jail's ignore list."""
model_config = ConfigDict(strict=True)
ip: str = Field(..., description="IP address or CIDR network to ignore.") ip: str = Field(..., description="IP address or CIDR network to ignore.")

View File

@@ -96,7 +96,27 @@ from pydantic import BaseModel, ConfigDict, Field
T = TypeVar("T") T = TypeVar("T")
class PaginatedListResponse(BaseModel, Generic[T]): class BanGuiBaseModel(BaseModel):
"""Project-wide Pydantic base model.
Enforces the canonical **snake_case** API field naming policy:
all JSON wire-format field names use ``snake_case`` on both the backend
(Python) and the frontend (TypeScript interfaces). No ``alias_generator``
is applied — field names are serialized exactly as written.
Rules:
- Every model in ``app/models/`` must inherit from this class.
- Field names must be ``snake_case`` in Python *and* in the JSON payload.
- The corresponding TypeScript interface fields must also be ``snake_case``.
- Never add a ``camelCase`` alias generator to individual models — any
serialization change must go through this base class so all models
update at once.
"""
model_config = ConfigDict(strict=True)
class PaginatedListResponse(BanGuiBaseModel, Generic[T]):
"""Standardized paginated list response. """Standardized paginated list response.
Use this as a base for all endpoints that return paginated collections. Use this as a base for all endpoints that return paginated collections.
@@ -123,15 +143,13 @@ class PaginatedListResponse(BaseModel, Generic[T]):
``` ```
""" """
model_config = ConfigDict(strict=True)
items: list[T] = Field(default_factory=list, description="Data items for the current page.") items: list[T] = Field(default_factory=list, description="Data items for the current page.")
total: int = Field(..., ge=0, description="Total number of items matching the query.") total: int = Field(..., ge=0, description="Total number of items matching the query.")
page: int = Field(..., ge=1, description="Current page number (1-based).") page: int = Field(..., ge=1, description="Current page number (1-based).")
page_size: int = Field(..., ge=1, description="Number of items per page.") page_size: int = Field(..., ge=1, description="Number of items per page.")
class CollectionResponse(BaseModel, Generic[T]): class CollectionResponse(BanGuiBaseModel, Generic[T]):
"""Standardized non-paginated collection response. """Standardized non-paginated collection response.
Use this for endpoints that return a collection without pagination support. Use this for endpoints that return a collection without pagination support.
@@ -154,13 +172,11 @@ class CollectionResponse(BaseModel, Generic[T]):
``` ```
""" """
model_config = ConfigDict(strict=True)
items: list[T] = Field(default_factory=list, description="Collection items.") items: list[T] = Field(default_factory=list, description="Collection items.")
total: int = Field(..., ge=0, description="Total number of items.") total: int = Field(..., ge=0, description="Total number of items.")
class CommandResponse(BaseModel): class CommandResponse(BanGuiBaseModel):
"""Standardized command/action result response. """Standardized command/action result response.
Use this for endpoints that execute commands (start, stop, reload, ban, unban, etc.). Use this for endpoints that execute commands (start, stop, reload, ban, unban, etc.).
@@ -184,8 +200,6 @@ class CommandResponse(BaseModel):
``` ```
""" """
model_config = ConfigDict(strict=True)
message: str = Field(..., description="Human-readable result or error message.") message: str = Field(..., description="Human-readable result or error message.")
success: bool = Field( success: bool = Field(
default=True, default=True,

View File

@@ -1,16 +1,21 @@
"""Server status and health-check Pydantic models. """Server status and health-check Pydantic models.
Used by the dashboard router, health service, and server settings router. Used by the dashboard router, health service, and server settings router.
All models inherit from :class:`~app.models.response.BanGuiBaseModel` which
enforces the project-wide **snake_case** API field naming policy: field names
are identical in Python, JSON wire format, and the corresponding TypeScript
interfaces.
""" """
from pydantic import BaseModel, ConfigDict, Field from pydantic import Field
from app.models.response import BanGuiBaseModel
class ServerStatus(BaseModel): class ServerStatus(BanGuiBaseModel):
"""Cached fail2ban server health snapshot.""" """Cached fail2ban server health snapshot."""
model_config = ConfigDict(strict=True)
online: bool = Field(..., description="Whether fail2ban is reachable via its socket.") online: bool = Field(..., description="Whether fail2ban is reachable via its socket.")
version: str | None = Field(default=None, description="fail2ban version string.") version: str | None = Field(default=None, description="fail2ban version string.")
active_jails: int = Field(default=0, ge=0, description="Number of currently active jails.") active_jails: int = Field(default=0, ge=0, description="Number of currently active jails.")
@@ -18,19 +23,15 @@ class ServerStatus(BaseModel):
total_failures: int = Field(default=0, ge=0, description="Aggregated current failure count across all jails.") total_failures: int = Field(default=0, ge=0, description="Aggregated current failure count across all jails.")
class ServerStatusResponse(BaseModel): class ServerStatusResponse(BanGuiBaseModel):
"""Response for ``GET /api/dashboard/status``.""" """Response for ``GET /api/dashboard/status``."""
model_config = ConfigDict(strict=True)
status: ServerStatus status: ServerStatus
class ServerSettings(BaseModel): class ServerSettings(BanGuiBaseModel):
"""Domain model for fail2ban server-level settings.""" """Domain model for fail2ban server-level settings."""
model_config = ConfigDict(strict=True)
log_level: str = Field(..., description="fail2ban daemon log level.") log_level: str = Field(..., description="fail2ban daemon log level.")
log_target: str = Field(..., description="Log destination: STDOUT, STDERR, SYSLOG, or a file path.") log_target: str = Field(..., description="Log destination: STDOUT, STDERR, SYSLOG, or a file path.")
syslog_socket: str | None = Field(default=None) syslog_socket: str | None = Field(default=None)
@@ -39,22 +40,18 @@ class ServerSettings(BaseModel):
db_max_matches: int = Field(..., description="Maximum stored matches per ban record.") db_max_matches: int = Field(..., description="Maximum stored matches per ban record.")
class ServerSettingsUpdate(BaseModel): class ServerSettingsUpdate(BanGuiBaseModel):
"""Payload for ``PUT /api/server/settings``.""" """Payload for ``PUT /api/server/settings``."""
model_config = ConfigDict(strict=True)
log_level: str | None = Field(default=None) log_level: str | None = Field(default=None)
log_target: str | None = Field(default=None) log_target: str | None = Field(default=None)
db_purge_age: int | None = Field(default=None, ge=0) db_purge_age: int | None = Field(default=None, ge=0)
db_max_matches: int | None = Field(default=None, ge=0) db_max_matches: int | None = Field(default=None, ge=0)
class ServerSettingsResponse(BaseModel): class ServerSettingsResponse(BanGuiBaseModel):
"""Response for ``GET /api/server/settings``.""" """Response for ``GET /api/server/settings``."""
model_config = ConfigDict(strict=True)
settings: ServerSettings settings: ServerSettings
warnings: dict[str, bool] = Field( warnings: dict[str, bool] = Field(
default_factory=dict, default_factory=dict,

View File

@@ -3,14 +3,13 @@
Request, response, and domain models for the first-run configuration wizard. Request, response, and domain models for the first-run configuration wizard.
""" """
from pydantic import BaseModel, ConfigDict, Field, field_validator from pydantic import Field, field_validator
from app.models.response import BanGuiBaseModel
class SetupRequest(BaseModel): class SetupRequest(BanGuiBaseModel):
"""Payload for ``POST /api/setup``.""" """Payload for ``POST /api/setup``."""
model_config = ConfigDict(strict=True)
master_password: str = Field( master_password: str = Field(
..., ...,
min_length=8, min_length=8,
@@ -53,30 +52,21 @@ class SetupRequest(BaseModel):
description="Number of minutes a user session remains valid.", description="Number of minutes a user session remains valid.",
) )
class SetupResponse(BanGuiBaseModel):
class SetupResponse(BaseModel):
"""Response returned after a successful initial setup.""" """Response returned after a successful initial setup."""
model_config = ConfigDict(strict=True)
message: str = Field( message: str = Field(
default="Setup completed successfully. Please log in.", default="Setup completed successfully. Please log in.",
) )
class SetupTimezoneResponse(BanGuiBaseModel):
class SetupTimezoneResponse(BaseModel):
"""Response for ``GET /api/setup/timezone``.""" """Response for ``GET /api/setup/timezone``."""
model_config = ConfigDict(strict=True)
timezone: str = Field(..., description="Configured IANA timezone identifier.") timezone: str = Field(..., description="Configured IANA timezone identifier.")
class SetupStatusResponse(BanGuiBaseModel):
class SetupStatusResponse(BaseModel):
"""Response indicating whether setup has been completed.""" """Response indicating whether setup has been completed."""
model_config = ConfigDict(strict=True)
completed: bool = Field( completed: bool = Field(
..., ...,
description="``True`` if the initial setup has already been performed.", description="``True`` if the initial setup has already been performed.",