from __future__ import annotations import shlex from pathlib import Path from typing import Annotated import structlog from fastapi import APIRouter, Depends, Query, Request, status from app.config import get_settings from app.dependencies import ( AuthDep, Fail2BanSocketDep, Fail2BanStartCommandDep, GlobalRateLimiterDep, SettingsServiceContextDep, ) from app.exceptions import OperationError from app.models.config import ( Fail2BanLogResponse, GlobalConfigResponse, GlobalConfigUpdate, LogPreviewRequest, LogPreviewResponse, MapColorThresholdsResponse, MapColorThresholdsUpdate, RegexTestRequest, RegexTestResponse, ServiceStatusResponse, ) from app.mappers import config_mappers from app.services import ( config_service, jail_service, log_service, ) from app.utils.constants import RATE_LIMIT_CONFIG_UPDATE_REQUESTS log: structlog.stdlib.BoundLogger = structlog.get_logger() router: APIRouter = APIRouter(tags=["Config Misc"]) # Rate limit bucket constants _CONFIG_UPDATE_BUCKET = "config:update" # 60 seconds per minute _MINUTE = 60 def _check_config_update_rate_limit( request: Request, rate_limiter: GlobalRateLimiterDep, ) -> None: """Check rate limit for config update operations.""" from app.utils.client_ip import get_client_ip settings = request.app.state.settings client_ip = get_client_ip(request, trusted_proxies=settings.trusted_proxies) is_allowed, retry_after = rate_limiter.check_allowed_for_bucket( _CONFIG_UPDATE_BUCKET, client_ip, RATE_LIMIT_CONFIG_UPDATE_REQUESTS, _MINUTE ) if not is_allowed: from app.exceptions import RateLimitError import structlog log = structlog.get_logger() log.warning( "config_update_rate_limit_exceeded", client_ip=client_ip, path=request.url.path, retry_after=retry_after, ) raise RateLimitError( "Rate limit exceeded for config updates. Please try again later.", retry_after_seconds=retry_after, ) def _validate_log_target(value: str) -> None: """Validate that log_target is either a special value or a valid file path. Args: value: The log target to validate. Raises: ValueError: If the target is not a special value and not in allowed directories. """ if value.upper() in ("STDOUT", "STDERR", "SYSLOG"): return settings = get_settings() try: resolved_path = Path(value).resolve() except (OSError, RuntimeError) as e: raise ValueError(f"Cannot resolve path {value!r}: {e}") from e for allowed_dir in settings.allowed_log_dirs: allowed_path = Path(allowed_dir).resolve() try: resolved_path.relative_to(allowed_path) return except ValueError: continue allowed_dirs_str = ", ".join(settings.allowed_log_dirs) raise ValueError( f"Log target {value!r} is outside allowed directories: {allowed_dirs_str}" ) @router.get( "/global", response_model=GlobalConfigResponse, summary="Return global fail2ban settings", ) async def get_global_config( _request: Request, _auth: AuthDep, socket_path: Fail2BanSocketDep, ) -> GlobalConfigResponse: """Return global fail2ban settings. Includes log level, log target, and database configuration. Args: request: Incoming request. _auth: Validated session. Returns: :class:`~app.models.config.GlobalConfigResponse`. Raises: HTTPException: 502 when fail2ban is unreachable. """ domain_result = await config_service.get_global_config(socket_path) return config_mappers.map_domain_global_config_to_response(domain_result) @router.put( "/global", status_code=status.HTTP_204_NO_CONTENT, summary="Update global fail2ban settings", dependencies=[Depends(_check_config_update_rate_limit)], ) async def update_global_config( _request: Request, _auth: AuthDep, socket_path: Fail2BanSocketDep, body: GlobalConfigUpdate, ) -> None: """Update global fail2ban settings. Args: request: Incoming request. _auth: Validated session. body: Partial update — only non-None fields are written. Raises: HTTPException: 400 when a set command is rejected or log_target is invalid. HTTPException: 502 when fail2ban is unreachable. """ if body.log_target is not None: _validate_log_target(body.log_target) await config_service.update_global_config(socket_path, body) # --------------------------------------------------------------------------- # Reload endpoint # --------------------------------------------------------------------------- @router.post( "/reload", status_code=status.HTTP_204_NO_CONTENT, summary="Reload fail2ban to apply configuration changes", ) async def reload_fail2ban( _request: Request, _auth: AuthDep, socket_path: Fail2BanSocketDep, ) -> None: """Trigger a full fail2ban reload. All jails are stopped and restarted with the current configuration. Args: request: Incoming request. _auth: Validated session. Raises: HTTPException: 409 when fail2ban reports the reload failed. HTTPException: 502 when fail2ban is unreachable. """ await jail_service.reload_all(socket_path) # Restart endpoint # --------------------------------------------------------------------------- @router.post( "/restart", status_code=status.HTTP_204_NO_CONTENT, summary="Restart the fail2ban service", ) async def restart_fail2ban( _request: Request, _auth: AuthDep, socket_path: Fail2BanSocketDep, start_cmd: Fail2BanStartCommandDep, ) -> None: """Trigger a full fail2ban service restart. Stops the fail2ban daemon via the Unix domain socket, then starts it again using the configured ``fail2ban_start_command``. After starting, probes the socket for up to 10 seconds to confirm the daemon came back online. Args: request: Incoming request. _auth: Validated session. Raises: HTTPException: 409 when fail2ban reports the stop command failed. HTTPException: 502 when fail2ban is unreachable for the stop command. HTTPException: 503 when fail2ban does not come back online within 10 seconds after being started. Check the fail2ban log for initialisation errors. Use ``POST /api/config/jails/{name}/rollback`` if a specific jail is suspect. """ start_cmd_parts: list[str] = shlex.split(start_cmd) restarted = await jail_service.restart_daemon( socket_path, start_cmd_parts, ) if not restarted: raise OperationError( "fail2ban was stopped but did not come back " "online within 10 seconds. " "Check the fail2ban log for initialisation errors. " "Use POST /api/config/jails/{name}/rollback if a " "specific jail is suspect." ) log.info("fail2ban_restarted") # --------------------------------------------------------------------------- # Regex tester (stateless) # --------------------------------------------------------------------------- @router.post( "/regex-test", response_model=RegexTestResponse, summary="Test a fail regex pattern against a sample log line", ) async def regex_test( _auth: AuthDep, body: RegexTestRequest, ) -> RegexTestResponse: """Test whether a regex pattern matches a given log line. This endpoint is entirely in-process — no fail2ban socket call is made. Returns the match result and any captured groups. Args: _auth: Validated session. body: Sample log line and regex pattern. Returns: :class:`~app.models.config.RegexTestResponse` with match result and groups. """ return log_service.test_regex(body) # --------------------------------------------------------------------------- # Log path management # --------------------------------------------------------------------------- @router.post( "/preview-log", response_model=LogPreviewResponse, summary="Preview log file lines against a regex pattern", ) async def preview_log( _auth: AuthDep, body: LogPreviewRequest, ) -> LogPreviewResponse: """Read the last N lines of a log file and test a regex against each one. Returns each line with a flag indicating whether the regex matched, and the captured groups for matching lines. The log file is read from the server's local filesystem. Args: _auth: Validated session. body: Log file path, regex pattern, and number of lines to read. Returns: :class:`~app.models.config.LogPreviewResponse` with per-line results. """ return await log_service.preview_log(body) # --------------------------------------------------------------------------- # Map color thresholds # --------------------------------------------------------------------------- @router.get( "/map-color-thresholds", response_model=MapColorThresholdsResponse, summary="Get map color threshold configuration", ) async def get_map_color_thresholds( _request: Request, _auth: AuthDep, settings_ctx: SettingsServiceContextDep, ) -> MapColorThresholdsResponse: """Return the configured map color thresholds. Args: _request: FastAPI request object. _auth: Validated session. settings_ctx: Settings service context containing db and repository. Returns: :class:`~app.models.config.MapColorThresholdsResponse` with current thresholds. """ return await config_service.get_map_color_thresholds(settings_ctx.db) @router.put( "/map-color-thresholds", response_model=MapColorThresholdsResponse, summary="Update map color threshold configuration", dependencies=[Depends(_check_config_update_rate_limit)], ) async def update_map_color_thresholds( _request: Request, _auth: AuthDep, settings_ctx: SettingsServiceContextDep, body: MapColorThresholdsUpdate, ) -> MapColorThresholdsResponse: """Update the map color threshold configuration. Args: _request: FastAPI request object. _auth: Validated session. settings_ctx: Settings service context containing db and repository. body: New threshold values. Returns: :class:`~app.models.config.MapColorThresholdsResponse` with updated thresholds. Raises: HTTPException: 400 if validation fails (thresholds not properly ordered). """ await config_service.update_map_color_thresholds(settings_ctx.db, body) return await config_service.get_map_color_thresholds(settings_ctx.db) @router.get( "/fail2ban-log", response_model=Fail2BanLogResponse, summary="Read the tail of the fail2ban daemon log file", ) async def get_fail2ban_log( _request: Request, _auth: AuthDep, socket_path: Fail2BanSocketDep, lines: Annotated[ int, Query( ge=1, le=2000, description="Number of lines to return from the tail.", ), ] = 200, filter_: Annotated[ # noqa: A002 str | None, Query( alias="filter", description=( "Plain-text substring filter; " "only matching lines are returned." ), ), ] = None, ) -> Fail2BanLogResponse: """Return the tail of the fail2ban daemon log file. Queries the fail2ban socket for the current log target and log level, reads the last *lines* entries from the file, and optionally filters them by *filter*. Only file-based log targets are supported. Args: request: Incoming request. _auth: Validated session — enforces authentication. lines: Number of tail lines to return (1–2000, default 200). filter: Optional plain-text substring — only matching lines returned. Returns: :class:`~app.models.config.Fail2BanLogResponse`. Raises: HTTPException: 400 when the log target is not a file or path is outside the allowed directory. HTTPException: 502 when fail2ban is unreachable. """ return await log_service.read_fail2ban_log(socket_path, lines, filter_) @router.get( "/service-status", response_model=ServiceStatusResponse, summary="Return fail2ban service health status with log configuration", ) async def get_service_status( _request: Request, _auth: AuthDep, socket_path: Fail2BanSocketDep, ) -> ServiceStatusResponse: """Return fail2ban service health and current log configuration. Probes the fail2ban daemon to determine online/offline state, then augments the result with the current log level and log target values. Args: request: Incoming request. _auth: Validated session — enforces authentication. Returns: :class:`~app.models.config.ServiceStatusResponse`. Raises: HTTPException: 502 when fail2ban is unreachable (the service itself handles this gracefully and returns ``online=False``). """ from app.services import health_service domain_result = await health_service.get_service_status( socket_path, probe_fn=health_service.probe, ) return config_mappers.map_domain_service_status_to_response(domain_result)