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>
248 lines
8.7 KiB
Python
248 lines
8.7 KiB
Python
"""Nexus — Pydantic Request/Response Models
|
|
Shared schemas for API validation, replacing raw `dict` parameters.
|
|
"""
|
|
|
|
from typing import Optional, List
|
|
from pydantic import BaseModel, Field, model_validator
|
|
|
|
|
|
# ── Setting ──
|
|
|
|
class SettingUpdatePayload(BaseModel):
|
|
value: str = Field(..., min_length=0)
|
|
|
|
|
|
# ── Agent ──
|
|
|
|
class AgentHeartbeat(BaseModel):
|
|
server_id: int
|
|
is_online: bool = True
|
|
system_info: Optional[dict] = None
|
|
agent_version: Optional[str] = None
|
|
|
|
|
|
# ── Server ──
|
|
|
|
class ServerCreate(BaseModel):
|
|
name: str = Field(..., min_length=1, max_length=100)
|
|
domain: str = Field(..., min_length=1, max_length=255)
|
|
port: int = Field(22, ge=1, le=65535)
|
|
username: str = Field("root", max_length=100)
|
|
auth_method: str = Field("key", pattern="^(key|password)$")
|
|
password: Optional[str] = None
|
|
ssh_key_path: Optional[str] = None
|
|
ssh_key_private: Optional[str] = None
|
|
ssh_key_public: Optional[str] = None
|
|
agent_port: int = Field(8601, ge=1, le=65535)
|
|
agent_api_key: Optional[str] = None
|
|
description: Optional[str] = None
|
|
target_path: Optional[str] = None
|
|
category: Optional[str] = None
|
|
platform_id: Optional[int] = None
|
|
node_id: Optional[int] = None
|
|
protocols: Optional[list] = None
|
|
extra_attrs: Optional[dict] = None
|
|
|
|
|
|
class ServerUpdate(BaseModel):
|
|
name: Optional[str] = Field(None, min_length=1, max_length=100)
|
|
domain: Optional[str] = Field(None, min_length=1, max_length=255)
|
|
port: Optional[int] = Field(None, ge=1, le=65535)
|
|
username: Optional[str] = None
|
|
auth_method: Optional[str] = Field(None, pattern="^(key|password)$")
|
|
password: Optional[str] = None
|
|
ssh_key_path: Optional[str] = None
|
|
ssh_key_private: Optional[str] = None
|
|
ssh_key_public: Optional[str] = None
|
|
agent_port: Optional[int] = Field(None, ge=1, le=65535)
|
|
agent_api_key: Optional[str] = None
|
|
description: Optional[str] = None
|
|
target_path: Optional[str] = None
|
|
category: Optional[str] = None
|
|
platform_id: Optional[int] = None
|
|
node_id: Optional[int] = None
|
|
protocols: Optional[list] = None
|
|
extra_attrs: Optional[dict] = None
|
|
|
|
|
|
class ServerCheck(BaseModel):
|
|
server_ids: List[int] = Field(..., min_length=1)
|
|
|
|
|
|
# ── Sync ──
|
|
|
|
class SyncFiles(BaseModel):
|
|
server_ids: List[int] = Field(..., min_length=1)
|
|
source_path: str = Field(..., min_length=1)
|
|
target_path: Optional[str] = None
|
|
sync_mode: str = Field("incremental", pattern="^(incremental|full|overwrite|checksum)$")
|
|
batch_size: int = Field(50, ge=1, le=200)
|
|
concurrency: int = Field(10, ge=1, le=50)
|
|
|
|
|
|
class SyncPreview(BaseModel):
|
|
"""Dry-run: rsync --dry-run --stats against one representative server."""
|
|
server_id: int = Field(..., ge=1)
|
|
source_path: str = Field(..., min_length=1)
|
|
target_path: Optional[str] = None
|
|
sync_mode: str = Field("incremental", pattern="^(incremental|full|overwrite|checksum)$")
|
|
verbose: bool = False # If True, include per-file list (truncated to 200 lines)
|
|
|
|
|
|
class SyncBrowse(BaseModel):
|
|
server_id: int
|
|
path: str = Field("/", min_length=1)
|
|
|
|
|
|
class FileOperation(BaseModel):
|
|
"""Remote file operation via SSH exec (delete / rename / mkdir)."""
|
|
server_id: int = Field(..., ge=1)
|
|
operation: str = Field(..., pattern="^(delete|rename|mkdir)$")
|
|
path: str = Field(..., min_length=1) # source path (or new dir path for mkdir)
|
|
new_path: Optional[str] = None # required for rename
|
|
|
|
|
|
# ── Script ──
|
|
|
|
class ScriptCreate(BaseModel):
|
|
name: str = Field(..., min_length=1, max_length=100)
|
|
category: str = Field("ops", pattern="^(ops|deploy|check|cleanup)$")
|
|
content: str = Field(..., min_length=1)
|
|
description: Optional[str] = None
|
|
created_by: Optional[str] = None
|
|
|
|
|
|
class ScriptUpdate(BaseModel):
|
|
name: Optional[str] = Field(None, min_length=1, max_length=100)
|
|
category: Optional[str] = Field(None, pattern="^(ops|deploy|check|cleanup)$")
|
|
content: Optional[str] = None
|
|
description: Optional[str] = None
|
|
|
|
|
|
class ScriptExecute(BaseModel):
|
|
script_id: Optional[int] = None
|
|
command: str = Field(..., min_length=1)
|
|
server_ids: List[int] = Field(..., min_length=1)
|
|
credential_id: Optional[int] = None
|
|
timeout: int = Field(60, ge=1, le=600)
|
|
long_task: bool = False
|
|
|
|
|
|
class ScriptExecutionRetry(BaseModel):
|
|
server_ids: Optional[List[int]] = None
|
|
credential_id: Optional[int] = None
|
|
long_task: Optional[bool] = None
|
|
timeout: int = Field(120, ge=1, le=600)
|
|
|
|
|
|
class ScriptExecutionMarkStuck(BaseModel):
|
|
detail: str = Field("", max_length=500)
|
|
|
|
|
|
class AgentScriptCallback(BaseModel):
|
|
"""Child host reports long-running script completion (curl from nohup wrapper)."""
|
|
job_id: str = Field(..., min_length=8, max_length=64)
|
|
server_id: int = Field(..., ge=1)
|
|
secret: str = Field(..., min_length=16, max_length=128)
|
|
exit_code: int = 0
|
|
message: str = Field("", max_length=500)
|
|
log_tail: Optional[str] = Field(None, max_length=2000)
|
|
|
|
|
|
# ── DB Credential ──
|
|
|
|
class DbCredentialCreate(BaseModel):
|
|
name: str = Field(..., min_length=1, max_length=100)
|
|
db_type: str = Field("mysql", pattern="^(mysql|postgresql|mariadb|mongodb|redis)$")
|
|
host: str = Field(..., min_length=1)
|
|
port: int = Field(3306, ge=1, le=65535)
|
|
username: str = Field(..., min_length=1)
|
|
password: str = Field(..., min_length=1)
|
|
database: Optional[str] = None
|
|
|
|
|
|
# ── Schedule ──
|
|
|
|
class ScheduleCreate(BaseModel):
|
|
name: str = Field(..., min_length=1, max_length=100)
|
|
server_ids: str = Field(..., min_length=3) # JSON array string
|
|
run_mode: str = Field("once", pattern="^(once|cron)$")
|
|
cron_expr: Optional[str] = None # required when run_mode=cron
|
|
fire_at: Optional[str] = None # ISO datetime, required when run_mode=once
|
|
enabled: bool = True
|
|
# Schedule type
|
|
schedule_type: str = Field("push", pattern="^(push|script)$")
|
|
# Push-specific (required when schedule_type == 'push')
|
|
source_path: Optional[str] = None
|
|
sync_mode: str = Field("incremental", pattern="^(incremental|full|overwrite|checksum)$")
|
|
# Script-specific (required when schedule_type == 'script')
|
|
script_id: Optional[int] = None
|
|
script_content: Optional[str] = None
|
|
exec_timeout: int = Field(60, ge=10, le=600)
|
|
long_task: bool = False
|
|
|
|
@model_validator(mode="after")
|
|
def _validate_schedule_fields(self):
|
|
if self.run_mode == "cron" and not self.cron_expr:
|
|
raise ValueError("cron 模式必须提供 cron_expr")
|
|
if self.run_mode == "once" and not self.fire_at:
|
|
raise ValueError("一次性模式必须提供 fire_at 执行时间")
|
|
if self.schedule_type == "script" and not self.script_id and not self.script_content:
|
|
raise ValueError("script 类型调度必须提供 script_id 或 script_content")
|
|
if self.schedule_type == "push" and not self.source_path:
|
|
raise ValueError("push 类型调度必须提供 source_path")
|
|
return self
|
|
|
|
|
|
class ScheduleUpdate(BaseModel):
|
|
name: Optional[str] = Field(None, min_length=1, max_length=100)
|
|
source_path: Optional[str] = None
|
|
server_ids: Optional[str] = None
|
|
run_mode: Optional[str] = Field(None, pattern="^(once|cron)$")
|
|
cron_expr: Optional[str] = None
|
|
fire_at: Optional[str] = None
|
|
sync_mode: Optional[str] = Field(None, pattern="^(incremental|full|overwrite|checksum)$")
|
|
enabled: Optional[bool] = None
|
|
schedule_type: Optional[str] = Field(None, pattern="^(push|script)$")
|
|
script_id: Optional[int] = None
|
|
script_content: Optional[str] = None
|
|
exec_timeout: Optional[int] = Field(None, ge=10, le=600)
|
|
long_task: Optional[bool] = None
|
|
|
|
|
|
# ── Preset ──
|
|
|
|
class PresetCreate(BaseModel):
|
|
name: str = Field(..., min_length=1, max_length=100)
|
|
encrypted_pw: str = Field(..., min_length=1)
|
|
|
|
|
|
# ── Platform / Node ──
|
|
|
|
class PlatformCreate(BaseModel):
|
|
name: str = Field(..., min_length=1, max_length=100)
|
|
category: str = Field(..., min_length=1, max_length=50)
|
|
type: str = Field(..., min_length=1, max_length=50)
|
|
default_protocols: Optional[list] = None
|
|
charset: str = "utf-8"
|
|
|
|
|
|
class PlatformUpdate(BaseModel):
|
|
name: Optional[str] = Field(None, min_length=1, max_length=100)
|
|
category: Optional[str] = None
|
|
type: Optional[str] = None
|
|
default_protocols: Optional[list] = None
|
|
charset: Optional[str] = None
|
|
|
|
|
|
class NodeCreate(BaseModel):
|
|
name: str = Field(..., min_length=1, max_length=100)
|
|
parent_id: Optional[int] = None
|
|
sort_order: int = 0
|
|
|
|
|
|
class NodeUpdate(BaseModel):
|
|
name: Optional[str] = Field(None, min_length=1, max_length=100)
|
|
parent_id: Optional[int] = None
|
|
sort_order: Optional[int] = None
|