"""Configuration CLI for AstrBot. This module provides: - secure hashing utilities for the dashboard password (argon2) - validators for commonly configurable items - click CLI group with `set`, `get`, and `password` subcommands """ from __future__ import annotations import json import zoneinfo from collections.abc import Callable from typing import Any import click from filelock import FileLock, Timeout from astrbot.cli.i18n import t from astrbot.core.config.default import DEFAULT_CONFIG from astrbot.core.utils.astrbot_path import astrbot_paths from astrbot.core.utils.auth_password import ( _is_argon2_hash, _is_pbkdf2_hash, hash_dashboard_password, hash_legacy_dashboard_password, is_legacy_dashboard_password, validate_dashboard_password, ) # --- Password hashing & validation utilities --- def is_dashboard_password_hash(value: str) -> bool: """Heuristic: return True if `value` looks like a supported dashboard password hash.""" if not isinstance(value, str) or not value: return False return _is_argon2_hash(value) or _is_pbkdf2_hash(value) # --- Validators for CLI configuration items --- def _validate_log_level(value: str) -> str: value_up = value.upper() allowed = {"DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"} if value_up not in allowed: raise click.ClickException(t("config_log_level_invalid")) return value_up def _validate_dashboard_port(value: str) -> int: try: port = int(value) except ValueError: raise click.ClickException(t("config_port_must_be_number")) from None if port < 1 or port > 65535: raise click.ClickException(t("config_port_range_invalid")) return port def _validate_dashboard_username(value: str) -> str: if value is None or value.strip() == "": raise click.ClickException(t("config_username_empty")) return value.strip() def _validate_dashboard_password(value: str) -> str: if value is None or value == "": raise click.ClickException(t("config_password_empty")) try: validate_dashboard_password(value) except ValueError as e: raise click.ClickException(str(e)) from e # Return the plaintext value; callers hash it before storage. return value def _validate_timezone(value: str) -> str: try: zoneinfo.ZoneInfo(value) except Exception as e: raise click.ClickException(t("config_timezone_invalid", value=value)) from e return value def _validate_callback_api_base(value: str) -> str: if not (value.startswith("http://") or value.startswith("https://")): raise click.ClickException(t("config_callback_invalid")) return value CONFIG_VALIDATORS: dict[str, Callable[[str], Any]] = { "timezone": _validate_timezone, "log_level": _validate_log_level, "dashboard.port": _validate_dashboard_port, "dashboard.username": _validate_dashboard_username, "dashboard.password": _validate_dashboard_password, "callback_api_base": _validate_callback_api_base, } # --- Config file helpers --- def _load_config() -> dict[str, Any]: """Load or initialize the CLI config file (data/cmd_config.json). Ensures the astrbot root is valid before proceeding. """ root = astrbot_paths.root if not astrbot_paths.is_root: raise click.ClickException( f"{root} is not a valid AstrBot root directory. Use 'astrbot init' to initialize", ) config_path = astrbot_paths.data / "cmd_config.json" if not config_path.exists(): # Write DEFAULT_CONFIG to disk if file missing config_path.write_text( json.dumps(DEFAULT_CONFIG, ensure_ascii=False, indent=2), encoding="utf-8-sig", ) try: return json.loads(config_path.read_text(encoding="utf-8-sig")) except json.JSONDecodeError as e: raise click.ClickException(f"Failed to parse config file: {e!s}") from e def _save_config(config: dict[str, Any]) -> None: config_path = astrbot_paths.data / "cmd_config.json" config_path.write_text( json.dumps(config, ensure_ascii=False, indent=2), encoding="utf-8-sig", ) def ensure_config_file() -> dict[str, Any]: return _load_config() def _set_nested_item(obj: dict[str, Any], path: str, value: Any) -> None: parts = path.split(".") cur = obj for part in parts[:-1]: if part not in cur: cur[part] = {} elif not isinstance(cur[part], dict): raise click.ClickException( f"Config path conflict: {'.'.join(parts[: parts.index(part) + 1])} is not a dict", ) cur = cur[part] cur[parts[-1]] = value def _get_nested_item(obj: dict[str, Any], path: str) -> Any: parts = path.split(".") cur = obj for part in parts: cur = cur[part] return cur # --- CLI commands --- def prompt_dashboard_password(prompt: str = "Dashboard password") -> str: # 显示密码规则提示 click.echo() click.echo("密码规则:") click.echo(" - 至少 12 个字符") click.echo(" - 必须包含至少一个大写字母") click.echo(" - 必须包含至少一个小写字母") click.echo(" - 必须包含至少一个数字") click.echo() password = click.prompt(prompt, hide_input=True, confirmation_prompt=True, type=str) click.echo(f"密码长度: {len(password)} 字符") return _validate_dashboard_password(password) def set_dashboard_credentials( config: dict[str, Any], *, username: str | None = None, password_hash: str | None = None, ) -> None: if username is not None: _set_nested_item( config, "dashboard.username", _validate_dashboard_username(username), ) if password_hash is not None: if isinstance(password_hash, str) and is_dashboard_password_hash(password_hash): _set_nested_item(config, "dashboard.password", password_hash) else: if is_legacy_dashboard_password(password_hash): raise click.ClickException( "Storing legacy dashboard password hashes is no longer supported. " "Please provide the plaintext password (it will be hashed securely), " "or provide an Argon2-encoded hash string.", ) validated = _validate_dashboard_password(password_hash) _set_nested_item( config, "dashboard.pbkdf2_password", hash_dashboard_password(validated), ) _set_nested_item( config, "dashboard.password", hash_legacy_dashboard_password(validated), ) def _set_dashboard_password(config: dict[str, Any], raw_password: str) -> None: """Set dashboard password hashes and clear password migration flags.""" _set_nested_item( config, "dashboard.pbkdf2_password", hash_dashboard_password(raw_password), ) _set_nested_item( config, "dashboard.password", hash_legacy_dashboard_password(raw_password), ) _set_nested_item(config, "dashboard.password_storage_upgraded", True) _set_nested_item(config, "dashboard.password_change_required", False) @click.group(name="config") def conf() -> None: """Configuration management commands. Supported config keys: - timezone - log_level - dashboard.port - dashboard.username - dashboard.password - callback_api_base """ @conf.command(name="set") @click.argument("key") @click.argument("value") def set_config(key: str, value: str) -> None: if key not in CONFIG_VALIDATORS: raise click.ClickException(f"Unsupported config key: {key}") config = _load_config() try: # Attempt to get old value (may raise KeyError) try: old_value = _get_nested_item(config, key) except Exception: old_value = "" validated_value = CONFIG_VALIDATORS[key](value) if key == "dashboard.password": _set_dashboard_password(config, validated_value) else: _set_nested_item(config, key, validated_value) _save_config(config) click.echo(f"Config updated: {key}") click.echo(f" Old value: {old_value}") click.echo(f" New value: {validated_value}") except KeyError as e: raise click.ClickException(f"Unknown config key: {key}") from e except click.ClickException: raise except Exception as e: raise click.UsageError(f"Failed to set config: {e!s}") from e @conf.command(name="get") @click.argument("key", required=False) def get_config(key: str | None = None) -> None: config = _load_config() if key: if key not in CONFIG_VALIDATORS: raise click.ClickException(f"Unsupported config key: {key}") try: value = _get_nested_item(config, key) if key == "dashboard.password": value = "********" click.echo(f"{key}: {value}") except KeyError as e: raise click.ClickException(f"Unknown config key: {key}") from e except Exception as e: raise click.UsageError(f"Failed to get config: {e!s}") from e else: click.echo("Current config:") for k in CONFIG_VALIDATORS: try: v = ( "********" if k == "dashboard.password" else _get_nested_item(config, k) ) click.echo(f" {k}: {v}") except (KeyError, TypeError): # Missing or non-dict paths are simply skipped in listing pass def _check_astrbot_not_running() -> None: """Refuse to proceed if astrbot is currently running (lock file held).""" lock_file = astrbot_paths.root / "astrbot.lock" if not lock_file.exists(): return lock = FileLock(lock_file, timeout=1) try: lock.acquire() except Timeout: raise click.ClickException( "AstrBot is currently running. " "Please stop it first before changing the password via CLI.", ) from None else: lock.release() @conf.command(name="admin") @click.option("-u", "--username", type=str, help="Update admain username as well") @click.option( "-p", "--password", type=str, help="Set admain password directly without interactive prompt", ) def set_dashboard_password(username: str | None, password: str | None) -> None: """Interactively set dashboard password (with confirmation) or set directly with -p. Acceptable inputs: - Plaintext password (recommended): it will be hashed securely before storage. - Argon2 encoded hash (advanced): stored as-is. """ _check_astrbot_not_running() config = _load_config() if password is not None: if isinstance(password, str) and is_dashboard_password_hash(password): password_hash = password else: if is_legacy_dashboard_password(password): raise click.ClickException( "Providing legacy dashboard password hashes is no longer supported. " "Please supply the plaintext password (it will be hashed securely), " "or provide an Argon2-encoded hash string.", ) password_hash = _validate_dashboard_password(password) else: password_hash = prompt_dashboard_password() set_dashboard_credentials( config, username=username.strip() if username is not None else None, password_hash=password_hash, ) _save_config(config) if username is not None: click.echo(f"Dashboard username updated: {username.strip()}") click.echo("Dashboard password updated.")