Files
Nexus/server/api/install.py
T
Your Name a1276f38ad feat: 一键安装脚本 + PHP完全移除 + config.php→config.json
新增文件:
- deploy/install.sh: 7步一键安装(自动检测宝塔/标准模式)
- deploy/upgrade.sh: git pull + pip install + restart + 健康检查
- deploy/uninstall.sh: 停服务+删配置, 可选--purge删部署目录
- docs/install-guide.md: 完整中文安装教程(宝塔+手动)

核心改动:
- install.py: 宝塔Supervisor路径自适应, init-db返回bt_panel标记,
  config.php→config.json, 移除PHP锁文件, 生成Fernet加密密钥
- install.html: Step 5根据btPanel显示不同Supervisor/Nginx/SSL指引
- crypto.py: 移除PHP AES-CBC兼容层, 纯Fernet加密
- 删除 web/install.php (1192行PHP, 功能已完全迁移到Python)

PHP清理:
- Nginx配置: config\.php→config\.json deny规则
- config.py/main.py/migrations.py: install.php注释→install wizard
- CLAUDE.md/AGENTS.md: config.php→config.json, 移除PHP引用
- firefox_server.py: login.php→login.html默认URL

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-05-25 08:23:37 +08:00

688 lines
23 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 — Installation Wizard API
Runs BEFORE the main app is fully configured (no .env required).
Provides endpoints for the 5-step install wizard.
Security: All endpoints refuse to operate once .env exists or install is locked.
"""
import os
import secrets
import logging
import subprocess
from pathlib import Path
from datetime import datetime, timezone
from urllib.parse import quote_plus
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from sqlalchemy import text
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker, AsyncSession
from server.infrastructure.database.engine_compat import patch_aiomysql_do_ping
logger = logging.getLogger("nexus.install")
router = APIRouter(prefix="/api/install", tags=["install"])
# ── Paths ──
ROOT_DIR = Path(__file__).resolve().parent.parent.parent
ENV_FILE = ROOT_DIR / ".env"
INSTALL_LOCK = ROOT_DIR / ".install_locked"
CONFIG_DIR = ROOT_DIR / "web" / "data"
CONFIG_JSON = CONFIG_DIR / "config.json"
STATE_FILE = ROOT_DIR / ".install_state.json"
def _is_installed() -> bool:
return ENV_FILE.exists() or INSTALL_LOCK.exists()
def _is_locked() -> bool:
return INSTALL_LOCK.exists()
# ── Pydantic Models ──
class InitDbRequest(BaseModel):
db_host: str = "localhost"
db_port: str = "3306"
db_name: str = "Nexus"
db_user: str = "Nexus"
db_pass: str
redis_host: str = "127.0.0.1"
redis_port: str = "6379"
redis_db: str = "0"
redis_pass: str = ""
api_port: str = "8600"
timezone: str = "Asia/Shanghai"
site_url: str = ""
class CreateAdminRequest(BaseModel):
db_host: str = "localhost"
db_port: str = "3306"
db_name: str = "Nexus"
db_user: str = "Nexus"
db_pass: str
admin_username: str = "admin"
admin_password: str
admin_email: str = ""
system_name: str = "Nexus"
system_title: str = "Nexus — 服务器运维管理平台"
# ── Helpers ──
def _make_db_url(req: InitDbRequest | CreateAdminRequest) -> str:
return (
f"mysql+aiomysql://{req.db_user}:{quote_plus(req.db_pass)}"
f"@{req.db_host}:{req.db_port}/{req.db_name}"
)
def _build_redis_url(req: InitDbRequest) -> str:
url = "redis://"
if req.redis_pass:
url += f"{quote_plus(req.redis_pass)}@"
url += f"{req.redis_host}:{req.redis_port}/{req.redis_db}"
return url
def _escape_env_value(value: str) -> str:
"""Escape a value for safe inclusion in a .env file.
.env files are essentially shell source files — unquoted values break
on spaces, ``#``, ``$``, newlines, backslashes, etc. The safest
approach is to double-quote the value and escape any internal
double-quotes and backslashes (Docker/python-dotenv convention).
"""
escaped = value.replace("\\", "\\\\").replace('"', '\\"').replace("\n", "\\n")
return f'"{escaped}"'
def _write_env(req: InitDbRequest, secret_key: str, api_key: str, encryption_key: str,
pool_size: int, max_overflow: int, redis_url: str,
site_url: str) -> None:
install_dir = str(ROOT_DIR)
db_url = (
f"mysql+aiomysql://{req.db_user}:{quote_plus(req.db_pass)}"
f"@{req.db_host}:{req.db_port}/{req.db_name}"
)
lines = [
"# Nexus .env — Auto-generated by installer",
f"# {datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M:%S')} UTC",
"",
f"NEXUS_SYSTEM_NAME={_escape_env_value('Nexus')}",
f"NEXUS_SYSTEM_TITLE={_escape_env_value('Nexus — 服务器运维管理平台')}",
"",
"NEXUS_HOST=0.0.0.0",
f"NEXUS_PORT={req.api_port}",
"",
"# Database (MySQL 8.4 + aiomysql async driver)",
f"NEXUS_DATABASE_URL={_escape_env_value(db_url)}",
f"NEXUS_DB_POOL_SIZE={pool_size}",
f"NEXUS_DB_MAX_OVERFLOW={max_overflow}",
"",
"# Security — PRODUCTION KEYS (immutable after install)",
f"NEXUS_SECRET_KEY={_escape_env_value(secret_key)}",
f"NEXUS_API_KEY={_escape_env_value(api_key)}",
f"NEXUS_ENCRYPTION_KEY={_escape_env_value(encryption_key)}",
"",
"# Redis",
f"NEXUS_REDIS_URL={_escape_env_value(redis_url)}",
"",
"# Deployment",
f"NEXUS_DEPLOY_PATH={_escape_env_value(install_dir)}",
f"NEXUS_CORS_ORIGINS={_escape_env_value(f'{site_url},http://localhost:{req.api_port}')}",
"",
"# SSH",
"NEXUS_SSH_STRICT_HOST_CHECKING=true",
"",
"# Health Check",
"NEXUS_HEALTH_CHECK_INTERVAL=60",
f"NEXUS_API_BASE_URL={_escape_env_value(site_url)}",
"",
"# Alert Thresholds",
"NEXUS_CPU_ALERT_THRESHOLD=80",
"NEXUS_MEM_ALERT_THRESHOLD=80",
"NEXUS_DISK_ALERT_THRESHOLD=80",
"",
"# Telegram Alerts (optional — configure in Settings UI)",
"NEXUS_TELEGRAM_BOT_TOKEN=",
"NEXUS_TELEGRAM_CHAT_ID=",
"",
]
ENV_FILE.write_text("\n".join(lines))
logger.info(f".env written to {ENV_FILE}")
def _write_config_json(req: InitDbRequest, api_key: str, site_url: str) -> None:
"""Write shared configuration as JSON (replaces legacy config.php)."""
import json
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
install_dir = str(ROOT_DIR)
config = {
"_generated": datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC"),
"_generator": "Nexus 6.0 Installer",
"api_base_url": site_url,
"api_key": api_key,
"db_host": req.db_host,
"db_port": req.db_port,
"db_name": req.db_name,
"db_user": req.db_user,
"db_pass": req.db_pass,
"app_name": "Nexus",
"app_version": "6.0.0",
"session_lifetime": 28800,
"deploy_root": install_dir,
"timezone": req.timezone,
}
CONFIG_JSON.write_text(json.dumps(config, indent=2, ensure_ascii=False))
logger.info(f"config.json written to {CONFIG_JSON}")
def _detect_bt_panel() -> bool:
"""Detect if BT Panel (宝塔面板) is installed on this system."""
return Path("/www/server/panel/class/common.py").exists()
def _configure_guardian(install_dir: str, api_port: str) -> list[str]:
"""Best-effort: configure Supervisor, crontab, health_monitor.sh"""
results: list[str] = []
bt_panel = _detect_bt_panel()
# 1. Log directory
if bt_panel:
log_dir = Path("/www/wwwlogs")
else:
log_dir = Path("/var/log/nexus")
try:
log_dir.mkdir(parents=True, exist_ok=True)
results.append(f"✓ 日志目录已创建 {log_dir}")
except OSError:
results.append(f"✗ 日志目录创建失败(需 root 权限)")
# 2. Supervisor config
if bt_panel:
conf_path = Path("/www/server/panel/plugin/supervisor/profile/nexus.ini")
else:
conf_path = Path("/etc/supervisor/conf.d/nexus.conf")
supervisor_conf = (
f"[program:nexus]\n"
f"command={install_dir}/venv/bin/uvicorn server.main:app --host 0.0.0.0 --port {api_port}\n"
f"directory={install_dir}\n"
f"user=root\n"
f"autostart=true\n"
f"autorestart=true\n"
f"startretries=10\n"
f"startsecs=3\n"
f"stopwaitsecs=10\n"
f"stopsignal=INT\n"
f"environment=PATH=\"{install_dir}/venv/bin:/usr/local/bin:%(ENV_PATH)s\",HOME=\"/root\"\n"
f"stderr_logfile={log_dir}/nexus_error.log\n"
f"stdout_logfile={log_dir}/nexus_access.log\n"
f"stderr_logfile_maxbytes=50MB\n"
f"stdout_logfile_maxbytes=50MB\n"
f"stderr_logfile_backups=5\n"
f"stdout_logfile_backups=5\n"
)
try:
conf_path.parent.mkdir(parents=True, exist_ok=True)
conf_path.write_text(supervisor_conf)
mode_label = "宝塔" if bt_panel else "标准"
results.append(f"✓ Supervisor 配置已写入 {conf_path}{mode_label}模式)")
except OSError:
results.append("✗ Supervisor 配置写入失败(需 root 权限)")
# 3. Update health_monitor.sh INSTALL_DIR
health_sh = Path(install_dir) / "deploy" / "health_monitor.sh"
if health_sh.exists():
try:
content = health_sh.read_text()
import re
content = re.sub(
r'INSTALL_DIR="[^"]*"',
f'INSTALL_DIR="{install_dir}"',
content,
)
health_sh.write_text(content)
health_sh.chmod(0o755)
results.append("✓ health_monitor.sh INSTALL_DIR 已更新")
except OSError:
results.append("✗ health_monitor.sh 更新失败")
else:
results.append("⚠ health_monitor.sh 未找到,跳过")
# 4. Crontab
cron_entry = f"* * * * * {install_dir}/deploy/health_monitor.sh"
try:
current = subprocess.run(
["crontab", "-l"], capture_output=True, text=True, timeout=5
).stdout or ""
if "health_monitor.sh" not in current:
new_cron = current.rstrip() + "\n" + cron_entry + "\n"
proc = subprocess.run(
["crontab", "-"], input=new_cron, capture_output=True, text=True, timeout=5
)
if proc.returncode == 0:
results.append("✓ Crontab 已配置(每分钟健康检查)")
else:
results.append("✗ Crontab 配置失败 — 请手动添加")
else:
results.append("✓ Crontab 已存在 health_monitor 条目")
except Exception:
results.append("⚠ Crontab 配置跳过(权限不足或 cron 不可用)")
# 5. Reload Supervisor
try:
subprocess.run(["supervisorctl", "reread"], capture_output=True, timeout=5)
subprocess.run(["supervisorctl", "update"], capture_output=True, timeout=5)
results.append("✓ Supervisor 已重载配置")
except Exception:
results.append("⚠ Supervisor 未运行 — 请手动执行 supervisorctl reread && supervisorctl update")
return results
# ── Endpoints ──
@router.get("/status")
async def install_status():
"""Check whether Nexus is already installed."""
return {
"installed": _is_installed(),
"locked": _is_locked(),
}
def _reject_if_installed():
"""Block install wizard after .env exists (except GET /status)."""
if _is_installed():
raise HTTPException(
status_code=403,
detail="系统已安装,安装向导已关闭。仅可查询 /api/install/status",
)
# Backward-compatible alias
_reject_post_install_wizard = _reject_if_installed
@router.get("/env-check")
async def env_check():
"""Detect environment readiness — Python, MySQL client, Redis, write permissions."""
_reject_post_install_wizard()
import sys
import importlib
checks = []
# Python version
checks.append({
"name": "Python 版本",
"required": "3.12+",
"current": f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}",
"pass": sys.version_info >= (3, 12),
})
# MySQL client (aiomysql)
try:
import aiomysql
checks.append({
"name": "aiomysql 驱动",
"required": "已安装",
"current": f"✓ {aiomysql.__version__}",
"pass": True,
})
except ImportError:
checks.append({
"name": "aiomysql 驱动",
"required": "已安装",
"current": "✗ 未安装",
"pass": False,
})
# Redis
redis_ok = False
redis_msg = "未安装"
try:
import redis as rlib
try:
r = rlib.Redis(host="127.0.0.1", port=6379, socket_timeout=0.5)
r.ping()
redis_ok = True
redis_msg = "✓ 已连接 127.0.0.1:6379"
except Exception as e:
redis_msg = f"连接失败: {e}"
except ImportError:
redis_msg = "redis 库未安装"
checks.append({
"name": "Redis",
"required": "已连接",
"current": redis_msg,
"pass": redis_ok,
})
# MySQL database — user configures in step 3
checks.append({
"name": "MySQL 数据库",
"required": "步骤3配置",
"current": "— 请在步骤3填写数据库连接信息",
"pass": True,
})
# Write permissions
data_writable = CONFIG_DIR.exists() and os.access(CONFIG_DIR, os.W_OK) or \
os.access(ROOT_DIR / "web", os.W_OK)
root_writable = os.access(ROOT_DIR, os.W_OK)
checks.append({
"name": "web/data 写入权限",
"required": "可写",
"current": "✓ 可写" if data_writable else "✗ 不可写",
"pass": data_writable,
})
checks.append({
"name": "根目录写入权限 (.env)",
"required": "可写",
"current": "✓ 可写" if root_writable else "✗ 不可写",
"pass": root_writable,
})
all_pass = all(c["pass"] for c in checks)
return {"checks": checks, "all_pass": all_pass}
@router.post("/init-db")
async def init_db(req: InitDbRequest):
"""Step 3: Connect to MySQL, create tables, write configs."""
_reject_if_installed()
db_url = _make_db_url(req)
redis_url = _build_redis_url(req)
try:
patch_aiomysql_do_ping()
engine = create_async_engine(db_url, pool_pre_ping=True, pool_recycle=300)
except Exception as e:
raise HTTPException(400, f"数据库引擎创建失败: {e}")
try:
async with engine.begin() as conn:
# Create tables using SQLAlchemy metadata
from server.domain.models import Base
await conn.run_sync(Base.metadata.create_all)
# Create additional indexes
indexes = [
"ALTER TABLE servers ADD INDEX idx_servers_is_online (is_online)",
"ALTER TABLE servers ADD INDEX idx_servers_category (category)",
"ALTER TABLE servers ADD INDEX idx_servers_platform_id (platform_id)",
"ALTER TABLE servers ADD INDEX idx_servers_node_id (node_id)",
"ALTER TABLE sync_logs ADD INDEX idx_sync_logs_srv_start (server_id, started_at)",
"ALTER TABLE login_attempts ADD INDEX idx_login_attempts_username_ip (username, ip_address)",
"ALTER TABLE ssh_sessions ADD INDEX idx_ssh_sessions_server_id (server_id)",
"ALTER TABLE ssh_sessions ADD INDEX idx_ssh_sessions_admin_id (admin_id)",
"ALTER TABLE command_logs ADD INDEX idx_cmdlog_srv_time (server_id, created_at)",
"ALTER TABLE command_logs ADD INDEX idx_cmdlog_session_id (session_id)",
]
for idx_sql in indexes:
try:
await conn.execute(text(idx_sql))
except Exception:
pass # Index may already exist
# Schema migrations for existing databases (safe — no-op if column exists)
migrations = [
"ALTER TABLE push_schedules ADD COLUMN sync_mode VARCHAR(20) DEFAULT 'incremental' COMMENT '同步模式'",
]
for mig_sql in migrations:
try:
await conn.execute(text(mig_sql))
except Exception:
pass # Column may already exist
# Calculate pool_size from max_connections
try:
result = await conn.execute(text("SHOW VARIABLES LIKE 'max_connections'"))
row = result.fetchone()
max_conn = max(100, int(row[1]) if row else 100)
pool_size = max(20, int(max_conn * 0.4))
max_overflow = max(20, int(max_conn * 0.3))
except Exception:
pool_size, max_overflow = 160, 120
# Generate keys
secret_key = secrets.token_hex(32)
api_key = secrets.token_hex(16)
# Generate Fernet-compatible encryption key (32 url-safe base64 bytes)
import base64, hashlib
encryption_key = base64.urlsafe_b64encode(secrets.token_bytes(32)).decode()
# Insert settings
settings_kv = {
"api_key": api_key,
"secret_key": secret_key,
"system_name": "Nexus",
"system_title": "Nexus — 服务器运维管理平台",
"db_pool_size": str(pool_size),
"db_max_overflow": str(max_overflow),
"redis_url": redis_url,
"heartbeat_timeout": "60",
"cpu_alert_threshold": "80",
"mem_alert_threshold": "80",
"disk_alert_threshold": "80",
"telegram_bot_token": "",
"telegram_chat_id": "",
}
for k, v in settings_kv.items():
await conn.execute(
text("INSERT INTO `settings` (`key`, `value`) VALUES (:k, :v) "
"ON DUPLICATE KEY UPDATE `value` = :v2"),
{"k": k, "v": v, "v2": v},
)
except Exception as e:
await engine.dispose()
raise HTTPException(400, f"数据库初始化失败: {e}")
await engine.dispose()
# Write .env
site_url = req.site_url.rstrip("/") if req.site_url else f"http://127.0.0.1:{req.api_port}"
_write_env(req, secret_key, api_key, encryption_key, pool_size, max_overflow, redis_url, site_url)
# Write config.json
_write_config_json(req, api_key, site_url)
# Configure process guardian (best-effort)
install_dir = str(ROOT_DIR)
guardian_results = _configure_guardian(install_dir, req.api_port)
# Save install state for step 4
import json
state = {
"db_host": req.db_host,
"db_port": req.db_port,
"db_name": req.db_name,
"db_user": req.db_user,
# P2-19: Don't store db_pass in state file — only needed for _make_db_url()
# which is called with the full CreateAdminRequest in step 4
"pool_size": pool_size,
"max_overflow": max_overflow,
"install_dir": install_dir,
"site_url": site_url,
"api_port": req.api_port,
"guardian_results": guardian_results,
"step": 3,
}
STATE_FILE.write_text(json.dumps(state, ensure_ascii=False))
return {
"success": True,
"pool_size": pool_size,
"max_overflow": max_overflow,
"tables_created": 14,
"guardian_results": guardian_results,
"bt_panel": _detect_bt_panel(),
}
@router.post("/create-admin")
async def create_admin(req: CreateAdminRequest):
"""Step 4: Create admin account and set brand name."""
_reject_if_installed()
if _is_locked():
raise HTTPException(400, "系统已锁定")
if len(req.admin_password) < 6:
raise HTTPException(400, "密码长度至少6位")
db_url = _make_db_url(req)
try:
patch_aiomysql_do_ping()
engine = create_async_engine(db_url, pool_pre_ping=True, pool_recycle=300)
async with engine.begin() as conn:
# Hash password with bcrypt
import bcrypt
password_hash = bcrypt.hashpw(
req.admin_password.encode("utf-8"),
bcrypt.gensalt(),
).decode("utf-8")
# Insert admin
await conn.execute(
text(
"INSERT INTO admins (username, password_hash, email) "
"VALUES (:u, :h, :e) "
"ON DUPLICATE KEY UPDATE password_hash = :h2, email = :e2"
),
{
"u": req.admin_username,
"h": password_hash,
"e": req.admin_email or None,
"h2": password_hash,
"e2": req.admin_email or None,
},
)
# Update brand in settings
await conn.execute(
text("INSERT INTO `settings` (`key`, `value`) VALUES (:k, :v) "
"ON DUPLICATE KEY UPDATE `value` = :v2"),
{"k": "system_name", "v": req.system_name, "v2": req.system_name},
)
await conn.execute(
text("INSERT INTO `settings` (`key`, `value`) VALUES (:k, :v) "
"ON DUPLICATE KEY UPDATE `value` = :v2"),
{"k": "system_title", "v": req.system_title, "v2": req.system_title},
)
await engine.dispose()
except Exception as e:
raise HTTPException(400, f"创建管理员失败: {e}")
# Update config.json with brand
if CONFIG_JSON.exists():
import json
try:
config = json.loads(CONFIG_JSON.read_text())
config["app_name"] = req.system_name
CONFIG_JSON.write_text(json.dumps(config, indent=2, ensure_ascii=False))
except Exception:
pass
# Update .env with brand
if ENV_FILE.exists():
import re
content = ENV_FILE.read_text()
content = re.sub(
r"NEXUS_SYSTEM_NAME=.*",
f"NEXUS_SYSTEM_NAME={_escape_env_value(req.system_name)}",
content,
)
content = re.sub(
r"NEXUS_SYSTEM_TITLE=.*",
f"NEXUS_SYSTEM_TITLE={_escape_env_value(req.system_title)}",
content,
)
ENV_FILE.write_text(content)
# Update state
if STATE_FILE.exists():
import json
state = json.loads(STATE_FILE.read_text())
state["step"] = 4
state["system_name"] = req.system_name
STATE_FILE.write_text(json.dumps(state, ensure_ascii=False))
return {"success": True}
@router.post("/lock")
async def lock_install():
"""Step 5: Lock the installer — prevent re-installation.
Requires that an admin account exists (step 4 completed) before locking.
This prevents unauthenticated callers from locking the installer
before the legitimate admin has finished setup.
"""
_reject_if_installed()
# Verify admin account exists before allowing lock
try:
from server.infrastructure.database.session import AsyncSessionLocal
from server.infrastructure.database.admin_repo import AdminRepositoryImpl
async with AsyncSessionLocal() as session:
repo = AdminRepositoryImpl(session)
admin = await repo.get_by_username("admin")
if not admin:
raise HTTPException(
status_code=400,
detail="无法锁定:管理员账户尚未创建。请先完成安装步骤4。",
)
except HTTPException:
raise
except Exception as e:
logger.warning("Failed to verify admin account before locking: %s", e)
# If DB is unreachable, allow lock (admin may have been created but DB is temporarily down)
# Create lock marker file
INSTALL_LOCK.write_text(
f"Nexus installation locked at {datetime.now(timezone.utc).isoformat()}\n"
)
# Clean up state file
if STATE_FILE.exists():
try:
STATE_FILE.unlink()
except OSError as e:
logger.warning("Failed to remove install state file: %s", e)
return {"success": True}
@router.get("/state")
async def get_install_state():
"""Get current install state (for step navigation)."""
_reject_post_install_wizard()
import json
if not STATE_FILE.exists():
return {"step": 1, "installed": _is_installed(), "locked": _is_locked()}
try:
state = json.loads(STATE_FILE.read_text())
state["installed"] = _is_installed()
state["locked"] = _is_locked()
return state
except Exception:
return {"step": 1, "installed": _is_installed(), "locked": _is_locked()}