"""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 app.utils.async_utils import run_blocking from pathlib import Path from typing import TYPE_CHECKING import bcrypt import structlog if TYPE_CHECKING: import aiosqlite from app.db import init_db, open_db 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() hashed: str = await run_blocking( lambda: bcrypt.hashpw(password_bytes, bcrypt.gensalt()).decode() ) await settings_repo.set_setting(db, _KEY_DATABASE_PATH, database_path) runtime_initialized = await _ensure_database_initialized(database_path) runtime_db: aiosqlite.Connection | None = None try: if runtime_initialized: runtime_db = await open_db(database_path) await settings_repo.set_setting(runtime_db, _KEY_PASSWORD_HASH, hashed) await settings_repo.set_setting(runtime_db, _KEY_DATABASE_PATH, database_path) await settings_repo.set_setting(runtime_db, _KEY_FAIL2BAN_SOCKET, fail2ban_socket) await settings_repo.set_setting(runtime_db, _KEY_TIMEZONE, timezone) await settings_repo.set_setting( runtime_db, _KEY_SESSION_DURATION, str(session_duration_minutes) ) await settings_repo.set_setting(runtime_db, _KEY_MAP_COLOR_THRESHOLD_HIGH, "100") await settings_repo.set_setting(runtime_db, _KEY_MAP_COLOR_THRESHOLD_MEDIUM, "50") await settings_repo.set_setting(runtime_db, _KEY_MAP_COLOR_THRESHOLD_LOW, "20") await settings_repo.set_setting(runtime_db, _KEY_SETUP_DONE, "1") finally: if runtime_db is not None: await runtime_db.close() # Mark setup as complete in the bootstrap configuration as the final step. await settings_repo.set_setting(db, _KEY_SETUP_DONE, "1") log.info("bangui_setup_completed") from app.utils.setup_utils import ( get_map_color_thresholds as util_get_map_color_thresholds, get_password_hash as util_get_password_hash, set_map_color_thresholds as util_set_map_color_thresholds, ) async def get_password_hash(db: aiosqlite.Connection) -> str | None: """Return the stored bcrypt password hash, or ``None`` if not set.""" return await util_get_password_hash(db) async def get_runtime_database_path(db: aiosqlite.Connection) -> str | None: """Return the runtime database path persisted during initial setup.""" return await settings_repo.get_setting(db, _KEY_DATABASE_PATH) async def get_persisted_runtime_settings(db: aiosqlite.Connection) -> dict[str, str | int]: """Return runtime configuration values persisted during initial setup.""" runtime_settings: dict[str, str | int] = {} database_path = await settings_repo.get_setting(db, _KEY_DATABASE_PATH) if database_path: runtime_settings["database_path"] = database_path fail2ban_socket = await settings_repo.get_setting(db, _KEY_FAIL2BAN_SOCKET) if fail2ban_socket: runtime_settings["fail2ban_socket"] = fail2ban_socket timezone = await settings_repo.get_setting(db, _KEY_TIMEZONE) if timezone: runtime_settings["timezone"] = timezone session_duration = await settings_repo.get_setting(db, _KEY_SESSION_DURATION) if session_duration is not None: try: runtime_settings["session_duration_minutes"] = int(session_duration) except ValueError: log.warning( "invalid_setup_setting", key=_KEY_SESSION_DURATION, value=session_duration, ) return runtime_settings async def _ensure_database_initialized(database_path: str) -> bool: """Create and initialise the configured runtime database if it does not exist.""" database_path_obj = Path(database_path) parent_dir = database_path_obj.parent try: parent_dir.mkdir(parents=True, exist_ok=True) except PermissionError: log.warning( "cannot_create_runtime_database_parent", database_path=database_path, parent=str(parent_dir), ) return False db = await open_db(str(database_path_obj)) try: await init_db(db) finally: await db.close() return True async def get_timezone(db: aiosqlite.Connection) -> str: """Return the configured IANA timezone string.""" 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).""" return await util_get_map_color_thresholds(db) 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.""" await util_set_map_color_thresholds( db, threshold_high=threshold_high, threshold_medium=threshold_medium, threshold_low=threshold_low, ) log.info( "map_color_thresholds_updated", high=threshold_high, medium=threshold_medium, low=threshold_low, )