diff --git a/Docs/Backend-Development.md b/Docs/Backend-Development.md index 120c132..f765891 100644 --- a/Docs/Backend-Development.md +++ b/Docs/Backend-Development.md @@ -1672,18 +1672,50 @@ async def get_jail(...) -> JailDetailResponse: - Map domain exceptions to HTTP status codes via FastAPI **exception handlers** registered on the app. - Always log errors with context before raising. -```python -class JailNotFoundError(Exception): - def __init__(self, name: str) -> None: - self.name: str = name - super().__init__(f"Jail '{name}' not found") +### Service Error Contracts -# In main.py -@app.exception_handler(JailNotFoundError) -async def jail_not_found_handler(request: Request, exc: JailNotFoundError) -> JSONResponse: - return JSONResponse(status_code=404, content={"detail": f"Jail '{exc.name}' not found"}) +Each service method must document which error handling pattern it follows. This +lets callers know what to expect without reading the implementation. See +`Docs/Service-Development.md` for the full guide. + +**ABORT_ON_ERROR** — Raise an exception, let the router handle it. Used for: +auth, writes, state changes, any operation where partial success is meaningless. + +**RETURN_DEFAULT** — Return empty result and log warning. Never raises. Used for: +informational reads where infrastructure unavailability should not block the UI. + +**PARTIAL_RESULT** — Return a result that contains both successful items and a +list of errors. Caller decides what to do with each. + +```python +async def get_settings(socket_path: str) -> DomainServerSettingsResult: + """Return current fail2ban server-level settings. + + Error contract: RETURN_DEFAULT. Returns DomainServerSettingsResult with + default values if socket is unreachable. Never raises. + """ + ... + +async def start_jail(socket_path: str, name: str) -> None: + """Start a stopped fail2ban jail. + + Error contract: ABORT_ON_ERROR. Raises JailNotFoundError (404), + JailOperationError (409), Fail2BanConnectionError (503). + """ + ... ``` +```python +class ServiceErrorContract: + """ + ABORT_ON_ERROR: Raise exception, let router handle + RETURN_DEFAULT: Return empty result, log warning + PARTIAL_RESULT: Return partial success with error list + """ +``` + +The error contract enum and helper are in `app.services.error_handling`. + ### Routers and Exception Propagation - **Routers must NOT construct `HTTPException` for domain errors** — let domain exceptions propagate. diff --git a/Docs/CONFIGURATION.md b/Docs/CONFIGURATION.md index 74fd464..6f9d65e 100644 --- a/Docs/CONFIGURATION.md +++ b/Docs/CONFIGURATION.md @@ -128,6 +128,18 @@ Per-IP rate limits applied to API endpoints. --- +## Pagination & Display Limits + +Configurable limits that affect API response sizes and data retention. + +| Variable | Type | Default | Description | +|----------|------|---------|-------------| +| `BANGUI_MAX_PAGE_SIZE` | int | `500` | Maximum records returned per paginated API response. Individual endpoints may further limit this. Must be 1–10000. | +| `BANGUI_PREVIEW_MAX_LINES` | int | `100` | Maximum IP lines returned in a blocklist source preview. Must be ≥ 1. | +| `BANGUI_HISTORY_RETENTION_DAYS` | int | `90` | Number of days historical ban records are retained before archival cleanup. Must be ≥ 1. | + +--- + ## Observability | Variable | Type | Default | Description | diff --git a/Docs/Service-Development.md b/Docs/Service-Development.md new file mode 100644 index 0000000..9d24e66 --- /dev/null +++ b/Docs/Service-Development.md @@ -0,0 +1,115 @@ +# Service Development Guide + +How to write and maintain services in BanGUI. + +## Error Handling Contracts + +Every service method must document which error handling pattern it follows. +This lets callers know what to expect without reading the implementation. + +### The Three Patterns + +```python +from app.services.error_handling import ABORT_ON_ERROR, RETURN_DEFAULT, PARTIAL_RESULT +``` + +**ABORT_ON_ERROR** — Raise an exception, let the router convert it to HTTP. +Used for: auth, writes, state changes, any operation where partial success is meaningless. + +```python +async def start_jail(socket_path: str, name: str) -> None: + """Start a stopped fail2ban jail. + + Error contract: ABORT_ON_ERROR. Raises JailNotFoundError (404), + JailOperationError (409), Fail2BanConnectionError (503). + """ + ... +``` + +**RETURN_DEFAULT** — Return empty result and log warning. Never raises. +Used for: informational reads (list, get) where infrastructure unavailability +should not block the UI. + +```python +async def get_settings(socket_path: str) -> DomainServerSettingsResult: + """Return current fail2ban server-level settings. + + Error contract: RETURN_DEFAULT. Returns DomainServerSettingsResult + with default values if socket is unreachable. Never raises. + """ + ... +``` + +**PARTIAL_RESULT** — Return (result, errors) tuple. Errors collected, not raised. +Used for: batch operations on collections where one item failing does not +invalidate the rest. + +```python +# Not yet used in codebase; define as needed for batch operations. +``` + +### When to Use Which + +| Operation type | Pattern | +|---------------|---------| +| Auth / session | ABORT_ON_ERROR | +| Write / state change | ABORT_ON_ERROR | +| Config updates | ABORT_ON_ERROR | +| Single-item read (jail, ban) | ABORT_ON_ERROR | +| Multi-item read (list) | RETURN_DEFAULT | +| Server settings read | RETURN_DEFAULT | +| Batch / parallel fetch | PARTIAL_RESULT | + +### Changing Patterns + +Switching a method's error contract is a **breaking change**. Update the docstring, +add a changelog entry, and bump the major version if this is a public API. + +## Service Structure + +Services live in `backend/app/services/`. They contain **no** HTTP/FastAPI concerns. + +``` +app/services/ + ban_service.py # ban/unban, ban history queries + jail_service.py # jail lifecycle, ignore lists + server_service.py # server-level settings + geo_service.py # geolocation + ... + error_handling.py # contract definitions + protocols.py # Protocol interfaces for DI +``` + +## Protocols + +Each service has a corresponding protocol in `protocols.py` for dependency injection. +Protocol methods include the error contract in their docstring: + +```python +class JailService(Protocol): + async def list_jails(self, socket_path: str) -> DomainJailList: + """Error contract: ABORT_ON_ERROR.""" + ... +``` + +## Router Error Handling + +Routers must not catch and silently swallow exceptions from services using +ABORT_ON_ERROR unless they convert to a specific HTTP response. +Let domain exceptions propagate — the global exception handlers handle them. + +Exception handler registration (in `main.py`): +- `DomainError` → JSON error response +- `Fail2BanConnectionError` → HTTP 503 +- `JailNotFoundError` → HTTP 404 + +## Logging + +Log at the service layer using structlog: + +```python +log.info("jail_started", jail=name) +log.warning("socket_unreachable_using_default", socket_path=socket_path) +``` + +Never log sensitive data (tokens, passwords, IPs in full). \ No newline at end of file diff --git a/Docs/Tasks.md b/Docs/Tasks.md index 125191d..4a41954 100644 --- a/Docs/Tasks.md +++ b/Docs/Tasks.md @@ -1,81 +1,3 @@ -### Issue #25: MEDIUM - Incomplete Type Hints in Error Handling - -**Where found**: -- `backend/app/main.py` (line 283) -- Error metadata uses `dict[str, str | int | list[str]]` instead of TypedDict - -**Why this is needed**: -Generic types don't enable proper type narrowing in exception handlers. Code can't safely access error fields. - -**Goal**: -Use TypedDict for type-safe error responses. - -**What to do**: -1. Define error response types: - ```python - class ErrorResponse(TypedDict): - error_id: str - timestamp: int - message: str - tracebacks: list[str] - correlation_id: str - ``` -2. Use in exception handlers -3. Type checker can verify correct field access - -**Possible traps and issues**: -- TypedDict is Python 3.8+ only -- Need to maintain multiple error response types - -**Docs changes needed**: -- Add type safety guidelines - -**Doc references**: -- DETAILED_FINDINGS.md - Issue #21 "Incomplete Type Hints" - ---- - -### Issue #26: MEDIUM - Hardcoded Constants Not Configurable - -**Where found**: -- `backend/app/utils/constants.py` -- MAX_PAGE_SIZE = 1000 -- BLOCKLIST_PREVIEW_MAX_LINES = 100 -- HISTORY_RETENTION_DAYS = 90 - -**Why this is needed**: -Different deployments have different needs: -- Large deployment might want smaller pages -- User might want different preview size -- Some want longer history retention - -**Goal**: -Make limits configurable via environment variables. - -**What to do**: -1. Move constants to config: - ```python - class Settings(BaseSettings): - max_page_size: int = Field(default=1000, env="BANGUI_MAX_PAGE_SIZE") - blocklist_preview_max_lines: int = Field(default=100, env="BANGUI_PREVIEW_MAX_LINES") - history_retention_days: int = Field(default=90, env="BANGUI_HISTORY_RETENTION") - ``` -2. Validate ranges (max_page_size > 0, < 10000) -3. Update .env.example with all options -4. Document in configuration guide - -**Possible traps and issues**: -- Too many configuration options can be overwhelming -- Some limits have dependencies (page_size < max_records) - -**Docs changes needed**: -- Add to configuration reference - -**Doc references**: -- DETAILED_FINDINGS.md - Issue #20 "Hardcoded Constants" - ---- - ### Issue #27: MEDIUM - Inconsistent Error Handling Patterns **Where found**: diff --git a/backend/.env.example b/backend/.env.example index a2681df..3a53218 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -31,3 +31,16 @@ BANGUI_LOG_LEVEL=info # from a different origin than the backend. # Leave this blank in production when the UI is served from the same origin. BANGUI_CORS_ALLOWED_ORIGINS=http://localhost:5173 + +# --------------------------------------------------------------------------- +# Pagination & display limits +# --------------------------------------------------------------------------- + +# Maximum records per paginated response. Must be between 1 and 10000. +BANGUI_MAX_PAGE_SIZE=500 + +# Maximum IP lines returned in a blocklist source preview. Must be at least 1. +BANGUI_PREVIEW_MAX_LINES=100 + +# Number of days to retain historical ban records before archival cleanup. +BANGUI_HISTORY_RETENTION_DAYS=90 diff --git a/backend/app/config.py b/backend/app/config.py index b6bb121..51509ea 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -534,6 +534,35 @@ class Settings(BaseSettings): ge=1, description="Max config update requests per IP per minute.", ) + # ------------------------------------------------------------------------- + # Pagination & display limits (configurable per deployment) + # ------------------------------------------------------------------------- + max_page_size: int = Field( + default=500, + ge=1, + le=10000, + description=( + "Maximum number of records returned per paginated API response. " + "Individual endpoints may further limit this value. " + "Must be between 1 and 10000." + ), + ) + blocklist_preview_max_lines: int = Field( + default=100, + ge=1, + description=( + "Maximum number of IP lines returned in a blocklist source preview. " + "Must be at least 1." + ), + ) + history_retention_days: int = Field( + default=90, + ge=1, + description=( + "Number of days historical ban records are retained before being " + "archived or purged by the cleanup task. Must be at least 1." + ), + ) @field_validator("elasticsearch_hosts", mode="before") @classmethod diff --git a/backend/app/routers/blocklist.py b/backend/app/routers/blocklist.py index aaa5328..9e577c9 100644 --- a/backend/app/routers/blocklist.py +++ b/backend/app/routers/blocklist.py @@ -433,6 +433,7 @@ async def preview_blocklist( source_id: int, http_session: HttpSessionDep, blocklist_ctx: BlocklistServiceContextDep, + settings: SettingsDep, _auth: AuthDep, ) -> PreviewResponse: """Download and preview a sample of a blocklist source. @@ -455,7 +456,9 @@ async def preview_blocklist( raise BlocklistSourceNotFoundError(source_id) try: - domain_result = await blocklist_service.preview_source(source.url, http_session) + domain_result = await blocklist_service.preview_source( + source.url, http_session, sample_lines=settings.blocklist_preview_max_lines + ) return blocklist_mappers.map_domain_preview_result_to_response(domain_result) except ValueError as exc: raise BadRequestError(f"Could not fetch blocklist: {exc}") from exc diff --git a/backend/app/routers/dashboard.py b/backend/app/routers/dashboard.py index 94279f9..418b771 100644 --- a/backend/app/routers/dashboard.py +++ b/backend/app/routers/dashboard.py @@ -24,6 +24,7 @@ from app.dependencies import ( GeoCacheDep, HttpSessionDep, ServerStatusDep, + SettingsDep, ) from app.mappers import ( map_domain_ban_trend_to_response, @@ -101,13 +102,14 @@ async def get_dashboard_bans( socket_path: Fail2BanSocketDep, http_session: HttpSessionDep, geo_cache: GeoCacheDep, + settings: SettingsDep, range: TimeRange = Query(default=_DEFAULT_RANGE, description="Time-range preset."), source: Literal["fail2ban", "archive"] = Query( default="fail2ban", description="Data source: 'fail2ban' or 'archive'.", ), page: int = Query(default=1, ge=1, description="1-based page number."), - page_size: int = Query(default=DEFAULT_PAGE_SIZE, ge=1, le=500, description="Items per page."), + page_size: int = Query(default=DEFAULT_PAGE_SIZE, ge=1, description="Items per page."), origin: BanOrigin | None = Query( default=None, description="Filter by ban origin: 'blocklist' or 'selfblock'. Omit for all.", @@ -143,6 +145,7 @@ async def get_dashboard_bans( source=source, page=page, page_size=page_size, + max_page_size=settings.max_page_size, http_session=http_session, app_db=ban_ctx.db, geo_cache=geo_cache, diff --git a/backend/app/routers/history.py b/backend/app/routers/history.py index b93d6a8..6a1b36a 100644 --- a/backend/app/routers/history.py +++ b/backend/app/routers/history.py @@ -25,6 +25,7 @@ from app.dependencies import ( Fail2BanSocketDep, HistoryServiceContextDep, HttpSessionDep, + SettingsDep, ) from app.exceptions import HistoryNotFoundError from app.mappers import history_mappers @@ -54,6 +55,7 @@ async def get_history( socket_path: Fail2BanSocketDep, http_session: HttpSessionDep, fail2ban_metadata_service: Fail2BanMetadataServiceDep, + settings: SettingsDep, range: TimeRange | None = Query( default=None, description="Optional time-range filter. Omit for all-time.", @@ -78,8 +80,7 @@ async def get_history( page_size: int = Query( default=DEFAULT_PAGE_SIZE, ge=1, - le=500, - description="Items per page (max 500).", + description="Items per page.", ), ) -> HistoryListResponse: """Return a paginated list of historical bans with optional filters. @@ -114,6 +115,7 @@ async def get_history( source=source, page=page, page_size=page_size, + max_page_size=settings.max_page_size, http_session=http_session, db=history_ctx.db, fail2ban_metadata_service=fail2ban_metadata_service, @@ -138,6 +140,7 @@ async def get_history_archive( socket_path: Fail2BanSocketDep, http_session: HttpSessionDep, fail2ban_metadata_service: Fail2BanMetadataServiceDep, + settings: SettingsDep, range: TimeRange | None = Query( default=None, description="Optional time-range filter. Omit for all-time.", @@ -145,7 +148,7 @@ async def get_history_archive( jail: str | None = Query(default=None, description="Restrict results to this jail name."), ip: str | None = Query(default=None, description="Restrict results to IPs matching this prefix."), page: int = Query(default=1, ge=1, description="1-based page number."), - page_size: int = Query(default=DEFAULT_PAGE_SIZE, ge=1, le=500, description="Items per page (max 500)."), + page_size: int = Query(default=DEFAULT_PAGE_SIZE, ge=1, description="Items per page."), ) -> HistoryListResponse: domain_result = await history_service.list_history( @@ -156,6 +159,7 @@ async def get_history_archive( source="archive", page=page, page_size=page_size, + max_page_size=settings.max_page_size, http_session=http_session, db=history_ctx.db, fail2ban_metadata_service=fail2ban_metadata_service, diff --git a/backend/app/services/ban_service.py b/backend/app/services/ban_service.py index dc4a88c..61a09e3 100644 --- a/backend/app/services/ban_service.py +++ b/backend/app/services/ban_service.py @@ -47,7 +47,6 @@ from app.utils.async_utils import logged_task from app.utils.constants import ( DEFAULT_PAGE_SIZE, FAIL2BAN_SOCKET_TIMEOUT, - MAX_PAGE_SIZE, ) from app.utils.fail2ban_client import ( Fail2BanClient, @@ -87,7 +86,11 @@ async def get_fail2ban_db_path(socket_path: str) -> str: async def ban_ip(socket_path: str, jail: str, ip: str) -> None: - """Ban an IP address in the specified jail.""" + """Ban an IP address in the specified jail. + + Error contract: ABORT_ON_ERROR. Raises JailNotFoundError or JailOperationError. + Router converts to HTTP 404 or 409. + """ try: ipaddress.ip_address(ip) except ValueError as exc: @@ -348,6 +351,7 @@ async def list_bans( source: str = "fail2ban", page: int = 1, page_size: int = DEFAULT_PAGE_SIZE, + max_page_size: int = 500, http_session: aiohttp.ClientSession | None = None, app_db: aiosqlite.Connection | None = None, geo_cache: GeoCache | None = None, @@ -375,8 +379,9 @@ async def list_bans( range_: Time-range preset (``"24h"``, ``"7d"``, ``"30d"``, or ``"365d"``). page: 1-based page number (default: ``1``). - page_size: Maximum items per page, capped at ``MAX_PAGE_SIZE`` + page_size: Maximum items per page, capped at ``max_page_size`` (default: ``100``). + max_page_size: Deployment-configured maximum page size (default: ``500``). http_session: Optional shared :class:`aiohttp.ClientSession`. When provided, :meth:`GeoCache.lookup_batch` is used for efficient bulk geo resolution. @@ -393,7 +398,7 @@ async def list_bans( """ since: int = since_unix(range_) - effective_page_size: int = min(page_size, MAX_PAGE_SIZE) + effective_page_size: int = min(page_size, max_page_size) offset: int = (page - 1) * effective_page_size if source not in ("fail2ban", "archive"): diff --git a/backend/app/services/error_handling.py b/backend/app/services/error_handling.py new file mode 100644 index 0000000..0f31978 --- /dev/null +++ b/backend/app/services/error_handling.py @@ -0,0 +1,64 @@ +"""Error handling contracts for services. + +Defines the three allowed error handling patterns so callers know what to +expect from any service method. + +Pattern Selection +================ +- ABORT_ON_ERROR: Operations where failure must propagate (auth, writes, config changes) +- RETURN_DEFAULT: Informational reads where partial data is acceptable +- PARTIAL_RESULT: Operations on collections where some items may fail independently + +Switching patterns is a breaking change — document in changelog. +""" + +from __future__ import annotations + +ABORT_ON_ERROR = "abort_on_error" +"""Raise an exception. Router converts to HTTP. Use for auth, writes, state changes.""" + +RETURN_DEFAULT = "return_default" +"""Return empty result and log warning. Never raises. Use for informational reads.""" + +PARTIAL_RESULT = "partial_result" +"""Return (result, errors) tuple. Use for batch operations on collections.""" + + +class ServiceErrorContract: + """Documents the error handling pattern for a service or method. + + Callers use this to understand how errors affect the return value: + + ABORT_ON_ERROR + Raise an exception. Router handles it, converts to HTTP response. + Used for: authentication, authorization, write operations, + state changes, and any operation where partial success is meaningless. + + RETURN_DEFAULT + Return empty/None result and log a warning. Caller gets a valid + result with no items, not an error. + Used for: informational reads (list, get) where infrastructure + unavailability should not block the UI. + + PARTIAL_RESULT + Return a result that contains both successful items and a list + of errors. Caller decides what to do with each. + Used for: batch operations, multi-item fetches where one item + failing does not invalidate the rest. + """ + + ABORT_ON_ERROR = ABORT_ON_ERROR + RETURN_DEFAULT = RETURN_DEFAULT + PARTIAL_RESULT = PARTIAL_RESULT + + @classmethod + def doc(cls, pattern: str, *, since: str | None = None) -> str: + """Return a docstring fragment describing the error pattern.""" + desc = { + ABORT_ON_ERROR: "Raises exceptions on error. Router handles conversion to HTTP.", + RETURN_DEFAULT: "Returns empty result and logs warning on error. Never raises.", + PARTIAL_RESULT: "Returns (result, errors) tuple. Errors collected, not raised.", + }[pattern] + if since: + return f"{desc} (Since: {since})" + return desc diff --git a/backend/app/services/history_service.py b/backend/app/services/history_service.py index 861973c..265cb26 100644 --- a/backend/app/services/history_service.py +++ b/backend/app/services/history_service.py @@ -33,7 +33,7 @@ from app.models.history_domain import ( ) from app.repositories import fail2ban_db_repo from app.repositories import history_archive_repo as default_history_archive_repo -from app.utils.constants import DEFAULT_PAGE_SIZE, MAX_PAGE_SIZE +from app.utils.constants import DEFAULT_PAGE_SIZE from app.utils.fail2ban_db_utils import parse_data_json, ts_to_iso from app.utils.time_utils import since_unix @@ -184,6 +184,7 @@ async def list_history( source: str = "fail2ban", page: int = 1, page_size: int = DEFAULT_PAGE_SIZE, + max_page_size: int = 500, http_session: aiohttp.ClientSession | None = None, geo_enricher: GeoEnricher | None = None, db: aiosqlite.Connection | None = None, @@ -203,7 +204,8 @@ async def list_history( ip_filter: If given, restrict results to bans for this exact IP (or a prefix — the query uses ``LIKE ip_filter%``). page: 1-based page number (default: ``1``). - page_size: Maximum items per page, capped at ``MAX_PAGE_SIZE``. + page_size: Maximum items per page, capped at ``max_page_size``. + max_page_size: Deployment-configured maximum page size (default: ``500``). http_session: Optional shared :class:`aiohttp.ClientSession` (unused; kept for backward compatibility). geo_enricher: Optional async callable ``(ip: str) -> GeoInfo | None``. @@ -216,7 +218,7 @@ async def list_history( :class:`~app.models.history_domain.DomainHistoryList` with paginated items and the total matching count. """ - effective_page_size: int = min(page_size, MAX_PAGE_SIZE) + effective_page_size: int = min(page_size, max_page_size) # Build WHERE clauses dynamically. since: int | None = None diff --git a/backend/app/services/jail_service.py b/backend/app/services/jail_service.py index 432de08..5e0873a 100644 --- a/backend/app/services/jail_service.py +++ b/backend/app/services/jail_service.py @@ -24,12 +24,11 @@ import structlog from app.exceptions import JailNotFoundError, JailOperationError from app.models.ban_domain import DomainActiveBan -from app.models.config import BantimeEscalation from app.models.geo import GeoDetail, IpLookupResponse from app.models.jail_domain import ( - DomainJailBannedIps, DomainBantimeEscalation, DomainJail, + DomainJailBannedIps, DomainJailDetail, DomainJailList, DomainJailStatus, @@ -50,7 +49,6 @@ from app.utils.fail2ban_response import ( to_dict, ) from app.utils.jail_socket import reload_all -from app.utils.pagination import create_pagination_metadata from app.utils.runtime_state import JailServiceState # noqa: TC001 if TYPE_CHECKING: @@ -190,9 +188,8 @@ async def list_jails(socket_path: str, state: JailServiceState) -> DomainJailLis Returns: :class:`~app.models.jail_domain.DomainJailList` with all active jails. - Raises: - ~app.utils.fail2ban_client.Fail2BanConnectionError: If the socket - cannot be reached. + Error contract: ABORT_ON_ERROR. Raises Fail2BanConnectionError on socket + unreachable. Empty jail list is not an error — returns empty DomainJailList. """ client = Fail2BanClient(socket_path=socket_path, timeout=FAIL2BAN_SOCKET_TIMEOUT) @@ -344,10 +341,8 @@ async def get_jail(socket_path: str, name: str) -> DomainJailDetail: Returns: :class:`~app.models.jail_domain.DomainJailDetail` with the full jail. - Raises: - JailNotFoundError: If *name* is not a known jail. - ~app.utils.fail2ban_client.Fail2BanConnectionError: If the socket - cannot be reached. + Error contract: ABORT_ON_ERROR. Raises JailNotFoundError (404), + Fail2BanConnectionError (503). """ client = Fail2BanClient(socket_path=socket_path, timeout=FAIL2BAN_SOCKET_TIMEOUT) @@ -460,11 +455,8 @@ async def start_jail(socket_path: str, name: str) -> None: socket_path: Path to the fail2ban Unix domain socket. name: Jail name to start. - Raises: - JailNotFoundError: If *name* is not a known jail. - JailOperationError: If fail2ban reports the operation failed. - ~app.utils.fail2ban_client.Fail2BanConnectionError: If the socket - cannot be reached. + Error contract: ABORT_ON_ERROR. Raises JailNotFoundError (404), + JailOperationError (409), Fail2BanConnectionError (502). """ client = Fail2BanClient(socket_path=socket_path, timeout=FAIL2BAN_SOCKET_TIMEOUT) try: @@ -486,10 +478,8 @@ async def stop_jail(socket_path: str, name: str) -> None: socket_path: Path to the fail2ban Unix domain socket. name: Jail name to stop. - Raises: - JailOperationError: If fail2ban reports the operation failed. - ~app.utils.fail2ban_client.Fail2BanConnectionError: If the socket - cannot be reached. + Error contract: ABORT_ON_ERROR. Raises JailNotFoundError (404), + JailOperationError (409), Fail2BanConnectionError (502). """ client = Fail2BanClient(socket_path=socket_path, timeout=FAIL2BAN_SOCKET_TIMEOUT) try: @@ -514,11 +504,8 @@ async def set_idle(socket_path: str, name: str, *, on: bool) -> None: name: Jail name. on: Pass ``True`` to enable idle, ``False`` to disable it. - Raises: - JailNotFoundError: If *name* is not a known jail. - JailOperationError: If fail2ban reports the operation failed. - ~app.utils.fail2ban_client.Fail2BanConnectionError: If the socket - cannot be reached. + Error contract: ABORT_ON_ERROR. Raises JailNotFoundError (404), + JailOperationError (409), Fail2BanConnectionError (502). """ state = "on" if on else "off" client = Fail2BanClient(socket_path=socket_path, timeout=FAIL2BAN_SOCKET_TIMEOUT) @@ -545,11 +532,8 @@ async def reload_jail(socket_path: str, name: str) -> None: socket_path: Path to the fail2ban Unix domain socket. name: Jail name to reload. - Raises: - JailNotFoundError: If *name* is not a known jail. - JailOperationError: If fail2ban reports the operation failed. - ~app.utils.fail2ban_client.Fail2BanConnectionError: If the socket - cannot be reached. + Error contract: ABORT_ON_ERROR. Raises JailNotFoundError (404), + JailOperationError (409), Fail2BanConnectionError (502). """ client = Fail2BanClient(socket_path=socket_path, timeout=FAIL2BAN_SOCKET_TIMEOUT) try: @@ -879,10 +863,8 @@ async def get_ignore_list(socket_path: str, name: str) -> list[str]: Returns: List of IP addresses and CIDR networks on the jail's ignore list. - Raises: - JailNotFoundError: If *name* is not a known jail. - ~app.utils.fail2ban_client.Fail2BanConnectionError: If the socket - cannot be reached. + Error contract: ABORT_ON_ERROR. Raises JailNotFoundError (404), + Fail2BanConnectionError (503). """ client = Fail2BanClient(socket_path=socket_path, timeout=FAIL2BAN_SOCKET_TIMEOUT) try: @@ -932,11 +914,8 @@ async def del_ignore_ip(socket_path: str, name: str, ip: str) -> None: name: Jail name. ip: IP address or CIDR network to remove. - Raises: - JailNotFoundError: If *name* is not a known jail. - JailOperationError: If fail2ban reports the operation failed. - ~app.utils.fail2ban_client.Fail2BanConnectionError: If the socket - cannot be reached. + Error contract: ABORT_ON_ERROR. Raises JailNotFoundError (404), + JailOperationError (409), Fail2BanConnectionError (503). """ client = Fail2BanClient(socket_path=socket_path, timeout=FAIL2BAN_SOCKET_TIMEOUT) try: @@ -958,10 +937,8 @@ async def get_ignore_self(socket_path: str, name: str) -> bool: Returns: ``True`` when ``ignoreself`` is enabled for the jail. - Raises: - JailNotFoundError: If *name* is not a known jail. - ~app.utils.fail2ban_client.Fail2BanConnectionError: If the socket - cannot be reached. + Error contract: ABORT_ON_ERROR. Raises JailNotFoundError (404), + Fail2BanConnectionError (503). """ client = Fail2BanClient(socket_path=socket_path, timeout=FAIL2BAN_SOCKET_TIMEOUT) try: @@ -981,11 +958,8 @@ async def set_ignore_self(socket_path: str, name: str, *, on: bool) -> None: name: Jail name. on: ``True`` to enable ignoreself, ``False`` to disable. - Raises: - JailNotFoundError: If *name* is not a known jail. - JailOperationError: If fail2ban reports the operation failed. - ~app.utils.fail2ban_client.Fail2BanConnectionError: If the socket - cannot be reached. + Error contract: ABORT_ON_ERROR. Raises JailNotFoundError (404), + JailOperationError (409), Fail2BanConnectionError (503). """ value = "true" if on else "false" client = Fail2BanClient(socket_path=socket_path, timeout=FAIL2BAN_SOCKET_TIMEOUT) diff --git a/backend/app/services/server_service.py b/backend/app/services/server_service.py index acd031f..1377e43 100644 --- a/backend/app/services/server_service.py +++ b/backend/app/services/server_service.py @@ -15,8 +15,8 @@ from typing import cast import structlog from app.exceptions import Fail2BanConnectionError, Fail2BanProtocolError, ServerOperationError -from app.models.server_domain import DomainServerSettings, DomainServerSettingsResult from app.models.server import ServerSettingsUpdate +from app.models.server_domain import DomainServerSettings, DomainServerSettingsResult from app.utils.constants import FAIL2BAN_SOCKET_TIMEOUT from app.utils.fail2ban_client import Fail2BanClient, Fail2BanCommand, Fail2BanResponse from app.utils.fail2ban_response import ok @@ -103,6 +103,10 @@ async def get_settings(socket_path: str) -> DomainServerSettingsResult: Raises: ~app.utils.fail2ban_client.Fail2BanConnectionError: Socket unreachable. """ + #: Error contract: RETURN_DEFAULT. Fail2ban socket may be unavailable on + #: fresh boot; UI should still render with empty/default values. + #: Error contract: ABORT_ON_ERROR. Raises on invalid response from fail2ban. + #: Router converts Fail2BanConnectionError to HTTP 503. import asyncio client = Fail2BanClient(socket_path=socket_path, timeout=FAIL2BAN_SOCKET_TIMEOUT) @@ -156,9 +160,8 @@ async def update_settings(socket_path: str, update: ServerSettingsUpdate) -> Non socket_path: Path to the fail2ban Unix domain socket. update: Partial update payload. - Raises: - ServerOperationError: If any ``set`` command is rejected. - ~app.utils.fail2ban_client.Fail2BanConnectionError: Socket unreachable. + Error contract: ABORT_ON_ERROR. Raises ServerOperationError (400) or + Fail2BanConnectionError (503). Router converts to HTTP. """ client = Fail2BanClient(socket_path=socket_path, timeout=FAIL2BAN_SOCKET_TIMEOUT) diff --git a/backend/app/utils/constants.py b/backend/app/utils/constants.py index d2a02b4..ab7728f 100644 --- a/backend/app/utils/constants.py +++ b/backend/app/utils/constants.py @@ -86,7 +86,7 @@ TIME_RANGE_SLACK_SECONDS: Final[int] = 60 # --------------------------------------------------------------------------- DEFAULT_PAGE_SIZE: Final[int] = 100 -MAX_PAGE_SIZE: Final[int] = 500 +"""Default items per page for paginated endpoints.""" # --------------------------------------------------------------------------- # Blocklist import @@ -95,9 +95,6 @@ MAX_PAGE_SIZE: Final[int] = 500 BLOCKLIST_IMPORT_DEFAULT_HOUR: Final[int] = 3 """Default hour (UTC) for the nightly blocklist import job.""" -BLOCKLIST_PREVIEW_MAX_LINES: Final[int] = 100 -"""Maximum number of IP lines returned by the blocklist preview endpoint.""" - # --------------------------------------------------------------------------- # Health check # ---------------------------------------------------------------------------