"""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}`` — overwrite a jail config file * ``PUT /api/config/jail-files/{filename}/enabled`` — enable/disable a jail config * ``GET /api/config/filters/{name}/raw`` — get one filter file raw content * ``PUT /api/config/filters/{name}/raw`` — update a filter file (raw content) * ``POST /api/config/filters/raw`` — create a new filter file (raw content) * ``GET /api/config/filters/{name}/parsed`` — parse a filter file into a structured model * ``PUT /api/config/filters/{name}/parsed`` — update a filter file from a structured model * ``GET /api/config/actions`` — list all action files * ``GET /api/config/actions/{name}/raw`` — get one action file (raw content) * ``PUT /api/config/actions/{name}/raw`` — update an action file (raw content) * ``POST /api/config/actions`` — create a new action file * ``GET /api/config/actions/{name}/parsed`` — parse an action file into a structured model * ``PUT /api/config/actions/{name}/parsed`` — update an action file from a structured model Note: ``GET /api/config/filters`` (enriched list) and ``GET /api/config/filters/{name}`` (full parsed detail) are handled by the config router (``config.py``), which is registered first and therefore takes precedence. Raw-content read/write variants are at ``/filters/{name}/raw`` and ``POST /filters/raw``. """ from __future__ import annotations from typing import Annotated from fastapi import APIRouter, Path, status from app.dependencies import AuthDep, Fail2BanConfigDirDep from app.models.config import ( ActionConfig, ActionConfigUpdate, FilterConfig, FilterConfigUpdate, JailFileConfig, JailFileConfigUpdate, ) from app.models.file_config import ( ConfFileContent, ConfFileCreateRequest, ConfFilesResponse, ConfFileUpdateRequest, JailConfigFileContent, JailConfigFileEnabledUpdate, JailConfigFilesResponse, ) from app.services import raw_config_io_service router: APIRouter = APIRouter(prefix="/api/v1/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``).") ] # --------------------------------------------------------------------------- # Jail config file endpoints (Task 4a) # --------------------------------------------------------------------------- @router.get( "/jail-files", response_model=JailConfigFilesResponse, summary="List all jail config files", responses={ 200: {"description": "Jail config files returned", "model": JailConfigFilesResponse}, 401: {"description": "Session missing, expired, or invalid"}, 503: {"description": "Config directory unavailable"}, }, ) async def list_jail_config_files( config_dir: Fail2BanConfigDirDep, _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: config_dir: Config directory path injected from application settings. _auth: Validated session — enforces authentication. Returns: :class:`~app.models.file_config.JailConfigFilesResponse`. """ return await raw_config_io_service.list_jail_config_files(config_dir) @router.get( "/jail-files/{filename}", response_model=JailConfigFileContent, summary="Return a single jail config file with its content", responses={ 200: {"description": "Jail config file returned", "model": JailConfigFileContent}, 400: {"description": "Filename unsafe"}, 401: {"description": "Session missing, expired, or invalid"}, 404: {"description": "File not found"}, 503: {"description": "Config directory unavailable"}, }, ) async def get_jail_config_file( config_dir: Fail2BanConfigDirDep, _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. """ return await raw_config_io_service.get_jail_config_file(config_dir, filename) @router.put( "/jail-files/{filename}", status_code=status.HTTP_204_NO_CONTENT, summary="Overwrite a jail.d config file with new raw content", responses={ 204: {"description": "File overwritten successfully"}, 400: {"description": "Filename unsafe or content invalid"}, 401: {"description": "Session missing, expired, or invalid"}, 404: {"description": "File not found"}, 503: {"description": "Config directory unavailable"}, }, ) async def write_jail_config_file( config_dir: Fail2BanConfigDirDep, _auth: AuthDep, filename: _FilenamePath, body: ConfFileUpdateRequest, ) -> None: """Overwrite the raw content of an existing jail.d config file. The change is written directly to 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 raw file content. Raises: HTTPException: 400 if *filename* is unsafe or content is invalid. HTTPException: 404 if the file does not exist. HTTPException: 503 if the config directory is unavailable. """ await raw_config_io_service.write_jail_config_file(config_dir, filename, body) @router.put( "/jail-files/{filename}/enabled", status_code=status.HTTP_204_NO_CONTENT, summary="Enable or disable a jail configuration file", responses={ 204: {"description": "Enabled state updated successfully"}, 400: {"description": "Filename unsafe or operation failed"}, 401: {"description": "Session missing, expired, or invalid"}, 404: {"description": "File not found"}, 503: {"description": "Config directory unavailable"}, }, ) async def set_jail_config_file_enabled( config_dir: Fail2BanConfigDirDep, _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. """ await raw_config_io_service.set_jail_config_enabled( config_dir, filename, body.enabled ) @router.post( "/jail-files", response_model=ConfFileContent, status_code=status.HTTP_201_CREATED, summary="Create a new jail.d config file", responses={ 201: {"description": "File created", "model": ConfFileContent}, 400: {"description": "Name unsafe or content exceeds size limit"}, 401: {"description": "Session missing, expired, or invalid"}, 409: {"description": "File with that name already exists"}, 503: {"description": "Config directory unavailable"}, }, ) async def create_jail_config_file( config_dir: Fail2BanConfigDirDep, _auth: AuthDep, body: ConfFileCreateRequest, ) -> ConfFileContent: """Create a new ``.conf`` file in ``jail.d/``. Args: request: Incoming request. _auth: Validated session. body: :class:`~app.models.file_config.ConfFileCreateRequest` with name and content. Returns: :class:`~app.models.file_config.ConfFileContent` with the created file metadata. Raises: HTTPException: 400 if the name is unsafe or the content exceeds the size limit. HTTPException: 409 if a file with that name already exists. HTTPException: 503 if the config directory is unavailable. """ filename = await raw_config_io_service.create_jail_config_file(config_dir, body) return ConfFileContent( name=body.name, filename=filename, content=body.content, ) # --------------------------------------------------------------------------- # Filter file endpoints (Task 4d) # --------------------------------------------------------------------------- @router.get( "/filters/{name}/raw", response_model=ConfFileContent, summary="Return a filter definition file's raw content", responses={ 200: {"description": "Filter file returned", "model": ConfFileContent}, 400: {"description": "Name unsafe"}, 401: {"description": "Session missing, expired, or invalid"}, 404: {"description": "File not found"}, 503: {"description": "Config directory unavailable"}, }, ) async def get_filter_file_raw( config_dir: Fail2BanConfigDirDep, _auth: AuthDep, name: _NamePath, ) -> ConfFileContent: """Return the raw content of a filter definition file. This endpoint provides direct access to the file bytes for the raw config editor. For structured parsing with active/inactive status use ``GET /api/config/filters/{name}`` (served by the config router). 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. """ return await raw_config_io_service.get_filter_file(config_dir, name) @router.put( "/filters/{name}/raw", status_code=status.HTTP_204_NO_CONTENT, summary="Update a filter definition file (raw content)", responses={ 204: {"description": "Filter file updated successfully"}, 400: {"description": "Name unsafe or content exceeds size limit"}, 401: {"description": "Session missing, expired, or invalid"}, 404: {"description": "File not found"}, 503: {"description": "Config directory unavailable"}, }, ) async def write_filter_file( config_dir: Fail2BanConfigDirDep, _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. """ await raw_config_io_service.write_filter_file(config_dir, name, body) @router.post( "/filters/raw", status_code=status.HTTP_201_CREATED, response_model=ConfFileContent, summary="Create a new filter definition file (raw content)", responses={ 201: {"description": "Filter file created", "model": ConfFileContent}, 400: {"description": "Name invalid or content exceeds limit"}, 401: {"description": "Session missing, expired, or invalid"}, 409: {"description": "File with that name already exists"}, 503: {"description": "Config directory unavailable"}, }, ) async def create_filter_file( config_dir: Fail2BanConfigDirDep, _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. """ filename = await raw_config_io_service.create_filter_file(config_dir, body) 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", responses={ 200: {"description": "Action files returned", "model": ConfFilesResponse}, 401: {"description": "Session missing, expired, or invalid"}, 503: {"description": "Config directory unavailable"}, }, ) async def list_action_files( config_dir: Fail2BanConfigDirDep, _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`. """ return await raw_config_io_service.list_action_files(config_dir) @router.get( "/actions/{name}/raw", response_model=ConfFileContent, summary="Return an action definition file with its content", responses={ 200: {"description": "Action file returned", "model": ConfFileContent}, 400: {"description": "Name unsafe"}, 401: {"description": "Session missing, expired, or invalid"}, 404: {"description": "File not found"}, 503: {"description": "Config directory unavailable"}, }, ) async def get_action_file( config_dir: Fail2BanConfigDirDep, _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. """ return await raw_config_io_service.get_action_file(config_dir, name) @router.put( "/actions/{name}/raw", status_code=status.HTTP_204_NO_CONTENT, summary="Update an action definition file", responses={ 204: {"description": "Action file updated successfully"}, 400: {"description": "Name unsafe or content exceeds size limit"}, 401: {"description": "Session missing, expired, or invalid"}, 404: {"description": "File not found"}, 503: {"description": "Config directory unavailable"}, }, ) async def write_action_file( config_dir: Fail2BanConfigDirDep, _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. """ await raw_config_io_service.write_action_file(config_dir, name, body) @router.post( "/actions", status_code=status.HTTP_201_CREATED, response_model=ConfFileContent, summary="Create a new action definition file", responses={ 201: {"description": "Action file created", "model": ConfFileContent}, 400: {"description": "Name invalid or content exceeds limit"}, 401: {"description": "Session missing, expired, or invalid"}, 409: {"description": "File with that name already exists"}, 503: {"description": "Config directory unavailable"}, }, ) async def create_action_file( config_dir: Fail2BanConfigDirDep, _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. """ filename = await raw_config_io_service.create_action_file(config_dir, body) return ConfFileContent( name=body.name, filename=filename, content=body.content, ) # --------------------------------------------------------------------------- # Parsed filter endpoints (Task 2.1) # --------------------------------------------------------------------------- @router.get( "/filters/{name}/parsed", response_model=FilterConfig, summary="Return a filter file parsed into a structured model", responses={ 200: {"description": "Filter config returned", "model": FilterConfig}, 400: {"description": "Name unsafe"}, 401: {"description": "Session missing, expired, or invalid"}, 404: {"description": "Filter file not found"}, 503: {"description": "Config directory unavailable"}, }, ) async def get_parsed_filter( config_dir: Fail2BanConfigDirDep, _auth: AuthDep, name: _NamePath, ) -> FilterConfig: """Parse a filter definition file and return its structured fields. The file is read from ``filter.d/``, parsed as fail2ban INI format, and returned as a :class:`~app.models.config.FilterConfig` JSON object. This is the input model for the form-based filter editor (Task 2.3). Args: request: Incoming request. _auth: Validated session. name: Base name (e.g. ``sshd`` or ``sshd.conf``). Returns: :class:`~app.models.config.FilterConfig`. Raises: HTTPException: 400 if *name* is unsafe. HTTPException: 404 if the file does not exist. HTTPException: 503 if the config directory is unavailable. """ return await raw_config_io_service.get_parsed_filter_file(config_dir, name) @router.put( "/filters/{name}/parsed", status_code=status.HTTP_204_NO_CONTENT, summary="Update a filter file from a structured model", responses={ 204: {"description": "Filter file updated successfully"}, 400: {"description": "Name unsafe or content exceeds size limit"}, 401: {"description": "Session missing, expired, or invalid"}, 404: {"description": "Filter file not found"}, 503: {"description": "Config directory unavailable"}, }, ) async def update_parsed_filter( config_dir: Fail2BanConfigDirDep, _auth: AuthDep, name: _NamePath, body: FilterConfigUpdate, ) -> None: """Apply a partial structured update to a filter definition file. Fields set to ``null`` in the request body are left unchanged. The file is re-serialized to fail2ban INI format after merging. Args: request: Incoming request. _auth: Validated session. name: Base name of the filter to update. body: Partial :class:`~app.models.config.FilterConfigUpdate`. 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. """ await raw_config_io_service.update_parsed_filter_file(config_dir, name, body) # --------------------------------------------------------------------------- # Parsed action endpoints (Task 3.1) # --------------------------------------------------------------------------- @router.get( "/actions/{name}/parsed", response_model=ActionConfig, summary="Return an action file parsed into a structured model", responses={ 200: {"description": "Action config returned", "model": ActionConfig}, 400: {"description": "Name unsafe"}, 401: {"description": "Session missing, expired, or invalid"}, 404: {"description": "Action file not found"}, 503: {"description": "Config directory unavailable"}, }, ) async def get_parsed_action( config_dir: Fail2BanConfigDirDep, _auth: AuthDep, name: _NamePath, ) -> ActionConfig: """Parse an action definition file and return its structured fields. The file is read from ``action.d/``, parsed as fail2ban INI format, and returned as a :class:`~app.models.config.ActionConfig` JSON object. This is the input model for the form-based action editor (Task 3.3). Args: request: Incoming request. _auth: Validated session. name: Base name (e.g. ``iptables`` or ``iptables.conf``). Returns: :class:`~app.models.config.ActionConfig`. Raises: HTTPException: 400 if *name* is unsafe. HTTPException: 404 if the file does not exist. HTTPException: 503 if the config directory is unavailable. """ return await raw_config_io_service.get_parsed_action_file(config_dir, name) @router.put( "/actions/{name}/parsed", status_code=status.HTTP_204_NO_CONTENT, summary="Update an action file from a structured model", responses={ 204: {"description": "Action file updated successfully"}, 400: {"description": "Name unsafe or content exceeds size limit"}, 401: {"description": "Session missing, expired, or invalid"}, 404: {"description": "Action file not found"}, 503: {"description": "Config directory unavailable"}, }, ) async def update_parsed_action( config_dir: Fail2BanConfigDirDep, _auth: AuthDep, name: _NamePath, body: ActionConfigUpdate, ) -> None: """Apply a partial structured update to an action definition file. Fields set to ``null`` in the request body are left unchanged. The file is re-serialized to fail2ban INI format after merging. Args: request: Incoming request. _auth: Validated session. name: Base name of the action to update. body: Partial :class:`~app.models.config.ActionConfigUpdate`. 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. """ await raw_config_io_service.update_parsed_action_file(config_dir, name, body) # --------------------------------------------------------------------------- # Parsed jail file endpoints (Task 6.1) # --------------------------------------------------------------------------- @router.get( "/jail-files/{filename}/parsed", response_model=JailFileConfig, summary="Return a jail.d file parsed into a structured model", responses={ 200: {"description": "Jail file config returned", "model": JailFileConfig}, 400: {"description": "Filename unsafe"}, 401: {"description": "Session missing, expired, or invalid"}, 404: {"description": "Jail file not found"}, 503: {"description": "Config directory unavailable"}, }, ) async def get_parsed_jail_file( config_dir: Fail2BanConfigDirDep, _auth: AuthDep, filename: _NamePath, ) -> JailFileConfig: """Parse a jail.d config file and return its structured fields. The file is read from ``jail.d/``, parsed as fail2ban INI format, and returned as a :class:`~app.models.config.JailFileConfig` JSON object. This is the input model for the form-based jail file editor (Task 6.2). Args: request: Incoming request. _auth: Validated session. filename: Filename including extension (e.g. ``sshd.conf``). Returns: :class:`~app.models.config.JailFileConfig`. Raises: HTTPException: 400 if *filename* is unsafe. HTTPException: 404 if the file does not exist. HTTPException: 503 if the config directory is unavailable. """ return await raw_config_io_service.get_parsed_jail_file(config_dir, filename) @router.put( "/jail-files/{filename}/parsed", status_code=status.HTTP_204_NO_CONTENT, summary="Update a jail.d file from a structured model", responses={ 204: {"description": "Jail file updated successfully"}, 400: {"description": "Filename unsafe or content exceeds size limit"}, 401: {"description": "Session missing, expired, or invalid"}, 404: {"description": "Jail file not found"}, 503: {"description": "Config directory unavailable"}, }, ) async def update_parsed_jail_file( config_dir: Fail2BanConfigDirDep, _auth: AuthDep, filename: _NamePath, body: JailFileConfigUpdate, ) -> None: """Apply a partial structured update to a jail.d config file. Fields set to ``null`` in the request body are left unchanged. The file is re-serialized to fail2ban INI format after merging. Args: request: Incoming request. _auth: Validated session. filename: Filename including extension (e.g. ``sshd.conf``). body: Partial :class:`~app.models.config.JailFileConfigUpdate`. Raises: HTTPException: 400 if *filename* is unsafe or content exceeds size limit. HTTPException: 404 if the file does not exist. HTTPException: 503 if the config directory is unavailable. """ await raw_config_io_service.update_parsed_jail_file(config_dir, filename, body)