Refactor map color threshold storage into dedicated settings service
This commit is contained in:
@@ -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()
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user