Refactor map color threshold storage into dedicated settings service
This commit is contained in:
@@ -13,10 +13,8 @@ routers can serialise them directly.
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from app.utils.async_utils import run_blocking
|
||||
import contextlib
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, TypeVar, cast
|
||||
|
||||
import structlog
|
||||
@@ -28,14 +26,10 @@ if TYPE_CHECKING:
|
||||
|
||||
import aiosqlite
|
||||
|
||||
from app import __version__
|
||||
from app.exceptions import ConfigOperationError, ConfigValidationError, JailNotFoundError
|
||||
from app.services.log_service import preview_log as util_preview_log
|
||||
from app.services.log_service import test_regex as util_test_regex
|
||||
from app.models.config import (
|
||||
AddLogPathRequest,
|
||||
BantimeEscalation,
|
||||
Fail2BanLogResponse,
|
||||
GlobalConfigResponse,
|
||||
GlobalConfigUpdate,
|
||||
JailConfig,
|
||||
@@ -48,15 +42,16 @@ from app.models.config import (
|
||||
MapColorThresholdsUpdate,
|
||||
RegexTestRequest,
|
||||
RegexTestResponse,
|
||||
ServiceStatusResponse,
|
||||
)
|
||||
from app.utils.fail2ban_client import Fail2BanClient
|
||||
from app.utils.setup_utils import (
|
||||
from app.services.log_service import preview_log as util_preview_log
|
||||
from app.services.log_service import test_regex as util_test_regex
|
||||
from app.services.settings_service import (
|
||||
get_map_color_thresholds as util_get_map_color_thresholds,
|
||||
)
|
||||
from app.utils.setup_utils import (
|
||||
from app.services.settings_service import (
|
||||
set_map_color_thresholds as util_set_map_color_thresholds,
|
||||
)
|
||||
from app.utils.fail2ban_client import Fail2BanClient
|
||||
|
||||
log: structlog.stdlib.BoundLogger = structlog.get_logger()
|
||||
|
||||
@@ -649,181 +644,3 @@ _NON_FILE_LOG_TARGETS: frozenset[str] = frozenset(
|
||||
_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."""
|
||||
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
|
||||
|
||||
|
||||
def _read_tail_lines(file_path: str, num_lines: int) -> list[str]:
|
||||
"""Read the last *num_lines* from *file_path* in a memory-efficient way."""
|
||||
chunk_size = 8192
|
||||
raw_lines: list[bytes] = []
|
||||
with open(file_path, "rb") as fh:
|
||||
fh.seek(0, 2)
|
||||
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")
|
||||
|
||||
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()]
|
||||
|
||||
|
||||
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_typed(client, ["get", "loglevel"], "INFO"),
|
||||
_safe_get_typed(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(
|
||||
run_blocking( _count_file_lines, resolved_str),
|
||||
run_blocking( _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,
|
||||
probe_fn: Callable[[str], Awaitable[ServiceStatusResponse]] | None = None,
|
||||
) -> ServiceStatusResponse:
|
||||
"""Return fail2ban service health status with log configuration.
|
||||
|
||||
Delegates to an injectable *probe_fn* (defaults to
|
||||
:func:`~app.services.health_service.probe`). This avoids direct service-to-
|
||||
service imports inside this module.
|
||||
|
||||
Args:
|
||||
socket_path: Path to the fail2ban Unix domain socket.
|
||||
probe_fn: Optional probe function.
|
||||
|
||||
Returns:
|
||||
:class:`~app.models.config.ServiceStatusResponse`.
|
||||
"""
|
||||
if probe_fn is None:
|
||||
raise ValueError("probe_fn is required to avoid service-to-service coupling")
|
||||
|
||||
server_status = await probe_fn(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_typed(client, ["get", "loglevel"], "INFO"),
|
||||
_safe_get_typed(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=__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,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user