Files
Nexus/server/config.py
T
Nexus Agent 27da842a20 fix: Telegram 开关多 worker 同步与服务器行内详情展开
关闭 notify_* 后通过 Redis 广播同步各 worker 内存设置;修复 expanded id 类型不匹配导致点击名称无法展开详情。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-08 13:05:22 +08:00

246 lines
10 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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()