Files
Nexus/server/api/servers.py
T
Your Name cdd0be328a fix: 六轮深度扫描 — 47项Bug修复、安全加固、死代码清理
Critical runtime bugs:
- terminal.html WebSSH完全不可用(URL前缀/JSON解析/Content-Type三处错误)
- servers.py路由遮蔽:/logs被/{id}拦截,3个前端页面同步日志查询失败
- scripts.html startExecPoll()→startExecPolling(),长任务快速执行崩溃
- agent.py {value!r!s:.50}格式串非法,agent发非数值时ValueError
- alerts.html d.daily.reduce()无null检查,API返回空数据时TypeError

Resource leak / stability:
- websocket.py僵尸连接未关闭TCP,文件描述符泄漏
- websocket.py _last_alert_time字典无限增长(加1小时过期清理)
- asyncssh_pool.py全忙时超过MAX_CONNECTIONS无限增长
- self_monitor.py Telegram告警无冷却,宕机时每30秒刷屏
- schedule_runner.py一次性调度执行超60秒会重复触发
- 限速脚本EXPIRE每次重置窗口可绕过(改用Lua原子脚本)

Security:
- JWT access token加token_version声明,改密码后旧token立失效(零宽限)
- INSTALL_MODE导入时常量→动态函数,安装后JWT认证不再残留禁用
- install.py /lock端点加管理员存在性验证,防止阻断安装
- ServerUpdate schema移除connectivity只读字段,防止伪造连接状态

Frontend fixes:
- doExec()缺r.ok检查、commands.html null检查
- _server_to_dict()补last_checked_at+ssh_key_public
- _field_match()逗号cron表达式修复
- alerts类型显示、SSH会话名称、搜索高亮定位
- 一次性/循环定时任务(run_mode+fire_at+自动禁用)

Dead code removed (400+ lines):
- SyncService batch_push/_push_single等5个方法(零调用者)
- 5个未使用schema(SyncCommands/SyncConfig/SyncSftp/FileDeploy/PaginatedResponse)
- 6个零调用service方法、3个无前端API端点
- 4个未使用import

Schema migrations:
- push_schedules: run_mode + fire_at列,cron_expr改NULL
- servers: 7个新列 + ssh_key_private/public VARCHAR(500)→TEXT

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-05-24 16:26:40 +08:00

663 lines
24 KiB
Python

