Files
BanGUI/backend/app/services/protocols.py
Lukas 3bbf413c55 refactor: Make service dependencies explicit and injectable
Remove hidden cross-service coupling by making dependencies explicit through
dependency injection while maintaining backward compatibility via lazy imports.

Key changes:
- history_service and ban_service: Removed direct module-level imports of
  fail2ban_metadata_service, added optional service parameters to functions
- Added get_fail2ban_metadata_service() provider to dependencies.py
- Updated history router to inject Fail2BanMetadataService dependency
- history_service functions now use lazy imports in fallback paths for
  backward compatibility when service is not explicitly injected
- All test patches updated to use internal _get_fail2ban_db_path() helper
- jail_config_service and jail_service already follow best practices

This pattern prevents circular imports, makes services testable via explicit
mocking, and documents service dependencies clearly.

Fixes: Instructions.md #2 - Hidden cross-service coupling

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-27 18:26:08 +02:00

408 lines
10 KiB
Python

"""Service interface protocols for dependency injection.
These structural protocols define the public contract that routers and higher
layers depend on, without binding them to concrete module implementations.
"""
from __future__ import annotations
from typing import TYPE_CHECKING, Protocol, runtime_checkable
if TYPE_CHECKING:
from collections.abc import Awaitable, Callable
import aiohttp
import aiosqlite
from app.models.auth import Session
from app.models.ban import BanOrigin, JailBannedIpsResponse, TimeRange
from app.models.blocklist import (
BlocklistSource,
ImportLogListResponse,
ImportRunResult,
ImportSourceResult,
PreviewResponse,
ScheduleConfig,
ScheduleInfo,
)
from app.models.config import (
AddLogPathRequest,
GlobalConfigResponse,
GlobalConfigUpdate,
JailConfigListResponse,
JailConfigResponse,
JailConfigUpdate,
LogPreviewRequest,
LogPreviewResponse,
MapColorThresholdsResponse,
MapColorThresholdsUpdate,
RegexTestResponse,
)
from app.models.geo import GeoEnricher, GeoInfo
from app.models.history import HistoryListResponse, IpDetailResponse
from app.models.jail import JailDetailResponse, JailListResponse
from app.models.server import ServerSettingsResponse, ServerSettingsUpdate, ServerStatus
from app.services.geo_cache import GeoCache
class AuthService(Protocol):
"""Protocol for authentication service operations."""
async def login(
self,
db: aiosqlite.Connection,
password: str,
session_duration_minutes: int,
session_secret: str,
session_repo: object | None = None,
) -> tuple[str, str]:
...
async def validate_session(
self,
db: aiosqlite.Connection,
token: str,
session_secret: str | None = None,
session_repo: object | None = None,
) -> Session:
...
async def logout(
self,
db: aiosqlite.Connection,
token: str,
session_secret: str | None = None,
session_repo: object | None = None,
) -> str | None:
...
class JailService(Protocol):
"""Protocol for jail management service operations."""
async def list_jails(self, socket_path: str) -> JailListResponse:
...
async def get_jail(self, socket_path: str, name: str) -> JailDetailResponse:
...
async def reload_all(self, socket_path: str) -> None:
...
async def start_jail(self, socket_path: str, name: str) -> None:
...
async def stop_jail(self, socket_path: str, name: str) -> None:
...
async def set_idle(self, socket_path: str, name: str, *, on: bool) -> None:
...
async def reload_jail(self, socket_path: str, name: str) -> None:
...
async def get_ignore_list(self, socket_path: str, name: str) -> list[str]:
...
async def add_ignore_ip(self, socket_path: str, name: str, ip: str) -> None:
...
async def del_ignore_ip(self, socket_path: str, name: str, ip: str) -> None:
...
async def set_ignore_self(self, socket_path: str, name: str, *, on: bool) -> None:
...
async def get_jail_banned_ips(
self,
socket_path: str,
jail_name: str,
page: int,
page_size: int,
search: str | None = None,
*,
geo_batch_lookup: object,
http_session: object,
app_db: aiosqlite.Connection,
) -> JailBannedIpsResponse:
...
async def lookup_ip(
self,
socket_path: str,
ip: str,
geo_enricher: object,
) -> object:
...
@runtime_checkable
class BlocklistService(Protocol):
async def list_sources(self, db: aiosqlite.Connection) -> list[BlocklistSource]:
...
async def get_source(
self,
db: aiosqlite.Connection,
source_id: int,
) -> BlocklistSource | None:
...
async def create_source(
self,
db: aiosqlite.Connection,
name: str,
url: str,
*,
enabled: bool = True,
) -> BlocklistSource:
...
async def update_source(
self,
db: aiosqlite.Connection,
source_id: int,
*,
name: str | None = None,
url: str | None = None,
enabled: bool | None = None,
) -> BlocklistSource | None:
...
async def delete_source(self, db: aiosqlite.Connection, source_id: int) -> bool:
...
async def preview_source(
self,
url: str,
http_session: aiohttp.ClientSession,
*,
sample_lines: int = ...,
) -> PreviewResponse:
...
async def import_source(
self,
source: BlocklistSource,
http_session: aiohttp.ClientSession,
socket_path: str,
db: aiosqlite.Connection,
*,
geo_is_cached: Callable[[str], bool] | None = None,
geo_cache: GeoCache | None = None,
ban_ip: Callable[[str, str, str], Awaitable[None]],
) -> ImportSourceResult:
...
async def import_all(
self,
db: aiosqlite.Connection,
http_session: aiohttp.ClientSession,
socket_path: str,
*,
ban_ip: Callable[[str, str, str], Awaitable[None]],
geo_is_cached: Callable[[str], bool] | None = None,
geo_cache: GeoCache | None = None,
) -> ImportRunResult:
...
async def get_schedule(self, db: aiosqlite.Connection) -> ScheduleConfig:
...
async def set_schedule(self, db: aiosqlite.Connection, update: ScheduleConfig) -> None:
...
async def get_schedule_info(
self,
db: aiosqlite.Connection,
next_run_at: str | None,
) -> ScheduleInfo:
...
async def list_import_logs(
self,
db: aiosqlite.Connection,
*,
source_id: int | None = None,
page: int = 1,
page_size: int = 50,
) -> ImportLogListResponse:
...
@runtime_checkable
class ConfigService(Protocol):
async def get_jail_config(self, socket_path: str, name: str) -> JailConfigResponse:
...
async def list_jail_configs(self, socket_path: str) -> JailConfigListResponse:
...
async def update_jail_config(
self,
socket_path: str,
name: str,
update: JailConfigUpdate,
) -> None:
...
async def get_global_config(self, socket_path: str) -> GlobalConfigResponse:
...
async def update_global_config(
self,
socket_path: str,
update: GlobalConfigUpdate,
) -> None:
...
def test_regex(self, request: object) -> RegexTestResponse:
...
async def add_log_path(
self,
socket_path: str,
jail: str,
req: AddLogPathRequest,
) -> None:
...
async def delete_log_path(self, socket_path: str, jail: str, log_path: str) -> None:
...
async def preview_log(
self,
req: LogPreviewRequest,
preview_fn: Callable[[LogPreviewRequest], Awaitable[LogPreviewResponse]] | None = None,
) -> LogPreviewResponse:
...
async def get_map_color_thresholds(self, db: aiosqlite.Connection) -> MapColorThresholdsResponse:
...
async def update_map_color_thresholds(
self,
db: aiosqlite.Connection,
update: MapColorThresholdsUpdate,
) -> None:
...
@runtime_checkable
class HistoryService(Protocol):
async def list_history(
self,
socket_path: str,
*,
range_: TimeRange | None = None,
jail: str | None = None,
ip_filter: str | None = None,
origin: BanOrigin | None = None,
source: str = "fail2ban",
page: int = 1,
page_size: int = 100,
http_session: aiohttp.ClientSession | None = None,
geo_enricher: GeoEnricher | None = None,
db: aiosqlite.Connection | None = None,
) -> HistoryListResponse:
...
async def get_ip_detail(
self,
socket_path: str,
ip: str,
*,
http_session: aiohttp.ClientSession | None = None,
geo_enricher: GeoEnricher | None = None,
) -> IpDetailResponse | None:
...
@runtime_checkable
class GeoService(Protocol):
def clear_cache(self) -> None:
...
def clear_neg_cache(self) -> None:
...
def is_cached(self, ip: str) -> bool:
...
def init_geoip(self, mmdb_path: str | None) -> None:
...
async def cache_stats(self, db: aiosqlite.Connection) -> dict[str, int]:
...
async def count_unresolved(self, db: aiosqlite.Connection) -> int:
...
async def get_unresolved_ips(self, db: aiosqlite.Connection) -> list[str]:
...
async def load_cache_from_db(self, db: aiosqlite.Connection) -> None:
...
async def lookup(
self,
ip: str,
http_session: aiohttp.ClientSession,
) -> GeoInfo | None:
...
async def lookup_batch(
self,
ips: list[str],
http_session: aiohttp.ClientSession,
db: aiosqlite.Connection | None = None,
) -> dict[str, GeoInfo]:
...
def lookup_cached_only(self, ip: str) -> GeoInfo | None:
...
async def flush_dirty(self, db: aiosqlite.Connection) -> int:
...
@runtime_checkable
class HealthService(Protocol):
async def probe(self, socket_path: str, timeout: float = ...) -> ServerStatus:
...
@runtime_checkable
class Fail2BanMetadataService(Protocol):
"""Protocol for fail2ban runtime metadata resolution and caching."""
async def get_db_path(self, socket_path: str, *, force_refresh: bool = False) -> str:
...
def invalidate_db_path(self, socket_path: str) -> None:
...
@runtime_checkable
class HealthProbe(Protocol):
"""Protocol for health probing functions that check fail2ban availability."""
async def __call__(self, socket_path: str) -> ServerStatus:
...
@runtime_checkable
class ServerService(Protocol):
async def get_settings(self, socket_path: str) -> ServerSettingsResponse:
...
async def update_settings(
self,
socket_path: str,
update: ServerSettingsUpdate,
) -> None:
...
async def flush_logs(self, socket_path: str) -> str:
...