"""File-based fail2ban configuration router. Provides endpoints to list, view, edit, and create fail2ban configuration files directly on the filesystem (``jail.d/``, ``filter.d/``, ``action.d/``). Endpoints: * ``GET /api/config/jail-files`` — list all jail config files * ``GET /api/config/jail-files/{filename}`` — get one jail config file (with content) * ``PUT /api/config/jail-files/{filename}/enabled`` — enable/disable a jail config * ``GET /api/config/filters`` — list all filter files * ``GET /api/config/filters/{name}`` — get one filter file (with content) * ``PUT /api/config/filters/{name}`` — update a filter file * ``POST /api/config/filters`` — create a new filter file * ``GET /api/config/actions`` — list all action files * ``GET /api/config/actions/{name}`` — get one action file (with content) * ``PUT /api/config/actions/{name}`` — update an action file * ``POST /api/config/actions`` — create a new action file """ from __future__ import annotations from typing import Annotated from fastapi import APIRouter, HTTPException, Path, Request, status from app.dependencies import AuthDep from app.models.file_config import ( ConfFileContent, ConfFileCreateRequest, ConfFilesResponse, ConfFileUpdateRequest, JailConfigFileContent, JailConfigFileEnabledUpdate, JailConfigFilesResponse, ) from app.services import file_config_service from app.services.file_config_service import ( ConfigDirError, ConfigFileExistsError, ConfigFileNameError, ConfigFileNotFoundError, ConfigFileWriteError, ) router: APIRouter = APIRouter(prefix="/api/config", tags=["Config"]) # --------------------------------------------------------------------------- # Path type aliases # --------------------------------------------------------------------------- _FilenamePath = Annotated[ str, Path(description="Config filename including extension (e.g. ``sshd.conf``).") ] _NamePath = Annotated[ str, Path(description="Base name with or without extension (e.g. ``sshd`` or ``sshd.conf``).") ] # --------------------------------------------------------------------------- # Error helpers # --------------------------------------------------------------------------- def _not_found(filename: str) -> HTTPException: return HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=f"Config file not found: {filename!r}", ) def _bad_request(message: str) -> HTTPException: return HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=message, ) def _conflict(filename: str) -> HTTPException: return HTTPException( status_code=status.HTTP_409_CONFLICT, detail=f"Config file already exists: {filename!r}", ) def _service_unavailable(message: str) -> HTTPException: return HTTPException( status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail=message, ) # --------------------------------------------------------------------------- # Jail config file endpoints (Task 4a) # --------------------------------------------------------------------------- @router.get( "/jail-files", response_model=JailConfigFilesResponse, summary="List all jail config files", ) async def list_jail_config_files( request: Request, _auth: AuthDep, ) -> JailConfigFilesResponse: """Return metadata for every ``.conf`` and ``.local`` file in ``jail.d/``. The ``enabled`` field reflects the value of the ``enabled`` key inside the file (defaulting to ``true`` when the key is absent). Args: request: Incoming request (used for ``app.state.settings``). _auth: Validated session — enforces authentication. Returns: :class:`~app.models.file_config.JailConfigFilesResponse`. """ config_dir: str = request.app.state.settings.fail2ban_config_dir try: return await file_config_service.list_jail_config_files(config_dir) except ConfigDirError as exc: raise _service_unavailable(str(exc)) from exc @router.get( "/jail-files/{filename}", response_model=JailConfigFileContent, summary="Return a single jail config file with its content", ) async def get_jail_config_file( request: Request, _auth: AuthDep, filename: _FilenamePath, ) -> JailConfigFileContent: """Return the metadata and raw content of one jail config file. Args: request: Incoming request. _auth: Validated session. filename: Filename including extension (e.g. ``sshd.conf``). Returns: :class:`~app.models.file_config.JailConfigFileContent`. Raises: HTTPException: 400 if *filename* is unsafe. HTTPException: 404 if the file does not exist. HTTPException: 503 if the config directory is unavailable. """ config_dir: str = request.app.state.settings.fail2ban_config_dir try: return await file_config_service.get_jail_config_file(config_dir, filename) except ConfigFileNameError as exc: raise _bad_request(str(exc)) from exc except ConfigFileNotFoundError: raise _not_found(filename) from None except ConfigDirError as exc: raise _service_unavailable(str(exc)) from exc @router.put( "/jail-files/{filename}/enabled", status_code=status.HTTP_204_NO_CONTENT, summary="Enable or disable a jail configuration file", ) async def set_jail_config_file_enabled( request: Request, _auth: AuthDep, filename: _FilenamePath, body: JailConfigFileEnabledUpdate, ) -> None: """Set the ``enabled = true/false`` key inside a jail config file. The change modifies the file on disk. You must reload fail2ban (``POST /api/config/reload``) separately for the change to take effect. Args: request: Incoming request. _auth: Validated session. filename: Filename of the jail config file (e.g. ``sshd.conf``). body: New enabled state. Raises: HTTPException: 400 if *filename* is unsafe or the operation fails. HTTPException: 404 if the file does not exist. HTTPException: 503 if the config directory is unavailable. """ config_dir: str = request.app.state.settings.fail2ban_config_dir try: await file_config_service.set_jail_config_enabled( config_dir, filename, body.enabled ) except ConfigFileNameError as exc: raise _bad_request(str(exc)) from exc except ConfigFileNotFoundError: raise _not_found(filename) from None except ConfigFileWriteError as exc: raise _bad_request(str(exc)) from exc except ConfigDirError as exc: raise _service_unavailable(str(exc)) from exc # --------------------------------------------------------------------------- # Filter file endpoints (Task 4d) # --------------------------------------------------------------------------- @router.get( "/filters", response_model=ConfFilesResponse, summary="List all filter definition files", ) async def list_filter_files( request: Request, _auth: AuthDep, ) -> ConfFilesResponse: """Return a list of every ``.conf`` and ``.local`` file in ``filter.d/``. Args: request: Incoming request. _auth: Validated session. Returns: :class:`~app.models.file_config.ConfFilesResponse`. """ config_dir: str = request.app.state.settings.fail2ban_config_dir try: return await file_config_service.list_filter_files(config_dir) except ConfigDirError as exc: raise _service_unavailable(str(exc)) from exc @router.get( "/filters/{name}", response_model=ConfFileContent, summary="Return a filter definition file with its content", ) async def get_filter_file( request: Request, _auth: AuthDep, name: _NamePath, ) -> ConfFileContent: """Return the content of a filter definition file. Args: request: Incoming request. _auth: Validated session. name: Base name with or without extension (e.g. ``sshd`` or ``sshd.conf``). Returns: :class:`~app.models.file_config.ConfFileContent`. Raises: HTTPException: 400 if *name* is unsafe. HTTPException: 404 if the file does not exist. HTTPException: 503 if the config directory is unavailable. """ config_dir: str = request.app.state.settings.fail2ban_config_dir try: return await file_config_service.get_filter_file(config_dir, name) except ConfigFileNameError as exc: raise _bad_request(str(exc)) from exc except ConfigFileNotFoundError: raise _not_found(name) from None except ConfigDirError as exc: raise _service_unavailable(str(exc)) from exc @router.put( "/filters/{name}", status_code=status.HTTP_204_NO_CONTENT, summary="Update a filter definition file", ) async def write_filter_file( request: Request, _auth: AuthDep, name: _NamePath, body: ConfFileUpdateRequest, ) -> None: """Overwrite the content of an existing filter definition file. Args: request: Incoming request. _auth: Validated session. name: Base name with or without extension. body: New file content. Raises: HTTPException: 400 if *name* is unsafe or content exceeds the size limit. HTTPException: 404 if the file does not exist. HTTPException: 503 if the config directory is unavailable. """ config_dir: str = request.app.state.settings.fail2ban_config_dir try: await file_config_service.write_filter_file(config_dir, name, body) except ConfigFileNameError as exc: raise _bad_request(str(exc)) from exc except ConfigFileNotFoundError: raise _not_found(name) from None except ConfigFileWriteError as exc: raise _bad_request(str(exc)) from exc except ConfigDirError as exc: raise _service_unavailable(str(exc)) from exc @router.post( "/filters", status_code=status.HTTP_201_CREATED, response_model=ConfFileContent, summary="Create a new filter definition file", ) async def create_filter_file( request: Request, _auth: AuthDep, body: ConfFileCreateRequest, ) -> ConfFileContent: """Create a new ``.conf`` file in ``filter.d/``. Args: request: Incoming request. _auth: Validated session. body: Name and initial content for the new file. Returns: The created :class:`~app.models.file_config.ConfFileContent`. Raises: HTTPException: 400 if *name* is invalid or content exceeds limit. HTTPException: 409 if a file with that name already exists. HTTPException: 503 if the config directory is unavailable. """ config_dir: str = request.app.state.settings.fail2ban_config_dir try: filename = await file_config_service.create_filter_file(config_dir, body) except ConfigFileNameError as exc: raise _bad_request(str(exc)) from exc except ConfigFileExistsError: raise _conflict(body.name) from None except ConfigFileWriteError as exc: raise _bad_request(str(exc)) from exc except ConfigDirError as exc: raise _service_unavailable(str(exc)) from exc return ConfFileContent( name=body.name, filename=filename, content=body.content, ) # --------------------------------------------------------------------------- # Action file endpoints (Task 4e) # --------------------------------------------------------------------------- @router.get( "/actions", response_model=ConfFilesResponse, summary="List all action definition files", ) async def list_action_files( request: Request, _auth: AuthDep, ) -> ConfFilesResponse: """Return a list of every ``.conf`` and ``.local`` file in ``action.d/``. Args: request: Incoming request. _auth: Validated session. Returns: :class:`~app.models.file_config.ConfFilesResponse`. """ config_dir: str = request.app.state.settings.fail2ban_config_dir try: return await file_config_service.list_action_files(config_dir) except ConfigDirError as exc: raise _service_unavailable(str(exc)) from exc @router.get( "/actions/{name}", response_model=ConfFileContent, summary="Return an action definition file with its content", ) async def get_action_file( request: Request, _auth: AuthDep, name: _NamePath, ) -> ConfFileContent: """Return the content of an action definition file. Args: request: Incoming request. _auth: Validated session. name: Base name with or without extension. Returns: :class:`~app.models.file_config.ConfFileContent`. Raises: HTTPException: 400 if *name* is unsafe. HTTPException: 404 if the file does not exist. HTTPException: 503 if the config directory is unavailable. """ config_dir: str = request.app.state.settings.fail2ban_config_dir try: return await file_config_service.get_action_file(config_dir, name) except ConfigFileNameError as exc: raise _bad_request(str(exc)) from exc except ConfigFileNotFoundError: raise _not_found(name) from None except ConfigDirError as exc: raise _service_unavailable(str(exc)) from exc @router.put( "/actions/{name}", status_code=status.HTTP_204_NO_CONTENT, summary="Update an action definition file", ) async def write_action_file( request: Request, _auth: AuthDep, name: _NamePath, body: ConfFileUpdateRequest, ) -> None: """Overwrite the content of an existing action definition file. Args: request: Incoming request. _auth: Validated session. name: Base name with or without extension. body: New file content. Raises: HTTPException: 400 if *name* is unsafe or content exceeds the size limit. HTTPException: 404 if the file does not exist. HTTPException: 503 if the config directory is unavailable. """ config_dir: str = request.app.state.settings.fail2ban_config_dir try: await file_config_service.write_action_file(config_dir, name, body) except ConfigFileNameError as exc: raise _bad_request(str(exc)) from exc except ConfigFileNotFoundError: raise _not_found(name) from None except ConfigFileWriteError as exc: raise _bad_request(str(exc)) from exc except ConfigDirError as exc: raise _service_unavailable(str(exc)) from exc @router.post( "/actions", status_code=status.HTTP_201_CREATED, response_model=ConfFileContent, summary="Create a new action definition file", ) async def create_action_file( request: Request, _auth: AuthDep, body: ConfFileCreateRequest, ) -> ConfFileContent: """Create a new ``.conf`` file in ``action.d/``. Args: request: Incoming request. _auth: Validated session. body: Name and initial content for the new file. Returns: The created :class:`~app.models.file_config.ConfFileContent`. Raises: HTTPException: 400 if *name* is invalid or content exceeds limit. HTTPException: 409 if a file with that name already exists. HTTPException: 503 if the config directory is unavailable. """ config_dir: str = request.app.state.settings.fail2ban_config_dir try: filename = await file_config_service.create_action_file(config_dir, body) except ConfigFileNameError as exc: raise _bad_request(str(exc)) from exc except ConfigFileExistsError: raise _conflict(body.name) from None except ConfigFileWriteError as exc: raise _bad_request(str(exc)) from exc except ConfigDirError as exc: raise _service_unavailable(str(exc)) from exc return ConfFileContent( name=body.name, filename=filename, content=body.content, )