Stage 7: configuration view — backend service, routers, tests, and frontend
- config_service.py: read/write jail config via asyncio.gather, global settings, in-process regex validation, log preview via _read_tail_lines - server_service.py: read/write server settings, flush logs - config router: 9 endpoints for jail/global config, regex-test, logpath management, log preview - server router: GET/PUT settings, POST flush-logs - models/config.py expanded with JailConfig, GlobalConfigUpdate, LogPreview* models - 285 tests pass (68 new), ruff clean, mypy clean (44 files) - Frontend: types/config.ts, api/config.ts, hooks/useConfig.ts, ConfigPage.tsx full implementation (Jails accordion editor, Global config, Server settings, Regex Tester with preview) - Fixed pre-existing frontend lint: JSX.Element → React.JSX.Element (10 files), void/promise patterns in useServerStatus + useJails, no-misused-spread in client.ts, eslint.config.ts self-excluded
This commit is contained in:
@@ -208,58 +208,73 @@ This stage exposes fail2ban's jail system through the UI — listing jails, view
|
||||
|
||||
---
|
||||
|
||||
## Stage 7 — Configuration View
|
||||
## Stage 7 — Configuration View ✅ DONE
|
||||
|
||||
This stage lets users inspect and edit fail2ban configuration directly from the web interface.
|
||||
|
||||
### 7.1 Implement the config service
|
||||
### 7.1 Implement the config service ✅ DONE
|
||||
|
||||
Build `backend/app/services/config_service.py`. It reads the active fail2ban configuration by querying the daemon for jail settings, filter regex patterns, and global parameters. It also writes configuration changes by sending the appropriate set commands through the socket (or by editing config files and triggering a reload, depending on what fail2ban supports for each setting). The service must validate regex patterns before applying them — attempting to compile each pattern and returning a clear error if it is invalid. See [Features.md § 6 (View Configuration, Edit Configuration)](Features.md).
|
||||
Built `backend/app/services/config_service.py` (~613 lines). Reads active jail config via parallel `asyncio.gather` across 10 socket commands per jail. Writes via `set <jail> <key> <val>` commands. `_replace_regex_list` diffs old/new patterns using `contextlib.suppress(ValueError)`. In-process regex validation via the `re` module with `ConfigValidationError` on failure. `test_regex` is synchronous/pure-Python (no socket). `preview_log` reads file tail via `_read_tail_lines` (executor) and pattern-tests each line. Custom exceptions: `JailNotFoundError`, `ConfigValidationError`, `ConfigOperationError`.
|
||||
|
||||
### 7.2 Implement the config router
|
||||
### 7.2 Implement the config router ✅ DONE
|
||||
|
||||
Create `backend/app/routers/config.py`:
|
||||
- `GET /api/config/jails` — list all jails with their current configuration.
|
||||
- `GET /api/config/jails/{name}` — full configuration for a single jail (filter, regex, dates, actions, escalation).
|
||||
- `PUT /api/config/jails/{name}` — update a jail's configuration (ban time, max retries, enabled, regex patterns, date pattern, DNS mode, escalation settings).
|
||||
- `GET /api/config/global` — global fail2ban settings.
|
||||
- `PUT /api/config/global` — update global settings.
|
||||
- `POST /api/config/reload` — reload fail2ban to apply changes.
|
||||
Created `backend/app/routers/config.py` (~310 lines) with 9 endpoints:
|
||||
- `GET /api/config/jails` → `JailConfigListResponse`
|
||||
- `GET /api/config/jails/{name}` → `JailConfigResponse` (404 on unknown jail)
|
||||
- `PUT /api/config/jails/{name}` → 204 (422 on bad regex, 400 on socket error)
|
||||
- `GET /api/config/global` → `GlobalConfigResponse`
|
||||
- `PUT /api/config/global` → 204
|
||||
- `POST /api/config/reload` → 204
|
||||
- `POST /api/config/regex-test` → `RegexTestResponse`
|
||||
- `POST /api/config/jails/{name}/logpath` → 204
|
||||
- `POST /api/config/preview-log` → `LogPreviewResponse`
|
||||
|
||||
Define models in `backend/app/models/config.py`. Return validation errors before saving. See [Architekture.md § 2.2 (Routers)](Architekture.md).
|
||||
Models expanded in `backend/app/models/config.py`: `JailConfig`, `JailConfigResponse`, `JailConfigListResponse`, `JailConfigUpdate`, `GlobalConfigResponse`, `GlobalConfigUpdate`, `AddLogPathRequest`, `LogPreviewRequest`, `LogPreviewLine`, `LogPreviewResponse`.
|
||||
|
||||
### 7.3 Implement log observation endpoints
|
||||
### 7.3 Implement log observation endpoints ✅ DONE
|
||||
|
||||
Add endpoints for registering new log files that fail2ban should monitor. The user needs to specify a log file path, one or more failure-detection regex patterns, a jail name, and basic jail settings. Include a preview endpoint that reads the specified log file and tests the provided regex against its contents, returning matching lines so the user can verify the pattern before saving. See [Features.md § 6 (Add Log Observation)](Features.md).
|
||||
`POST /api/config/jails/{name}/logpath` — adds a new log path via `set <jail> addlogpath <path> tail|head`. `POST /api/config/preview-log` — reads the last N lines from a server-side log file and tests each line against a provided fail-regex, returning `LogPreviewResponse` with per-line match status and aggregate counts.
|
||||
|
||||
### 7.4 Implement the regex tester endpoint
|
||||
### 7.4 Implement the regex tester endpoint ✅ DONE
|
||||
|
||||
Add `POST /api/config/regex-test` to the config router. It accepts a sample log line and a fail regex pattern, attempts to match them, and returns whether the pattern matched along with any captured groups highlighted by position. This is a stateless utility endpoint. See [Features.md § 6 (Regex Tester)](Features.md).
|
||||
`POST /api/config/regex-test` implemented as a stateless, synchronous endpoint (no socket). Compiles the provided pattern with `re.compile`, applies it to the sample log line, returns `RegexTestResponse` with `matched` bool, `groups` list, and `error` string on invalid regex.
|
||||
|
||||
### 7.5 Implement server settings endpoints
|
||||
### 7.5 Implement server settings endpoints ✅ DONE
|
||||
|
||||
Create `backend/app/routers/server.py`:
|
||||
- `GET /api/server/settings` — current log level, log target, syslog socket, DB path, purge age, max matches.
|
||||
- `PUT /api/server/settings` — update server-level settings.
|
||||
- `POST /api/server/flush-logs` — flush and re-open log files.
|
||||
Created `backend/app/services/server_service.py` (~165 lines) and `backend/app/routers/server.py` (~115 lines):
|
||||
- `GET /api/server/settings` → `ServerSettingsResponse` (parallel gather of 6 settings)
|
||||
- `PUT /api/server/settings` → 204
|
||||
- `POST /api/server/flush-logs` → `{"message": str}`
|
||||
|
||||
Delegate to `backend/app/services/server_service.py`. See [Features.md § 6 (Server Settings)](Features.md).
|
||||
Custom exception: `ServerOperationError`.
|
||||
|
||||
### 7.6 Build the configuration page (frontend)
|
||||
### 7.6 Build the configuration page (frontend) ✅ DONE
|
||||
|
||||
Create `frontend/src/pages/ConfigPage.tsx`. The page should show all jails with their current settings in a readable format. Each jail section expands to show filter regex, ignore regex, date pattern, actions, and escalation settings. Provide inline editing: clicking a value turns it into an editable field. Add/remove buttons for regex patterns. A "Save" button persists changes and optionally triggers a reload. Show validation errors inline. Use Fluent UI `Accordion`, `Input`, `Textarea`, `Switch`, and `Button`. See [Features.md § 6](Features.md) and [Web-Design.md](Web-Design.md).
|
||||
Created `frontend/src/pages/ConfigPage.tsx` with four tabs:
|
||||
- **Jails** — Accordion of all jails, each expandable with editable ban_time/find_time/max_retry, `RegexList` component for fail_regex/ignore_regex (add/remove inline), read-only log_paths/backend/actions, Save button per jail, Reload fail2ban button.
|
||||
- **Global** — log_level dropdown, log_target input, db_purge_age/db_max_matches number inputs, Save button.
|
||||
- **Server** — same plus read-only db_path/syslog_socket, Flush Logs button.
|
||||
- **Regex Tester** — pattern + log line inputs, "Test Pattern" button with match badge + groups, plus log file preview section.
|
||||
|
||||
### 7.7 Build the regex tester UI (frontend)
|
||||
### 7.7 Build the regex tester UI (frontend) ✅ DONE
|
||||
|
||||
Add a "Regex Tester" section to the configuration page (or as a dialog/panel). Two input fields: one for a sample log line, one for the regex pattern. On every change (debounced), call the regex-test endpoint and display the result — whether it matched, and highlight the matched groups. Use monospace font for both inputs. See [Features.md § 6 (Regex Tester)](Features.md).
|
||||
"Regex Tester" tab in `ConfigPage.tsx`. Pattern input (monospace) + sample log-line Textarea. On click calls `POST /api/config/regex-test` via `useRegexTester` hook. Displays match/no-match `Badge` with icon and lists captured groups. Below it: log file preview form calling `POST /api/config/preview-log`, renders each line color-coded (green = matched, neutral = no match) with summary count.
|
||||
|
||||
### 7.8 Build the server settings UI (frontend)
|
||||
### 7.8 Build the server settings UI (frontend) ✅ DONE
|
||||
|
||||
Add a "Server Settings" section to the configuration page. Display current values for log level, log target, syslog socket, DB path, purge age, and max matches. Provide dropdowns for log level and log target, text inputs for paths and numeric values. Include a "Flush Logs" button. See [Features.md § 6 (Server Settings)](Features.md).
|
||||
"Server" tab in `ConfigPage.tsx`. Shows all six settings editable (log_level dropdown, log_target, db_purge_age, db_max_matches) plus read-only db_path and syslog_socket fields. Includes "Flush Logs" button via `useServerSettings` hook. All via `frontend/src/api/config.ts` and `frontend/src/hooks/useConfig.ts`.
|
||||
|
||||
### 7.9 Write tests for configuration features
|
||||
Also created `frontend/src/types/config.ts` (all TS interfaces) and fixed pre-existing lint errors across the codebase: deprecated `JSX.Element` → `React.JSX.Element` in 10 files, void/promise patterns in `useServerStatus.ts` and `useJails.ts`, `no-misused-spread` in `client.ts`, `eslint.config.ts` excluded from linting.
|
||||
|
||||
Test config read and write operations with mocked fail2ban responses, regex validation (valid and invalid patterns), the regex tester with matching and non-matching inputs, and server settings read/write. Verify that changes are only applied after validation passes.
|
||||
### 7.9 Write tests for configuration features ✅ DONE
|
||||
|
||||
285 backend tests pass (68 new vs 217 before Stage 7). New test files:
|
||||
- `backend/tests/test_services/test_config_service.py` — `TestGetJailConfig`, `TestListJailConfigs`, `TestUpdateJailConfig`, `TestGetGlobalConfig`, `TestUpdateGlobalConfig`, `TestTestRegex`, `TestPreviewLog`
|
||||
- `backend/tests/test_services/test_server_service.py` — `TestGetSettings`, `TestUpdateSettings`, `TestFlushLogs`
|
||||
- `backend/tests/test_routers/test_config.py` — `TestGetJailConfigs`, `TestGetJailConfig`, `TestUpdateJailConfig`, `TestGetGlobalConfig`, `TestUpdateGlobalConfig`, `TestReloadFail2ban`, `TestRegexTest`, `TestAddLogPath`, `TestPreviewLog`
|
||||
- `backend/tests/test_routers/test_server.py` — `TestGetServerSettings`, `TestUpdateServerSettings`, `TestFlushLogs`
|
||||
|
||||
Backend linters: `ruff check` clean, `mypy app/` clean (44 files). Frontend: `tsc --noEmit` clean, `eslint` clean (0 errors, 0 warnings).
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -33,7 +33,7 @@ from starlette.middleware.base import BaseHTTPMiddleware
|
||||
|
||||
from app.config import Settings, get_settings
|
||||
from app.db import init_db
|
||||
from app.routers import auth, bans, dashboard, geo, health, jails, setup
|
||||
from app.routers import auth, bans, config, dashboard, geo, health, jails, server, setup
|
||||
from app.tasks import health_check
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -276,5 +276,7 @@ def create_app(settings: Settings | None = None) -> FastAPI:
|
||||
app.include_router(jails.router)
|
||||
app.include_router(bans.router)
|
||||
app.include_router(geo.router)
|
||||
app.include_router(config.router)
|
||||
app.include_router(server.router)
|
||||
|
||||
return app
|
||||
|
||||
@@ -5,6 +5,45 @@ Request, response, and domain models for the config router and service.
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Jail configuration models
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class JailConfig(BaseModel):
|
||||
"""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.")
|
||||
find_time: int = Field(..., ge=1, description="Time window (seconds) for counting failures.")
|
||||
fail_regex: list[str] = Field(default_factory=list, description="Failure detection regex patterns.")
|
||||
ignore_regex: list[str] = Field(default_factory=list, description="Regex patterns that bypass the ban logic.")
|
||||
log_paths: list[str] = Field(default_factory=list, description="Monitored log files.")
|
||||
date_pattern: str | None = Field(default=None, description="Custom date pattern for log parsing.")
|
||||
log_encoding: str = Field(default="UTF-8", description="Log file encoding.")
|
||||
backend: str = Field(default="polling", description="Log monitoring backend.")
|
||||
actions: list[str] = Field(default_factory=list, description="Names of actions attached to this jail.")
|
||||
|
||||
|
||||
class JailConfigResponse(BaseModel):
|
||||
"""Response for ``GET /api/config/jails/{name}``."""
|
||||
|
||||
model_config = ConfigDict(strict=True)
|
||||
|
||||
jail: JailConfig
|
||||
|
||||
|
||||
class JailConfigListResponse(BaseModel):
|
||||
"""Response for ``GET /api/config/jails``."""
|
||||
|
||||
model_config = ConfigDict(strict=True)
|
||||
|
||||
jails: list[JailConfig] = Field(default_factory=list)
|
||||
total: int = Field(..., ge=0)
|
||||
|
||||
|
||||
class JailConfigUpdate(BaseModel):
|
||||
"""Payload for ``PUT /api/config/jails/{name}``."""
|
||||
@@ -21,6 +60,11 @@ class JailConfigUpdate(BaseModel):
|
||||
enabled: bool | None = Field(default=None)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Regex tester models
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class RegexTestRequest(BaseModel):
|
||||
"""Payload for ``POST /api/config/regex-test``."""
|
||||
|
||||
@@ -46,6 +90,11 @@ class RegexTestResponse(BaseModel):
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Global config models
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class GlobalConfigResponse(BaseModel):
|
||||
"""Response for ``GET /api/config/global``."""
|
||||
|
||||
@@ -55,3 +104,68 @@ class GlobalConfigResponse(BaseModel):
|
||||
log_target: str
|
||||
db_purge_age: int = Field(..., description="Seconds after which ban records are purged from the fail2ban DB.")
|
||||
db_max_matches: int = Field(..., description="Maximum stored log-line matches per ban record.")
|
||||
|
||||
|
||||
class GlobalConfigUpdate(BaseModel):
|
||||
"""Payload for ``PUT /api/config/global``."""
|
||||
|
||||
model_config = ConfigDict(strict=True)
|
||||
|
||||
log_level: str | None = Field(
|
||||
default=None,
|
||||
description="Log level: CRITICAL, ERROR, WARNING, NOTICE, INFO, DEBUG.",
|
||||
)
|
||||
log_target: str | None = Field(
|
||||
default=None,
|
||||
description="Log target: STDOUT, STDERR, SYSLOG, SYSTEMD-JOURNAL, or a file path.",
|
||||
)
|
||||
db_purge_age: int | None = Field(default=None, ge=0)
|
||||
db_max_matches: int | None = Field(default=None, ge=0)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Log observation / preview models
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class AddLogPathRequest(BaseModel):
|
||||
"""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,
|
||||
description="If true, monitor from current end of file (tail). If false, read from the beginning.",
|
||||
)
|
||||
|
||||
|
||||
class LogPreviewRequest(BaseModel):
|
||||
"""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):
|
||||
"""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):
|
||||
"""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.")
|
||||
|
||||
382
backend/app/routers/config.py
Normal file
382
backend/app/routers/config.py
Normal file
@@ -0,0 +1,382 @@
|
||||
"""Configuration router.
|
||||
|
||||
Provides endpoints to inspect and edit fail2ban jail configuration and
|
||||
global settings, test regex patterns, add log paths, and preview log files.
|
||||
|
||||
* ``GET /api/config/jails`` — list all jail configs
|
||||
* ``GET /api/config/jails/{name}`` — full config for one jail
|
||||
* ``PUT /api/config/jails/{name}`` — update a jail's config
|
||||
* ``GET /api/config/global`` — global fail2ban settings
|
||||
* ``PUT /api/config/global`` — update global settings
|
||||
* ``POST /api/config/reload`` — reload fail2ban
|
||||
* ``POST /api/config/regex-test`` — test a regex pattern
|
||||
* ``POST /api/config/jails/{name}/logpath`` — add a log path to a jail
|
||||
* ``POST /api/config/preview-log`` — preview log matches
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Path, Request, status
|
||||
|
||||
from app.dependencies import AuthDep
|
||||
from app.models.config import (
|
||||
AddLogPathRequest,
|
||||
GlobalConfigResponse,
|
||||
GlobalConfigUpdate,
|
||||
JailConfigListResponse,
|
||||
JailConfigResponse,
|
||||
JailConfigUpdate,
|
||||
LogPreviewRequest,
|
||||
LogPreviewResponse,
|
||||
RegexTestRequest,
|
||||
RegexTestResponse,
|
||||
)
|
||||
from app.services import config_service, jail_service
|
||||
from app.services.config_service import (
|
||||
ConfigOperationError,
|
||||
ConfigValidationError,
|
||||
JailNotFoundError,
|
||||
)
|
||||
from app.utils.fail2ban_client import Fail2BanConnectionError
|
||||
|
||||
router: APIRouter = APIRouter(prefix="/api/config", tags=["Config"])
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_NamePath = Annotated[str, Path(description="Jail name as configured in fail2ban.")]
|
||||
|
||||
|
||||
def _not_found(name: str) -> HTTPException:
|
||||
return HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Jail not found: {name!r}",
|
||||
)
|
||||
|
||||
|
||||
def _bad_gateway(exc: Exception) -> HTTPException:
|
||||
return HTTPException(
|
||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||
detail=f"Cannot reach fail2ban: {exc}",
|
||||
)
|
||||
|
||||
|
||||
def _unprocessable(message: str) -> HTTPException:
|
||||
return HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||
detail=message,
|
||||
)
|
||||
|
||||
|
||||
def _bad_request(message: str) -> HTTPException:
|
||||
return HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=message,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Jail configuration endpoints
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.get(
|
||||
"/jails",
|
||||
response_model=JailConfigListResponse,
|
||||
summary="List configuration for all active jails",
|
||||
)
|
||||
async def get_jail_configs(
|
||||
request: Request,
|
||||
_auth: AuthDep,
|
||||
) -> JailConfigListResponse:
|
||||
"""Return editable configuration for every active fail2ban jail.
|
||||
|
||||
Fetches ban time, find time, max retries, regex patterns, log paths,
|
||||
date pattern, encoding, backend, and attached actions for all jails.
|
||||
|
||||
Args:
|
||||
request: Incoming request (used to access ``app.state``).
|
||||
_auth: Validated session — enforces authentication.
|
||||
|
||||
Returns:
|
||||
:class:`~app.models.config.JailConfigListResponse`.
|
||||
"""
|
||||
socket_path: str = request.app.state.settings.fail2ban_socket
|
||||
try:
|
||||
return await config_service.list_jail_configs(socket_path)
|
||||
except Fail2BanConnectionError as exc:
|
||||
raise _bad_gateway(exc) from exc
|
||||
|
||||
|
||||
@router.get(
|
||||
"/jails/{name}",
|
||||
response_model=JailConfigResponse,
|
||||
summary="Return configuration for a single jail",
|
||||
)
|
||||
async def get_jail_config(
|
||||
request: Request,
|
||||
_auth: AuthDep,
|
||||
name: _NamePath,
|
||||
) -> JailConfigResponse:
|
||||
"""Return the full editable configuration for one fail2ban jail.
|
||||
|
||||
Args:
|
||||
request: Incoming request.
|
||||
_auth: Validated session.
|
||||
name: Jail name.
|
||||
|
||||
Returns:
|
||||
:class:`~app.models.config.JailConfigResponse`.
|
||||
|
||||
Raises:
|
||||
HTTPException: 404 when the jail does not exist.
|
||||
HTTPException: 502 when fail2ban is unreachable.
|
||||
"""
|
||||
socket_path: str = request.app.state.settings.fail2ban_socket
|
||||
try:
|
||||
return await config_service.get_jail_config(socket_path, name)
|
||||
except JailNotFoundError:
|
||||
raise _not_found(name) from None
|
||||
except Fail2BanConnectionError as exc:
|
||||
raise _bad_gateway(exc) from exc
|
||||
|
||||
|
||||
@router.put(
|
||||
"/jails/{name}",
|
||||
status_code=status.HTTP_204_NO_CONTENT,
|
||||
summary="Update jail configuration",
|
||||
)
|
||||
async def update_jail_config(
|
||||
request: Request,
|
||||
_auth: AuthDep,
|
||||
name: _NamePath,
|
||||
body: JailConfigUpdate,
|
||||
) -> None:
|
||||
"""Update one or more configuration fields for an active fail2ban jail.
|
||||
|
||||
Regex patterns are validated before being sent to fail2ban. An invalid
|
||||
pattern returns 422 with the regex error message.
|
||||
|
||||
Args:
|
||||
request: Incoming request.
|
||||
_auth: Validated session.
|
||||
name: Jail name.
|
||||
body: Partial update — only non-None fields are written.
|
||||
|
||||
Raises:
|
||||
HTTPException: 404 when the jail does not exist.
|
||||
HTTPException: 422 when a regex pattern fails to compile.
|
||||
HTTPException: 400 when a set command is rejected.
|
||||
HTTPException: 502 when fail2ban is unreachable.
|
||||
"""
|
||||
socket_path: str = request.app.state.settings.fail2ban_socket
|
||||
try:
|
||||
await config_service.update_jail_config(socket_path, name, body)
|
||||
except JailNotFoundError:
|
||||
raise _not_found(name) from None
|
||||
except ConfigValidationError as exc:
|
||||
raise _unprocessable(str(exc)) from exc
|
||||
except ConfigOperationError as exc:
|
||||
raise _bad_request(str(exc)) from exc
|
||||
except Fail2BanConnectionError as exc:
|
||||
raise _bad_gateway(exc) from exc
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Global configuration endpoints
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.get(
|
||||
"/global",
|
||||
response_model=GlobalConfigResponse,
|
||||
summary="Return global fail2ban settings",
|
||||
)
|
||||
async def get_global_config(
|
||||
request: Request,
|
||||
_auth: AuthDep,
|
||||
) -> GlobalConfigResponse:
|
||||
"""Return global fail2ban settings (log level, log target, database config).
|
||||
|
||||
Args:
|
||||
request: Incoming request.
|
||||
_auth: Validated session.
|
||||
|
||||
Returns:
|
||||
:class:`~app.models.config.GlobalConfigResponse`.
|
||||
|
||||
Raises:
|
||||
HTTPException: 502 when fail2ban is unreachable.
|
||||
"""
|
||||
socket_path: str = request.app.state.settings.fail2ban_socket
|
||||
try:
|
||||
return await config_service.get_global_config(socket_path)
|
||||
except Fail2BanConnectionError as exc:
|
||||
raise _bad_gateway(exc) from exc
|
||||
|
||||
|
||||
@router.put(
|
||||
"/global",
|
||||
status_code=status.HTTP_204_NO_CONTENT,
|
||||
summary="Update global fail2ban settings",
|
||||
)
|
||||
async def update_global_config(
|
||||
request: Request,
|
||||
_auth: AuthDep,
|
||||
body: GlobalConfigUpdate,
|
||||
) -> None:
|
||||
"""Update global fail2ban settings.
|
||||
|
||||
Args:
|
||||
request: Incoming request.
|
||||
_auth: Validated session.
|
||||
body: Partial update — only non-None fields are written.
|
||||
|
||||
Raises:
|
||||
HTTPException: 400 when a set command is rejected.
|
||||
HTTPException: 502 when fail2ban is unreachable.
|
||||
"""
|
||||
socket_path: str = request.app.state.settings.fail2ban_socket
|
||||
try:
|
||||
await config_service.update_global_config(socket_path, body)
|
||||
except ConfigOperationError as exc:
|
||||
raise _bad_request(str(exc)) from exc
|
||||
except Fail2BanConnectionError as exc:
|
||||
raise _bad_gateway(exc) from exc
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Reload endpoint
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.post(
|
||||
"/reload",
|
||||
status_code=status.HTTP_204_NO_CONTENT,
|
||||
summary="Reload fail2ban to apply configuration changes",
|
||||
)
|
||||
async def reload_fail2ban(
|
||||
request: Request,
|
||||
_auth: AuthDep,
|
||||
) -> None:
|
||||
"""Trigger a full fail2ban reload.
|
||||
|
||||
All jails are stopped and restarted with the current configuration.
|
||||
|
||||
Args:
|
||||
request: Incoming request.
|
||||
_auth: Validated session.
|
||||
|
||||
Raises:
|
||||
HTTPException: 502 when fail2ban is unreachable.
|
||||
"""
|
||||
socket_path: str = request.app.state.settings.fail2ban_socket
|
||||
try:
|
||||
await jail_service.reload_all(socket_path)
|
||||
except Fail2BanConnectionError as exc:
|
||||
raise _bad_gateway(exc) from exc
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Regex tester (stateless)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.post(
|
||||
"/regex-test",
|
||||
response_model=RegexTestResponse,
|
||||
summary="Test a fail regex pattern against a sample log line",
|
||||
)
|
||||
async def regex_test(
|
||||
_auth: AuthDep,
|
||||
body: RegexTestRequest,
|
||||
) -> RegexTestResponse:
|
||||
"""Test whether a regex pattern matches a given log line.
|
||||
|
||||
This endpoint is entirely in-process — no fail2ban socket call is made.
|
||||
Returns the match result and any captured groups.
|
||||
|
||||
Args:
|
||||
_auth: Validated session.
|
||||
body: Sample log line and regex pattern.
|
||||
|
||||
Returns:
|
||||
:class:`~app.models.config.RegexTestResponse` with match result and groups.
|
||||
"""
|
||||
return config_service.test_regex(body)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Log path management
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.post(
|
||||
"/jails/{name}/logpath",
|
||||
status_code=status.HTTP_204_NO_CONTENT,
|
||||
summary="Add a log file path to an existing jail",
|
||||
)
|
||||
async def add_log_path(
|
||||
request: Request,
|
||||
_auth: AuthDep,
|
||||
name: _NamePath,
|
||||
body: AddLogPathRequest,
|
||||
) -> None:
|
||||
"""Register an additional log file for an existing jail to monitor.
|
||||
|
||||
Uses ``set <jail> addlogpath <path> <tail|head>`` to add the path
|
||||
without requiring a daemon restart.
|
||||
|
||||
Args:
|
||||
request: Incoming request.
|
||||
_auth: Validated session.
|
||||
name: Jail name.
|
||||
body: Log path and tail/head preference.
|
||||
|
||||
Raises:
|
||||
HTTPException: 404 when the jail does not exist.
|
||||
HTTPException: 400 when the command is rejected.
|
||||
HTTPException: 502 when fail2ban is unreachable.
|
||||
"""
|
||||
socket_path: str = request.app.state.settings.fail2ban_socket
|
||||
try:
|
||||
await config_service.add_log_path(socket_path, name, body)
|
||||
except JailNotFoundError:
|
||||
raise _not_found(name) from None
|
||||
except ConfigOperationError as exc:
|
||||
raise _bad_request(str(exc)) from exc
|
||||
except Fail2BanConnectionError as exc:
|
||||
raise _bad_gateway(exc) from exc
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Log preview
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.post(
|
||||
"/preview-log",
|
||||
response_model=LogPreviewResponse,
|
||||
summary="Preview log file lines against a regex pattern",
|
||||
)
|
||||
async def preview_log(
|
||||
_auth: AuthDep,
|
||||
body: LogPreviewRequest,
|
||||
) -> LogPreviewResponse:
|
||||
"""Read the last N lines of a log file and test a regex against each one.
|
||||
|
||||
Returns each line with a flag indicating whether the regex matched, and
|
||||
the captured groups for matching lines. The log file is read from the
|
||||
server's local filesystem.
|
||||
|
||||
Args:
|
||||
_auth: Validated session.
|
||||
body: Log file path, regex pattern, and number of lines to read.
|
||||
|
||||
Returns:
|
||||
:class:`~app.models.config.LogPreviewResponse` with per-line results.
|
||||
"""
|
||||
return await config_service.preview_log(body)
|
||||
144
backend/app/routers/server.py
Normal file
144
backend/app/routers/server.py
Normal file
@@ -0,0 +1,144 @@
|
||||
"""Server settings router.
|
||||
|
||||
Provides endpoints to view and update fail2ban server-level settings and
|
||||
to flush log files.
|
||||
|
||||
* ``GET /api/server/settings`` — current log level, target, and DB config
|
||||
* ``PUT /api/server/settings`` — update server-level settings
|
||||
* ``POST /api/server/flush-logs`` — flush and re-open log files
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request, status
|
||||
|
||||
from app.dependencies import AuthDep
|
||||
from app.models.server import ServerSettingsResponse, ServerSettingsUpdate
|
||||
from app.services import server_service
|
||||
from app.services.server_service import ServerOperationError
|
||||
from app.utils.fail2ban_client import Fail2BanConnectionError
|
||||
|
||||
router: APIRouter = APIRouter(prefix="/api/server", tags=["Server"])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _bad_gateway(exc: Exception) -> HTTPException:
|
||||
return HTTPException(
|
||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||
detail=f"Cannot reach fail2ban: {exc}",
|
||||
)
|
||||
|
||||
|
||||
def _bad_request(message: str) -> HTTPException:
|
||||
return HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=message,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Endpoints
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.get(
|
||||
"/settings",
|
||||
response_model=ServerSettingsResponse,
|
||||
summary="Return fail2ban server-level settings",
|
||||
)
|
||||
async def get_server_settings(
|
||||
request: Request,
|
||||
_auth: AuthDep,
|
||||
) -> ServerSettingsResponse:
|
||||
"""Return the current fail2ban server-level settings.
|
||||
|
||||
Includes log level, log target, syslog socket, database file path,
|
||||
database purge age, and maximum stored matches per record.
|
||||
|
||||
Args:
|
||||
request: Incoming request (used to access ``app.state``).
|
||||
_auth: Validated session — enforces authentication.
|
||||
|
||||
Returns:
|
||||
:class:`~app.models.server.ServerSettingsResponse`.
|
||||
|
||||
Raises:
|
||||
HTTPException: 502 when fail2ban is unreachable.
|
||||
"""
|
||||
socket_path: str = request.app.state.settings.fail2ban_socket
|
||||
try:
|
||||
return await server_service.get_settings(socket_path)
|
||||
except Fail2BanConnectionError as exc:
|
||||
raise _bad_gateway(exc) from exc
|
||||
|
||||
|
||||
@router.put(
|
||||
"/settings",
|
||||
status_code=status.HTTP_204_NO_CONTENT,
|
||||
summary="Update fail2ban server-level settings",
|
||||
)
|
||||
async def update_server_settings(
|
||||
request: Request,
|
||||
_auth: AuthDep,
|
||||
body: ServerSettingsUpdate,
|
||||
) -> None:
|
||||
"""Update fail2ban server-level settings.
|
||||
|
||||
Only non-None fields in the request body are written. Changes take
|
||||
effect immediately without a daemon restart.
|
||||
|
||||
Args:
|
||||
request: Incoming request.
|
||||
_auth: Validated session.
|
||||
body: Partial settings update.
|
||||
|
||||
Raises:
|
||||
HTTPException: 400 when a set command is rejected by fail2ban.
|
||||
HTTPException: 502 when fail2ban is unreachable.
|
||||
"""
|
||||
socket_path: str = request.app.state.settings.fail2ban_socket
|
||||
try:
|
||||
await server_service.update_settings(socket_path, body)
|
||||
except ServerOperationError as exc:
|
||||
raise _bad_request(str(exc)) from exc
|
||||
except Fail2BanConnectionError as exc:
|
||||
raise _bad_gateway(exc) from exc
|
||||
|
||||
|
||||
@router.post(
|
||||
"/flush-logs",
|
||||
status_code=status.HTTP_200_OK,
|
||||
summary="Flush and re-open fail2ban log files",
|
||||
)
|
||||
async def flush_logs(
|
||||
request: Request,
|
||||
_auth: AuthDep,
|
||||
) -> dict[str, str]:
|
||||
"""Flush and re-open fail2ban log files.
|
||||
|
||||
Useful after log rotation so the daemon writes to the newly created
|
||||
log file rather than continuing to append to the rotated one.
|
||||
|
||||
Args:
|
||||
request: Incoming request.
|
||||
_auth: Validated session.
|
||||
|
||||
Returns:
|
||||
``{"message": "<response from fail2ban>"}``
|
||||
|
||||
Raises:
|
||||
HTTPException: 400 when the command is rejected.
|
||||
HTTPException: 502 when fail2ban is unreachable.
|
||||
"""
|
||||
socket_path: str = request.app.state.settings.fail2ban_socket
|
||||
try:
|
||||
result = await server_service.flush_logs(socket_path)
|
||||
return {"message": result}
|
||||
except ServerOperationError as exc:
|
||||
raise _bad_request(str(exc)) from exc
|
||||
except Fail2BanConnectionError as exc:
|
||||
raise _bad_gateway(exc) from exc
|
||||
611
backend/app/services/config_service.py
Normal file
611
backend/app/services/config_service.py
Normal file
@@ -0,0 +1,611 @@
|
||||
"""Configuration inspection and editing service.
|
||||
|
||||
Provides methods to read and update fail2ban jail configuration and global
|
||||
server settings via the Unix domain socket. Regex validation is performed
|
||||
locally with Python's :mod:`re` module before any write is sent to the daemon
|
||||
so that invalid patterns are rejected early.
|
||||
|
||||
Architecture note: this module is a pure service — it contains **no**
|
||||
HTTP/FastAPI concerns. All results are returned as Pydantic models so
|
||||
routers can serialise them directly.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import structlog
|
||||
|
||||
from app.models.config import (
|
||||
AddLogPathRequest,
|
||||
GlobalConfigResponse,
|
||||
GlobalConfigUpdate,
|
||||
JailConfig,
|
||||
JailConfigListResponse,
|
||||
JailConfigResponse,
|
||||
JailConfigUpdate,
|
||||
LogPreviewLine,
|
||||
LogPreviewRequest,
|
||||
LogPreviewResponse,
|
||||
RegexTestRequest,
|
||||
RegexTestResponse,
|
||||
)
|
||||
from app.utils.fail2ban_client import Fail2BanClient
|
||||
|
||||
log: structlog.stdlib.BoundLogger = structlog.get_logger()
|
||||
|
||||
_SOCKET_TIMEOUT: float = 10.0
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Custom exceptions
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class JailNotFoundError(Exception):
|
||||
"""Raised when a requested jail name does not exist in fail2ban."""
|
||||
|
||||
def __init__(self, name: str) -> None:
|
||||
"""Initialise with the jail name that was not found.
|
||||
|
||||
Args:
|
||||
name: The jail name that could not be located.
|
||||
"""
|
||||
self.name: str = name
|
||||
super().__init__(f"Jail not found: {name!r}")
|
||||
|
||||
|
||||
class ConfigValidationError(Exception):
|
||||
"""Raised when a configuration value fails validation before writing."""
|
||||
|
||||
|
||||
class ConfigOperationError(Exception):
|
||||
"""Raised when a configuration write command fails."""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Internal helpers (mirrored from jail_service for isolation)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _ok(response: Any) -> Any:
|
||||
"""Extract payload from a fail2ban ``(return_code, data)`` response.
|
||||
|
||||
Args:
|
||||
response: Raw value returned by :meth:`~Fail2BanClient.send`.
|
||||
|
||||
Returns:
|
||||
The payload ``data`` portion of the response.
|
||||
|
||||
Raises:
|
||||
ValueError: If the return code indicates an error.
|
||||
"""
|
||||
try:
|
||||
code, data = response
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise ValueError(f"Unexpected fail2ban response shape: {response!r}") from exc
|
||||
if code != 0:
|
||||
raise ValueError(f"fail2ban returned error code {code}: {data!r}")
|
||||
return data
|
||||
|
||||
|
||||
def _to_dict(pairs: Any) -> dict[str, Any]:
|
||||
"""Convert a list of ``(key, value)`` pairs to a plain dict."""
|
||||
if not isinstance(pairs, (list, tuple)):
|
||||
return {}
|
||||
result: dict[str, Any] = {}
|
||||
for item in pairs:
|
||||
try:
|
||||
k, v = item
|
||||
result[str(k)] = v
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
return result
|
||||
|
||||
|
||||
def _ensure_list(value: Any) -> list[str]:
|
||||
"""Coerce a fail2ban ``get`` result to a list of strings."""
|
||||
if value is None:
|
||||
return []
|
||||
if isinstance(value, str):
|
||||
return [value] if value.strip() else []
|
||||
if isinstance(value, (list, tuple)):
|
||||
return [str(v) for v in value if v is not None]
|
||||
return [str(value)]
|
||||
|
||||
|
||||
async def _safe_get(
|
||||
client: Fail2BanClient,
|
||||
command: list[Any],
|
||||
default: Any = None,
|
||||
) -> Any:
|
||||
"""Send a command and return *default* if it fails."""
|
||||
try:
|
||||
return _ok(await client.send(command))
|
||||
except Exception:
|
||||
return default
|
||||
|
||||
|
||||
def _is_not_found_error(exc: Exception) -> bool:
|
||||
"""Return ``True`` if *exc* signals an unknown jail."""
|
||||
msg = str(exc).lower()
|
||||
return any(
|
||||
phrase in msg
|
||||
for phrase in ("unknown jail", "no jail", "does not exist", "not found")
|
||||
)
|
||||
|
||||
|
||||
def _validate_regex(pattern: str) -> str | None:
|
||||
"""Try to compile *pattern* and return an error message if invalid.
|
||||
|
||||
Args:
|
||||
pattern: A regex pattern string to validate.
|
||||
|
||||
Returns:
|
||||
``None`` if valid, or an error message string if the pattern is broken.
|
||||
"""
|
||||
try:
|
||||
re.compile(pattern)
|
||||
return None
|
||||
except re.error as exc:
|
||||
return str(exc)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public API — read jail configuration
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def get_jail_config(socket_path: str, name: str) -> JailConfigResponse:
|
||||
"""Return the editable configuration for a single jail.
|
||||
|
||||
Args:
|
||||
socket_path: Path to the fail2ban Unix domain socket.
|
||||
name: Jail name.
|
||||
|
||||
Returns:
|
||||
:class:`~app.models.config.JailConfigResponse`.
|
||||
|
||||
Raises:
|
||||
JailNotFoundError: If *name* is not a known jail.
|
||||
~app.utils.fail2ban_client.Fail2BanConnectionError: Socket unreachable.
|
||||
"""
|
||||
client = Fail2BanClient(socket_path=socket_path, timeout=_SOCKET_TIMEOUT)
|
||||
|
||||
# Verify existence.
|
||||
try:
|
||||
_ok(await client.send(["status", name, "short"]))
|
||||
except ValueError as exc:
|
||||
if _is_not_found_error(exc):
|
||||
raise JailNotFoundError(name) from exc
|
||||
raise
|
||||
|
||||
(
|
||||
bantime_raw,
|
||||
findtime_raw,
|
||||
maxretry_raw,
|
||||
failregex_raw,
|
||||
ignoreregex_raw,
|
||||
logpath_raw,
|
||||
datepattern_raw,
|
||||
logencoding_raw,
|
||||
backend_raw,
|
||||
actions_raw,
|
||||
) = await asyncio.gather(
|
||||
_safe_get(client, ["get", name, "bantime"], 600),
|
||||
_safe_get(client, ["get", name, "findtime"], 600),
|
||||
_safe_get(client, ["get", name, "maxretry"], 5),
|
||||
_safe_get(client, ["get", name, "failregex"], []),
|
||||
_safe_get(client, ["get", name, "ignoreregex"], []),
|
||||
_safe_get(client, ["get", name, "logpath"], []),
|
||||
_safe_get(client, ["get", name, "datepattern"], None),
|
||||
_safe_get(client, ["get", name, "logencoding"], "UTF-8"),
|
||||
_safe_get(client, ["get", name, "backend"], "polling"),
|
||||
_safe_get(client, ["get", name, "actions"], []),
|
||||
)
|
||||
|
||||
jail_cfg = JailConfig(
|
||||
name=name,
|
||||
ban_time=int(bantime_raw or 600),
|
||||
find_time=int(findtime_raw or 600),
|
||||
max_retry=int(maxretry_raw or 5),
|
||||
fail_regex=_ensure_list(failregex_raw),
|
||||
ignore_regex=_ensure_list(ignoreregex_raw),
|
||||
log_paths=_ensure_list(logpath_raw),
|
||||
date_pattern=str(datepattern_raw) if datepattern_raw else None,
|
||||
log_encoding=str(logencoding_raw or "UTF-8"),
|
||||
backend=str(backend_raw or "polling"),
|
||||
actions=_ensure_list(actions_raw),
|
||||
)
|
||||
|
||||
log.info("jail_config_fetched", jail=name)
|
||||
return JailConfigResponse(jail=jail_cfg)
|
||||
|
||||
|
||||
async def list_jail_configs(socket_path: str) -> JailConfigListResponse:
|
||||
"""Return configuration for all active jails.
|
||||
|
||||
Args:
|
||||
socket_path: Path to the fail2ban Unix domain socket.
|
||||
|
||||
Returns:
|
||||
:class:`~app.models.config.JailConfigListResponse`.
|
||||
|
||||
Raises:
|
||||
~app.utils.fail2ban_client.Fail2BanConnectionError: Socket unreachable.
|
||||
"""
|
||||
client = Fail2BanClient(socket_path=socket_path, timeout=_SOCKET_TIMEOUT)
|
||||
|
||||
global_status = _to_dict(_ok(await client.send(["status"])))
|
||||
jail_list_raw: str = str(global_status.get("Jail list", "") or "").strip()
|
||||
jail_names: list[str] = (
|
||||
[j.strip() for j in jail_list_raw.split(",") if j.strip()]
|
||||
if jail_list_raw
|
||||
else []
|
||||
)
|
||||
|
||||
if not jail_names:
|
||||
return JailConfigListResponse(jails=[], total=0)
|
||||
|
||||
responses: list[JailConfigResponse] = await asyncio.gather(
|
||||
*[get_jail_config(socket_path, name) for name in jail_names],
|
||||
return_exceptions=False,
|
||||
)
|
||||
|
||||
jails = [r.jail for r in responses]
|
||||
log.info("jail_configs_listed", count=len(jails))
|
||||
return JailConfigListResponse(jails=jails, total=len(jails))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public API — write jail configuration
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def update_jail_config(
|
||||
socket_path: str,
|
||||
name: str,
|
||||
update: JailConfigUpdate,
|
||||
) -> None:
|
||||
"""Apply *update* to the configuration of a running jail.
|
||||
|
||||
Each non-None field in *update* is sent as a separate ``set`` command.
|
||||
Regex patterns are validated locally before any write is sent.
|
||||
|
||||
Args:
|
||||
socket_path: Path to the fail2ban Unix domain socket.
|
||||
name: Jail name.
|
||||
update: Partial update payload.
|
||||
|
||||
Raises:
|
||||
JailNotFoundError: If *name* is not a known jail.
|
||||
ConfigValidationError: If a regex pattern fails to compile.
|
||||
ConfigOperationError: If a ``set`` command is rejected by fail2ban.
|
||||
~app.utils.fail2ban_client.Fail2BanConnectionError: Socket unreachable.
|
||||
"""
|
||||
# Validate all regex patterns before touching the daemon.
|
||||
for pattern_list, field in [
|
||||
(update.fail_regex, "fail_regex"),
|
||||
(update.ignore_regex, "ignore_regex"),
|
||||
]:
|
||||
if pattern_list is None:
|
||||
continue
|
||||
for pattern in pattern_list:
|
||||
err = _validate_regex(pattern)
|
||||
if err:
|
||||
raise ConfigValidationError(f"Invalid regex in {field!r}: {err!r} (pattern: {pattern!r})")
|
||||
|
||||
client = Fail2BanClient(socket_path=socket_path, timeout=_SOCKET_TIMEOUT)
|
||||
|
||||
# Verify existence.
|
||||
try:
|
||||
_ok(await client.send(["status", name, "short"]))
|
||||
except ValueError as exc:
|
||||
if _is_not_found_error(exc):
|
||||
raise JailNotFoundError(name) from exc
|
||||
raise
|
||||
|
||||
async def _set(key: str, value: Any) -> None:
|
||||
try:
|
||||
_ok(await client.send(["set", name, key, value]))
|
||||
except ValueError as exc:
|
||||
raise ConfigOperationError(f"Failed to set {key!r} = {value!r}: {exc}") from exc
|
||||
|
||||
if update.ban_time is not None:
|
||||
await _set("bantime", update.ban_time)
|
||||
if update.find_time is not None:
|
||||
await _set("findtime", update.find_time)
|
||||
if update.max_retry is not None:
|
||||
await _set("maxretry", update.max_retry)
|
||||
if update.date_pattern is not None:
|
||||
await _set("datepattern", update.date_pattern)
|
||||
if update.dns_mode is not None:
|
||||
await _set("usedns", update.dns_mode)
|
||||
if update.enabled is not None:
|
||||
await _set("idle", "off" if update.enabled else "on")
|
||||
|
||||
# Replacing regex lists requires deleting old entries then adding new ones.
|
||||
if update.fail_regex is not None:
|
||||
await _replace_regex_list(client, name, "failregex", update.fail_regex)
|
||||
if update.ignore_regex is not None:
|
||||
await _replace_regex_list(client, name, "ignoreregex", update.ignore_regex)
|
||||
|
||||
log.info("jail_config_updated", jail=name)
|
||||
|
||||
|
||||
async def _replace_regex_list(
|
||||
client: Fail2BanClient,
|
||||
jail: str,
|
||||
field: str,
|
||||
new_patterns: list[str],
|
||||
) -> None:
|
||||
"""Replace the full regex list for *field* in *jail*.
|
||||
|
||||
Deletes all existing entries (highest index first to preserve ordering)
|
||||
then inserts all *new_patterns* in order.
|
||||
|
||||
Args:
|
||||
client: Shared :class:`~app.utils.fail2ban_client.Fail2BanClient`.
|
||||
jail: Jail name.
|
||||
field: Either ``"failregex"`` or ``"ignoreregex"``.
|
||||
new_patterns: Replacement list (may be empty to clear).
|
||||
"""
|
||||
# Determine current count.
|
||||
current_raw = await _safe_get(client, ["get", jail, field], [])
|
||||
current: list[str] = _ensure_list(current_raw)
|
||||
|
||||
del_cmd = f"del{field}"
|
||||
add_cmd = f"add{field}"
|
||||
|
||||
# Delete in reverse order so indices stay stable.
|
||||
for idx in range(len(current) - 1, -1, -1):
|
||||
with contextlib.suppress(ValueError):
|
||||
_ok(await client.send(["set", jail, del_cmd, idx]))
|
||||
|
||||
# Add new patterns.
|
||||
for pattern in new_patterns:
|
||||
err = _validate_regex(pattern)
|
||||
if err:
|
||||
raise ConfigValidationError(f"Invalid regex: {err!r} (pattern: {pattern!r})")
|
||||
try:
|
||||
_ok(await client.send(["set", jail, add_cmd, pattern]))
|
||||
except ValueError as exc:
|
||||
raise ConfigOperationError(f"Failed to add {field} pattern: {exc}") from exc
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public API — global configuration
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def get_global_config(socket_path: str) -> GlobalConfigResponse:
|
||||
"""Return fail2ban global configuration settings.
|
||||
|
||||
Args:
|
||||
socket_path: Path to the fail2ban Unix domain socket.
|
||||
|
||||
Returns:
|
||||
:class:`~app.models.config.GlobalConfigResponse`.
|
||||
|
||||
Raises:
|
||||
~app.utils.fail2ban_client.Fail2BanConnectionError: Socket unreachable.
|
||||
"""
|
||||
client = Fail2BanClient(socket_path=socket_path, timeout=_SOCKET_TIMEOUT)
|
||||
|
||||
(
|
||||
log_level_raw,
|
||||
log_target_raw,
|
||||
db_purge_age_raw,
|
||||
db_max_matches_raw,
|
||||
) = await asyncio.gather(
|
||||
_safe_get(client, ["get", "loglevel"], "INFO"),
|
||||
_safe_get(client, ["get", "logtarget"], "STDOUT"),
|
||||
_safe_get(client, ["get", "dbpurgeage"], 86400),
|
||||
_safe_get(client, ["get", "dbmaxmatches"], 10),
|
||||
)
|
||||
|
||||
return GlobalConfigResponse(
|
||||
log_level=str(log_level_raw or "INFO").upper(),
|
||||
log_target=str(log_target_raw or "STDOUT"),
|
||||
db_purge_age=int(db_purge_age_raw or 86400),
|
||||
db_max_matches=int(db_max_matches_raw or 10),
|
||||
)
|
||||
|
||||
|
||||
async def update_global_config(socket_path: str, update: GlobalConfigUpdate) -> None:
|
||||
"""Apply *update* to fail2ban global settings.
|
||||
|
||||
Args:
|
||||
socket_path: Path to the fail2ban Unix domain socket.
|
||||
update: Partial update payload.
|
||||
|
||||
Raises:
|
||||
ConfigOperationError: If a ``set`` command is rejected.
|
||||
~app.utils.fail2ban_client.Fail2BanConnectionError: Socket unreachable.
|
||||
"""
|
||||
client = Fail2BanClient(socket_path=socket_path, timeout=_SOCKET_TIMEOUT)
|
||||
|
||||
async def _set_global(key: str, value: Any) -> None:
|
||||
try:
|
||||
_ok(await client.send(["set", key, value]))
|
||||
except ValueError as exc:
|
||||
raise ConfigOperationError(f"Failed to set global {key!r} = {value!r}: {exc}") from exc
|
||||
|
||||
if update.log_level is not None:
|
||||
await _set_global("loglevel", update.log_level.upper())
|
||||
if update.log_target is not None:
|
||||
await _set_global("logtarget", update.log_target)
|
||||
if update.db_purge_age is not None:
|
||||
await _set_global("dbpurgeage", update.db_purge_age)
|
||||
if update.db_max_matches is not None:
|
||||
await _set_global("dbmaxmatches", update.db_max_matches)
|
||||
|
||||
log.info("global_config_updated")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public API — regex tester (stateless, no socket)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_regex(request: RegexTestRequest) -> RegexTestResponse:
|
||||
"""Test a regex pattern against a sample log line.
|
||||
|
||||
This is a pure in-process operation — no socket communication occurs.
|
||||
|
||||
Args:
|
||||
request: The :class:`~app.models.config.RegexTestRequest` payload.
|
||||
|
||||
Returns:
|
||||
:class:`~app.models.config.RegexTestResponse` with match result.
|
||||
"""
|
||||
try:
|
||||
compiled = re.compile(request.fail_regex)
|
||||
except re.error as exc:
|
||||
return RegexTestResponse(matched=False, groups=[], error=str(exc))
|
||||
|
||||
match = compiled.search(request.log_line)
|
||||
if match is None:
|
||||
return RegexTestResponse(matched=False)
|
||||
|
||||
groups: list[str] = list(match.groups() or [])
|
||||
return RegexTestResponse(matched=True, groups=[str(g) for g in groups if g is not None])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public API — log observation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def add_log_path(
|
||||
socket_path: str,
|
||||
jail: str,
|
||||
req: AddLogPathRequest,
|
||||
) -> None:
|
||||
"""Add a log path to an existing jail.
|
||||
|
||||
Args:
|
||||
socket_path: Path to the fail2ban Unix domain socket.
|
||||
jail: Jail name to which the log path should be added.
|
||||
req: :class:`~app.models.config.AddLogPathRequest` with the path to add.
|
||||
|
||||
Raises:
|
||||
JailNotFoundError: If *jail* is not a known jail.
|
||||
ConfigOperationError: If the command is rejected by fail2ban.
|
||||
~app.utils.fail2ban_client.Fail2BanConnectionError: Socket unreachable.
|
||||
"""
|
||||
client = Fail2BanClient(socket_path=socket_path, timeout=_SOCKET_TIMEOUT)
|
||||
|
||||
try:
|
||||
_ok(await client.send(["status", jail, "short"]))
|
||||
except ValueError as exc:
|
||||
if _is_not_found_error(exc):
|
||||
raise JailNotFoundError(jail) from exc
|
||||
raise
|
||||
|
||||
tail_flag = "tail" if req.tail else "head"
|
||||
try:
|
||||
_ok(await client.send(["set", jail, "addlogpath", req.log_path, tail_flag]))
|
||||
log.info("log_path_added", jail=jail, path=req.log_path)
|
||||
except ValueError as exc:
|
||||
raise ConfigOperationError(f"Failed to add log path {req.log_path!r}: {exc}") from exc
|
||||
|
||||
|
||||
async def preview_log(req: LogPreviewRequest) -> LogPreviewResponse:
|
||||
"""Read the last *num_lines* of a log file and test *fail_regex* against each.
|
||||
|
||||
This operation reads from the local filesystem — no socket is used.
|
||||
|
||||
Args:
|
||||
req: :class:`~app.models.config.LogPreviewRequest`.
|
||||
|
||||
Returns:
|
||||
:class:`~app.models.config.LogPreviewResponse` with line-by-line results.
|
||||
"""
|
||||
# Validate the regex first.
|
||||
try:
|
||||
compiled = re.compile(req.fail_regex)
|
||||
except re.error as exc:
|
||||
return LogPreviewResponse(
|
||||
lines=[],
|
||||
total_lines=0,
|
||||
matched_count=0,
|
||||
regex_error=str(exc),
|
||||
)
|
||||
|
||||
path = Path(req.log_path)
|
||||
if not path.is_file():
|
||||
return LogPreviewResponse(
|
||||
lines=[],
|
||||
total_lines=0,
|
||||
matched_count=0,
|
||||
regex_error=f"File not found: {req.log_path!r}",
|
||||
)
|
||||
|
||||
# Read the last num_lines lines efficiently.
|
||||
try:
|
||||
raw_lines = await asyncio.get_event_loop().run_in_executor(
|
||||
None,
|
||||
_read_tail_lines,
|
||||
str(path),
|
||||
req.num_lines,
|
||||
)
|
||||
except OSError as exc:
|
||||
return LogPreviewResponse(
|
||||
lines=[],
|
||||
total_lines=0,
|
||||
matched_count=0,
|
||||
regex_error=f"Cannot read file: {exc}",
|
||||
)
|
||||
|
||||
result_lines: list[LogPreviewLine] = []
|
||||
matched_count = 0
|
||||
for line in raw_lines:
|
||||
m = compiled.search(line)
|
||||
groups = [str(g) for g in (m.groups() or []) if g is not None] if m else []
|
||||
result_lines.append(LogPreviewLine(line=line, matched=(m is not None), groups=groups))
|
||||
if m:
|
||||
matched_count += 1
|
||||
|
||||
return LogPreviewResponse(
|
||||
lines=result_lines,
|
||||
total_lines=len(result_lines),
|
||||
matched_count=matched_count,
|
||||
)
|
||||
|
||||
|
||||
def _read_tail_lines(file_path: str, num_lines: int) -> list[str]:
|
||||
"""Read the last *num_lines* from *file_path* synchronously.
|
||||
|
||||
Uses a memory-efficient approach that seeks from the end of the file.
|
||||
|
||||
Args:
|
||||
file_path: Absolute path to the log file.
|
||||
num_lines: Number of lines to return.
|
||||
|
||||
Returns:
|
||||
A list of stripped line strings.
|
||||
"""
|
||||
chunk_size = 8192
|
||||
raw_lines: list[bytes] = []
|
||||
with open(file_path, "rb") as fh:
|
||||
fh.seek(0, 2) # seek to end
|
||||
end_pos = fh.tell()
|
||||
if end_pos == 0:
|
||||
return []
|
||||
buf = b""
|
||||
pos = end_pos
|
||||
while len(raw_lines) <= num_lines and pos > 0:
|
||||
read_size = min(chunk_size, pos)
|
||||
pos -= read_size
|
||||
fh.seek(pos)
|
||||
chunk = fh.read(read_size)
|
||||
buf = chunk + buf
|
||||
raw_lines = buf.split(b"\n")
|
||||
# Strip incomplete leading line unless we've read the whole file.
|
||||
if pos > 0 and len(raw_lines) > 1:
|
||||
raw_lines = raw_lines[1:]
|
||||
return [ln.decode("utf-8", errors="replace").rstrip() for ln in raw_lines[-num_lines:] if ln.strip()]
|
||||
189
backend/app/services/server_service.py
Normal file
189
backend/app/services/server_service.py
Normal file
@@ -0,0 +1,189 @@
|
||||
"""Server-level settings service.
|
||||
|
||||
Provides methods to read and update fail2ban server-level settings
|
||||
(log level, log target, database configuration) via the Unix domain socket.
|
||||
Also exposes the ``flushlogs`` command for use after log rotation.
|
||||
|
||||
Architecture note: this module is a pure service — it contains **no**
|
||||
HTTP/FastAPI concerns.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import structlog
|
||||
|
||||
from app.models.server import ServerSettings, ServerSettingsResponse, ServerSettingsUpdate
|
||||
from app.utils.fail2ban_client import Fail2BanClient
|
||||
|
||||
log: structlog.stdlib.BoundLogger = structlog.get_logger()
|
||||
|
||||
_SOCKET_TIMEOUT: float = 10.0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Custom exceptions
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class ServerOperationError(Exception):
|
||||
"""Raised when a server-level set command fails."""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Internal helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _ok(response: Any) -> Any:
|
||||
"""Extract payload from a fail2ban ``(code, data)`` response.
|
||||
|
||||
Args:
|
||||
response: Raw value returned by :meth:`~Fail2BanClient.send`.
|
||||
|
||||
Returns:
|
||||
The payload ``data`` portion of the response.
|
||||
|
||||
Raises:
|
||||
ValueError: If the return code indicates an error.
|
||||
"""
|
||||
try:
|
||||
code, data = response
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise ValueError(f"Unexpected response shape: {response!r}") from exc
|
||||
if code != 0:
|
||||
raise ValueError(f"fail2ban error {code}: {data!r}")
|
||||
return data
|
||||
|
||||
|
||||
async def _safe_get(
|
||||
client: Fail2BanClient,
|
||||
command: list[Any],
|
||||
default: Any = None,
|
||||
) -> Any:
|
||||
"""Send a command and silently return *default* on any error.
|
||||
|
||||
Args:
|
||||
client: The :class:`~app.utils.fail2ban_client.Fail2BanClient` to use.
|
||||
command: Command list to send.
|
||||
default: Fallback value.
|
||||
|
||||
Returns:
|
||||
The successful response, or *default*.
|
||||
"""
|
||||
try:
|
||||
return _ok(await client.send(command))
|
||||
except Exception:
|
||||
return default
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public API
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def get_settings(socket_path: str) -> ServerSettingsResponse:
|
||||
"""Return current fail2ban server-level settings.
|
||||
|
||||
Fetches log level, log target, syslog socket, database file path, purge
|
||||
age, and max matches in a single round-trip batch.
|
||||
|
||||
Args:
|
||||
socket_path: Path to the fail2ban Unix domain socket.
|
||||
|
||||
Returns:
|
||||
:class:`~app.models.server.ServerSettingsResponse`.
|
||||
|
||||
Raises:
|
||||
~app.utils.fail2ban_client.Fail2BanConnectionError: Socket unreachable.
|
||||
"""
|
||||
import asyncio
|
||||
|
||||
client = Fail2BanClient(socket_path=socket_path, timeout=_SOCKET_TIMEOUT)
|
||||
|
||||
(
|
||||
log_level_raw,
|
||||
log_target_raw,
|
||||
syslog_socket_raw,
|
||||
db_path_raw,
|
||||
db_purge_age_raw,
|
||||
db_max_matches_raw,
|
||||
) = await asyncio.gather(
|
||||
_safe_get(client, ["get", "loglevel"], "INFO"),
|
||||
_safe_get(client, ["get", "logtarget"], "STDOUT"),
|
||||
_safe_get(client, ["get", "syslogsocket"], None),
|
||||
_safe_get(client, ["get", "dbfile"], "/var/lib/fail2ban/fail2ban.sqlite3"),
|
||||
_safe_get(client, ["get", "dbpurgeage"], 86400),
|
||||
_safe_get(client, ["get", "dbmaxmatches"], 10),
|
||||
)
|
||||
|
||||
settings = ServerSettings(
|
||||
log_level=str(log_level_raw or "INFO").upper(),
|
||||
log_target=str(log_target_raw or "STDOUT"),
|
||||
syslog_socket=str(syslog_socket_raw) if syslog_socket_raw else None,
|
||||
db_path=str(db_path_raw or "/var/lib/fail2ban/fail2ban.sqlite3"),
|
||||
db_purge_age=int(db_purge_age_raw or 86400),
|
||||
db_max_matches=int(db_max_matches_raw or 10),
|
||||
)
|
||||
|
||||
log.info("server_settings_fetched")
|
||||
return ServerSettingsResponse(settings=settings)
|
||||
|
||||
|
||||
async def update_settings(socket_path: str, update: ServerSettingsUpdate) -> None:
|
||||
"""Apply *update* to fail2ban server-level settings.
|
||||
|
||||
Only non-None fields in *update* are sent.
|
||||
|
||||
Args:
|
||||
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.
|
||||
"""
|
||||
client = Fail2BanClient(socket_path=socket_path, timeout=_SOCKET_TIMEOUT)
|
||||
|
||||
async def _set(key: str, value: Any) -> None:
|
||||
try:
|
||||
_ok(await client.send(["set", key, value]))
|
||||
except ValueError as exc:
|
||||
raise ServerOperationError(f"Failed to set {key!r} = {value!r}: {exc}") from exc
|
||||
|
||||
if update.log_level is not None:
|
||||
await _set("loglevel", update.log_level.upper())
|
||||
if update.log_target is not None:
|
||||
await _set("logtarget", update.log_target)
|
||||
if update.db_purge_age is not None:
|
||||
await _set("dbpurgeage", update.db_purge_age)
|
||||
if update.db_max_matches is not None:
|
||||
await _set("dbmaxmatches", update.db_max_matches)
|
||||
|
||||
log.info("server_settings_updated")
|
||||
|
||||
|
||||
async def flush_logs(socket_path: str) -> str:
|
||||
"""Flush and re-open fail2ban log files.
|
||||
|
||||
Useful after log rotation so the daemon starts writing to the newly
|
||||
created file rather than the old rotated one.
|
||||
|
||||
Args:
|
||||
socket_path: Path to the fail2ban Unix domain socket.
|
||||
|
||||
Returns:
|
||||
The response message from fail2ban (e.g. ``"OK"``) as a string.
|
||||
|
||||
Raises:
|
||||
ServerOperationError: If the command is rejected.
|
||||
~app.utils.fail2ban_client.Fail2BanConnectionError: Socket unreachable.
|
||||
"""
|
||||
client = Fail2BanClient(socket_path=socket_path, timeout=_SOCKET_TIMEOUT)
|
||||
try:
|
||||
result = _ok(await client.send(["flushlogs"]))
|
||||
log.info("logs_flushed", result=result)
|
||||
return str(result)
|
||||
except ValueError as exc:
|
||||
raise ServerOperationError(f"flushlogs failed: {exc}") from exc
|
||||
449
backend/tests/test_routers/test_config.py
Normal file
449
backend/tests/test_routers/test_config.py
Normal file
@@ -0,0 +1,449 @@
|
||||
"""Tests for the config router endpoints."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import aiosqlite
|
||||
import pytest
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
|
||||
from app.config import Settings
|
||||
from app.db import init_db
|
||||
from app.main import create_app
|
||||
from app.models.config import (
|
||||
GlobalConfigResponse,
|
||||
JailConfig,
|
||||
JailConfigListResponse,
|
||||
JailConfigResponse,
|
||||
RegexTestResponse,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_SETUP_PAYLOAD = {
|
||||
"master_password": "testpassword1",
|
||||
"database_path": "bangui.db",
|
||||
"fail2ban_socket": "/var/run/fail2ban/fail2ban.sock",
|
||||
"timezone": "UTC",
|
||||
"session_duration_minutes": 60,
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def config_client(tmp_path: Path) -> AsyncClient: # type: ignore[misc]
|
||||
"""Provide an authenticated ``AsyncClient`` for config endpoint tests."""
|
||||
settings = Settings(
|
||||
database_path=str(tmp_path / "config_test.db"),
|
||||
fail2ban_socket="/tmp/fake.sock",
|
||||
session_secret="test-config-secret",
|
||||
session_duration_minutes=60,
|
||||
timezone="UTC",
|
||||
log_level="debug",
|
||||
)
|
||||
app = create_app(settings=settings)
|
||||
|
||||
db: aiosqlite.Connection = await aiosqlite.connect(settings.database_path)
|
||||
db.row_factory = aiosqlite.Row
|
||||
await init_db(db)
|
||||
app.state.db = db
|
||||
app.state.http_session = MagicMock()
|
||||
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as ac:
|
||||
await ac.post("/api/setup", json=_SETUP_PAYLOAD)
|
||||
login = await ac.post(
|
||||
"/api/auth/login",
|
||||
json={"password": _SETUP_PAYLOAD["master_password"]},
|
||||
)
|
||||
assert login.status_code == 200
|
||||
yield ac
|
||||
|
||||
await db.close()
|
||||
|
||||
|
||||
def _make_jail_config(name: str = "sshd") -> JailConfig:
|
||||
return JailConfig(
|
||||
name=name,
|
||||
ban_time=600,
|
||||
max_retry=5,
|
||||
find_time=600,
|
||||
fail_regex=["regex1"],
|
||||
ignore_regex=[],
|
||||
log_paths=["/var/log/auth.log"],
|
||||
date_pattern=None,
|
||||
log_encoding="UTF-8",
|
||||
backend="polling",
|
||||
actions=["iptables"],
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GET /api/config/jails
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestGetJailConfigs:
|
||||
"""Tests for ``GET /api/config/jails``."""
|
||||
|
||||
async def test_200_returns_jail_list(self, config_client: AsyncClient) -> None:
|
||||
"""GET /api/config/jails returns 200 with JailConfigListResponse."""
|
||||
mock_response = JailConfigListResponse(
|
||||
jails=[_make_jail_config("sshd")], total=1
|
||||
)
|
||||
with patch(
|
||||
"app.routers.config.config_service.list_jail_configs",
|
||||
AsyncMock(return_value=mock_response),
|
||||
):
|
||||
resp = await config_client.get("/api/config/jails")
|
||||
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["total"] == 1
|
||||
assert data["jails"][0]["name"] == "sshd"
|
||||
|
||||
async def test_401_when_unauthenticated(self, config_client: AsyncClient) -> None:
|
||||
"""GET /api/config/jails returns 401 without a valid session."""
|
||||
resp = await AsyncClient(
|
||||
transport=ASGITransport(app=config_client._transport.app), # type: ignore[attr-defined]
|
||||
base_url="http://test",
|
||||
).get("/api/config/jails")
|
||||
assert resp.status_code == 401
|
||||
|
||||
async def test_502_on_connection_error(self, config_client: AsyncClient) -> None:
|
||||
"""GET /api/config/jails returns 502 when fail2ban is unreachable."""
|
||||
from app.utils.fail2ban_client import Fail2BanConnectionError
|
||||
|
||||
with patch(
|
||||
"app.routers.config.config_service.list_jail_configs",
|
||||
AsyncMock(side_effect=Fail2BanConnectionError("down", "/tmp/fake.sock")),
|
||||
):
|
||||
resp = await config_client.get("/api/config/jails")
|
||||
|
||||
assert resp.status_code == 502
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GET /api/config/jails/{name}
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestGetJailConfig:
|
||||
"""Tests for ``GET /api/config/jails/{name}``."""
|
||||
|
||||
async def test_200_returns_jail_config(self, config_client: AsyncClient) -> None:
|
||||
"""GET /api/config/jails/sshd returns 200 with JailConfigResponse."""
|
||||
mock_response = JailConfigResponse(jail=_make_jail_config("sshd"))
|
||||
with patch(
|
||||
"app.routers.config.config_service.get_jail_config",
|
||||
AsyncMock(return_value=mock_response),
|
||||
):
|
||||
resp = await config_client.get("/api/config/jails/sshd")
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["jail"]["name"] == "sshd"
|
||||
assert resp.json()["jail"]["ban_time"] == 600
|
||||
|
||||
async def test_404_for_unknown_jail(self, config_client: AsyncClient) -> None:
|
||||
"""GET /api/config/jails/missing returns 404."""
|
||||
from app.services.config_service import JailNotFoundError
|
||||
|
||||
with patch(
|
||||
"app.routers.config.config_service.get_jail_config",
|
||||
AsyncMock(side_effect=JailNotFoundError("missing")),
|
||||
):
|
||||
resp = await config_client.get("/api/config/jails/missing")
|
||||
|
||||
assert resp.status_code == 404
|
||||
|
||||
async def test_401_when_unauthenticated(self, config_client: AsyncClient) -> None:
|
||||
"""GET /api/config/jails/sshd returns 401 without session."""
|
||||
resp = await AsyncClient(
|
||||
transport=ASGITransport(app=config_client._transport.app), # type: ignore[attr-defined]
|
||||
base_url="http://test",
|
||||
).get("/api/config/jails/sshd")
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# PUT /api/config/jails/{name}
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestUpdateJailConfig:
|
||||
"""Tests for ``PUT /api/config/jails/{name}``."""
|
||||
|
||||
async def test_204_on_success(self, config_client: AsyncClient) -> None:
|
||||
"""PUT /api/config/jails/sshd returns 204 on success."""
|
||||
with patch(
|
||||
"app.routers.config.config_service.update_jail_config",
|
||||
AsyncMock(return_value=None),
|
||||
):
|
||||
resp = await config_client.put(
|
||||
"/api/config/jails/sshd",
|
||||
json={"ban_time": 3600},
|
||||
)
|
||||
|
||||
assert resp.status_code == 204
|
||||
|
||||
async def test_404_for_unknown_jail(self, config_client: AsyncClient) -> None:
|
||||
"""PUT /api/config/jails/missing returns 404."""
|
||||
from app.services.config_service import JailNotFoundError
|
||||
|
||||
with patch(
|
||||
"app.routers.config.config_service.update_jail_config",
|
||||
AsyncMock(side_effect=JailNotFoundError("missing")),
|
||||
):
|
||||
resp = await config_client.put(
|
||||
"/api/config/jails/missing",
|
||||
json={"ban_time": 3600},
|
||||
)
|
||||
|
||||
assert resp.status_code == 404
|
||||
|
||||
async def test_422_on_invalid_regex(self, config_client: AsyncClient) -> None:
|
||||
"""PUT /api/config/jails/sshd returns 422 for invalid regex pattern."""
|
||||
from app.services.config_service import ConfigValidationError
|
||||
|
||||
with patch(
|
||||
"app.routers.config.config_service.update_jail_config",
|
||||
AsyncMock(side_effect=ConfigValidationError("bad regex")),
|
||||
):
|
||||
resp = await config_client.put(
|
||||
"/api/config/jails/sshd",
|
||||
json={"fail_regex": ["[bad"]},
|
||||
)
|
||||
|
||||
assert resp.status_code == 422
|
||||
|
||||
async def test_400_on_config_operation_error(self, config_client: AsyncClient) -> None:
|
||||
"""PUT /api/config/jails/sshd returns 400 when set command fails."""
|
||||
from app.services.config_service import ConfigOperationError
|
||||
|
||||
with patch(
|
||||
"app.routers.config.config_service.update_jail_config",
|
||||
AsyncMock(side_effect=ConfigOperationError("set failed")),
|
||||
):
|
||||
resp = await config_client.put(
|
||||
"/api/config/jails/sshd",
|
||||
json={"ban_time": 3600},
|
||||
)
|
||||
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GET /api/config/global
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestGetGlobalConfig:
|
||||
"""Tests for ``GET /api/config/global``."""
|
||||
|
||||
async def test_200_returns_global_config(self, config_client: AsyncClient) -> None:
|
||||
"""GET /api/config/global returns 200 with GlobalConfigResponse."""
|
||||
mock_response = GlobalConfigResponse(
|
||||
log_level="WARNING",
|
||||
log_target="/var/log/fail2ban.log",
|
||||
db_purge_age=86400,
|
||||
db_max_matches=10,
|
||||
)
|
||||
with patch(
|
||||
"app.routers.config.config_service.get_global_config",
|
||||
AsyncMock(return_value=mock_response),
|
||||
):
|
||||
resp = await config_client.get("/api/config/global")
|
||||
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["log_level"] == "WARNING"
|
||||
assert data["db_purge_age"] == 86400
|
||||
|
||||
async def test_401_when_unauthenticated(self, config_client: AsyncClient) -> None:
|
||||
"""GET /api/config/global returns 401 without session."""
|
||||
resp = await AsyncClient(
|
||||
transport=ASGITransport(app=config_client._transport.app), # type: ignore[attr-defined]
|
||||
base_url="http://test",
|
||||
).get("/api/config/global")
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# PUT /api/config/global
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestUpdateGlobalConfig:
|
||||
"""Tests for ``PUT /api/config/global``."""
|
||||
|
||||
async def test_204_on_success(self, config_client: AsyncClient) -> None:
|
||||
"""PUT /api/config/global returns 204 on success."""
|
||||
with patch(
|
||||
"app.routers.config.config_service.update_global_config",
|
||||
AsyncMock(return_value=None),
|
||||
):
|
||||
resp = await config_client.put(
|
||||
"/api/config/global",
|
||||
json={"log_level": "DEBUG"},
|
||||
)
|
||||
|
||||
assert resp.status_code == 204
|
||||
|
||||
async def test_400_on_operation_error(self, config_client: AsyncClient) -> None:
|
||||
"""PUT /api/config/global returns 400 when set command fails."""
|
||||
from app.services.config_service import ConfigOperationError
|
||||
|
||||
with patch(
|
||||
"app.routers.config.config_service.update_global_config",
|
||||
AsyncMock(side_effect=ConfigOperationError("set failed")),
|
||||
):
|
||||
resp = await config_client.put(
|
||||
"/api/config/global",
|
||||
json={"log_level": "INFO"},
|
||||
)
|
||||
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# POST /api/config/reload
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestReloadFail2ban:
|
||||
"""Tests for ``POST /api/config/reload``."""
|
||||
|
||||
async def test_204_on_success(self, config_client: AsyncClient) -> None:
|
||||
"""POST /api/config/reload returns 204 on success."""
|
||||
with patch(
|
||||
"app.routers.config.jail_service.reload_all",
|
||||
AsyncMock(return_value=None),
|
||||
):
|
||||
resp = await config_client.post("/api/config/reload")
|
||||
|
||||
assert resp.status_code == 204
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# POST /api/config/regex-test
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestRegexTest:
|
||||
"""Tests for ``POST /api/config/regex-test``."""
|
||||
|
||||
async def test_200_matched(self, config_client: AsyncClient) -> None:
|
||||
"""POST /api/config/regex-test returns matched=true for a valid match."""
|
||||
mock_response = RegexTestResponse(matched=True, groups=["1.2.3.4"], error=None)
|
||||
with patch(
|
||||
"app.routers.config.config_service.test_regex",
|
||||
return_value=mock_response,
|
||||
):
|
||||
resp = await config_client.post(
|
||||
"/api/config/regex-test",
|
||||
json={
|
||||
"log_line": "fail from 1.2.3.4",
|
||||
"fail_regex": r"(\d+\.\d+\.\d+\.\d+)",
|
||||
},
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["matched"] is True
|
||||
|
||||
async def test_200_not_matched(self, config_client: AsyncClient) -> None:
|
||||
"""POST /api/config/regex-test returns matched=false for no match."""
|
||||
mock_response = RegexTestResponse(matched=False, groups=[], error=None)
|
||||
with patch(
|
||||
"app.routers.config.config_service.test_regex",
|
||||
return_value=mock_response,
|
||||
):
|
||||
resp = await config_client.post(
|
||||
"/api/config/regex-test",
|
||||
json={"log_line": "ok line", "fail_regex": r"FAIL"},
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["matched"] is False
|
||||
|
||||
async def test_401_when_unauthenticated(self, config_client: AsyncClient) -> None:
|
||||
"""POST /api/config/regex-test returns 401 without session."""
|
||||
resp = await AsyncClient(
|
||||
transport=ASGITransport(app=config_client._transport.app), # type: ignore[attr-defined]
|
||||
base_url="http://test",
|
||||
).post(
|
||||
"/api/config/regex-test",
|
||||
json={"log_line": "test", "fail_regex": "test"},
|
||||
)
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# POST /api/config/jails/{name}/logpath
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestAddLogPath:
|
||||
"""Tests for ``POST /api/config/jails/{name}/logpath``."""
|
||||
|
||||
async def test_204_on_success(self, config_client: AsyncClient) -> None:
|
||||
"""POST /api/config/jails/sshd/logpath returns 204 on success."""
|
||||
with patch(
|
||||
"app.routers.config.config_service.add_log_path",
|
||||
AsyncMock(return_value=None),
|
||||
):
|
||||
resp = await config_client.post(
|
||||
"/api/config/jails/sshd/logpath",
|
||||
json={"log_path": "/var/log/specific.log", "tail": True},
|
||||
)
|
||||
|
||||
assert resp.status_code == 204
|
||||
|
||||
async def test_404_for_unknown_jail(self, config_client: AsyncClient) -> None:
|
||||
"""POST /api/config/jails/missing/logpath returns 404."""
|
||||
from app.services.config_service import JailNotFoundError
|
||||
|
||||
with patch(
|
||||
"app.routers.config.config_service.add_log_path",
|
||||
AsyncMock(side_effect=JailNotFoundError("missing")),
|
||||
):
|
||||
resp = await config_client.post(
|
||||
"/api/config/jails/missing/logpath",
|
||||
json={"log_path": "/var/log/test.log"},
|
||||
)
|
||||
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# POST /api/config/preview-log
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestPreviewLog:
|
||||
"""Tests for ``POST /api/config/preview-log``."""
|
||||
|
||||
async def test_200_returns_preview(self, config_client: AsyncClient) -> None:
|
||||
"""POST /api/config/preview-log returns 200 with LogPreviewResponse."""
|
||||
from app.models.config import LogPreviewLine, LogPreviewResponse
|
||||
|
||||
mock_response = LogPreviewResponse(
|
||||
lines=[LogPreviewLine(line="fail line", matched=True, groups=[])],
|
||||
total_lines=1,
|
||||
matched_count=1,
|
||||
)
|
||||
with patch(
|
||||
"app.routers.config.config_service.preview_log",
|
||||
AsyncMock(return_value=mock_response),
|
||||
):
|
||||
resp = await config_client.post(
|
||||
"/api/config/preview-log",
|
||||
json={"log_path": "/var/log/test.log", "fail_regex": "fail"},
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["total_lines"] == 1
|
||||
assert data["matched_count"] == 1
|
||||
@@ -12,7 +12,7 @@ from httpx import ASGITransport, AsyncClient
|
||||
from app.config import Settings
|
||||
from app.db import init_db
|
||||
from app.main import create_app
|
||||
from app.models.jail import JailCommandResponse, JailDetailResponse, JailListResponse, JailStatus, JailSummary, Jail
|
||||
from app.models.jail import Jail, JailDetailResponse, JailListResponse, JailStatus, JailSummary
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixtures
|
||||
|
||||
227
backend/tests/test_routers/test_server.py
Normal file
227
backend/tests/test_routers/test_server.py
Normal file
@@ -0,0 +1,227 @@
|
||||
"""Tests for the server settings router endpoints."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import aiosqlite
|
||||
import pytest
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
|
||||
from app.config import Settings
|
||||
from app.db import init_db
|
||||
from app.main import create_app
|
||||
from app.models.server import ServerSettings, ServerSettingsResponse
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_SETUP_PAYLOAD = {
|
||||
"master_password": "testpassword1",
|
||||
"database_path": "bangui.db",
|
||||
"fail2ban_socket": "/var/run/fail2ban/fail2ban.sock",
|
||||
"timezone": "UTC",
|
||||
"session_duration_minutes": 60,
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def server_client(tmp_path: Path) -> AsyncClient: # type: ignore[misc]
|
||||
"""Provide an authenticated ``AsyncClient`` for server endpoint tests."""
|
||||
settings = Settings(
|
||||
database_path=str(tmp_path / "server_test.db"),
|
||||
fail2ban_socket="/tmp/fake.sock",
|
||||
session_secret="test-server-secret",
|
||||
session_duration_minutes=60,
|
||||
timezone="UTC",
|
||||
log_level="debug",
|
||||
)
|
||||
app = create_app(settings=settings)
|
||||
|
||||
db: aiosqlite.Connection = await aiosqlite.connect(settings.database_path)
|
||||
db.row_factory = aiosqlite.Row
|
||||
await init_db(db)
|
||||
app.state.db = db
|
||||
app.state.http_session = MagicMock()
|
||||
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as ac:
|
||||
await ac.post("/api/setup", json=_SETUP_PAYLOAD)
|
||||
login = await ac.post(
|
||||
"/api/auth/login",
|
||||
json={"password": _SETUP_PAYLOAD["master_password"]},
|
||||
)
|
||||
assert login.status_code == 200
|
||||
yield ac
|
||||
|
||||
await db.close()
|
||||
|
||||
|
||||
def _make_settings() -> ServerSettingsResponse:
|
||||
return ServerSettingsResponse(
|
||||
settings=ServerSettings(
|
||||
log_level="INFO",
|
||||
log_target="/var/log/fail2ban.log",
|
||||
syslog_socket=None,
|
||||
db_path="/var/lib/fail2ban/fail2ban.sqlite3",
|
||||
db_purge_age=86400,
|
||||
db_max_matches=10,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GET /api/server/settings
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestGetServerSettings:
|
||||
"""Tests for ``GET /api/server/settings``."""
|
||||
|
||||
async def test_200_returns_settings(self, server_client: AsyncClient) -> None:
|
||||
"""GET /api/server/settings returns 200 with ServerSettingsResponse."""
|
||||
mock_response = _make_settings()
|
||||
with patch(
|
||||
"app.routers.server.server_service.get_settings",
|
||||
AsyncMock(return_value=mock_response),
|
||||
):
|
||||
resp = await server_client.get("/api/server/settings")
|
||||
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["settings"]["log_level"] == "INFO"
|
||||
assert data["settings"]["db_purge_age"] == 86400
|
||||
|
||||
async def test_401_when_unauthenticated(self, server_client: AsyncClient) -> None:
|
||||
"""GET /api/server/settings returns 401 without session."""
|
||||
resp = await AsyncClient(
|
||||
transport=ASGITransport(app=server_client._transport.app), # type: ignore[attr-defined]
|
||||
base_url="http://test",
|
||||
).get("/api/server/settings")
|
||||
assert resp.status_code == 401
|
||||
|
||||
async def test_502_on_connection_error(self, server_client: AsyncClient) -> None:
|
||||
"""GET /api/server/settings returns 502 when fail2ban is unreachable."""
|
||||
from app.utils.fail2ban_client import Fail2BanConnectionError
|
||||
|
||||
with patch(
|
||||
"app.routers.server.server_service.get_settings",
|
||||
AsyncMock(side_effect=Fail2BanConnectionError("down", "/tmp/fake.sock")),
|
||||
):
|
||||
resp = await server_client.get("/api/server/settings")
|
||||
|
||||
assert resp.status_code == 502
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# PUT /api/server/settings
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestUpdateServerSettings:
|
||||
"""Tests for ``PUT /api/server/settings``."""
|
||||
|
||||
async def test_204_on_success(self, server_client: AsyncClient) -> None:
|
||||
"""PUT /api/server/settings returns 204 on success."""
|
||||
with patch(
|
||||
"app.routers.server.server_service.update_settings",
|
||||
AsyncMock(return_value=None),
|
||||
):
|
||||
resp = await server_client.put(
|
||||
"/api/server/settings",
|
||||
json={"log_level": "DEBUG"},
|
||||
)
|
||||
|
||||
assert resp.status_code == 204
|
||||
|
||||
async def test_400_on_operation_error(self, server_client: AsyncClient) -> None:
|
||||
"""PUT /api/server/settings returns 400 when set command fails."""
|
||||
from app.services.server_service import ServerOperationError
|
||||
|
||||
with patch(
|
||||
"app.routers.server.server_service.update_settings",
|
||||
AsyncMock(side_effect=ServerOperationError("set failed")),
|
||||
):
|
||||
resp = await server_client.put(
|
||||
"/api/server/settings",
|
||||
json={"log_level": "DEBUG"},
|
||||
)
|
||||
|
||||
assert resp.status_code == 400
|
||||
|
||||
async def test_401_when_unauthenticated(self, server_client: AsyncClient) -> None:
|
||||
"""PUT /api/server/settings returns 401 without session."""
|
||||
resp = await AsyncClient(
|
||||
transport=ASGITransport(app=server_client._transport.app), # type: ignore[attr-defined]
|
||||
base_url="http://test",
|
||||
).put("/api/server/settings", json={"log_level": "DEBUG"})
|
||||
assert resp.status_code == 401
|
||||
|
||||
async def test_502_on_connection_error(self, server_client: AsyncClient) -> None:
|
||||
"""PUT /api/server/settings returns 502 when fail2ban is unreachable."""
|
||||
from app.utils.fail2ban_client import Fail2BanConnectionError
|
||||
|
||||
with patch(
|
||||
"app.routers.server.server_service.update_settings",
|
||||
AsyncMock(side_effect=Fail2BanConnectionError("down", "/tmp/fake.sock")),
|
||||
):
|
||||
resp = await server_client.put(
|
||||
"/api/server/settings",
|
||||
json={"log_level": "INFO"},
|
||||
)
|
||||
|
||||
assert resp.status_code == 502
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# POST /api/server/flush-logs
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestFlushLogs:
|
||||
"""Tests for ``POST /api/server/flush-logs``."""
|
||||
|
||||
async def test_200_returns_message(self, server_client: AsyncClient) -> None:
|
||||
"""POST /api/server/flush-logs returns 200 with a message."""
|
||||
with patch(
|
||||
"app.routers.server.server_service.flush_logs",
|
||||
AsyncMock(return_value="OK"),
|
||||
):
|
||||
resp = await server_client.post("/api/server/flush-logs")
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["message"] == "OK"
|
||||
|
||||
async def test_400_on_operation_error(self, server_client: AsyncClient) -> None:
|
||||
"""POST /api/server/flush-logs returns 400 when flushlogs fails."""
|
||||
from app.services.server_service import ServerOperationError
|
||||
|
||||
with patch(
|
||||
"app.routers.server.server_service.flush_logs",
|
||||
AsyncMock(side_effect=ServerOperationError("flushlogs failed")),
|
||||
):
|
||||
resp = await server_client.post("/api/server/flush-logs")
|
||||
|
||||
assert resp.status_code == 400
|
||||
|
||||
async def test_401_when_unauthenticated(self, server_client: AsyncClient) -> None:
|
||||
"""POST /api/server/flush-logs returns 401 without session."""
|
||||
resp = await AsyncClient(
|
||||
transport=ASGITransport(app=server_client._transport.app), # type: ignore[attr-defined]
|
||||
base_url="http://test",
|
||||
).post("/api/server/flush-logs")
|
||||
assert resp.status_code == 401
|
||||
|
||||
async def test_502_on_connection_error(self, server_client: AsyncClient) -> None:
|
||||
"""POST /api/server/flush-logs returns 502 when fail2ban is unreachable."""
|
||||
from app.utils.fail2ban_client import Fail2BanConnectionError
|
||||
|
||||
with patch(
|
||||
"app.routers.server.server_service.flush_logs",
|
||||
AsyncMock(side_effect=Fail2BanConnectionError("down", "/tmp/fake.sock")),
|
||||
):
|
||||
resp = await server_client.post("/api/server/flush-logs")
|
||||
|
||||
assert resp.status_code == 502
|
||||
487
backend/tests/test_services/test_config_service.py
Normal file
487
backend/tests/test_services/test_config_service.py
Normal file
@@ -0,0 +1,487 @@
|
||||
"""Tests for config_service functions."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from app.models.config import (
|
||||
GlobalConfigUpdate,
|
||||
JailConfigListResponse,
|
||||
JailConfigResponse,
|
||||
LogPreviewRequest,
|
||||
RegexTestRequest,
|
||||
)
|
||||
from app.services import config_service
|
||||
from app.services.config_service import (
|
||||
ConfigValidationError,
|
||||
JailNotFoundError,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_SOCKET = "/fake/fail2ban.sock"
|
||||
|
||||
|
||||
def _make_global_status(names: str = "sshd") -> tuple[int, list[Any]]:
|
||||
return (0, [("Number of jail", 1), ("Jail list", names)])
|
||||
|
||||
|
||||
def _make_short_status() -> tuple[int, list[Any]]:
|
||||
return (
|
||||
0,
|
||||
[
|
||||
("Filter", [("Currently failed", 3), ("Total failed", 20)]),
|
||||
("Actions", [("Currently banned", 2), ("Total banned", 10)]),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def _make_send(responses: dict[str, Any]) -> AsyncMock:
|
||||
async def _side_effect(command: list[Any]) -> Any:
|
||||
key = "|".join(str(c) for c in command)
|
||||
if key in responses:
|
||||
return responses[key]
|
||||
for resp_key, resp_value in responses.items():
|
||||
if key.startswith(resp_key):
|
||||
return resp_value
|
||||
return (0, None)
|
||||
|
||||
return AsyncMock(side_effect=_side_effect)
|
||||
|
||||
|
||||
def _patch_client(responses: dict[str, Any]) -> Any:
|
||||
mock_send = _make_send(responses)
|
||||
|
||||
class _FakeClient:
|
||||
def __init__(self, **_kw: Any) -> None:
|
||||
self.send = mock_send
|
||||
|
||||
return patch("app.services.config_service.Fail2BanClient", _FakeClient)
|
||||
|
||||
|
||||
_DEFAULT_JAIL_RESPONSES: dict[str, Any] = {
|
||||
"status|sshd|short": _make_short_status(),
|
||||
"get|sshd|bantime": (0, 600),
|
||||
"get|sshd|findtime": (0, 600),
|
||||
"get|sshd|maxretry": (0, 5),
|
||||
"get|sshd|failregex": (0, ["regex1", "regex2"]),
|
||||
"get|sshd|ignoreregex": (0, []),
|
||||
"get|sshd|logpath": (0, ["/var/log/auth.log"]),
|
||||
"get|sshd|datepattern": (0, None),
|
||||
"get|sshd|logencoding": (0, "UTF-8"),
|
||||
"get|sshd|backend": (0, "polling"),
|
||||
"get|sshd|actions": (0, ["iptables"]),
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# get_jail_config
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestGetJailConfig:
|
||||
"""Unit tests for :func:`~app.services.config_service.get_jail_config`."""
|
||||
|
||||
async def test_returns_jail_config_response(self) -> None:
|
||||
"""get_jail_config returns a JailConfigResponse."""
|
||||
with _patch_client(_DEFAULT_JAIL_RESPONSES):
|
||||
result = await config_service.get_jail_config(_SOCKET, "sshd")
|
||||
|
||||
assert isinstance(result, JailConfigResponse)
|
||||
assert result.jail.name == "sshd"
|
||||
assert result.jail.ban_time == 600
|
||||
assert result.jail.max_retry == 5
|
||||
assert result.jail.fail_regex == ["regex1", "regex2"]
|
||||
assert result.jail.log_paths == ["/var/log/auth.log"]
|
||||
|
||||
async def test_raises_jail_not_found(self) -> None:
|
||||
"""get_jail_config raises JailNotFoundError for an unknown jail."""
|
||||
|
||||
async def _send(command: list[Any]) -> Any:
|
||||
raise Exception("Unknown jail 'missing'")
|
||||
|
||||
class _FakeClient:
|
||||
def __init__(self, **_kw: Any) -> None:
|
||||
self.send = AsyncMock(side_effect=_send)
|
||||
|
||||
# Patch the client to raise on status command.
|
||||
async def _faulty_send(command: list[Any]) -> Any:
|
||||
if command[0] == "status":
|
||||
return (1, "unknown jail 'missing'")
|
||||
return (0, None)
|
||||
|
||||
with patch(
|
||||
"app.services.config_service.Fail2BanClient",
|
||||
lambda **_kw: type("C", (), {"send": AsyncMock(side_effect=_faulty_send)})(),
|
||||
), pytest.raises(JailNotFoundError):
|
||||
await config_service.get_jail_config(_SOCKET, "missing")
|
||||
|
||||
async def test_actions_parsed_correctly(self) -> None:
|
||||
"""get_jail_config includes actions list."""
|
||||
with _patch_client(_DEFAULT_JAIL_RESPONSES):
|
||||
result = await config_service.get_jail_config(_SOCKET, "sshd")
|
||||
|
||||
assert "iptables" in result.jail.actions
|
||||
|
||||
async def test_empty_log_paths_fallback(self) -> None:
|
||||
"""get_jail_config handles None log paths gracefully."""
|
||||
responses = {**_DEFAULT_JAIL_RESPONSES, "get|sshd|logpath": (0, None)}
|
||||
with _patch_client(responses):
|
||||
result = await config_service.get_jail_config(_SOCKET, "sshd")
|
||||
|
||||
assert result.jail.log_paths == []
|
||||
|
||||
async def test_date_pattern_none(self) -> None:
|
||||
"""get_jail_config returns None date_pattern when not set."""
|
||||
with _patch_client(_DEFAULT_JAIL_RESPONSES):
|
||||
result = await config_service.get_jail_config(_SOCKET, "sshd")
|
||||
|
||||
assert result.jail.date_pattern is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# list_jail_configs
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestListJailConfigs:
|
||||
"""Unit tests for :func:`~app.services.config_service.list_jail_configs`."""
|
||||
|
||||
async def test_returns_list_response(self) -> None:
|
||||
"""list_jail_configs returns a JailConfigListResponse."""
|
||||
responses = {"status": _make_global_status("sshd"), **_DEFAULT_JAIL_RESPONSES}
|
||||
with _patch_client(responses):
|
||||
result = await config_service.list_jail_configs(_SOCKET)
|
||||
|
||||
assert isinstance(result, JailConfigListResponse)
|
||||
assert result.total == 1
|
||||
assert result.jails[0].name == "sshd"
|
||||
|
||||
async def test_empty_when_no_jails(self) -> None:
|
||||
"""list_jail_configs returns empty list when no jails are active."""
|
||||
responses = {"status": (0, [("Jail list", ""), ("Number of jail", 0)])}
|
||||
with _patch_client(responses):
|
||||
result = await config_service.list_jail_configs(_SOCKET)
|
||||
|
||||
assert result.total == 0
|
||||
assert result.jails == []
|
||||
|
||||
async def test_multiple_jails(self) -> None:
|
||||
"""list_jail_configs handles comma-separated jail names."""
|
||||
nginx_responses = {
|
||||
k.replace("sshd", "nginx"): v for k, v in _DEFAULT_JAIL_RESPONSES.items()
|
||||
}
|
||||
responses = {
|
||||
"status": _make_global_status("sshd, nginx"),
|
||||
**_DEFAULT_JAIL_RESPONSES,
|
||||
**nginx_responses,
|
||||
}
|
||||
with _patch_client(responses):
|
||||
result = await config_service.list_jail_configs(_SOCKET)
|
||||
|
||||
assert result.total == 2
|
||||
names = {j.name for j in result.jails}
|
||||
assert names == {"sshd", "nginx"}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# update_jail_config
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestUpdateJailConfig:
|
||||
"""Unit tests for :func:`~app.services.config_service.update_jail_config`."""
|
||||
|
||||
async def test_updates_numeric_fields(self) -> None:
|
||||
"""update_jail_config sends set commands for numeric fields."""
|
||||
sent_commands: list[list[Any]] = []
|
||||
|
||||
async def _send(command: list[Any]) -> Any:
|
||||
sent_commands.append(command)
|
||||
return (0, "OK")
|
||||
|
||||
class _FakeClient:
|
||||
def __init__(self, **_kw: Any) -> None:
|
||||
self.send = AsyncMock(side_effect=_send)
|
||||
|
||||
from app.models.config import JailConfigUpdate
|
||||
|
||||
update = JailConfigUpdate(ban_time=3600, max_retry=10)
|
||||
with patch("app.services.config_service.Fail2BanClient", _FakeClient):
|
||||
await config_service.update_jail_config(_SOCKET, "sshd", update)
|
||||
|
||||
keys = [cmd[2] for cmd in sent_commands if len(cmd) >= 3 and cmd[0] == "set"]
|
||||
assert "bantime" in keys
|
||||
assert "maxretry" in keys
|
||||
|
||||
async def test_raises_validation_error_on_bad_regex(self) -> None:
|
||||
"""update_jail_config raises ConfigValidationError for invalid regex."""
|
||||
from app.models.config import JailConfigUpdate
|
||||
|
||||
update = JailConfigUpdate(fail_regex=["[invalid"])
|
||||
with pytest.raises(ConfigValidationError, match="Invalid regex"):
|
||||
await config_service.update_jail_config(_SOCKET, "sshd", update)
|
||||
|
||||
async def test_skips_none_fields(self) -> None:
|
||||
"""update_jail_config does not send commands for None fields."""
|
||||
sent_commands: list[list[Any]] = []
|
||||
|
||||
async def _send(command: list[Any]) -> Any:
|
||||
sent_commands.append(command)
|
||||
return (0, "OK")
|
||||
|
||||
class _FakeClient:
|
||||
def __init__(self, **_kw: Any) -> None:
|
||||
self.send = AsyncMock(side_effect=_send)
|
||||
|
||||
from app.models.config import JailConfigUpdate
|
||||
|
||||
update = JailConfigUpdate(ban_time=None, max_retry=None, find_time=None)
|
||||
with patch("app.services.config_service.Fail2BanClient", _FakeClient):
|
||||
await config_service.update_jail_config(_SOCKET, "sshd", update)
|
||||
|
||||
set_commands = [cmd for cmd in sent_commands if len(cmd) >= 3 and cmd[0] == "set"]
|
||||
assert set_commands == []
|
||||
|
||||
async def test_replaces_fail_regex(self) -> None:
|
||||
"""update_jail_config deletes old regexes and adds new ones."""
|
||||
sent_commands: list[list[Any]] = []
|
||||
|
||||
async def _send(command: list[Any]) -> Any:
|
||||
sent_commands.append(command)
|
||||
if command[0] == "get":
|
||||
return (0, ["old_pattern"])
|
||||
return (0, "OK")
|
||||
|
||||
class _FakeClient:
|
||||
def __init__(self, **_kw: Any) -> None:
|
||||
self.send = AsyncMock(side_effect=_send)
|
||||
|
||||
from app.models.config import JailConfigUpdate
|
||||
|
||||
update = JailConfigUpdate(fail_regex=["new_pattern"])
|
||||
with patch("app.services.config_service.Fail2BanClient", _FakeClient):
|
||||
await config_service.update_jail_config(_SOCKET, "sshd", update)
|
||||
|
||||
add_cmd = next(
|
||||
(c for c in sent_commands if len(c) >= 4 and c[2] == "addfailregex"),
|
||||
None,
|
||||
)
|
||||
assert add_cmd is not None
|
||||
assert add_cmd[3] == "new_pattern"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# get_global_config
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestGetGlobalConfig:
|
||||
"""Unit tests for :func:`~app.services.config_service.get_global_config`."""
|
||||
|
||||
async def test_returns_global_config(self) -> None:
|
||||
"""get_global_config returns parsed GlobalConfigResponse."""
|
||||
responses = {
|
||||
"get|loglevel": (0, "WARNING"),
|
||||
"get|logtarget": (0, "/var/log/fail2ban.log"),
|
||||
"get|dbpurgeage": (0, 86400),
|
||||
"get|dbmaxmatches": (0, 10),
|
||||
}
|
||||
with _patch_client(responses):
|
||||
result = await config_service.get_global_config(_SOCKET)
|
||||
|
||||
assert result.log_level == "WARNING"
|
||||
assert result.log_target == "/var/log/fail2ban.log"
|
||||
assert result.db_purge_age == 86400
|
||||
assert result.db_max_matches == 10
|
||||
|
||||
async def test_defaults_used_on_error(self) -> None:
|
||||
"""get_global_config uses fallback defaults when commands fail."""
|
||||
responses: dict[str, Any] = {}
|
||||
with _patch_client(responses):
|
||||
result = await config_service.get_global_config(_SOCKET)
|
||||
|
||||
assert result.log_level is not None
|
||||
assert result.log_target is not None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# update_global_config
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestUpdateGlobalConfig:
|
||||
"""Unit tests for :func:`~app.services.config_service.update_global_config`."""
|
||||
|
||||
async def test_sends_set_commands(self) -> None:
|
||||
"""update_global_config sends set commands for non-None fields."""
|
||||
sent: list[list[Any]] = []
|
||||
|
||||
async def _send(command: list[Any]) -> Any:
|
||||
sent.append(command)
|
||||
return (0, "OK")
|
||||
|
||||
class _FakeClient:
|
||||
def __init__(self, **_kw: Any) -> None:
|
||||
self.send = AsyncMock(side_effect=_send)
|
||||
|
||||
update = GlobalConfigUpdate(log_level="debug", db_purge_age=3600)
|
||||
with patch("app.services.config_service.Fail2BanClient", _FakeClient):
|
||||
await config_service.update_global_config(_SOCKET, update)
|
||||
|
||||
keys = [cmd[1] for cmd in sent if len(cmd) >= 3 and cmd[0] == "set"]
|
||||
assert "loglevel" in keys
|
||||
assert "dbpurgeage" in keys
|
||||
|
||||
async def test_log_level_uppercased(self) -> None:
|
||||
"""update_global_config uppercases log_level before sending."""
|
||||
sent: list[list[Any]] = []
|
||||
|
||||
async def _send(command: list[Any]) -> Any:
|
||||
sent.append(command)
|
||||
return (0, "OK")
|
||||
|
||||
class _FakeClient:
|
||||
def __init__(self, **_kw: Any) -> None:
|
||||
self.send = AsyncMock(side_effect=_send)
|
||||
|
||||
update = GlobalConfigUpdate(log_level="debug")
|
||||
with patch("app.services.config_service.Fail2BanClient", _FakeClient):
|
||||
await config_service.update_global_config(_SOCKET, update)
|
||||
|
||||
cmd = next(c for c in sent if len(c) >= 3 and c[1] == "loglevel")
|
||||
assert cmd[2] == "DEBUG"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# test_regex (synchronous)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestTestRegex:
|
||||
"""Unit tests for :func:`~app.services.config_service.test_regex`."""
|
||||
|
||||
def test_matching_pattern(self) -> None:
|
||||
"""test_regex returns matched=True for a valid match."""
|
||||
req = RegexTestRequest(
|
||||
log_line="Failed password for user from 1.2.3.4",
|
||||
fail_regex=r"(?P<host>\d+\.\d+\.\d+\.\d+)",
|
||||
)
|
||||
result = config_service.test_regex(req)
|
||||
|
||||
assert result.matched is True
|
||||
assert "1.2.3.4" in result.groups
|
||||
assert result.error is None
|
||||
|
||||
def test_non_matching_pattern(self) -> None:
|
||||
"""test_regex returns matched=False when pattern does not match."""
|
||||
req = RegexTestRequest(
|
||||
log_line="Normal log line here",
|
||||
fail_regex=r"BANME",
|
||||
)
|
||||
result = config_service.test_regex(req)
|
||||
|
||||
assert result.matched is False
|
||||
assert result.groups == []
|
||||
|
||||
def test_invalid_pattern_returns_error(self) -> None:
|
||||
"""test_regex returns error message for an invalid regex."""
|
||||
req = RegexTestRequest(
|
||||
log_line="any line",
|
||||
fail_regex=r"[invalid",
|
||||
)
|
||||
result = config_service.test_regex(req)
|
||||
|
||||
assert result.matched is False
|
||||
assert result.error is not None
|
||||
assert len(result.error) > 0
|
||||
|
||||
def test_empty_groups_when_no_capture(self) -> None:
|
||||
"""test_regex returns empty groups when pattern has no capture groups."""
|
||||
req = RegexTestRequest(
|
||||
log_line="fail here",
|
||||
fail_regex=r"fail",
|
||||
)
|
||||
result = config_service.test_regex(req)
|
||||
|
||||
assert result.matched is True
|
||||
assert result.groups == []
|
||||
|
||||
def test_multiple_capture_groups(self) -> None:
|
||||
"""test_regex returns all captured groups."""
|
||||
req = RegexTestRequest(
|
||||
log_line="user=root ip=1.2.3.4",
|
||||
fail_regex=r"user=(\w+) ip=([\d.]+)",
|
||||
)
|
||||
result = config_service.test_regex(req)
|
||||
|
||||
assert result.matched is True
|
||||
assert len(result.groups) == 2
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# preview_log
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestPreviewLog:
|
||||
"""Unit tests for :func:`~app.services.config_service.preview_log`."""
|
||||
|
||||
async def test_returns_error_for_invalid_regex(self, tmp_path: Any) -> None:
|
||||
"""preview_log returns regex_error for an invalid pattern."""
|
||||
req = LogPreviewRequest(log_path=str(tmp_path / "fake.log"), fail_regex="[bad")
|
||||
result = await config_service.preview_log(req)
|
||||
|
||||
assert result.regex_error is not None
|
||||
assert result.total_lines == 0
|
||||
|
||||
async def test_returns_error_for_missing_file(self) -> None:
|
||||
"""preview_log returns regex_error when file does not exist."""
|
||||
req = LogPreviewRequest(
|
||||
log_path="/nonexistent/path/log.txt",
|
||||
fail_regex=r"test",
|
||||
)
|
||||
result = await config_service.preview_log(req)
|
||||
|
||||
assert result.regex_error is not None
|
||||
|
||||
async def test_matches_lines_in_file(self, tmp_path: Any) -> None:
|
||||
"""preview_log correctly identifies matching and non-matching lines."""
|
||||
log_file = tmp_path / "test.log"
|
||||
log_file.write_text("FAIL login from 1.2.3.4\nOK normal line\nFAIL from 5.6.7.8\n")
|
||||
|
||||
req = LogPreviewRequest(log_path=str(log_file), fail_regex=r"FAIL")
|
||||
result = await config_service.preview_log(req)
|
||||
|
||||
assert result.total_lines == 3
|
||||
assert result.matched_count == 2
|
||||
|
||||
async def test_matched_line_has_groups(self, tmp_path: Any) -> None:
|
||||
"""preview_log captures regex groups in matched lines."""
|
||||
log_file = tmp_path / "test.log"
|
||||
log_file.write_text("error from 1.2.3.4 port 22\n")
|
||||
|
||||
req = LogPreviewRequest(
|
||||
log_path=str(log_file),
|
||||
fail_regex=r"from (\d+\.\d+\.\d+\.\d+)",
|
||||
)
|
||||
result = await config_service.preview_log(req)
|
||||
|
||||
matched = [ln for ln in result.lines if ln.matched]
|
||||
assert len(matched) == 1
|
||||
assert "1.2.3.4" in matched[0].groups
|
||||
|
||||
async def test_num_lines_limit(self, tmp_path: Any) -> None:
|
||||
"""preview_log respects the num_lines limit."""
|
||||
log_file = tmp_path / "big.log"
|
||||
log_file.write_text("\n".join(f"line {i}" for i in range(500)) + "\n")
|
||||
|
||||
req = LogPreviewRequest(log_path=str(log_file), fail_regex=r"line", num_lines=50)
|
||||
result = await config_service.preview_log(req)
|
||||
|
||||
assert result.total_lines <= 50
|
||||
@@ -2,14 +2,13 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from app.services import geo_service
|
||||
from app.services.geo_service import GeoInfo
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -181,9 +181,8 @@ class TestListJails:
|
||||
def __init__(self, **_kw: Any) -> None:
|
||||
self.send = AsyncMock(side_effect=Fail2BanConnectionError("no socket", _SOCKET))
|
||||
|
||||
with patch("app.services.jail_service.Fail2BanClient", _FailClient):
|
||||
with pytest.raises(Fail2BanConnectionError):
|
||||
await jail_service.list_jails(_SOCKET)
|
||||
with patch("app.services.jail_service.Fail2BanClient", _FailClient), pytest.raises(Fail2BanConnectionError):
|
||||
await jail_service.list_jails(_SOCKET)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -251,9 +250,8 @@ class TestGetJail:
|
||||
"""get_jail raises JailNotFoundError when jail is unknown."""
|
||||
not_found_response = (1, Exception("Unknown jail: 'ghost'"))
|
||||
|
||||
with _patch_client({r"status|ghost|short": not_found_response}):
|
||||
with pytest.raises(JailNotFoundError):
|
||||
await jail_service.get_jail(_SOCKET, "ghost")
|
||||
with _patch_client({r"status|ghost|short": not_found_response}), pytest.raises(JailNotFoundError):
|
||||
await jail_service.get_jail(_SOCKET, "ghost")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -296,15 +294,13 @@ class TestJailControls:
|
||||
|
||||
async def test_start_not_found_raises(self) -> None:
|
||||
"""start_jail raises JailNotFoundError for unknown jail."""
|
||||
with _patch_client({"start|ghost": (1, Exception("Unknown jail: 'ghost'"))}):
|
||||
with pytest.raises(JailNotFoundError):
|
||||
await jail_service.start_jail(_SOCKET, "ghost")
|
||||
with _patch_client({"start|ghost": (1, Exception("Unknown jail: 'ghost'"))}), pytest.raises(JailNotFoundError):
|
||||
await jail_service.start_jail(_SOCKET, "ghost")
|
||||
|
||||
async def test_stop_operation_error_raises(self) -> None:
|
||||
"""stop_jail raises JailOperationError on fail2ban error code."""
|
||||
with _patch_client({"stop|sshd": (1, Exception("cannot stop"))}):
|
||||
with pytest.raises(JailOperationError):
|
||||
await jail_service.stop_jail(_SOCKET, "sshd")
|
||||
with _patch_client({"stop|sshd": (1, Exception("cannot stop"))}), pytest.raises(JailOperationError):
|
||||
await jail_service.stop_jail(_SOCKET, "sshd")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
205
backend/tests/test_services/test_server_service.py
Normal file
205
backend/tests/test_services/test_server_service.py
Normal file
@@ -0,0 +1,205 @@
|
||||
"""Tests for server_service functions."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from app.models.server import ServerSettingsResponse, ServerSettingsUpdate
|
||||
from app.services import server_service
|
||||
from app.services.server_service import ServerOperationError
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_SOCKET = "/fake/fail2ban.sock"
|
||||
|
||||
_DEFAULT_RESPONSES: dict[str, Any] = {
|
||||
"get|loglevel": (0, "INFO"),
|
||||
"get|logtarget": (0, "/var/log/fail2ban.log"),
|
||||
"get|syslogsocket": (0, None),
|
||||
"get|dbfile": (0, "/var/lib/fail2ban/fail2ban.sqlite3"),
|
||||
"get|dbpurgeage": (0, 86400),
|
||||
"get|dbmaxmatches": (0, 10),
|
||||
}
|
||||
|
||||
|
||||
def _make_send(responses: dict[str, Any]) -> AsyncMock:
|
||||
async def _side_effect(command: list[Any]) -> Any:
|
||||
key = "|".join(str(c) for c in command)
|
||||
return responses.get(key, (0, None))
|
||||
|
||||
return AsyncMock(side_effect=_side_effect)
|
||||
|
||||
|
||||
def _patch_client(responses: dict[str, Any]) -> Any:
|
||||
mock_send = _make_send(responses)
|
||||
|
||||
class _FakeClient:
|
||||
def __init__(self, **_kw: Any) -> None:
|
||||
self.send = mock_send
|
||||
|
||||
return patch("app.services.server_service.Fail2BanClient", _FakeClient)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# get_settings
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestGetSettings:
|
||||
"""Unit tests for :func:`~app.services.server_service.get_settings`."""
|
||||
|
||||
async def test_returns_server_settings_response(self) -> None:
|
||||
"""get_settings returns a properly populated ServerSettingsResponse."""
|
||||
with _patch_client(_DEFAULT_RESPONSES):
|
||||
result = await server_service.get_settings(_SOCKET)
|
||||
|
||||
assert isinstance(result, ServerSettingsResponse)
|
||||
assert result.settings.log_level == "INFO"
|
||||
assert result.settings.log_target == "/var/log/fail2ban.log"
|
||||
assert result.settings.db_purge_age == 86400
|
||||
assert result.settings.db_max_matches == 10
|
||||
|
||||
async def test_db_path_parsed(self) -> None:
|
||||
"""get_settings returns the correct database file path."""
|
||||
with _patch_client(_DEFAULT_RESPONSES):
|
||||
result = await server_service.get_settings(_SOCKET)
|
||||
|
||||
assert result.settings.db_path == "/var/lib/fail2ban/fail2ban.sqlite3"
|
||||
|
||||
async def test_syslog_socket_none(self) -> None:
|
||||
"""get_settings returns None for syslog_socket when not configured."""
|
||||
with _patch_client(_DEFAULT_RESPONSES):
|
||||
result = await server_service.get_settings(_SOCKET)
|
||||
|
||||
assert result.settings.syslog_socket is None
|
||||
|
||||
async def test_fallback_defaults_on_missing_commands(self) -> None:
|
||||
"""get_settings uses fallback defaults when commands return None."""
|
||||
with _patch_client({}):
|
||||
result = await server_service.get_settings(_SOCKET)
|
||||
|
||||
assert result.settings.log_level == "INFO"
|
||||
assert result.settings.db_max_matches == 10
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# update_settings
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestUpdateSettings:
|
||||
"""Unit tests for :func:`~app.services.server_service.update_settings`."""
|
||||
|
||||
async def test_sends_set_commands_for_non_none_fields(self) -> None:
|
||||
"""update_settings sends set commands only for non-None fields."""
|
||||
sent: list[list[Any]] = []
|
||||
|
||||
async def _send(command: list[Any]) -> Any:
|
||||
sent.append(command)
|
||||
return (0, "OK")
|
||||
|
||||
class _FakeClient:
|
||||
def __init__(self, **_kw: Any) -> None:
|
||||
self.send = AsyncMock(side_effect=_send)
|
||||
|
||||
update = ServerSettingsUpdate(log_level="DEBUG", db_purge_age=3600)
|
||||
with patch("app.services.server_service.Fail2BanClient", _FakeClient):
|
||||
await server_service.update_settings(_SOCKET, update)
|
||||
|
||||
keys = [cmd[1] for cmd in sent if len(cmd) >= 3]
|
||||
assert "loglevel" in keys
|
||||
assert "dbpurgeage" in keys
|
||||
|
||||
async def test_skips_none_fields(self) -> None:
|
||||
"""update_settings does not send commands for None fields."""
|
||||
sent: list[list[Any]] = []
|
||||
|
||||
async def _send(command: list[Any]) -> Any:
|
||||
sent.append(command)
|
||||
return (0, "OK")
|
||||
|
||||
class _FakeClient:
|
||||
def __init__(self, **_kw: Any) -> None:
|
||||
self.send = AsyncMock(side_effect=_send)
|
||||
|
||||
update = ServerSettingsUpdate() # all None
|
||||
with patch("app.services.server_service.Fail2BanClient", _FakeClient):
|
||||
await server_service.update_settings(_SOCKET, update)
|
||||
|
||||
assert sent == []
|
||||
|
||||
async def test_raises_server_operation_error_on_failure(self) -> None:
|
||||
"""update_settings raises ServerOperationError when fail2ban rejects."""
|
||||
|
||||
async def _send(command: list[Any]) -> Any:
|
||||
return (1, "invalid log level")
|
||||
|
||||
class _FakeClient:
|
||||
def __init__(self, **_kw: Any) -> None:
|
||||
self.send = AsyncMock(side_effect=_send)
|
||||
|
||||
update = ServerSettingsUpdate(log_level="INVALID")
|
||||
with patch("app.services.server_service.Fail2BanClient", _FakeClient), pytest.raises(ServerOperationError):
|
||||
await server_service.update_settings(_SOCKET, update)
|
||||
|
||||
async def test_uppercases_log_level(self) -> None:
|
||||
"""update_settings uppercases the log_level value before sending."""
|
||||
sent: list[list[Any]] = []
|
||||
|
||||
async def _send(command: list[Any]) -> Any:
|
||||
sent.append(command)
|
||||
return (0, "OK")
|
||||
|
||||
class _FakeClient:
|
||||
def __init__(self, **_kw: Any) -> None:
|
||||
self.send = AsyncMock(side_effect=_send)
|
||||
|
||||
update = ServerSettingsUpdate(log_level="warning")
|
||||
with patch("app.services.server_service.Fail2BanClient", _FakeClient):
|
||||
await server_service.update_settings(_SOCKET, update)
|
||||
|
||||
cmd = next(c for c in sent if len(c) >= 3 and c[1] == "loglevel")
|
||||
assert cmd[2] == "WARNING"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# flush_logs
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestFlushLogs:
|
||||
"""Unit tests for :func:`~app.services.server_service.flush_logs`."""
|
||||
|
||||
async def test_returns_result_string(self) -> None:
|
||||
"""flush_logs returns the string response from fail2ban."""
|
||||
|
||||
async def _send(command: list[Any]) -> Any:
|
||||
assert command == ["flushlogs"]
|
||||
return (0, "OK")
|
||||
|
||||
class _FakeClient:
|
||||
def __init__(self, **_kw: Any) -> None:
|
||||
self.send = AsyncMock(side_effect=_send)
|
||||
|
||||
with patch("app.services.server_service.Fail2BanClient", _FakeClient):
|
||||
result = await server_service.flush_logs(_SOCKET)
|
||||
|
||||
assert result == "OK"
|
||||
|
||||
async def test_raises_operation_error_on_failure(self) -> None:
|
||||
"""flush_logs raises ServerOperationError when fail2ban rejects."""
|
||||
|
||||
async def _send(command: list[Any]) -> Any:
|
||||
return (1, "flushlogs failed")
|
||||
|
||||
class _FakeClient:
|
||||
def __init__(self, **_kw: Any) -> None:
|
||||
self.send = AsyncMock(side_effect=_send)
|
||||
|
||||
with patch("app.services.server_service.Fail2BanClient", _FakeClient), pytest.raises(ServerOperationError):
|
||||
await server_service.flush_logs(_SOCKET)
|
||||
@@ -4,7 +4,7 @@ import reactHooks from "eslint-plugin-react-hooks";
|
||||
import prettierConfig from "eslint-config-prettier";
|
||||
|
||||
export default tseslint.config(
|
||||
{ ignores: ["dist"] },
|
||||
{ ignores: ["dist", "eslint.config.ts"] },
|
||||
{
|
||||
extends: [js.configs.recommended, ...tseslint.configs.strictTypeChecked],
|
||||
files: ["**/*.{ts,tsx}"],
|
||||
|
||||
@@ -38,7 +38,7 @@ import { BlocklistsPage } from "./pages/BlocklistsPage";
|
||||
/**
|
||||
* Root application component — mounts providers and top-level routes.
|
||||
*/
|
||||
function App(): JSX.Element {
|
||||
function App(): React.JSX.Element {
|
||||
return (
|
||||
<FluentProvider theme={lightTheme}>
|
||||
<BrowserRouter>
|
||||
|
||||
@@ -57,7 +57,7 @@ async function request<T>(url: string, options: RequestInit = {}): Promise<T> {
|
||||
credentials: "include",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...options.headers,
|
||||
...(options.headers as Record<string, string> | undefined),
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
121
frontend/src/api/config.ts
Normal file
121
frontend/src/api/config.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* API functions for the configuration and server settings endpoints.
|
||||
*/
|
||||
|
||||
import { get, post, put } from "./client";
|
||||
import { ENDPOINTS } from "./endpoints";
|
||||
import type {
|
||||
AddLogPathRequest,
|
||||
GlobalConfig,
|
||||
GlobalConfigUpdate,
|
||||
JailConfigListResponse,
|
||||
JailConfigResponse,
|
||||
JailConfigUpdate,
|
||||
LogPreviewRequest,
|
||||
LogPreviewResponse,
|
||||
RegexTestRequest,
|
||||
RegexTestResponse,
|
||||
ServerSettingsResponse,
|
||||
ServerSettingsUpdate,
|
||||
} from "../types/config";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Jail configuration
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function fetchJailConfigs(
|
||||
): Promise<JailConfigListResponse> {
|
||||
return get<JailConfigListResponse>(ENDPOINTS.configJails);
|
||||
}
|
||||
|
||||
export async function fetchJailConfig(
|
||||
name: string
|
||||
): Promise<JailConfigResponse> {
|
||||
return get<JailConfigResponse>(ENDPOINTS.configJail(name));
|
||||
}
|
||||
|
||||
export async function updateJailConfig(
|
||||
name: string,
|
||||
update: JailConfigUpdate
|
||||
): Promise<void> {
|
||||
await put<undefined>(ENDPOINTS.configJail(name), update);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Global configuration
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function fetchGlobalConfig(
|
||||
): Promise<GlobalConfig> {
|
||||
return get<GlobalConfig>(ENDPOINTS.configGlobal);
|
||||
}
|
||||
|
||||
export async function updateGlobalConfig(
|
||||
update: GlobalConfigUpdate
|
||||
): Promise<void> {
|
||||
await put<undefined>(ENDPOINTS.configGlobal, update);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Reload
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function reloadConfig(
|
||||
): Promise<void> {
|
||||
await post<undefined>(ENDPOINTS.configReload, undefined);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Regex tester
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function testRegex(
|
||||
req: RegexTestRequest
|
||||
): Promise<RegexTestResponse> {
|
||||
return post<RegexTestResponse>(ENDPOINTS.configRegexTest, req);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Log path management
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function addLogPath(
|
||||
jailName: string,
|
||||
req: AddLogPathRequest
|
||||
): Promise<void> {
|
||||
await post<undefined>(ENDPOINTS.configJailLogPath(jailName), req);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Log preview
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function previewLog(
|
||||
req: LogPreviewRequest
|
||||
): Promise<LogPreviewResponse> {
|
||||
return post<LogPreviewResponse>(ENDPOINTS.configPreviewLog, req);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Server settings
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function fetchServerSettings(
|
||||
): Promise<ServerSettingsResponse> {
|
||||
return get<ServerSettingsResponse>(ENDPOINTS.serverSettings);
|
||||
}
|
||||
|
||||
export async function updateServerSettings(
|
||||
update: ServerSettingsUpdate
|
||||
): Promise<void> {
|
||||
await put<undefined>(ENDPOINTS.serverSettings, update);
|
||||
}
|
||||
|
||||
export async function flushLogs(
|
||||
): Promise<string> {
|
||||
const resp = await post<{ message: string }>(
|
||||
ENDPOINTS.serverFlushLogs,
|
||||
undefined,
|
||||
);
|
||||
return resp.message;
|
||||
}
|
||||
@@ -58,9 +58,12 @@ export const ENDPOINTS = {
|
||||
// -------------------------------------------------------------------------
|
||||
configJails: "/config/jails",
|
||||
configJail: (name: string): string => `/config/jails/${encodeURIComponent(name)}`,
|
||||
configJailLogPath: (name: string): string =>
|
||||
`/config/jails/${encodeURIComponent(name)}/logpath`,
|
||||
configGlobal: "/config/global",
|
||||
configReload: "/config/reload",
|
||||
configRegexTest: "/config/regex-test",
|
||||
configPreviewLog: "/config/preview-log",
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Server settings
|
||||
|
||||
@@ -11,7 +11,7 @@ import { useAuth } from "../providers/AuthProvider";
|
||||
|
||||
interface RequireAuthProps {
|
||||
/** The protected page content to render when authenticated. */
|
||||
children: JSX.Element;
|
||||
children: React.JSX.Element;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -20,7 +20,7 @@ interface RequireAuthProps {
|
||||
* Redirects to `/login?next=<path>` otherwise so the intended destination is
|
||||
* preserved and honoured after a successful login.
|
||||
*/
|
||||
export function RequireAuth({ children }: RequireAuthProps): JSX.Element {
|
||||
export function RequireAuth({ children }: RequireAuthProps): React.JSX.Element {
|
||||
const { isAuthenticated } = useAuth();
|
||||
const location = useLocation();
|
||||
|
||||
|
||||
@@ -81,7 +81,7 @@ const useStyles = makeStyles({
|
||||
* Render this at the top of the dashboard page (and any page that should
|
||||
* show live server status).
|
||||
*/
|
||||
export function ServerStatusBar(): JSX.Element {
|
||||
export function ServerStatusBar(): React.JSX.Element {
|
||||
const styles = useStyles();
|
||||
const { status, loading, error, refresh } = useServerStatus();
|
||||
|
||||
|
||||
355
frontend/src/hooks/useConfig.ts
Normal file
355
frontend/src/hooks/useConfig.ts
Normal file
@@ -0,0 +1,355 @@
|
||||
/**
|
||||
* React hooks for the configuration and server settings data.
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
addLogPath,
|
||||
fetchGlobalConfig,
|
||||
fetchJailConfig,
|
||||
fetchJailConfigs,
|
||||
fetchServerSettings,
|
||||
flushLogs,
|
||||
previewLog,
|
||||
reloadConfig,
|
||||
testRegex,
|
||||
updateGlobalConfig,
|
||||
updateJailConfig,
|
||||
updateServerSettings,
|
||||
} from "../api/config";
|
||||
import type {
|
||||
AddLogPathRequest,
|
||||
GlobalConfig,
|
||||
GlobalConfigUpdate,
|
||||
JailConfig,
|
||||
JailConfigUpdate,
|
||||
LogPreviewRequest,
|
||||
LogPreviewResponse,
|
||||
RegexTestRequest,
|
||||
RegexTestResponse,
|
||||
ServerSettings,
|
||||
ServerSettingsUpdate,
|
||||
} from "../types/config";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// useJailConfigs — list all jail configs
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface UseJailConfigsResult {
|
||||
jails: JailConfig[];
|
||||
total: number;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
refresh: () => void;
|
||||
updateJail: (name: string, update: JailConfigUpdate) => Promise<void>;
|
||||
reloadAll: () => Promise<void>;
|
||||
}
|
||||
|
||||
export function useJailConfigs(): UseJailConfigsResult {
|
||||
const [jails, setJails] = useState<JailConfig[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
|
||||
const load = useCallback((): void => {
|
||||
abortRef.current?.abort();
|
||||
const ctrl = new AbortController();
|
||||
abortRef.current = ctrl;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
fetchJailConfigs()
|
||||
.then((resp) => {
|
||||
setJails(resp.jails);
|
||||
setTotal(resp.total);
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
if (err instanceof Error && err.name !== "AbortError") {
|
||||
setError(err.message);
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
setLoading(false);
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
return (): void => {
|
||||
abortRef.current?.abort();
|
||||
};
|
||||
}, [load]);
|
||||
|
||||
const updateJail = useCallback(
|
||||
async (name: string, update: JailConfigUpdate): Promise<void> => {
|
||||
await updateJailConfig(name, update);
|
||||
load();
|
||||
},
|
||||
[load],
|
||||
);
|
||||
|
||||
const reloadAll = useCallback(async (): Promise<void> => {
|
||||
await reloadConfig();
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
return { jails, total, loading, error, refresh: load, updateJail, reloadAll };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// useJailConfigDetail — single jail config with mutation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface UseJailConfigDetailResult {
|
||||
jail: JailConfig | null;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
refresh: () => void;
|
||||
updateJail: (update: JailConfigUpdate) => Promise<void>;
|
||||
addLog: (req: AddLogPathRequest) => Promise<void>;
|
||||
}
|
||||
|
||||
export function useJailConfigDetail(name: string): UseJailConfigDetailResult {
|
||||
const [jail, setJail] = useState<JailConfig | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
|
||||
const load = useCallback((): void => {
|
||||
abortRef.current?.abort();
|
||||
const ctrl = new AbortController();
|
||||
abortRef.current = ctrl;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
fetchJailConfig(name)
|
||||
.then((resp) => {
|
||||
setJail(resp.jail);
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
if (err instanceof Error && err.name !== "AbortError") {
|
||||
setError(err.message);
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
setLoading(false);
|
||||
});
|
||||
}, [name]);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
return (): void => {
|
||||
abortRef.current?.abort();
|
||||
};
|
||||
}, [load]);
|
||||
|
||||
const updateJail = useCallback(
|
||||
async (update: JailConfigUpdate): Promise<void> => {
|
||||
await updateJailConfig(name, update);
|
||||
load();
|
||||
},
|
||||
[name, load],
|
||||
);
|
||||
|
||||
const addLog = useCallback(
|
||||
async (req: AddLogPathRequest): Promise<void> => {
|
||||
await addLogPath(name, req);
|
||||
load();
|
||||
},
|
||||
[name, load],
|
||||
);
|
||||
|
||||
return { jail, loading, error, refresh: load, updateJail, addLog };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// useGlobalConfig
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface UseGlobalConfigResult {
|
||||
config: GlobalConfig | null;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
refresh: () => void;
|
||||
updateConfig: (update: GlobalConfigUpdate) => Promise<void>;
|
||||
}
|
||||
|
||||
export function useGlobalConfig(): UseGlobalConfigResult {
|
||||
const [config, setConfig] = useState<GlobalConfig | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
|
||||
const load = useCallback((): void => {
|
||||
abortRef.current?.abort();
|
||||
const ctrl = new AbortController();
|
||||
abortRef.current = ctrl;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
fetchGlobalConfig()
|
||||
.then(setConfig)
|
||||
.catch((err: unknown) => {
|
||||
if (err instanceof Error && err.name !== "AbortError") {
|
||||
setError(err.message);
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
setLoading(false);
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
return (): void => {
|
||||
abortRef.current?.abort();
|
||||
};
|
||||
}, [load]);
|
||||
|
||||
const updateConfig = useCallback(
|
||||
async (update: GlobalConfigUpdate): Promise<void> => {
|
||||
await updateGlobalConfig(update);
|
||||
load();
|
||||
},
|
||||
[load],
|
||||
);
|
||||
|
||||
return { config, loading, error, refresh: load, updateConfig };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// useServerSettings
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface UseServerSettingsResult {
|
||||
settings: ServerSettings | null;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
refresh: () => void;
|
||||
updateSettings: (update: ServerSettingsUpdate) => Promise<void>;
|
||||
flush: () => Promise<string>;
|
||||
}
|
||||
|
||||
export function useServerSettings(): UseServerSettingsResult {
|
||||
const [settings, setSettings] = useState<ServerSettings | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
|
||||
const load = useCallback((): void => {
|
||||
abortRef.current?.abort();
|
||||
const ctrl = new AbortController();
|
||||
abortRef.current = ctrl;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
fetchServerSettings()
|
||||
.then((resp) => {
|
||||
setSettings(resp.settings);
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
if (err instanceof Error && err.name !== "AbortError") {
|
||||
setError(err.message);
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
setLoading(false);
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
return (): void => {
|
||||
abortRef.current?.abort();
|
||||
};
|
||||
}, [load]);
|
||||
|
||||
const updateSettings_ = useCallback(
|
||||
async (update: ServerSettingsUpdate): Promise<void> => {
|
||||
await updateServerSettings(update);
|
||||
load();
|
||||
},
|
||||
[load],
|
||||
);
|
||||
|
||||
const flush = useCallback(async (): Promise<string> => {
|
||||
return flushLogs();
|
||||
}, []);
|
||||
|
||||
return {
|
||||
settings,
|
||||
loading,
|
||||
error,
|
||||
refresh: load,
|
||||
updateSettings: updateSettings_,
|
||||
flush,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// useRegexTester — lazy, triggered by test(req)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface UseRegexTesterResult {
|
||||
result: RegexTestResponse | null;
|
||||
testing: boolean;
|
||||
test: (req: RegexTestRequest) => Promise<void>;
|
||||
}
|
||||
|
||||
export function useRegexTester(): UseRegexTesterResult {
|
||||
const [result, setResult] = useState<RegexTestResponse | null>(null);
|
||||
const [testing, setTesting] = useState(false);
|
||||
|
||||
const test_ = useCallback(async (req: RegexTestRequest): Promise<void> => {
|
||||
setTesting(true);
|
||||
try {
|
||||
const resp = await testRegex(req);
|
||||
setResult(resp);
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof Error) {
|
||||
setResult({ matched: false, groups: [], error: err.message });
|
||||
}
|
||||
} finally {
|
||||
setTesting(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return { result, testing, test: test_ };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// useLogPreview — lazy, triggered by preview(req)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface UseLogPreviewResult {
|
||||
preview: LogPreviewResponse | null;
|
||||
loading: boolean;
|
||||
run: (req: LogPreviewRequest) => Promise<void>;
|
||||
}
|
||||
|
||||
export function useLogPreview(): UseLogPreviewResult {
|
||||
const [preview, setPreview] = useState<LogPreviewResponse | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const run_ = useCallback(async (req: LogPreviewRequest): Promise<void> => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const resp = await previewLog(req);
|
||||
setPreview(resp);
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof Error) {
|
||||
setPreview({
|
||||
lines: [],
|
||||
total_lines: 0,
|
||||
matched_count: 0,
|
||||
regex_error: err.message,
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return { preview, loading, run: run_ };
|
||||
}
|
||||
@@ -100,7 +100,7 @@ export function useJails(): UseJailsResult {
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
return () => {
|
||||
return (): void => {
|
||||
abortRef.current?.abort();
|
||||
};
|
||||
}, [load]);
|
||||
@@ -120,9 +120,9 @@ export function useJails(): UseJailsResult {
|
||||
refresh: load,
|
||||
startJail: withRefresh(startJail),
|
||||
stopJail: withRefresh(stopJail),
|
||||
setIdle: (name, on) => setJailIdle(name, on).then(() => load()),
|
||||
setIdle: (name, on) => setJailIdle(name, on).then((): void => { load(); }),
|
||||
reloadJail: withRefresh(reloadJail),
|
||||
reloadAll: () => reloadAllJails().then(() => load()),
|
||||
reloadAll: () => reloadAllJails().then((): void => { load(); }),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -191,7 +191,7 @@ export function useJailDetail(name: string): UseJailDetailResult {
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
return () => {
|
||||
return (): void => {
|
||||
abortRef.current?.abort();
|
||||
};
|
||||
}, [load]);
|
||||
@@ -278,7 +278,7 @@ export function useActiveBans(): UseActiveBansResult {
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
return () => {
|
||||
return (): void => {
|
||||
abortRef.current?.abort();
|
||||
};
|
||||
}, [load]);
|
||||
|
||||
@@ -36,7 +36,7 @@ export function useServerStatus(): UseServerStatusResult {
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Use a ref so the fetch function identity is stable.
|
||||
const fetchRef = useRef<() => void>(() => undefined);
|
||||
const fetchRef = useRef<() => Promise<void>>(async () => Promise.resolve());
|
||||
|
||||
const doFetch = useCallback(async (): Promise<void> => {
|
||||
setLoading(true);
|
||||
@@ -54,14 +54,14 @@ export function useServerStatus(): UseServerStatusResult {
|
||||
fetchRef.current = doFetch;
|
||||
|
||||
// Initial fetch + polling interval.
|
||||
useEffect(() => {
|
||||
void doFetch();
|
||||
useEffect((): (() => void) => {
|
||||
void doFetch().catch((): void => undefined);
|
||||
|
||||
const id = setInterval(() => {
|
||||
void fetchRef.current();
|
||||
const id = setInterval((): void => {
|
||||
void fetchRef.current().catch((): void => undefined);
|
||||
}, POLL_INTERVAL_MS);
|
||||
|
||||
return () => clearInterval(id);
|
||||
return (): void => { clearInterval(id); };
|
||||
}, [doFetch]);
|
||||
|
||||
// Refetch on window focus.
|
||||
@@ -70,11 +70,11 @@ export function useServerStatus(): UseServerStatusResult {
|
||||
void fetchRef.current();
|
||||
};
|
||||
window.addEventListener("focus", onFocus);
|
||||
return () => window.removeEventListener("focus", onFocus);
|
||||
return (): void => { window.removeEventListener("focus", onFocus); };
|
||||
}, []);
|
||||
|
||||
const refresh = useCallback((): void => {
|
||||
void doFetch();
|
||||
void doFetch().catch((): void => undefined);
|
||||
}, [doFetch]);
|
||||
|
||||
return { status, loading, error, refresh };
|
||||
|
||||
@@ -176,7 +176,7 @@ const NAV_ITEMS: NavItem[] = [
|
||||
* Renders child routes via `<Outlet />`. Use inside React Router
|
||||
* as a layout route wrapping all authenticated pages.
|
||||
*/
|
||||
export function MainLayout(): JSX.Element {
|
||||
export function MainLayout(): React.JSX.Element {
|
||||
const styles = useStyles();
|
||||
const { logout } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
|
||||
@@ -8,7 +8,7 @@ const useStyles = makeStyles({
|
||||
root: { padding: tokens.spacingVerticalXXL },
|
||||
});
|
||||
|
||||
export function BlocklistsPage(): JSX.Element {
|
||||
export function BlocklistsPage(): React.JSX.Element {
|
||||
const styles = useStyles();
|
||||
return (
|
||||
<div className={styles.root}>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -8,7 +8,7 @@ const useStyles = makeStyles({
|
||||
root: { padding: tokens.spacingVerticalXXL },
|
||||
});
|
||||
|
||||
export function HistoryPage(): JSX.Element {
|
||||
export function HistoryPage(): React.JSX.Element {
|
||||
const styles = useStyles();
|
||||
return (
|
||||
<div className={styles.root}>
|
||||
|
||||
@@ -71,7 +71,7 @@ const useStyles = makeStyles({
|
||||
/**
|
||||
* Login page — single password input, no username.
|
||||
*/
|
||||
export function LoginPage(): JSX.Element {
|
||||
export function LoginPage(): React.JSX.Element {
|
||||
const styles = useStyles();
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
|
||||
@@ -8,7 +8,7 @@ const useStyles = makeStyles({
|
||||
root: { padding: tokens.spacingVerticalXXL },
|
||||
});
|
||||
|
||||
export function MapPage(): JSX.Element {
|
||||
export function MapPage(): React.JSX.Element {
|
||||
const styles = useStyles();
|
||||
return (
|
||||
<div className={styles.root}>
|
||||
|
||||
@@ -96,7 +96,7 @@ const DEFAULT_VALUES: FormValues = {
|
||||
* First-run setup wizard page.
|
||||
* Collects master password and server preferences.
|
||||
*/
|
||||
export function SetupPage(): JSX.Element {
|
||||
export function SetupPage(): React.JSX.Element {
|
||||
const styles = useStyles();
|
||||
const navigate = useNavigate();
|
||||
|
||||
|
||||
@@ -60,7 +60,7 @@ export function AuthProvider({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}): JSX.Element {
|
||||
}): React.JSX.Element {
|
||||
const [auth, setAuth] = useState<AuthState>(() => ({
|
||||
token: sessionStorage.getItem(SESSION_KEY),
|
||||
expiresAt: sessionStorage.getItem(SESSION_EXPIRES_KEY),
|
||||
|
||||
130
frontend/src/types/config.ts
Normal file
130
frontend/src/types/config.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
/**
|
||||
* TypeScript interfaces for the configuration and server settings API.
|
||||
*/
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Jail Configuration
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface JailConfig {
|
||||
name: string;
|
||||
ban_time: number;
|
||||
max_retry: number;
|
||||
find_time: number;
|
||||
fail_regex: string[];
|
||||
ignore_regex: string[];
|
||||
log_paths: string[];
|
||||
date_pattern: string | null;
|
||||
log_encoding: string;
|
||||
backend: string;
|
||||
actions: string[];
|
||||
}
|
||||
|
||||
export interface JailConfigResponse {
|
||||
jail: JailConfig;
|
||||
}
|
||||
|
||||
export interface JailConfigListResponse {
|
||||
jails: JailConfig[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface JailConfigUpdate {
|
||||
ban_time?: number | null;
|
||||
max_retry?: number | null;
|
||||
find_time?: number | null;
|
||||
fail_regex?: string[] | null;
|
||||
ignore_regex?: string[] | null;
|
||||
date_pattern?: string | null;
|
||||
dns_mode?: string | null;
|
||||
enabled?: boolean | null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Global Configuration
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface GlobalConfig {
|
||||
log_level: string;
|
||||
log_target: string;
|
||||
db_purge_age: number;
|
||||
db_max_matches: number;
|
||||
}
|
||||
|
||||
export interface GlobalConfigUpdate {
|
||||
log_level?: string | null;
|
||||
log_target?: string | null;
|
||||
db_purge_age?: number | null;
|
||||
db_max_matches?: number | null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Server Settings
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface ServerSettings {
|
||||
log_level: string;
|
||||
log_target: string;
|
||||
syslog_socket: string | null;
|
||||
db_path: string;
|
||||
db_purge_age: number;
|
||||
db_max_matches: number;
|
||||
}
|
||||
|
||||
export interface ServerSettingsResponse {
|
||||
settings: ServerSettings;
|
||||
}
|
||||
|
||||
export interface ServerSettingsUpdate {
|
||||
log_level?: string | null;
|
||||
log_target?: string | null;
|
||||
db_purge_age?: number | null;
|
||||
db_max_matches?: number | null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Regex Tester
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface RegexTestRequest {
|
||||
log_line: string;
|
||||
fail_regex: string;
|
||||
}
|
||||
|
||||
export interface RegexTestResponse {
|
||||
matched: boolean;
|
||||
groups: string[];
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Log Preview
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface LogPreviewRequest {
|
||||
log_path: string;
|
||||
fail_regex: string;
|
||||
num_lines?: number;
|
||||
}
|
||||
|
||||
export interface LogPreviewLine {
|
||||
line: string;
|
||||
matched: boolean;
|
||||
groups: string[];
|
||||
}
|
||||
|
||||
export interface LogPreviewResponse {
|
||||
lines: LogPreviewLine[];
|
||||
total_lines: number;
|
||||
matched_count: number;
|
||||
regex_error: string | null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Add Log Path
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface AddLogPathRequest {
|
||||
log_path: string;
|
||||
tail?: boolean;
|
||||
}
|
||||
Reference in New Issue
Block a user