cdd0be328a
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>
377 lines
13 KiB
Python
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,
|
|
}
|