refactor(backend): clean up jail service, add error handling service
- Extract jail status/processing to helper functions - Add error_handling.py service for centralized error handling - Update config.py with validation and defaults - Update .env.example with all config options - Remove obsolete Tasks.md, add Service-Development.md - Minor fixes across routers and services Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -1672,18 +1672,50 @@ async def get_jail(...) -> JailDetailResponse:
|
|||||||
- Map domain exceptions to HTTP status codes via FastAPI **exception handlers** registered on the app.
|
- Map domain exceptions to HTTP status codes via FastAPI **exception handlers** registered on the app.
|
||||||
- Always log errors with context before raising.
|
- Always log errors with context before raising.
|
||||||
|
|
||||||
```python
|
### Service Error Contracts
|
||||||
class JailNotFoundError(Exception):
|
|
||||||
def __init__(self, name: str) -> None:
|
|
||||||
self.name: str = name
|
|
||||||
super().__init__(f"Jail '{name}' not found")
|
|
||||||
|
|
||||||
# In main.py
|
Each service method must document which error handling pattern it follows. This
|
||||||
@app.exception_handler(JailNotFoundError)
|
lets callers know what to expect without reading the implementation. See
|
||||||
async def jail_not_found_handler(request: Request, exc: JailNotFoundError) -> JSONResponse:
|
`Docs/Service-Development.md` for the full guide.
|
||||||
return JSONResponse(status_code=404, content={"detail": f"Jail '{exc.name}' not found"})
|
|
||||||
|
**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 and Exception Propagation
|
||||||
|
|
||||||
- **Routers must NOT construct `HTTPException` for domain errors** — let domain exceptions propagate.
|
- **Routers must NOT construct `HTTPException` for domain errors** — let domain exceptions propagate.
|
||||||
|
|||||||
@@ -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
|
## Observability
|
||||||
|
|
||||||
| Variable | Type | Default | Description |
|
| Variable | Type | Default | Description |
|
||||||
|
|||||||
115
Docs/Service-Development.md
Normal file
115
Docs/Service-Development.md
Normal file
@@ -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).
|
||||||
@@ -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
|
### Issue #27: MEDIUM - Inconsistent Error Handling Patterns
|
||||||
|
|
||||||
**Where found**:
|
**Where found**:
|
||||||
|
|||||||
@@ -31,3 +31,16 @@ BANGUI_LOG_LEVEL=info
|
|||||||
# from a different origin than the backend.
|
# from a different origin than the backend.
|
||||||
# Leave this blank in production when the UI is served from the same origin.
|
# Leave this blank in production when the UI is served from the same origin.
|
||||||
BANGUI_CORS_ALLOWED_ORIGINS=http://localhost:5173
|
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
|
||||||
|
|||||||
@@ -534,6 +534,35 @@ class Settings(BaseSettings):
|
|||||||
ge=1,
|
ge=1,
|
||||||
description="Max config update requests per IP per minute.",
|
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")
|
@field_validator("elasticsearch_hosts", mode="before")
|
||||||
@classmethod
|
@classmethod
|
||||||
|
|||||||
@@ -433,6 +433,7 @@ async def preview_blocklist(
|
|||||||
source_id: int,
|
source_id: int,
|
||||||
http_session: HttpSessionDep,
|
http_session: HttpSessionDep,
|
||||||
blocklist_ctx: BlocklistServiceContextDep,
|
blocklist_ctx: BlocklistServiceContextDep,
|
||||||
|
settings: SettingsDep,
|
||||||
_auth: AuthDep,
|
_auth: AuthDep,
|
||||||
) -> PreviewResponse:
|
) -> PreviewResponse:
|
||||||
"""Download and preview a sample of a blocklist source.
|
"""Download and preview a sample of a blocklist source.
|
||||||
@@ -455,7 +456,9 @@ async def preview_blocklist(
|
|||||||
raise BlocklistSourceNotFoundError(source_id)
|
raise BlocklistSourceNotFoundError(source_id)
|
||||||
|
|
||||||
try:
|
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)
|
return blocklist_mappers.map_domain_preview_result_to_response(domain_result)
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
raise BadRequestError(f"Could not fetch blocklist: {exc}") from exc
|
raise BadRequestError(f"Could not fetch blocklist: {exc}") from exc
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ from app.dependencies import (
|
|||||||
GeoCacheDep,
|
GeoCacheDep,
|
||||||
HttpSessionDep,
|
HttpSessionDep,
|
||||||
ServerStatusDep,
|
ServerStatusDep,
|
||||||
|
SettingsDep,
|
||||||
)
|
)
|
||||||
from app.mappers import (
|
from app.mappers import (
|
||||||
map_domain_ban_trend_to_response,
|
map_domain_ban_trend_to_response,
|
||||||
@@ -101,13 +102,14 @@ async def get_dashboard_bans(
|
|||||||
socket_path: Fail2BanSocketDep,
|
socket_path: Fail2BanSocketDep,
|
||||||
http_session: HttpSessionDep,
|
http_session: HttpSessionDep,
|
||||||
geo_cache: GeoCacheDep,
|
geo_cache: GeoCacheDep,
|
||||||
|
settings: SettingsDep,
|
||||||
range: TimeRange = Query(default=_DEFAULT_RANGE, description="Time-range preset."),
|
range: TimeRange = Query(default=_DEFAULT_RANGE, description="Time-range preset."),
|
||||||
source: Literal["fail2ban", "archive"] = Query(
|
source: Literal["fail2ban", "archive"] = Query(
|
||||||
default="fail2ban",
|
default="fail2ban",
|
||||||
description="Data source: 'fail2ban' or 'archive'.",
|
description="Data source: 'fail2ban' or 'archive'.",
|
||||||
),
|
),
|
||||||
page: int = Query(default=1, ge=1, description="1-based page number."),
|
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(
|
origin: BanOrigin | None = Query(
|
||||||
default=None,
|
default=None,
|
||||||
description="Filter by ban origin: 'blocklist' or 'selfblock'. Omit for all.",
|
description="Filter by ban origin: 'blocklist' or 'selfblock'. Omit for all.",
|
||||||
@@ -143,6 +145,7 @@ async def get_dashboard_bans(
|
|||||||
source=source,
|
source=source,
|
||||||
page=page,
|
page=page,
|
||||||
page_size=page_size,
|
page_size=page_size,
|
||||||
|
max_page_size=settings.max_page_size,
|
||||||
http_session=http_session,
|
http_session=http_session,
|
||||||
app_db=ban_ctx.db,
|
app_db=ban_ctx.db,
|
||||||
geo_cache=geo_cache,
|
geo_cache=geo_cache,
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ from app.dependencies import (
|
|||||||
Fail2BanSocketDep,
|
Fail2BanSocketDep,
|
||||||
HistoryServiceContextDep,
|
HistoryServiceContextDep,
|
||||||
HttpSessionDep,
|
HttpSessionDep,
|
||||||
|
SettingsDep,
|
||||||
)
|
)
|
||||||
from app.exceptions import HistoryNotFoundError
|
from app.exceptions import HistoryNotFoundError
|
||||||
from app.mappers import history_mappers
|
from app.mappers import history_mappers
|
||||||
@@ -54,6 +55,7 @@ async def get_history(
|
|||||||
socket_path: Fail2BanSocketDep,
|
socket_path: Fail2BanSocketDep,
|
||||||
http_session: HttpSessionDep,
|
http_session: HttpSessionDep,
|
||||||
fail2ban_metadata_service: Fail2BanMetadataServiceDep,
|
fail2ban_metadata_service: Fail2BanMetadataServiceDep,
|
||||||
|
settings: SettingsDep,
|
||||||
range: TimeRange | None = Query(
|
range: TimeRange | None = Query(
|
||||||
default=None,
|
default=None,
|
||||||
description="Optional time-range filter. Omit for all-time.",
|
description="Optional time-range filter. Omit for all-time.",
|
||||||
@@ -78,8 +80,7 @@ async def get_history(
|
|||||||
page_size: int = Query(
|
page_size: int = Query(
|
||||||
default=DEFAULT_PAGE_SIZE,
|
default=DEFAULT_PAGE_SIZE,
|
||||||
ge=1,
|
ge=1,
|
||||||
le=500,
|
description="Items per page.",
|
||||||
description="Items per page (max 500).",
|
|
||||||
),
|
),
|
||||||
) -> HistoryListResponse:
|
) -> HistoryListResponse:
|
||||||
"""Return a paginated list of historical bans with optional filters.
|
"""Return a paginated list of historical bans with optional filters.
|
||||||
@@ -114,6 +115,7 @@ async def get_history(
|
|||||||
source=source,
|
source=source,
|
||||||
page=page,
|
page=page,
|
||||||
page_size=page_size,
|
page_size=page_size,
|
||||||
|
max_page_size=settings.max_page_size,
|
||||||
http_session=http_session,
|
http_session=http_session,
|
||||||
db=history_ctx.db,
|
db=history_ctx.db,
|
||||||
fail2ban_metadata_service=fail2ban_metadata_service,
|
fail2ban_metadata_service=fail2ban_metadata_service,
|
||||||
@@ -138,6 +140,7 @@ async def get_history_archive(
|
|||||||
socket_path: Fail2BanSocketDep,
|
socket_path: Fail2BanSocketDep,
|
||||||
http_session: HttpSessionDep,
|
http_session: HttpSessionDep,
|
||||||
fail2ban_metadata_service: Fail2BanMetadataServiceDep,
|
fail2ban_metadata_service: Fail2BanMetadataServiceDep,
|
||||||
|
settings: SettingsDep,
|
||||||
range: TimeRange | None = Query(
|
range: TimeRange | None = Query(
|
||||||
default=None,
|
default=None,
|
||||||
description="Optional time-range filter. Omit for all-time.",
|
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."),
|
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."),
|
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: 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:
|
) -> HistoryListResponse:
|
||||||
|
|
||||||
domain_result = await history_service.list_history(
|
domain_result = await history_service.list_history(
|
||||||
@@ -156,6 +159,7 @@ async def get_history_archive(
|
|||||||
source="archive",
|
source="archive",
|
||||||
page=page,
|
page=page,
|
||||||
page_size=page_size,
|
page_size=page_size,
|
||||||
|
max_page_size=settings.max_page_size,
|
||||||
http_session=http_session,
|
http_session=http_session,
|
||||||
db=history_ctx.db,
|
db=history_ctx.db,
|
||||||
fail2ban_metadata_service=fail2ban_metadata_service,
|
fail2ban_metadata_service=fail2ban_metadata_service,
|
||||||
|
|||||||
@@ -47,7 +47,6 @@ from app.utils.async_utils import logged_task
|
|||||||
from app.utils.constants import (
|
from app.utils.constants import (
|
||||||
DEFAULT_PAGE_SIZE,
|
DEFAULT_PAGE_SIZE,
|
||||||
FAIL2BAN_SOCKET_TIMEOUT,
|
FAIL2BAN_SOCKET_TIMEOUT,
|
||||||
MAX_PAGE_SIZE,
|
|
||||||
)
|
)
|
||||||
from app.utils.fail2ban_client import (
|
from app.utils.fail2ban_client import (
|
||||||
Fail2BanClient,
|
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:
|
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:
|
try:
|
||||||
ipaddress.ip_address(ip)
|
ipaddress.ip_address(ip)
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
@@ -348,6 +351,7 @@ async def list_bans(
|
|||||||
source: str = "fail2ban",
|
source: str = "fail2ban",
|
||||||
page: int = 1,
|
page: int = 1,
|
||||||
page_size: int = DEFAULT_PAGE_SIZE,
|
page_size: int = DEFAULT_PAGE_SIZE,
|
||||||
|
max_page_size: int = 500,
|
||||||
http_session: aiohttp.ClientSession | None = None,
|
http_session: aiohttp.ClientSession | None = None,
|
||||||
app_db: aiosqlite.Connection | None = None,
|
app_db: aiosqlite.Connection | None = None,
|
||||||
geo_cache: GeoCache | None = None,
|
geo_cache: GeoCache | None = None,
|
||||||
@@ -375,8 +379,9 @@ async def list_bans(
|
|||||||
range_: Time-range preset (``"24h"``, ``"7d"``, ``"30d"``, or
|
range_: Time-range preset (``"24h"``, ``"7d"``, ``"30d"``, or
|
||||||
``"365d"``).
|
``"365d"``).
|
||||||
page: 1-based page number (default: ``1``).
|
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``).
|
(default: ``100``).
|
||||||
|
max_page_size: Deployment-configured maximum page size (default: ``500``).
|
||||||
http_session: Optional shared :class:`aiohttp.ClientSession`. When
|
http_session: Optional shared :class:`aiohttp.ClientSession`. When
|
||||||
provided, :meth:`GeoCache.lookup_batch` is used
|
provided, :meth:`GeoCache.lookup_batch` is used
|
||||||
for efficient bulk geo resolution.
|
for efficient bulk geo resolution.
|
||||||
@@ -393,7 +398,7 @@ async def list_bans(
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
since: int = since_unix(range_)
|
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
|
offset: int = (page - 1) * effective_page_size
|
||||||
|
|
||||||
if source not in ("fail2ban", "archive"):
|
if source not in ("fail2ban", "archive"):
|
||||||
|
|||||||
64
backend/app/services/error_handling.py
Normal file
64
backend/app/services/error_handling.py
Normal file
@@ -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
|
||||||
@@ -33,7 +33,7 @@ from app.models.history_domain import (
|
|||||||
)
|
)
|
||||||
from app.repositories import fail2ban_db_repo
|
from app.repositories import fail2ban_db_repo
|
||||||
from app.repositories import history_archive_repo as default_history_archive_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.fail2ban_db_utils import parse_data_json, ts_to_iso
|
||||||
from app.utils.time_utils import since_unix
|
from app.utils.time_utils import since_unix
|
||||||
|
|
||||||
@@ -184,6 +184,7 @@ async def list_history(
|
|||||||
source: str = "fail2ban",
|
source: str = "fail2ban",
|
||||||
page: int = 1,
|
page: int = 1,
|
||||||
page_size: int = DEFAULT_PAGE_SIZE,
|
page_size: int = DEFAULT_PAGE_SIZE,
|
||||||
|
max_page_size: int = 500,
|
||||||
http_session: aiohttp.ClientSession | None = None,
|
http_session: aiohttp.ClientSession | None = None,
|
||||||
geo_enricher: GeoEnricher | None = None,
|
geo_enricher: GeoEnricher | None = None,
|
||||||
db: aiosqlite.Connection | 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
|
ip_filter: If given, restrict results to bans for this exact IP
|
||||||
(or a prefix — the query uses ``LIKE ip_filter%``).
|
(or a prefix — the query uses ``LIKE ip_filter%``).
|
||||||
page: 1-based page number (default: ``1``).
|
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;
|
http_session: Optional shared :class:`aiohttp.ClientSession` (unused;
|
||||||
kept for backward compatibility).
|
kept for backward compatibility).
|
||||||
geo_enricher: Optional async callable ``(ip: str) -> GeoInfo | None``.
|
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
|
:class:`~app.models.history_domain.DomainHistoryList` with paginated items
|
||||||
and the total matching count.
|
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.
|
# Build WHERE clauses dynamically.
|
||||||
since: int | None = None
|
since: int | None = None
|
||||||
|
|||||||
@@ -24,12 +24,11 @@ import structlog
|
|||||||
|
|
||||||
from app.exceptions import JailNotFoundError, JailOperationError
|
from app.exceptions import JailNotFoundError, JailOperationError
|
||||||
from app.models.ban_domain import DomainActiveBan
|
from app.models.ban_domain import DomainActiveBan
|
||||||
from app.models.config import BantimeEscalation
|
|
||||||
from app.models.geo import GeoDetail, IpLookupResponse
|
from app.models.geo import GeoDetail, IpLookupResponse
|
||||||
from app.models.jail_domain import (
|
from app.models.jail_domain import (
|
||||||
DomainJailBannedIps,
|
|
||||||
DomainBantimeEscalation,
|
DomainBantimeEscalation,
|
||||||
DomainJail,
|
DomainJail,
|
||||||
|
DomainJailBannedIps,
|
||||||
DomainJailDetail,
|
DomainJailDetail,
|
||||||
DomainJailList,
|
DomainJailList,
|
||||||
DomainJailStatus,
|
DomainJailStatus,
|
||||||
@@ -50,7 +49,6 @@ from app.utils.fail2ban_response import (
|
|||||||
to_dict,
|
to_dict,
|
||||||
)
|
)
|
||||||
from app.utils.jail_socket import reload_all
|
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
|
from app.utils.runtime_state import JailServiceState # noqa: TC001
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
@@ -190,9 +188,8 @@ async def list_jails(socket_path: str, state: JailServiceState) -> DomainJailLis
|
|||||||
Returns:
|
Returns:
|
||||||
:class:`~app.models.jail_domain.DomainJailList` with all active jails.
|
:class:`~app.models.jail_domain.DomainJailList` with all active jails.
|
||||||
|
|
||||||
Raises:
|
Error contract: ABORT_ON_ERROR. Raises Fail2BanConnectionError on socket
|
||||||
~app.utils.fail2ban_client.Fail2BanConnectionError: If the socket
|
unreachable. Empty jail list is not an error — returns empty DomainJailList.
|
||||||
cannot be reached.
|
|
||||||
"""
|
"""
|
||||||
client = Fail2BanClient(socket_path=socket_path, timeout=FAIL2BAN_SOCKET_TIMEOUT)
|
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:
|
Returns:
|
||||||
:class:`~app.models.jail_domain.DomainJailDetail` with the full jail.
|
:class:`~app.models.jail_domain.DomainJailDetail` with the full jail.
|
||||||
|
|
||||||
Raises:
|
Error contract: ABORT_ON_ERROR. Raises JailNotFoundError (404),
|
||||||
JailNotFoundError: If *name* is not a known jail.
|
Fail2BanConnectionError (503).
|
||||||
~app.utils.fail2ban_client.Fail2BanConnectionError: If the socket
|
|
||||||
cannot be reached.
|
|
||||||
"""
|
"""
|
||||||
client = Fail2BanClient(socket_path=socket_path, timeout=FAIL2BAN_SOCKET_TIMEOUT)
|
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.
|
socket_path: Path to the fail2ban Unix domain socket.
|
||||||
name: Jail name to start.
|
name: Jail name to start.
|
||||||
|
|
||||||
Raises:
|
Error contract: ABORT_ON_ERROR. Raises JailNotFoundError (404),
|
||||||
JailNotFoundError: If *name* is not a known jail.
|
JailOperationError (409), Fail2BanConnectionError (502).
|
||||||
JailOperationError: If fail2ban reports the operation failed.
|
|
||||||
~app.utils.fail2ban_client.Fail2BanConnectionError: If the socket
|
|
||||||
cannot be reached.
|
|
||||||
"""
|
"""
|
||||||
client = Fail2BanClient(socket_path=socket_path, timeout=FAIL2BAN_SOCKET_TIMEOUT)
|
client = Fail2BanClient(socket_path=socket_path, timeout=FAIL2BAN_SOCKET_TIMEOUT)
|
||||||
try:
|
try:
|
||||||
@@ -486,10 +478,8 @@ async def stop_jail(socket_path: str, name: str) -> None:
|
|||||||
socket_path: Path to the fail2ban Unix domain socket.
|
socket_path: Path to the fail2ban Unix domain socket.
|
||||||
name: Jail name to stop.
|
name: Jail name to stop.
|
||||||
|
|
||||||
Raises:
|
Error contract: ABORT_ON_ERROR. Raises JailNotFoundError (404),
|
||||||
JailOperationError: If fail2ban reports the operation failed.
|
JailOperationError (409), Fail2BanConnectionError (502).
|
||||||
~app.utils.fail2ban_client.Fail2BanConnectionError: If the socket
|
|
||||||
cannot be reached.
|
|
||||||
"""
|
"""
|
||||||
client = Fail2BanClient(socket_path=socket_path, timeout=FAIL2BAN_SOCKET_TIMEOUT)
|
client = Fail2BanClient(socket_path=socket_path, timeout=FAIL2BAN_SOCKET_TIMEOUT)
|
||||||
try:
|
try:
|
||||||
@@ -514,11 +504,8 @@ async def set_idle(socket_path: str, name: str, *, on: bool) -> None:
|
|||||||
name: Jail name.
|
name: Jail name.
|
||||||
on: Pass ``True`` to enable idle, ``False`` to disable it.
|
on: Pass ``True`` to enable idle, ``False`` to disable it.
|
||||||
|
|
||||||
Raises:
|
Error contract: ABORT_ON_ERROR. Raises JailNotFoundError (404),
|
||||||
JailNotFoundError: If *name* is not a known jail.
|
JailOperationError (409), Fail2BanConnectionError (502).
|
||||||
JailOperationError: If fail2ban reports the operation failed.
|
|
||||||
~app.utils.fail2ban_client.Fail2BanConnectionError: If the socket
|
|
||||||
cannot be reached.
|
|
||||||
"""
|
"""
|
||||||
state = "on" if on else "off"
|
state = "on" if on else "off"
|
||||||
client = Fail2BanClient(socket_path=socket_path, timeout=FAIL2BAN_SOCKET_TIMEOUT)
|
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.
|
socket_path: Path to the fail2ban Unix domain socket.
|
||||||
name: Jail name to reload.
|
name: Jail name to reload.
|
||||||
|
|
||||||
Raises:
|
Error contract: ABORT_ON_ERROR. Raises JailNotFoundError (404),
|
||||||
JailNotFoundError: If *name* is not a known jail.
|
JailOperationError (409), Fail2BanConnectionError (502).
|
||||||
JailOperationError: If fail2ban reports the operation failed.
|
|
||||||
~app.utils.fail2ban_client.Fail2BanConnectionError: If the socket
|
|
||||||
cannot be reached.
|
|
||||||
"""
|
"""
|
||||||
client = Fail2BanClient(socket_path=socket_path, timeout=FAIL2BAN_SOCKET_TIMEOUT)
|
client = Fail2BanClient(socket_path=socket_path, timeout=FAIL2BAN_SOCKET_TIMEOUT)
|
||||||
try:
|
try:
|
||||||
@@ -879,10 +863,8 @@ async def get_ignore_list(socket_path: str, name: str) -> list[str]:
|
|||||||
Returns:
|
Returns:
|
||||||
List of IP addresses and CIDR networks on the jail's ignore list.
|
List of IP addresses and CIDR networks on the jail's ignore list.
|
||||||
|
|
||||||
Raises:
|
Error contract: ABORT_ON_ERROR. Raises JailNotFoundError (404),
|
||||||
JailNotFoundError: If *name* is not a known jail.
|
Fail2BanConnectionError (503).
|
||||||
~app.utils.fail2ban_client.Fail2BanConnectionError: If the socket
|
|
||||||
cannot be reached.
|
|
||||||
"""
|
"""
|
||||||
client = Fail2BanClient(socket_path=socket_path, timeout=FAIL2BAN_SOCKET_TIMEOUT)
|
client = Fail2BanClient(socket_path=socket_path, timeout=FAIL2BAN_SOCKET_TIMEOUT)
|
||||||
try:
|
try:
|
||||||
@@ -932,11 +914,8 @@ async def del_ignore_ip(socket_path: str, name: str, ip: str) -> None:
|
|||||||
name: Jail name.
|
name: Jail name.
|
||||||
ip: IP address or CIDR network to remove.
|
ip: IP address or CIDR network to remove.
|
||||||
|
|
||||||
Raises:
|
Error contract: ABORT_ON_ERROR. Raises JailNotFoundError (404),
|
||||||
JailNotFoundError: If *name* is not a known jail.
|
JailOperationError (409), Fail2BanConnectionError (503).
|
||||||
JailOperationError: If fail2ban reports the operation failed.
|
|
||||||
~app.utils.fail2ban_client.Fail2BanConnectionError: If the socket
|
|
||||||
cannot be reached.
|
|
||||||
"""
|
"""
|
||||||
client = Fail2BanClient(socket_path=socket_path, timeout=FAIL2BAN_SOCKET_TIMEOUT)
|
client = Fail2BanClient(socket_path=socket_path, timeout=FAIL2BAN_SOCKET_TIMEOUT)
|
||||||
try:
|
try:
|
||||||
@@ -958,10 +937,8 @@ async def get_ignore_self(socket_path: str, name: str) -> bool:
|
|||||||
Returns:
|
Returns:
|
||||||
``True`` when ``ignoreself`` is enabled for the jail.
|
``True`` when ``ignoreself`` is enabled for the jail.
|
||||||
|
|
||||||
Raises:
|
Error contract: ABORT_ON_ERROR. Raises JailNotFoundError (404),
|
||||||
JailNotFoundError: If *name* is not a known jail.
|
Fail2BanConnectionError (503).
|
||||||
~app.utils.fail2ban_client.Fail2BanConnectionError: If the socket
|
|
||||||
cannot be reached.
|
|
||||||
"""
|
"""
|
||||||
client = Fail2BanClient(socket_path=socket_path, timeout=FAIL2BAN_SOCKET_TIMEOUT)
|
client = Fail2BanClient(socket_path=socket_path, timeout=FAIL2BAN_SOCKET_TIMEOUT)
|
||||||
try:
|
try:
|
||||||
@@ -981,11 +958,8 @@ async def set_ignore_self(socket_path: str, name: str, *, on: bool) -> None:
|
|||||||
name: Jail name.
|
name: Jail name.
|
||||||
on: ``True`` to enable ignoreself, ``False`` to disable.
|
on: ``True`` to enable ignoreself, ``False`` to disable.
|
||||||
|
|
||||||
Raises:
|
Error contract: ABORT_ON_ERROR. Raises JailNotFoundError (404),
|
||||||
JailNotFoundError: If *name* is not a known jail.
|
JailOperationError (409), Fail2BanConnectionError (503).
|
||||||
JailOperationError: If fail2ban reports the operation failed.
|
|
||||||
~app.utils.fail2ban_client.Fail2BanConnectionError: If the socket
|
|
||||||
cannot be reached.
|
|
||||||
"""
|
"""
|
||||||
value = "true" if on else "false"
|
value = "true" if on else "false"
|
||||||
client = Fail2BanClient(socket_path=socket_path, timeout=FAIL2BAN_SOCKET_TIMEOUT)
|
client = Fail2BanClient(socket_path=socket_path, timeout=FAIL2BAN_SOCKET_TIMEOUT)
|
||||||
|
|||||||
@@ -15,8 +15,8 @@ from typing import cast
|
|||||||
import structlog
|
import structlog
|
||||||
|
|
||||||
from app.exceptions import Fail2BanConnectionError, Fail2BanProtocolError, ServerOperationError
|
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 import ServerSettingsUpdate
|
||||||
|
from app.models.server_domain import DomainServerSettings, DomainServerSettingsResult
|
||||||
from app.utils.constants import FAIL2BAN_SOCKET_TIMEOUT
|
from app.utils.constants import FAIL2BAN_SOCKET_TIMEOUT
|
||||||
from app.utils.fail2ban_client import Fail2BanClient, Fail2BanCommand, Fail2BanResponse
|
from app.utils.fail2ban_client import Fail2BanClient, Fail2BanCommand, Fail2BanResponse
|
||||||
from app.utils.fail2ban_response import ok
|
from app.utils.fail2ban_response import ok
|
||||||
@@ -103,6 +103,10 @@ async def get_settings(socket_path: str) -> DomainServerSettingsResult:
|
|||||||
Raises:
|
Raises:
|
||||||
~app.utils.fail2ban_client.Fail2BanConnectionError: Socket unreachable.
|
~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
|
import asyncio
|
||||||
|
|
||||||
client = Fail2BanClient(socket_path=socket_path, timeout=FAIL2BAN_SOCKET_TIMEOUT)
|
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.
|
socket_path: Path to the fail2ban Unix domain socket.
|
||||||
update: Partial update payload.
|
update: Partial update payload.
|
||||||
|
|
||||||
Raises:
|
Error contract: ABORT_ON_ERROR. Raises ServerOperationError (400) or
|
||||||
ServerOperationError: If any ``set`` command is rejected.
|
Fail2BanConnectionError (503). Router converts to HTTP.
|
||||||
~app.utils.fail2ban_client.Fail2BanConnectionError: Socket unreachable.
|
|
||||||
"""
|
"""
|
||||||
client = Fail2BanClient(socket_path=socket_path, timeout=FAIL2BAN_SOCKET_TIMEOUT)
|
client = Fail2BanClient(socket_path=socket_path, timeout=FAIL2BAN_SOCKET_TIMEOUT)
|
||||||
|
|
||||||
|
|||||||
@@ -86,7 +86,7 @@ TIME_RANGE_SLACK_SECONDS: Final[int] = 60
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
DEFAULT_PAGE_SIZE: Final[int] = 100
|
DEFAULT_PAGE_SIZE: Final[int] = 100
|
||||||
MAX_PAGE_SIZE: Final[int] = 500
|
"""Default items per page for paginated endpoints."""
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Blocklist import
|
# Blocklist import
|
||||||
@@ -95,9 +95,6 @@ MAX_PAGE_SIZE: Final[int] = 500
|
|||||||
BLOCKLIST_IMPORT_DEFAULT_HOUR: Final[int] = 3
|
BLOCKLIST_IMPORT_DEFAULT_HOUR: Final[int] = 3
|
||||||
"""Default hour (UTC) for the nightly blocklist import job."""
|
"""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
|
# Health check
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
Reference in New Issue
Block a user