Files
Nexus/server/api/scripts.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

377 lines
13 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,
)
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"name={created.name}", ip_address=request.client.host if request.client else "",
))
return _credential_to_dict(created)
@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"name={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")
for key, value in payload.model_dump(exclude_unset=True).items():
if hasattr(script, key) and key != "id":
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"name={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"name={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,
}