Files
Nexus/server/config.py
T

246 lines
10 KiB
Python
Raw Normal View History

"""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
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"
API_BASE_URL: str = "" # Public site URL — used for Agent HTTPS scheme (NEXUS_API_BASE_URL)
2026-05-22 08:19:56 +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"
# 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)
# 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
# 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()