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>
210 lines
7.2 KiB
Python
210 lines
7.2 KiB
Python
"""Nexus — Sync Engine v2 API Routes (Step 4)
|
|
|
|
S1: POST /api/sync/files — rsync file sync
|
|
P1: POST /api/sync/preview — rsync dry-run (方案D: 智能提示预览)
|
|
"""
|
|
|
|
import logging
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, Request
|
|
|
|
from server.api.schemas import SyncFiles, SyncBrowse, SyncPreview, FileOperation
|
|
from server.api.dependencies import get_server_service
|
|
from server.application.services.sync_engine_v2 import SyncEngineV2
|
|
from server.infrastructure.database.server_repo import ServerRepositoryImpl
|
|
from server.infrastructure.database.sync_log_repo import SyncLogRepositoryImpl
|
|
from server.infrastructure.database.audit_log_repo import AuditLogRepositoryImpl
|
|
from server.infrastructure.database.push_schedule_repo import PushRetryJobRepositoryImpl
|
|
from server.api.auth_jwt import get_current_admin
|
|
from server.domain.models import Admin, AuditLog
|
|
|
|
router = APIRouter(prefix="/api/sync", tags=["sync"])
|
|
|
|
logger = logging.getLogger("nexus.sync_v2")
|
|
|
|
|
|
async def _get_sync_engine(request: Request) -> SyncEngineV2:
|
|
"""DI factory for SyncEngineV2"""
|
|
session = request.state.db
|
|
return SyncEngineV2(
|
|
server_repo=ServerRepositoryImpl(session),
|
|
sync_log_repo=SyncLogRepositoryImpl(session),
|
|
audit_repo=AuditLogRepositoryImpl(session),
|
|
retry_repo=PushRetryJobRepositoryImpl(session),
|
|
)
|
|
|
|
|
|
@router.post("/files")
|
|
async def sync_files(
|
|
payload: SyncFiles,
|
|
request: Request,
|
|
admin: Admin = Depends(get_current_admin),
|
|
):
|
|
"""S1: File sync via rsync over SSH (with parallel execution)"""
|
|
engine = await _get_sync_engine(request)
|
|
return await engine.sync_files(
|
|
server_ids=payload.server_ids,
|
|
source_path=payload.source_path,
|
|
target_path=payload.target_path,
|
|
sync_mode=payload.sync_mode,
|
|
operator=admin.username,
|
|
batch_size=payload.batch_size,
|
|
concurrency=payload.concurrency,
|
|
)
|
|
|
|
|
|
@router.post("/preview")
|
|
async def preview_sync(
|
|
payload: SyncPreview,
|
|
request: Request,
|
|
admin: Admin = Depends(get_current_admin),
|
|
):
|
|
"""P1: Dry-run rsync --stats against one representative server.
|
|
|
|
No data is transferred. Returns file-change statistics and optional
|
|
per-file list (verbose=true, capped at 200 lines).
|
|
|
|
Full-sync mode (--delete) shows files_deleted — use this to confirm
|
|
before running a destructive full sync.
|
|
"""
|
|
engine = await _get_sync_engine(request)
|
|
result = await engine.preview_sync(
|
|
server_id=payload.server_id,
|
|
source_path=payload.source_path,
|
|
target_path=payload.target_path,
|
|
sync_mode=payload.sync_mode,
|
|
verbose=payload.verbose,
|
|
)
|
|
if "error" in result:
|
|
raise HTTPException(status_code=400, detail=result["error"])
|
|
|
|
await _audit_sync(
|
|
"sync_preview", "server", payload.server_id,
|
|
f"Dry-run preview: {payload.source_path} → server #{payload.server_id} ({payload.sync_mode})",
|
|
admin.username, request,
|
|
)
|
|
return result
|
|
|
|
|
|
@router.post("/file-ops")
|
|
async def file_operation(
|
|
payload: FileOperation,
|
|
request: Request,
|
|
admin: Admin = Depends(get_current_admin),
|
|
):
|
|
"""Remote file operation (delete / rename / mkdir) via SSH exec.
|
|
|
|
All paths are shlex-quoted server-side. Dirs use rm -rf; confirm
|
|
client-side before sending delete on a directory. Audit-logged.
|
|
"""
|
|
import shlex
|
|
from server.infrastructure.database.server_repo import ServerRepositoryImpl
|
|
from server.infrastructure.ssh.asyncssh_pool import exec_ssh_command
|
|
|
|
session = request.state.db
|
|
server = await ServerRepositoryImpl(session).get_by_id(payload.server_id)
|
|
if not server:
|
|
raise HTTPException(status_code=404, detail="Server not found")
|
|
|
|
op = payload.operation
|
|
p = payload.path.rstrip("/") or "/"
|
|
|
|
if op == "delete":
|
|
# rm -rf covers both files and directories
|
|
cmd = f"rm -rf {shlex.quote(p)}"
|
|
audit_detail = f"Delete: {p}"
|
|
elif op == "rename":
|
|
if not payload.new_path:
|
|
raise HTTPException(status_code=400, detail="new_path required for rename")
|
|
np = payload.new_path.rstrip("/") or "/"
|
|
cmd = f"mv {shlex.quote(p)} {shlex.quote(np)}"
|
|
audit_detail = f"Rename: {p} → {np}"
|
|
elif op == "mkdir":
|
|
cmd = f"mkdir -p {shlex.quote(p)}"
|
|
audit_detail = f"Mkdir: {p}"
|
|
else:
|
|
raise HTTPException(status_code=400, detail="Invalid operation")
|
|
|
|
result = await exec_ssh_command(server, cmd, timeout=30)
|
|
if result["exit_code"] != 0:
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail=result["stderr"][:300] or f"Command failed (exit {result['exit_code']})"
|
|
)
|
|
|
|
await _audit_sync(
|
|
f"file_{op}", "server", payload.server_id,
|
|
f"{audit_detail} on {server.name}", admin.username, request,
|
|
)
|
|
return {"success": True, "operation": op, "path": p}
|
|
|
|
|
|
@router.post("/browse")
|
|
async def browse_directory(
|
|
payload: SyncBrowse,
|
|
request: Request,
|
|
admin: Admin = Depends(get_current_admin),
|
|
):
|
|
"""Browse remote directory listing via SSH"""
|
|
from server.infrastructure.ssh.asyncssh_pool import exec_ssh_command
|
|
from server.infrastructure.database.server_repo import ServerRepositoryImpl
|
|
|
|
session = request.state.db
|
|
server_id = payload.server_id
|
|
path = payload.path
|
|
|
|
if not server_id:
|
|
raise HTTPException(status_code=400, detail="server_id required")
|
|
|
|
repo = ServerRepositoryImpl(session)
|
|
server = await repo.get_by_id(server_id)
|
|
if not server:
|
|
raise HTTPException(status_code=404, detail="Server not found")
|
|
|
|
# Use ls -la for detailed listing, parse output
|
|
import shlex
|
|
result = await exec_ssh_command(server, f"ls -la {shlex.quote(path)}", timeout=10)
|
|
|
|
# Audit
|
|
await _audit_sync("browse_directory", "server", server_id, f"Browse {server.name}:{path}", admin.username, request)
|
|
|
|
if result["exit_code"] != 0:
|
|
return {"path": path, "entries": [], "error": result["stderr"][:500]}
|
|
|
|
entries = []
|
|
for line in result["stdout"].split("\n")[1:]: # Skip "total" line
|
|
if not line.strip():
|
|
continue
|
|
parts = line.split(None, 8)
|
|
if len(parts) >= 9:
|
|
is_dir = parts[0].startswith("d")
|
|
entries.append({
|
|
"name": parts[8], # Last field after split(None, 8) — handles spaces in names
|
|
"is_dir": is_dir,
|
|
"size": parts[4],
|
|
"perms": parts[0],
|
|
"owner": parts[2],
|
|
"modified": " ".join(parts[5:8]),
|
|
})
|
|
|
|
return {"path": path, "entries": entries}
|
|
|
|
|
|
# ── Shared audit helper for sync routes ──
|
|
|
|
async def _audit_sync(action: str, target_type: str, target_id: int, detail: str, operator: str, request: Request):
|
|
"""Create audit log entry for sync operations"""
|
|
try:
|
|
session = request.state.db
|
|
audit_repo = AuditLogRepositoryImpl(session)
|
|
await audit_repo.create(AuditLog(
|
|
admin_username=operator,
|
|
action=action,
|
|
target_type=target_type,
|
|
target_id=target_id,
|
|
detail=detail,
|
|
ip_address=request.client.host if request.client else "",
|
|
))
|
|
except Exception:
|
|
logger.warning(f"Audit log write failed for {action} on {target_type}/{target_id}", exc_info=True)
|