diff --git a/Docs/Tasks.md b/Docs/Tasks.md index e5a8f29..75cf54a 100644 --- a/Docs/Tasks.md +++ b/Docs/Tasks.md @@ -107,6 +107,7 @@ Reference: `Docs/Refactoring.md` for full analysis of each issue. - Issue: `app/routers/config.py` and `app/routers/file_config.py` each contain many unrelated endpoints with mixed concerns, making the HTTP layer large, hard to navigate, and harder to evolve safely. - Propose: Refactor configuration endpoints into smaller routers or feature-specific modules such as `jail_config`, `filter_config`, `action_config`, and `raw_config`. Keep each router focused on one vertical slice of functionality and delegate business logic to a single service. - Test: Verify the refactor preserves endpoint behavior and that router tests are split along the new modules, ensuring each HTTP layer unit test covers a narrow, well-defined feature. + - Status: completed 15. Centralize fail2ban metadata resolution and avoid repeated socket discovery - Goal: Reduce coupling between history APIs and fail2ban socket metadata discovery, improving performance and reliability. diff --git a/backend/app/routers/action_config.py b/backend/app/routers/action_config.py new file mode 100644 index 0000000..41f9349 --- /dev/null +++ b/backend/app/routers/action_config.py @@ -0,0 +1,373 @@ +from __future__ import annotations + +from typing import Annotated + +from fastapi import APIRouter, HTTPException, Path, Query, status + +from app.dependencies import AuthDep, Fail2BanConfigDirDep, Fail2BanSocketDep +from app.exceptions import ConfigWriteError +from app.models.config import ( + ActionConfig, + ActionCreateRequest, + ActionListResponse, + ActionUpdateRequest, + AssignActionRequest, +) +from app.services import action_config_service +from app.services.action_config_service import ( + ActionAlreadyExistsError, + ActionNameError, + ActionNotFoundError, + ActionReadonlyError, +) +from app.services.jail_config_service import JailNameError, JailNotFoundInConfigError + +router: APIRouter = APIRouter() + +_ActionNamePath = Annotated[ + str, + Path(description='Action base name, e.g. ``iptables`` or ``iptables.conf``.'), +] + + +def _action_not_found(name: str) -> HTTPException: + return HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Action not found: {name!r}", + ) + +@router.get( + "/actions", + response_model=ActionListResponse, + summary="List all available actions with active/inactive status", +) +async def list_actions( + request: Request, + _auth: AuthDep, + config_dir: Fail2BanConfigDirDep, + socket_path: Fail2BanSocketDep, +) -> ActionListResponse: + """Return all actions discovered in ``action.d/`` with active/inactive status. + + Scans ``{config_dir}/action.d/`` for ``.conf`` files, merges any + corresponding ``.local`` overrides, and cross-references each action's + name against the ``action`` fields of currently running jails to determine + whether it is active. + + Active actions (those used by at least one running jail) are sorted to the + top of the list; inactive actions follow. Both groups are sorted + alphabetically within themselves. + + Args: + request: FastAPI request object. + _auth: Validated session — enforces authentication. + + Returns: + :class:`~app.models.config.ActionListResponse` with all discovered + actions. + """ + result = await action_config_service.list_actions(config_dir, socket_path) + result.actions.sort(key=lambda a: (not a.active, a.name.lower())) + return result + + + + +@router.get( + "/actions/{name}", + response_model=ActionConfig, + summary="Return full parsed detail for a single action", +) +async def get_action( + request: Request, + _auth: AuthDep, + config_dir: Fail2BanConfigDirDep, + socket_path: Fail2BanSocketDep, + name: _ActionNamePath, +) -> ActionConfig: + """Return the full parsed configuration and active/inactive status for one action. + + Reads ``{config_dir}/action.d/{name}.conf``, merges any corresponding + ``.local`` override, and annotates the result with ``active``, + ``used_by_jails``, ``source_file``, and ``has_local_override``. + + Args: + request: FastAPI request object. + _auth: Validated session — enforces authentication. + name: Action base name (with or without ``.conf`` extension). + + Returns: + :class:`~app.models.config.ActionConfig`. + + Raises: + HTTPException: 404 if the action is not found in ``action.d/``. + """ + try: + return await action_config_service.get_action(config_dir, socket_path, name) + except ActionNotFoundError: + raise _action_not_found(name) from None + + +# --------------------------------------------------------------------------- +# Action write endpoints (Task 3.2) +# --------------------------------------------------------------------------- + + + + +@router.put( + "/actions/{name}", + response_model=ActionConfig, + summary="Update an action's .local override with new lifecycle command values", +) +async def update_action( + request: Request, + _auth: AuthDep, + config_dir: Fail2BanConfigDirDep, + socket_path: Fail2BanSocketDep, + name: _ActionNamePath, + body: ActionUpdateRequest, + reload: bool = Query(default=False, description="Reload fail2ban after writing."), +) -> ActionConfig: + """Update an action's ``[Definition]`` fields by writing a ``.local`` override. + + Only non-``null`` fields in the request body are written. The original + ``.conf`` file is never modified. + + Args: + request: FastAPI request object. + _auth: Validated session. + name: Action base name (with or without ``.conf`` extension). + body: Partial update — lifecycle commands and ``[Init]`` parameters. + reload: When ``true``, trigger a fail2ban reload after writing. + + Returns: + Updated :class:`~app.models.config.ActionConfig`. + + Raises: + HTTPException: 400 if *name* contains invalid characters. + HTTPException: 404 if the action does not exist. + HTTPException: 500 if writing the ``.local`` file fails. + """ + try: + return await action_config_service.update_action(config_dir, socket_path, name, body, do_reload=reload) + except ActionNameError as exc: + raise _bad_request(str(exc)) from exc + except ActionNotFoundError: + raise _action_not_found(name) from None + except ConfigWriteError as exc: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to write action override: {exc}", + ) from exc + + + + +@router.post( + "/actions", + response_model=ActionConfig, + status_code=status.HTTP_201_CREATED, + summary="Create a new user-defined action", +) +async def create_action( + request: Request, + _auth: AuthDep, + config_dir: Fail2BanConfigDirDep, + socket_path: Fail2BanSocketDep, + body: ActionCreateRequest, + reload: bool = Query(default=False, description="Reload fail2ban after creating."), +) -> ActionConfig: + """Create a new user-defined action at ``action.d/{name}.local``. + + Returns 409 if a ``.conf`` or ``.local`` for the requested name already + exists. + + Args: + request: FastAPI request object. + _auth: Validated session. + body: Action name and ``[Definition]`` lifecycle fields. + reload: When ``true``, trigger a fail2ban reload after creating. + + Returns: + :class:`~app.models.config.ActionConfig` for the new action. + + Raises: + HTTPException: 400 if the name contains invalid characters. + HTTPException: 409 if the action already exists. + HTTPException: 500 if writing fails. + """ + try: + return await action_config_service.create_action(config_dir, socket_path, body, do_reload=reload) + except ActionNameError as exc: + raise _bad_request(str(exc)) from exc + except ActionAlreadyExistsError as exc: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail=f"Action {exc.name!r} already exists.", + ) from exc + except ConfigWriteError as exc: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to write action: {exc}", + ) from exc + + + + +@router.delete( + "/actions/{name}", + status_code=status.HTTP_204_NO_CONTENT, + summary="Delete a user-created action's .local file", +) +async def delete_action( + request: Request, + _auth: AuthDep, + config_dir: Fail2BanConfigDirDep, + name: _ActionNamePath, +) -> None: + """Delete a user-created action's ``.local`` override file. + + Shipped ``.conf``-only actions cannot be deleted (returns 409). When + both a ``.conf`` and a ``.local`` exist, only the ``.local`` is removed. + + Args: + request: FastAPI request object. + _auth: Validated session. + name: Action base name. + + Raises: + HTTPException: 400 if *name* contains invalid characters. + HTTPException: 404 if the action does not exist. + HTTPException: 409 if the action is a shipped default (conf-only). + HTTPException: 500 if deletion fails. + """ + try: + await action_config_service.delete_action(config_dir, name) + except ActionNameError as exc: + raise _bad_request(str(exc)) from exc + except ActionNotFoundError: + raise _action_not_found(name) from None + except ActionReadonlyError as exc: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail=str(exc), + ) from exc + except ConfigWriteError as exc: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to delete action: {exc}", + ) from exc + + + + +@router.post( + "/jails/{name}/action", + status_code=status.HTTP_204_NO_CONTENT, + summary="Add an action to a jail", +) +async def assign_action_to_jail( + request: Request, + _auth: AuthDep, + config_dir: Fail2BanConfigDirDep, + socket_path: Fail2BanSocketDep, + name: _NamePath, + body: AssignActionRequest, + reload: bool = Query(default=False, description="Reload fail2ban after assigning."), +) -> None: + """Append an action entry to the jail's ``.local`` config. + + Existing keys in the jail's ``.local`` file are preserved. If the file + does not exist it is created. The action is not duplicated if it is + already present. + + Args: + request: FastAPI request object. + _auth: Validated session. + name: Jail name. + body: Action to add plus optional per-jail parameters. + reload: When ``true``, trigger a fail2ban reload after writing. + + Raises: + HTTPException: 400 if *name* or *action_name* contain invalid characters. + HTTPException: 404 if the jail or action does not exist. + HTTPException: 500 if writing fails. + """ + try: + await action_config_service.assign_action_to_jail(config_dir, socket_path, name, body, do_reload=reload) + except (JailNameError, ActionNameError) as exc: + raise _bad_request(str(exc)) from exc + except JailNotFoundInConfigError: + raise _not_found(name) from None + except ActionNotFoundError as exc: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Action not found: {exc.name!r}", + ) from exc + except ConfigWriteError as exc: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to write jail override: {exc}", + ) from exc + + + + +@router.delete( + "/jails/{name}/action/{action_name}", + status_code=status.HTTP_204_NO_CONTENT, + summary="Remove an action from a jail", +) +async def remove_action_from_jail( + request: Request, + _auth: AuthDep, + config_dir: Fail2BanConfigDirDep, + socket_path: Fail2BanSocketDep, + name: _NamePath, + action_name: Annotated[str, Path(description="Action base name to remove.")], + reload: bool = Query(default=False, description="Reload fail2ban after removing."), +) -> None: + """Remove an action from the jail's ``.local`` config. + + If the jail has no ``.local`` file or the action is not listed there, + the call is silently idempotent. + + Args: + request: FastAPI request object. + _auth: Validated session. + name: Jail name. + action_name: Base name of the action to remove. + reload: When ``true``, trigger a fail2ban reload after writing. + + Raises: + HTTPException: 400 if *name* or *action_name* contain invalid characters. + HTTPException: 404 if the jail is not found in config files. + HTTPException: 500 if writing fails. + """ + try: + await action_config_service.remove_action_from_jail( + config_dir, + socket_path, + name, + action_name, + do_reload=reload, + ) + except (JailNameError, ActionNameError) as exc: + raise _bad_request(str(exc)) from exc + except JailNotFoundInConfigError: + raise _not_found(name) from None + except ConfigWriteError as exc: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to write jail override: {exc}", + ) from exc + + +# --------------------------------------------------------------------------- +# fail2ban log viewer endpoints +# --------------------------------------------------------------------------- + + + + diff --git a/backend/app/routers/config.py b/backend/app/routers/config.py index c5b7415..adf5d43 100644 --- a/backend/app/routers/config.py +++ b/backend/app/routers/config.py @@ -1,89 +1,8 @@ -"""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/jails/inactive`` — list all inactive jails -* ``POST /api/config/jails/{name}/activate`` — activate an inactive jail -* ``POST /api/config/jails/{name}/deactivate`` — deactivate an active jail -* ``POST /api/config/jails/{name}/validate`` — validate jail config pre-activation (Task 3) -* ``POST /api/config/jails/{name}/rollback`` — disable bad jail and restart fail2ban (Task 3) -* ``GET /api/config/pending-recovery`` — active crash-recovery record (Task 3) -* ``POST /api/config/jails/{name}/filter`` — assign a filter to a jail -* ``POST /api/config/jails/{name}/action`` — add an action to a jail -* ``DELETE /api/config/jails/{name}/action/{action_name}`` — remove an action from a jail -* ``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 -* ``GET /api/config/filters`` — list all filters with active/inactive status -* ``GET /api/config/filters/{name}`` — full parsed detail for one filter -* ``PUT /api/config/filters/{name}`` — update a filter's .local override -* ``POST /api/config/filters`` — create a new user-defined filter -* ``DELETE /api/config/filters/{name}`` — delete a filter's .local file -* ``GET /api/config/actions`` — list all actions with active/inactive status -* ``GET /api/config/actions/{name}`` — full parsed detail for one action -* ``PUT /api/config/actions/{name}`` — update an action's .local override -* ``POST /api/config/actions`` — create a new user-defined action -* ``DELETE /api/config/actions/{name}`` — delete an action's .local file -* ``GET /api/config/fail2ban-log`` — read the tail of the fail2ban log file -* ``GET /api/config/service-status`` — fail2ban health + log configuration -""" - from __future__ import annotations -from typing import Annotated +from fastapi import APIRouter -import structlog -from fastapi import APIRouter, HTTPException, Path, Query, Request, status - -from app.dependencies import ( - AppDep, - AuthDep, - DbDep, - Fail2BanConfigDirDep, - Fail2BanSocketDep, - Fail2BanStartCommandDep, - PendingRecoveryDep, -) -from app.exceptions import ConfigOperationError, ConfigValidationError, JailNotFoundError, JailOperationError -from app.models.config import ( - ActionConfig, - ActionCreateRequest, - ActionListResponse, - ActionUpdateRequest, - ActivateJailRequest, - AddLogPathRequest, - AssignActionRequest, - AssignFilterRequest, - Fail2BanLogResponse, - FilterConfig, - FilterCreateRequest, - FilterListResponse, - FilterUpdateRequest, - GlobalConfigResponse, - GlobalConfigUpdate, - InactiveJailListResponse, - JailActivationResponse, - JailConfigListResponse, - JailConfigResponse, - JailConfigUpdate, - JailValidationResult, - LogPreviewRequest, - LogPreviewResponse, - MapColorThresholdsResponse, - MapColorThresholdsUpdate, - PendingRecovery, - RegexTestRequest, - RegexTestResponse, - RollbackResponse, - ServiceStatusResponse, -) +from app.routers import action_config, config_misc, filter_config, jail_config from app.services import ( action_config_service, config_file_service, @@ -92,1572 +11,11 @@ from app.services import ( jail_config_service, jail_service, log_service, -) -from app.services.action_config_service import ( - ActionAlreadyExistsError, - ActionNameError, - ActionNotFoundError, - ActionReadonlyError, - ConfigWriteError, -) -from app.services.filter_config_service import ( - FilterAlreadyExistsError, - FilterInvalidRegexError, - FilterNameError, - FilterNotFoundError, - FilterReadonlyError, -) -from app.services.jail_config_service import ( - JailAlreadyActiveError, - JailAlreadyInactiveError, - JailNameError, - JailNotFoundInConfigError, -) -from app.utils.fail2ban_client import Fail2BanConnectionError - -log: structlog.stdlib.BoundLogger = structlog.get_logger() +) # noqa: F401 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, - socket_path: Fail2BanSocketDep, -) -> 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`. - """ - try: - return await config_service.list_jail_configs(socket_path) - except Fail2BanConnectionError as exc: - raise _bad_gateway(exc) from exc - - -@router.get( - "/jails/inactive", - response_model=InactiveJailListResponse, - summary="List all inactive jails discovered in config files", -) -async def get_inactive_jails( - request: Request, - _auth: AuthDep, - config_dir: Fail2BanConfigDirDep, - socket_path: Fail2BanSocketDep, -) -> InactiveJailListResponse: - """Return all jails defined in fail2ban config files that are not running. - - Parses ``jail.conf``, ``jail.local``, and ``jail.d/`` following the - fail2ban merge order. Jails that fail2ban currently reports as running - are excluded; only truly inactive entries are returned. - - Args: - request: FastAPI request object. - _auth: Validated session — enforces authentication. - - Returns: - :class:`~app.models.config.InactiveJailListResponse`. - """ - return await jail_config_service.list_inactive_jails(config_dir, socket_path) - - -@router.get( - "/jails/{name}", - response_model=JailConfigResponse, - summary="Return configuration for a single jail", -) -async def get_jail_config( - request: Request, - _auth: AuthDep, - socket_path: Fail2BanSocketDep, - 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. - """ - 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, - socket_path: Fail2BanSocketDep, - 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. - """ - 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, - socket_path: Fail2BanSocketDep, -) -> 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. - """ - 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, - socket_path: Fail2BanSocketDep, - 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. - """ - 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, - socket_path: Fail2BanSocketDep, -) -> 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: 409 when fail2ban reports the reload failed. - HTTPException: 502 when fail2ban is unreachable. - """ - try: - await jail_service.reload_all(socket_path) - except JailOperationError as exc: - raise HTTPException( - status_code=status.HTTP_409_CONFLICT, - detail=f"fail2ban reload failed: {exc}", - ) from exc - except Fail2BanConnectionError as exc: - raise _bad_gateway(exc) from exc - - -# Restart endpoint -# --------------------------------------------------------------------------- - - -@router.post( - "/restart", - status_code=status.HTTP_204_NO_CONTENT, - summary="Restart the fail2ban service", -) -async def restart_fail2ban( - request: Request, - _auth: AuthDep, - socket_path: Fail2BanSocketDep, - start_cmd: Fail2BanStartCommandDep, -) -> None: - """Trigger a full fail2ban service restart. - - Stops the fail2ban daemon via the Unix domain socket, then starts it - again using the configured ``fail2ban_start_command``. After starting, - probes the socket for up to 10 seconds to confirm the daemon came back - online. - - Args: - request: Incoming request. - _auth: Validated session. - - Raises: - HTTPException: 409 when fail2ban reports the stop command failed. - HTTPException: 502 when fail2ban is unreachable for the stop command. - HTTPException: 503 when fail2ban does not come back online within - 10 seconds after being started. Check the fail2ban log for - initialisation errors. Use - ``POST /api/config/jails/{name}/rollback`` if a specific jail - is suspect. - """ - start_cmd_parts: list[str] = start_cmd.split() - - # Step 1: stop the daemon via socket. - try: - await jail_service.restart(socket_path) - except JailOperationError as exc: - raise HTTPException( - status_code=status.HTTP_409_CONFLICT, - detail=f"fail2ban stop command failed: {exc}", - ) from exc - except Fail2BanConnectionError as exc: - raise _bad_gateway(exc) from exc - - # Step 2: start the daemon via subprocess. - await config_file_service.start_daemon(start_cmd_parts) - - # Step 3: probe the socket until fail2ban is responsive or the budget expires. - fail2ban_running: bool = await config_file_service.wait_for_fail2ban(socket_path, max_wait_seconds=10.0) - if not fail2ban_running: - raise HTTPException( - status_code=status.HTTP_503_SERVICE_UNAVAILABLE, - detail=( - "fail2ban was stopped but did not come back online within 10 seconds. " - "Check the fail2ban log for initialisation errors. " - "Use POST /api/config/jails/{name}/rollback if a specific jail is suspect." - ), - ) - log.info("fail2ban_restarted") - - -# --------------------------------------------------------------------------- -# 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 log_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, - socket_path: Fail2BanSocketDep, - name: _NamePath, - body: AddLogPathRequest, -) -> None: - """Register an additional log file for an existing jail to monitor. - - Uses ``set addlogpath `` 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. - """ - 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 - - -@router.delete( - "/jails/{name}/logpath", - status_code=status.HTTP_204_NO_CONTENT, - summary="Remove a monitored log path from a jail", -) -async def delete_log_path( - request: Request, - _auth: AuthDep, - socket_path: Fail2BanSocketDep, - name: _NamePath, - log_path: str = Query(..., description="Absolute path of the log file to stop monitoring."), -) -> None: - """Stop a jail from monitoring the specified log file. - - Uses ``set dellogpath `` to remove the log path at runtime - without requiring a daemon restart. - - Args: - request: Incoming request. - _auth: Validated session. - name: Jail name. - log_path: Absolute path to the log file to remove (query parameter). - - Raises: - HTTPException: 404 when the jail does not exist. - HTTPException: 400 when the command is rejected. - HTTPException: 502 when fail2ban is unreachable. - """ - try: - await config_service.delete_log_path(socket_path, name, log_path) - 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 - - -@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 log_service.preview_log(body) - - -# --------------------------------------------------------------------------- -# Map color thresholds -# --------------------------------------------------------------------------- - - -@router.get( - "/map-color-thresholds", - response_model=MapColorThresholdsResponse, - summary="Get map color threshold configuration", -) -async def get_map_color_thresholds( - request: Request, - _auth: AuthDep, - db: DbDep, -) -> MapColorThresholdsResponse: - """Return the configured map color thresholds. - - Args: - request: FastAPI request object. - _auth: Validated session. - - Returns: - :class:`~app.models.config.MapColorThresholdsResponse` with - current thresholds. - """ - from app.services import setup_service - - high, medium, low = await setup_service.get_map_color_thresholds(db) - return MapColorThresholdsResponse( - threshold_high=high, - threshold_medium=medium, - threshold_low=low, - ) - - -@router.put( - "/map-color-thresholds", - response_model=MapColorThresholdsResponse, - summary="Update map color threshold configuration", -) -async def update_map_color_thresholds( - request: Request, - _auth: AuthDep, - db: DbDep, - body: MapColorThresholdsUpdate, -) -> MapColorThresholdsResponse: - """Update the map color threshold configuration. - - Args: - request: FastAPI request object. - _auth: Validated session. - body: New threshold values. - - Returns: - :class:`~app.models.config.MapColorThresholdsResponse` with - updated thresholds. - - Raises: - HTTPException: 400 if validation fails (thresholds not - properly ordered). - """ - from app.services import setup_service - - try: - await setup_service.set_map_color_thresholds( - db, - threshold_high=body.threshold_high, - threshold_medium=body.threshold_medium, - threshold_low=body.threshold_low, - ) - except ValueError as exc: - raise _bad_request(str(exc)) from exc - - return MapColorThresholdsResponse( - threshold_high=body.threshold_high, - threshold_medium=body.threshold_medium, - threshold_low=body.threshold_low, - ) - - -@router.post( - "/jails/{name}/activate", - response_model=JailActivationResponse, - summary="Activate an inactive jail", -) -async def activate_jail( - app: AppDep, - _auth: AuthDep, - config_dir: Fail2BanConfigDirDep, - socket_path: Fail2BanSocketDep, - name: _NamePath, - body: ActivateJailRequest | None = None, -) -> JailActivationResponse: - """Enable an inactive jail and reload fail2ban. - - Writes ``enabled = true`` (plus any override values from the request - body) to ``jail.d/{name}.local`` and triggers a full fail2ban reload so - the jail starts immediately. - - Args: - app: FastAPI application instance. - _auth: Validated session. - name: Name of the jail to activate. - body: Optional override values (bantime, findtime, maxretry, port, - logpath). - - Returns: - :class:`~app.models.config.JailActivationResponse`. - - Raises: - HTTPException: 400 if *name* contains invalid characters. - HTTPException: 404 if *name* is not found in any config file. - HTTPException: 409 if the jail is already active. - HTTPException: 502 if fail2ban is unreachable. - """ - req = body if body is not None else ActivateJailRequest() - - try: - result = await jail_config_service.activate_jail(app, config_dir, socket_path, name, req) - except JailNameError as exc: - raise _bad_request(str(exc)) from exc - except JailNotFoundInConfigError: - raise _not_found(name) from None - except JailAlreadyActiveError: - raise HTTPException( - status_code=status.HTTP_409_CONFLICT, - detail=f"Jail {name!r} is already active.", - ) from None - except ConfigWriteError as exc: - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=f"Failed to write config override: {exc}", - ) from exc - except Fail2BanConnectionError as exc: - raise _bad_gateway(exc) from exc - - return result - - -@router.post( - "/jails/{name}/deactivate", - response_model=JailActivationResponse, - summary="Deactivate an active jail", -) -async def deactivate_jail( - app: AppDep, - _auth: AuthDep, - config_dir: Fail2BanConfigDirDep, - socket_path: Fail2BanSocketDep, - name: _NamePath, -) -> JailActivationResponse: - """Disable an active jail and reload fail2ban. - - Writes ``enabled = false`` to ``jail.d/{name}.local`` and triggers a - full fail2ban reload so the jail stops immediately. - - Args: - app: FastAPI application instance. - _auth: Validated session. - name: Name of the jail to deactivate. - - Returns: - :class:`~app.models.config.JailActivationResponse`. - - Raises: - HTTPException: 400 if *name* contains invalid characters. - HTTPException: 404 if *name* is not found in any config file. - HTTPException: 409 if the jail is already inactive. - HTTPException: 502 if fail2ban is unreachable. - """ - - try: - result = await jail_config_service.deactivate_jail(app, config_dir, socket_path, name) - except JailNameError as exc: - raise _bad_request(str(exc)) from exc - except JailNotFoundInConfigError: - raise _not_found(name) from None - except JailAlreadyInactiveError: - raise HTTPException( - status_code=status.HTTP_409_CONFLICT, - detail=f"Jail {name!r} is already inactive.", - ) from None - except ConfigWriteError as exc: - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=f"Failed to write config override: {exc}", - ) from exc - except Fail2BanConnectionError as exc: - raise _bad_gateway(exc) from exc - - return result - - -@router.delete( - "/jails/{name}/local", - status_code=status.HTTP_204_NO_CONTENT, - summary="Delete the jail.d override file for an inactive jail", -) -async def delete_jail_local_override( - request: Request, - _auth: AuthDep, - config_dir: Fail2BanConfigDirDep, - socket_path: Fail2BanSocketDep, - name: _NamePath, -) -> None: - """Remove the ``jail.d/{name}.local`` override file for an inactive jail. - - This endpoint is the clean-up action for inactive jails that still carry - a ``.local`` override file (e.g. one written with ``enabled = false`` by a - previous deactivation). The file is deleted without modifying fail2ban's - running state, since the jail is already inactive. - - Args: - request: FastAPI request object. - _auth: Validated session. - name: Name of the jail whose ``.local`` file should be removed. - - Raises: - HTTPException: 400 if *name* contains invalid characters. - HTTPException: 404 if *name* is not found in any config file. - HTTPException: 409 if the jail is currently active. - HTTPException: 500 if the file cannot be deleted. - HTTPException: 502 if fail2ban is unreachable. - """ - - try: - await jail_config_service.delete_jail_local_override(config_dir, socket_path, name) - except JailNameError as exc: - raise _bad_request(str(exc)) from exc - except JailNotFoundInConfigError: - raise _not_found(name) from None - except JailAlreadyActiveError: - raise HTTPException( - status_code=status.HTTP_409_CONFLICT, - detail=f"Jail {name!r} is currently active; deactivate it first.", - ) from None - except ConfigWriteError as exc: - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=f"Failed to delete config override: {exc}", - ) from exc - except Fail2BanConnectionError as exc: - raise _bad_gateway(exc) from exc - - -# --------------------------------------------------------------------------- -# Jail validation & rollback endpoints (Task 3) -# --------------------------------------------------------------------------- - - -@router.post( - "/jails/{name}/validate", - response_model=JailValidationResult, - summary="Validate jail configuration before activation", -) -async def validate_jail( - request: Request, - _auth: AuthDep, - config_dir: Fail2BanConfigDirDep, - name: _NamePath, -) -> JailValidationResult: - """Run pre-activation validation checks on a jail configuration. - - Validates filter and action file existence, regex pattern compilation, and - log path existence without modifying any files or reloading fail2ban. - - Args: - request: FastAPI request object. - _auth: Validated session. - name: Jail name to validate. - - Returns: - :class:`~app.models.config.JailValidationResult` with any issues found. - - Raises: - HTTPException: 400 if *name* contains invalid characters. - HTTPException: 404 if *name* is not found in any config file. - """ - try: - return await jail_config_service.validate_jail_config(config_dir, name) - except JailNameError as exc: - raise _bad_request(str(exc)) from exc - - -@router.get( - "/pending-recovery", - response_model=PendingRecovery | None, - summary="Return active crash-recovery record if one exists", -) -async def get_pending_recovery( - _auth: AuthDep, - pending_recovery: PendingRecoveryDep, -) -> PendingRecovery | None: - """Return the current :class:`~app.models.config.PendingRecovery` record. - - A non-null response means fail2ban crashed shortly after a jail activation - and the user should be offered a rollback option. Returns ``null`` (HTTP - 200 with ``null`` body) when no recovery is pending. - - Args: - request: FastAPI request object. - _auth: Validated session. - - Returns: - :class:`~app.models.config.PendingRecovery` or ``None``. - """ - return pending_recovery - - -@router.post( - "/jails/{name}/rollback", - response_model=RollbackResponse, - summary="Disable a bad jail config and restart fail2ban", -) -async def rollback_jail( - _auth: AuthDep, - app: AppDep, - config_dir: Fail2BanConfigDirDep, - socket_path: Fail2BanSocketDep, - start_cmd: Fail2BanStartCommandDep, - name: _NamePath, -) -> RollbackResponse: - """Disable the specified jail and attempt to restart fail2ban. - - Writes ``enabled = false`` to ``jail.d/{name}.local`` (works even when - fail2ban is down — no socket is needed), then runs the configured start - command and waits up to ten seconds for the daemon to come back online. - - On success, clears the :class:`~app.models.config.PendingRecovery` record. - - Args: - _auth: Validated session. - app: FastAPI application instance. - name: Jail name to disable and roll back. - - Returns: - :class:`~app.models.config.RollbackResponse`. - - Raises: - HTTPException: 400 if *name* contains invalid characters. - HTTPException: 500 if writing the .local override file fails. - """ - start_cmd_parts: list[str] = start_cmd.split() - - try: - result = await jail_config_service.rollback_jail(app, config_dir, socket_path, name, start_cmd_parts) - except JailNameError as exc: - raise _bad_request(str(exc)) from exc - except ConfigWriteError as exc: - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=f"Failed to write config override: {exc}", - ) from exc - - return result - - -# --------------------------------------------------------------------------- -# Filter discovery endpoints (Task 2.1) -# --------------------------------------------------------------------------- - - -@router.get( - "/filters", - response_model=FilterListResponse, - summary="List all available filters with active/inactive status", -) -async def list_filters( - request: Request, - _auth: AuthDep, - config_dir: Fail2BanConfigDirDep, - socket_path: Fail2BanSocketDep, -) -> FilterListResponse: - """Return all filters discovered in ``filter.d/`` with active/inactive status. - - Scans ``{config_dir}/filter.d/`` for ``.conf`` files, merges any - corresponding ``.local`` overrides, and cross-references each filter's - name against the ``filter`` fields of currently running jails to determine - whether it is active. - - Active filters (those used by at least one running jail) are sorted to the - top of the list; inactive filters follow. Both groups are sorted - alphabetically within themselves. - - Args: - request: FastAPI request object. - _auth: Validated session — enforces authentication. - - Returns: - :class:`~app.models.config.FilterListResponse` with all discovered - filters. - """ - result = await filter_config_service.list_filters(config_dir, socket_path) - # Sort: active first (by name), then inactive (by name). - result.filters.sort(key=lambda f: (not f.active, f.name.lower())) - return result - - -@router.get( - "/filters/{name}", - response_model=FilterConfig, - summary="Return full parsed detail for a single filter", -) -async def get_filter( - request: Request, - _auth: AuthDep, - config_dir: Fail2BanConfigDirDep, - socket_path: Fail2BanSocketDep, - name: Annotated[str, Path(description="Filter base name, e.g. ``sshd`` or ``sshd.conf``.")], -) -> FilterConfig: - """Return the full parsed configuration and active/inactive status for one filter. - - Reads ``{config_dir}/filter.d/{name}.conf``, merges any corresponding - ``.local`` override, and annotates the result with ``active``, - ``used_by_jails``, ``source_file``, and ``has_local_override``. - - Args: - request: FastAPI request object. - _auth: Validated session — enforces authentication. - name: Filter base name (with or without ``.conf`` extension). - - Returns: - :class:`~app.models.config.FilterConfig`. - - Raises: - HTTPException: 404 if the filter is not found in ``filter.d/``. - HTTPException: 502 if fail2ban is unreachable. - """ - try: - return await filter_config_service.get_filter(config_dir, socket_path, name) - except FilterNotFoundError: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail=f"Filter not found: {name!r}", - ) from None - - -# --------------------------------------------------------------------------- -# Filter write endpoints (Task 2.2) -# --------------------------------------------------------------------------- - - -_FilterNamePath = Annotated[ - str, - Path(description="Filter base name, e.g. ``sshd`` or ``sshd.conf``."), -] - - -def _filter_not_found(name: str) -> HTTPException: - return HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail=f"Filter not found: {name!r}", - ) - - -@router.put( - "/filters/{name}", - response_model=FilterConfig, - summary="Update a filter's .local override with new regex/pattern values", -) -async def update_filter( - request: Request, - _auth: AuthDep, - config_dir: Fail2BanConfigDirDep, - socket_path: Fail2BanSocketDep, - name: _FilterNamePath, - body: FilterUpdateRequest, - reload: bool = Query(default=False, description="Reload fail2ban after writing."), -) -> FilterConfig: - """Update a filter's ``[Definition]`` fields by writing a ``.local`` override. - - All regex patterns are validated before writing. The original ``.conf`` - file is never modified. Fields left as ``null`` in the request body are - kept at their current values. - - Args: - request: FastAPI request object. - _auth: Validated session. - name: Filter base name (with or without ``.conf`` extension). - body: Partial update — ``failregex``, ``ignoreregex``, ``datepattern``, - ``journalmatch``. - reload: When ``true``, trigger a fail2ban reload after writing. - - Returns: - Updated :class:`~app.models.config.FilterConfig`. - - Raises: - HTTPException: 400 if *name* contains invalid characters. - HTTPException: 404 if the filter does not exist. - HTTPException: 422 if any regex pattern fails to compile. - HTTPException: 500 if writing the ``.local`` file fails. - """ - try: - return await filter_config_service.update_filter(config_dir, socket_path, name, body, do_reload=reload) - except FilterNameError as exc: - raise _bad_request(str(exc)) from exc - except FilterNotFoundError: - raise _filter_not_found(name) from None - except FilterInvalidRegexError as exc: - raise _unprocessable(str(exc)) from exc - except ConfigWriteError as exc: - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=f"Failed to write filter override: {exc}", - ) from exc - - -@router.post( - "/filters", - response_model=FilterConfig, - status_code=status.HTTP_201_CREATED, - summary="Create a new user-defined filter", -) -async def create_filter( - request: Request, - _auth: AuthDep, - config_dir: Fail2BanConfigDirDep, - socket_path: Fail2BanSocketDep, - body: FilterCreateRequest, - reload: bool = Query(default=False, description="Reload fail2ban after creating."), -) -> FilterConfig: - """Create a new user-defined filter at ``filter.d/{name}.local``. - - The filter is created as a ``.local`` file so it can coexist safely with - shipped ``.conf`` files. Returns 409 if a ``.conf`` or ``.local`` for - the requested name already exists. - - Args: - request: FastAPI request object. - _auth: Validated session. - body: Filter name and ``[Definition]`` fields. - reload: When ``true``, trigger a fail2ban reload after creating. - - Returns: - :class:`~app.models.config.FilterConfig` for the new filter. - - Raises: - HTTPException: 400 if the name contains invalid characters. - HTTPException: 409 if the filter already exists. - HTTPException: 422 if any regex pattern is invalid. - HTTPException: 500 if writing fails. - """ - try: - return await filter_config_service.create_filter(config_dir, socket_path, body, do_reload=reload) - except FilterNameError as exc: - raise _bad_request(str(exc)) from exc - except FilterAlreadyExistsError as exc: - raise HTTPException( - status_code=status.HTTP_409_CONFLICT, - detail=f"Filter {exc.name!r} already exists.", - ) from exc - except FilterInvalidRegexError as exc: - raise _unprocessable(str(exc)) from exc - except ConfigWriteError as exc: - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=f"Failed to write filter: {exc}", - ) from exc - - -@router.delete( - "/filters/{name}", - status_code=status.HTTP_204_NO_CONTENT, - summary="Delete a user-created filter's .local file", -) -async def delete_filter( - request: Request, - _auth: AuthDep, - config_dir: Fail2BanConfigDirDep, - name: _FilterNamePath, -) -> None: - """Delete a user-created filter's ``.local`` override file. - - Shipped ``.conf``-only filters cannot be deleted (returns 409). When - both a ``.conf`` and a ``.local`` exist, only the ``.local`` is removed. - When only a ``.local`` exists (user-created filter), the file is deleted - entirely. - - Args: - request: FastAPI request object. - _auth: Validated session. - name: Filter base name. - - Raises: - HTTPException: 400 if *name* contains invalid characters. - HTTPException: 404 if the filter does not exist. - HTTPException: 409 if the filter is a shipped default (conf-only). - HTTPException: 500 if deletion fails. - """ - try: - await filter_config_service.delete_filter(config_dir, name) - except FilterNameError as exc: - raise _bad_request(str(exc)) from exc - except FilterNotFoundError: - raise _filter_not_found(name) from None - except FilterReadonlyError as exc: - raise HTTPException( - status_code=status.HTTP_409_CONFLICT, - detail=str(exc), - ) from exc - except ConfigWriteError as exc: - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=f"Failed to delete filter: {exc}", - ) from exc - - -@router.post( - "/jails/{name}/filter", - status_code=status.HTTP_204_NO_CONTENT, - summary="Assign a filter to a jail", -) -async def assign_filter_to_jail( - request: Request, - _auth: AuthDep, - config_dir: Fail2BanConfigDirDep, - socket_path: Fail2BanSocketDep, - name: _NamePath, - body: AssignFilterRequest, - reload: bool = Query(default=False, description="Reload fail2ban after assigning."), -) -> None: - """Write ``filter = {filter_name}`` to the jail's ``.local`` config. - - Existing keys in the jail's ``.local`` file are preserved. If the file - does not exist it is created. - - Args: - request: FastAPI request object. - _auth: Validated session. - name: Jail name. - body: Filter to assign. - reload: When ``true``, trigger a fail2ban reload after writing. - - Raises: - HTTPException: 400 if *name* or *filter_name* contain invalid characters. - HTTPException: 404 if the jail or filter does not exist. - HTTPException: 500 if writing fails. - """ - try: - await filter_config_service.assign_filter_to_jail(config_dir, socket_path, name, body, do_reload=reload) - except (JailNameError, FilterNameError) as exc: - raise _bad_request(str(exc)) from exc - except JailNotFoundInConfigError: - raise _not_found(name) from None - except FilterNotFoundError as exc: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail=f"Filter not found: {exc.name!r}", - ) from exc - except ConfigWriteError as exc: - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=f"Failed to write jail override: {exc}", - ) from exc - - -# --------------------------------------------------------------------------- -# Action discovery endpoints (Task 3.1) -# --------------------------------------------------------------------------- - -_ActionNamePath = Annotated[ - str, - Path(description="Action base name, e.g. ``iptables`` or ``iptables.conf``."), -] - - -def _action_not_found(name: str) -> HTTPException: - return HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail=f"Action not found: {name!r}", - ) - - -@router.get( - "/actions", - response_model=ActionListResponse, - summary="List all available actions with active/inactive status", -) -async def list_actions( - request: Request, - _auth: AuthDep, - config_dir: Fail2BanConfigDirDep, - socket_path: Fail2BanSocketDep, -) -> ActionListResponse: - """Return all actions discovered in ``action.d/`` with active/inactive status. - - Scans ``{config_dir}/action.d/`` for ``.conf`` files, merges any - corresponding ``.local`` overrides, and cross-references each action's - name against the ``action`` fields of currently running jails to determine - whether it is active. - - Active actions (those used by at least one running jail) are sorted to the - top of the list; inactive actions follow. Both groups are sorted - alphabetically within themselves. - - Args: - request: FastAPI request object. - _auth: Validated session — enforces authentication. - - Returns: - :class:`~app.models.config.ActionListResponse` with all discovered - actions. - """ - result = await action_config_service.list_actions(config_dir, socket_path) - result.actions.sort(key=lambda a: (not a.active, a.name.lower())) - return result - - -@router.get( - "/actions/{name}", - response_model=ActionConfig, - summary="Return full parsed detail for a single action", -) -async def get_action( - request: Request, - _auth: AuthDep, - config_dir: Fail2BanConfigDirDep, - socket_path: Fail2BanSocketDep, - name: _ActionNamePath, -) -> ActionConfig: - """Return the full parsed configuration and active/inactive status for one action. - - Reads ``{config_dir}/action.d/{name}.conf``, merges any corresponding - ``.local`` override, and annotates the result with ``active``, - ``used_by_jails``, ``source_file``, and ``has_local_override``. - - Args: - request: FastAPI request object. - _auth: Validated session — enforces authentication. - name: Action base name (with or without ``.conf`` extension). - - Returns: - :class:`~app.models.config.ActionConfig`. - - Raises: - HTTPException: 404 if the action is not found in ``action.d/``. - """ - try: - return await action_config_service.get_action(config_dir, socket_path, name) - except ActionNotFoundError: - raise _action_not_found(name) from None - - -# --------------------------------------------------------------------------- -# Action write endpoints (Task 3.2) -# --------------------------------------------------------------------------- - - -@router.put( - "/actions/{name}", - response_model=ActionConfig, - summary="Update an action's .local override with new lifecycle command values", -) -async def update_action( - request: Request, - _auth: AuthDep, - config_dir: Fail2BanConfigDirDep, - socket_path: Fail2BanSocketDep, - name: _ActionNamePath, - body: ActionUpdateRequest, - reload: bool = Query(default=False, description="Reload fail2ban after writing."), -) -> ActionConfig: - """Update an action's ``[Definition]`` fields by writing a ``.local`` override. - - Only non-``null`` fields in the request body are written. The original - ``.conf`` file is never modified. - - Args: - request: FastAPI request object. - _auth: Validated session. - name: Action base name (with or without ``.conf`` extension). - body: Partial update — lifecycle commands and ``[Init]`` parameters. - reload: When ``true``, trigger a fail2ban reload after writing. - - Returns: - Updated :class:`~app.models.config.ActionConfig`. - - Raises: - HTTPException: 400 if *name* contains invalid characters. - HTTPException: 404 if the action does not exist. - HTTPException: 500 if writing the ``.local`` file fails. - """ - try: - return await action_config_service.update_action(config_dir, socket_path, name, body, do_reload=reload) - except ActionNameError as exc: - raise _bad_request(str(exc)) from exc - except ActionNotFoundError: - raise _action_not_found(name) from None - except ConfigWriteError as exc: - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=f"Failed to write action override: {exc}", - ) from exc - - -@router.post( - "/actions", - response_model=ActionConfig, - status_code=status.HTTP_201_CREATED, - summary="Create a new user-defined action", -) -async def create_action( - request: Request, - _auth: AuthDep, - config_dir: Fail2BanConfigDirDep, - socket_path: Fail2BanSocketDep, - body: ActionCreateRequest, - reload: bool = Query(default=False, description="Reload fail2ban after creating."), -) -> ActionConfig: - """Create a new user-defined action at ``action.d/{name}.local``. - - Returns 409 if a ``.conf`` or ``.local`` for the requested name already - exists. - - Args: - request: FastAPI request object. - _auth: Validated session. - body: Action name and ``[Definition]`` lifecycle fields. - reload: When ``true``, trigger a fail2ban reload after creating. - - Returns: - :class:`~app.models.config.ActionConfig` for the new action. - - Raises: - HTTPException: 400 if the name contains invalid characters. - HTTPException: 409 if the action already exists. - HTTPException: 500 if writing fails. - """ - try: - return await action_config_service.create_action(config_dir, socket_path, body, do_reload=reload) - except ActionNameError as exc: - raise _bad_request(str(exc)) from exc - except ActionAlreadyExistsError as exc: - raise HTTPException( - status_code=status.HTTP_409_CONFLICT, - detail=f"Action {exc.name!r} already exists.", - ) from exc - except ConfigWriteError as exc: - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=f"Failed to write action: {exc}", - ) from exc - - -@router.delete( - "/actions/{name}", - status_code=status.HTTP_204_NO_CONTENT, - summary="Delete a user-created action's .local file", -) -async def delete_action( - request: Request, - _auth: AuthDep, - config_dir: Fail2BanConfigDirDep, - name: _ActionNamePath, -) -> None: - """Delete a user-created action's ``.local`` override file. - - Shipped ``.conf``-only actions cannot be deleted (returns 409). When - both a ``.conf`` and a ``.local`` exist, only the ``.local`` is removed. - - Args: - request: FastAPI request object. - _auth: Validated session. - name: Action base name. - - Raises: - HTTPException: 400 if *name* contains invalid characters. - HTTPException: 404 if the action does not exist. - HTTPException: 409 if the action is a shipped default (conf-only). - HTTPException: 500 if deletion fails. - """ - try: - await action_config_service.delete_action(config_dir, name) - except ActionNameError as exc: - raise _bad_request(str(exc)) from exc - except ActionNotFoundError: - raise _action_not_found(name) from None - except ActionReadonlyError as exc: - raise HTTPException( - status_code=status.HTTP_409_CONFLICT, - detail=str(exc), - ) from exc - except ConfigWriteError as exc: - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=f"Failed to delete action: {exc}", - ) from exc - - -@router.post( - "/jails/{name}/action", - status_code=status.HTTP_204_NO_CONTENT, - summary="Add an action to a jail", -) -async def assign_action_to_jail( - request: Request, - _auth: AuthDep, - config_dir: Fail2BanConfigDirDep, - socket_path: Fail2BanSocketDep, - name: _NamePath, - body: AssignActionRequest, - reload: bool = Query(default=False, description="Reload fail2ban after assigning."), -) -> None: - """Append an action entry to the jail's ``.local`` config. - - Existing keys in the jail's ``.local`` file are preserved. If the file - does not exist it is created. The action is not duplicated if it is - already present. - - Args: - request: FastAPI request object. - _auth: Validated session. - name: Jail name. - body: Action to add plus optional per-jail parameters. - reload: When ``true``, trigger a fail2ban reload after writing. - - Raises: - HTTPException: 400 if *name* or *action_name* contain invalid characters. - HTTPException: 404 if the jail or action does not exist. - HTTPException: 500 if writing fails. - """ - try: - await action_config_service.assign_action_to_jail(config_dir, socket_path, name, body, do_reload=reload) - except (JailNameError, ActionNameError) as exc: - raise _bad_request(str(exc)) from exc - except JailNotFoundInConfigError: - raise _not_found(name) from None - except ActionNotFoundError as exc: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail=f"Action not found: {exc.name!r}", - ) from exc - except ConfigWriteError as exc: - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=f"Failed to write jail override: {exc}", - ) from exc - - -@router.delete( - "/jails/{name}/action/{action_name}", - status_code=status.HTTP_204_NO_CONTENT, - summary="Remove an action from a jail", -) -async def remove_action_from_jail( - request: Request, - _auth: AuthDep, - config_dir: Fail2BanConfigDirDep, - socket_path: Fail2BanSocketDep, - name: _NamePath, - action_name: Annotated[str, Path(description="Action base name to remove.")], - reload: bool = Query(default=False, description="Reload fail2ban after removing."), -) -> None: - """Remove an action from the jail's ``.local`` config. - - If the jail has no ``.local`` file or the action is not listed there, - the call is silently idempotent. - - Args: - request: FastAPI request object. - _auth: Validated session. - name: Jail name. - action_name: Base name of the action to remove. - reload: When ``true``, trigger a fail2ban reload after writing. - - Raises: - HTTPException: 400 if *name* or *action_name* contain invalid characters. - HTTPException: 404 if the jail is not found in config files. - HTTPException: 500 if writing fails. - """ - try: - await action_config_service.remove_action_from_jail( - config_dir, - socket_path, - name, - action_name, - do_reload=reload, - ) - except (JailNameError, ActionNameError) as exc: - raise _bad_request(str(exc)) from exc - except JailNotFoundInConfigError: - raise _not_found(name) from None - except ConfigWriteError as exc: - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=f"Failed to write jail override: {exc}", - ) from exc - - -# --------------------------------------------------------------------------- -# fail2ban log viewer endpoints -# --------------------------------------------------------------------------- - - -@router.get( - "/fail2ban-log", - response_model=Fail2BanLogResponse, - summary="Read the tail of the fail2ban daemon log file", -) -async def get_fail2ban_log( - request: Request, - _auth: AuthDep, - socket_path: Fail2BanSocketDep, - lines: Annotated[int, Query(ge=1, le=2000, description="Number of lines to return from the tail.")] = 200, - filter: Annotated[ # noqa: A002 - str | None, - Query(description="Plain-text substring filter; only matching lines are returned."), - ] = None, -) -> Fail2BanLogResponse: - """Return the tail of the fail2ban daemon log file. - - Queries the fail2ban socket for the current log target and log level, - reads the last *lines* entries from the file, and optionally filters - them by *filter*. Only file-based log targets are supported. - - Args: - request: Incoming request. - _auth: Validated session — enforces authentication. - lines: Number of tail lines to return (1–2000, default 200). - filter: Optional plain-text substring — only matching lines returned. - - Returns: - :class:`~app.models.config.Fail2BanLogResponse`. - - Raises: - HTTPException: 400 when the log target is not a file or path is outside - the allowed directory. - HTTPException: 502 when fail2ban is unreachable. - """ - try: - return await config_service.read_fail2ban_log(socket_path, lines, filter) - except config_service.ConfigOperationError as exc: - raise _bad_request(str(exc)) from exc - except Fail2BanConnectionError as exc: - raise _bad_gateway(exc) from exc - - -@router.get( - "/service-status", - response_model=ServiceStatusResponse, - summary="Return fail2ban service health status with log configuration", -) -async def get_service_status( - request: Request, - _auth: AuthDep, - socket_path: Fail2BanSocketDep, -) -> ServiceStatusResponse: - """Return fail2ban service health and current log configuration. - - Probes the fail2ban daemon to determine online/offline state, then - augments the result with the current log level and log target values. - - Args: - request: Incoming request. - _auth: Validated session — enforces authentication. - - Returns: - :class:`~app.models.config.ServiceStatusResponse`. - - Raises: - HTTPException: 502 when fail2ban is unreachable (the service itself - handles this gracefully and returns ``online=False``). - """ - from app.services import health_service - - try: - return await config_service.get_service_status( - socket_path, - probe_fn=health_service.probe, - ) - except Fail2BanConnectionError as exc: - raise _bad_gateway(exc) from exc +router.include_router(jail_config.router) +router.include_router(filter_config.router) +router.include_router(action_config.router) +router.include_router(config_misc.router) diff --git a/backend/app/routers/config_misc.py b/backend/app/routers/config_misc.py new file mode 100644 index 0000000..483a4af --- /dev/null +++ b/backend/app/routers/config_misc.py @@ -0,0 +1,429 @@ +from __future__ import annotations + +from typing import Annotated + +from fastapi import APIRouter, HTTPException, Query, Request, status + +from app.dependencies import AuthDep, DbDep, Fail2BanSocketDep, Fail2BanStartCommandDep +from app.models.config import ( + Fail2BanLogResponse, + GlobalConfigResponse, + GlobalConfigUpdate, + LogPreviewRequest, + LogPreviewResponse, + MapColorThresholdsResponse, + MapColorThresholdsUpdate, + RegexTestRequest, + RegexTestResponse, + ServiceStatusResponse, +) +from app.services import config_file_service, config_service, jail_service, log_service, setup_service +from app.utils.fail2ban_client import Fail2BanConnectionError + +router: APIRouter = APIRouter() + + +def _bad_gateway(exc: Exception) -> HTTPException: + return HTTPException( + status_code=status.HTTP_502_BAD_GATEWAY, + detail=f"Cannot reach fail2ban: {exc}", + ) + +@router.get( + "/global", + response_model=GlobalConfigResponse, + summary="Return global fail2ban settings", +) +async def get_global_config( + request: Request, + _auth: AuthDep, + socket_path: Fail2BanSocketDep, +) -> 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. + """ + 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, + socket_path: Fail2BanSocketDep, + 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. + """ + 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, + socket_path: Fail2BanSocketDep, +) -> 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: 409 when fail2ban reports the reload failed. + HTTPException: 502 when fail2ban is unreachable. + """ + try: + await jail_service.reload_all(socket_path) + except JailOperationError as exc: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail=f"fail2ban reload failed: {exc}", + ) from exc + except Fail2BanConnectionError as exc: + raise _bad_gateway(exc) from exc + + +# Restart endpoint +# --------------------------------------------------------------------------- + + + + +@router.post( + "/restart", + status_code=status.HTTP_204_NO_CONTENT, + summary="Restart the fail2ban service", +) +async def restart_fail2ban( + request: Request, + _auth: AuthDep, + socket_path: Fail2BanSocketDep, + start_cmd: Fail2BanStartCommandDep, +) -> None: + """Trigger a full fail2ban service restart. + + Stops the fail2ban daemon via the Unix domain socket, then starts it + again using the configured ``fail2ban_start_command``. After starting, + probes the socket for up to 10 seconds to confirm the daemon came back + online. + + Args: + request: Incoming request. + _auth: Validated session. + + Raises: + HTTPException: 409 when fail2ban reports the stop command failed. + HTTPException: 502 when fail2ban is unreachable for the stop command. + HTTPException: 503 when fail2ban does not come back online within + 10 seconds after being started. Check the fail2ban log for + initialisation errors. Use + ``POST /api/config/jails/{name}/rollback`` if a specific jail + is suspect. + """ + start_cmd_parts: list[str] = start_cmd.split() + + # Step 1: stop the daemon via socket. + try: + await jail_service.restart(socket_path) + except JailOperationError as exc: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail=f"fail2ban stop command failed: {exc}", + ) from exc + except Fail2BanConnectionError as exc: + raise _bad_gateway(exc) from exc + + # Step 2: start the daemon via subprocess. + await config_file_service.start_daemon(start_cmd_parts) + + # Step 3: probe the socket until fail2ban is responsive or the budget expires. + fail2ban_running: bool = await config_file_service.wait_for_fail2ban(socket_path, max_wait_seconds=10.0) + if not fail2ban_running: + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail=( + "fail2ban was stopped but did not come back online within 10 seconds. " + "Check the fail2ban log for initialisation errors. " + "Use POST /api/config/jails/{name}/rollback if a specific jail is suspect." + ), + ) + log.info("fail2ban_restarted") + + +# --------------------------------------------------------------------------- +# 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 log_service.test_regex(body) + + +# --------------------------------------------------------------------------- +# Log path management +# --------------------------------------------------------------------------- + + + + +@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 log_service.preview_log(body) + + +# --------------------------------------------------------------------------- +# Map color thresholds +# --------------------------------------------------------------------------- + + + + +@router.get( + "/map-color-thresholds", + response_model=MapColorThresholdsResponse, + summary="Get map color threshold configuration", +) +async def get_map_color_thresholds( + request: Request, + _auth: AuthDep, + db: DbDep, +) -> MapColorThresholdsResponse: + """Return the configured map color thresholds. + + Args: + request: FastAPI request object. + _auth: Validated session. + + Returns: + :class:`~app.models.config.MapColorThresholdsResponse` with + current thresholds. + """ + from app.services import setup_service + + high, medium, low = await setup_service.get_map_color_thresholds(db) + return MapColorThresholdsResponse( + threshold_high=high, + threshold_medium=medium, + threshold_low=low, + ) + + + + +@router.put( + "/map-color-thresholds", + response_model=MapColorThresholdsResponse, + summary="Update map color threshold configuration", +) +async def update_map_color_thresholds( + request: Request, + _auth: AuthDep, + db: DbDep, + body: MapColorThresholdsUpdate, +) -> MapColorThresholdsResponse: + """Update the map color threshold configuration. + + Args: + request: FastAPI request object. + _auth: Validated session. + body: New threshold values. + + Returns: + :class:`~app.models.config.MapColorThresholdsResponse` with + updated thresholds. + + Raises: + HTTPException: 400 if validation fails (thresholds not + properly ordered). + """ + from app.services import setup_service + + try: + await setup_service.set_map_color_thresholds( + db, + threshold_high=body.threshold_high, + threshold_medium=body.threshold_medium, + threshold_low=body.threshold_low, + ) + except ValueError as exc: + raise _bad_request(str(exc)) from exc + + return MapColorThresholdsResponse( + threshold_high=body.threshold_high, + threshold_medium=body.threshold_medium, + threshold_low=body.threshold_low, + ) + + + + +@router.get( + "/fail2ban-log", + response_model=Fail2BanLogResponse, + summary="Read the tail of the fail2ban daemon log file", +) +async def get_fail2ban_log( + request: Request, + _auth: AuthDep, + socket_path: Fail2BanSocketDep, + lines: Annotated[int, Query(ge=1, le=2000, description="Number of lines to return from the tail.")] = 200, + filter: Annotated[ # noqa: A002 + str | None, + Query(description="Plain-text substring filter; only matching lines are returned."), + ] = None, +) -> Fail2BanLogResponse: + """Return the tail of the fail2ban daemon log file. + + Queries the fail2ban socket for the current log target and log level, + reads the last *lines* entries from the file, and optionally filters + them by *filter*. Only file-based log targets are supported. + + Args: + request: Incoming request. + _auth: Validated session — enforces authentication. + lines: Number of tail lines to return (1–2000, default 200). + filter: Optional plain-text substring — only matching lines returned. + + Returns: + :class:`~app.models.config.Fail2BanLogResponse`. + + Raises: + HTTPException: 400 when the log target is not a file or path is outside + the allowed directory. + HTTPException: 502 when fail2ban is unreachable. + """ + try: + return await config_service.read_fail2ban_log(socket_path, lines, filter) + except config_service.ConfigOperationError as exc: + raise _bad_request(str(exc)) from exc + except Fail2BanConnectionError as exc: + raise _bad_gateway(exc) from exc + + + + +@router.get( + "/service-status", + response_model=ServiceStatusResponse, + summary="Return fail2ban service health status with log configuration", +) +async def get_service_status( + request: Request, + _auth: AuthDep, + socket_path: Fail2BanSocketDep, +) -> ServiceStatusResponse: + """Return fail2ban service health and current log configuration. + + Probes the fail2ban daemon to determine online/offline state, then + augments the result with the current log level and log target values. + + Args: + request: Incoming request. + _auth: Validated session — enforces authentication. + + Returns: + :class:`~app.models.config.ServiceStatusResponse`. + + Raises: + HTTPException: 502 when fail2ban is unreachable (the service itself + handles this gracefully and returns ``online=False``). + """ + from app.services import health_service + + try: + return await config_service.get_service_status( + socket_path, + probe_fn=health_service.probe, + ) + except Fail2BanConnectionError as exc: + raise _bad_gateway(exc) from exc + + diff --git a/backend/app/routers/filter_config.py b/backend/app/routers/filter_config.py new file mode 100644 index 0000000..7be7a77 --- /dev/null +++ b/backend/app/routers/filter_config.py @@ -0,0 +1,367 @@ +from __future__ import annotations + +from typing import Annotated + +from fastapi import APIRouter, HTTPException, Path, Query, status + +from app.dependencies import AuthDep, Fail2BanConfigDirDep, Fail2BanSocketDep +from app.services.config_file_service import ConfigWriteError +from app.models.config import ( + AssignFilterRequest, + FilterConfig, + FilterConfigUpdate, + FilterCreateRequest, + FilterListResponse, +) +from app.services import filter_config_service +from app.services.filter_config_service import ( + FilterAlreadyExistsError, + FilterInvalidRegexError, + FilterNameError, + FilterNotFoundError, + FilterReadonlyError, +) +from app.services.jail_config_service import JailNameError, JailNotFoundInConfigError + +router: APIRouter = APIRouter() + +_NamePath = Annotated[ + str, + Path(description='Jail name as configured in fail2ban.'), +] + +_FilterNamePath = Annotated[ + str, + Path(description='Filter base name, e.g. ``sshd`` or ``sshd.conf``.'), +] + + +def _filter_not_found(name: str) -> HTTPException: + return HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Filter not found: {name!r}", + ) + +@router.get( + "/filters", + response_model=FilterListResponse, + summary="List all available filters with active/inactive status", +) +async def list_filters( + request: Request, + _auth: AuthDep, + config_dir: Fail2BanConfigDirDep, + socket_path: Fail2BanSocketDep, +) -> FilterListResponse: + """Return all filters discovered in ``filter.d/`` with active/inactive status. + + Scans ``{config_dir}/filter.d/`` for ``.conf`` files, merges any + corresponding ``.local`` overrides, and cross-references each filter's + name against the ``filter`` fields of currently running jails to determine + whether it is active. + + Active filters (those used by at least one running jail) are sorted to the + top of the list; inactive filters follow. Both groups are sorted + alphabetically within themselves. + + Args: + request: FastAPI request object. + _auth: Validated session — enforces authentication. + + Returns: + :class:`~app.models.config.FilterListResponse` with all discovered + filters. + """ + result = await filter_config_service.list_filters(config_dir, socket_path) + # Sort: active first (by name), then inactive (by name). + result.filters.sort(key=lambda f: (not f.active, f.name.lower())) + return result + + + + +@router.get( + "/filters/{name}", + response_model=FilterConfig, + summary="Return full parsed detail for a single filter", +) +async def get_filter( + request: Request, + _auth: AuthDep, + config_dir: Fail2BanConfigDirDep, + socket_path: Fail2BanSocketDep, + name: Annotated[str, Path(description="Filter base name, e.g. ``sshd`` or ``sshd.conf``.")], +) -> FilterConfig: + """Return the full parsed configuration and active/inactive status for one filter. + + Reads ``{config_dir}/filter.d/{name}.conf``, merges any corresponding + ``.local`` override, and annotates the result with ``active``, + ``used_by_jails``, ``source_file``, and ``has_local_override``. + + Args: + request: FastAPI request object. + _auth: Validated session — enforces authentication. + name: Filter base name (with or without ``.conf`` extension). + + Returns: + :class:`~app.models.config.FilterConfig`. + + Raises: + HTTPException: 404 if the filter is not found in ``filter.d/``. + HTTPException: 502 if fail2ban is unreachable. + """ + try: + return await filter_config_service.get_filter(config_dir, socket_path, name) + except FilterNotFoundError: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Filter not found: {name!r}", + ) from None + + +# --------------------------------------------------------------------------- +# Filter write endpoints (Task 2.2) +# --------------------------------------------------------------------------- + + +_FilterNamePath = Annotated[ + str, + Path(description="Filter base name, e.g. ``sshd`` or ``sshd.conf``."), +] + + +def _filter_not_found(name: str) -> HTTPException: + return HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Filter not found: {name!r}", + ) + + + + +@router.put( + "/filters/{name}", + response_model=FilterConfig, + summary="Update a filter's .local override with new regex/pattern values", +) +async def update_filter( + request: Request, + _auth: AuthDep, + config_dir: Fail2BanConfigDirDep, + socket_path: Fail2BanSocketDep, + name: _FilterNamePath, + body: FilterUpdateRequest, + reload: bool = Query(default=False, description="Reload fail2ban after writing."), +) -> FilterConfig: + """Update a filter's ``[Definition]`` fields by writing a ``.local`` override. + + All regex patterns are validated before writing. The original ``.conf`` + file is never modified. Fields left as ``null`` in the request body are + kept at their current values. + + Args: + request: FastAPI request object. + _auth: Validated session. + name: Filter base name (with or without ``.conf`` extension). + body: Partial update — ``failregex``, ``ignoreregex``, ``datepattern``, + ``journalmatch``. + reload: When ``true``, trigger a fail2ban reload after writing. + + Returns: + Updated :class:`~app.models.config.FilterConfig`. + + Raises: + HTTPException: 400 if *name* contains invalid characters. + HTTPException: 404 if the filter does not exist. + HTTPException: 422 if any regex pattern fails to compile. + HTTPException: 500 if writing the ``.local`` file fails. + """ + try: + return await filter_config_service.update_filter(config_dir, socket_path, name, body, do_reload=reload) + except FilterNameError as exc: + raise _bad_request(str(exc)) from exc + except FilterNotFoundError: + raise _filter_not_found(name) from None + except FilterInvalidRegexError as exc: + raise _unprocessable(str(exc)) from exc + except ConfigWriteError as exc: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to write filter override: {exc}", + ) from exc + + + + +@router.post( + "/filters", + response_model=FilterConfig, + status_code=status.HTTP_201_CREATED, + summary="Create a new user-defined filter", +) +async def create_filter( + request: Request, + _auth: AuthDep, + config_dir: Fail2BanConfigDirDep, + socket_path: Fail2BanSocketDep, + body: FilterCreateRequest, + reload: bool = Query(default=False, description="Reload fail2ban after creating."), +) -> FilterConfig: + """Create a new user-defined filter at ``filter.d/{name}.local``. + + The filter is created as a ``.local`` file so it can coexist safely with + shipped ``.conf`` files. Returns 409 if a ``.conf`` or ``.local`` for + the requested name already exists. + + Args: + request: FastAPI request object. + _auth: Validated session. + body: Filter name and ``[Definition]`` fields. + reload: When ``true``, trigger a fail2ban reload after creating. + + Returns: + :class:`~app.models.config.FilterConfig` for the new filter. + + Raises: + HTTPException: 400 if the name contains invalid characters. + HTTPException: 409 if the filter already exists. + HTTPException: 422 if any regex pattern is invalid. + HTTPException: 500 if writing fails. + """ + try: + return await filter_config_service.create_filter(config_dir, socket_path, body, do_reload=reload) + except FilterNameError as exc: + raise _bad_request(str(exc)) from exc + except FilterAlreadyExistsError as exc: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail=f"Filter {exc.name!r} already exists.", + ) from exc + except FilterInvalidRegexError as exc: + raise _unprocessable(str(exc)) from exc + except ConfigWriteError as exc: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to write filter: {exc}", + ) from exc + + + + +@router.delete( + "/filters/{name}", + status_code=status.HTTP_204_NO_CONTENT, + summary="Delete a user-created filter's .local file", +) +async def delete_filter( + request: Request, + _auth: AuthDep, + config_dir: Fail2BanConfigDirDep, + name: _FilterNamePath, +) -> None: + """Delete a user-created filter's ``.local`` override file. + + Shipped ``.conf``-only filters cannot be deleted (returns 409). When + both a ``.conf`` and a ``.local`` exist, only the ``.local`` is removed. + When only a ``.local`` exists (user-created filter), the file is deleted + entirely. + + Args: + request: FastAPI request object. + _auth: Validated session. + name: Filter base name. + + Raises: + HTTPException: 400 if *name* contains invalid characters. + HTTPException: 404 if the filter does not exist. + HTTPException: 409 if the filter is a shipped default (conf-only). + HTTPException: 500 if deletion fails. + """ + try: + await filter_config_service.delete_filter(config_dir, name) + except FilterNameError as exc: + raise _bad_request(str(exc)) from exc + except FilterNotFoundError: + raise _filter_not_found(name) from None + except FilterReadonlyError as exc: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail=str(exc), + ) from exc + except ConfigWriteError as exc: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to delete filter: {exc}", + ) from exc + + + + +@router.post( + "/jails/{name}/filter", + status_code=status.HTTP_204_NO_CONTENT, + summary="Assign a filter to a jail", +) +async def assign_filter_to_jail( + request: Request, + _auth: AuthDep, + config_dir: Fail2BanConfigDirDep, + socket_path: Fail2BanSocketDep, + name: _NamePath, + body: AssignFilterRequest, + reload: bool = Query(default=False, description="Reload fail2ban after assigning."), +) -> None: + """Write ``filter = {filter_name}`` to the jail's ``.local`` config. + + Existing keys in the jail's ``.local`` file are preserved. If the file + does not exist it is created. + + Args: + request: FastAPI request object. + _auth: Validated session. + name: Jail name. + body: Filter to assign. + reload: When ``true``, trigger a fail2ban reload after writing. + + Raises: + HTTPException: 400 if *name* or *filter_name* contain invalid characters. + HTTPException: 404 if the jail or filter does not exist. + HTTPException: 500 if writing fails. + """ + try: + await filter_config_service.assign_filter_to_jail(config_dir, socket_path, name, body, do_reload=reload) + except (JailNameError, FilterNameError) as exc: + raise _bad_request(str(exc)) from exc + except JailNotFoundInConfigError: + raise _not_found(name) from None + except FilterNotFoundError as exc: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Filter not found: {exc.name!r}", + ) from exc + except ConfigWriteError as exc: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to write jail override: {exc}", + ) from exc + + +# --------------------------------------------------------------------------- +# Action discovery endpoints (Task 3.1) +# --------------------------------------------------------------------------- + +_ActionNamePath = Annotated[ + str, + Path(description="Action base name, e.g. ``iptables`` or ``iptables.conf``."), +] + + +def _action_not_found(name: str) -> HTTPException: + return HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Action not found: {name!r}", + ) + + + + diff --git a/backend/app/routers/jail_config.py b/backend/app/routers/jail_config.py new file mode 100644 index 0000000..81cb03b --- /dev/null +++ b/backend/app/routers/jail_config.py @@ -0,0 +1,589 @@ +from __future__ import annotations + +from typing import Annotated + +from fastapi import APIRouter, HTTPException, Path, Query, Request, status + +from app.dependencies import ( + AppDep, + AuthDep, + Fail2BanConfigDirDep, + Fail2BanSocketDep, + Fail2BanStartCommandDep, + PendingRecoveryDep, +) +from app.exceptions import ( + ConfigOperationError, + ConfigValidationError, + JailNotFoundError, +) +from app.models.config import ( + ActivateJailRequest, + AddLogPathRequest, + InactiveJailListResponse, + JailActivationResponse, + JailConfigListResponse, + JailConfigResponse, + JailConfigUpdate, + JailValidationResult, + PendingRecovery, + RollbackResponse, +) +from app.services import config_service, jail_config_service, jail_service +from app.services.jail_config_service import ( + ConfigWriteError, + JailAlreadyActiveError, + JailAlreadyInactiveError, + JailNameError, + JailNotFoundInConfigError, +) +from app.utils.fail2ban_client import Fail2BanConnectionError + +router: APIRouter = APIRouter() + +_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, + ) + +@router.get( + "/jails", + response_model=JailConfigListResponse, + summary="List configuration for all active jails", +) +async def get_jail_configs( + request: Request, + _auth: AuthDep, + socket_path: Fail2BanSocketDep, +) -> 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`. + """ + try: + return await config_service.list_jail_configs(socket_path) + except Fail2BanConnectionError as exc: + raise _bad_gateway(exc) from exc + + + + +@router.get( + "/jails/inactive", + response_model=InactiveJailListResponse, + summary="List all inactive jails discovered in config files", +) +async def get_inactive_jails( + request: Request, + _auth: AuthDep, + config_dir: Fail2BanConfigDirDep, + socket_path: Fail2BanSocketDep, +) -> InactiveJailListResponse: + """Return all jails defined in fail2ban config files that are not running. + + Parses ``jail.conf``, ``jail.local``, and ``jail.d/`` following the + fail2ban merge order. Jails that fail2ban currently reports as running + are excluded; only truly inactive entries are returned. + + Args: + request: FastAPI request object. + _auth: Validated session — enforces authentication. + + Returns: + :class:`~app.models.config.InactiveJailListResponse`. + """ + return await jail_config_service.list_inactive_jails(config_dir, socket_path) + + + + +@router.get( + "/jails/{name}", + response_model=JailConfigResponse, + summary="Return configuration for a single jail", +) +async def get_jail_config( + request: Request, + _auth: AuthDep, + socket_path: Fail2BanSocketDep, + 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. + """ + 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, + socket_path: Fail2BanSocketDep, + 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. + """ + 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.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, + socket_path: Fail2BanSocketDep, + name: _NamePath, + body: AddLogPathRequest, +) -> None: + """Register an additional log file for an existing jail to monitor. + + Uses ``set addlogpath `` 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. + """ + 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 + + + + +@router.delete( + "/jails/{name}/logpath", + status_code=status.HTTP_204_NO_CONTENT, + summary="Remove a monitored log path from a jail", +) +async def delete_log_path( + request: Request, + _auth: AuthDep, + socket_path: Fail2BanSocketDep, + name: _NamePath, + log_path: str = Query(..., description="Absolute path of the log file to stop monitoring."), +) -> None: + """Stop a jail from monitoring the specified log file. + + Uses ``set dellogpath `` to remove the log path at runtime + without requiring a daemon restart. + + Args: + request: Incoming request. + _auth: Validated session. + name: Jail name. + log_path: Absolute path to the log file to remove (query parameter). + + Raises: + HTTPException: 404 when the jail does not exist. + HTTPException: 400 when the command is rejected. + HTTPException: 502 when fail2ban is unreachable. + """ + try: + await config_service.delete_log_path(socket_path, name, log_path) + 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 + + + + +@router.post( + "/jails/{name}/activate", + response_model=JailActivationResponse, + summary="Activate an inactive jail", +) +async def activate_jail( + app: AppDep, + _auth: AuthDep, + config_dir: Fail2BanConfigDirDep, + socket_path: Fail2BanSocketDep, + name: _NamePath, + body: ActivateJailRequest | None = None, +) -> JailActivationResponse: + """Enable an inactive jail and reload fail2ban. + + Writes ``enabled = true`` (plus any override values from the request + body) to ``jail.d/{name}.local`` and triggers a full fail2ban reload so + the jail starts immediately. + + Args: + app: FastAPI application instance. + _auth: Validated session. + name: Name of the jail to activate. + body: Optional override values (bantime, findtime, maxretry, port, + logpath). + + Returns: + :class:`~app.models.config.JailActivationResponse`. + + Raises: + HTTPException: 400 if *name* contains invalid characters. + HTTPException: 404 if *name* is not found in any config file. + HTTPException: 409 if the jail is already active. + HTTPException: 502 if fail2ban is unreachable. + """ + req = body if body is not None else ActivateJailRequest() + + try: + result = await jail_config_service.activate_jail(app, config_dir, socket_path, name, req) + except JailNameError as exc: + raise _bad_request(str(exc)) from exc + except JailNotFoundInConfigError: + raise _not_found(name) from None + except JailAlreadyActiveError: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail=f"Jail {name!r} is already active.", + ) from None + except ConfigWriteError as exc: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to write config override: {exc}", + ) from exc + except Fail2BanConnectionError as exc: + raise _bad_gateway(exc) from exc + + return result + + + + +@router.post( + "/jails/{name}/deactivate", + response_model=JailActivationResponse, + summary="Deactivate an active jail", +) +async def deactivate_jail( + app: AppDep, + _auth: AuthDep, + config_dir: Fail2BanConfigDirDep, + socket_path: Fail2BanSocketDep, + name: _NamePath, +) -> JailActivationResponse: + """Disable an active jail and reload fail2ban. + + Writes ``enabled = false`` to ``jail.d/{name}.local`` and triggers a + full fail2ban reload so the jail stops immediately. + + Args: + app: FastAPI application instance. + _auth: Validated session. + name: Name of the jail to deactivate. + + Returns: + :class:`~app.models.config.JailActivationResponse`. + + Raises: + HTTPException: 400 if *name* contains invalid characters. + HTTPException: 404 if *name* is not found in any config file. + HTTPException: 409 if the jail is already inactive. + HTTPException: 502 if fail2ban is unreachable. + """ + + try: + result = await jail_config_service.deactivate_jail(app, config_dir, socket_path, name) + except JailNameError as exc: + raise _bad_request(str(exc)) from exc + except JailNotFoundInConfigError: + raise _not_found(name) from None + except JailAlreadyInactiveError: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail=f"Jail {name!r} is already inactive.", + ) from None + except ConfigWriteError as exc: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to write config override: {exc}", + ) from exc + except Fail2BanConnectionError as exc: + raise _bad_gateway(exc) from exc + + return result + + + + +@router.delete( + "/jails/{name}/local", + status_code=status.HTTP_204_NO_CONTENT, + summary="Delete the jail.d override file for an inactive jail", +) +async def delete_jail_local_override( + request: Request, + _auth: AuthDep, + config_dir: Fail2BanConfigDirDep, + socket_path: Fail2BanSocketDep, + name: _NamePath, +) -> None: + """Remove the ``jail.d/{name}.local`` override file for an inactive jail. + + This endpoint is the clean-up action for inactive jails that still carry + a ``.local`` override file (e.g. one written with ``enabled = false`` by a + previous deactivation). The file is deleted without modifying fail2ban's + running state, since the jail is already inactive. + + Args: + request: FastAPI request object. + _auth: Validated session. + name: Name of the jail whose ``.local`` file should be removed. + + Raises: + HTTPException: 400 if *name* contains invalid characters. + HTTPException: 404 if *name* is not found in any config file. + HTTPException: 409 if the jail is currently active. + HTTPException: 500 if the file cannot be deleted. + HTTPException: 502 if fail2ban is unreachable. + """ + + try: + await jail_config_service.delete_jail_local_override(config_dir, socket_path, name) + except JailNameError as exc: + raise _bad_request(str(exc)) from exc + except JailNotFoundInConfigError: + raise _not_found(name) from None + except JailAlreadyActiveError: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail=f"Jail {name!r} is currently active; deactivate it first.", + ) from None + except ConfigWriteError as exc: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to delete config override: {exc}", + ) from exc + except Fail2BanConnectionError as exc: + raise _bad_gateway(exc) from exc + + +# --------------------------------------------------------------------------- +# Jail validation & rollback endpoints (Task 3) +# --------------------------------------------------------------------------- + + + + +@router.post( + "/jails/{name}/validate", + response_model=JailValidationResult, + summary="Validate jail configuration before activation", +) +async def validate_jail( + request: Request, + _auth: AuthDep, + config_dir: Fail2BanConfigDirDep, + name: _NamePath, +) -> JailValidationResult: + """Run pre-activation validation checks on a jail configuration. + + Validates filter and action file existence, regex pattern compilation, and + log path existence without modifying any files or reloading fail2ban. + + Args: + request: FastAPI request object. + _auth: Validated session. + name: Jail name to validate. + + Returns: + :class:`~app.models.config.JailValidationResult` with any issues found. + + Raises: + HTTPException: 400 if *name* contains invalid characters. + HTTPException: 404 if *name* is not found in any config file. + """ + try: + return await jail_config_service.validate_jail_config(config_dir, name) + except JailNameError as exc: + raise _bad_request(str(exc)) from exc + + + + +@router.get( + "/pending-recovery", + response_model=PendingRecovery | None, + summary="Return active crash-recovery record if one exists", +) +async def get_pending_recovery( + _auth: AuthDep, + pending_recovery: PendingRecoveryDep, +) -> PendingRecovery | None: + """Return the current :class:`~app.models.config.PendingRecovery` record. + + A non-null response means fail2ban crashed shortly after a jail activation + and the user should be offered a rollback option. Returns ``null`` (HTTP + 200 with ``null`` body) when no recovery is pending. + + Args: + request: FastAPI request object. + _auth: Validated session. + + Returns: + :class:`~app.models.config.PendingRecovery` or ``None``. + """ + return pending_recovery + + + + +@router.post( + "/jails/{name}/rollback", + response_model=RollbackResponse, + summary="Disable a bad jail config and restart fail2ban", +) +async def rollback_jail( + _auth: AuthDep, + app: AppDep, + config_dir: Fail2BanConfigDirDep, + socket_path: Fail2BanSocketDep, + start_cmd: Fail2BanStartCommandDep, + name: _NamePath, +) -> RollbackResponse: + """Disable the specified jail and attempt to restart fail2ban. + + Writes ``enabled = false`` to ``jail.d/{name}.local`` (works even when + fail2ban is down — no socket is needed), then runs the configured start + command and waits up to ten seconds for the daemon to come back online. + + On success, clears the :class:`~app.models.config.PendingRecovery` record. + + Args: + _auth: Validated session. + app: FastAPI application instance. + name: Jail name to disable and roll back. + + Returns: + :class:`~app.models.config.RollbackResponse`. + + Raises: + HTTPException: 400 if *name* contains invalid characters. + HTTPException: 500 if writing the .local override file fails. + """ + start_cmd_parts: list[str] = start_cmd.split() + + try: + result = await jail_config_service.rollback_jail(app, config_dir, socket_path, name, start_cmd_parts) + except JailNameError as exc: + raise _bad_request(str(exc)) from exc + except ConfigWriteError as exc: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to write config override: {exc}", + ) from exc + + return result + + +# --------------------------------------------------------------------------- +# Filter discovery endpoints (Task 2.1) +# --------------------------------------------------------------------------- + + + +