diff --git a/backend/app/routers/file_config.py b/backend/app/routers/file_config.py index 668402d..43fd808 100644 --- a/backend/app/routers/file_config.py +++ b/backend/app/routers/file_config.py @@ -11,10 +11,14 @@ Endpoints: * ``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/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}`` — get one action file (with content) * ``PUT /api/config/actions/{name}`` — update an action file * ``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 """ from __future__ import annotations @@ -24,6 +28,14 @@ from typing import Annotated from fastapi import APIRouter, HTTPException, Path, Request, status from app.dependencies import AuthDep +from app.models.config import ( + ActionConfig, + ActionConfigUpdate, + FilterConfig, + FilterConfigUpdate, + JailFileConfig, + JailFileConfigUpdate, +) from app.models.file_config import ( ConfFileContent, ConfFileCreateRequest, @@ -199,6 +211,51 @@ async def set_jail_config_file_enabled( raise _service_unavailable(str(exc)) from exc +@router.post( + "/jail-files", + response_model=ConfFileContent, + status_code=status.HTTP_201_CREATED, + summary="Create a new jail.d config file", +) +async def create_jail_config_file( + request: Request, + _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. + """ + config_dir: str = request.app.state.settings.fail2ban_config_dir + try: + filename = await file_config_service.create_jail_config_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, + ) + + # --------------------------------------------------------------------------- # Filter file endpoints (Task 4d) # --------------------------------------------------------------------------- @@ -493,3 +550,258 @@ async def create_action_file( 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", +) +async def get_parsed_filter( + request: Request, + _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. + """ + config_dir: str = request.app.state.settings.fail2ban_config_dir + try: + return await file_config_service.get_parsed_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}/parsed", + status_code=status.HTTP_204_NO_CONTENT, + summary="Update a filter file from a structured model", +) +async def update_parsed_filter( + request: Request, + _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. + """ + config_dir: str = request.app.state.settings.fail2ban_config_dir + try: + await file_config_service.update_parsed_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 + + +# --------------------------------------------------------------------------- +# Parsed action endpoints (Task 3.1) +# --------------------------------------------------------------------------- + + +@router.get( + "/actions/{name}/parsed", + response_model=ActionConfig, + summary="Return an action file parsed into a structured model", +) +async def get_parsed_action( + request: Request, + _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. + """ + config_dir: str = request.app.state.settings.fail2ban_config_dir + try: + return await file_config_service.get_parsed_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}/parsed", + status_code=status.HTTP_204_NO_CONTENT, + summary="Update an action file from a structured model", +) +async def update_parsed_action( + request: Request, + _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. + """ + config_dir: str = request.app.state.settings.fail2ban_config_dir + try: + await file_config_service.update_parsed_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 + + +# --------------------------------------------------------------------------- +# 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", +) +async def get_parsed_jail_file( + request: Request, + _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. + """ + config_dir: str = request.app.state.settings.fail2ban_config_dir + try: + return await file_config_service.get_parsed_jail_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}/parsed", + status_code=status.HTTP_204_NO_CONTENT, + summary="Update a jail.d file from a structured model", +) +async def update_parsed_jail_file( + request: Request, + _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. + """ + config_dir: str = request.app.state.settings.fail2ban_config_dir + try: + await file_config_service.update_parsed_jail_file(config_dir, filename, body) + 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 diff --git a/backend/app/services/file_config_service.py b/backend/app/services/file_config_service.py index de44c2b..ad0462a 100644 --- a/backend/app/services/file_config_service.py +++ b/backend/app/services/file_config_service.py @@ -19,6 +19,7 @@ import asyncio import configparser import re from pathlib import Path +from typing import TYPE_CHECKING import structlog @@ -33,6 +34,16 @@ from app.models.file_config import ( JailConfigFilesResponse, ) +if TYPE_CHECKING: + from app.models.config import ( + ActionConfig, + ActionConfigUpdate, + FilterConfig, + FilterConfigUpdate, + JailFileConfig, + JailFileConfigUpdate, + ) + log: structlog.stdlib.BoundLogger = structlog.get_logger() # --------------------------------------------------------------------------- @@ -378,6 +389,35 @@ async def set_jail_config_enabled( await asyncio.get_event_loop().run_in_executor(None, _do) +async def create_jail_config_file( + config_dir: str, + req: ConfFileCreateRequest, +) -> str: + """Create a new jail.d config file. + + Args: + config_dir: Path to the fail2ban configuration directory. + req: :class:`~app.models.file_config.ConfFileCreateRequest`. + + Returns: + The filename that was created. + + Raises: + ConfigFileExistsError: If a file with that name already exists. + ConfigFileNameError: If the name is invalid. + ConfigFileWriteError: If the file cannot be created. + ConfigDirError: If *config_dir* does not exist. + """ + + def _do() -> str: + jail_d = _resolve_subdir(config_dir, "jail.d") + filename = _create_conf_file(jail_d, req.name, req.content) + log.info("jail_config_file_created", filename=filename) + return filename + + return await asyncio.get_event_loop().run_in_executor(None, _do) + + # --------------------------------------------------------------------------- # Internal helpers — generic conf file listing / reading / writing # --------------------------------------------------------------------------- @@ -723,3 +763,206 @@ async def create_action_file( return filename return await asyncio.get_event_loop().run_in_executor(None, _do) + + +# --------------------------------------------------------------------------- +# Public API — structured (parsed) filter files (Task 2.1) +# --------------------------------------------------------------------------- + + +async def get_parsed_filter_file(config_dir: str, name: str) -> FilterConfig: + """Parse a filter definition file and return its structured representation. + + Reads the raw ``.conf``/``.local`` file from ``filter.d/``, parses it with + :func:`~app.services.conffile_parser.parse_filter_file`, and returns the + result. + + Args: + config_dir: Path to the fail2ban configuration directory. + name: Base name with or without extension. + + Returns: + :class:`~app.models.config.FilterConfig`. + + Raises: + ConfigFileNotFoundError: If no matching file is found. + ConfigDirError: If *config_dir* does not exist. + """ + from app.services.conffile_parser import parse_filter_file # avoid circular imports + + def _do() -> FilterConfig: + filter_d = _resolve_subdir(config_dir, "filter.d") + raw = _read_conf_file(filter_d, name) + result = parse_filter_file(raw.content, name=raw.name, filename=raw.filename) + log.debug("filter_file_parsed", name=raw.name) + return result + + return await asyncio.get_event_loop().run_in_executor(None, _do) + + +async def update_parsed_filter_file( + config_dir: str, + name: str, + update: FilterConfigUpdate, +) -> None: + """Apply a structured partial update to a filter definition file. + + Reads the existing file, merges *update* onto it, serializes to INI format, + and writes the result back to disk. + + Args: + config_dir: Path to the fail2ban configuration directory. + name: Base name of the file to update. + update: Partial fields to apply. + + Raises: + ConfigFileNotFoundError: If no matching file is found. + ConfigFileWriteError: If the file cannot be written. + ConfigDirError: If *config_dir* does not exist. + """ + from app.services.conffile_parser import ( # avoid circular imports + merge_filter_update, + parse_filter_file, + serialize_filter_config, + ) + + def _do() -> None: + filter_d = _resolve_subdir(config_dir, "filter.d") + raw = _read_conf_file(filter_d, name) + current = parse_filter_file(raw.content, name=raw.name, filename=raw.filename) + merged = merge_filter_update(current, update) + new_content = serialize_filter_config(merged) + _validate_content(new_content) + _write_conf_file(filter_d, name, new_content) + log.info("filter_file_updated_parsed", name=name) + + await asyncio.get_event_loop().run_in_executor(None, _do) + + +# --------------------------------------------------------------------------- +# Public API — structured (parsed) action files (Task 3.1) +# --------------------------------------------------------------------------- + + +async def get_parsed_action_file(config_dir: str, name: str) -> ActionConfig: + """Parse an action definition file and return its structured representation. + + Args: + config_dir: Path to the fail2ban configuration directory. + name: Base name with or without extension. + + Returns: + :class:`~app.models.config.ActionConfig`. + + Raises: + ConfigFileNotFoundError: If no matching file is found. + ConfigDirError: If *config_dir* does not exist. + """ + from app.services.conffile_parser import parse_action_file # avoid circular imports + + def _do() -> ActionConfig: + action_d = _resolve_subdir(config_dir, "action.d") + raw = _read_conf_file(action_d, name) + result = parse_action_file(raw.content, name=raw.name, filename=raw.filename) + log.debug("action_file_parsed", name=raw.name) + return result + + return await asyncio.get_event_loop().run_in_executor(None, _do) + + +async def update_parsed_action_file( + config_dir: str, + name: str, + update: ActionConfigUpdate, +) -> None: + """Apply a structured partial update to an action definition file. + + Args: + config_dir: Path to the fail2ban configuration directory. + name: Base name of the file to update. + update: Partial fields to apply. + + Raises: + ConfigFileNotFoundError: If no matching file is found. + ConfigFileWriteError: If the file cannot be written. + ConfigDirError: If *config_dir* does not exist. + """ + from app.services.conffile_parser import ( # avoid circular imports + merge_action_update, + parse_action_file, + serialize_action_config, + ) + + def _do() -> None: + action_d = _resolve_subdir(config_dir, "action.d") + raw = _read_conf_file(action_d, name) + current = parse_action_file(raw.content, name=raw.name, filename=raw.filename) + merged = merge_action_update(current, update) + new_content = serialize_action_config(merged) + _validate_content(new_content) + _write_conf_file(action_d, name, new_content) + log.info("action_file_updated_parsed", name=name) + + await asyncio.get_event_loop().run_in_executor(None, _do) + + +async def get_parsed_jail_file(config_dir: str, filename: str) -> JailFileConfig: + """Parse a jail.d config file into a structured :class:`~app.models.config.JailFileConfig`. + + Args: + config_dir: Path to the fail2ban configuration directory. + filename: Filename including extension (e.g. ``"sshd.conf"``). + + Returns: + :class:`~app.models.config.JailFileConfig`. + + Raises: + ConfigFileNotFoundError: If no matching file is found. + ConfigDirError: If *config_dir* does not exist. + """ + from app.services.conffile_parser import parse_jail_file # avoid circular imports + + def _do() -> JailFileConfig: + jail_d = _resolve_subdir(config_dir, "jail.d") + raw = _read_conf_file(jail_d, filename) + result = parse_jail_file(raw.content, filename=raw.filename) + log.debug("jail_file_parsed", filename=raw.filename) + return result + + return await asyncio.get_event_loop().run_in_executor(None, _do) + + +async def update_parsed_jail_file( + config_dir: str, + filename: str, + update: JailFileConfigUpdate, +) -> None: + """Apply a structured partial update to a jail.d config file. + + Args: + config_dir: Path to the fail2ban configuration directory. + filename: Filename including extension (e.g. ``"sshd.conf"``). + update: Partial fields to apply. + + Raises: + ConfigFileNotFoundError: If no matching file is found. + ConfigFileWriteError: If the file cannot be written. + ConfigDirError: If *config_dir* does not exist. + """ + from app.services.conffile_parser import ( # avoid circular imports + merge_jail_file_update, + parse_jail_file, + serialize_jail_file_config, + ) + + def _do() -> None: + jail_d = _resolve_subdir(config_dir, "jail.d") + raw = _read_conf_file(jail_d, filename) + current = parse_jail_file(raw.content, filename=raw.filename) + merged = merge_jail_file_update(current, update) + new_content = serialize_jail_file_config(merged) + _validate_content(new_content) + _write_conf_file(jail_d, filename, new_content) + log.info("jail_file_updated_parsed", filename=filename) + + await asyncio.get_event_loop().run_in_executor(None, _do)