"""Setup service. Implements the one-time first-run configuration wizard. Responsible for hashing the master password, persisting all initial settings, and enforcing the rule that setup can only run once. """ from __future__ import annotations import asyncio from typing import TYPE_CHECKING import bcrypt import structlog if TYPE_CHECKING: import aiosqlite from app.repositories import settings_repo log: structlog.stdlib.BoundLogger = structlog.get_logger() # Keys used in the settings table. _KEY_PASSWORD_HASH = "master_password_hash" _KEY_SETUP_DONE = "setup_completed" _KEY_DATABASE_PATH = "database_path" _KEY_FAIL2BAN_SOCKET = "fail2ban_socket" _KEY_TIMEZONE = "timezone" _KEY_SESSION_DURATION = "session_duration_minutes" _KEY_MAP_COLOR_THRESHOLD_HIGH = "map_color_threshold_high" _KEY_MAP_COLOR_THRESHOLD_MEDIUM = "map_color_threshold_medium" _KEY_MAP_COLOR_THRESHOLD_LOW = "map_color_threshold_low" async def is_setup_complete(db: aiosqlite.Connection) -> bool: """Return ``True`` if initial setup has already been performed. Args: db: Active aiosqlite connection. Returns: ``True`` when the ``setup_completed`` key exists in settings. """ value = await settings_repo.get_setting(db, _KEY_SETUP_DONE) return value == "1" async def run_setup( db: aiosqlite.Connection, *, master_password: str, database_path: str, fail2ban_socket: str, timezone: str, session_duration_minutes: int, ) -> None: """Persist the initial configuration and mark setup as complete. Hashes *master_password* with bcrypt before storing. Raises :class:`RuntimeError` if setup has already been completed. Args: db: Active aiosqlite connection. master_password: Plain-text master password chosen by the user. database_path: Filesystem path to the BanGUI SQLite database. fail2ban_socket: Unix socket path for the fail2ban daemon. timezone: IANA timezone identifier (e.g. ``"UTC"``). session_duration_minutes: Session validity period in minutes. Raises: RuntimeError: If setup has already been completed. """ if await is_setup_complete(db): raise RuntimeError("Setup has already been completed.") log.info("bangui_setup_started") # Hash the master password — bcrypt automatically generates a salt. # Run in a thread executor so the blocking bcrypt operation does not stall # the asyncio event loop. password_bytes = master_password.encode() loop = asyncio.get_running_loop() hashed: str = await loop.run_in_executor( None, lambda: bcrypt.hashpw(password_bytes, bcrypt.gensalt()).decode() ) await settings_repo.set_setting(db, _KEY_PASSWORD_HASH, hashed) await settings_repo.set_setting(db, _KEY_DATABASE_PATH, database_path) await settings_repo.set_setting(db, _KEY_FAIL2BAN_SOCKET, fail2ban_socket) await settings_repo.set_setting(db, _KEY_TIMEZONE, timezone) await settings_repo.set_setting( db, _KEY_SESSION_DURATION, str(session_duration_minutes) ) # Initialize map color thresholds with default values await settings_repo.set_setting(db, _KEY_MAP_COLOR_THRESHOLD_HIGH, "100") await settings_repo.set_setting(db, _KEY_MAP_COLOR_THRESHOLD_MEDIUM, "50") await settings_repo.set_setting(db, _KEY_MAP_COLOR_THRESHOLD_LOW, "20") # Mark setup as complete — must be last so a partial failure leaves # setup_completed unset and does not lock out the user. await settings_repo.set_setting(db, _KEY_SETUP_DONE, "1") log.info("bangui_setup_completed") async def get_password_hash(db: aiosqlite.Connection) -> str | None: """Return the stored bcrypt password hash, or ``None`` if not set. Args: db: Active aiosqlite connection. Returns: The bcrypt hash string, or ``None``. """ return await settings_repo.get_setting(db, _KEY_PASSWORD_HASH) async def get_timezone(db: aiosqlite.Connection) -> str: """Return the configured IANA timezone string. Falls back to ``"UTC"`` when no timezone has been stored (e.g. before setup completes or for legacy databases). Args: db: Active aiosqlite connection. Returns: An IANA timezone identifier such as ``"Europe/Berlin"`` or ``"UTC"``. """ tz = await settings_repo.get_setting(db, _KEY_TIMEZONE) return tz if tz else "UTC" async def get_map_color_thresholds( db: aiosqlite.Connection, ) -> tuple[int, int, int]: """Return the configured map color thresholds (high, medium, low). Falls back to default values (100, 50, 20) if not set. Args: db: Active aiosqlite connection. Returns: A tuple of (threshold_high, threshold_medium, threshold_low). """ high = await settings_repo.get_setting( db, _KEY_MAP_COLOR_THRESHOLD_HIGH ) medium = await settings_repo.get_setting( db, _KEY_MAP_COLOR_THRESHOLD_MEDIUM ) low = await settings_repo.get_setting( db, _KEY_MAP_COLOR_THRESHOLD_LOW ) return ( int(high) if high else 100, int(medium) if medium else 50, int(low) if low else 20, ) async def set_map_color_thresholds( db: aiosqlite.Connection, *, threshold_high: int, threshold_medium: int, threshold_low: int, ) -> None: """Update the map color threshold configuration. Args: db: Active aiosqlite connection. threshold_high: Ban count for red coloring. threshold_medium: Ban count for yellow coloring. threshold_low: Ban count for green coloring. Raises: ValueError: If thresholds are not positive integers or if high <= medium <= low. """ if threshold_high <= 0 or threshold_medium <= 0 or threshold_low <= 0: raise ValueError("All thresholds must be positive integers.") if not (threshold_high > threshold_medium > threshold_low): raise ValueError("Thresholds must satisfy: high > medium > low.") await settings_repo.set_setting( db, _KEY_MAP_COLOR_THRESHOLD_HIGH, str(threshold_high) ) await settings_repo.set_setting( db, _KEY_MAP_COLOR_THRESHOLD_MEDIUM, str(threshold_medium) ) await settings_repo.set_setting( db, _KEY_MAP_COLOR_THRESHOLD_LOW, str(threshold_low) ) log.info( "map_color_thresholds_updated", high=threshold_high, medium=threshold_medium, low=threshold_low, )