"""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 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" 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. password_bytes = master_password.encode() hashed = 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) ) # 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"