82c297776a
F1: broadcast_sync_progress() + batch_id + 前端WS逐台更新 F2: get_paginated() platform_id/node_id + /categories端点 + 筛选下拉框 F4: _sync_log_to_dict() 补全字段 + diff_summary捕获 + 可展开详情面板 F7: /local-file-ops端点 + 文件管理器删除/重命名 F8: /verify端点 md5sum对比 + 推送后校验UI Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1543 lines
59 KiB
Python
1543 lines
59 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
|
||
|
||
import bcrypt
|
||
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, ApiKeyRevealRequest,
|
||
ServerImportResult, BatchAgentAction, BatchAgentResult, BatchAgentResultItem,
|
||
)
|
||
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
|
||
import shlex
|
||
|
||
|
||
def _sudo_wrap(cmd: str, ssh_user: str) -> str:
|
||
"""Wrap a command with sudo setup/teardown for non-root SSH users.
|
||
|
||
For non-root users, pre-configures NOPASSWD sudo before the command
|
||
and cleans up the sudoers entry afterwards. For root, returns cmd as-is.
|
||
"""
|
||
if ssh_user == "root":
|
||
return cmd
|
||
sudoers_tag = "nexus-agent"
|
||
setup = (
|
||
f"echo {shlex.quote(ssh_user + ' ALL=(ALL) NOPASSWD:ALL')} "
|
||
f"| sudo tee /etc/sudoers.d/{sudoers_tag} > /dev/null 2>&1; "
|
||
)
|
||
cleanup = f"; sudo rm -f /etc/sudoers.d/{sudoers_tag} 2>/dev/null"
|
||
return setup + cmd + cleanup
|
||
|
||
# Translation table: install.sh ERROR lines (English) → Chinese
|
||
_INSTALL_ERROR_ZH: dict[str, str] = {
|
||
"Python 3.10+ required but not found and could not be installed automatically.":
|
||
"Python 版本低于 3.10,且无法自动安装",
|
||
"sudo requires a password. Configure passwordless sudo first:":
|
||
"sudo 需要密码,请先配置免密 sudo",
|
||
"Running as non-root and sudo is not available.":
|
||
"非 root 用户且无 sudo 权限",
|
||
"--url is required":
|
||
"缺少 --url 参数",
|
||
"--id is required (server ID from Nexus dashboard)":
|
||
"缺少 --id 参数(服务器 ID)",
|
||
"--key is required (API key from Nexus settings)":
|
||
"缺少 --key 参数(API Key)",
|
||
"venv creation failed even after installing python3-venv":
|
||
"虚拟环境创建失败(python3-venv 模块不可用)",
|
||
"Cannot download from":
|
||
"无法下载 agent.py",
|
||
"upgrade_ok":
|
||
"升级验证失败",
|
||
}
|
||
|
||
|
||
def _install_error_msg(stdout: str, stderr: str, exit_code: int) -> str:
|
||
"""Extract concise error reason from install.sh / upgrade output.
|
||
|
||
install.sh writes ``ERROR: ...`` lines to **stdout** (not stderr),
|
||
so extracting only stderr yields empty/unhelpful messages like
|
||
"安装失败 (exit 1)". This function finds the first ``ERROR:`` line
|
||
in stdout, translates it to Chinese, and appends the original English
|
||
for full context. Falls back to the last non-empty stdout line,
|
||
then stderr, and finally a generic exit-code message.
|
||
"""
|
||
# 1. Prefer ERROR: lines from stdout (install.sh convention)
|
||
for line in reversed(stdout.split("\n")):
|
||
line = line.strip()
|
||
if line.startswith("ERROR:"):
|
||
en = line.replace("ERROR: ", "")
|
||
zh = None
|
||
# Exact match first
|
||
if en in _INSTALL_ERROR_ZH:
|
||
zh = _INSTALL_ERROR_ZH[en]
|
||
else:
|
||
# Prefix match (e.g. "Cannot download from https://...")
|
||
for prefix, translation in _INSTALL_ERROR_ZH.items():
|
||
if en.startswith(prefix):
|
||
zh = translation
|
||
break
|
||
if zh:
|
||
return f"{zh}({en[:200]})"
|
||
return en[:300]
|
||
# 2. Last non-empty stdout line (may contain useful context)
|
||
for line in reversed(stdout.split("\n")):
|
||
line = line.strip()
|
||
if line:
|
||
return line[:300]
|
||
# 3. stderr
|
||
if stderr.strip():
|
||
return stderr.strip()[:300]
|
||
# 4. Generic
|
||
return f"exit {exit_code}"
|
||
|
||
|
||
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"),
|
||
platform_id: Optional[int] = Query(None, description="Filter by platform ID"),
|
||
node_id: Optional[int] = Query(None, description="Filter by node ID"),
|
||
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, platform_id, node_id, offset, per_page)
|
||
redis = get_redis()
|
||
|
||
result = []
|
||
for server in page_servers:
|
||
server_data = _server_to_dict(server)
|
||
|
||
# Overlay Redis heartbeat data (real-time)
|
||
# Redis is the source of truth for Agent status.
|
||
# If a server has/had an Agent (agent_version set), absence of a Redis
|
||
# heartbeat key means the Agent is offline (heartbeat TTL expired).
|
||
# Only fall back to MySQL is_online for servers that never had an Agent.
|
||
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"
|
||
elif server.agent_version:
|
||
# Had Agent but no heartbeat → Agent is offline
|
||
server_data["is_online"] = False
|
||
server_data["_source"] = "redis_miss"
|
||
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("/categories", response_model=dict)
|
||
async def server_categories(
|
||
admin: Admin = Depends(get_current_admin),
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
"""Return distinct categories with counts and platform list for server filtering."""
|
||
from sqlalchemy import func as sqlfunc
|
||
from server.domain.models import Platform
|
||
|
||
# Categories with server counts
|
||
cat_result = await db.execute(
|
||
select(Server.category, sqlfunc.count(Server.id))
|
||
.group_by(Server.category)
|
||
)
|
||
categories = []
|
||
for cat, cnt in cat_result.all():
|
||
categories.append({"name": cat or "uncategorized", "count": cnt})
|
||
|
||
# Platforms
|
||
plat_result = await db.execute(select(Platform).order_by(Platform.id))
|
||
platforms = []
|
||
for p in plat_result.scalars().all():
|
||
platforms.append({"id": p.id, "name": p.name, "category": p.category or ""})
|
||
|
||
return {"categories": categories, "platforms": platforms}
|
||
|
||
|
||
@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}
|
||
|
||
|
||
# ── CSV Batch Import ──
|
||
|
||
MAX_IMPORT_FILE_SIZE = 1_048_576 # 1 MB
|
||
MAX_IMPORT_ROWS = 500
|
||
|
||
# CSV column mapping: CSV header (Chinese) → Server model field
|
||
CSV_COLUMNS = [
|
||
("名称", "name", True),
|
||
("地址", "domain", True),
|
||
("SSH端口", "port", False),
|
||
("用户名", "username", False),
|
||
("认证方式", "auth_method", False),
|
||
("密码", "password", False),
|
||
("密钥路径", "ssh_key_path", False),
|
||
("私钥内容", "ssh_key_private", False),
|
||
("分类", "category", False),
|
||
("目标路径", "target_path", False),
|
||
("备注", "description", False),
|
||
]
|
||
|
||
# Columns excluded from the template but still accepted on import (backward compat)
|
||
_CSV_IMPORT_ONLY_COLUMNS = [
|
||
("Agent端口", "agent_port", False),
|
||
("平台", "platform_name", False),
|
||
("节点", "node_name", False),
|
||
]
|
||
|
||
|
||
def _sanitize_csv_cell(value: str) -> str:
|
||
"""Sanitize a CSV cell to prevent CSV injection (Excel formula injection).
|
||
|
||
Strips leading dangerous characters: = + - @ \t \r
|
||
that could be interpreted as formulas by spreadsheet software.
|
||
"""
|
||
if not value:
|
||
return value
|
||
# Remove leading whitespace and dangerous formula prefix characters
|
||
stripped = value.lstrip()
|
||
if stripped and stripped[0] in ("=", "+", "-", "@", "\t", "\r"):
|
||
# Prepend a single quote to defang the formula (standard CSV injection defense)
|
||
return "'" + value
|
||
return value
|
||
|
||
|
||
@router.get("/meta/api_base_url")
|
||
async def get_api_base_url(admin: Admin = Depends(get_current_admin)):
|
||
"""Return the configured API_BASE_URL (from .env / settings).
|
||
|
||
Used by the frontend to determine if Agent install commands can be generated.
|
||
"""
|
||
return {"api_base_url": settings.API_BASE_URL or ""}
|
||
|
||
|
||
@router.get("/import/template")
|
||
async def download_import_template(
|
||
admin: Admin = Depends(get_current_admin),
|
||
):
|
||
"""Download CSV import template with headers and one example row."""
|
||
from fastapi.responses import StreamingResponse
|
||
import io
|
||
|
||
headers = ",".join(col[0] for col in CSV_COLUMNS)
|
||
example = "web-01,192.168.1.10,22,root,password,your-password,,,商城,/www/wwwroot,Web服务器"
|
||
csv_content = f"{headers}\n{example}\n" # BOM for Excel Chinese compatibility
|
||
|
||
return StreamingResponse(
|
||
io.BytesIO(csv_content.encode("utf-8")),
|
||
media_type="text/csv; charset=utf-8",
|
||
headers={"Content-Disposition": "attachment; filename=servers_import_template.csv"},
|
||
)
|
||
|
||
|
||
@router.post("/import", response_model=ServerImportResult)
|
||
async def import_servers(
|
||
request: Request,
|
||
admin: Admin = Depends(get_current_admin),
|
||
service: ServerService = Depends(get_server_service),
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
"""Import servers from a CSV file (multipart/form-data upload).
|
||
|
||
Each row is processed independently — a failure in one row does not affect others.
|
||
Duplicate domains (already in DB) are skipped, not counted as errors.
|
||
"""
|
||
from server.infrastructure.database.crypto import encrypt_value
|
||
from server.infrastructure.database.server_repo import ServerRepositoryImpl
|
||
import csv
|
||
import io
|
||
import secrets
|
||
|
||
# Read the uploaded file
|
||
form = await request.form()
|
||
file = form.get("file")
|
||
if not file or not hasattr(file, "read"):
|
||
raise HTTPException(status_code=400, detail="请上传 CSV 文件")
|
||
|
||
content = await file.read()
|
||
if len(content) > MAX_IMPORT_FILE_SIZE:
|
||
raise HTTPException(status_code=400, detail=f"文件大小超过限制 ({MAX_IMPORT_FILE_SIZE // 1024}KB)")
|
||
|
||
# Decode CSV
|
||
try:
|
||
text = content.decode("utf-8-sig") # Handle BOM
|
||
except UnicodeDecodeError:
|
||
try:
|
||
text = content.decode("gbk") # Fallback for Chinese Excel
|
||
except UnicodeDecodeError:
|
||
raise HTTPException(status_code=400, detail="CSV 编码不支持,请使用 UTF-8 或 GBK")
|
||
|
||
reader = csv.DictReader(io.StringIO(text))
|
||
if not reader.fieldnames:
|
||
raise HTTPException(status_code=400, detail="CSV 文件为空或缺少表头")
|
||
|
||
# Validate headers — at least name and domain must exist
|
||
header_set = set(reader.fieldnames)
|
||
required_headers = {col[0] for col in CSV_COLUMNS if col[2]}
|
||
missing = required_headers - header_set
|
||
if missing:
|
||
raise HTTPException(status_code=400, detail=f"CSV 缺少必填列: {', '.join(missing)}")
|
||
|
||
# Build column mapping: CSV header → field name (template columns + backward-compat columns)
|
||
col_map = {}
|
||
all_columns = CSV_COLUMNS + _CSV_IMPORT_ONLY_COLUMNS
|
||
for cn_name, field_name, _ in all_columns:
|
||
if cn_name in header_set:
|
||
col_map[cn_name] = field_name
|
||
|
||
# Load existing domains for duplicate check
|
||
server_repo = ServerRepositoryImpl(db)
|
||
existing_servers = await server_repo.get_all()
|
||
existing_domains = {s.domain for s in existing_servers}
|
||
|
||
# Load platform/node lookup by name for mapping
|
||
from server.domain.models import Platform, Node
|
||
platform_map = {} # name → id
|
||
node_map = {} # name → id
|
||
try:
|
||
result = await db.execute(select(Platform))
|
||
for p in result.scalars().all():
|
||
platform_map[p.name] = p.id
|
||
result = await db.execute(select(Node))
|
||
for n in result.scalars().all():
|
||
node_map[n.name] = n.id
|
||
except Exception as e:
|
||
logger.warning(f"Platform/node lookup failed during CSV import: {e}")
|
||
|
||
result = ServerImportResult()
|
||
row_num = 1 # Header is row 0
|
||
|
||
for row in reader:
|
||
row_num += 1
|
||
if row_num - 1 > MAX_IMPORT_ROWS:
|
||
result.errors.append({"row": row_num, "name": "", "domain": "", "error": f"超过最大行数限制 ({MAX_IMPORT_ROWS})"})
|
||
break
|
||
|
||
# Map CSV columns to server data
|
||
data = {}
|
||
for cn_name, field_name, _ in all_columns:
|
||
if cn_name in col_map:
|
||
value = row.get(cn_name, "").strip()
|
||
if value:
|
||
value = _sanitize_csv_cell(value)
|
||
data[field_name] = value
|
||
|
||
# Validate required fields
|
||
name = data.get("name", "").strip()
|
||
domain = data.get("domain", "").strip()
|
||
if not name or not domain:
|
||
result.failed += 1
|
||
result.errors.append({"row": row_num, "name": name, "domain": domain, "error": "名称和地址为必填项"})
|
||
continue
|
||
|
||
# Duplicate check
|
||
if domain in existing_domains:
|
||
result.skipped += 1
|
||
continue
|
||
|
||
# Parse numeric fields
|
||
try:
|
||
if "port" in data:
|
||
data["port"] = int(data["port"])
|
||
if "agent_port" in data:
|
||
data["agent_port"] = int(data["agent_port"])
|
||
except ValueError:
|
||
result.failed += 1
|
||
result.errors.append({"row": row_num, "name": name, "domain": domain, "error": "端口号必须为数字"})
|
||
continue
|
||
|
||
# Set defaults
|
||
data.setdefault("port", 22)
|
||
data.setdefault("username", "root")
|
||
data.setdefault("auth_method", "password")
|
||
data.setdefault("agent_port", 8601)
|
||
|
||
# Validate auth_method
|
||
if data["auth_method"] not in ("password", "key"):
|
||
result.failed += 1
|
||
result.errors.append({"row": row_num, "name": name, "domain": domain, "error": f"认证方式无效: {data['auth_method']}"})
|
||
continue
|
||
|
||
# Resolve platform/node by name
|
||
if data.get("platform_name") and data["platform_name"] in platform_map:
|
||
data["platform_id"] = platform_map[data["platform_name"]]
|
||
if data.get("node_name") and data["node_name"] in node_map:
|
||
data["node_id"] = node_map[data["node_name"]]
|
||
# Remove helper fields that are not Server model columns
|
||
data.pop("platform_name", None)
|
||
data.pop("node_name", None)
|
||
|
||
# Encrypt sensitive fields
|
||
try:
|
||
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"])
|
||
data["ssh_key_configured"] = True
|
||
|
||
# Auto-generate Agent API Key
|
||
data["agent_api_key"] = f"nxs-{secrets.token_urlsafe(32)}"
|
||
|
||
server = Server(**data)
|
||
created = await service.create_server(server)
|
||
existing_domains.add(domain)
|
||
|
||
result.created += 1
|
||
result.created_ids.append(created.id)
|
||
except Exception as e:
|
||
result.failed += 1
|
||
result.errors.append({"row": row_num, "name": name, "domain": domain, "error": str(e)[:200]})
|
||
logger.warning(f"CSV import row {row_num} failed: {e}")
|
||
|
||
result.total = result.created + result.skipped + result.failed
|
||
|
||
# Audit log
|
||
ip_address = request.client.host if request.client else ""
|
||
audit_repo = AuditLogRepositoryImpl(db)
|
||
await audit_repo.create(AuditLog(
|
||
admin_username=admin.username,
|
||
action="import_servers",
|
||
target_type="server",
|
||
target_id=0,
|
||
detail=f"CSV导入: 成功{result.created} 跳过{result.skipped} 失败{result.failed}",
|
||
ip_address=ip_address,
|
||
))
|
||
|
||
return result
|
||
|
||
|
||
# ── Batch Agent Install/Upgrade ──
|
||
|
||
@router.post("/batch/install-agent", response_model=BatchAgentResult)
|
||
async def batch_install_agent(
|
||
request: Request,
|
||
payload: BatchAgentAction,
|
||
admin: Admin = Depends(get_current_admin),
|
||
service: ServerService = Depends(get_server_service),
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
"""Install Nexus Agent on multiple servers via SSH (concurrent, max 5 parallel)."""
|
||
import asyncio
|
||
from server.infrastructure.ssh.asyncssh_pool import exec_ssh_command
|
||
import shlex
|
||
|
||
base_url = (settings.API_BASE_URL or "").strip().rstrip("/")
|
||
if not base_url:
|
||
raise HTTPException(status_code=400, detail="NEXUS_API_BASE_URL 未配置")
|
||
|
||
sem = asyncio.Semaphore(5)
|
||
results = []
|
||
|
||
async def _install_one(sid: int):
|
||
async with sem:
|
||
server_name = ""
|
||
try:
|
||
server = await service.get_server(sid)
|
||
if not server:
|
||
return BatchAgentResultItem(server_id=sid, server_name="", success=False, error="服务器不存在")
|
||
server_name = server.name
|
||
api_key = server.agent_api_key or settings.API_KEY
|
||
if not api_key:
|
||
return BatchAgentResultItem(server_id=sid, server_name=server_name, success=False, error="请先生成 Agent API Key")
|
||
ssh_user = (server.username or "root").strip()
|
||
install_cmd = (
|
||
f"TMP=$(mktemp) && curl -fsSL {shlex.quote(base_url)}/agent/install.sh -o \"$TMP\" && bash \"$TMP\" -- "
|
||
f"--url {shlex.quote(base_url)} --key {shlex.quote(api_key)} "
|
||
f"--id {sid} --port {shlex.quote(str(server.agent_port or 8601))}"
|
||
f"; RC=$?; rm -f \"$TMP\"; exit $RC"
|
||
)
|
||
install_cmd = _sudo_wrap(install_cmd, ssh_user)
|
||
r = await exec_ssh_command(server, install_cmd, timeout=180)
|
||
ok = r["exit_code"] == 0
|
||
# Also detect install.sh reporting FAILED in stdout
|
||
if ok and "Status: FAILED" in r.get("stdout", ""):
|
||
ok = False
|
||
return BatchAgentResultItem(
|
||
server_id=sid, server_name=server_name, success=ok,
|
||
stdout=r["stdout"][:2000] if ok else r["stdout"][:1000],
|
||
error="" if ok else _install_error_msg(r.get("stdout", ""), r.get("stderr", ""), r["exit_code"]),
|
||
)
|
||
except Exception as e:
|
||
return BatchAgentResultItem(server_id=sid, server_name=server_name, success=False, error=str(e)[:300])
|
||
|
||
items = await asyncio.gather(*[_install_one(sid) for sid in payload.server_ids])
|
||
results = list(items)
|
||
|
||
# Audit
|
||
ip_address = request.client.host if request.client else ""
|
||
audit_repo = AuditLogRepositoryImpl(db)
|
||
ok_count = sum(1 for r in results if r.success)
|
||
await audit_repo.create(AuditLog(
|
||
admin_username=admin.username,
|
||
action="batch_install_agent",
|
||
target_type="server",
|
||
target_id=0,
|
||
detail=f"批量安装Agent: {ok_count}/{len(results)} 成功",
|
||
ip_address=ip_address,
|
||
))
|
||
|
||
return BatchAgentResult(results=results)
|
||
|
||
|
||
@router.post("/batch/upgrade-agent", response_model=BatchAgentResult)
|
||
async def batch_upgrade_agent(
|
||
request: Request,
|
||
payload: BatchAgentAction,
|
||
admin: Admin = Depends(get_current_admin),
|
||
service: ServerService = Depends(get_server_service),
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
"""Upgrade Nexus Agent on multiple servers via SSH (concurrent, max 5 parallel)."""
|
||
import asyncio
|
||
from server.infrastructure.ssh.asyncssh_pool import exec_ssh_command
|
||
import shlex
|
||
|
||
base_url = (settings.API_BASE_URL or "").strip().rstrip("/")
|
||
if not base_url:
|
||
raise HTTPException(status_code=400, detail="NEXUS_API_BASE_URL 未配置")
|
||
|
||
sem = asyncio.Semaphore(5)
|
||
results = []
|
||
|
||
async def _upgrade_one(sid: int):
|
||
async with sem:
|
||
try:
|
||
server = await service.get_server(sid)
|
||
if not server:
|
||
return BatchAgentResultItem(server_id=sid, server_name="", success=False, error="服务器不存在")
|
||
|
||
agent_url = f"{base_url}/agent/agent.py"
|
||
install_dir = "/opt/nexus-agent"
|
||
venv_python = f"{install_dir}/.venv/bin/python"
|
||
ssh_user = (server.username or "root").strip()
|
||
|
||
upgrade_cmd = _sudo_wrap(
|
||
f"{venv_python} -c 'import sys; v=sys.version_info; sys.exit(0 if v.major==3 and v.minor>=10 else 1)' "
|
||
f"&& {venv_python} -m pip install -q fastapi==0.115.6 uvicorn==0.34.0 httpx==0.28.1 psutil python-multipart==0.0.19 "
|
||
f"&& cp {install_dir}/agent.py {install_dir}/agent.py.bak "
|
||
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'",
|
||
ssh_user,
|
||
)
|
||
r = await exec_ssh_command(server, upgrade_cmd, timeout=120)
|
||
ok = r["exit_code"] == 0 and "upgrade_ok" in r["stdout"]
|
||
|
||
if not ok:
|
||
# Attempt rollback
|
||
try:
|
||
rollback = _sudo_wrap(
|
||
f"cp {install_dir}/agent.py.bak {install_dir}/agent.py && systemctl restart nexus-agent",
|
||
ssh_user,
|
||
)
|
||
await exec_ssh_command(server, rollback, timeout=30)
|
||
except Exception as rb_err:
|
||
logger.warning(f"Rollback failed for server {sid}: {rb_err}")
|
||
|
||
return BatchAgentResultItem(
|
||
server_id=sid, server_name=server.name, success=ok,
|
||
stdout=r["stdout"][:500] if ok else "",
|
||
error=_install_error_msg(r.get("stdout", ""), r.get("stderr", ""), r["exit_code"]) if not ok else "",
|
||
)
|
||
except Exception as e:
|
||
return BatchAgentResultItem(server_id=sid, server_name="", success=False, error=str(e)[:200])
|
||
|
||
items = await asyncio.gather(*[_upgrade_one(sid) for sid in payload.server_ids])
|
||
results = list(items)
|
||
|
||
# Audit
|
||
ip_address = request.client.host if request.client else ""
|
||
audit_repo = AuditLogRepositoryImpl(db)
|
||
ok_count = sum(1 for r in results if r.success)
|
||
await audit_repo.create(AuditLog(
|
||
admin_username=admin.username,
|
||
action="batch_upgrade_agent",
|
||
target_type="server",
|
||
target_id=0,
|
||
detail=f"批量升级Agent: {ok_count}/{len(results)} 成功",
|
||
ip_address=ip_address,
|
||
))
|
||
|
||
return BatchAgentResult(results=results)
|
||
|
||
|
||
@router.post("/batch/detect-path", response_model=BatchAgentResult)
|
||
async def batch_detect_path(
|
||
request: Request,
|
||
payload: BatchAgentAction,
|
||
admin: Admin = Depends(get_current_admin),
|
||
service: ServerService = Depends(get_server_service),
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
"""SSH 到各服务器搜索 workerman.bat,自动设置 target_path。
|
||
|
||
递归搜索 /www/wwwroot 下的 workerman.bat 文件,找到后取其目录路径
|
||
作为服务器的 target_path 写入数据库。找到第一个即停止搜索。
|
||
"""
|
||
import asyncio
|
||
import os
|
||
from server.infrastructure.ssh.asyncssh_pool import exec_ssh_command
|
||
|
||
sem = asyncio.Semaphore(5)
|
||
results = []
|
||
|
||
async def _detect_one(sid: int):
|
||
async with sem:
|
||
server_name = ""
|
||
try:
|
||
server = await service.get_server(sid)
|
||
if not server:
|
||
return BatchAgentResultItem(server_id=sid, server_name="", success=False, error="服务器不存在")
|
||
server_name = server.name
|
||
ssh_user = (server.username or "root").strip()
|
||
# find 第一个 workerman.bat 即停止 (-quit)
|
||
cmd = "find /www/wwwroot -iname 'workerman.bat' -type f -print -quit 2>/dev/null"
|
||
cmd = _sudo_wrap(cmd, ssh_user)
|
||
r = await exec_ssh_command(server, cmd, timeout=30)
|
||
if r["exit_code"] != 0 and not r.get("stdout", "").strip():
|
||
return BatchAgentResultItem(
|
||
server_id=sid, server_name=server_name, success=False,
|
||
error=f"搜索失败 (exit {r['exit_code']})",
|
||
)
|
||
found = r["stdout"].strip()
|
||
if not found:
|
||
return BatchAgentResultItem(
|
||
server_id=sid, server_name=server_name, success=False,
|
||
error="未找到 workerman.bat",
|
||
)
|
||
# 取目录路径(可能有多个结果,取第一个)
|
||
first_match = found.split("\n")[0].strip()
|
||
target_dir = os.path.dirname(first_match)
|
||
# 更新服务器 target_path
|
||
server.target_path = target_dir
|
||
await db.commit()
|
||
return BatchAgentResultItem(
|
||
server_id=sid, server_name=server_name, success=True,
|
||
stdout=f"target_path → {target_dir}",
|
||
)
|
||
except Exception as e:
|
||
return BatchAgentResultItem(server_id=sid, server_name=server_name, success=False, error=str(e)[:300])
|
||
|
||
items = await asyncio.gather(*[_detect_one(sid) for sid in payload.server_ids])
|
||
results = list(items)
|
||
|
||
# Audit
|
||
ip_address = request.client.host if request.client else ""
|
||
audit_repo = AuditLogRepositoryImpl(db)
|
||
ok_count = sum(1 for r in results if r.success)
|
||
await audit_repo.create(AuditLog(
|
||
admin_username=admin.username,
|
||
action="batch_detect_path",
|
||
target_type="server",
|
||
target_id=0,
|
||
detail=f"批量检测路径: {ok_count}/{len(results)} 成功",
|
||
ip_address=ip_address,
|
||
))
|
||
|
||
return BatchAgentResult(results=results)
|
||
|
||
|
||
@router.post("/batch/uninstall-agent", response_model=BatchAgentResult)
|
||
async def batch_uninstall_agent(
|
||
request: Request,
|
||
payload: BatchAgentAction,
|
||
admin: Admin = Depends(get_current_admin),
|
||
service: ServerService = Depends(get_server_service),
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
"""Uninstall Nexus Agent on multiple servers via SSH (concurrent, max 5 parallel)."""
|
||
import asyncio
|
||
from server.infrastructure.ssh.asyncssh_pool import exec_ssh_command
|
||
import shlex
|
||
|
||
base_url = (settings.API_BASE_URL or "").strip().rstrip("/")
|
||
if not base_url:
|
||
raise HTTPException(status_code=400, detail="NEXUS_API_BASE_URL 未配置")
|
||
|
||
sem = asyncio.Semaphore(5)
|
||
results = []
|
||
|
||
async def _uninstall_one(sid: int):
|
||
async with sem:
|
||
server_name = ""
|
||
try:
|
||
server = await service.get_server(sid)
|
||
if not server:
|
||
return BatchAgentResultItem(server_id=sid, server_name="", success=False, error="服务器不存在")
|
||
server_name = server.name
|
||
ssh_user = (server.username or "root").strip()
|
||
|
||
uninstall_cmd = (
|
||
f"TMP=$(mktemp) && curl -fsSL {shlex.quote(base_url)}/agent/uninstall.sh -o \"$TMP\" "
|
||
f"&& bash \"$TMP\" ; RC=$?; rm -f \"$TMP\"; exit $RC"
|
||
)
|
||
uninstall_cmd = _sudo_wrap(uninstall_cmd, ssh_user)
|
||
r = await exec_ssh_command(server, uninstall_cmd, timeout=60)
|
||
ok = r["exit_code"] == 0
|
||
|
||
if ok:
|
||
server.is_online = False
|
||
server.agent_version = None
|
||
await db.commit()
|
||
|
||
return BatchAgentResultItem(
|
||
server_id=sid, server_name=server_name, success=ok,
|
||
stdout=r["stdout"][:500] if ok else "",
|
||
error="" if ok else _install_error_msg(r.get("stdout", ""), r.get("stderr", ""), r["exit_code"]),
|
||
)
|
||
except Exception as e:
|
||
return BatchAgentResultItem(server_id=sid, server_name=server_name, success=False, error=str(e)[:300])
|
||
|
||
items = await asyncio.gather(*[_uninstall_one(sid) for sid in payload.server_ids])
|
||
results = list(items)
|
||
|
||
# Audit
|
||
ip_address = request.client.host if request.client else ""
|
||
audit_repo = AuditLogRepositoryImpl(db)
|
||
ok_count = sum(1 for r in results if r.success)
|
||
await audit_repo.create(AuditLog(
|
||
admin_username=admin.username,
|
||
action="batch_uninstall_agent",
|
||
target_type="server",
|
||
target_id=0,
|
||
detail=f"批量卸载 Agent: {ok_count}/{len(results)} 成功",
|
||
ip_address=ip_address,
|
||
))
|
||
|
||
return BatchAgentResult(results=results)
|
||
|
||
|
||
@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, decrypt_value
|
||
from server.infrastructure.database.password_preset_repo import PasswordPresetRepositoryImpl
|
||
from server.infrastructure.database.ssh_key_preset_repo import SshKeyPresetRepositoryImpl
|
||
|
||
data = payload.model_dump(exclude_none=True)
|
||
|
||
# If preset_id is provided, resolve the preset password (server-side, no re-auth needed)
|
||
if data.get("preset_id") and data.get("auth_method") == "password" and not data.get("password"):
|
||
preset_repo = PasswordPresetRepositoryImpl(db)
|
||
preset = await preset_repo.get_by_id(data["preset_id"])
|
||
if preset:
|
||
data["password"] = decrypt_value(preset.encrypted_pw)
|
||
# If preset not found, fall through — user may provide password manually
|
||
|
||
# If ssh_key_preset_id is provided, resolve the preset private key (server-side, no re-auth needed)
|
||
if data.get("ssh_key_preset_id") and data.get("auth_method") == "key" and not data.get("ssh_key_private"):
|
||
ssh_key_preset_repo = SshKeyPresetRepositoryImpl(db)
|
||
ssh_key_preset = await ssh_key_preset_repo.get_by_id(data["ssh_key_preset_id"])
|
||
if ssh_key_preset:
|
||
data["ssh_key_private"] = decrypt_value(ssh_key_preset.encrypted_private_key)
|
||
if ssh_key_preset.public_key and not data.get("ssh_key_public"):
|
||
data["ssh_key_public"] = ssh_key_preset.public_key
|
||
# If preset not found, fall through — user may provide key manually
|
||
|
||
# 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"])
|
||
data["ssh_key_configured"] = True
|
||
|
||
# Auto-generate Agent API Key — used by /install-agent, no need to expose to frontend
|
||
import secrets
|
||
data["agent_api_key"] = f"nxs-{secrets.token_urlsafe(32)}"
|
||
|
||
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"名称={created.name} 域名={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, decrypt_value
|
||
from server.infrastructure.database.password_preset_repo import PasswordPresetRepositoryImpl
|
||
from server.infrastructure.database.ssh_key_preset_repo import SshKeyPresetRepositoryImpl
|
||
|
||
server = await service.get_server(id)
|
||
if not server:
|
||
raise HTTPException(status_code=404, detail="Server not found")
|
||
|
||
# If preset_id is provided (and not None), resolve the preset password
|
||
update_data = payload.model_dump(exclude_unset=True)
|
||
if "preset_id" in update_data and update_data["preset_id"] is not None and not update_data.get("password"):
|
||
preset_repo = PasswordPresetRepositoryImpl(db)
|
||
preset = await preset_repo.get_by_id(update_data["preset_id"])
|
||
if preset:
|
||
update_data["password"] = decrypt_value(preset.encrypted_pw)
|
||
|
||
# If ssh_key_preset_id is provided, resolve the preset private key
|
||
if "ssh_key_preset_id" in update_data and update_data["ssh_key_preset_id"] is not None and not update_data.get("ssh_key_private"):
|
||
ssh_key_preset_repo = SshKeyPresetRepositoryImpl(db)
|
||
ssh_key_preset = await ssh_key_preset_repo.get_by_id(update_data["ssh_key_preset_id"])
|
||
if ssh_key_preset:
|
||
update_data["ssh_key_private"] = decrypt_value(ssh_key_preset.encrypted_private_key)
|
||
if ssh_key_preset.public_key:
|
||
update_data["ssh_key_public"] = ssh_key_preset.public_key
|
||
|
||
# Explicit allowlist to prevent mass-assignment to sensitive columns
|
||
# agent_api_key is excluded — must be set via dedicated /agent-key endpoint
|
||
ALLOWED_FIELDS = {
|
||
"name", "domain", "port", "username", "auth_method",
|
||
"password", "ssh_key_path", "ssh_key_private", "ssh_key_public",
|
||
"agent_port", "preset_id", "ssh_key_preset_id", "description", "target_path", "category",
|
||
"platform_id", "node_id", "protocols", "extra_attrs",
|
||
}
|
||
|
||
# When auth_method changes, clear the opposite side's credentials
|
||
new_auth = update_data.get("auth_method")
|
||
if new_auth == "password":
|
||
# Switching to password: clear key-side fields if not explicitly provided
|
||
update_data.setdefault("ssh_key_private", None)
|
||
update_data.setdefault("ssh_key_path", None)
|
||
update_data.setdefault("ssh_key_public", None)
|
||
update_data.setdefault("ssh_key_preset_id", None)
|
||
# Also clear ssh_key_configured on the server object
|
||
server.ssh_key_configured = False
|
||
elif new_auth == "key":
|
||
# Switching to key: clear password-side fields if not explicitly provided
|
||
update_data.setdefault("password", None)
|
||
update_data.setdefault("preset_id", None)
|
||
|
||
for key, value in update_data.items():
|
||
if key not in ALLOWED_FIELDS:
|
||
continue
|
||
# 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)
|
||
server.ssh_key_configured = True # Mark key as configured when private key is set
|
||
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"名称={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"名称={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,
|
||
payload: ApiKeyRevealRequest,
|
||
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 (re-auth required)"""
|
||
# Re-authenticate: require current password (same pattern as reveal_api_key)
|
||
from server.infrastructure.database.admin_repo import AdminRepositoryImpl
|
||
|
||
admin_repo = AdminRepositoryImpl(db)
|
||
current = await admin_repo.get_by_id(admin.id)
|
||
if not current or not bcrypt.checkpw(
|
||
payload.current_password.encode(),
|
||
current.password_hash.encode(),
|
||
):
|
||
raise HTTPException(status_code=400, detail="当前密码错误")
|
||
|
||
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"为 {server.name} 生成新 Agent API Key",
|
||
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
|
||
import shlex
|
||
|
||
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
|
||
ssh_user = (server.username or "root").strip()
|
||
|
||
# Build install command — download first, then execute (avoids pipe hiding curl errors)
|
||
install_cmd = (
|
||
f"TMP=$(mktemp) && curl -fsSL {shlex.quote(base_url)}/agent/install.sh -o \"$TMP\" && bash \"$TMP\" -- "
|
||
f"--url {shlex.quote(base_url)} --key {shlex.quote(api_key)} "
|
||
f"--id {id} --port {shlex.quote(str(agent_port))}"
|
||
f"; RC=$?; rm -f \"$TMP\"; exit $RC"
|
||
)
|
||
|
||
# For non-root SSH users, pre-configure passwordless sudo so install.sh
|
||
# can run apt-get / systemctl / mkdir /opt without prompting.
|
||
install_cmd = _sudo_wrap(install_cmd, ssh_user)
|
||
|
||
# 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"安装失败: {_install_error_msg(result.get('stdout', ''), result.get('stderr', ''), result['exit_code'])}",
|
||
)
|
||
|
||
# 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.post("/{id}/uninstall-agent", response_model=dict)
|
||
async def uninstall_agent_remote(
|
||
request: Request,
|
||
id: int,
|
||
admin: Admin = Depends(get_current_admin),
|
||
service: ServerService = Depends(get_server_service),
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
"""Uninstall Nexus Agent on the managed server via SSH.
|
||
|
||
Stops the systemd service, removes the install directory and log file,
|
||
then clears the agent metadata (is_online, agent_version) in the database.
|
||
"""
|
||
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 未配置,无法下载卸载脚本。",
|
||
)
|
||
|
||
ssh_user = (server.username or "root").strip()
|
||
|
||
# Build uninstall command — download uninstall.sh then execute
|
||
uninstall_cmd = (
|
||
f"TMP=$(mktemp) && curl -fsSL {shlex.quote(base_url)}/agent/uninstall.sh -o \"$TMP\" "
|
||
f"&& bash \"$TMP\" ; RC=$?; rm -f \"$TMP\"; exit $RC"
|
||
)
|
||
uninstall_cmd = _sudo_wrap(uninstall_cmd, ssh_user)
|
||
|
||
# Execute via SSH
|
||
try:
|
||
result = await exec_ssh_command(server, uninstall_cmd, timeout=60)
|
||
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"卸载失败: {_install_error_msg(result.get('stdout', ''), result.get('stderr', ''), result['exit_code'])}",
|
||
)
|
||
|
||
# Clear agent metadata in database
|
||
server.is_online = False
|
||
server.agent_version = None
|
||
await db.commit()
|
||
|
||
# Audit
|
||
ip_address = request.client.host if request.client else ""
|
||
audit_repo = AuditLogRepositoryImpl(db)
|
||
await audit_repo.create(AuditLog(
|
||
admin_username=admin.username,
|
||
action="uninstall_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.post("/{id}/agent-install-cmd", response_model=dict)
|
||
async def get_agent_install_cmd(
|
||
id: int,
|
||
payload: "ApiKeyRevealRequest",
|
||
request: Request,
|
||
admin: Admin = Depends(get_current_admin),
|
||
service: ServerService = Depends(get_server_service),
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
"""Return the curl install command for this server (re-auth required).
|
||
|
||
Security: Only exposes per-server agent_api_key after password verification.
|
||
Never falls back to global API_KEY — that would expose all servers' auth.
|
||
"""
|
||
# Re-authenticate: require current password (same pattern as reveal_api_key)
|
||
from server.infrastructure.database.admin_repo import AdminRepositoryImpl
|
||
|
||
admin_repo = AdminRepositoryImpl(db)
|
||
current = await admin_repo.get_by_id(admin.id)
|
||
if not current or not bcrypt.checkpw(
|
||
payload.current_password.encode(),
|
||
current.password_hash.encode(),
|
||
):
|
||
raise HTTPException(status_code=400, detail="当前密码错误")
|
||
|
||
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("/")
|
||
agent_port = server.agent_port or 8601
|
||
|
||
# Only use per-server key — NEVER expose global API_KEY via this endpoint
|
||
api_key = server.agent_api_key
|
||
has_key = bool(api_key)
|
||
cmd = None
|
||
if base_url and api_key:
|
||
import shlex
|
||
cmd = (
|
||
f"curl -fsSL {shlex.quote(base_url)}/agent/install.sh | bash -s -- "
|
||
f"--url {shlex.quote(base_url)} --key {shlex.quote(api_key)} "
|
||
f"--id {id} --port {shlex.quote(str(agent_port))}"
|
||
)
|
||
|
||
ip_address = request.client.host if request.client else ""
|
||
audit_repo = AuditLogRepositoryImpl(db)
|
||
await audit_repo.create(AuditLog(
|
||
admin_username=admin.username,
|
||
action="reveal_install_cmd",
|
||
target_type="server",
|
||
target_id=id,
|
||
detail=f"查看 {server.name} 的安装命令",
|
||
ip_address=ip_address,
|
||
))
|
||
|
||
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"
|
||
ssh_user = (server.username or "root").strip()
|
||
|
||
# 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 = _sudo_wrap(
|
||
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'",
|
||
ssh_user,
|
||
)
|
||
|
||
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 = _sudo_wrap(
|
||
f"cp {install_dir}/agent.py.bak {install_dir}/agent.py "
|
||
f"&& systemctl restart nexus-agent",
|
||
ssh_user,
|
||
)
|
||
try:
|
||
await exec_ssh_command(server, rollback_cmd, timeout=30)
|
||
except Exception as rb_err:
|
||
logger.warning(f"Rollback failed for server {id}: {rb_err}")
|
||
raise HTTPException(
|
||
status_code=400,
|
||
detail=f"升级失败: {_install_error_msg(result.get('stdout', ''), result.get('stderr', ''), result['exit_code'])},已回滚至旧版本",
|
||
)
|
||
|
||
# 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),
|
||
"preset_id": server.preset_id,
|
||
"ssh_key_preset_id": server.ssh_key_preset_id,
|
||
"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,
|
||
"files_skipped": log.files_skipped,
|
||
"bytes_transferred": log.bytes_transferred,
|
||
"duration_seconds": log.duration_seconds,
|
||
"error_message": log.error_message,
|
||
"diff_summary": log.diff_summary,
|
||
"started_at": str(log.started_at) if log.started_at else None,
|
||
"finished_at": str(log.finished_at) if log.finished_at else None,
|
||
} |