Refactor map color threshold storage into dedicated settings service

This commit is contained in:
2026-04-17 15:13:07 +02:00
parent 13b3fde274
commit c21cf82e9e
11 changed files with 467 additions and 349 deletions

View File

@@ -1,22 +1,168 @@
"""Log helper service.
Contains regex test and log preview helpers that are independent of
fail2ban socket operations.
Contains regex test, log preview, and fail2ban log reading helpers.
"""
from __future__ import annotations
from app.utils.async_utils import run_blocking
import asyncio
import re
import structlog
from pathlib import Path
from typing import TypeVar, cast
from app.exceptions import ConfigOperationError
from app.models.config import (
Fail2BanLogResponse,
LogPreviewLine,
LogPreviewRequest,
LogPreviewResponse,
RegexTestRequest,
RegexTestResponse,
)
from app.utils.async_utils import run_blocking
from app.utils.fail2ban_client import (
Fail2BanClient,
Fail2BanConnectionError,
Fail2BanProtocolError,
Fail2BanResponse,
)
log: structlog.stdlib.BoundLogger = structlog.get_logger()
_SOCKET_TIMEOUT: float = 10.0
_NON_FILE_LOG_TARGETS: frozenset[str] = frozenset(
{"STDOUT", "STDERR", "SYSLOG", "SYSTEMD-JOURNAL"}
)
_SAFE_LOG_PREFIXES: tuple[str, ...] = ("/var/log", "/config/log")
def _ok(response: object) -> object:
"""Extract the payload from a fail2ban ``(return_code, data)`` response."""
try:
code, data = cast(Fail2BanResponse, 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 _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
async def _safe_get(
client: Fail2BanClient,
command: list[str],
default: object | None = None,
) -> object | None:
"""Send a command and return *default* if it fails."""
try:
return _ok(await client.send(command))
except (
Fail2BanConnectionError,
Fail2BanProtocolError,
OSError,
ValueError,
):
return default
T = TypeVar("T")
async def _safe_get_typed(
client: Fail2BanClient,
command: list[str],
default: T,
) -> T:
"""Send a command and return the result typed as ``default``'s type."""
return cast("T", await _safe_get(client, command, default))
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*.
"""
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")
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."
)
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}")
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,
)
def test_regex(request: RegexTestRequest) -> RegexTestResponse:
@@ -38,7 +184,10 @@ def test_regex(request: RegexTestRequest) -> RegexTestResponse:
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])
return RegexTestResponse(
matched=True,
groups=[str(g) for g in groups if g is not None],
)
async def preview_log(req: LogPreviewRequest) -> LogPreviewResponse:
@@ -87,7 +236,11 @@ async def preview_log(req: LogPreviewRequest) -> LogPreviewResponse:
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 []
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),
)
@@ -124,4 +277,8 @@ def _read_tail_lines(file_path: str, num_lines: int) -> list[str]:
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()]
return [
ln.decode("utf-8", errors="replace").rstrip()
for ln in raw_lines[-num_lines:]
if ln.strip()
]