Add filter write/create/delete and jail-filter assign endpoints (Task 2.2)
- PUT /api/config/filters/{name}: updates failregex/ignoreregex/datepattern/
journalmatch in filter.d/{name}.local; validates regex via re.compile();
supports ?reload=true
- POST /api/config/filters: creates filter.d/{name}.local from FilterCreateRequest;
returns 409 if file already exists
- DELETE /api/config/filters/{name}: deletes .local only; returns 409 for
conf-only (readonly) filters
- POST /api/config/jails/{name}/filter: assigns filter to jail by writing
'filter = {name}' to jail.d/{jail}.local; supports ?reload=true
- New models: FilterUpdateRequest, FilterCreateRequest, AssignFilterRequest
- New service helpers: _safe_filter_name, _validate_regex_patterns,
_write_filter_local_sync, _set_jail_local_key_sync
- Fixed .local-only filter discovery in _parse_filters_sync (5-tuple return)
- Fixed get_filter extension stripping (.local is 6 chars not 5)
- Renamed file_config.py raw-write routes to /raw suffix
(PUT /filters/{name}/raw, POST /filters/raw) to avoid routing conflicts
- Full service + router tests; all 930 tests pass
This commit is contained in:
@@ -23,6 +23,7 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
import configparser
|
||||
import contextlib
|
||||
import io
|
||||
import os
|
||||
import re
|
||||
import tempfile
|
||||
@@ -33,8 +34,12 @@ import structlog
|
||||
|
||||
from app.models.config import (
|
||||
ActivateJailRequest,
|
||||
AssignFilterRequest,
|
||||
FilterConfig,
|
||||
FilterConfigUpdate,
|
||||
FilterCreateRequest,
|
||||
FilterListResponse,
|
||||
FilterUpdateRequest,
|
||||
InactiveJail,
|
||||
InactiveJailListResponse,
|
||||
JailActivationResponse,
|
||||
@@ -117,6 +122,54 @@ class ConfigWriteError(Exception):
|
||||
"""Raised when writing a ``.local`` override file fails."""
|
||||
|
||||
|
||||
class FilterNameError(Exception):
|
||||
"""Raised when a filter name contains invalid characters."""
|
||||
|
||||
|
||||
class FilterAlreadyExistsError(Exception):
|
||||
"""Raised when trying to create a filter whose ``.conf`` or ``.local`` already exists."""
|
||||
|
||||
def __init__(self, name: str) -> None:
|
||||
"""Initialise with the filter name that already exists.
|
||||
|
||||
Args:
|
||||
name: The filter name that already exists.
|
||||
"""
|
||||
self.name: str = name
|
||||
super().__init__(f"Filter already exists: {name!r}")
|
||||
|
||||
|
||||
class FilterReadonlyError(Exception):
|
||||
"""Raised when trying to delete a shipped ``.conf`` filter with no ``.local`` override."""
|
||||
|
||||
def __init__(self, name: str) -> None:
|
||||
"""Initialise with the filter name that cannot be deleted.
|
||||
|
||||
Args:
|
||||
name: The filter name that is read-only (shipped ``.conf`` only).
|
||||
"""
|
||||
self.name: str = name
|
||||
super().__init__(
|
||||
f"Filter {name!r} is a shipped default (.conf only); "
|
||||
"only user-created .local files can be deleted."
|
||||
)
|
||||
|
||||
|
||||
class FilterInvalidRegexError(Exception):
|
||||
"""Raised when a regex pattern fails to compile."""
|
||||
|
||||
def __init__(self, pattern: str, error: str) -> None:
|
||||
"""Initialise with the invalid pattern and the compile error.
|
||||
|
||||
Args:
|
||||
pattern: The regex string that failed to compile.
|
||||
error: The ``re.error`` message.
|
||||
"""
|
||||
self.pattern: str = pattern
|
||||
self.error: str = error
|
||||
super().__init__(f"Invalid regex {pattern!r}: {error}")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Internal helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -143,6 +196,27 @@ def _safe_jail_name(name: str) -> str:
|
||||
return name
|
||||
|
||||
|
||||
def _safe_filter_name(name: str) -> str:
|
||||
"""Validate *name* and return it unchanged or raise :class:`FilterNameError`.
|
||||
|
||||
Args:
|
||||
name: Proposed filter name (without extension).
|
||||
|
||||
Returns:
|
||||
The name unchanged if valid.
|
||||
|
||||
Raises:
|
||||
FilterNameError: If *name* contains unsafe characters.
|
||||
"""
|
||||
if not _SAFE_FILTER_NAME_RE.match(name):
|
||||
raise FilterNameError(
|
||||
f"Filter name {name!r} contains invalid characters. "
|
||||
"Only alphanumeric characters, hyphens, underscores, and dots are "
|
||||
"allowed; must start with an alphanumeric character."
|
||||
)
|
||||
return name
|
||||
|
||||
|
||||
def _ordered_config_files(config_dir: Path) -> list[Path]:
|
||||
"""Return all jail config files in fail2ban merge order.
|
||||
|
||||
@@ -479,6 +553,144 @@ def _write_local_override_sync(
|
||||
)
|
||||
|
||||
|
||||
def _validate_regex_patterns(patterns: list[str]) -> None:
|
||||
"""Validate each pattern in *patterns* using Python's ``re`` module.
|
||||
|
||||
Args:
|
||||
patterns: List of regex strings to validate.
|
||||
|
||||
Raises:
|
||||
FilterInvalidRegexError: If any pattern fails to compile.
|
||||
"""
|
||||
for pattern in patterns:
|
||||
try:
|
||||
re.compile(pattern)
|
||||
except re.error as exc:
|
||||
raise FilterInvalidRegexError(pattern, str(exc)) from exc
|
||||
|
||||
|
||||
def _write_filter_local_sync(filter_d: Path, name: str, content: str) -> None:
|
||||
"""Write *content* to ``filter.d/{name}.local`` atomically.
|
||||
|
||||
The write is atomic: content is written to a temp file first, then
|
||||
renamed into place. The ``filter.d/`` directory is created if absent.
|
||||
|
||||
Args:
|
||||
filter_d: Path to the ``filter.d`` directory.
|
||||
name: Validated filter base name (used as filename stem).
|
||||
content: Full serialized filter content to write.
|
||||
|
||||
Raises:
|
||||
ConfigWriteError: If writing fails.
|
||||
"""
|
||||
try:
|
||||
filter_d.mkdir(parents=True, exist_ok=True)
|
||||
except OSError as exc:
|
||||
raise ConfigWriteError(
|
||||
f"Cannot create filter.d directory: {exc}"
|
||||
) from exc
|
||||
|
||||
local_path = filter_d / f"{name}.local"
|
||||
try:
|
||||
with tempfile.NamedTemporaryFile(
|
||||
mode="w",
|
||||
encoding="utf-8",
|
||||
dir=filter_d,
|
||||
delete=False,
|
||||
suffix=".tmp",
|
||||
) as tmp:
|
||||
tmp.write(content)
|
||||
tmp_name = tmp.name
|
||||
os.replace(tmp_name, local_path)
|
||||
except OSError as exc:
|
||||
with contextlib.suppress(OSError):
|
||||
os.unlink(tmp_name) # noqa: F821
|
||||
raise ConfigWriteError(
|
||||
f"Failed to write {local_path}: {exc}"
|
||||
) from exc
|
||||
|
||||
log.info("filter_local_written", filter=name, path=str(local_path))
|
||||
|
||||
|
||||
def _set_jail_local_key_sync(
|
||||
config_dir: Path,
|
||||
jail_name: str,
|
||||
key: str,
|
||||
value: str,
|
||||
) -> None:
|
||||
"""Update ``jail.d/{jail_name}.local`` to set a single key in the jail section.
|
||||
|
||||
If the ``.local`` file already exists it is read, the key is updated (or
|
||||
added), and the file is written back atomically without disturbing other
|
||||
settings. If the file does not exist a new one is created containing
|
||||
only the BanGUI header comment, the jail section, and the requested key.
|
||||
|
||||
Args:
|
||||
config_dir: The fail2ban configuration root directory.
|
||||
jail_name: Validated jail name (used as section name and filename stem).
|
||||
key: Config key to set inside the jail section.
|
||||
value: Config value to assign.
|
||||
|
||||
Raises:
|
||||
ConfigWriteError: If writing fails.
|
||||
"""
|
||||
jail_d = config_dir / "jail.d"
|
||||
try:
|
||||
jail_d.mkdir(parents=True, exist_ok=True)
|
||||
except OSError as exc:
|
||||
raise ConfigWriteError(
|
||||
f"Cannot create jail.d directory: {exc}"
|
||||
) from exc
|
||||
|
||||
local_path = jail_d / f"{jail_name}.local"
|
||||
|
||||
parser = _build_parser()
|
||||
if local_path.is_file():
|
||||
try:
|
||||
parser.read(str(local_path), encoding="utf-8")
|
||||
except (configparser.Error, OSError) as exc:
|
||||
log.warning(
|
||||
"jail_local_read_for_update_error",
|
||||
jail=jail_name,
|
||||
error=str(exc),
|
||||
)
|
||||
|
||||
if not parser.has_section(jail_name):
|
||||
parser.add_section(jail_name)
|
||||
parser.set(jail_name, key, value)
|
||||
|
||||
# Serialize: write a BanGUI header then the parser output.
|
||||
buf = io.StringIO()
|
||||
buf.write("# Managed by BanGUI — do not edit manually\n\n")
|
||||
parser.write(buf)
|
||||
content = buf.getvalue()
|
||||
|
||||
try:
|
||||
with tempfile.NamedTemporaryFile(
|
||||
mode="w",
|
||||
encoding="utf-8",
|
||||
dir=jail_d,
|
||||
delete=False,
|
||||
suffix=".tmp",
|
||||
) as tmp:
|
||||
tmp.write(content)
|
||||
tmp_name = tmp.name
|
||||
os.replace(tmp_name, local_path)
|
||||
except OSError as exc:
|
||||
with contextlib.suppress(OSError):
|
||||
os.unlink(tmp_name) # noqa: F821
|
||||
raise ConfigWriteError(
|
||||
f"Failed to write {local_path}: {exc}"
|
||||
) from exc
|
||||
|
||||
log.info(
|
||||
"jail_local_key_set",
|
||||
jail=jail_name,
|
||||
key=key,
|
||||
path=str(local_path),
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public API
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -743,32 +955,43 @@ def _build_filter_to_jails_map(
|
||||
|
||||
def _parse_filters_sync(
|
||||
filter_d: Path,
|
||||
) -> list[tuple[str, str, str, bool]]:
|
||||
) -> list[tuple[str, str, str, bool, str]]:
|
||||
"""Synchronously scan ``filter.d/`` and return per-filter tuples.
|
||||
|
||||
Each tuple contains:
|
||||
|
||||
- ``name`` — filter base name (``"sshd"``).
|
||||
- ``filename`` — actual filename (``"sshd.conf"``).
|
||||
- ``filename`` — actual filename (``"sshd.conf"`` or ``"sshd.local"``).
|
||||
- ``content`` — merged file content (``conf`` overridden by ``local``).
|
||||
- ``has_local`` — whether a ``.local`` override exists.
|
||||
- ``has_local`` — whether a ``.local`` override exists alongside a ``.conf``.
|
||||
- ``source_path`` — absolute path to the primary (``conf``) source file, or
|
||||
to the ``.local`` file for user-created (local-only) filters.
|
||||
|
||||
Also discovers ``.local``-only files (user-created filters with no
|
||||
corresponding ``.conf``). These are returned with ``has_local = False``
|
||||
and ``source_path`` pointing to the ``.local`` file itself.
|
||||
|
||||
Args:
|
||||
filter_d: Path to the ``filter.d`` directory.
|
||||
|
||||
Returns:
|
||||
List of ``(name, filename, content, has_local)`` tuples, sorted by name.
|
||||
List of ``(name, filename, content, has_local, source_path)`` tuples,
|
||||
sorted by name.
|
||||
"""
|
||||
if not filter_d.is_dir():
|
||||
log.warning("filter_d_not_found", path=str(filter_d))
|
||||
return []
|
||||
|
||||
results: list[tuple[str, str, str, bool]] = []
|
||||
conf_names: set[str] = set()
|
||||
results: list[tuple[str, str, str, bool, str]] = []
|
||||
|
||||
# ---- .conf-based filters (with optional .local override) ----------------
|
||||
for conf_path in sorted(filter_d.glob("*.conf")):
|
||||
if not conf_path.is_file():
|
||||
continue
|
||||
name = conf_path.stem
|
||||
filename = conf_path.name
|
||||
conf_names.add(name)
|
||||
local_path = conf_path.with_suffix(".local")
|
||||
has_local = local_path.is_file()
|
||||
|
||||
@@ -794,8 +1017,29 @@ def _parse_filters_sync(
|
||||
error=str(exc),
|
||||
)
|
||||
|
||||
results.append((name, filename, content, has_local))
|
||||
results.append((name, filename, content, has_local, str(conf_path)))
|
||||
|
||||
# ---- .local-only filters (user-created, no corresponding .conf) ----------
|
||||
for local_path in sorted(filter_d.glob("*.local")):
|
||||
if not local_path.is_file():
|
||||
continue
|
||||
name = local_path.stem
|
||||
if name in conf_names:
|
||||
# Already covered above as a .conf filter with a .local override.
|
||||
continue
|
||||
try:
|
||||
content = local_path.read_text(encoding="utf-8")
|
||||
except OSError as exc:
|
||||
log.warning(
|
||||
"filter_local_read_error",
|
||||
name=name,
|
||||
path=str(local_path),
|
||||
error=str(exc),
|
||||
)
|
||||
continue
|
||||
results.append((name, local_path.name, content, False, str(local_path)))
|
||||
|
||||
results.sort(key=lambda t: t[0])
|
||||
log.debug("filters_scanned", count=len(results), filter_d=str(filter_d))
|
||||
return results
|
||||
|
||||
@@ -832,7 +1076,7 @@ async def list_filters(
|
||||
loop = asyncio.get_event_loop()
|
||||
|
||||
# Run the synchronous scan in a thread-pool executor.
|
||||
raw_filters: list[tuple[str, str, str, bool]] = await loop.run_in_executor(
|
||||
raw_filters: list[tuple[str, str, str, bool, str]] = await loop.run_in_executor(
|
||||
None, _parse_filters_sync, filter_d
|
||||
)
|
||||
|
||||
@@ -846,8 +1090,7 @@ async def list_filters(
|
||||
filter_to_jails = _build_filter_to_jails_map(all_jails, active_names)
|
||||
|
||||
filters: list[FilterConfig] = []
|
||||
for name, filename, content, has_local in raw_filters:
|
||||
conf_path = filter_d / filename
|
||||
for name, filename, content, has_local, source_path in raw_filters:
|
||||
cfg = conffile_parser.parse_filter_file(
|
||||
content, name=name, filename=filename
|
||||
)
|
||||
@@ -867,7 +1110,7 @@ async def list_filters(
|
||||
journalmatch=cfg.journalmatch,
|
||||
active=len(used_by) > 0,
|
||||
used_by_jails=used_by,
|
||||
source_file=str(conf_path),
|
||||
source_file=source_path,
|
||||
has_local_override=has_local,
|
||||
)
|
||||
)
|
||||
@@ -897,35 +1140,46 @@ async def get_filter(
|
||||
:class:`~app.models.config.FilterConfig` with status fields populated.
|
||||
|
||||
Raises:
|
||||
FilterNotFoundError: If no ``{name}.conf`` file exists in
|
||||
``filter.d/``.
|
||||
FilterNotFoundError: If no ``{name}.conf`` or ``{name}.local`` file
|
||||
exists in ``filter.d/``.
|
||||
"""
|
||||
# Normalise — strip extension if provided.
|
||||
base_name = name[:-5] if name.endswith(".conf") else name
|
||||
# Normalise — strip extension if provided (.conf=5 chars, .local=6 chars).
|
||||
if name.endswith(".conf"):
|
||||
base_name = name[:-5]
|
||||
elif name.endswith(".local"):
|
||||
base_name = name[:-6]
|
||||
else:
|
||||
base_name = name
|
||||
|
||||
filter_d = Path(config_dir) / "filter.d"
|
||||
conf_path = filter_d / f"{base_name}.conf"
|
||||
local_path = conf_path.with_suffix(".local")
|
||||
local_path = filter_d / f"{base_name}.local"
|
||||
loop = asyncio.get_event_loop()
|
||||
|
||||
def _read() -> tuple[str, bool]:
|
||||
if not conf_path.is_file():
|
||||
raise FilterNotFoundError(base_name)
|
||||
content = conf_path.read_text(encoding="utf-8")
|
||||
def _read() -> tuple[str, bool, str]:
|
||||
"""Read filter content and return (content, has_local_override, source_path)."""
|
||||
has_local = local_path.is_file()
|
||||
if has_local:
|
||||
try:
|
||||
content += "\n" + local_path.read_text(encoding="utf-8")
|
||||
except OSError as exc:
|
||||
log.warning(
|
||||
"filter_local_read_error",
|
||||
name=base_name,
|
||||
path=str(local_path),
|
||||
error=str(exc),
|
||||
)
|
||||
return content, has_local
|
||||
if conf_path.is_file():
|
||||
content = conf_path.read_text(encoding="utf-8")
|
||||
if has_local:
|
||||
try:
|
||||
content += "\n" + local_path.read_text(encoding="utf-8")
|
||||
except OSError as exc:
|
||||
log.warning(
|
||||
"filter_local_read_error",
|
||||
name=base_name,
|
||||
path=str(local_path),
|
||||
error=str(exc),
|
||||
)
|
||||
return content, has_local, str(conf_path)
|
||||
elif has_local:
|
||||
# Local-only filter: created by the user, no shipped .conf base.
|
||||
content = local_path.read_text(encoding="utf-8")
|
||||
return content, False, str(local_path)
|
||||
else:
|
||||
raise FilterNotFoundError(base_name)
|
||||
|
||||
content, has_local = await loop.run_in_executor(None, _read)
|
||||
content, has_local, source_path = await loop.run_in_executor(None, _read)
|
||||
|
||||
cfg = conffile_parser.parse_filter_file(
|
||||
content, name=base_name, filename=f"{base_name}.conf"
|
||||
@@ -954,6 +1208,299 @@ async def get_filter(
|
||||
journalmatch=cfg.journalmatch,
|
||||
active=len(used_by) > 0,
|
||||
used_by_jails=used_by,
|
||||
source_file=str(conf_path),
|
||||
source_file=source_path,
|
||||
has_local_override=has_local,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public API — filter write operations (Task 2.2)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def update_filter(
|
||||
config_dir: str,
|
||||
socket_path: str,
|
||||
name: str,
|
||||
req: FilterUpdateRequest,
|
||||
do_reload: bool = False,
|
||||
) -> FilterConfig:
|
||||
"""Update a filter's ``.local`` override with new regex/pattern values.
|
||||
|
||||
Reads the current merged configuration for *name* (``conf`` + any existing
|
||||
``local``), applies the non-``None`` fields in *req* on top of it, and
|
||||
writes the resulting definition to ``filter.d/{name}.local``. The
|
||||
original ``.conf`` file is never modified.
|
||||
|
||||
All regex patterns in *req* are validated with Python's ``re`` module
|
||||
before any write occurs.
|
||||
|
||||
Args:
|
||||
config_dir: Absolute path to the fail2ban configuration directory.
|
||||
socket_path: Path to the fail2ban Unix domain socket.
|
||||
name: Filter base name (e.g. ``"sshd"`` or ``"sshd.conf"``).
|
||||
req: Partial update — only non-``None`` fields are applied.
|
||||
do_reload: When ``True``, trigger a full fail2ban reload after writing.
|
||||
|
||||
Returns:
|
||||
:class:`~app.models.config.FilterConfig` reflecting the updated state.
|
||||
|
||||
Raises:
|
||||
FilterNameError: If *name* contains invalid characters.
|
||||
FilterNotFoundError: If no ``{name}.conf`` or ``{name}.local`` exists.
|
||||
FilterInvalidRegexError: If any supplied regex pattern is invalid.
|
||||
ConfigWriteError: If writing the ``.local`` file fails.
|
||||
"""
|
||||
base_name = name[:-5] if name.endswith(".conf") or name.endswith(".local") else name
|
||||
_safe_filter_name(base_name)
|
||||
|
||||
# Validate regex patterns before touching the filesystem.
|
||||
patterns: list[str] = []
|
||||
if req.failregex is not None:
|
||||
patterns.extend(req.failregex)
|
||||
if req.ignoreregex is not None:
|
||||
patterns.extend(req.ignoreregex)
|
||||
_validate_regex_patterns(patterns)
|
||||
|
||||
# Fetch the current merged config (raises FilterNotFoundError if absent).
|
||||
current = await get_filter(config_dir, socket_path, base_name)
|
||||
|
||||
# Build a FilterConfigUpdate from the request fields.
|
||||
update = FilterConfigUpdate(
|
||||
failregex=req.failregex,
|
||||
ignoreregex=req.ignoreregex,
|
||||
datepattern=req.datepattern,
|
||||
journalmatch=req.journalmatch,
|
||||
)
|
||||
|
||||
merged = conffile_parser.merge_filter_update(current, update)
|
||||
content = conffile_parser.serialize_filter_config(merged)
|
||||
|
||||
filter_d = Path(config_dir) / "filter.d"
|
||||
loop = asyncio.get_event_loop()
|
||||
await loop.run_in_executor(None, _write_filter_local_sync, filter_d, base_name, content)
|
||||
|
||||
if do_reload:
|
||||
try:
|
||||
await jail_service.reload_all(socket_path)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
log.warning(
|
||||
"reload_after_filter_update_failed",
|
||||
filter=base_name,
|
||||
error=str(exc),
|
||||
)
|
||||
|
||||
log.info("filter_updated", filter=base_name, reload=do_reload)
|
||||
return await get_filter(config_dir, socket_path, base_name)
|
||||
|
||||
|
||||
async def create_filter(
|
||||
config_dir: str,
|
||||
socket_path: str,
|
||||
req: FilterCreateRequest,
|
||||
do_reload: bool = False,
|
||||
) -> FilterConfig:
|
||||
"""Create a brand-new user-defined filter in ``filter.d/{name}.local``.
|
||||
|
||||
No ``.conf`` is written; fail2ban loads ``.local`` files directly. If a
|
||||
``.conf`` or ``.local`` file already exists for the requested name, a
|
||||
:class:`FilterAlreadyExistsError` is raised.
|
||||
|
||||
All regex patterns are validated with Python's ``re`` module before
|
||||
writing.
|
||||
|
||||
Args:
|
||||
config_dir: Absolute path to the fail2ban configuration directory.
|
||||
socket_path: Path to the fail2ban Unix domain socket.
|
||||
req: Filter name and definition fields.
|
||||
do_reload: When ``True``, trigger a full fail2ban reload after writing.
|
||||
|
||||
Returns:
|
||||
:class:`~app.models.config.FilterConfig` for the newly created filter.
|
||||
|
||||
Raises:
|
||||
FilterNameError: If ``req.name`` contains invalid characters.
|
||||
FilterAlreadyExistsError: If a ``.conf`` or ``.local`` already exists.
|
||||
FilterInvalidRegexError: If any regex pattern is invalid.
|
||||
ConfigWriteError: If writing fails.
|
||||
"""
|
||||
_safe_filter_name(req.name)
|
||||
|
||||
filter_d = Path(config_dir) / "filter.d"
|
||||
conf_path = filter_d / f"{req.name}.conf"
|
||||
local_path = filter_d / f"{req.name}.local"
|
||||
|
||||
def _check_not_exists() -> None:
|
||||
if conf_path.is_file() or local_path.is_file():
|
||||
raise FilterAlreadyExistsError(req.name)
|
||||
|
||||
loop = asyncio.get_event_loop()
|
||||
await loop.run_in_executor(None, _check_not_exists)
|
||||
|
||||
# Validate regex patterns.
|
||||
patterns: list[str] = list(req.failregex) + list(req.ignoreregex)
|
||||
_validate_regex_patterns(patterns)
|
||||
|
||||
# Build a FilterConfig and serialise it.
|
||||
cfg = FilterConfig(
|
||||
name=req.name,
|
||||
filename=f"{req.name}.local",
|
||||
failregex=req.failregex,
|
||||
ignoreregex=req.ignoreregex,
|
||||
prefregex=req.prefregex,
|
||||
datepattern=req.datepattern,
|
||||
journalmatch=req.journalmatch,
|
||||
)
|
||||
content = conffile_parser.serialize_filter_config(cfg)
|
||||
|
||||
await loop.run_in_executor(None, _write_filter_local_sync, filter_d, req.name, content)
|
||||
|
||||
if do_reload:
|
||||
try:
|
||||
await jail_service.reload_all(socket_path)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
log.warning(
|
||||
"reload_after_filter_create_failed",
|
||||
filter=req.name,
|
||||
error=str(exc),
|
||||
)
|
||||
|
||||
log.info("filter_created", filter=req.name, reload=do_reload)
|
||||
# Re-fetch to get the canonical FilterConfig (source_file, active, etc.).
|
||||
return await get_filter(config_dir, socket_path, req.name)
|
||||
|
||||
|
||||
async def delete_filter(
|
||||
config_dir: str,
|
||||
name: str,
|
||||
) -> None:
|
||||
"""Delete a user-created filter's ``.local`` file.
|
||||
|
||||
Deletion rules:
|
||||
- If only a ``.conf`` file exists (shipped default, no user override) →
|
||||
:class:`FilterReadonlyError`.
|
||||
- If a ``.local`` file exists (whether or not a ``.conf`` also exists) →
|
||||
the ``.local`` file is deleted. The shipped ``.conf`` is never touched.
|
||||
- If neither file exists → :class:`FilterNotFoundError`.
|
||||
|
||||
Args:
|
||||
config_dir: Absolute path to the fail2ban configuration directory.
|
||||
name: Filter base name (e.g. ``"sshd"``).
|
||||
|
||||
Raises:
|
||||
FilterNameError: If *name* contains invalid characters.
|
||||
FilterNotFoundError: If no filter file is found for *name*.
|
||||
FilterReadonlyError: If only a shipped ``.conf`` exists (no ``.local``).
|
||||
ConfigWriteError: If deletion of the ``.local`` file fails.
|
||||
"""
|
||||
base_name = name[:-5] if name.endswith(".conf") or name.endswith(".local") else name
|
||||
_safe_filter_name(base_name)
|
||||
|
||||
filter_d = Path(config_dir) / "filter.d"
|
||||
conf_path = filter_d / f"{base_name}.conf"
|
||||
local_path = filter_d / f"{base_name}.local"
|
||||
|
||||
loop = asyncio.get_event_loop()
|
||||
|
||||
def _delete() -> None:
|
||||
has_conf = conf_path.is_file()
|
||||
has_local = local_path.is_file()
|
||||
|
||||
if not has_conf and not has_local:
|
||||
raise FilterNotFoundError(base_name)
|
||||
|
||||
if has_conf and not has_local:
|
||||
# Shipped default — nothing user-writable to remove.
|
||||
raise FilterReadonlyError(base_name)
|
||||
|
||||
try:
|
||||
local_path.unlink()
|
||||
except OSError as exc:
|
||||
raise ConfigWriteError(
|
||||
f"Failed to delete {local_path}: {exc}"
|
||||
) from exc
|
||||
|
||||
log.info("filter_local_deleted", filter=base_name, path=str(local_path))
|
||||
|
||||
await loop.run_in_executor(None, _delete)
|
||||
|
||||
|
||||
async def assign_filter_to_jail(
|
||||
config_dir: str,
|
||||
socket_path: str,
|
||||
jail_name: str,
|
||||
req: AssignFilterRequest,
|
||||
do_reload: bool = False,
|
||||
) -> None:
|
||||
"""Assign a filter to a jail by updating the jail's ``.local`` file.
|
||||
|
||||
Writes ``filter = {req.filter_name}`` into the ``[{jail_name}]`` section
|
||||
of ``jail.d/{jail_name}.local``. If the ``.local`` file already contains
|
||||
other settings for this jail they are preserved.
|
||||
|
||||
Args:
|
||||
config_dir: Absolute path to the fail2ban configuration directory.
|
||||
socket_path: Path to the fail2ban Unix domain socket.
|
||||
jail_name: Name of the jail to update.
|
||||
req: Request containing the filter name to assign.
|
||||
do_reload: When ``True``, trigger a full fail2ban reload after writing.
|
||||
|
||||
Raises:
|
||||
JailNameError: If *jail_name* contains invalid characters.
|
||||
FilterNameError: If ``req.filter_name`` contains invalid characters.
|
||||
JailNotFoundInConfigError: If *jail_name* is not defined in any config
|
||||
file.
|
||||
FilterNotFoundError: If ``req.filter_name`` does not exist in
|
||||
``filter.d/``.
|
||||
ConfigWriteError: If writing fails.
|
||||
"""
|
||||
_safe_jail_name(jail_name)
|
||||
_safe_filter_name(req.filter_name)
|
||||
|
||||
loop = asyncio.get_event_loop()
|
||||
|
||||
# Verify the jail exists in config.
|
||||
all_jails, _src = await loop.run_in_executor(
|
||||
None, _parse_jails_sync, Path(config_dir)
|
||||
)
|
||||
if jail_name not in all_jails:
|
||||
raise JailNotFoundInConfigError(jail_name)
|
||||
|
||||
# Verify the filter exists (conf or local).
|
||||
filter_d = Path(config_dir) / "filter.d"
|
||||
|
||||
def _check_filter() -> None:
|
||||
conf_exists = (filter_d / f"{req.filter_name}.conf").is_file()
|
||||
local_exists = (filter_d / f"{req.filter_name}.local").is_file()
|
||||
if not conf_exists and not local_exists:
|
||||
raise FilterNotFoundError(req.filter_name)
|
||||
|
||||
await loop.run_in_executor(None, _check_filter)
|
||||
|
||||
await loop.run_in_executor(
|
||||
None,
|
||||
_set_jail_local_key_sync,
|
||||
Path(config_dir),
|
||||
jail_name,
|
||||
"filter",
|
||||
req.filter_name,
|
||||
)
|
||||
|
||||
if do_reload:
|
||||
try:
|
||||
await jail_service.reload_all(socket_path)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
log.warning(
|
||||
"reload_after_assign_filter_failed",
|
||||
jail=jail_name,
|
||||
filter=req.filter_name,
|
||||
error=str(exc),
|
||||
)
|
||||
|
||||
log.info(
|
||||
"filter_assigned_to_jail",
|
||||
jail=jail_name,
|
||||
filter=req.filter_name,
|
||||
reload=do_reload,
|
||||
)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user