19cfb7caaa
Co-authored-by: Cursor <cursoragent@cursor.com>
1585 lines
54 KiB
Python
1585 lines
54 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,
|
||
SyncBrowseLocal,
|
||
SyncPreview,
|
||
FileOperation,
|
||
FileClipboardApply,
|
||
LocalFileOperation,
|
||
LocalFilePreview,
|
||
SyncVerify,
|
||
SyncCancel,
|
||
ValidateSourcePath,
|
||
SyncDiagnose,
|
||
FileSyncDiff,
|
||
FileDownload,
|
||
FileRead,
|
||
FileWrite,
|
||
FileChmod,
|
||
FileCompress,
|
||
FileDecompress,
|
||
)
|
||
from server.api.remote_path_validation import RemotePathError, assert_clipboard_transfer_safe
|
||
from server.utils.posix_paths import (
|
||
PosixPathError,
|
||
assert_zip_member_safe,
|
||
ensure_under_nexus_upload,
|
||
is_path_under_prefix,
|
||
normalize_remote_abs_path,
|
||
posix_basename,
|
||
posix_dirname,
|
||
posix_join,
|
||
normalize_remote_dest,
|
||
remote_join,
|
||
resolve_nexus_host_path,
|
||
to_posix,
|
||
)
|
||
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.remote_shell import exec_ssh_command_with_fallback
|
||
|
||
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
|
||
try:
|
||
p = normalize_remote_abs_path(payload.path)
|
||
except PosixPathError as exc:
|
||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||
|
||
if op == "delete":
|
||
# rm -rf covers both files and directories
|
||
cmd = f"rm -rf {shlex.quote(p)}"
|
||
audit_detail = f"删除: {p}"
|
||
elif op == "rename":
|
||
if not payload.new_path:
|
||
raise HTTPException(status_code=400, detail="new_path required for rename")
|
||
try:
|
||
np = normalize_remote_abs_path(payload.new_path)
|
||
except PosixPathError as exc:
|
||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||
cmd = f"mv {shlex.quote(p)} {shlex.quote(np)}"
|
||
audit_detail = f"重命名: {p} → {np}"
|
||
elif op == "mkdir":
|
||
cmd = f"mkdir -p {shlex.quote(p)}"
|
||
audit_detail = f"新建目录: {p}"
|
||
else:
|
||
raise HTTPException(status_code=400, detail="Invalid operation")
|
||
|
||
result = await exec_ssh_command_with_fallback(server, cmd, timeout=30)
|
||
if result["exit_code"] != 0:
|
||
err = (result.get("stderr") or result.get("stdout") or "")[:300]
|
||
raise HTTPException(
|
||
status_code=403 if "permission denied" in err.lower() else 400,
|
||
detail=err or f"Command failed (exit {result['exit_code']})",
|
||
)
|
||
|
||
await _audit_sync(
|
||
f"file_{op}", "server", payload.server_id,
|
||
f"{audit_detail} 于 {server.name}", admin.username, request,
|
||
)
|
||
return {"success": True, "operation": op, "path": p}
|
||
|
||
|
||
@router.post("/file-clipboard")
|
||
async def apply_file_clipboard(
|
||
payload: FileClipboardApply,
|
||
request: Request,
|
||
admin: Admin = Depends(get_current_admin),
|
||
):
|
||
"""Batch copy (cp -r) or move/cut (mv -t) remote paths into dest_dir."""
|
||
import shlex
|
||
from server.infrastructure.database.server_repo import ServerRepositoryImpl
|
||
from server.infrastructure.ssh.asyncssh_pool import exec_ssh_command
|
||
from server.infrastructure.ssh.remote_shell import exec_ssh_command_with_fallback
|
||
|
||
try:
|
||
sources, dest_dir = assert_clipboard_transfer_safe(payload.sources, payload.dest_dir)
|
||
except RemotePathError as exc:
|
||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||
|
||
session = request.state.db
|
||
server = await ServerRepositoryImpl(session).get_by_id(payload.server_id)
|
||
if not server:
|
||
raise HTTPException(status_code=404, detail="服务器不存在")
|
||
|
||
dest_q = shlex.quote(dest_dir)
|
||
check = await exec_ssh_command(server, f"test -d {dest_q}", timeout=15)
|
||
if check["exit_code"] != 0:
|
||
raise HTTPException(status_code=400, detail="目标目录不存在")
|
||
|
||
quoted_sources = " ".join(shlex.quote(s) for s in sources)
|
||
if payload.mode == "copy":
|
||
cmd = f"cp -r {quoted_sources} {dest_q}"
|
||
audit_action = "file_copy"
|
||
audit_detail = f"复制 {len(sources)} 项 → {dest_dir}"
|
||
else:
|
||
cmd = f"mv -t {dest_q} {quoted_sources}"
|
||
audit_action = "file_move"
|
||
audit_detail = f"移动 {len(sources)} 项 → {dest_dir}"
|
||
|
||
result = await exec_ssh_command_with_fallback(server, cmd, timeout=120)
|
||
if result["exit_code"] != 0:
|
||
raise HTTPException(
|
||
status_code=502,
|
||
detail=(result["stderr"] or result["stdout"] or "传输失败")[:300],
|
||
)
|
||
|
||
await _audit_sync(
|
||
audit_action,
|
||
"server",
|
||
payload.server_id,
|
||
f"{audit_detail} 于 {server.name}",
|
||
admin.username,
|
||
request,
|
||
)
|
||
return {
|
||
"success": True,
|
||
"mode": payload.mode,
|
||
"dest_dir": dest_dir,
|
||
"count": len(sources),
|
||
}
|
||
|
||
|
||
@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")
|
||
|
||
try:
|
||
path = normalize_remote_abs_path(path)
|
||
except PosixPathError as exc:
|
||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||
|
||
import shlex
|
||
from server.infrastructure.ssh.remote_shell import exec_ssh_command_with_fallback
|
||
from server.utils.unix_ls import parse_ls_la_line, perms_to_mode_octal
|
||
|
||
path_q = shlex.quote(path)
|
||
# 与写文件/chmod 一致:非 root SSH 在 Permission denied 时按 files_elevation 尝试 sudo -n
|
||
result = await exec_ssh_command_with_fallback(
|
||
server,
|
||
f"ls -la --time-style=long-iso {path_q}",
|
||
timeout=10,
|
||
)
|
||
if result["exit_code"] != 0:
|
||
result = await exec_ssh_command_with_fallback(
|
||
server, f"ls -la {path_q}", timeout=10
|
||
)
|
||
|
||
await _audit_sync("browse_directory", "server", server_id, f"Browse {server.name}:{path}", admin.username, request)
|
||
|
||
if result["exit_code"] != 0:
|
||
return {"path": path, "items": [], "error": (result.get("stderr") or "")[:500]}
|
||
|
||
raw_entries = []
|
||
for line in result["stdout"].split("\n"):
|
||
parsed = parse_ls_la_line(line)
|
||
if parsed:
|
||
raw_entries.append(parsed)
|
||
|
||
items = []
|
||
for e in raw_entries:
|
||
is_dir = bool(e["is_dir"])
|
||
is_link = bool(e["is_link"])
|
||
try:
|
||
size = int(e["size"]) if not is_dir and not is_link else 0
|
||
except (TypeError, ValueError):
|
||
size = 0
|
||
if is_dir:
|
||
ftype = "directory"
|
||
elif is_link:
|
||
ftype = "symlink"
|
||
else:
|
||
ftype = "file"
|
||
mode_octal = perms_to_mode_octal(e["perms"]) or ""
|
||
items.append({
|
||
"name": e["name"],
|
||
"type": ftype,
|
||
"size": size,
|
||
"modified": e["modified"],
|
||
"permissions": e["perms"],
|
||
"owner": e.get("owner") or "",
|
||
"group": e.get("group") or "",
|
||
"mode_octal": mode_octal,
|
||
"link_target": e.get("link_target"),
|
||
})
|
||
|
||
return {"path": path, "items": items}
|
||
|
||
|
||
@router.post("/browse-local")
|
||
async def browse_local_directory(
|
||
payload: SyncBrowseLocal,
|
||
request: Request,
|
||
admin: Admin = Depends(get_current_admin),
|
||
):
|
||
"""Browse a local directory on the Nexus server (for upload file manager).
|
||
|
||
Only allows browsing under /tmp/nexus_upload_* directories.
|
||
Returns entries with name, is_dir, size.
|
||
"""
|
||
import os
|
||
|
||
try:
|
||
real_path = ensure_under_nexus_upload(payload.path)
|
||
except PosixPathError as exc:
|
||
raise HTTPException(status_code=403, detail=str(exc)) from exc
|
||
|
||
if not os.path.isdir(real_path):
|
||
raise HTTPException(status_code=404, detail="目录不存在")
|
||
|
||
entries = []
|
||
try:
|
||
for entry in sorted(os.scandir(real_path), key=lambda e: (not e.is_dir(), e.name.lower())):
|
||
try:
|
||
stat = entry.stat()
|
||
entries.append({
|
||
"name": entry.name,
|
||
"is_dir": entry.is_dir(),
|
||
"size": stat.st_size if not entry.is_dir() else 0,
|
||
})
|
||
except OSError:
|
||
continue
|
||
except PermissionError:
|
||
raise HTTPException(status_code=403, detail="无权限访问该目录") from None
|
||
|
||
return {"path": real_path, "entries": entries}
|
||
|
||
|
||
@router.post("/local-file-ops")
|
||
async def local_file_operation(
|
||
payload: LocalFileOperation,
|
||
request: Request,
|
||
admin: Admin = Depends(get_current_admin),
|
||
):
|
||
"""Delete or rename a file/directory in the local upload staging area.
|
||
|
||
Only allows operations under /tmp/nexus_upload_* directories.
|
||
"""
|
||
import os
|
||
import shutil
|
||
|
||
try:
|
||
real_path = ensure_under_nexus_upload(payload.path)
|
||
except PosixPathError as exc:
|
||
raise HTTPException(status_code=403, detail=str(exc)) from exc
|
||
|
||
path = to_posix(real_path)
|
||
|
||
if payload.operation == "delete":
|
||
if not os.path.exists(real_path):
|
||
raise HTTPException(status_code=404, detail="文件或目录不存在")
|
||
try:
|
||
if os.path.isdir(real_path):
|
||
shutil.rmtree(real_path)
|
||
else:
|
||
os.unlink(real_path)
|
||
except OSError as e:
|
||
raise HTTPException(status_code=500, detail=f"删除失败: {e}") from e
|
||
|
||
await _audit_sync(
|
||
"local_file_delete", "sync", 0,
|
||
f"删除: {path}", admin.username, request,
|
||
)
|
||
return {"success": True, "operation": "delete", "path": path}
|
||
|
||
elif payload.operation == "rename":
|
||
if not payload.new_name:
|
||
raise HTTPException(status_code=400, detail="new_name 必填")
|
||
# Sanitize: strip slashes and path traversal
|
||
safe_name = payload.new_name.replace("/", "_").replace("\\", "_").strip()
|
||
if not safe_name or safe_name == "." or safe_name == "..":
|
||
raise HTTPException(status_code=400, detail="无效的新名称")
|
||
|
||
if not os.path.exists(real_path):
|
||
raise HTTPException(status_code=404, detail="文件或目录不存在")
|
||
|
||
new_path = posix_join(posix_dirname(real_path), safe_name)
|
||
|
||
try:
|
||
ensure_under_nexus_upload(new_path)
|
||
except PosixPathError as exc:
|
||
raise HTTPException(status_code=403, detail=str(exc)) from exc
|
||
new_path = resolve_nexus_host_path(new_path)
|
||
|
||
if os.path.exists(new_path):
|
||
raise HTTPException(status_code=400, detail="目标名称已存在")
|
||
|
||
try:
|
||
os.rename(real_path, new_path)
|
||
except OSError as e:
|
||
raise HTTPException(status_code=500, detail=f"重命名失败: {e}") from e
|
||
|
||
await _audit_sync(
|
||
"local_file_rename", "sync", 0,
|
||
f"重命名: {path} → {safe_name}", admin.username, request,
|
||
)
|
||
return {"success": True, "operation": "rename", "path": path, "new_name": safe_name}
|
||
|
||
|
||
@router.post("/local-file-preview")
|
||
async def local_file_preview(
|
||
payload: LocalFilePreview,
|
||
request: Request,
|
||
admin: Admin = Depends(get_current_admin),
|
||
):
|
||
"""Preview file content in the local upload staging area.
|
||
|
||
Only allows files under /tmp/nexus_upload_* directories.
|
||
Returns up to 4KB of file content, base64-encoded.
|
||
"""
|
||
import os
|
||
import base64
|
||
|
||
try:
|
||
real_path = ensure_under_nexus_upload(payload.path)
|
||
except PosixPathError as exc:
|
||
raise HTTPException(status_code=403, detail=str(exc)) from exc
|
||
|
||
path = to_posix(real_path)
|
||
|
||
if not os.path.isfile(real_path):
|
||
raise HTTPException(status_code=404, detail="文件不存在或不是普通文件")
|
||
|
||
# Size check: skip files over 1MB
|
||
file_size = os.path.getsize(real_path)
|
||
if file_size > 1_048_576:
|
||
raise HTTPException(status_code=400, detail="文件过大,不支持预览(>1MB)")
|
||
|
||
# Read up to 4KB
|
||
max_read = 4096
|
||
try:
|
||
with open(real_path, "rb") as f:
|
||
raw = f.read(max_read)
|
||
except OSError as e:
|
||
raise HTTPException(status_code=500, detail=f"读取失败: {e}") from None
|
||
|
||
truncated = file_size > max_read
|
||
|
||
# Detect encoding: try UTF-8 decode
|
||
encoding_hint = "binary"
|
||
try:
|
||
raw.decode("utf-8")
|
||
encoding_hint = "text"
|
||
except (UnicodeDecodeError, ValueError):
|
||
pass
|
||
|
||
name = posix_basename(real_path)
|
||
|
||
await _audit_sync(
|
||
"local_file_preview", "sync", 0,
|
||
f"预览: {path}", admin.username, request,
|
||
)
|
||
|
||
return {
|
||
"name": name,
|
||
"size": file_size,
|
||
"content_base64": base64.b64encode(raw).decode("ascii"),
|
||
"truncated": truncated,
|
||
"encoding_hint": encoding_hint,
|
||
}
|
||
|
||
|
||
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)
|
||
|
||
|
||
@router.post("/cancel")
|
||
async def cancel_sync(
|
||
payload: SyncCancel,
|
||
request: Request,
|
||
admin: Admin = Depends(get_current_admin),
|
||
):
|
||
"""Cancel an in-progress push by setting a Redis flag.
|
||
|
||
The sync engine checks this flag before each rsync execution.
|
||
Already-completed servers retain their results; pending ones are skipped.
|
||
"""
|
||
try:
|
||
from server.infrastructure.redis.client import get_redis
|
||
redis = get_redis()
|
||
await redis.set(f"sync:cancel:{payload.batch_id}", "1", ex=3600)
|
||
except Exception as e:
|
||
raise HTTPException(status_code=500, detail=f"取消操作失败: {e}") from e
|
||
|
||
await _audit_sync(
|
||
"sync_cancel", "sync", 0,
|
||
f"取消推送: batch_id={payload.batch_id}", admin.username, request,
|
||
)
|
||
|
||
return {"cancelled": True, "batch_id": payload.batch_id}
|
||
|
||
|
||
# ── H5: Validate Source Path ──
|
||
|
||
_FORBIDDEN_PATH_PREFIXES = ("/etc", "/root", "/home", "/var", "/boot", "/dev", "/proc", "/sys", "/run", "/srv")
|
||
|
||
|
||
@router.post("/validate-source-path")
|
||
async def validate_source_path(
|
||
payload: ValidateSourcePath,
|
||
request: Request,
|
||
admin: Admin = Depends(get_current_admin),
|
||
):
|
||
"""Validate a local directory path on the Nexus server as a push source.
|
||
|
||
Security: rejects sensitive system paths (/etc, /root, /home, etc.)
|
||
and paths with traversal components.
|
||
"""
|
||
import os
|
||
|
||
path = payload.path.strip()
|
||
|
||
try:
|
||
real_path = resolve_nexus_host_path(path)
|
||
except PosixPathError as exc:
|
||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||
if ".." in to_posix(path).split("/"):
|
||
raise HTTPException(status_code=400, detail="路径不允许包含 '..'")
|
||
|
||
# Reject sensitive paths
|
||
for prefix in _FORBIDDEN_PATH_PREFIXES:
|
||
if real_path == prefix or real_path.startswith(prefix + "/"):
|
||
raise HTTPException(status_code=403, detail=f"不允许使用系统路径: {prefix}")
|
||
|
||
if not os.path.exists(real_path):
|
||
raise HTTPException(status_code=404, detail="路径不存在")
|
||
|
||
if not os.path.isdir(real_path):
|
||
raise HTTPException(status_code=400, detail="路径不是目录")
|
||
|
||
# Count files and total size
|
||
file_count = 0
|
||
total_size = 0
|
||
for root, _dirs, fnames in os.walk(real_path):
|
||
for fn in fnames:
|
||
file_count += 1
|
||
try:
|
||
total_size += os.path.getsize(posix_join(root, fn))
|
||
except OSError:
|
||
pass
|
||
if file_count >= 10000:
|
||
break
|
||
if file_count >= 10000:
|
||
break
|
||
|
||
await _audit_sync(
|
||
"validate_source_path", "sync", 0,
|
||
f"验证源路径: {path} ({file_count} 文件)",
|
||
admin.username, request,
|
||
)
|
||
|
||
return {
|
||
"valid": True,
|
||
"is_dir": True,
|
||
"path": real_path,
|
||
"file_count": file_count,
|
||
"size_bytes": total_size,
|
||
"file_count_truncated": file_count >= 10000,
|
||
}
|
||
|
||
|
||
# ── H4: Diagnose Push Failure ──
|
||
|
||
@router.post("/diagnose")
|
||
async def diagnose_push(
|
||
payload: SyncDiagnose,
|
||
request: Request,
|
||
admin: Admin = Depends(get_current_admin),
|
||
):
|
||
"""Diagnose why a push failed for a specific server.
|
||
|
||
Checks SSH connectivity, disk space, target path permissions, and write access.
|
||
"""
|
||
import shlex
|
||
from server.infrastructure.ssh.asyncssh_pool import exec_ssh_command
|
||
from server.infrastructure.database.server_repo import ServerRepositoryImpl
|
||
|
||
session = request.state.db
|
||
server = await ServerRepositoryImpl(session).get_by_id(payload.server_id)
|
||
if not server:
|
||
raise HTTPException(status_code=404, detail="服务器不存在")
|
||
|
||
result = {
|
||
"server_id": payload.server_id,
|
||
"server_name": server.name,
|
||
"ssh_ok": False,
|
||
"disk_avail": None,
|
||
"disk_used_pct": None,
|
||
"path_exists": None,
|
||
"path_perms": None,
|
||
"path_writable": None,
|
||
"errors": [],
|
||
}
|
||
|
||
# 1. SSH connectivity
|
||
try:
|
||
r = await exec_ssh_command(server, "echo ok", timeout=10)
|
||
if r["exit_code"] == 0 and "ok" in r["stdout"]:
|
||
result["ssh_ok"] = True
|
||
else:
|
||
result["errors"].append(f"SSH 连接异常: {(r.get('stderr') or '')[:200] or 'exit code ' + str(r['exit_code'])}")
|
||
# Cannot continue if SSH fails
|
||
await _audit_sync("diagnose", "server", payload.server_id, "诊断: SSH不通", admin.username, request)
|
||
return result
|
||
except Exception as e:
|
||
result["errors"].append(f"SSH 连接失败: {e}")
|
||
await _audit_sync("diagnose", "server", payload.server_id, "诊断: SSH失败", admin.username, request)
|
||
return result
|
||
|
||
try:
|
||
dest = normalize_remote_dest(payload.target_path or server.target_path)
|
||
except PosixPathError as exc:
|
||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||
safe_dest = shlex.quote(dest)
|
||
|
||
# 2. Disk space
|
||
try:
|
||
r = await exec_ssh_command(server, f"df -h {safe_dest} | tail -1", timeout=10)
|
||
if r["exit_code"] == 0:
|
||
parts = r["stdout"].strip().split()
|
||
if len(parts) >= 6:
|
||
result["disk_avail"] = parts[3] # e.g. "50G"
|
||
try:
|
||
result["disk_used_pct"] = int(parts[4].replace("%", "")) # e.g. 72
|
||
except ValueError:
|
||
pass
|
||
except Exception as e:
|
||
logger.debug(f"Diagnose disk check failed for server {payload.server_id}: {e}")
|
||
try:
|
||
r = await exec_ssh_command(server, f"ls -ld {safe_dest} 2>&1", timeout=10)
|
||
if r["exit_code"] == 0:
|
||
result["path_exists"] = True
|
||
parts = r["stdout"].strip().split()
|
||
if parts:
|
||
result["path_perms"] = parts[0] # e.g. "drwxr-xr-x"
|
||
else:
|
||
result["path_exists"] = False
|
||
result["errors"].append(f"目标路径不存在: {dest}")
|
||
except Exception as e:
|
||
logger.debug(f"Diagnose path check failed for server {payload.server_id}: {e}")
|
||
|
||
# 4. Write test
|
||
if result["path_exists"]:
|
||
try:
|
||
test_file = remote_join(dest, ".nexus_diag_test_$$")
|
||
r = await exec_ssh_command(
|
||
server,
|
||
f"touch {shlex.quote(test_file)} && rm -f {shlex.quote(test_file)}",
|
||
timeout=10,
|
||
)
|
||
result["path_writable"] = r["exit_code"] == 0
|
||
if r["exit_code"] != 0:
|
||
result["errors"].append(f"目标路径不可写: {(r.get('stderr') or '')[:200]}")
|
||
except Exception as e:
|
||
result["errors"].append(f"写入测试失败: {e}")
|
||
result["path_writable"] = False
|
||
else:
|
||
result["path_writable"] = False
|
||
|
||
await _audit_sync(
|
||
"diagnose", "server", payload.server_id,
|
||
f"诊断: SSH={'✓' if result['ssh_ok'] else '✗'} 路径={'✓' if result['path_exists'] else '✗'} 写入={'✓' if result['path_writable'] else '✗'}",
|
||
admin.username, request,
|
||
)
|
||
|
||
return result
|
||
|
||
|
||
# ── H3: File Diff View ──
|
||
|
||
_MAX_DIFF_SIZE = 102_400 # 100 KB
|
||
|
||
|
||
@router.post("/file-diff")
|
||
async def file_diff(
|
||
payload: FileSyncDiff,
|
||
request: Request,
|
||
admin: Admin = Depends(get_current_admin),
|
||
):
|
||
"""Compare a local file with its remote counterpart to show push diff.
|
||
|
||
Security: only allows diffing files under /tmp/nexus_upload_* source paths.
|
||
Reads up to 100KB from each side. Returns unified diff lines.
|
||
"""
|
||
import os
|
||
import difflib
|
||
import shlex
|
||
from server.infrastructure.ssh.asyncssh_pool import exec_ssh_command
|
||
from server.infrastructure.database.server_repo import ServerRepositoryImpl
|
||
|
||
try:
|
||
real_source = ensure_under_nexus_upload(payload.source_path)
|
||
except PosixPathError as exc:
|
||
raise HTTPException(status_code=403, detail=str(exc)) from exc
|
||
|
||
rel = to_posix(payload.relative_path).lstrip("/")
|
||
if not rel or ".." in rel.split("/"):
|
||
raise HTTPException(status_code=400, detail="相对路径无效")
|
||
|
||
local_file = resolve_nexus_host_path(posix_join(real_source, rel))
|
||
if not is_path_under_prefix(local_file, real_source):
|
||
raise HTTPException(status_code=403, detail="文件路径越界")
|
||
|
||
if not os.path.isfile(local_file):
|
||
return {
|
||
"file_name": posix_basename(rel),
|
||
"local_exists": False,
|
||
"remote_exists": None,
|
||
"local_size": 0,
|
||
"remote_size": None,
|
||
"diff_lines": [],
|
||
"truncated": False,
|
||
"error": "本地文件不存在",
|
||
}
|
||
|
||
# Read local file
|
||
local_size = os.path.getsize(local_file)
|
||
try:
|
||
with open(local_file, "r", encoding="utf-8", errors="replace") as f:
|
||
local_text = f.read(_MAX_DIFF_SIZE)
|
||
except OSError as e:
|
||
raise HTTPException(status_code=500, detail=f"读取本地文件失败: {e}") from None
|
||
|
||
local_lines = local_text.splitlines(keepends=True)
|
||
local_truncated = local_size > _MAX_DIFF_SIZE
|
||
|
||
# Get server + target path
|
||
session = request.state.db
|
||
server = await ServerRepositoryImpl(session).get_by_id(payload.server_id)
|
||
if not server:
|
||
raise HTTPException(status_code=404, detail="服务器不存在")
|
||
|
||
try:
|
||
dest = normalize_remote_dest(payload.target_path or server.target_path)
|
||
remote_file = remote_join(dest, rel)
|
||
except PosixPathError as exc:
|
||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||
|
||
# Read remote file
|
||
remote_exists = False
|
||
remote_size = None
|
||
remote_lines = []
|
||
remote_truncated = False
|
||
|
||
try:
|
||
r = await exec_ssh_command(
|
||
server,
|
||
f"wc -c {shlex.quote(remote_file)} 2>/dev/null && cat {shlex.quote(remote_file)}",
|
||
timeout=15,
|
||
)
|
||
if r["exit_code"] == 0:
|
||
remote_exists = True
|
||
output = r["stdout"]
|
||
# First line is wc -c output: " 12345 /path/file"
|
||
first_newline = output.index("\n") if "\n" in output else 0
|
||
wc_line = output[:first_newline].strip()
|
||
try:
|
||
remote_size = int(wc_line.split()[0])
|
||
except (ValueError, IndexError):
|
||
remote_size = None
|
||
remote_text = output[first_newline + 1:]
|
||
if remote_size is not None and remote_size > _MAX_DIFF_SIZE:
|
||
remote_truncated = True
|
||
remote_lines = remote_text.splitlines(keepends=True)
|
||
else:
|
||
# File doesn't exist on remote — treat as empty (all local lines are additions)
|
||
remote_exists = False
|
||
except Exception as e:
|
||
# SSH failed — report error
|
||
logger.warning(f"File diff remote read failed for server {payload.server_id}: {e}")
|
||
remote_exists = None
|
||
|
||
# Compute unified diff
|
||
diff_lines = []
|
||
for line in difflib.unified_diff(
|
||
remote_lines, local_lines,
|
||
fromfile=f"remote/{payload.relative_path}",
|
||
tofile=f"local/{payload.relative_path}",
|
||
lineterm="",
|
||
):
|
||
if line.startswith("+") and not line.startswith("+++"):
|
||
diff_lines.append({"type": "add", "content": line[1:]})
|
||
elif line.startswith("-") and not line.startswith("---"):
|
||
diff_lines.append({"type": "del", "content": line[1:]})
|
||
elif line.startswith("@@"):
|
||
diff_lines.append({"type": "header", "content": line})
|
||
elif line.startswith(" "):
|
||
diff_lines.append({"type": "ctx", "content": line[1:]})
|
||
|
||
await _audit_sync(
|
||
"file_diff", "server", payload.server_id,
|
||
f"文件对比: {payload.relative_path}",
|
||
admin.username, request,
|
||
)
|
||
|
||
return {
|
||
"file_name": posix_basename(rel),
|
||
"local_exists": True,
|
||
"remote_exists": remote_exists,
|
||
"local_size": local_size,
|
||
"remote_size": remote_size,
|
||
"diff_lines": diff_lines,
|
||
"truncated": local_truncated or remote_truncated,
|
||
}
|
||
|
||
|
||
# ── File Upload via SFTP ──
|
||
|
||
MAX_UPLOAD_FILE_SIZE = 104_857_600 # 100 MB
|
||
|
||
|
||
@router.post("/upload")
|
||
async def upload_file(
|
||
request: Request,
|
||
admin: Admin = Depends(get_current_admin),
|
||
):
|
||
"""Upload a file to a remote server via SFTP.
|
||
|
||
Multipart form fields:
|
||
- server_id: int (required)
|
||
- remote_path: str (target directory, required)
|
||
- file: UploadFile (required)
|
||
|
||
The file is uploaded to {remote_path}/{filename} on the target server.
|
||
If the file already exists, it will be overwritten.
|
||
"""
|
||
import asyncssh
|
||
from server.infrastructure.ssh.asyncssh_pool import remote_write_bytes, ssh_pool
|
||
|
||
form = await request.form()
|
||
server_id_raw = form.get("server_id")
|
||
remote_path_raw = form.get("remote_path", "/tmp")
|
||
file = form.get("file")
|
||
|
||
if not server_id_raw:
|
||
raise HTTPException(status_code=400, detail="server_id 必填")
|
||
try:
|
||
server_id = int(server_id_raw)
|
||
except (ValueError, TypeError):
|
||
raise HTTPException(status_code=400, detail="server_id 必须为数字") from None
|
||
|
||
if not file or not hasattr(file, "filename") or not file.filename:
|
||
raise HTTPException(status_code=400, detail="请选择要上传的文件")
|
||
|
||
# Read file content with size check
|
||
content = await file.read()
|
||
if len(content) > MAX_UPLOAD_FILE_SIZE:
|
||
raise HTTPException(status_code=400, detail=f"文件大小超过限制 ({MAX_UPLOAD_FILE_SIZE // (1024*1024)}MB)")
|
||
|
||
remote_path = str(remote_path_raw).strip()
|
||
filename = file.filename
|
||
|
||
try:
|
||
remote_dir = normalize_remote_abs_path(remote_path)
|
||
except PosixPathError as exc:
|
||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||
|
||
# Sanitize filename — strip slashes to prevent path injection
|
||
safe_filename = filename.replace("/", "_").replace("\\", "_").strip()
|
||
if not safe_filename:
|
||
raise HTTPException(status_code=400, detail="文件名无效")
|
||
|
||
# Get server from DB
|
||
session = request.state.db
|
||
server = await ServerRepositoryImpl(session).get_by_id(server_id)
|
||
if not server:
|
||
raise HTTPException(status_code=404, detail="服务器不存在")
|
||
|
||
try:
|
||
full_remote_path = remote_join(remote_dir, safe_filename)
|
||
except PosixPathError as exc:
|
||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||
|
||
from server.utils.files_elevation import get_files_elevation
|
||
|
||
try:
|
||
conn = await ssh_pool.acquire(server)
|
||
try:
|
||
await remote_write_bytes(
|
||
conn,
|
||
full_remote_path,
|
||
content,
|
||
elevation=get_files_elevation(server),
|
||
)
|
||
finally:
|
||
await ssh_pool.release(server.id)
|
||
except asyncssh.PermissionDenied:
|
||
raise HTTPException(status_code=403, detail=f"SSH 权限不足,无法写入 {full_remote_path}") from None
|
||
except asyncssh.SFTPError as e:
|
||
raise HTTPException(status_code=400, detail=f"SFTP 上传失败: {e}") from e
|
||
except Exception as e:
|
||
raise HTTPException(status_code=502, detail=f"SSH 连接失败: {e}") from e
|
||
|
||
# Audit
|
||
await _audit_sync(
|
||
"file_upload", "server", server_id,
|
||
f"上传 {safe_filename} ({len(content)} bytes) → {server.name}:{full_remote_path}",
|
||
admin.username, request,
|
||
)
|
||
|
||
return {
|
||
"success": True,
|
||
"server_id": server_id,
|
||
"remote_path": full_remote_path,
|
||
"filename": safe_filename,
|
||
"size": len(content),
|
||
}
|
||
|
||
|
||
# ── ZIP Upload & Extract (for push workflow) ──
|
||
|
||
MAX_ZIP_FILE_SIZE = 524_288_000 # 500 MB
|
||
|
||
|
||
@router.post("/upload-zip")
|
||
async def upload_zip(
|
||
request: Request,
|
||
admin: Admin = Depends(get_current_admin),
|
||
):
|
||
"""Upload a ZIP file to Nexus, extract it, return the source path for rsync push.
|
||
|
||
Multipart form fields:
|
||
- file: UploadFile (required, must be .zip)
|
||
|
||
Returns {"source_path": "/tmp/nexus_upload_xxx/", "file_count": N}
|
||
"""
|
||
import os
|
||
import posixpath
|
||
import uuid
|
||
import zipfile
|
||
import shutil
|
||
|
||
form = await request.form()
|
||
file = form.get("file")
|
||
|
||
if not file or not hasattr(file, "filename") or not file.filename:
|
||
raise HTTPException(status_code=400, detail="请选择 ZIP 文件")
|
||
|
||
filename = file.filename
|
||
if not filename.lower().endswith(".zip"):
|
||
raise HTTPException(status_code=400, detail="仅支持 .zip 格式")
|
||
|
||
# Read with size check
|
||
content = await file.read()
|
||
if len(content) > MAX_ZIP_FILE_SIZE:
|
||
raise HTTPException(status_code=400, detail=f"文件大小超过限制 ({MAX_ZIP_FILE_SIZE // (1024*1024)}MB)")
|
||
if len(content) == 0:
|
||
raise HTTPException(status_code=400, detail="文件为空")
|
||
|
||
# Create temp directory
|
||
upload_id = uuid.uuid4().hex[:12]
|
||
extract_dir = f"/tmp/nexus_upload_{upload_id}"
|
||
zip_path = f"{extract_dir}.zip"
|
||
|
||
try:
|
||
os.makedirs(extract_dir, exist_ok=True)
|
||
|
||
# Save ZIP
|
||
with open(zip_path, "wb") as f:
|
||
f.write(content)
|
||
|
||
# Validate and extract with zip slip protection
|
||
file_count = 0
|
||
with zipfile.ZipFile(zip_path, "r") as zf:
|
||
extract_base = resolve_nexus_host_path(extract_dir)
|
||
for info in zf.infolist():
|
||
try:
|
||
member_path = assert_zip_member_safe(extract_dir, info.filename)
|
||
except PosixPathError as exc:
|
||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||
real_path = resolve_nexus_host_path(member_path)
|
||
if not is_path_under_prefix(real_path, extract_base):
|
||
raise HTTPException(status_code=400, detail=f"ZIP 包含非法路径: {info.filename}")
|
||
if info.is_dir():
|
||
continue
|
||
file_count += 1
|
||
|
||
zf.extractall(extract_dir)
|
||
|
||
# Clean up ZIP file
|
||
os.unlink(zip_path)
|
||
|
||
if file_count == 0:
|
||
shutil.rmtree(extract_dir, ignore_errors=True)
|
||
raise HTTPException(status_code=400, detail="ZIP 文件为空(无文件)")
|
||
|
||
# Build file listing (relative paths, capped at 500)
|
||
files = []
|
||
for root, _dirs, fnames in os.walk(extract_dir):
|
||
for fn in fnames:
|
||
rel = posixpath.relpath(posix_join(root, fn), extract_dir)
|
||
files.append(rel)
|
||
if len(files) >= 500:
|
||
break
|
||
if len(files) >= 500:
|
||
break
|
||
files_truncated = file_count > len(files)
|
||
|
||
# Audit
|
||
await _audit_sync(
|
||
"upload_zip", "sync", 0,
|
||
f"上传 ZIP: {filename} ({len(content)} bytes) → {extract_dir} ({file_count} 个文件)",
|
||
admin.username, request,
|
||
)
|
||
|
||
return {
|
||
"success": True,
|
||
"source_path": extract_dir,
|
||
"file_count": file_count,
|
||
"files": files,
|
||
"files_truncated": files_truncated,
|
||
"filename": filename,
|
||
"size": len(content),
|
||
}
|
||
|
||
except HTTPException:
|
||
shutil.rmtree(extract_dir, ignore_errors=True)
|
||
try:
|
||
os.unlink(zip_path)
|
||
except OSError:
|
||
pass
|
||
raise
|
||
except zipfile.BadZipFile:
|
||
shutil.rmtree(extract_dir, ignore_errors=True)
|
||
try:
|
||
os.unlink(zip_path)
|
||
except OSError:
|
||
pass
|
||
raise HTTPException(status_code=400, detail="无效的 ZIP 文件") from None
|
||
except Exception as e:
|
||
shutil.rmtree(extract_dir, ignore_errors=True)
|
||
try:
|
||
os.unlink(zip_path)
|
||
except OSError:
|
||
pass
|
||
raise HTTPException(status_code=500, detail=f"解压失败: {e}") from e
|
||
|
||
|
||
# ── Post-Push Verification (sha256sum comparison) ──
|
||
|
||
@router.post("/verify")
|
||
async def verify_sync(
|
||
payload: SyncVerify,
|
||
request: Request,
|
||
admin: Admin = Depends(get_current_admin),
|
||
):
|
||
"""Verify pushed files by comparing sha256sums between source and target servers.
|
||
|
||
Walks the local source directory and computes sha256 for each file,
|
||
then runs sha256sum on each target server via SSH and compares.
|
||
Returns per-server results: matched / missing / mismatched file lists.
|
||
"""
|
||
import asyncio
|
||
import hashlib
|
||
import os
|
||
import posixpath
|
||
import shlex
|
||
from server.infrastructure.ssh.asyncssh_pool import exec_ssh_command
|
||
from server.infrastructure.database.server_repo import ServerRepositoryImpl
|
||
|
||
session = request.state.db
|
||
|
||
if not os.path.isdir(payload.source_path):
|
||
raise HTTPException(status_code=400, detail=f"源路径不存在: {payload.source_path}")
|
||
|
||
# Build local file manifest: {relative_path: sha256hex}
|
||
local_files = {}
|
||
for root, _dirs, fnames in os.walk(payload.source_path):
|
||
for fn in fnames:
|
||
full = posix_join(root, fn)
|
||
rel = posixpath.relpath(full, to_posix(payload.source_path))
|
||
if len(local_files) >= payload.max_files:
|
||
break
|
||
try:
|
||
h = hashlib.sha256()
|
||
with open(full, "rb") as f:
|
||
for chunk in iter(lambda: f.read(8192), b""):
|
||
h.update(chunk)
|
||
local_files[rel] = h.hexdigest()
|
||
except OSError:
|
||
continue
|
||
if len(local_files) >= payload.max_files:
|
||
break
|
||
|
||
if not local_files:
|
||
raise HTTPException(status_code=400, detail="源目录无文件可校验")
|
||
|
||
# Verify against each server concurrently
|
||
sem = asyncio.Semaphore(5)
|
||
results = {}
|
||
|
||
async def _verify_one(sid: int):
|
||
async with sem:
|
||
server = await ServerRepositoryImpl(session).get_by_id(sid)
|
||
if not server:
|
||
results[sid] = {"server_id": sid, "server_name": "", "error": "服务器不存在"}
|
||
return
|
||
|
||
try:
|
||
dest = normalize_remote_dest(payload.target_path or server.target_path)
|
||
except PosixPathError as exc:
|
||
results[sid] = {
|
||
"server_id": sid,
|
||
"server_name": server.name,
|
||
"error": str(exc),
|
||
}
|
||
return
|
||
# Run sha256sum on remote server for the same relative paths
|
||
# Build a file list for sha256sum to check
|
||
file_list = " ".join(shlex.quote(f) for f in local_files.keys())
|
||
cmd = f"cd {shlex.quote(dest)} && sha256sum {file_list} 2>/dev/null"
|
||
|
||
try:
|
||
r = await exec_ssh_command(server, cmd, timeout=60)
|
||
except Exception as e:
|
||
results[sid] = {
|
||
"server_id": sid, "server_name": server.name,
|
||
"error": f"SSH 连接失败: {e}",
|
||
}
|
||
return
|
||
|
||
# Parse sha256sum output: "hash filename"
|
||
remote_files = {}
|
||
if r["exit_code"] == 0:
|
||
for line in r["stdout"].strip().split("\n"):
|
||
parts = line.split(None, 1)
|
||
if len(parts) == 2:
|
||
remote_files[parts[1]] = parts[0]
|
||
|
||
matched = []
|
||
missing = []
|
||
mismatched = []
|
||
for rel, local_hash in local_files.items():
|
||
if rel not in remote_files:
|
||
missing.append(rel)
|
||
elif remote_files[rel] != local_hash:
|
||
mismatched.append(rel)
|
||
else:
|
||
matched.append(rel)
|
||
|
||
results[sid] = {
|
||
"server_id": sid,
|
||
"server_name": server.name,
|
||
"matched": len(matched),
|
||
"missing": len(missing),
|
||
"mismatched": len(mismatched),
|
||
"missing_files": missing[:50],
|
||
"mismatched_files": mismatched[:50],
|
||
}
|
||
|
||
await asyncio.gather(*[_verify_one(sid) for sid in payload.server_ids])
|
||
|
||
# Audit
|
||
await _audit_sync(
|
||
"sync_verify", "sync", 0,
|
||
f"推送校验: {payload.source_path} → {len(payload.server_ids)} 台服务器",
|
||
admin.username, request,
|
||
)
|
||
|
||
return {"results": results, "total_local_files": len(local_files)}
|
||
|
||
|
||
# ── File Download / Read / Write ──
|
||
|
||
MAX_READ_FILE_SIZE = 1_000_000 # 1MB
|
||
|
||
|
||
@router.post("/download")
|
||
async def download_file(
|
||
payload: FileDownload,
|
||
request: Request,
|
||
admin: Admin = Depends(get_current_admin),
|
||
):
|
||
"""P2-7: Download a file from a remote server via SFTP."""
|
||
from fastapi.responses import StreamingResponse
|
||
from server.infrastructure.database.server_repo import ServerRepositoryImpl
|
||
from server.infrastructure.ssh.asyncssh_pool import sftp_session, ssh_pool
|
||
|
||
session = request.state.db
|
||
server = await ServerRepositoryImpl(session).get_by_id(payload.server_id)
|
||
if not server:
|
||
raise HTTPException(status_code=404, detail="服务器不存在")
|
||
|
||
try:
|
||
remote_path = normalize_remote_abs_path(payload.path)
|
||
except PosixPathError as exc:
|
||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||
|
||
conn = await ssh_pool.acquire(server)
|
||
# NOTE: connection is released INSIDE file_generator (after stream finishes),
|
||
# not in a finally block here — releasing before the generator runs would close
|
||
# the SFTP channel mid-download.
|
||
try:
|
||
async with sftp_session(conn) as sftp:
|
||
# Check file exists and size
|
||
try:
|
||
stat = await sftp.stat(remote_path)
|
||
except FileNotFoundError:
|
||
await ssh_pool.release(server.id)
|
||
raise HTTPException(status_code=404, detail="文件不存在") from None
|
||
except Exception as e:
|
||
await ssh_pool.release(server.id)
|
||
raise HTTPException(status_code=502, detail=f"无法访问文件: {e}") from e
|
||
|
||
if stat.type not in ("regular file", "unknown"):
|
||
await ssh_pool.release(server.id)
|
||
raise HTTPException(status_code=400, detail="只能下载文件,不能下载目录")
|
||
|
||
# H-15: Download file size limit (100MB)
|
||
MAX_DOWNLOAD_SIZE = 100 * 1024 * 1024
|
||
if hasattr(stat, "size") and stat.size and stat.size > MAX_DOWNLOAD_SIZE:
|
||
await ssh_pool.release(server.id)
|
||
raise HTTPException(status_code=413, detail=f"文件大小 {stat.size} 字节超过下载限制 100MB")
|
||
|
||
# H-02: Sanitize filename for Content-Disposition (RFC 6266)
|
||
import re as _re
|
||
filename = _re.sub(r'[^\w.\-]', '_', posix_basename(remote_path)) or "download"
|
||
|
||
async def file_generator():
|
||
try:
|
||
async with sftp.open(remote_path, "rb") as f:
|
||
while chunk := await f.read(65536):
|
||
yield chunk
|
||
finally:
|
||
# Release after stream is fully consumed (or on client disconnect)
|
||
await ssh_pool.release(server.id)
|
||
|
||
await _audit_sync(
|
||
"file_download", "server", payload.server_id,
|
||
f"下载文件: {remote_path}",
|
||
admin.username, request,
|
||
)
|
||
|
||
return StreamingResponse(
|
||
file_generator(),
|
||
media_type="application/octet-stream",
|
||
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
|
||
)
|
||
except HTTPException:
|
||
raise
|
||
except Exception as e:
|
||
await ssh_pool.release(server.id)
|
||
raise HTTPException(status_code=502, detail=f"下载失败: {e}") from e
|
||
|
||
|
||
@router.post("/read-file")
|
||
async def read_file(
|
||
payload: FileRead,
|
||
request: Request,
|
||
admin: Admin = Depends(get_current_admin),
|
||
):
|
||
"""P2-8: Read text file content from a remote server."""
|
||
import shlex
|
||
from server.infrastructure.database.server_repo import ServerRepositoryImpl
|
||
from server.infrastructure.ssh.remote_shell import exec_ssh_command_with_fallback
|
||
|
||
session = request.state.db
|
||
server = await ServerRepositoryImpl(session).get_by_id(payload.server_id)
|
||
if not server:
|
||
raise HTTPException(status_code=404, detail="服务器不存在")
|
||
|
||
try:
|
||
remote_path = normalize_remote_abs_path(payload.path)
|
||
except PosixPathError as exc:
|
||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||
|
||
path_q = shlex.quote(remote_path)
|
||
size_result = await exec_ssh_command_with_fallback(
|
||
server, f"stat -c%s {path_q}", timeout=5,
|
||
)
|
||
if size_result["exit_code"] != 0:
|
||
raise HTTPException(status_code=404, detail="文件不存在或无法访问")
|
||
|
||
try:
|
||
file_size = int(size_result["stdout"].strip())
|
||
except ValueError:
|
||
raise HTTPException(status_code=502, detail="无法获取文件大小") from None
|
||
|
||
if file_size > payload.max_size:
|
||
raise HTTPException(
|
||
status_code=413,
|
||
detail=f"文件大小 {file_size} 字节超过限制 {payload.max_size} 字节",
|
||
)
|
||
|
||
result = await exec_ssh_command_with_fallback(server, f"cat {path_q}", timeout=15)
|
||
if result["exit_code"] != 0:
|
||
err = ((result.get("stderr") or "") + (result.get("stdout") or ""))[:200]
|
||
raise HTTPException(status_code=502, detail=f"读取失败: {err}")
|
||
|
||
await _audit_sync(
|
||
"file_read", "server", payload.server_id,
|
||
f"读取文件: {remote_path} ({file_size} bytes)",
|
||
admin.username, request,
|
||
)
|
||
|
||
return {"content": result["stdout"], "size": file_size, "path": remote_path}
|
||
|
||
|
||
@router.post("/write-file")
|
||
async def write_file(
|
||
payload: FileWrite,
|
||
request: Request,
|
||
admin: Admin = Depends(get_current_admin),
|
||
):
|
||
"""Write text file content to a remote server (SFTP + SSH tee/mv/sudo fallback)."""
|
||
import asyncssh
|
||
from server.infrastructure.database.server_repo import ServerRepositoryImpl
|
||
from server.infrastructure.ssh.asyncssh_pool import remote_write_bytes, ssh_pool
|
||
|
||
session = request.state.db
|
||
server = await ServerRepositoryImpl(session).get_by_id(payload.server_id)
|
||
if not server:
|
||
raise HTTPException(status_code=404, detail="服务器不存在")
|
||
|
||
try:
|
||
remote_path = normalize_remote_abs_path(payload.path)
|
||
except PosixPathError as e:
|
||
raise HTTPException(status_code=400, detail=str(e)) from e
|
||
|
||
from server.utils.files_elevation import get_files_elevation
|
||
|
||
content_bytes = payload.content.encode("utf-8")
|
||
if len(content_bytes) > 5_000_000:
|
||
raise HTTPException(status_code=413, detail="文件内容超过 5MB 限制")
|
||
|
||
try:
|
||
conn = await ssh_pool.acquire(server)
|
||
try:
|
||
await remote_write_bytes(
|
||
conn,
|
||
remote_path,
|
||
content_bytes,
|
||
elevation=get_files_elevation(server),
|
||
)
|
||
finally:
|
||
await ssh_pool.release(server.id)
|
||
except asyncssh.PermissionDenied:
|
||
raise HTTPException(
|
||
status_code=403,
|
||
detail=(
|
||
f"无写入权限: {remote_path}。"
|
||
"请确认 SSH 用户对目录有写权限,或已配置免密 sudo(tee/mv)。"
|
||
),
|
||
) from None
|
||
except FileNotFoundError:
|
||
raise HTTPException(status_code=404, detail="目标路径不存在") from None
|
||
except asyncssh.SFTPError as e:
|
||
raise HTTPException(
|
||
status_code=403,
|
||
detail=(
|
||
f"写入失败: {e}。"
|
||
f"路径 {remote_path} — 若文件属主为 root,请为 SSH 用户配置免密 sudo 或修改文件权限。"
|
||
),
|
||
) from e
|
||
except Exception as e:
|
||
raise HTTPException(status_code=502, detail=f"SSH 连接失败: {e}") from e
|
||
|
||
await _audit_sync(
|
||
"file_write", "server", payload.server_id,
|
||
f"写入文件: {remote_path} ({len(content_bytes)} bytes)",
|
||
admin.username, request,
|
||
)
|
||
|
||
return {"success": True, "path": remote_path, "size": len(content_bytes)}
|
||
|
||
|
||
@router.post("/chmod")
|
||
async def chmod_file(
|
||
payload: FileChmod,
|
||
request: Request,
|
||
admin: Admin = Depends(get_current_admin),
|
||
):
|
||
"""Change file permissions (and optional owner) on a remote server."""
|
||
import shlex
|
||
from server.infrastructure.database.server_repo import ServerRepositoryImpl
|
||
from server.infrastructure.ssh.remote_shell import exec_ssh_command_with_fallback
|
||
|
||
session = request.state.db
|
||
server = await ServerRepositoryImpl(session).get_by_id(payload.server_id)
|
||
if not server:
|
||
raise HTTPException(status_code=404, detail="服务器不存在")
|
||
|
||
try:
|
||
remote_path = normalize_remote_abs_path(payload.path)
|
||
except PosixPathError as exc:
|
||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||
|
||
from server.infrastructure.ssh.asyncssh_pool import exec_ssh_command
|
||
from server.utils.files_chmod_policy import (
|
||
RECURSIVE_CHMOD_TIMEOUT_SEC,
|
||
SINGLE_CHMOD_TIMEOUT_SEC,
|
||
assert_recursive_chmod_allowed,
|
||
)
|
||
|
||
path_q = shlex.quote(remote_path)
|
||
recursive = bool(payload.recursive)
|
||
if recursive:
|
||
try:
|
||
assert_recursive_chmod_allowed(remote_path)
|
||
except ValueError as exc:
|
||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||
dir_check = await exec_ssh_command(server, f"test -d {path_q}", timeout=10)
|
||
if dir_check.get("exit_code") != 0:
|
||
raise HTTPException(status_code=400, detail="递归 chmod 仅适用于目录")
|
||
|
||
chmod_prefix = "chmod -R " if recursive else "chmod "
|
||
chown_prefix = "chown -R " if recursive else "chown "
|
||
cmd_parts = [f"{chmod_prefix}{shlex.quote(payload.mode)} {path_q}"]
|
||
if payload.owner:
|
||
group = (payload.group or payload.owner).strip()
|
||
owner_spec = shlex.quote(f"{payload.owner}:{group}")
|
||
cmd_parts.append(f"{chown_prefix}{owner_spec} {path_q}")
|
||
|
||
timeout = RECURSIVE_CHMOD_TIMEOUT_SEC if recursive else SINGLE_CHMOD_TIMEOUT_SEC
|
||
result = await exec_ssh_command_with_fallback(server, " && ".join(cmd_parts), timeout=timeout)
|
||
if result["exit_code"] != 0:
|
||
err = ((result.get("stderr") or "") + (result.get("stdout") or ""))[:300]
|
||
raise HTTPException(
|
||
status_code=403 if "permission denied" in err.lower() else 502,
|
||
detail=err or "chmod 失败",
|
||
)
|
||
|
||
audit_bits = [f"chmod {'-R ' if recursive else ''}{payload.mode}"]
|
||
if payload.owner:
|
||
audit_bits.append(
|
||
f"chown {'-R ' if recursive else ''}{payload.owner}:{payload.group or payload.owner}"
|
||
)
|
||
await _audit_sync(
|
||
"file_permission_change",
|
||
"server",
|
||
payload.server_id,
|
||
f"{' '.join(audit_bits)} {remote_path}",
|
||
admin.username,
|
||
request,
|
||
)
|
||
|
||
return {
|
||
"success": True,
|
||
"path": remote_path,
|
||
"mode": payload.mode,
|
||
"owner": payload.owner,
|
||
"group": payload.group,
|
||
"recursive": recursive,
|
||
"elevated": bool(result.get("elevated")),
|
||
}
|
||
|
||
|
||
@router.post("/compress")
|
||
async def compress_files(
|
||
payload: FileCompress,
|
||
request: Request,
|
||
admin: Admin = Depends(get_current_admin),
|
||
):
|
||
"""Compress files into tar.gz or zip archive."""
|
||
from server.infrastructure.database.server_repo import ServerRepositoryImpl
|
||
from server.infrastructure.ssh.remote_archive import run_remote_compress
|
||
|
||
session = request.state.db
|
||
server = await ServerRepositoryImpl(session).get_by_id(payload.server_id)
|
||
if not server:
|
||
raise HTTPException(status_code=404, detail="服务器不存在")
|
||
|
||
try:
|
||
dest = normalize_remote_abs_path(payload.dest)
|
||
paths = [normalize_remote_abs_path(p) for p in payload.paths]
|
||
except PosixPathError as e:
|
||
raise HTTPException(status_code=400, detail=str(e)) from e
|
||
|
||
result = await run_remote_compress(
|
||
server, paths, dest, payload.format, timeout=120,
|
||
)
|
||
if result["exit_code"] != 0:
|
||
err = ((result.get("stderr") or "") + (result.get("stdout") or ""))[:400]
|
||
raise HTTPException(
|
||
status_code=403 if "permission denied" in err.lower() else 502,
|
||
detail=(
|
||
f"压缩失败: {err}"
|
||
if err.strip()
|
||
else "压缩失败。请确认对目标目录有写权限,或配置免密 sudo。"
|
||
),
|
||
)
|
||
|
||
await _audit_sync(
|
||
"file_compress", "server", payload.server_id,
|
||
f"压缩 {len(payload.paths)} 个文件 → {dest}",
|
||
admin.username, request,
|
||
)
|
||
|
||
return {"success": True, "dest": dest, "format": payload.format}
|
||
|
||
|
||
@router.post("/decompress")
|
||
async def decompress_file(
|
||
payload: FileDecompress,
|
||
request: Request,
|
||
admin: Admin = Depends(get_current_admin),
|
||
):
|
||
"""Decompress a tar.gz or zip archive."""
|
||
import shlex
|
||
from server.infrastructure.database.server_repo import ServerRepositoryImpl
|
||
from server.infrastructure.ssh.remote_shell import exec_ssh_command_with_fallback
|
||
|
||
session = request.state.db
|
||
server = await ServerRepositoryImpl(session).get_by_id(payload.server_id)
|
||
if not server:
|
||
raise HTTPException(status_code=404, detail="服务器不存在")
|
||
|
||
try:
|
||
archive_path = normalize_remote_abs_path(payload.path)
|
||
dest_dir = normalize_remote_abs_path(payload.dest)
|
||
except PosixPathError as exc:
|
||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||
|
||
path = shlex.quote(archive_path)
|
||
dest = shlex.quote(dest_dir)
|
||
|
||
if archive_path.endswith(".tar.gz") or archive_path.endswith(".tgz"):
|
||
cmd = f"tar xzf {path} -C {dest}"
|
||
elif archive_path.endswith(".zip"):
|
||
cmd = f"unzip -o {path} -d {dest}"
|
||
else:
|
||
raise HTTPException(status_code=400, detail="仅支持 .tar.gz/.tgz 和 .zip 格式")
|
||
|
||
result = await exec_ssh_command_with_fallback(server, cmd, timeout=120)
|
||
if result["exit_code"] != 0:
|
||
raise HTTPException(status_code=502, detail=f"解压失败: {(result.get('stderr') or '')[:300]}")
|
||
|
||
await _audit_sync(
|
||
"file_decompress", "server", payload.server_id,
|
||
f"解压 {archive_path} → {dest_dir}",
|
||
admin.username, request,
|
||
)
|
||
|
||
return {"success": True, "path": archive_path, "dest": dest_dir}
|