Files
BanGUI/backend/app/routers/file_config.py
Lukas cf2336c0bc feat(backend): add raw file write endpoints for jail, filter, and action configs
Add PUT endpoints for overwriting raw content of jail.d, filter.d, and
action.d config files. Mirrors the existing GET endpoints so the frontend
can show an editable raw-text view of each config file.
2026-03-13 14:34:41 +01:00

849 lines
28 KiB
Python

"""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`` — 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/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
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,
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}",
status_code=status.HTTP_204_NO_CONTENT,
summary="Overwrite a jail.d config file with new raw content",
)
async def write_jail_config_file(
request: Request,
_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.
"""
config_dir: str = request.app.state.settings.fail2ban_config_dir
try:
await file_config_service.write_jail_config_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
@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
@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)
# ---------------------------------------------------------------------------
@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,
)
# ---------------------------------------------------------------------------
# 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