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

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)