"""Nexus Configuration — Pydantic Settings with MySQL overrides Settings loading priority: 1. .env file (bootstrap — required for DB connection to even work) 2. MySQL settings table (dynamic overrides — loaded at startup via load_settings_from_db) Immutable settings (not overridable from DB): - SECRET_KEY, API_KEY, ENCRYPTION_KEY, DATABASE_URL — must be set in .env Mutable settings (overridable from DB): - system_name/title, pool_size/overflow, redis_url, alert thresholds, Telegram config """ import logging from typing import Optional from pydantic_settings import BaseSettings, SettingsConfigDict from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession logger = logging.getLogger("nexus.config") class Settings(BaseSettings): """Base settings loaded from .env file, overrideable via MySQL settings table""" # Brand (configurable in system settings) SYSTEM_NAME: str = "Nexus" SYSTEM_TITLE: str = "Nexus — 服务器运维管理平台" # Server HOST: str = "0.0.0.0" PORT: int = 8600 # Deployment DEPLOY_PATH: str = "/opt/nexus" # Base directory on the server CORS_ORIGINS: str = "" # Comma-separated: "https://example.com,http://localhost:8600" API_BASE_URL: str = "" # Public site URL — used for Agent HTTPS scheme (NEXUS_API_BASE_URL) # Database (immutable — must be set in .env) DATABASE_URL: str = "mysql+aiomysql://root:password@127.0.0.1:3306/nexus" # Pool params: install wizard auto-calculates from MySQL max_connections (authoritative). # Formula: pool_size = max(20, maxConn * 0.4), overflow = max(20, maxConn * 0.3) # Defaults below are pre-install placeholders only; production uses wizard .env or # docker/profiles/*.env via Compose — not these class defaults after install. DB_POOL_SIZE: Optional[int] = 160 DB_MAX_OVERFLOW: Optional[int] = 120 # Security (immutable — changing breaks encryption/auth) SECRET_KEY: str = "" # Must be set in .env; empty = startup error API_KEY: str = "" ENCRYPTION_KEY: str = "" # Redis (mutable — can be changed from settings UI) REDIS_URL: str = "redis://127.0.0.1:6379/0" # SSH — 运维平台连接大量未知子机,默认跳过主机密钥校验(安装向导可在 .env 写 true 覆盖) SSH_STRICT_HOST_CHECKING: str = "false" # JWT (mutable via .env / settings table — not immutable secrets) JWT_ACCESS_TOKEN_EXPIRE_MINUTES: int = 60 JWT_REFRESH_TOKEN_EXPIRE_DAYS: int = 30 # Health Check / Heartbeat HEALTH_CHECK_INTERVAL: int = 60 # seconds — Agent heartbeat interval # Script exec — built-in batching (servers per wave; avoids long single HTTP) SCRIPT_EXEC_BATCH_SIZE: int = 50 SCRIPT_EXEC_CONCURRENCY: int = 10 # max parallel SSH exec calls per batch # Rsync push — remote owner/mode after file sync (Baota/1Panel www site convention) RSYNC_PUSH_CHOWN: str = "www:www" RSYNC_PUSH_CHMOD: str = "D755,F755" # Alert thresholds (mutable — configurable from settings UI) CPU_ALERT_THRESHOLD: int = 80 MEM_ALERT_THRESHOLD: int = 80 DISK_ALERT_THRESHOLD: int = 80 # Telegram (mutable — configurable from settings UI) TELEGRAM_BOT_TOKEN: str = "" TELEGRAM_CHAT_ID: str = "" # API docs — "true" exposes /docs /redoc /openapi.json (default off for production) EXPOSE_API_DOCS: str = "false" # Login IP allowlist master switch — "true" = enforce allowlist, "false" = allow all LOGIN_ALLOWLIST_ENABLED: str = "false" # Subscription URL for auto-refreshing subscription IPs (every 2h) LOGIN_SUBSCRIPTION_URL: str = "" # IPs from subscription (replaced entirely on each refresh) LOGIN_SUBSCRIPTION_IPS: str = "" # Manually added IPs (never touched by auto-refresh) LOGIN_MANUAL_IPS: str = "" # Legacy: combined view used by auth check = subscription + manual LOGIN_ALLOWED_IPS: str = "" # Notification toggles — each alert type can be independently enabled/disabled # Stored as "true"/"false" strings in MySQL settings table NOTIFY_ALERT_CPU: str = "true" # CPU 超阈值告警 NOTIFY_ALERT_MEM: str = "true" # 内存超阈值告警 NOTIFY_ALERT_DISK: str = "true" # 磁盘超阈值告警 NOTIFY_RECOVERY: str = "true" # 资源恢复正常通知 NOTIFY_TIME_DRIFT: str = "true" # 时钟偏差严重告警(≥60s) NOTIFY_SYSTEM_REDIS: str = "true" # Redis 连接异常/恢复 NOTIFY_SYSTEM_MYSQL: str = "true" # MySQL 连接异常/恢复 NOTIFY_RESTART: str = "true" # Nexus 后端重启通知 NOTIFY_SCRIPT_COMPLETE: str = "true" # 脚本执行完成汇总(含看板链接) # extra=ignore: .env 中与 MCP 共存的 MYSQL_* / NEXUS_API_BASE_URL 等不进入 Settings model_config = SettingsConfigDict( env_prefix="NEXUS_", env_file=".env", extra="ignore", ) # ── MySQL settings table override ── # Mapping: MySQL settings.key → Settings attribute name # Only mutable settings are listed here; immutable ones (SECRET_KEY, API_KEY, etc.) are NOT DB_OVERRIDE_MAP: dict = { "system_name": "SYSTEM_NAME", "system_title": "SYSTEM_TITLE", "db_pool_size": "DB_POOL_SIZE", "db_max_overflow": "DB_MAX_OVERFLOW", "redis_url": "REDIS_URL", "heartbeat_timeout": "HEALTH_CHECK_INTERVAL", "cpu_alert_threshold": "CPU_ALERT_THRESHOLD", "mem_alert_threshold": "MEM_ALERT_THRESHOLD", "disk_alert_threshold": "DISK_ALERT_THRESHOLD", "telegram_bot_token": "TELEGRAM_BOT_TOKEN", "telegram_chat_id": "TELEGRAM_CHAT_ID", "login_allowlist_enabled": "LOGIN_ALLOWLIST_ENABLED", "login_subscription_url": "LOGIN_SUBSCRIPTION_URL", "login_subscription_ips": "LOGIN_SUBSCRIPTION_IPS", "login_manual_ips": "LOGIN_MANUAL_IPS", "login_allowed_ips": "LOGIN_ALLOWED_IPS", "api_base_url": "API_BASE_URL", "jwt_access_token_expire_minutes": "JWT_ACCESS_TOKEN_EXPIRE_MINUTES", "jwt_refresh_token_expire_days": "JWT_REFRESH_TOKEN_EXPIRE_DAYS", # Notification toggles "notify_alert_cpu": "NOTIFY_ALERT_CPU", "notify_alert_mem": "NOTIFY_ALERT_MEM", "notify_alert_disk": "NOTIFY_ALERT_DISK", "notify_recovery": "NOTIFY_RECOVERY", "notify_time_drift": "NOTIFY_TIME_DRIFT", "notify_system_redis": "NOTIFY_SYSTEM_REDIS", "notify_system_mysql": "NOTIFY_SYSTEM_MYSQL", "notify_restart": "NOTIFY_RESTART", "notify_script_complete": "NOTIFY_SCRIPT_COMPLETE", } # Settings that require int conversion from DB string values INT_SETTINGS: set = { "DB_POOL_SIZE", "DB_MAX_OVERFLOW", "HEALTH_CHECK_INTERVAL", "JWT_ACCESS_TOKEN_EXPIRE_MINUTES", "JWT_REFRESH_TOKEN_EXPIRE_DAYS", "CPU_ALERT_THRESHOLD", "MEM_ALERT_THRESHOLD", "DISK_ALERT_THRESHOLD", } async def load_settings_from_db(self, session: AsyncSession) -> int: """Override mutable settings from MySQL settings table. Called during app startup (after init_db) to apply dynamic config. Immutable settings (SECRET_KEY, API_KEY, ENCRYPTION_KEY, DATABASE_URL) are NOT overridable — they stay as .env values. Returns: number of settings overridden from DB """ from server.domain.models import Setting try: result = await session.execute(select(Setting)) rows = result.scalars().all() overridden = 0 for row in rows: attr_name = self.DB_OVERRIDE_MAP.get(row.key) if attr_name and hasattr(self, attr_name): value = row.value # Skip null values — .env value takes precedence if value is None: continue # Type conversion if attr_name in self.INT_SETTINGS: try: value = int(value) except (ValueError, TypeError): logger.warning(f"Invalid int value for {row.key}: {value}, skipping") continue setattr(self, attr_name, value) overridden += 1 logger.info( f"Settings loaded from DB: {len(rows)} keys read, " f"{overridden} overridden" ) return overridden except Exception as e: logger.error(f"Failed to load settings from DB: {e}") return 0 def apply_db_override(self, db_key: str, value: str) -> bool: """Apply one MySQL settings row to this in-memory instance. Used by PUT hot-reload and Redis Pub/Sub cross-worker sync. """ attr_name = self.DB_OVERRIDE_MAP.get(db_key) if not attr_name or not hasattr(self, attr_name): return False if value is None: return False parsed: str | int = value if attr_name in self.INT_SETTINGS: try: parsed = int(value) except (ValueError, TypeError): logger.warning("Invalid int value for %s: %s, skipping hot-reload", db_key, value) return False setattr(self, attr_name, parsed) return True async def ensure_api_base_url_in_db(self, session: AsyncSession) -> None: """Migrate api_base_url from .env to MySQL settings table if missing. Ensures existing installations (where .env has NEXUS_API_BASE_URL but the MySQL settings table was created before api_base_url was added to DB_OVERRIDE_MAP) have the value available for the settings API. """ from server.domain.models import Setting from sqlalchemy import select as sa_select try: result = await session.execute( sa_select(Setting).where(Setting.key == "api_base_url") ) existing = result.scalar_one_or_none() if existing is None and self.API_BASE_URL: new_setting = Setting(key="api_base_url", value=self.API_BASE_URL) session.add(new_setting) await session.commit() logger.info(f"Migrated api_base_url to MySQL settings table: {self.API_BASE_URL}") except Exception as e: logger.warning(f"Could not migrate api_base_url to DB: {e}") settings = Settings()