dc4593f8b5
Product decision: drop 3389 remote desktop page, API, guacd sidecar, and related tests after unresolved target-side black screen issues.
248 lines
10 KiB
Python
248 lines
10 KiB
Python
"""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_ALERT_OFFLINE: str = "true" # 子机离线告警(Agent 心跳丢失)
|
||
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_alert_offline": "NOTIFY_ALERT_OFFLINE",
|
||
"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() |