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:
@@ -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)
|
||||
Reference in New Issue
Block a user