Task 1: Remove ActiveBansSection from JailsPage
- Delete buildBanColumns, fmtTimestamp, ActiveBansSection
- Remove Dialog/Delete/Dismiss imports, ActiveBan type
- Update JSDoc to reflect three sections
Task 2: Remove JailDistributionChart from Dashboard
- Delete import and JSX block from DashboardPage.tsx
Task 3: Fix transparent pie chart (TopCountriesPieChart)
- Add Cell import and per-slice <Cell fill={slice.fill}> children inside <Pie>
- Suppress @typescript-eslint/no-deprecated (recharts v3 types)
Task 4: Allow /config/log as safe log prefix
- Add '/config/log' to _SAFE_LOG_PREFIXES in config_service.py
- Update error message to list both allowed directories
Task 5: Block jail activation on missing filter/logpath
- activate_jail refuses to proceed when filter/logpath issues found
- ActivateJailDialog treats all validation issues as blocking
- Trigger immediate _run_probe after activation in config router
- /api/health now reports fail2ban online/offline from cached probe
- Add TestActivateJailBlocking tests; fix existing tests to mock validation
930 lines
31 KiB
Python
930 lines
31 KiB
Python
"""Configuration inspection and editing service.
|
||
|
||
Provides methods to read and update fail2ban jail configuration and global
|
||
server settings via the Unix domain socket. Regex validation is performed
|
||
locally with Python's :mod:`re` module before any write is sent to the daemon
|
||
so that invalid patterns are rejected early.
|
||
|
||
Architecture note: this module is a pure service — it contains **no**
|
||
HTTP/FastAPI concerns. All results are returned as Pydantic models so
|
||
routers can serialise them directly.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import asyncio
|
||
import contextlib
|
||
import re
|
||
from pathlib import Path
|
||
from typing import TYPE_CHECKING, Any
|
||
|
||
import structlog
|
||
|
||
if TYPE_CHECKING:
|
||
import aiosqlite
|
||
|
||
from app.models.config import (
|
||
AddLogPathRequest,
|
||
BantimeEscalation,
|
||
Fail2BanLogResponse,
|
||
GlobalConfigResponse,
|
||
GlobalConfigUpdate,
|
||
JailConfig,
|
||
JailConfigListResponse,
|
||
JailConfigResponse,
|
||
JailConfigUpdate,
|
||
LogPreviewLine,
|
||
LogPreviewRequest,
|
||
LogPreviewResponse,
|
||
MapColorThresholdsResponse,
|
||
MapColorThresholdsUpdate,
|
||
RegexTestRequest,
|
||
RegexTestResponse,
|
||
ServiceStatusResponse,
|
||
)
|
||
from app.services import setup_service
|
||
from app.utils.fail2ban_client import Fail2BanClient
|
||
|
||
log: structlog.stdlib.BoundLogger = structlog.get_logger()
|
||
|
||
_SOCKET_TIMEOUT: float = 10.0
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Custom exceptions
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
class JailNotFoundError(Exception):
|
||
"""Raised when a requested jail name does not exist in fail2ban."""
|
||
|
||
def __init__(self, name: str) -> None:
|
||
"""Initialise with the jail name that was not found.
|
||
|
||
Args:
|
||
name: The jail name that could not be located.
|
||
"""
|
||
self.name: str = name
|
||
super().__init__(f"Jail not found: {name!r}")
|
||
|
||
|
||
class ConfigValidationError(Exception):
|
||
"""Raised when a configuration value fails validation before writing."""
|
||
|
||
|
||
class ConfigOperationError(Exception):
|
||
"""Raised when a configuration write command fails."""
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Internal helpers (mirrored from jail_service for isolation)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def _ok(response: Any) -> Any:
|
||
"""Extract payload from a fail2ban ``(return_code, data)`` response.
|
||
|
||
Args:
|
||
response: Raw value returned by :meth:`~Fail2BanClient.send`.
|
||
|
||
Returns:
|
||
The payload ``data`` portion of the response.
|
||
|
||
Raises:
|
||
ValueError: If the return code indicates an error.
|
||
"""
|
||
try:
|
||
code, data = response
|
||
except (TypeError, ValueError) as exc:
|
||
raise ValueError(f"Unexpected fail2ban response shape: {response!r}") from exc
|
||
if code != 0:
|
||
raise ValueError(f"fail2ban returned error code {code}: {data!r}")
|
||
return data
|
||
|
||
|
||
def _to_dict(pairs: Any) -> dict[str, Any]:
|
||
"""Convert a list of ``(key, value)`` pairs to a plain dict."""
|
||
if not isinstance(pairs, (list, tuple)):
|
||
return {}
|
||
result: dict[str, Any] = {}
|
||
for item in pairs:
|
||
try:
|
||
k, v = item
|
||
result[str(k)] = v
|
||
except (TypeError, ValueError):
|
||
pass
|
||
return result
|
||
|
||
|
||
def _ensure_list(value: Any) -> list[str]:
|
||
"""Coerce a fail2ban ``get`` result to a list of strings."""
|
||
if value is None:
|
||
return []
|
||
if isinstance(value, str):
|
||
return [value] if value.strip() else []
|
||
if isinstance(value, (list, tuple)):
|
||
return [str(v) for v in value if v is not None]
|
||
return [str(value)]
|
||
|
||
|
||
async def _safe_get(
|
||
client: Fail2BanClient,
|
||
command: list[Any],
|
||
default: Any = None,
|
||
) -> Any:
|
||
"""Send a command and return *default* if it fails."""
|
||
try:
|
||
return _ok(await client.send(command))
|
||
except Exception:
|
||
return default
|
||
|
||
|
||
def _is_not_found_error(exc: Exception) -> bool:
|
||
"""Return ``True`` if *exc* signals an unknown jail."""
|
||
msg = str(exc).lower()
|
||
return any(
|
||
phrase in msg
|
||
for phrase in ("unknown jail", "no jail", "does not exist", "not found")
|
||
)
|
||
|
||
|
||
def _validate_regex(pattern: str) -> str | None:
|
||
"""Try to compile *pattern* and return an error message if invalid.
|
||
|
||
Args:
|
||
pattern: A regex pattern string to validate.
|
||
|
||
Returns:
|
||
``None`` if valid, or an error message string if the pattern is broken.
|
||
"""
|
||
try:
|
||
re.compile(pattern)
|
||
return None
|
||
except re.error as exc:
|
||
return str(exc)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Public API — read jail configuration
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
async def get_jail_config(socket_path: str, name: str) -> JailConfigResponse:
|
||
"""Return the editable configuration for a single jail.
|
||
|
||
Args:
|
||
socket_path: Path to the fail2ban Unix domain socket.
|
||
name: Jail name.
|
||
|
||
Returns:
|
||
:class:`~app.models.config.JailConfigResponse`.
|
||
|
||
Raises:
|
||
JailNotFoundError: If *name* is not a known jail.
|
||
~app.utils.fail2ban_client.Fail2BanConnectionError: Socket unreachable.
|
||
"""
|
||
client = Fail2BanClient(socket_path=socket_path, timeout=_SOCKET_TIMEOUT)
|
||
|
||
# Verify existence.
|
||
try:
|
||
_ok(await client.send(["status", name, "short"]))
|
||
except ValueError as exc:
|
||
if _is_not_found_error(exc):
|
||
raise JailNotFoundError(name) from exc
|
||
raise
|
||
|
||
(
|
||
bantime_raw,
|
||
findtime_raw,
|
||
maxretry_raw,
|
||
failregex_raw,
|
||
ignoreregex_raw,
|
||
logpath_raw,
|
||
datepattern_raw,
|
||
logencoding_raw,
|
||
backend_raw,
|
||
usedns_raw,
|
||
prefregex_raw,
|
||
actions_raw,
|
||
bt_increment_raw,
|
||
bt_factor_raw,
|
||
bt_formula_raw,
|
||
bt_multipliers_raw,
|
||
bt_maxtime_raw,
|
||
bt_rndtime_raw,
|
||
bt_overalljails_raw,
|
||
) = await asyncio.gather(
|
||
_safe_get(client, ["get", name, "bantime"], 600),
|
||
_safe_get(client, ["get", name, "findtime"], 600),
|
||
_safe_get(client, ["get", name, "maxretry"], 5),
|
||
_safe_get(client, ["get", name, "failregex"], []),
|
||
_safe_get(client, ["get", name, "ignoreregex"], []),
|
||
_safe_get(client, ["get", name, "logpath"], []),
|
||
_safe_get(client, ["get", name, "datepattern"], None),
|
||
_safe_get(client, ["get", name, "logencoding"], "UTF-8"),
|
||
_safe_get(client, ["get", name, "backend"], "polling"),
|
||
_safe_get(client, ["get", name, "usedns"], "warn"),
|
||
_safe_get(client, ["get", name, "prefregex"], ""),
|
||
_safe_get(client, ["get", name, "actions"], []),
|
||
_safe_get(client, ["get", name, "bantime.increment"], False),
|
||
_safe_get(client, ["get", name, "bantime.factor"], None),
|
||
_safe_get(client, ["get", name, "bantime.formula"], None),
|
||
_safe_get(client, ["get", name, "bantime.multipliers"], None),
|
||
_safe_get(client, ["get", name, "bantime.maxtime"], None),
|
||
_safe_get(client, ["get", name, "bantime.rndtime"], None),
|
||
_safe_get(client, ["get", name, "bantime.overalljails"], False),
|
||
)
|
||
|
||
bantime_escalation = BantimeEscalation(
|
||
increment=bool(bt_increment_raw),
|
||
factor=float(bt_factor_raw) if bt_factor_raw is not None else None,
|
||
formula=str(bt_formula_raw) if bt_formula_raw else None,
|
||
multipliers=str(bt_multipliers_raw) if bt_multipliers_raw else None,
|
||
max_time=int(bt_maxtime_raw) if bt_maxtime_raw is not None else None,
|
||
rnd_time=int(bt_rndtime_raw) if bt_rndtime_raw is not None else None,
|
||
overall_jails=bool(bt_overalljails_raw),
|
||
)
|
||
|
||
jail_cfg = JailConfig(
|
||
name=name,
|
||
ban_time=int(bantime_raw or 600),
|
||
find_time=int(findtime_raw or 600),
|
||
max_retry=int(maxretry_raw or 5),
|
||
fail_regex=_ensure_list(failregex_raw),
|
||
ignore_regex=_ensure_list(ignoreregex_raw),
|
||
log_paths=_ensure_list(logpath_raw),
|
||
date_pattern=str(datepattern_raw) if datepattern_raw else None,
|
||
log_encoding=str(logencoding_raw or "UTF-8"),
|
||
backend=str(backend_raw or "polling"),
|
||
use_dns=str(usedns_raw or "warn"),
|
||
prefregex=str(prefregex_raw) if prefregex_raw else "",
|
||
actions=_ensure_list(actions_raw),
|
||
bantime_escalation=bantime_escalation,
|
||
)
|
||
|
||
log.info("jail_config_fetched", jail=name)
|
||
return JailConfigResponse(jail=jail_cfg)
|
||
|
||
|
||
async def list_jail_configs(socket_path: str) -> JailConfigListResponse:
|
||
"""Return configuration for all active jails.
|
||
|
||
Args:
|
||
socket_path: Path to the fail2ban Unix domain socket.
|
||
|
||
Returns:
|
||
:class:`~app.models.config.JailConfigListResponse`.
|
||
|
||
Raises:
|
||
~app.utils.fail2ban_client.Fail2BanConnectionError: Socket unreachable.
|
||
"""
|
||
client = Fail2BanClient(socket_path=socket_path, timeout=_SOCKET_TIMEOUT)
|
||
|
||
global_status = _to_dict(_ok(await client.send(["status"])))
|
||
jail_list_raw: str = str(global_status.get("Jail list", "") or "").strip()
|
||
jail_names: list[str] = (
|
||
[j.strip() for j in jail_list_raw.split(",") if j.strip()]
|
||
if jail_list_raw
|
||
else []
|
||
)
|
||
|
||
if not jail_names:
|
||
return JailConfigListResponse(jails=[], total=0)
|
||
|
||
responses: list[JailConfigResponse] = await asyncio.gather(
|
||
*[get_jail_config(socket_path, name) for name in jail_names],
|
||
return_exceptions=False,
|
||
)
|
||
|
||
jails = [r.jail for r in responses]
|
||
log.info("jail_configs_listed", count=len(jails))
|
||
return JailConfigListResponse(jails=jails, total=len(jails))
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Public API — write jail configuration
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
async def update_jail_config(
|
||
socket_path: str,
|
||
name: str,
|
||
update: JailConfigUpdate,
|
||
) -> None:
|
||
"""Apply *update* to the configuration of a running jail.
|
||
|
||
Each non-None field in *update* is sent as a separate ``set`` command.
|
||
Regex patterns are validated locally before any write is sent.
|
||
|
||
Args:
|
||
socket_path: Path to the fail2ban Unix domain socket.
|
||
name: Jail name.
|
||
update: Partial update payload.
|
||
|
||
Raises:
|
||
JailNotFoundError: If *name* is not a known jail.
|
||
ConfigValidationError: If a regex pattern fails to compile.
|
||
ConfigOperationError: If a ``set`` command is rejected by fail2ban.
|
||
~app.utils.fail2ban_client.Fail2BanConnectionError: Socket unreachable.
|
||
"""
|
||
# Validate all regex patterns before touching the daemon.
|
||
for pattern_list, field in [
|
||
(update.fail_regex, "fail_regex"),
|
||
(update.ignore_regex, "ignore_regex"),
|
||
]:
|
||
if pattern_list is None:
|
||
continue
|
||
for pattern in pattern_list:
|
||
err = _validate_regex(pattern)
|
||
if err:
|
||
raise ConfigValidationError(f"Invalid regex in {field!r}: {err!r} (pattern: {pattern!r})")
|
||
if update.prefregex is not None and update.prefregex:
|
||
err = _validate_regex(update.prefregex)
|
||
if err:
|
||
raise ConfigValidationError(f"Invalid regex in 'prefregex': {err!r} (pattern: {update.prefregex!r})")
|
||
|
||
client = Fail2BanClient(socket_path=socket_path, timeout=_SOCKET_TIMEOUT)
|
||
|
||
# Verify existence.
|
||
try:
|
||
_ok(await client.send(["status", name, "short"]))
|
||
except ValueError as exc:
|
||
if _is_not_found_error(exc):
|
||
raise JailNotFoundError(name) from exc
|
||
raise
|
||
|
||
async def _set(key: str, value: Any) -> None:
|
||
try:
|
||
_ok(await client.send(["set", name, key, value]))
|
||
except ValueError as exc:
|
||
raise ConfigOperationError(f"Failed to set {key!r} = {value!r}: {exc}") from exc
|
||
|
||
if update.ban_time is not None:
|
||
await _set("bantime", update.ban_time)
|
||
if update.find_time is not None:
|
||
await _set("findtime", update.find_time)
|
||
if update.max_retry is not None:
|
||
await _set("maxretry", update.max_retry)
|
||
if update.date_pattern is not None:
|
||
await _set("datepattern", update.date_pattern)
|
||
if update.dns_mode is not None:
|
||
await _set("usedns", update.dns_mode)
|
||
if update.backend is not None:
|
||
await _set("backend", update.backend)
|
||
if update.log_encoding is not None:
|
||
await _set("logencoding", update.log_encoding)
|
||
if update.prefregex is not None:
|
||
await _set("prefregex", update.prefregex)
|
||
if update.enabled is not None:
|
||
await _set("idle", "off" if update.enabled else "on")
|
||
|
||
# Ban-time escalation fields.
|
||
if update.bantime_escalation is not None:
|
||
esc = update.bantime_escalation
|
||
if esc.increment is not None:
|
||
await _set("bantime.increment", "true" if esc.increment else "false")
|
||
if esc.factor is not None:
|
||
await _set("bantime.factor", str(esc.factor))
|
||
if esc.formula is not None:
|
||
await _set("bantime.formula", esc.formula)
|
||
if esc.multipliers is not None:
|
||
await _set("bantime.multipliers", esc.multipliers)
|
||
if esc.max_time is not None:
|
||
await _set("bantime.maxtime", esc.max_time)
|
||
if esc.rnd_time is not None:
|
||
await _set("bantime.rndtime", esc.rnd_time)
|
||
if esc.overall_jails is not None:
|
||
await _set("bantime.overalljails", "true" if esc.overall_jails else "false")
|
||
|
||
# Replacing regex lists requires deleting old entries then adding new ones.
|
||
if update.fail_regex is not None:
|
||
await _replace_regex_list(client, name, "failregex", update.fail_regex)
|
||
if update.ignore_regex is not None:
|
||
await _replace_regex_list(client, name, "ignoreregex", update.ignore_regex)
|
||
|
||
log.info("jail_config_updated", jail=name)
|
||
|
||
|
||
async def _replace_regex_list(
|
||
client: Fail2BanClient,
|
||
jail: str,
|
||
field: str,
|
||
new_patterns: list[str],
|
||
) -> None:
|
||
"""Replace the full regex list for *field* in *jail*.
|
||
|
||
Deletes all existing entries (highest index first to preserve ordering)
|
||
then inserts all *new_patterns* in order.
|
||
|
||
Args:
|
||
client: Shared :class:`~app.utils.fail2ban_client.Fail2BanClient`.
|
||
jail: Jail name.
|
||
field: Either ``"failregex"`` or ``"ignoreregex"``.
|
||
new_patterns: Replacement list (may be empty to clear).
|
||
"""
|
||
# Determine current count.
|
||
current_raw = await _safe_get(client, ["get", jail, field], [])
|
||
current: list[str] = _ensure_list(current_raw)
|
||
|
||
del_cmd = f"del{field}"
|
||
add_cmd = f"add{field}"
|
||
|
||
# Delete in reverse order so indices stay stable.
|
||
for idx in range(len(current) - 1, -1, -1):
|
||
with contextlib.suppress(ValueError):
|
||
_ok(await client.send(["set", jail, del_cmd, idx]))
|
||
|
||
# Add new patterns.
|
||
for pattern in new_patterns:
|
||
err = _validate_regex(pattern)
|
||
if err:
|
||
raise ConfigValidationError(f"Invalid regex: {err!r} (pattern: {pattern!r})")
|
||
try:
|
||
_ok(await client.send(["set", jail, add_cmd, pattern]))
|
||
except ValueError as exc:
|
||
raise ConfigOperationError(f"Failed to add {field} pattern: {exc}") from exc
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Public API — global configuration
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
async def get_global_config(socket_path: str) -> GlobalConfigResponse:
|
||
"""Return fail2ban global configuration settings.
|
||
|
||
Args:
|
||
socket_path: Path to the fail2ban Unix domain socket.
|
||
|
||
Returns:
|
||
:class:`~app.models.config.GlobalConfigResponse`.
|
||
|
||
Raises:
|
||
~app.utils.fail2ban_client.Fail2BanConnectionError: Socket unreachable.
|
||
"""
|
||
client = Fail2BanClient(socket_path=socket_path, timeout=_SOCKET_TIMEOUT)
|
||
|
||
(
|
||
log_level_raw,
|
||
log_target_raw,
|
||
db_purge_age_raw,
|
||
db_max_matches_raw,
|
||
) = await asyncio.gather(
|
||
_safe_get(client, ["get", "loglevel"], "INFO"),
|
||
_safe_get(client, ["get", "logtarget"], "STDOUT"),
|
||
_safe_get(client, ["get", "dbpurgeage"], 86400),
|
||
_safe_get(client, ["get", "dbmaxmatches"], 10),
|
||
)
|
||
|
||
return GlobalConfigResponse(
|
||
log_level=str(log_level_raw or "INFO").upper(),
|
||
log_target=str(log_target_raw or "STDOUT"),
|
||
db_purge_age=int(db_purge_age_raw or 86400),
|
||
db_max_matches=int(db_max_matches_raw or 10),
|
||
)
|
||
|
||
|
||
async def update_global_config(socket_path: str, update: GlobalConfigUpdate) -> None:
|
||
"""Apply *update* to fail2ban global settings.
|
||
|
||
Args:
|
||
socket_path: Path to the fail2ban Unix domain socket.
|
||
update: Partial update payload.
|
||
|
||
Raises:
|
||
ConfigOperationError: If a ``set`` command is rejected.
|
||
~app.utils.fail2ban_client.Fail2BanConnectionError: Socket unreachable.
|
||
"""
|
||
client = Fail2BanClient(socket_path=socket_path, timeout=_SOCKET_TIMEOUT)
|
||
|
||
async def _set_global(key: str, value: Any) -> None:
|
||
try:
|
||
_ok(await client.send(["set", key, value]))
|
||
except ValueError as exc:
|
||
raise ConfigOperationError(f"Failed to set global {key!r} = {value!r}: {exc}") from exc
|
||
|
||
if update.log_level is not None:
|
||
await _set_global("loglevel", update.log_level.upper())
|
||
if update.log_target is not None:
|
||
await _set_global("logtarget", update.log_target)
|
||
if update.db_purge_age is not None:
|
||
await _set_global("dbpurgeage", update.db_purge_age)
|
||
if update.db_max_matches is not None:
|
||
await _set_global("dbmaxmatches", update.db_max_matches)
|
||
|
||
log.info("global_config_updated")
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Public API — regex tester (stateless, no socket)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def test_regex(request: RegexTestRequest) -> RegexTestResponse:
|
||
"""Test a regex pattern against a sample log line.
|
||
|
||
This is a pure in-process operation — no socket communication occurs.
|
||
|
||
Args:
|
||
request: The :class:`~app.models.config.RegexTestRequest` payload.
|
||
|
||
Returns:
|
||
:class:`~app.models.config.RegexTestResponse` with match result.
|
||
"""
|
||
try:
|
||
compiled = re.compile(request.fail_regex)
|
||
except re.error as exc:
|
||
return RegexTestResponse(matched=False, groups=[], error=str(exc))
|
||
|
||
match = compiled.search(request.log_line)
|
||
if match is None:
|
||
return RegexTestResponse(matched=False)
|
||
|
||
groups: list[str] = list(match.groups() or [])
|
||
return RegexTestResponse(matched=True, groups=[str(g) for g in groups if g is not None])
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Public API — log observation
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
async def add_log_path(
|
||
socket_path: str,
|
||
jail: str,
|
||
req: AddLogPathRequest,
|
||
) -> None:
|
||
"""Add a log path to an existing jail.
|
||
|
||
Args:
|
||
socket_path: Path to the fail2ban Unix domain socket.
|
||
jail: Jail name to which the log path should be added.
|
||
req: :class:`~app.models.config.AddLogPathRequest` with the path to add.
|
||
|
||
Raises:
|
||
JailNotFoundError: If *jail* is not a known jail.
|
||
ConfigOperationError: If the command is rejected by fail2ban.
|
||
~app.utils.fail2ban_client.Fail2BanConnectionError: Socket unreachable.
|
||
"""
|
||
client = Fail2BanClient(socket_path=socket_path, timeout=_SOCKET_TIMEOUT)
|
||
|
||
try:
|
||
_ok(await client.send(["status", jail, "short"]))
|
||
except ValueError as exc:
|
||
if _is_not_found_error(exc):
|
||
raise JailNotFoundError(jail) from exc
|
||
raise
|
||
|
||
tail_flag = "tail" if req.tail else "head"
|
||
try:
|
||
_ok(await client.send(["set", jail, "addlogpath", req.log_path, tail_flag]))
|
||
log.info("log_path_added", jail=jail, path=req.log_path)
|
||
except ValueError as exc:
|
||
raise ConfigOperationError(f"Failed to add log path {req.log_path!r}: {exc}") from exc
|
||
|
||
|
||
async def delete_log_path(
|
||
socket_path: str,
|
||
jail: str,
|
||
log_path: str,
|
||
) -> None:
|
||
"""Remove a monitored log path from an existing jail.
|
||
|
||
Uses ``set <jail> dellogpath <path>`` to remove the path at runtime
|
||
without requiring a daemon restart.
|
||
|
||
Args:
|
||
socket_path: Path to the fail2ban Unix domain socket.
|
||
jail: Jail name from which the log path should be removed.
|
||
log_path: Absolute path of the log file to stop monitoring.
|
||
|
||
Raises:
|
||
JailNotFoundError: If *jail* is not a known jail.
|
||
ConfigOperationError: If the command is rejected by fail2ban.
|
||
~app.utils.fail2ban_client.Fail2BanConnectionError: Socket unreachable.
|
||
"""
|
||
client = Fail2BanClient(socket_path=socket_path, timeout=_SOCKET_TIMEOUT)
|
||
|
||
try:
|
||
_ok(await client.send(["status", jail, "short"]))
|
||
except ValueError as exc:
|
||
if _is_not_found_error(exc):
|
||
raise JailNotFoundError(jail) from exc
|
||
raise
|
||
|
||
try:
|
||
_ok(await client.send(["set", jail, "dellogpath", log_path]))
|
||
log.info("log_path_deleted", jail=jail, path=log_path)
|
||
except ValueError as exc:
|
||
raise ConfigOperationError(f"Failed to delete log path {log_path!r}: {exc}") from exc
|
||
|
||
|
||
async def preview_log(req: LogPreviewRequest) -> LogPreviewResponse:
|
||
"""Read the last *num_lines* of a log file and test *fail_regex* against each.
|
||
|
||
This operation reads from the local filesystem — no socket is used.
|
||
|
||
Args:
|
||
req: :class:`~app.models.config.LogPreviewRequest`.
|
||
|
||
Returns:
|
||
:class:`~app.models.config.LogPreviewResponse` with line-by-line results.
|
||
"""
|
||
# Validate the regex first.
|
||
try:
|
||
compiled = re.compile(req.fail_regex)
|
||
except re.error as exc:
|
||
return LogPreviewResponse(
|
||
lines=[],
|
||
total_lines=0,
|
||
matched_count=0,
|
||
regex_error=str(exc),
|
||
)
|
||
|
||
path = Path(req.log_path)
|
||
if not path.is_file():
|
||
return LogPreviewResponse(
|
||
lines=[],
|
||
total_lines=0,
|
||
matched_count=0,
|
||
regex_error=f"File not found: {req.log_path!r}",
|
||
)
|
||
|
||
# Read the last num_lines lines efficiently.
|
||
try:
|
||
raw_lines = await asyncio.get_event_loop().run_in_executor(
|
||
None,
|
||
_read_tail_lines,
|
||
str(path),
|
||
req.num_lines,
|
||
)
|
||
except OSError as exc:
|
||
return LogPreviewResponse(
|
||
lines=[],
|
||
total_lines=0,
|
||
matched_count=0,
|
||
regex_error=f"Cannot read file: {exc}",
|
||
)
|
||
|
||
result_lines: list[LogPreviewLine] = []
|
||
matched_count = 0
|
||
for line in raw_lines:
|
||
m = compiled.search(line)
|
||
groups = [str(g) for g in (m.groups() or []) if g is not None] if m else []
|
||
result_lines.append(LogPreviewLine(line=line, matched=(m is not None), groups=groups))
|
||
if m:
|
||
matched_count += 1
|
||
|
||
return LogPreviewResponse(
|
||
lines=result_lines,
|
||
total_lines=len(result_lines),
|
||
matched_count=matched_count,
|
||
)
|
||
|
||
|
||
def _read_tail_lines(file_path: str, num_lines: int) -> list[str]:
|
||
"""Read the last *num_lines* from *file_path* synchronously.
|
||
|
||
Uses a memory-efficient approach that seeks from the end of the file.
|
||
|
||
Args:
|
||
file_path: Absolute path to the log file.
|
||
num_lines: Number of lines to return.
|
||
|
||
Returns:
|
||
A list of stripped line strings.
|
||
"""
|
||
chunk_size = 8192
|
||
raw_lines: list[bytes] = []
|
||
with open(file_path, "rb") as fh:
|
||
fh.seek(0, 2) # seek to end
|
||
end_pos = fh.tell()
|
||
if end_pos == 0:
|
||
return []
|
||
buf = b""
|
||
pos = end_pos
|
||
while len(raw_lines) <= num_lines and pos > 0:
|
||
read_size = min(chunk_size, pos)
|
||
pos -= read_size
|
||
fh.seek(pos)
|
||
chunk = fh.read(read_size)
|
||
buf = chunk + buf
|
||
raw_lines = buf.split(b"\n")
|
||
# Strip incomplete leading line unless we've read the whole file.
|
||
if pos > 0 and len(raw_lines) > 1:
|
||
raw_lines = raw_lines[1:]
|
||
return [ln.decode("utf-8", errors="replace").rstrip() for ln in raw_lines[-num_lines:] if ln.strip()]
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Map color thresholds
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
async def get_map_color_thresholds(db: aiosqlite.Connection) -> MapColorThresholdsResponse:
|
||
"""Retrieve the current map color threshold configuration.
|
||
|
||
Args:
|
||
db: Active aiosqlite connection to the application database.
|
||
|
||
Returns:
|
||
A :class:`MapColorThresholdsResponse` containing the three threshold values.
|
||
"""
|
||
high, medium, low = await setup_service.get_map_color_thresholds(db)
|
||
return MapColorThresholdsResponse(
|
||
threshold_high=high,
|
||
threshold_medium=medium,
|
||
threshold_low=low,
|
||
)
|
||
|
||
|
||
async def update_map_color_thresholds(
|
||
db: aiosqlite.Connection,
|
||
update: MapColorThresholdsUpdate,
|
||
) -> None:
|
||
"""Update the map color threshold configuration.
|
||
|
||
Args:
|
||
db: Active aiosqlite connection to the application database.
|
||
update: The new threshold values.
|
||
|
||
Raises:
|
||
ValueError: If validation fails (thresholds must satisfy high > medium > low).
|
||
"""
|
||
await setup_service.set_map_color_thresholds(
|
||
db,
|
||
threshold_high=update.threshold_high,
|
||
threshold_medium=update.threshold_medium,
|
||
threshold_low=update.threshold_low,
|
||
)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# fail2ban log file reader
|
||
# ---------------------------------------------------------------------------
|
||
|
||
# Log targets that are not file paths — log viewing is unavailable for these.
|
||
_NON_FILE_LOG_TARGETS: frozenset[str] = frozenset(
|
||
{"STDOUT", "STDERR", "SYSLOG", "SYSTEMD-JOURNAL"}
|
||
)
|
||
|
||
# Only allow reading log files under these base directories (security).
|
||
_SAFE_LOG_PREFIXES: tuple[str, ...] = ("/var/log", "/config/log")
|
||
|
||
|
||
def _count_file_lines(file_path: str) -> int:
|
||
"""Count the total number of lines in *file_path* synchronously.
|
||
|
||
Uses a memory-efficient buffered read to avoid loading the whole file.
|
||
|
||
Args:
|
||
file_path: Absolute path to the file.
|
||
|
||
Returns:
|
||
Total number of lines in the file.
|
||
"""
|
||
count = 0
|
||
with open(file_path, "rb") as fh:
|
||
for chunk in iter(lambda: fh.read(65536), b""):
|
||
count += chunk.count(b"\n")
|
||
return count
|
||
|
||
|
||
async def read_fail2ban_log(
|
||
socket_path: str,
|
||
lines: int,
|
||
filter_text: str | None = None,
|
||
) -> Fail2BanLogResponse:
|
||
"""Read the tail of the fail2ban daemon log file.
|
||
|
||
Queries the fail2ban socket for the current log target and log level,
|
||
validates that the target is a readable file, then returns the last
|
||
*lines* entries optionally filtered by *filter_text*.
|
||
|
||
Security: the resolved log path is rejected unless it starts with one of
|
||
the paths in :data:`_SAFE_LOG_PREFIXES`, preventing path traversal.
|
||
|
||
Args:
|
||
socket_path: Path to the fail2ban Unix domain socket.
|
||
lines: Number of lines to return from the tail of the file (1–2000).
|
||
filter_text: Optional plain-text substring — only matching lines are
|
||
returned. Applied server-side; does not affect *total_lines*.
|
||
|
||
Returns:
|
||
:class:`~app.models.config.Fail2BanLogResponse`.
|
||
|
||
Raises:
|
||
ConfigOperationError: When the log target is not a file, when the
|
||
resolved path is outside the allowed directories, or when the
|
||
file cannot be read.
|
||
~app.utils.fail2ban_client.Fail2BanConnectionError: Socket unreachable.
|
||
"""
|
||
client = Fail2BanClient(socket_path=socket_path, timeout=_SOCKET_TIMEOUT)
|
||
|
||
log_level_raw, log_target_raw = await asyncio.gather(
|
||
_safe_get(client, ["get", "loglevel"], "INFO"),
|
||
_safe_get(client, ["get", "logtarget"], "STDOUT"),
|
||
)
|
||
|
||
log_level = str(log_level_raw or "INFO").upper()
|
||
log_target = str(log_target_raw or "STDOUT")
|
||
|
||
# Reject non-file targets up front.
|
||
if log_target.upper() in _NON_FILE_LOG_TARGETS:
|
||
raise ConfigOperationError(
|
||
f"fail2ban is logging to {log_target!r}. "
|
||
"File-based log viewing is only available when fail2ban logs to a file path."
|
||
)
|
||
|
||
# Resolve and validate (security: no path traversal outside safe dirs).
|
||
try:
|
||
resolved = Path(log_target).resolve()
|
||
except (ValueError, OSError) as exc:
|
||
raise ConfigOperationError(
|
||
f"Cannot resolve log target path {log_target!r}: {exc}"
|
||
) from exc
|
||
|
||
resolved_str = str(resolved)
|
||
if not any(resolved_str.startswith(safe) for safe in _SAFE_LOG_PREFIXES):
|
||
raise ConfigOperationError(
|
||
f"Log path {resolved_str!r} is outside the allowed directory. "
|
||
"Only paths under /var/log or /config/log are permitted."
|
||
)
|
||
|
||
if not resolved.is_file():
|
||
raise ConfigOperationError(f"Log file not found: {resolved_str!r}")
|
||
|
||
loop = asyncio.get_event_loop()
|
||
|
||
total_lines, raw_lines = await asyncio.gather(
|
||
loop.run_in_executor(None, _count_file_lines, resolved_str),
|
||
loop.run_in_executor(None, _read_tail_lines, resolved_str, lines),
|
||
)
|
||
|
||
filtered = (
|
||
[ln for ln in raw_lines if filter_text in ln]
|
||
if filter_text
|
||
else raw_lines
|
||
)
|
||
|
||
log.info(
|
||
"fail2ban_log_read",
|
||
log_path=resolved_str,
|
||
lines_requested=lines,
|
||
lines_returned=len(filtered),
|
||
filter_active=filter_text is not None,
|
||
)
|
||
|
||
return Fail2BanLogResponse(
|
||
log_path=resolved_str,
|
||
lines=filtered,
|
||
total_lines=total_lines,
|
||
log_level=log_level,
|
||
log_target=log_target,
|
||
)
|
||
|
||
|
||
async def get_service_status(socket_path: str) -> ServiceStatusResponse:
|
||
"""Return fail2ban service health status with log configuration.
|
||
|
||
Delegates to :func:`~app.services.health_service.probe` for the core
|
||
health snapshot and augments it with the current log-level and log-target
|
||
values from the socket.
|
||
|
||
Args:
|
||
socket_path: Path to the fail2ban Unix domain socket.
|
||
|
||
Returns:
|
||
:class:`~app.models.config.ServiceStatusResponse`.
|
||
"""
|
||
from app.services.health_service import probe # lazy import avoids circular dep
|
||
|
||
server_status = await probe(socket_path)
|
||
|
||
if server_status.online:
|
||
client = Fail2BanClient(socket_path=socket_path, timeout=_SOCKET_TIMEOUT)
|
||
log_level_raw, log_target_raw = await asyncio.gather(
|
||
_safe_get(client, ["get", "loglevel"], "INFO"),
|
||
_safe_get(client, ["get", "logtarget"], "STDOUT"),
|
||
)
|
||
log_level = str(log_level_raw or "INFO").upper()
|
||
log_target = str(log_target_raw or "STDOUT")
|
||
else:
|
||
log_level = "UNKNOWN"
|
||
log_target = "UNKNOWN"
|
||
|
||
log.info(
|
||
"service_status_fetched",
|
||
online=server_status.online,
|
||
jail_count=server_status.active_jails,
|
||
)
|
||
|
||
return ServiceStatusResponse(
|
||
online=server_status.online,
|
||
version=server_status.version,
|
||
jail_count=server_status.active_jails,
|
||
total_bans=server_status.total_bans,
|
||
total_failures=server_status.total_failures,
|
||
log_level=log_level,
|
||
log_target=log_target,
|
||
)
|