2026-05-20 17:02:45 +08:00
|
|
|
|
"""Nexus Configuration — Pydantic Settings with MySQL overrides
|
2026-05-20 14:19:09 +08:00
|
|
|
|
|
2026-05-20 17:02:45 +08:00
|
|
|
|
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
|
2026-05-20 14:19:09 +08:00
|
|
|
|
from typing import Optional
|
|
|
|
|
|
|
2026-05-23 15:26:56 +08:00
|
|
|
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
2026-05-20 17:02:45 +08:00
|
|
|
|
from sqlalchemy import select
|
|
|
|
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
|
|
|
|
|
|
|
|
logger = logging.getLogger("nexus.config")
|
|
|
|
|
|
|
2026-05-20 14:19:09 +08:00
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
|
|
2026-05-22 08:19:56 +08:00
|
|
|
|
# Deployment
|
|
|
|
|
|
DEPLOY_PATH: str = "/opt/nexus" # Base directory on the server
|
|
|
|
|
|
CORS_ORIGINS: str = "" # Comma-separated: "https://example.com,http://localhost:8600"
|
2026-05-23 15:26:56 +08:00
|
|
|
|
API_BASE_URL: str = "" # Public site URL — used for Agent HTTPS scheme (NEXUS_API_BASE_URL)
|
2026-05-22 08:19:56 +08:00
|
|
|
|
|
2026-05-20 17:02:45 +08:00
|
|
|
|
# Database (immutable — must be set in .env)
|
2026-05-21 22:54:35 +08:00
|
|
|
|
DATABASE_URL: str = "mysql+aiomysql://root:password@127.0.0.1:3306/nexus"
|
2026-06-06 17:40:30 +08:00
|
|
|
|
# Pool params: install wizard auto-calculates from MySQL max_connections (authoritative).
|
2026-05-22 08:19:56 +08:00
|
|
|
|
# Formula: pool_size = max(20, maxConn * 0.4), overflow = max(20, maxConn * 0.3)
|
2026-06-06 17:40:30 +08:00
|
|
|
|
# Defaults below are pre-install placeholders only; production uses wizard .env or
|
|
|
|
|
|
# docker/profiles/*.env via Compose — not these class defaults after install.
|
2026-05-22 08:19:56 +08:00
|
|
|
|
DB_POOL_SIZE: Optional[int] = 160
|
|
|
|
|
|
DB_MAX_OVERFLOW: Optional[int] = 120
|
2026-05-20 14:19:09 +08:00
|
|
|
|
|
2026-05-20 17:02:45 +08:00
|
|
|
|
# Security (immutable — changing breaks encryption/auth)
|
|
|
|
|
|
SECRET_KEY: str = "" # Must be set in .env; empty = startup error
|
2026-05-20 14:19:09 +08:00
|
|
|
|
API_KEY: str = ""
|
|
|
|
|
|
ENCRYPTION_KEY: str = ""
|
|
|
|
|
|
|
2026-05-20 17:02:45 +08:00
|
|
|
|
# Redis (mutable — can be changed from settings UI)
|
2026-05-20 14:19:09 +08:00
|
|
|
|
REDIS_URL: str = "redis://127.0.0.1:6379/0"
|
|
|
|
|
|
|
|
|
|
|
|
# SSH
|
|
|
|
|
|
SSH_STRICT_HOST_CHECKING: str = "true"
|
|
|
|
|
|
|
2026-06-01 12:17:47 +08:00
|
|
|
|
# JWT (mutable via .env / settings table — not immutable secrets)
|
|
|
|
|
|
JWT_ACCESS_TOKEN_EXPIRE_MINUTES: int = 60
|
|
|
|
|
|
JWT_REFRESH_TOKEN_EXPIRE_DAYS: int = 30
|
|
|
|
|
|
|
2026-05-20 17:02:45 +08:00
|
|
|
|
# Health Check / Heartbeat
|
|
|
|
|
|
HEALTH_CHECK_INTERVAL: int = 60 # seconds — Agent heartbeat interval
|
2026-05-20 14:19:09 +08:00
|
|
|
|
|
2026-05-23 15:26:56 +08:00
|
|
|
|
# Script exec — built-in batching (servers per wave; avoids long single HTTP)
|
|
|
|
|
|
SCRIPT_EXEC_BATCH_SIZE: int = 50
|
2026-05-24 16:26:40 +08:00
|
|
|
|
SCRIPT_EXEC_CONCURRENCY: int = 10 # max parallel SSH exec calls per batch
|
2026-05-23 15:26:56 +08:00
|
|
|
|
|
2026-05-20 17:02:45 +08:00
|
|
|
|
# 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)
|
2026-05-20 14:19:09 +08:00
|
|
|
|
TELEGRAM_BOT_TOKEN: str = ""
|
|
|
|
|
|
TELEGRAM_CHAT_ID: str = ""
|
|
|
|
|
|
|
2026-06-07 01:45:10 +08:00
|
|
|
|
# API docs — "true" exposes /docs /redoc /openapi.json (default off for production)
|
|
|
|
|
|
EXPOSE_API_DOCS: str = "false"
|
|
|
|
|
|
|
2026-05-23 18:19:47 +08:00
|
|
|
|
# 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)
|
2026-05-23 18:15:29 +08:00
|
|
|
|
LOGIN_SUBSCRIPTION_URL: str = ""
|
2026-05-23 18:19:47 +08:00
|
|
|
|
# IPs from subscription (replaced entirely on each refresh)
|
|
|
|
|
|
LOGIN_SUBSCRIPTION_IPS: str = ""
|
|
|
|
|
|
# Manually added IPs (never touched by auto-refresh)
|
2026-05-23 18:15:29 +08:00
|
|
|
|
LOGIN_MANUAL_IPS: str = ""
|
2026-05-23 18:19:47 +08:00
|
|
|
|
# Legacy: combined view used by auth check = subscription + manual
|
|
|
|
|
|
LOGIN_ALLOWED_IPS: str = ""
|
2026-05-23 18:11:23 +08:00
|
|
|
|
|
2026-05-23 17:12:07 +08:00
|
|
|
|
# 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 后端重启通知
|
|
|
|
|
|
|
2026-05-23 15:26:56 +08:00
|
|
|
|
# extra=ignore: .env 中与 MCP 共存的 MYSQL_* / NEXUS_API_BASE_URL 等不进入 Settings
|
|
|
|
|
|
model_config = SettingsConfigDict(
|
|
|
|
|
|
env_prefix="NEXUS_",
|
|
|
|
|
|
env_file=".env",
|
|
|
|
|
|
extra="ignore",
|
|
|
|
|
|
)
|
2026-05-20 14:19:09 +08:00
|
|
|
|
|
2026-05-20 17:02:45 +08:00
|
|
|
|
# ── 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",
|
2026-05-23 18:19:47 +08:00
|
|
|
|
"login_allowlist_enabled": "LOGIN_ALLOWLIST_ENABLED",
|
2026-05-23 18:15:29 +08:00
|
|
|
|
"login_subscription_url": "LOGIN_SUBSCRIPTION_URL",
|
2026-05-23 18:19:47 +08:00
|
|
|
|
"login_subscription_ips": "LOGIN_SUBSCRIPTION_IPS",
|
2026-05-23 18:15:29 +08:00
|
|
|
|
"login_manual_ips": "LOGIN_MANUAL_IPS",
|
2026-05-23 18:19:47 +08:00
|
|
|
|
"login_allowed_ips": "LOGIN_ALLOWED_IPS",
|
2026-05-27 14:33:19 +08:00
|
|
|
|
"api_base_url": "API_BASE_URL",
|
2026-06-01 12:17:47 +08:00
|
|
|
|
"jwt_access_token_expire_minutes": "JWT_ACCESS_TOKEN_EXPIRE_MINUTES",
|
|
|
|
|
|
"jwt_refresh_token_expire_days": "JWT_REFRESH_TOKEN_EXPIRE_DAYS",
|
2026-05-23 17:12:07 +08:00
|
|
|
|
# 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",
|
2026-05-20 17:02:45 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
# Settings that require int conversion from DB string values
|
|
|
|
|
|
INT_SETTINGS: set = {
|
|
|
|
|
|
"DB_POOL_SIZE", "DB_MAX_OVERFLOW", "HEALTH_CHECK_INTERVAL",
|
2026-06-01 12:17:47 +08:00
|
|
|
|
"JWT_ACCESS_TOKEN_EXPIRE_MINUTES", "JWT_REFRESH_TOKEN_EXPIRE_DAYS",
|
2026-05-20 17:02:45 +08:00
|
|
|
|
"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
|
2026-05-23 15:26:56 +08:00
|
|
|
|
# Skip null values — .env value takes precedence
|
|
|
|
|
|
if value is None:
|
|
|
|
|
|
continue
|
2026-05-20 17:02:45 +08:00
|
|
|
|
# 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
|
|
|
|
|
|
|
2026-05-27 14:33:19 +08:00
|
|
|
|
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}")
|
|
|
|
|
|
|
2026-05-20 14:19:09 +08:00
|
|
|
|
|
|
|
|
|
|
settings = Settings()
|