From ad21590f604b9a0e312420d6f6b7416f3d3f8977 Mon Sep 17 00:00:00 2001 From: Lukas Date: Tue, 28 Apr 2026 21:27:26 +0200 Subject: [PATCH] No canonical snake_case/camelCase serialization policy --- Docs/Backend-Development.md | 32 +++-- Docs/Tasks.md | 21 --- Docs/Web-Development.md | 9 ++ backend/app/models/auth.py | 22 +-- backend/app/models/ban.py | 76 +++-------- backend/app/models/blocklist.py | 63 +++------ backend/app/models/config.py | 215 ++++++------------------------ backend/app/models/file_config.py | 51 ++----- backend/app/models/geo.py | 32 ++--- backend/app/models/history.py | 25 +--- backend/app/models/jail.py | 32 +---- backend/app/models/response.py | 32 +++-- backend/app/models/server.py | 29 ++-- backend/app/models/setup.py | 22 +-- 14 files changed, 186 insertions(+), 475 deletions(-) diff --git a/Docs/Backend-Development.md b/Docs/Backend-Development.md index 3f70bbe..6a77bee 100644 --- a/Docs/Backend-Development.md +++ b/Docs/Backend-Development.md @@ -465,18 +465,14 @@ class BansByCountryResponse(BaseModel): ## 5. Pydantic Models -- Every model inherits from `pydantic.BaseModel`. -- 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. -- 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. +### Base Class + +Every model in `app/models/` **must** inherit from `BanGuiBaseModel` (defined in `app/models/response.py`), not from `pydantic.BaseModel` directly. ```python -from pydantic import BaseModel, Field -from datetime import datetime +from app.models.response import BanGuiBaseModel -class BanResponse(BaseModel): +class BanResponse(BanGuiBaseModel): ip: str = Field(..., description="Banned IP address") jail: str = Field(..., description="Jail that issued 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") ``` +`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 frontend–backend 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 When a field should only accept a small set of predefined values, use `Literal` to enforce this at the type level: diff --git a/Docs/Tasks.md b/Docs/Tasks.md index 048a581..2c8d655 100644 --- a/Docs/Tasks.md +++ b/Docs/Tasks.md @@ -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 - Where found: - [backend/app/models/server.py](backend/app/models/server.py) diff --git a/Docs/Web-Development.md b/Docs/Web-Development.md index 1059198..d84f312 100644 --- a/Docs/Web-Development.md +++ b/Docs/Web-Development.md @@ -966,6 +966,15 @@ This pattern prevents **stale session flicker** — the brief moment when a user | Directories | lowercase kebab‑case or camelCase | `components/`, `hooks/` | | 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 diff --git a/backend/app/models/auth.py b/backend/app/models/auth.py index 7b6fb52..02fbb7f 100644 --- a/backend/app/models/auth.py +++ b/backend/app/models/auth.py @@ -3,22 +3,20 @@ 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``.""" - model_config = ConfigDict(strict=True) - password: str = Field( ..., max_length=72, description="Master password to authenticate with (max 72 bytes due to bcrypt truncation).", ) - -class LoginResponse(BaseModel): +class LoginResponse(BanGuiBaseModel): """Successful login response. 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. """ - model_config = ConfigDict(strict=True) - expires_at: str = Field(..., description="ISO 8601 UTC expiry timestamp.") - -class LogoutResponse(BaseModel): +class LogoutResponse(BanGuiBaseModel): """Response body for ``POST /api/auth/logout``.""" - model_config = ConfigDict(strict=True) - message: str = Field(default="Logged out successfully.") - -class Session(BaseModel): +class Session(BanGuiBaseModel): """Internal domain model representing a persisted session record.""" - model_config = ConfigDict(strict=True) - id: int = Field(..., description="Auto-incremented row ID.") token: str = Field(..., description="Opaque session token.") created_at: str = Field(..., description="ISO 8601 UTC creation timestamp.") diff --git a/backend/app/models/ban.py b/backend/app/models/ban.py index 18b2ae2..b0a45ab 100644 --- a/backend/app/models/ban.py +++ b/backend/app/models/ban.py @@ -6,9 +6,10 @@ Request, response, and domain models used by the ban router and service. import math 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 @@ -25,21 +26,15 @@ TIME_RANGE_SECONDS: dict[str, int] = { "365d": 365 * 24 * 3600, } - -class BanRequest(BaseModel): +class BanRequest(BanGuiBaseModel): """Payload for ``POST /api/bans`` (ban an IP).""" - model_config = ConfigDict(strict=True) - ip: str = Field(..., description="IP address to ban.") jail: str = Field(..., description="Jail in which to apply the ban.") - -class UnbanRequest(BaseModel): +class UnbanRequest(BanGuiBaseModel): """Payload for ``DELETE /api/bans`` (unban an IP).""" - model_config = ConfigDict(strict=True) - ip: str = Field(..., description="IP address to unban.") jail: str | None = Field( default=None, @@ -50,14 +45,12 @@ class UnbanRequest(BaseModel): description="When ``true`` the IP is unbanned from every jail.", ) - #: Discriminator literal for the origin of a ban. BanOrigin = Literal["blocklist", "selfblock"] #: Jail name used by the blocklist import service. BLOCKLIST_JAIL: str = "blocklist-import" - def _derive_origin(jail: str) -> BanOrigin: """Derive the ban origin from the jail name. @@ -70,12 +63,9 @@ def _derive_origin(jail: str) -> BanOrigin: """ return "blocklist" if jail == BLOCKLIST_JAIL else "selfblock" - -class Ban(BaseModel): +class Ban(BanGuiBaseModel): """Domain model representing a single active or historical ban record.""" - model_config = ConfigDict(strict=True) - ip: str = Field(..., description="Banned IP address.") jail: str = Field(..., description="Jail that issued the ban.") banned_at: str = Field(..., description="ISO 8601 UTC timestamp of the ban.") @@ -93,15 +83,11 @@ class Ban(BaseModel): description="Whether this ban came from a blocklist import or fail2ban itself.", ) - -class BanResponse(BaseModel): +class BanResponse(BanGuiBaseModel): """Response containing a single ban record.""" - model_config = ConfigDict(strict=True) - ban: Ban - class BanListResponse(PaginatedListResponse[Ban]): """Paginated list of ban records. @@ -114,12 +100,9 @@ class BanListResponse(PaginatedListResponse[Ban]): pass - -class ActiveBan(BaseModel): +class ActiveBan(BanGuiBaseModel): """A currently active ban entry returned by ``GET /api/bans/active``.""" - model_config = ConfigDict(strict=True) - ip: str = Field(..., description="Banned IP address.") jail: str = Field(..., description="Jail holding the ban.") banned_at: str | None = Field(default=None, description="ISO 8601 UTC start of the ban.") @@ -130,7 +113,6 @@ class ActiveBan(BaseModel): 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.") - class ActiveBanListResponse(CollectionResponse[ActiveBan]): """List of all currently active bans across all jails. @@ -143,29 +125,22 @@ class ActiveBanListResponse(CollectionResponse[ActiveBan]): pass - -class UnbanAllResponse(BaseModel): +class UnbanAllResponse(BanGuiBaseModel): """Response for ``DELETE /api/bans/all``.""" - model_config = ConfigDict(strict=True) - message: str = Field(..., description="Human-readable summary of the operation.") count: int = Field(..., ge=0, description="Number of IPs that were unbanned.") - # --------------------------------------------------------------------------- # Dashboard ban-list view models # --------------------------------------------------------------------------- - -class DashboardBanItem(BaseModel): +class DashboardBanItem(BanGuiBaseModel): """A single row in the dashboard ban-list table. Populated from the fail2ban database and enriched with geo data. """ - model_config = ConfigDict(strict=True) - ip: str = Field(..., description="Banned IP address.") jail: str = Field(..., description="Jail that issued the ban.") banned_at: str = Field(..., description="ISO 8601 UTC timestamp of the ban.") @@ -195,7 +170,6 @@ class DashboardBanItem(BaseModel): description="Whether this ban came from a blocklist import or fail2ban itself.", ) - class DashboardBanListResponse(PaginatedListResponse[DashboardBanItem]): """Paginated dashboard ban-list response. @@ -205,8 +179,7 @@ class DashboardBanListResponse(PaginatedListResponse[DashboardBanItem]): pass - -class BansByCountryResponse(BaseModel): +class BansByCountryResponse(BanGuiBaseModel): """Response for the bans-by-country aggregation endpoint. Contains a per-country ban count, a human-readable country name map, and @@ -215,8 +188,6 @@ class BansByCountryResponse(BaseModel): single request. """ - model_config = ConfigDict(strict=True) - countries: dict[str, int] = Field( default_factory=dict, 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.") - # --------------------------------------------------------------------------- # Trend endpoint models # --------------------------------------------------------------------------- @@ -252,7 +222,6 @@ BUCKET_SIZE_LABEL: dict[str, str] = { "365d": "7d", } - def bucket_count(range_: TimeRange) -> int: """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_]) - -class BanTrendBucket(BaseModel): +class BanTrendBucket(BanGuiBaseModel): """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.") count: int = Field(..., ge=0, description="Number of bans that started in this bucket.") - -class BanTrendResponse(BaseModel): +class BanTrendResponse(BanGuiBaseModel): """Response for the ``GET /api/dashboard/bans/trend`` endpoint.""" - model_config = ConfigDict(strict=True) - buckets: list[BanTrendBucket] = Field( default_factory=list, 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').", ) - # --------------------------------------------------------------------------- # By-jail endpoint models # --------------------------------------------------------------------------- - -class JailBanCount(BaseModel): +class JailBanCount(BanGuiBaseModel): """A single jail entry in the bans-by-jail aggregation.""" - model_config = ConfigDict(strict=True) - jail: str = Field(..., description="Jail name.") count: int = Field(..., ge=0, description="Number of bans recorded in this jail.") - -class BansByJailResponse(BaseModel): +class BansByJailResponse(BanGuiBaseModel): """Response for the ``GET /api/dashboard/bans/by-jail`` endpoint.""" - model_config = ConfigDict(strict=True) - jails: list[JailBanCount] = Field( default_factory=list, description="Jails ordered by ban count descending.", ) total: int = Field(..., ge=0, description="Total ban count in the selected window.") - # --------------------------------------------------------------------------- # Jail-specific paginated bans # --------------------------------------------------------------------------- - class JailBannedIpsResponse(PaginatedListResponse[ActiveBan]): """Paginated response for ``GET /api/jails/{name}/banned``. diff --git a/backend/app/models/blocklist.py b/backend/app/models/blocklist.py index 531d07f..b53ca9f 100644 --- a/backend/app/models/blocklist.py +++ b/backend/app/models/blocklist.py @@ -8,18 +8,18 @@ from __future__ import annotations from enum import StrEnum -from pydantic import AnyHttpUrl, BaseModel, ConfigDict, Field +from pydantic import AnyHttpUrl, Field + +from app.models.response import BanGuiBaseModel + # --------------------------------------------------------------------------- # Blocklist source # --------------------------------------------------------------------------- - -class BlocklistSource(BaseModel): +class BlocklistSource(BanGuiBaseModel): """Domain model for a blocklist source definition.""" - model_config = ConfigDict(strict=True) - id: int name: str url: str @@ -27,8 +27,7 @@ class BlocklistSource(BaseModel): created_at: str updated_at: str - -class BlocklistSourceCreate(BaseModel): +class BlocklistSourceCreate(BanGuiBaseModel): """Payload for ``POST /api/blocklists``. 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. """ - model_config = ConfigDict(strict=True) - 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).") enabled: bool = Field(default=True) - -class BlocklistSourceUpdate(BaseModel): +class BlocklistSourceUpdate(BanGuiBaseModel): """Payload for ``PUT /api/blocklists/{id}``. All fields are optional. 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) url: AnyHttpUrl | None = Field(default=None) enabled: bool | None = Field(default=None) - -class BlocklistListResponse(BaseModel): +class BlocklistListResponse(BanGuiBaseModel): """Response for ``GET /api/blocklists``.""" - model_config = ConfigDict(strict=True) - sources: list[BlocklistSource] = Field(default_factory=list) - # --------------------------------------------------------------------------- # Import log # --------------------------------------------------------------------------- - -class ImportLogEntry(BaseModel): +class ImportLogEntry(BanGuiBaseModel): """A single blocklist import run record.""" - model_config = ConfigDict(strict=True) - id: int source_id: int | None source_url: str @@ -82,24 +69,19 @@ class ImportLogEntry(BaseModel): ips_skipped: int errors: str | None - -class ImportLogListResponse(BaseModel): +class ImportLogListResponse(BanGuiBaseModel): """Response for ``GET /api/blocklists/log``.""" - model_config = ConfigDict(strict=True) - items: list[ImportLogEntry] = Field(default_factory=list) total: int = Field(..., ge=0) page: int = Field(default=1, ge=1) page_size: int = Field(default=50, ge=1) total_pages: int = Field(default=1, ge=1) - # --------------------------------------------------------------------------- # Schedule # --------------------------------------------------------------------------- - class ScheduleFrequency(StrEnum): """Available import schedule frequency presets.""" @@ -107,8 +89,7 @@ class ScheduleFrequency(StrEnum): daily = "daily" weekly = "weekly" - -class ScheduleConfig(BaseModel): +class ScheduleConfig(BanGuiBaseModel): """Import schedule configuration. 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)", ) - -class ScheduleInfo(BaseModel): +class ScheduleInfo(BanGuiBaseModel): """Current schedule configuration together with runtime metadata.""" - model_config = ConfigDict(strict=True) - config: ScheduleConfig next_run_at: str | None last_run_at: str | None last_run_errors: bool | None = None """``True`` if the most recent import had errors, ``False`` if clean, ``None`` if never run.""" - # --------------------------------------------------------------------------- # Import results # --------------------------------------------------------------------------- - -class ImportSourceResult(BaseModel): +class ImportSourceResult(BanGuiBaseModel): """Result of importing a single blocklist source.""" - model_config = ConfigDict(strict=True) - source_id: int | None source_url: str ips_imported: int ips_skipped: int error: str | None - -class ImportRunResult(BaseModel): +class ImportRunResult(BanGuiBaseModel): """Aggregated result from a full import run across all enabled sources.""" - model_config = ConfigDict(strict=True) - results: list[ImportSourceResult] = Field(default_factory=list) total_imported: int total_skipped: int errors_count: int - # --------------------------------------------------------------------------- # Preview # --------------------------------------------------------------------------- - -class PreviewResponse(BaseModel): +class PreviewResponse(BanGuiBaseModel): """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") total_lines: int valid_count: int diff --git a/backend/app/models/config.py b/backend/app/models/config.py index af6f7d3..5a6bc65 100644 --- a/backend/app/models/config.py +++ b/backend/app/models/config.py @@ -7,10 +7,10 @@ import datetime from pathlib import Path 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.models.response import CollectionResponse +from app.models.response import BanGuiBaseModel, CollectionResponse from app.utils.path_utils import validate_log_path DNSMode = Literal["yes", "warn", "no", "raw"] @@ -23,12 +23,9 @@ LogTarget = Literal["STDOUT", "STDERR", "SYSLOG"] # Ban-time escalation # --------------------------------------------------------------------------- - -class BantimeEscalation(BaseModel): +class BantimeEscalation(BanGuiBaseModel): """Incremental ban-time escalation configuration for a jail.""" - model_config = ConfigDict(strict=True) - increment: bool = Field( default=False, 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.", ) - -class BantimeEscalationUpdate(BaseModel): +class BantimeEscalationUpdate(BanGuiBaseModel): """Partial update payload for ban-time escalation settings.""" - model_config = ConfigDict(strict=True) - increment: bool | None = Field(default=None) factor: float | None = Field(default=None) formula: str | None = Field(default=None) @@ -72,17 +66,13 @@ class BantimeEscalationUpdate(BaseModel): rnd_time: int | None = Field(default=None) overall_jails: bool | None = Field(default=None) - # --------------------------------------------------------------------------- # Jail configuration models # --------------------------------------------------------------------------- - -class JailConfig(BaseModel): +class JailConfig(BanGuiBaseModel): """Configuration snapshot of a single jail (editable fields).""" - model_config = ConfigDict(strict=True) - name: str = Field(..., description="Jail name as configured in fail2ban.") 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.") @@ -101,15 +91,11 @@ class JailConfig(BaseModel): description="Incremental ban-time escalation settings, or None if not configured.", ) - -class JailConfigResponse(BaseModel): +class JailConfigResponse(BanGuiBaseModel): """Response for ``GET /api/config/jails/{name}``.""" - model_config = ConfigDict(strict=True) - jail: JailConfig - class JailConfigListResponse(CollectionResponse[JailConfig]): """Response for ``GET /api/config/jails``. @@ -118,12 +104,9 @@ class JailConfigListResponse(CollectionResponse[JailConfig]): pass - -class JailConfigUpdate(BaseModel): +class JailConfigUpdate(BanGuiBaseModel): """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.") max_retry: 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.", ) - # --------------------------------------------------------------------------- # Regex tester models # --------------------------------------------------------------------------- - -class RegexTestRequest(BaseModel): +class RegexTestRequest(BanGuiBaseModel): """Payload for ``POST /api/config/regex-test``.""" - model_config = ConfigDict(strict=True) - log_line: str = Field(..., description="Sample log line to test against.") fail_regex: str = Field(..., description="Regex pattern to match.") - -class RegexTestResponse(BaseModel): +class RegexTestResponse(BanGuiBaseModel): """Result of a regex test.""" - model_config = ConfigDict(strict=True) - matched: bool = Field(..., description="Whether the pattern matched the log line.") groups: list[str] = Field( default_factory=list, @@ -170,17 +146,13 @@ class RegexTestResponse(BaseModel): description="Compilation error message if the regex is invalid.", ) - # --------------------------------------------------------------------------- # Global config models # --------------------------------------------------------------------------- - -class GlobalConfigResponse(BaseModel): +class GlobalConfigResponse(BanGuiBaseModel): """Response for ``GET /api/config/global``.""" - model_config = ConfigDict(strict=True) - log_level: LogLevel 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.") @@ -222,12 +194,9 @@ class GlobalConfigResponse(BaseModel): f"Log target {value!r} is outside allowed directories: {allowed_dirs_str}" ) - -class GlobalConfigUpdate(BaseModel): +class GlobalConfigUpdate(BanGuiBaseModel): """Payload for ``PUT /api/config/global``.""" - model_config = ConfigDict(strict=True) - log_level: LogLevel | None = Field( default=None, 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}" ) - # --------------------------------------------------------------------------- # Log observation / preview models # --------------------------------------------------------------------------- - -class AddLogPathRequest(BaseModel): +class AddLogPathRequest(BanGuiBaseModel): """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.") tail: bool = Field( default=True, @@ -311,49 +276,35 @@ class AddLogPathRequest(BaseModel): """ return validate_log_path(value) - - -class LogPreviewRequest(BaseModel): +class LogPreviewRequest(BanGuiBaseModel): """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.") 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.") - -class LogPreviewLine(BaseModel): +class LogPreviewLine(BanGuiBaseModel): """A single log line with match information.""" - model_config = ConfigDict(strict=True) - line: str matched: bool groups: list[str] = Field(default_factory=list) - -class LogPreviewResponse(BaseModel): +class LogPreviewResponse(BanGuiBaseModel): """Response for ``POST /api/config/preview-log``.""" - model_config = ConfigDict(strict=True) - lines: list[LogPreviewLine] = Field(default_factory=list) total_lines: 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.") - # --------------------------------------------------------------------------- # Map color threshold models # --------------------------------------------------------------------------- - -class MapColorThresholdsResponse(BaseModel): +class MapColorThresholdsResponse(BanGuiBaseModel): """Response for ``GET /api/config/map-thresholds``.""" - model_config = ConfigDict(strict=True) - threshold_high: int = Field( ..., description="Ban count for red coloring." ) @@ -364,25 +315,20 @@ class MapColorThresholdsResponse(BaseModel): ..., description="Ban count for green coloring." ) - -class MapColorThresholdsUpdate(BaseModel): +class MapColorThresholdsUpdate(BanGuiBaseModel): """Payload for ``PUT /api/config/map-thresholds``.""" - model_config = ConfigDict(strict=True) - threshold_high: int = Field(..., gt=0, description="Ban count for red.") threshold_medium: int = Field( ..., gt=0, description="Ban count for yellow." ) threshold_low: int = Field(..., gt=0, description="Ban count for green.") - # --------------------------------------------------------------------------- # Parsed filter file models # --------------------------------------------------------------------------- - -class FilterConfig(BaseModel): +class FilterConfig(BanGuiBaseModel): """Structured representation of a ``filter.d/*.conf`` file. The ``active``, ``used_by_jails``, ``source_file``, and @@ -393,8 +339,6 @@ class FilterConfig(BaseModel): these fields carry their default values. """ - model_config = ConfigDict(strict=True) - name: str = Field(..., description="Filter base name, e.g. ``sshd``.") filename: str = Field(..., description="Actual filename, e.g. ``sshd.conf``.") # [INCLUDES] @@ -458,15 +402,12 @@ class FilterConfig(BaseModel): ), ) - -class FilterConfigUpdate(BaseModel): +class FilterConfigUpdate(BanGuiBaseModel): """Partial update payload for a parsed filter file. Only explicitly set (non-``None``) fields are written back. """ - model_config = ConfigDict(strict=True) - before: str | None = Field(default=None) after: 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) journalmatch: str | None = Field(default=None) - -class FilterUpdateRequest(BaseModel): +class FilterUpdateRequest(BanGuiBaseModel): """Payload for ``PUT /api/config/filters/{name}``. Accepts only the user-editable ``[Definition]`` fields. Fields left as @@ -486,8 +426,6 @@ class FilterUpdateRequest(BaseModel): preserved. """ - model_config = ConfigDict(strict=True) - failregex: list[str] | None = Field( default=None, description="Updated failure-detection regex patterns. ``None`` = keep existing.", @@ -505,15 +443,12 @@ class FilterUpdateRequest(BaseModel): description="Systemd journal match expression. ``None`` = keep existing.", ) - -class FilterCreateRequest(BaseModel): +class FilterCreateRequest(BanGuiBaseModel): """Payload for ``POST /api/config/filters``. Creates a new user-defined filter at ``filter.d/{name}.local``. """ - model_config = ConfigDict(strict=True) - name: str = Field( ..., 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.", ) - -class AssignFilterRequest(BaseModel): +class AssignFilterRequest(BanGuiBaseModel): """Payload for ``POST /api/config/jails/{jail_name}/filter``.""" - model_config = ConfigDict(strict=True) - filter_name: str = Field( ..., description="Filter base name to assign to the jail (e.g. ``sshd``).", ) - -class FilterListResponse(BaseModel): +class FilterListResponse(BanGuiBaseModel): """Response for ``GET /api/config/filters``.""" - model_config = ConfigDict(strict=True) - filters: list[FilterConfig] = Field( default_factory=list, description=( @@ -565,17 +494,13 @@ class FilterListResponse(BaseModel): ) total: int = Field(..., ge=0, description="Total number of filters found.") - # --------------------------------------------------------------------------- # Parsed action file models # --------------------------------------------------------------------------- - -class ActionConfig(BaseModel): +class ActionConfig(BanGuiBaseModel): """Structured representation of an ``action.d/*.conf`` file.""" - model_config = ConfigDict(strict=True) - name: str = Field(..., description="Action base name, e.g. ``iptables``.") filename: str = Field(..., description="Actual filename, e.g. ``iptables.conf``.") # [INCLUDES] @@ -644,12 +569,9 @@ class ActionConfig(BaseModel): ), ) - -class ActionConfigUpdate(BaseModel): +class ActionConfigUpdate(BanGuiBaseModel): """Partial update payload for a parsed action file.""" - model_config = ConfigDict(strict=True) - before: str | None = Field(default=None) after: 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) init_vars: dict[str, str] | None = Field(default=None) - -class ActionListResponse(BaseModel): +class ActionListResponse(BanGuiBaseModel): """Response for ``GET /api/config/actions``.""" - model_config = ConfigDict(strict=True) - actions: list[ActionConfig] = Field( default_factory=list, description=( @@ -676,16 +595,13 @@ class ActionListResponse(BaseModel): ) total: int = Field(..., ge=0, description="Total number of actions found.") - -class ActionUpdateRequest(BaseModel): +class ActionUpdateRequest(BanGuiBaseModel): """Payload for ``PUT /api/config/actions/{name}``. Accepts only the user-editable ``[Definition]`` lifecycle fields and ``[Init]`` parameters. Fields left as ``None`` are not changed. """ - model_config = ConfigDict(strict=True) - actionstart: str | None = Field( default=None, description="Updated ``actionstart`` command. ``None`` = keep existing.", @@ -719,15 +635,12 @@ class ActionUpdateRequest(BaseModel): description="``[Init]`` parameters to set. ``None`` = keep existing.", ) - -class ActionCreateRequest(BaseModel): +class ActionCreateRequest(BanGuiBaseModel): """Payload for ``POST /api/config/actions``. Creates a new user-defined action at ``action.d/{name}.local``. """ - model_config = ConfigDict(strict=True) - name: str = Field( ..., description="Action base name (e.g. ``my-custom-action``). Must not already exist.", @@ -747,12 +660,9 @@ class ActionCreateRequest(BaseModel): description="``[Init]`` runtime parameters.", ) - -class AssignActionRequest(BaseModel): +class AssignActionRequest(BanGuiBaseModel): """Payload for ``POST /api/config/jails/{jail_name}/action``.""" - model_config = ConfigDict(strict=True) - action_name: str = Field( ..., 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) # --------------------------------------------------------------------------- - -class JailSectionConfig(BaseModel): +class JailSectionConfig(BanGuiBaseModel): """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.") 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').") @@ -787,36 +693,28 @@ class JailSectionConfig(BaseModel): 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.") - -class JailFileConfig(BaseModel): +class JailFileConfig(BanGuiBaseModel): """Structured representation of a jail.d/*.conf file.""" - model_config = ConfigDict(strict=True) - filename: str = Field(..., description="Filename including extension (e.g. 'sshd.conf').") jails: dict[str, JailSectionConfig] = Field( default_factory=dict, description="Mapping of jail name → settings for each [section] in the file.", ) - -class JailFileConfigUpdate(BaseModel): +class JailFileConfigUpdate(BanGuiBaseModel): """Partial update payload for a jail.d file.""" - model_config = ConfigDict(strict=True) - jails: dict[str, JailSectionConfig] | None = Field( default=None, description="Jail section updates. Only jails present in this dict are updated.", ) - # --------------------------------------------------------------------------- # Inactive jail models (Stage 1) # --------------------------------------------------------------------------- - -class InactiveJail(BaseModel): +class InactiveJail(BanGuiBaseModel): """A jail defined in fail2ban config files that is not currently active. A jail is considered inactive when its ``enabled`` key is ``false`` (or @@ -825,8 +723,6 @@ class InactiveJail(BaseModel): running. """ - model_config = ConfigDict(strict=True) - name: str = Field(..., description="Jail name from the config section header.") filter: str = Field( ..., @@ -920,7 +816,6 @@ class InactiveJail(BaseModel): ), ) - class InactiveJailListResponse(CollectionResponse[InactiveJail]): """Response for ``GET /api/config/jails/inactive``. @@ -929,8 +824,7 @@ class InactiveJailListResponse(CollectionResponse[InactiveJail]): pass - -class ActivateJailRequest(BaseModel): +class ActivateJailRequest(BanGuiBaseModel): """Optional override values when activating an inactive jail. All fields are optional. Omitted fields are not written to the @@ -938,8 +832,6 @@ class ActivateJailRequest(BaseModel): values. """ - model_config = ConfigDict(strict=True) - bantime: str | None = Field( default=None, description="Override ban duration, e.g. ``1h`` or ``3600``.", @@ -962,12 +854,9 @@ class ActivateJailRequest(BaseModel): description="Override log file paths.", ) - -class JailActivationResponse(BaseModel): +class JailActivationResponse(BanGuiBaseModel): """Response for jail activation and deactivation endpoints.""" - model_config = ConfigDict(strict=True) - name: str = Field(..., description="Name of the affected jail.") active: bool = Field( ..., @@ -996,29 +885,22 @@ class JailActivationResponse(BaseModel): ), ) - # --------------------------------------------------------------------------- # Jail validation models (Task 3) # --------------------------------------------------------------------------- - -class JailValidationIssue(BaseModel): +class JailValidationIssue(BanGuiBaseModel): """A single issue found during pre-activation validation of a jail config.""" - model_config = ConfigDict(strict=True) - field: str = Field( ..., description="Config field associated with this issue, e.g. 'filter', 'failregex', 'logpath'.", ) message: str = Field(..., description="Human-readable description of the issue.") - -class JailValidationResult(BaseModel): +class JailValidationResult(BanGuiBaseModel): """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.") valid: bool = Field(..., description="True when no issues were found.") issues: list[JailValidationIssue] = Field( @@ -1026,17 +908,13 @@ class JailValidationResult(BaseModel): description="Validation issues found. Empty when valid=True.", ) - # --------------------------------------------------------------------------- # Rollback response model (Task 3) # --------------------------------------------------------------------------- - -class RollbackResponse(BaseModel): +class RollbackResponse(BanGuiBaseModel): """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.") disabled: bool = Field( ..., @@ -1053,17 +931,13 @@ class RollbackResponse(BaseModel): ) message: str = Field(..., description="Human-readable result message.") - # --------------------------------------------------------------------------- # Pending recovery model (Task 3) # --------------------------------------------------------------------------- - -class PendingRecovery(BaseModel): +class PendingRecovery(BanGuiBaseModel): """Records a probable activation-caused fail2ban crash pending user action.""" - model_config = ConfigDict(strict=True) - jail_name: str = Field( ..., 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.", ) - # --------------------------------------------------------------------------- # fail2ban log viewer models # --------------------------------------------------------------------------- - -class Fail2BanLogResponse(BaseModel): +class Fail2BanLogResponse(BanGuiBaseModel): """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.") 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.") log_level: str = Field(..., description="Current fail2ban log level.") log_target: str = Field(..., description="Current fail2ban log target (file path or special value).") - -class ServiceStatusResponse(BaseModel): +class ServiceStatusResponse(BanGuiBaseModel): """Response for ``GET /api/config/service-status``.""" - model_config = ConfigDict(strict=True) - 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).") jail_count: int = Field(default=0, ge=0, description="Number of currently active jails.") diff --git a/backend/app/models/file_config.py b/backend/app/models/file_config.py index f77dbe3..2cb87b4 100644 --- a/backend/app/models/file_config.py +++ b/backend/app/models/file_config.py @@ -4,18 +4,18 @@ Covers jail config files (``jail.d/``), filter definitions (``filter.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) # --------------------------------------------------------------------------- - -class JailConfigFile(BaseModel): +class JailConfigFile(BanGuiBaseModel): """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``).") filename: str = Field(..., description="Actual filename (e.g. ``sshd.conf``).") enabled: bool = Field( @@ -26,81 +26,56 @@ class JailConfigFile(BaseModel): ), ) - -class JailConfigFilesResponse(BaseModel): +class JailConfigFilesResponse(BanGuiBaseModel): """Response for ``GET /api/config/jail-files``.""" - model_config = ConfigDict(strict=True) - files: list[JailConfigFile] = Field(default_factory=list) total: int = Field(..., ge=0) - -class JailConfigFileContent(BaseModel): +class JailConfigFileContent(BanGuiBaseModel): """Single jail config file with its raw content.""" - model_config = ConfigDict(strict=True) - name: str = Field(..., description="Jail name (file stem).") filename: str = Field(..., description="Actual filename.") enabled: bool = Field(..., description="Whether the jail is enabled.") content: str = Field(..., description="Raw file content.") - -class JailConfigFileEnabledUpdate(BaseModel): +class JailConfigFileEnabledUpdate(BanGuiBaseModel): """Payload for ``PUT /api/config/jail-files/{filename}/enabled``.""" - model_config = ConfigDict(strict=True) - enabled: bool = Field(..., description="New enabled state for this jail.") - # --------------------------------------------------------------------------- # Generic conf-file entry (shared by filter.d and action.d) # --------------------------------------------------------------------------- - -class ConfFileEntry(BaseModel): +class ConfFileEntry(BanGuiBaseModel): """Metadata for a single ``.conf`` or ``.local`` file.""" - model_config = ConfigDict(strict=True) - name: str = Field(..., description="Base name without extension (e.g. ``sshd``).") filename: str = Field(..., description="Actual filename (e.g. ``sshd.conf``).") - -class ConfFilesResponse(BaseModel): +class ConfFilesResponse(BanGuiBaseModel): """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) total: int = Field(..., ge=0) - -class ConfFileContent(BaseModel): +class ConfFileContent(BanGuiBaseModel): """A conf file with its raw text content.""" - model_config = ConfigDict(strict=True) - name: str = Field(..., description="Base name without extension.") filename: str = Field(..., description="Actual filename.") content: str = Field(..., description="Raw file content.") - -class ConfFileUpdateRequest(BaseModel): +class ConfFileUpdateRequest(BanGuiBaseModel): """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).") - -class ConfFileCreateRequest(BaseModel): +class ConfFileCreateRequest(BanGuiBaseModel): """Payload for ``POST /api/config/filters`` and ``POST /api/config/actions``.""" - model_config = ConfigDict(strict=True) - name: str = Field( ..., description="New file base name (without extension). Must contain only " diff --git a/backend/app/models/geo.py b/backend/app/models/geo.py index fc6fd27..135500c 100644 --- a/backend/app/models/geo.py +++ b/backend/app/models/geo.py @@ -9,21 +9,20 @@ from collections.abc import Awaitable, Callable from dataclasses import dataclass from typing import TYPE_CHECKING -from pydantic import BaseModel, ConfigDict, Field +from pydantic import Field + +from app.models.response import BanGuiBaseModel if TYPE_CHECKING: import aiohttp import aiosqlite - -class GeoDetail(BaseModel): +class GeoDetail(BanGuiBaseModel): """Enriched geolocation data for an IP address. Populated from the ip-api.com free API. """ - model_config = ConfigDict(strict=True) - country_code: str | None = Field( default=None, description="ISO 3166-1 alpha-2 country code.", @@ -41,15 +40,12 @@ class GeoDetail(BaseModel): description="Organisation associated with the ASN.", ) - -class GeoCacheEntry(BaseModel): +class GeoCacheEntry(BanGuiBaseModel): """A single cached geolocation entry for an IP address. 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).") country_code: str | None = Field( default=None, @@ -68,43 +64,34 @@ class GeoCacheEntry(BaseModel): description="Organisation associated with the ASN.", ) - -class GeoCacheStatsResponse(BaseModel): +class GeoCacheStatsResponse(BanGuiBaseModel): """Response for ``GET /api/geo/stats``. Exposes diagnostic counters of the geo cache subsystem so operators 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.") 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.") dirty_size: int = Field(..., description="Number of newly resolved entries not yet flushed to disk.") - -class GeoReResolveResponse(BaseModel): +class GeoReResolveResponse(BanGuiBaseModel): """Response for ``POST /api/geo/re-resolve``. Reports how many previously unresolved IPs were retried and how many gained a resolved country code after the re-resolve operation. """ - model_config = ConfigDict(strict=True) - resolved: int = Field(..., description="Number of IPs successfully resolved.") total: int = Field(..., description="Number of IPs retried.") - -class IpLookupResponse(BaseModel): +class IpLookupResponse(BanGuiBaseModel): """Response for ``GET /api/geo/lookup/{ip}``. Aggregates current ban status and geographical information for an IP. """ - model_config = ConfigDict(strict=True) - ip: str = Field(..., description="The queried IP address.") currently_banned_in: list[str] = Field( default_factory=list, @@ -115,12 +102,10 @@ class IpLookupResponse(BaseModel): description="Enriched geographical and network information.", ) - # --------------------------------------------------------------------------- # shared service types # --------------------------------------------------------------------------- - @dataclass class GeoInfo: """Geo resolution result used throughout backend services.""" @@ -130,7 +115,6 @@ class GeoInfo: asn: str | None org: str | None - GeoEnricher = Callable[[str], Awaitable[GeoInfo | None]] GeoBatchLookup = Callable[ [list[str], "aiohttp.ClientSession", "aiosqlite.Connection | None"], diff --git a/backend/app/models/history.py b/backend/app/models/history.py index 8fbac56..c8dcd6f 100644 --- a/backend/app/models/history.py +++ b/backend/app/models/history.py @@ -5,7 +5,9 @@ Request, response, and domain models used by the history router and service. 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 @@ -17,16 +19,13 @@ __all__ = [ "TimeRange", ] - -class HistoryBanItem(BaseModel): +class HistoryBanItem(BanGuiBaseModel): """A single row in the history ban-list table. Populated from the fail2ban database and optionally enriched with geolocation data. """ - model_config = ConfigDict(strict=True) - ip: str = Field(..., description="Banned IP address.") jail: str = Field(..., description="Jail that issued the ban.") banned_at: str = Field(..., description="ISO 8601 UTC timestamp of the ban.") @@ -57,31 +56,24 @@ class HistoryBanItem(BaseModel): description="Organisation name associated with the IP.", ) - -class HistoryListResponse(BaseModel): +class HistoryListResponse(BanGuiBaseModel): """Paginated history ban-list response.""" - model_config = ConfigDict(strict=True) - items: list[HistoryBanItem] = Field(default_factory=list) total: int = Field(..., ge=0, description="Total matching records.") page: int = Field(..., ge=1) page_size: int = Field(..., ge=1) - # --------------------------------------------------------------------------- # Per-IP timeline # --------------------------------------------------------------------------- - -class IpTimelineEvent(BaseModel): +class IpTimelineEvent(BanGuiBaseModel): """A single ban event in a per-IP timeline. 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.") banned_at: str = Field(..., description="ISO 8601 UTC timestamp of the ban.") ban_count: int = Field( @@ -99,16 +91,13 @@ class IpTimelineEvent(BaseModel): description="Matched log lines that triggered the ban.", ) - -class IpDetailResponse(BaseModel): +class IpDetailResponse(BanGuiBaseModel): """Full historical record for a single IP address. Contains aggregated totals and a chronological timeline of all ban events recorded in the fail2ban database for the given IP. """ - model_config = ConfigDict(strict=True) - ip: str = Field(..., description="The IP address.") total_bans: int = Field(..., ge=0, description="Total number of ban records.") total_failures: int = Field( diff --git a/backend/app/models/jail.py b/backend/app/models/jail.py index 7eb2a17..e5d688c 100644 --- a/backend/app/models/jail.py +++ b/backend/app/models/jail.py @@ -3,28 +3,22 @@ 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.response import CommandResponse, CollectionResponse +from app.models.response import BanGuiBaseModel, CommandResponse, CollectionResponse - -class JailStatus(BaseModel): +class JailStatus(BanGuiBaseModel): """Runtime metrics for a single jail.""" - model_config = ConfigDict(strict=True) - currently_banned: int = Field(..., ge=0) total_banned: int = Field(..., ge=0) currently_failed: int = Field(..., ge=0) total_failed: int = Field(..., ge=0) - -class Jail(BaseModel): +class Jail(BanGuiBaseModel): """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.") enabled: bool = Field(..., description="Whether the jail is currently active.") 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.") - -class JailSummary(BaseModel): +class JailSummary(BanGuiBaseModel): """Lightweight jail entry for the overview list.""" - model_config = ConfigDict(strict=True) - name: str enabled: bool running: bool @@ -62,7 +53,6 @@ class JailSummary(BaseModel): max_retry: int status: JailStatus | None = None - class JailListResponse(CollectionResponse[JailSummary]): """Response for ``GET /api/jails``. @@ -71,7 +61,6 @@ class JailListResponse(CollectionResponse[JailSummary]): pass - class IgnoreListResponse(CollectionResponse[str]): """Response for ``GET /api/jails/{name}/ignoreip``. @@ -80,16 +69,13 @@ class IgnoreListResponse(CollectionResponse[str]): pass - -class JailDetailResponse(BaseModel): +class JailDetailResponse(BanGuiBaseModel): """Response for ``GET /api/jails/{name}``. Includes the primary jail object together with supplemental metadata required by the UI. """ - model_config = ConfigDict(strict=True) - jail: Jail ignore_list: list[str] = Field( default_factory=list, @@ -100,7 +86,6 @@ class JailDetailResponse(BaseModel): description="Whether the jail ignores the server's own IP addresses.", ) - class JailCommandResponse(CommandResponse): """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.") - -class IgnoreIpRequest(BaseModel): +class IgnoreIpRequest(BanGuiBaseModel): """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.") diff --git a/backend/app/models/response.py b/backend/app/models/response.py index fedbfa3..9ac5ed9 100644 --- a/backend/app/models/response.py +++ b/backend/app/models/response.py @@ -96,7 +96,27 @@ from pydantic import BaseModel, ConfigDict, Field 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. 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.") 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_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. 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.") total: int = Field(..., ge=0, description="Total number of items.") -class CommandResponse(BaseModel): +class CommandResponse(BanGuiBaseModel): """Standardized command/action result response. 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.") success: bool = Field( default=True, diff --git a/backend/app/models/server.py b/backend/app/models/server.py index 4fb578a..456bbac 100644 --- a/backend/app/models/server.py +++ b/backend/app/models/server.py @@ -1,16 +1,21 @@ """Server status and health-check Pydantic models. 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.""" - model_config = ConfigDict(strict=True) - online: bool = Field(..., description="Whether fail2ban is reachable via its socket.") version: str | None = Field(default=None, description="fail2ban version string.") 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.") -class ServerStatusResponse(BaseModel): +class ServerStatusResponse(BanGuiBaseModel): """Response for ``GET /api/dashboard/status``.""" - model_config = ConfigDict(strict=True) - status: ServerStatus -class ServerSettings(BaseModel): +class ServerSettings(BanGuiBaseModel): """Domain model for fail2ban server-level settings.""" - model_config = ConfigDict(strict=True) - log_level: str = Field(..., description="fail2ban daemon log level.") log_target: str = Field(..., description="Log destination: STDOUT, STDERR, SYSLOG, or a file path.") 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.") -class ServerSettingsUpdate(BaseModel): +class ServerSettingsUpdate(BanGuiBaseModel): """Payload for ``PUT /api/server/settings``.""" - model_config = ConfigDict(strict=True) - log_level: str | None = Field(default=None) log_target: str | None = Field(default=None) db_purge_age: 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``.""" - model_config = ConfigDict(strict=True) - settings: ServerSettings warnings: dict[str, bool] = Field( default_factory=dict, diff --git a/backend/app/models/setup.py b/backend/app/models/setup.py index 9b9ff80..1264c48 100644 --- a/backend/app/models/setup.py +++ b/backend/app/models/setup.py @@ -3,14 +3,13 @@ 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``.""" - model_config = ConfigDict(strict=True) - master_password: str = Field( ..., min_length=8, @@ -53,30 +52,21 @@ class SetupRequest(BaseModel): description="Number of minutes a user session remains valid.", ) - -class SetupResponse(BaseModel): +class SetupResponse(BanGuiBaseModel): """Response returned after a successful initial setup.""" - model_config = ConfigDict(strict=True) - message: str = Field( default="Setup completed successfully. Please log in.", ) - -class SetupTimezoneResponse(BaseModel): +class SetupTimezoneResponse(BanGuiBaseModel): """Response for ``GET /api/setup/timezone``.""" - model_config = ConfigDict(strict=True) - timezone: str = Field(..., description="Configured IANA timezone identifier.") - -class SetupStatusResponse(BaseModel): +class SetupStatusResponse(BanGuiBaseModel): """Response indicating whether setup has been completed.""" - model_config = ConfigDict(strict=True) - completed: bool = Field( ..., description="``True`` if the initial setup has already been performed.",