"""Nexus — Servers API Routes (CRUD + heartbeat + health check + push)
Presentation layer — receives HTTP requests, delegates to ServerService/SyncService.
Live server status comes from Redis (real-time), base info from MySQL.
All write operations require JWT authentication and produce audit logs.
"""
import json
import logging
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from server.api.dependencies import get_server_service, get_sync_service, get_db
from server.api.auth_jwt import get_current_admin
from server.api.schemas import ServerCreate, ServerUpdate, ServerCheck
from server.application.services.server_service import ServerService
from server.application.services.sync_service import SyncService
from server.domain.models import Server, SyncLog, Admin, AuditLog
from server.config import settings
from server.infrastructure.redis.client import get_redis
from server.infrastructure.database.audit_log_repo import AuditLogRepositoryImpl
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
logger = logging.getLogger("nexus.servers")
router = APIRouter(prefix="/api/servers", tags=["servers"])
REDIS_KEY_PREFIX = "heartbeat:"
# ── CRUD ──
@router.get("/", response_model=dict)
async def list_servers(
category: Optional[str] = Query(None, description="Filter by category"),
page: int = Query(1, ge=1, description="Page number"),
per_page: int = Query(50, ge=1, le=200, description="Items per page"),
admin: Admin = Depends(get_current_admin),
service: ServerService = Depends(get_server_service),
db: AsyncSession = Depends(get_db),
):
"""List servers with live status from Redis (DB-level pagination)
Base info (name/IP/category) from MySQL with DB-level pagination,
real-time status from Redis. If Redis heartbeat key exists,
override is_online/system_info/last_heartbeat.
"""
from server.infrastructure.database.server_repo import ServerRepositoryImpl
offset = (page - 1) * per_page
repo = ServerRepositoryImpl(db)
page_servers, total = await repo.get_paginated(category, offset, per_page)
redis = get_redis()
result = []
for server in page_servers:
server_data = _server_to_dict(server)
# Overlay Redis heartbeat data (real-time)
try:
heartbeat = await redis.hgetall(f"{REDIS_KEY_PREFIX}{server.id}")
if heartbeat:
server_data["is_online"] = heartbeat.get("is_online") == "True"
if heartbeat.get("system_info"):
try:
server_data["system_info"] = json.loads(heartbeat.get("system_info", "{}"))
except (json.JSONDecodeError, TypeError):
pass # Keep MySQL value
server_data["last_heartbeat"] = heartbeat.get("last_heartbeat", server_data.get("last_heartbeat"))
server_data["agent_version"] = heartbeat.get("agent_version", server_data.get("agent_version", ""))
# Time drift detection fields
try:
server_data["time_drift_seconds"] = float(heartbeat.get("time_drift_seconds", 0))
except (ValueError, TypeError):
server_data["time_drift_seconds"] = 0.0
server_data["drift_level"] = heartbeat.get("drift_level", "ok")
server_data["_source"] = "redis"
except Exception as e:
logger.warning(f"Redis read failed for server {server.id}: {e}")
result.append(server_data)
return {
"items": result,
"total": total,
"page": page,
"per_page": per_page,
"pages": (total + per_page - 1) // per_page,
}
# ── Dashboard Stats (must be before /{id} to avoid path collision) ──
@router.get("/stats", response_model=dict)
async def server_stats(
admin: Admin = Depends(get_current_admin),
db: AsyncSession = Depends(get_db),
):
"""Aggregated server stats for dashboard — SQL aggregation + Redis alert count"""
from sqlalchemy import func, text
# Total + category breakdown in a single query
result = await db.execute(
select(Server.category, func.count(Server.id))
.group_by(Server.category)
)
categories = {}
total = 0
for cat, cnt in result.all():
key = cat or "uncategorized"
categories[key] = cnt
total += cnt
# Online count from Redis heartbeats (real-time); MySQL is stale between flushes
redis = get_redis()
online = 0
try:
cursor = 0
while True:
cursor, keys = await redis.scan(cursor, match="heartbeat:*", count=200)
for key in keys:
data = await redis.hgetall(key)
if data.get("is_online") == "True":
online += 1
if cursor == 0:
break
except Exception as e:
logger.warning(f"Failed to count Redis heartbeats in server_stats: {e}")
result = await db.execute(
select(func.count(Server.id)).where(Server.is_online == True)
)
online = int(result.scalar() or 0)
alerts = 0
try:
cursor = 0
while True:
cursor, keys = await redis.scan(cursor, match="alerts:*", count=200)
alerts += len(keys)
if cursor == 0:
break
except Exception as e:
logger.warning(f"Failed to count Redis alerts in server_stats: {e}")
return {
"total": total,
"online": online,
"offline": max(0, total - online),
"alerts": alerts,
"categories": categories,
}
# ── Sync Logs ──
@router.get("/logs", response_model=dict)
async def get_all_sync_logs(
server_id: Optional[int] = Query(None, description="Filter by server ID"),
status: Optional[str] = Query(None, description="success|failed|running"),
sync_mode: Optional[str] = Query(None, description="incremental|full|overwrite|checksum|command"),
trigger_type: Optional[str] = Query(None, description="manual|schedule|retry|batch"),
offset: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=500),
admin: Admin = Depends(get_current_admin),
db: AsyncSession = Depends(get_db),
):
"""Get paginated sync logs across all servers with optional filters."""
from server.infrastructure.database.sync_log_repo import SyncLogRepositoryImpl
repo = SyncLogRepositoryImpl(db)
rows, total = await repo.get_all_paginated(
server_id=server_id, status=status, sync_mode=sync_mode,
trigger_type=trigger_type, offset=offset, limit=limit,
)
items = [_sync_log_to_dict(log, server_name=name) for log, name in rows]
return {"items": items, "total": total, "offset": offset, "limit": limit}
@router.get("/{id}", response_model=dict)
async def get_server(
id: int,
admin: Admin = Depends(get_current_admin),
service: ServerService = Depends(get_server_service),
):
"""Get a single server by ID with live status from Redis"""
server = await service.get_server(id)
if not server:
raise HTTPException(status_code=404, detail="Server not found")
server_data = _server_to_dict(server)
# Overlay Redis heartbeat data
redis = get_redis()
try:
heartbeat = await redis.hgetall(f"{REDIS_KEY_PREFIX}{id}")
if heartbeat:
server_data["is_online"] = heartbeat.get("is_online") == "True"
if heartbeat.get("system_info"):
try:
server_data["system_info"] = json.loads(heartbeat.get("system_info", "{}"))
except (json.JSONDecodeError, TypeError):
pass
server_data["last_heartbeat"] = heartbeat.get("last_heartbeat", server_data.get("last_heartbeat"))
server_data["agent_version"] = heartbeat.get("agent_version", server_data.get("agent_version", ""))
try:
server_data["time_drift_seconds"] = float(heartbeat.get("time_drift_seconds", 0))
except (ValueError, TypeError):
server_data["time_drift_seconds"] = 0.0
server_data["drift_level"] = heartbeat.get("drift_level", "ok")
server_data["_source"] = "redis"
except Exception as e:
logger.warning(f"Redis read failed for server {id}: {e}")
return server_data
@router.post("/", response_model=dict, status_code=201)
async def create_server(
request: Request,
payload: ServerCreate,
admin: Admin = Depends(get_current_admin),
service: ServerService = Depends(get_server_service),
db: AsyncSession = Depends(get_db),
):
"""Create a new server (sensitive fields encrypted at rest)"""
from server.infrastructure.database.crypto import encrypt_value
data = payload.model_dump(exclude_none=True)
# Encrypt sensitive fields before storing
if data.get("password"):
data["password"] = encrypt_value(data["password"])
if data.get("ssh_key_private"):
data["ssh_key_private"] = encrypt_value(data["ssh_key_private"])
server = Server(**data)
created = await service.create_server(server)
ip_address = request.client.host if request.client else ""
audit_repo = AuditLogRepositoryImpl(db)
await audit_repo.create(AuditLog(
admin_username=admin.username,
action="create_server",
target_type="server",
target_id=created.id,
detail=f"name={created.name} domain={created.domain}",
ip_address=ip_address,
))
return _server_to_dict(created)
@router.put("/{id}", response_model=dict)
async def update_server(
request: Request,
id: int,
payload: ServerUpdate,
admin: Admin = Depends(get_current_admin),
service: ServerService = Depends(get_server_service),
db: AsyncSession = Depends(get_db),
):
"""Update an existing server (sensitive fields encrypted at rest)"""
from server.infrastructure.database.crypto import encrypt_value
server = await service.get_server(id)
if not server:
raise HTTPException(status_code=404, detail="Server not found")
for key, value in payload.model_dump(exclude_unset=True).items():
if hasattr(server, key) and key != "id":
# Encrypt sensitive fields on update
if key == "password" and value:
value = encrypt_value(value)
elif key == "ssh_key_private" and value:
value = encrypt_value(value)
setattr(server, key, value)
updated = await service.update_server(server)
ip_address = request.client.host if request.client else ""
audit_repo = AuditLogRepositoryImpl(db)
await audit_repo.create(AuditLog(
admin_username=admin.username,
action="update_server",
target_type="server",
target_id=id,
detail=f"name={updated.name}",
ip_address=ip_address,
))
return _server_to_dict(updated)
@router.delete("/{id}", status_code=204)
async def delete_server(
request: Request,
id: int,
admin: Admin = Depends(get_current_admin),
service: ServerService = Depends(get_server_service),
db: AsyncSession = Depends(get_db),
):
"""Delete a server"""
server = await service.get_server(id)
if not server:
raise HTTPException(status_code=404, detail="Server not found")
result = await service.delete_server(id)
if not result:
raise HTTPException(status_code=404, detail="Server not found")
ip_address = request.client.host if request.client else ""
audit_repo = AuditLogRepositoryImpl(db)
await audit_repo.create(AuditLog(
admin_username=admin.username,
action="delete_server",
target_type="server",
target_id=id,
detail=f"name={server.name}",
ip_address=ip_address,
))
# ── Agent API Key ──
@router.post("/{id}/agent-key", response_model=dict)
async def generate_agent_api_key(
request: Request,
id: int,
admin: Admin = Depends(get_current_admin),
service: ServerService = Depends(get_server_service),
db: AsyncSession = Depends(get_db),
):
"""Generate a new per-server Agent API key"""
server = await service.get_server(id)
if not server:
raise HTTPException(status_code=404, detail="Server not found")
import secrets
new_key = f"nxs-{secrets.token_urlsafe(32)}"
server.agent_api_key = new_key
await service.update_server(server)
ip_address = request.client.host if request.client else ""
audit_repo = AuditLogRepositoryImpl(db)
await audit_repo.create(AuditLog(
admin_username=admin.username,
action="generate_agent_key",
target_type="server",
target_id=id,
detail=f"Generated new Agent API key for {server.name}",
ip_address=ip_address,
))
return {
"server_id": id,
"agent_api_key": new_key,
"agent_api_key_preview": f"{new_key[:12]}...",
"display_once": True,
"message": "完整密钥仅在本响应中返回一次,请立即复制保存",
}
# ── Agent Install ──
@router.post("/{id}/install-agent", response_model=dict)
async def install_agent_remote(
request: Request,
id: int,
admin: Admin = Depends(get_current_admin),
service: ServerService = Depends(get_server_service),
db: AsyncSession = Depends(get_db),
):
"""Install Nexus Agent on the managed server via SSH.
Uses the server's stored SSH credentials to connect and run install.sh.
Requires NEXUS_API_BASE_URL to be configured (used in the install command).
"""
from server.infrastructure.ssh.asyncssh_pool import exec_ssh_command
from server.infrastructure.database.crypto import decrypt_value
server = await service.get_server(id)
if not server:
raise HTTPException(status_code=404, detail="Server not found")
# Validate prerequisites
base_url = (settings.API_BASE_URL or "").strip().rstrip("/")
if not base_url:
raise HTTPException(
status_code=400,
detail="NEXUS_API_BASE_URL 未配置,无法生成安装命令。请在系统设置中配置主站对外 URL。",
)
api_key = server.agent_api_key or settings.API_KEY
if not api_key:
raise HTTPException(status_code=400, detail="请先为该服务器生成 Agent API Key")
agent_port = server.agent_port or 8601
# Build install command
install_cmd = (
f"curl -fsSL {base_url}/agent/install.sh | bash -s -- "
f"--url {base_url} --key {api_key} --id {id} --port {agent_port}"
)
# Execute via SSH
try:
result = await exec_ssh_command(server, install_cmd, timeout=120)
except Exception as e:
raise HTTPException(status_code=502, detail=f"SSH 连接失败: {e}")
if result["exit_code"] != 0:
raise HTTPException(
status_code=400,
detail=f"安装脚本失败 (exit {result['exit_code']}): {result['stderr'][:500]}",
)
# Audit
ip_address = request.client.host if request.client else ""
audit_repo = AuditLogRepositoryImpl(db)
await audit_repo.create(AuditLog(
admin_username=admin.username,
action="install_agent",
target_type="server",
target_id=id,
detail=f"Agent 远程安装: {server.name} ({server.domain})",
ip_address=ip_address,
))
return {
"success": True,
"server_id": id,
"server_name": server.name,
"stdout": result["stdout"][:3000],
"exit_code": result["exit_code"],
}
@router.get("/{id}/agent-install-cmd", response_model=dict)
async def get_agent_install_cmd(
id: int,
admin: Admin = Depends(get_current_admin),
service: ServerService = Depends(get_server_service),
):
"""Return the curl install command for this server (for manual copy-paste)."""
server = await service.get_server(id)
if not server:
raise HTTPException(status_code=404, detail="Server not found")
base_url = (settings.API_BASE_URL or "").strip().rstrip("/")
api_key = server.agent_api_key or settings.API_KEY
agent_port = server.agent_port or 8601
has_key = bool(server.agent_api_key)
cmd = None
if base_url and api_key:
cmd = (
f"curl -fsSL {base_url}/agent/install.sh | bash -s -- "
f"--url {base_url} --key {api_key} --id {id} --port {agent_port}"
)
return {
"server_id": id,
"server_name": server.name,
"base_url": base_url,
"has_agent_key": has_key,
"agent_port": agent_port,
"install_cmd": cmd,
"is_online": server.is_online,
}
# ── Agent Upgrade ──
@router.post("/{id}/upgrade-agent", response_model=dict)
async def upgrade_agent(
request: Request,
id: int,
admin: Admin = Depends(get_current_admin),
service: ServerService = Depends(get_server_service),
db: AsyncSession = Depends(get_db),
):
"""Upgrade Nexus Agent on the managed server via SSH.
Downloads the latest agent.py from the central server and restarts
the nexus-agent systemd service. Does NOT touch config.json.
"""
from server.infrastructure.ssh.asyncssh_pool import exec_ssh_command
import shlex
server = await service.get_server(id)
if not server:
raise HTTPException(status_code=404, detail="Server not found")
base_url = (settings.API_BASE_URL or "").strip().rstrip("/")
if not base_url:
raise HTTPException(
status_code=400,
detail="NEXUS_API_BASE_URL 未配置,无法构建下载地址",
)
agent_url = f"{base_url}/agent/agent.py"
install_dir = "/opt/nexus-agent"
venv_python = f"{install_dir}/.venv/bin/python"
# Step 1: Check Python version in venv
pyver_cmd = (
f"{venv_python} -c '"
"import sys; v=sys.version_info; "
"print(f\"py_ok_{v.major}.{v.minor}.{v.micro}\") "
"if v.major==3 and v.minor>=10 else sys.exit(1)'"
)
try:
pyver = await exec_ssh_command(server, pyver_cmd, timeout=15)
except Exception as e:
raise HTTPException(status_code=502, detail=f"SSH 连接失败: {e}")
if pyver["exit_code"] != 0 or "py_ok_" not in pyver["stdout"]:
ver_info = pyver["stderr"].replace("\n", "; ").strip()[:200] if pyver["stderr"] else "venv 不存在或 Python 版本 < 3.10"
raise HTTPException(
status_code=424,
detail=(
f"子机 Python 版本不满足要求(需要 3.10+),请手动修复后重试。\n"
f" venv 路径: {venv_python}\n"
f" 详情: {ver_info}"
),
)
# Step 2: Auto-update pip dependencies (locked versions)
pip_cmd = (
f"{venv_python} -m pip install -q "
f"fastapi==0.115.6 uvicorn==0.34.0 httpx==0.28.1 psutil python-multipart==0.0.19"
)
# Step 3: Backup current agent.py
backup_cmd = f"cp {install_dir}/agent.py {install_dir}/agent.py.bak"
# Step 4: pip install + backup + download new agent.py → restart service
upgrade_cmd = (
f"{pip_cmd} "
f"&& {backup_cmd} "
f"&& curl -fsSL {shlex.quote(agent_url)} -o {install_dir}/agent.py "
f"&& systemctl restart nexus-agent "
f"&& sleep 2 "
f"&& systemctl is-active nexus-agent "
f"&& echo 'upgrade_ok'"
)
try:
result = await exec_ssh_command(server, upgrade_cmd, timeout=120)
except Exception as e:
raise HTTPException(status_code=502, detail=f"SSH 连接失败: {e}")
success = result["exit_code"] == 0 and "upgrade_ok" in result["stdout"]
if not success:
# Rollback: restore backup and restart
rollback_cmd = (
f"cp {install_dir}/agent.py.bak {install_dir}/agent.py "
f"&& systemctl restart nexus-agent"
)
try:
await exec_ssh_command(server, rollback_cmd, timeout=30)
except Exception:
pass
raise HTTPException(
status_code=400,
detail=f"升级失败 (exit {result['exit_code']}): {result['stderr'][:300]},已回滚至旧版本",
)
# Audit
ip_address = request.client.host if request.client else ""
audit_repo = AuditLogRepositoryImpl(db)
await audit_repo.create(AuditLog(
admin_username=admin.username,
action="upgrade_agent",
target_type="server",
target_id=id,
detail=f"Agent 升级: {server.name} ({server.domain})",
ip_address=ip_address,
))
return {"success": True, "server_id": id, "server_name": server.name, "stdout": result["stdout"][:500]}
# ── Health Check ──
@router.post("/check", response_model=dict)
async def check_servers(
payload: ServerCheck,
admin: Admin = Depends(get_current_admin),
service: ServerService = Depends(get_server_service),
):
"""Check health status of specified servers via SSH probe (no Agent port required)"""
return await service.check_all_servers(payload.server_ids)
@router.get("/{id}/logs", response_model=list)
async def get_server_logs(
id: int,
limit: int = Query(50, ge=1, le=200),
admin: Admin = Depends(get_current_admin),
sync_service: SyncService = Depends(get_sync_service),
):
"""Get sync logs for a specific server"""
logs = await sync_service.get_sync_logs(id, limit)
return [_sync_log_to_dict(log) for log in logs]
# ── Helper functions ──
def _server_to_dict(server: Server) -> dict:
"""Convert Server model to API response dict (hide sensitive fields)"""
return {
"id": server.id,
"name": server.name,
"domain": server.domain,
"port": server.port,
"username": server.username,
"auth_method": server.auth_method,
"password_set": bool(server.password),
"ssh_key_path": server.ssh_key_path,
"ssh_key_public": server.ssh_key_public or "",
"ssh_key_private_set": bool(server.ssh_key_private),
"agent_port": server.agent_port,
"agent_api_key": (server.agent_api_key[:8] + "...") if server.agent_api_key and len(server.agent_api_key) > 8 else (server.agent_api_key or ""),
"agent_api_key_set": bool(server.agent_api_key),
"description": server.description,
"target_path": server.target_path,
"category": server.category,
"platform_id": server.platform_id,
"node_id": server.node_id,
"protocols": server.protocols,
"extra_attrs": server.extra_attrs,
"connectivity": server.connectivity,
"ssh_key_configured": server.ssh_key_configured,
"is_online": server.is_online,
"last_heartbeat": str(server.last_heartbeat) if server.last_heartbeat else None,
"last_checked_at": str(server.last_checked_at) if server.last_checked_at else None,
"system_info": server.system_info,
"agent_version": server.agent_version,
"created_at": str(server.created_at) if server.created_at else None,
"updated_at": str(server.updated_at) if server.updated_at else None,
}
def _sync_log_to_dict(log, server_name: str = None) -> dict:
"""Convert SyncLog model to API response dict"""
return {
"id": log.id,
"server_id": log.server_id,
"server_name": server_name,
"source_path": log.source_path,
"target_path": log.target_path,
"trigger_type": log.trigger_type,
"operator": log.operator,
"status": log.status,
"sync_mode": log.sync_mode,
"files_total": log.files_total,
"files_transferred": log.files_transferred,
"duration_seconds": log.duration_seconds,
"error_message": log.error_message,
"started_at": str(log.started_at) if log.started_at else None,
"finished_at": str(log.finished_at) if log.finished_at else None,
}