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
- 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 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
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
- Where found:
- [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/` |
| 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

View File

@@ -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.")

View File

@@ -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``.

View File

@@ -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

View File

@@ -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.")

View File

@@ -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 "

View File

@@ -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"],

View File

@@ -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(

View File

@@ -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.")

View File

@@ -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,

View File

@@ -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,

View File

@@ -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.",