"""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 from pathlib import Path from typing import TYPE_CHECKING import bcrypt import structlog from app.db import init_db, open_db from app.repositories import settings_repo as default_settings_repo from app.utils.async_utils import run_blocking if TYPE_CHECKING: import aiosqlite from app.repositories.protocols import SettingsRepository 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_SETUP_STATE = "setup_state" _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" # Setup state transitions: None → "in_progress" → "complete" _SETUP_STATE_IN_PROGRESS = "in_progress" _SETUP_STATE_COMPLETE = "complete" async def is_setup_complete( db: aiosqlite.Connection, settings_repo: SettingsRepository = default_settings_repo, ) -> bool: """Return ``True`` if initial setup has already been performed. Args: db: Active aiosqlite connection. settings_repo: Repository interface for settings persistence. 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, settings_repo: SettingsRepository = default_settings_repo, ) -> None: """Persist the initial configuration and mark setup as complete. Executes in three transactional phases to ensure atomicity across the bootstrap and runtime databases: 1. Bootstrap DB: marks setup as "in_progress" and records database_path 2. Runtime DB: writes all configuration settings atomically 3. Bootstrap DB: marks setup as "complete" If any phase fails, the entire operation can be safely retried. The setup_state key allows detection of partial setup states for cleanup. Raises: RuntimeError: If setup has already been completed, or if runtime DB initialization fails. Args: db: Active aiosqlite connection to the bootstrap database. 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. settings_repo: Repository interface for settings persistence. """ # Check if setup is already complete. if await is_setup_complete(db, settings_repo=settings_repo): raise RuntimeError("Setup has already been completed.") log.info("bangui_setup_started") # PHASE 0: Compute password hash early (before any DB writes). # This ensures the same hash value is used throughout retries, # making setup truly idempotent. Bcrypt uses a random salt, so # computing this fresh on each retry would create divergent hashes. password_bytes = master_password.encode() hashed: str = await run_blocking( lambda: bcrypt.hashpw(password_bytes, bcrypt.gensalt()).decode() ) log.info("bangui_setup_password_hashed") # PHASE 1: Bootstrap DB transaction # Mark setup as in-progress and record the runtime database path. # This is our first commit point — if we get here, the runtime DB # MUST be initialized or setup is in an incomplete state. try: await db.execute("BEGIN IMMEDIATE;") await db.execute( "INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)", (_KEY_SETUP_STATE, _SETUP_STATE_IN_PROGRESS), ) await db.execute( "INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)", (_KEY_DATABASE_PATH, database_path), ) await db.commit() except Exception as e: await db.rollback() log.error( "bangui_setup_failed", reason="bootstrap_db_transaction_failed", error=str(e), ) raise RuntimeError("Failed to initialize setup state in bootstrap database.") from e log.info("bangui_setup_bootstrap_phase_complete", database_path=database_path) # PHASE 2: Initialize runtime database. # This is outside a transaction because it involves filesystem operations. # If this fails, setup_state remains "in_progress" in bootstrap DB, allowing retry. runtime_initialized = await _ensure_database_initialized(database_path) if not runtime_initialized: log.error( "bangui_setup_failed", reason="runtime_database_initialization_failed", database_path=database_path, ) raise RuntimeError("Runtime database could not be initialized.") log.info("bangui_setup_runtime_db_initialized", database_path=database_path) # PHASE 3: Runtime DB transaction # Write all runtime settings atomically. If any setting fails, the entire # batch is rolled back. This ensures no partial configuration state. runtime_db: aiosqlite.Connection | None = None try: runtime_db = await open_db(database_path) # Prepare all runtime settings for atomic batch write. runtime_settings = { _KEY_PASSWORD_HASH: hashed, _KEY_DATABASE_PATH: database_path, _KEY_FAIL2BAN_SOCKET: fail2ban_socket, _KEY_TIMEZONE: timezone, _KEY_SESSION_DURATION: str(session_duration_minutes), _KEY_MAP_COLOR_THRESHOLD_HIGH: "100", _KEY_MAP_COLOR_THRESHOLD_MEDIUM: "50", _KEY_MAP_COLOR_THRESHOLD_LOW: "20", } await settings_repo.set_settings_batch(runtime_db, runtime_settings) log.info("bangui_setup_runtime_settings_written") except Exception as e: log.error( "bangui_setup_failed", reason="runtime_db_transaction_failed", error=str(e), ) raise RuntimeError("Failed to write settings to runtime database.") from e finally: if runtime_db is not None: await runtime_db.close() # PHASE 4: Bootstrap DB transaction (final) # Mark setup as complete. This is the final commit point — once this # succeeds, setup is fully complete. If this fails, setup_state remains # "in_progress" but can be detected on retry and cleaned up if needed. try: await db.execute("BEGIN IMMEDIATE;") await db.execute( "INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)", (_KEY_SETUP_STATE, _SETUP_STATE_COMPLETE), ) await db.execute( "INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)", (_KEY_SETUP_DONE, "1"), ) await db.commit() except Exception as e: await db.rollback() log.error( "bangui_setup_failed", reason="bootstrap_db_final_transaction_failed", error=str(e), ) raise RuntimeError("Failed to mark setup as complete.") from e log.info("bangui_setup_completed") async def get_runtime_database_path( db: aiosqlite.Connection, settings_repo: SettingsRepository = default_settings_repo, ) -> 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, settings_repo: SettingsRepository = default_settings_repo, ) -> 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: await run_blocking(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, settings_repo: SettingsRepository = default_settings_repo, ) -> str: """Return the configured IANA timezone string.""" tz = await settings_repo.get_setting(db, _KEY_TIMEZONE) return tz if tz else "UTC"