Files
Nexus/server/api/scripts.py
T
Your Name fd9b7d85b5 fix: 批量安装按钮检测 + 审计日志中文化 + terminal断开反馈
1. updateBatchBar() hasAgent 判断从 agent_api_key_set 改为 agent_version
2. 所有后端 AuditLog detail 字段统一中文
3. audit.html 操作/目标类型中文映射
4. terminal.html 断开覆盖层 + 重新连接按钮
5. Agent os_release 上报 + 前端系统版本拆分

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 02:17:35 +08:00

420 lines
15 KiB
Python

"""Nexus — Scripts API Routes (Script library CRUD + command execution)
Presentation layer — receives HTTP requests, delegates to ScriptService.
All operations require JWT authentication.
"""
import json
import logging
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from server.config import settings
from server.api.dependencies import get_script_service, get_db
from server.api.auth_jwt import get_current_admin
from server.api.schemas import (
ScriptCreate, ScriptUpdate, ScriptExecute, ScriptExecutionRetry,
ScriptExecutionMarkStuck, DbCredentialCreate, DbCredentialUpdate,
)
from server.application.services.script_service import ScriptService
from server.domain.models import Script, DbCredential, Admin, AuditLog
from server.infrastructure.database.audit_log_repo import AuditLogRepositoryImpl
from sqlalchemy.ext.asyncio import AsyncSession
router = APIRouter(prefix="/api/scripts", tags=["scripts"])
logger = logging.getLogger("nexus.scripts")
# ── Script CRUD ──
@router.get("/", response_model=list)
async def list_scripts(
category: Optional[str] = Query(None, description="Filter by category"),
admin: Admin = Depends(get_current_admin),
service: ScriptService = Depends(get_script_service),
):
"""List all scripts, optionally filtered by category"""
scripts = await service.list_scripts(category)
return [_script_to_dict(s) for s in scripts]
@router.get("/exec-config", response_model=dict)
async def script_exec_config(
admin: Admin = Depends(get_current_admin),
):
"""Limits for script execution UI (built-in batching)."""
return {
"batch_size": settings.SCRIPT_EXEC_BATCH_SIZE,
"concurrency": settings.SCRIPT_EXEC_CONCURRENCY,
"max_timeout": 600,
}
# ── DB Credentials (must register before /{id}) ──
@router.get("/credentials", response_model=list)
async def list_credentials(
admin: Admin = Depends(get_current_admin),
service: ScriptService = Depends(get_script_service),
):
"""List all database credentials"""
credentials = await service.list_credentials()
return [_credential_to_dict(c) for c in credentials]
@router.post("/credentials", response_model=dict, status_code=201)
async def create_credential(
payload: DbCredentialCreate,
request: Request,
admin: Admin = Depends(get_current_admin),
service: ScriptService = Depends(get_script_service),
db: AsyncSession = Depends(get_db),
):
"""Create a database credential (password will be encrypted)"""
data = payload.model_dump(exclude_none=True)
data["encrypted_password"] = data.pop("password", "")
credential = DbCredential(**data)
created = await service.create_credential(credential)
audit_repo = AuditLogRepositoryImpl(db)
await audit_repo.create(AuditLog(
admin_username=admin.username, action="create_credential",
target_type="credential", target_id=created.id,
detail=f"名称={created.name}", ip_address=request.client.host if request.client else "",
))
return _credential_to_dict(created)
@router.put("/credentials/{id}", response_model=dict)
async def update_credential(
id: int,
payload: DbCredentialUpdate,
request: Request,
admin: Admin = Depends(get_current_admin),
service: ScriptService = Depends(get_script_service),
db: AsyncSession = Depends(get_db),
):
"""Update a database credential (password optional — omit to keep existing)"""
from server.infrastructure.database.db_credential_repo import DbCredentialRepositoryImpl
from server.infrastructure.database.crypto import encrypt_value
repo = DbCredentialRepositoryImpl(db)
credential = await repo.get_by_id(id)
if not credential:
raise HTTPException(status_code=404, detail="Credential not found")
updates = payload.model_dump(exclude_unset=True)
# Handle password separately — encrypt if provided, skip if omitted
new_password = updates.pop("password", None)
# Explicit allowlist to prevent mass-assignment to sensitive columns
ALLOWED_FIELDS = {"name", "db_type", "host", "port", "username", "database"}
for key, value in updates.items():
if key in ALLOWED_FIELDS and hasattr(credential, key):
setattr(credential, key, value)
if new_password:
credential.encrypted_password = encrypt_value(new_password)
updated = await repo.update(credential)
audit_repo = AuditLogRepositoryImpl(db)
await audit_repo.create(AuditLog(
admin_username=admin.username, action="update_credential",
target_type="credential", target_id=id,
detail=f"名称={updated.name}", ip_address=request.client.host if request.client else "",
))
return _credential_to_dict(updated)
@router.delete("/credentials/{id}", status_code=204)
async def delete_credential(
id: int,
request: Request,
admin: Admin = Depends(get_current_admin),
service: ScriptService = Depends(get_script_service),
db: AsyncSession = Depends(get_db),
):
"""Delete a database credential"""
result = await service.delete_credential(id)
if not result:
raise HTTPException(status_code=404, detail="Credential not found")
audit_repo = AuditLogRepositoryImpl(db)
await audit_repo.create(AuditLog(
admin_username=admin.username, action="delete_credential",
target_type="credential", target_id=id,
ip_address=request.client.host if request.client else "",
))
@router.get("/executions", response_model=dict)
async def list_executions(
limit: int = Query(50, ge=1, le=200),
offset: int = Query(0, ge=0),
status: Optional[str] = Query(None, description="Filter: running/completed/failed/partial"),
admin: Admin = Depends(get_current_admin),
service: ScriptService = Depends(get_script_service),
):
"""List script execution history (newest first)."""
return await service.list_executions(limit=limit, offset=offset, status=status)
@router.post("/executions/{id}/stop", response_model=dict)
async def stop_execution(
id: int,
admin: Admin = Depends(get_current_admin),
service: ScriptService = Depends(get_script_service),
):
"""Stop pending long-task processes on child hosts."""
execution = await service.stop_execution(id, operator=admin.username)
if not execution:
raise HTTPException(status_code=404, detail="Execution not found")
detail = await service.get_execution_detail(id)
return detail if detail else _execution_to_dict(execution)
@router.post("/executions/{id}/mark-stuck", response_model=dict)
async def mark_execution_stuck(
id: int,
payload: ScriptExecutionMarkStuck,
admin: Admin = Depends(get_current_admin),
service: ScriptService = Depends(get_script_service),
):
"""Mark execution as suspected stuck — event timeline + MySQL + audit."""
execution = await service.mark_execution_stuck(
id, operator=admin.username, detail=payload.detail,
)
if not execution:
raise HTTPException(status_code=404, detail="Execution not found")
detail = await service.get_execution_detail(id)
return detail or _execution_to_dict(execution)
@router.post("/executions/{id}/retry", response_model=dict, status_code=201)
async def retry_execution(
id: int,
payload: ScriptExecutionRetry,
admin: Admin = Depends(get_current_admin),
service: ScriptService = Depends(get_script_service),
):
"""Re-run failed / pending / cancelled servers from a previous execution."""
from server.api.dependencies import check_dangerous_command
prev = await service.get_execution(id)
if not prev:
raise HTTPException(status_code=404, detail="Execution not found")
command, _ = service._parse_execution_meta(prev.command or "")
warnings = check_dangerous_command(command)
if warnings:
raise HTTPException(
status_code=400,
detail={"message": "危险命令已拒绝执行", "warnings": warnings},
)
try:
execution = await service.retry_execution(
id,
server_ids=payload.server_ids,
credential_id=payload.credential_id,
timeout=payload.timeout,
long_task=payload.long_task,
operator=admin.username,
)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) from e
detail = await service.get_execution_detail(execution.id)
if detail:
detail["parent_execution_id"] = id
return detail
out = _execution_to_dict(execution)
out["parent_execution_id"] = id
return out
@router.get("/executions/{id}", response_model=dict)
async def get_execution(
id: int,
fetch_logs: bool = Query(
False,
description="Tail /var/log/nexus-job/*.log on child hosts (long tasks)",
),
log_lines: int = Query(100, ge=10, le=500),
admin: Admin = Depends(get_current_admin),
service: ScriptService = Depends(get_script_service),
):
"""Get execution result; optional live log tail from managed servers."""
detail = await service.get_execution_detail(
id, fetch_logs=fetch_logs, log_tail_lines=log_lines,
)
if not detail:
raise HTTPException(status_code=404, detail="Execution not found")
return detail
@router.get("/{id}", response_model=dict)
async def get_script(
id: int,
admin: Admin = Depends(get_current_admin),
service: ScriptService = Depends(get_script_service),
):
"""Get a single script by ID"""
script = await service.get_script(id)
if not script:
raise HTTPException(status_code=404, detail="Script not found")
return _script_to_dict(script)
@router.post("/", response_model=dict, status_code=201)
async def create_script(
payload: ScriptCreate,
request: Request,
admin: Admin = Depends(get_current_admin),
service: ScriptService = Depends(get_script_service),
db: AsyncSession = Depends(get_db),
):
"""Create a new script"""
script = Script(**payload.model_dump(exclude_none=True))
created = await service.create_script(script)
audit_repo = AuditLogRepositoryImpl(db)
await audit_repo.create(AuditLog(
admin_username=admin.username, action="create_script",
target_type="script", target_id=created.id,
detail=f"名称={created.name}", ip_address=request.client.host if request.client else "",
))
return _script_to_dict(created)
@router.put("/{id}", response_model=dict)
async def update_script(
id: int,
payload: ScriptUpdate,
request: Request,
admin: Admin = Depends(get_current_admin),
service: ScriptService = Depends(get_script_service),
db: AsyncSession = Depends(get_db),
):
"""Update an existing script"""
script = await service.get_script(id)
if not script:
raise HTTPException(status_code=404, detail="Script not found")
# Explicit allowlist to prevent mass-assignment to sensitive columns
ALLOWED_FIELDS = {"name", "category", "content", "description"}
for key, value in payload.model_dump(exclude_unset=True).items():
if key in ALLOWED_FIELDS:
setattr(script, key, value)
updated = await service.update_script(script)
audit_repo = AuditLogRepositoryImpl(db)
await audit_repo.create(AuditLog(
admin_username=admin.username, action="update_script",
target_type="script", target_id=id,
detail=f"名称={updated.name}", ip_address=request.client.host if request.client else "",
))
return _script_to_dict(updated)
@router.delete("/{id}", status_code=204)
async def delete_script(
id: int,
request: Request,
admin: Admin = Depends(get_current_admin),
service: ScriptService = Depends(get_script_service),
db: AsyncSession = Depends(get_db),
):
"""Delete a script"""
script = await service.get_script(id)
if not script:
raise HTTPException(status_code=404, detail="Script not found")
result = await service.delete_script(id)
if not result:
raise HTTPException(status_code=404, detail="Script not found")
audit_repo = AuditLogRepositoryImpl(db)
await audit_repo.create(AuditLog(
admin_username=admin.username, action="delete_script",
target_type="script", target_id=id,
detail=f"名称={script.name}", ip_address=request.client.host if request.client else "",
))
# ── Command Execution ──
@router.post("/exec", response_model=dict)
async def execute_command(
payload: ScriptExecute,
admin: Admin = Depends(get_current_admin),
service: ScriptService = Depends(get_script_service),
):
"""Execute a shell command on multiple servers via Agent /api/exec"""
# P2-18: Log dangerous commands for audit trail
from server.api.dependencies import check_dangerous_command
warnings = check_dangerous_command(payload.command)
if warnings:
raise HTTPException(
status_code=400,
detail={"message": "危险命令已拒绝执行", "warnings": warnings},
)
try:
execution = await service.execute_command(
command=payload.command,
server_ids=payload.server_ids,
script_id=payload.script_id,
credential_id=payload.credential_id,
timeout=payload.timeout,
operator=admin.username,
long_task=payload.long_task,
)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) from e
detail = await service.get_execution_detail(execution.id)
return detail if detail else _execution_to_dict(execution)
# ── Helper functions ──
def _script_to_dict(script: Script) -> dict:
return {
"id": script.id,
"name": script.name,
"category": script.category,
"content": script.content,
"description": script.description,
"created_by": script.created_by,
"created_at": str(script.created_at) if script.created_at else None,
"updated_at": str(script.updated_at) if script.updated_at else None,
}
def _execution_to_dict(execution) -> dict:
return {
"id": execution.id,
"script_id": execution.script_id,
"command": execution.command,
"server_ids": execution.server_ids,
"status": execution.status,
"results": execution.results,
"operator": execution.operator,
"credential_id": getattr(execution, "credential_id", None),
"started_at": str(execution.started_at) if execution.started_at else None,
"completed_at": str(execution.completed_at) if execution.completed_at else None,
}
def _credential_to_dict(credential: DbCredential) -> dict:
"""Convert credential to dict (never expose password in API response)"""
return {
"id": credential.id,
"name": credential.name,
"db_type": credential.db_type,
"host": credential.host,
"port": credential.port,
"username": credential.username,
"database": credential.database,
"created_at": str(credential.created_at) if credential.created_at else None,
}