Files
WebSSHGateway/backend/app/api/system.py
T

1037 lines
34 KiB
Python

from __future__ import annotations
import asyncio
import json
import re
import tempfile
import os
import tarfile
from contextlib import asynccontextmanager
from pathlib import Path
from typing import List, AsyncIterator
from urllib.parse import quote
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File
from fastapi.responses import StreamingResponse
from pydantic import BaseModel
from sqlalchemy import select
from app.api.dependencies import AppState, get_current_user, get_state, get_db
from app.models.connection import Connection
from app.models.session import SessionRecord
from app.services.crypto import CryptoService, EncryptedPayload
router = APIRouter(prefix="/system", tags=["system"])
class MemoryInfo(BaseModel):
total: int # 总量 bytes
used: int # 已用 bytes
percent: float # 鐧惧垎姣?
class SwapInfo(BaseModel):
total: int
used: int
percent: float
class CpuInfo(BaseModel):
percent: float # CPU 使用率百分比
count: int # CPU 鏍稿績鏁?
class NetworkInfo(BaseModel):
upload_speed: int # 上传速度 bytes/s
download_speed: int # 下载速度 bytes/s
class SystemStats(BaseModel):
cpu: CpuInfo
memory: MemoryInfo
swap: SwapInfo
class ProcessInfo(BaseModel):
pid: int
name: str
command: str # 完整命令/路径
cpu_percent: float
memory_percent: float
memory_bytes: int
class ProcessList(BaseModel):
processes: List[ProcessInfo]
class FileInfo(BaseModel):
name: str
path: str
is_dir: bool
size: int
modified: str
permissions: str
owner: str
group: str
class DirectoryListing(BaseModel):
path: str
files: List[FileInfo]
class DiskInfo(BaseModel):
mount: str
total: int
used: int
percent: float
class DiskList(BaseModel):
disks: List[DiskInfo]
class SystemOverview(BaseModel):
stats: SystemStats
network: NetworkInfo
processes: ProcessList
disks: DiskList
class FileContent(BaseModel):
content: str
class UploadResult(BaseModel):
status: str = "ok"
uploaded: int
skipped: int
failed: int
errors: List[str]
def sanitize_shell_path(value: str) -> str:
return value.replace("'", "").replace('"', "").replace(";", "").replace("&", "").replace("|", "")
def sanitize_upload_filename(filename: str | None, fallback: str = "file") -> str:
name = (filename or fallback).strip().replace("\\", "/")
if not name:
name = fallback
safe = Path(name).name
if safe in {"", ".", ".."}:
safe = fallback
return safe
def sanitize_upload_relative_path(filename: str | None, fallback: str = "file") -> str:
raw = (filename or fallback).strip().replace("\\", "/")
if not raw:
raw = fallback
parts = [part for part in raw.split("/") if part not in {"", ".", ".."}]
if not parts:
return fallback
return "/".join(parts)
def ensure_within_directory(base_dir: str, target_path: str) -> str:
base_real = os.path.realpath(base_dir)
target_real = os.path.realpath(target_path)
if os.path.commonpath([base_real, target_real]) != base_real:
raise HTTPException(status_code=400, detail="非法文件路径")
return target_real
def create_tar_gz(archive_path: str, source_path: str, arcname: str) -> None:
with tarfile.open(archive_path, "w:gz") as tar:
tar.add(source_path, arcname=arcname)
def build_upload_result(
uploaded: int = 0,
skipped: int = 0,
failed: int = 0,
errors: List[str] | None = None,
) -> dict:
return UploadResult(uploaded=uploaded, skipped=skipped, failed=failed, errors=errors or []).model_dump()
@asynccontextmanager
async def resolve_ssh_client(
session_id: str,
state: AppState,
user,
db,
) -> AsyncIterator:
session = state.session_manager.get_session(session_id)
if session:
yield session.client
return
record = db.execute(
select(SessionRecord).where(SessionRecord.id == session_id, SessionRecord.user_id == user.id)
).scalar_one_or_none()
if not record or record.status != "active":
raise HTTPException(status_code=404, detail="会话不存在")
conn = db.execute(
select(Connection).where(Connection.id == record.connection_id, Connection.user_id == user.id)
).scalar_one_or_none()
if not conn:
raise HTTPException(status_code=404, detail="连接不存在")
crypto = CryptoService(state.config.secret_keys)
auth_data = json.loads(conn.auth_data)
decrypted = crypto.decrypt(EncryptedPayload(nonce=auth_data["nonce"], ciphertext=auth_data["ciphertext"]))
auth_payload = json.loads(decrypted)
client = await state.session_manager.connect_client(conn, auth_payload)
try:
yield client
finally:
client.close()
async def run_ssh_command(session_or_client, command: str, timeout: float = 5.0) -> str:
"""在 SSH 会话中执行命令并返回输出"""
try:
client = getattr(session_or_client, "client", session_or_client)
result = await asyncio.wait_for(
client.run(command, check=False),
timeout=timeout
)
return result.stdout or ""
except asyncio.TimeoutError:
return ""
except Exception:
return ""
def parse_memory_info(meminfo: str) -> tuple[MemoryInfo, SwapInfo]:
"""解析 /proc/meminfo 输出"""
mem_total = mem_available = mem_free = mem_buffers = mem_cached = 0
swap_total = swap_free = 0
for line in meminfo.splitlines():
parts = line.split()
if len(parts) < 2:
continue
key = parts[0].rstrip(":")
value = int(parts[1]) * 1024 # KB to bytes
if key == "MemTotal":
mem_total = value
elif key == "MemAvailable":
mem_available = value
elif key == "MemFree":
mem_free = value
elif key == "Buffers":
mem_buffers = value
elif key == "Cached":
mem_cached = value
elif key == "SwapTotal":
swap_total = value
elif key == "SwapFree":
swap_free = value
# 如果没有 MemAvailable,用 Free + Buffers + Cached 估算
if mem_available == 0:
mem_available = mem_free + mem_buffers + mem_cached
mem_used = mem_total - mem_available
mem_percent = (mem_used / mem_total * 100) if mem_total > 0 else 0
swap_used = swap_total - swap_free
swap_percent = (swap_used / swap_total * 100) if swap_total > 0 else 0
return (
MemoryInfo(total=mem_total, used=mem_used, percent=round(mem_percent, 1)),
SwapInfo(total=swap_total, used=swap_used, percent=round(swap_percent, 1))
)
def parse_cpu_info(stat1: str, stat2: str, cpuinfo: str) -> CpuInfo:
"""解析 CPU 使用率"""
def parse_cpu_line(line: str) -> tuple[int, int]:
parts = line.split()
if len(parts) < 5:
return 0, 0
# user, nice, system, idle, iowait, irq, softirq, steal
values = [int(x) for x in parts[1:8] if x.isdigit()]
if len(values) < 4:
return 0, 0
idle = values[3]
total = sum(values)
return idle, total
idle1, total1 = 0, 0
idle2, total2 = 0, 0
for line in stat1.splitlines():
if line.startswith("cpu "):
idle1, total1 = parse_cpu_line(line)
break
for line in stat2.splitlines():
if line.startswith("cpu "):
idle2, total2 = parse_cpu_line(line)
break
diff_idle = idle2 - idle1
diff_total = total2 - total1
cpu_percent = 100.0 * (1.0 - diff_idle / diff_total) if diff_total > 0 else 0
# 计算 CPU 核心数
cpu_count = 0
for line in cpuinfo.splitlines():
if line.startswith("processor"):
cpu_count += 1
if cpu_count == 0:
cpu_count = 1
return CpuInfo(percent=round(cpu_percent, 1), count=cpu_count)
def parse_network_stats(net1: str, net2: str, interval: float) -> NetworkInfo:
"""解析 /proc/net/dev 输出计算网络速度
格式:
Inter-| Receive | Transmit
face |bytes packets errs drop fifo frame compressed multicast|bytes packets errs drop fifo colls carrier compressed
lo: 1234567 12345 0 0 0 0 0 0 1234567 12345 0 0 0 0 0 0
eth0: 1234567 12345 0 0 0 0 0 0 1234567 12345 0 0 0 0 0 0
"""
def parse_net_dev(content: str) -> tuple[int, int]:
total_rx = 0
total_tx = 0
for line in content.splitlines():
line = line.strip()
if ":" not in line or line.startswith("Inter") or line.startswith("face"):
continue
# 跳过 lo 回环接口
if line.startswith("lo:"):
continue
parts = line.split()
if len(parts) < 10:
continue
try:
# 鎺ュ彛鍚?bytes packets ...
# 第一部分可能是 "eth0:1234" 或 "eth0: 1234"
if ":" in parts[0]:
iface_data = parts[0].split(":")
if len(iface_data) > 1 and iface_data[1]:
rx_bytes = int(iface_data[1])
tx_bytes = int(parts[7])
else:
rx_bytes = int(parts[1])
tx_bytes = int(parts[9])
else:
rx_bytes = int(parts[1])
tx_bytes = int(parts[9])
total_rx += rx_bytes
total_tx += tx_bytes
except (ValueError, IndexError):
continue
return total_rx, total_tx
rx1, tx1 = parse_net_dev(net1)
rx2, tx2 = parse_net_dev(net2)
# 计算速度 (bytes/s)
download_speed = int((rx2 - rx1) / interval) if interval > 0 else 0
upload_speed = int((tx2 - tx1) / interval) if interval > 0 else 0
# 防止负值(可能是计数器溢出)
download_speed = max(0, download_speed)
upload_speed = max(0, upload_speed)
return NetworkInfo(upload_speed=upload_speed, download_speed=download_speed)
def parse_processes(ps_output: str) -> List[ProcessInfo]:
"""解析 ps 命令输出
ps aux 输出格式:
USER PID %CPU %MEM VSZ RSS TTY STAT START TIME COMMAND
root 1 0.0 0.1 169436 11896 ? Ss Feb28 0:03 /sbin/init
"""
processes = []
lines = ps_output.strip().splitlines()
for line in lines[1:]: # 跳过表头
parts = line.split()
if len(parts) < 11:
continue
try:
pid = int(parts[1])
cpu_percent = float(parts[2])
mem_percent = float(parts[3])
mem_kb = int(parts[5]) # RSS 鏄 6 鍒?(index 5)
# COMMAND 鏄粠绗?11 鍒楀紑濮嬪埌琛屽熬
command = " ".join(parts[10:])
# 取命令名,去掉路径和参数
name = command.split()[0].split("/")[-1] if command else "unknown"
# 去掉方括号(内核线程)
name = name.strip("[]")
processes.append(ProcessInfo(
pid=pid,
name=name[:32], # 限制长度
command=command[:256], # 完整命令,限制长度
cpu_percent=cpu_percent,
memory_percent=mem_percent,
memory_bytes=mem_kb * 1024,
))
except (ValueError, IndexError):
continue
return processes
def parse_disks(df_output: str) -> List[DiskInfo]:
"""解析 df 输出并返回挂载信息"""
disks = []
for line in df_output.strip().splitlines():
parts = line.split()
if len(parts) >= 6:
try:
total = int(parts[1])
used = int(parts[2])
percent = float(parts[4].rstrip("%"))
mount = parts[5]
disks.append(DiskInfo(mount=mount, total=total, used=used, percent=percent))
except (ValueError, IndexError):
continue
return disks
async def collect_system_overview(session) -> SystemOverview:
"""一次性采集系统监控需要的全部信息,降低前端请求次数。"""
stat1, meminfo, cpuinfo, net1, ps_output, df_output = await asyncio.gather(
run_ssh_command(session, "cat /proc/stat"),
run_ssh_command(session, "cat /proc/meminfo"),
run_ssh_command(session, "cat /proc/cpuinfo"),
run_ssh_command(session, "cat /proc/net/dev"),
run_ssh_command(session, "ps aux 2>/dev/null || ps -ef 2>/dev/null", timeout=5.0),
run_ssh_command(session, "df -B1 | grep '^/'", timeout=5.0),
)
# 等待一次采样间隔,计算 CPU 与网络速度
interval = 0.5
await asyncio.sleep(interval)
stat2, net2 = await asyncio.gather(
run_ssh_command(session, "cat /proc/stat"),
run_ssh_command(session, "cat /proc/net/dev"),
)
memory, swap = parse_memory_info(meminfo)
cpu = parse_cpu_info(stat1, stat2, cpuinfo)
network = parse_network_stats(net1, net2, interval)
processes = parse_processes(ps_output)
processes.sort(key=lambda x: x.memory_bytes, reverse=True)
disks = parse_disks(df_output)
return SystemOverview(
stats=SystemStats(cpu=cpu, memory=memory, swap=swap),
network=network,
processes=ProcessList(processes=processes[:20]),
disks=DiskList(disks=disks),
)
@router.get("/stats/{session_id}", response_model=SystemStats)
async def get_system_stats(
session_id: str,
state: AppState = Depends(get_state),
user=Depends(get_current_user),
db=Depends(get_db),
) -> SystemStats:
"""获取远程服务器系统状态信息(CPU、内存、交换区)"""
async with resolve_ssh_client(session_id, state, user, db) as client:
stat1, meminfo, cpuinfo = await asyncio.gather(
run_ssh_command(client, "cat /proc/stat"),
run_ssh_command(client, "cat /proc/meminfo"),
run_ssh_command(client, "cat /proc/cpuinfo"),
)
interval = 0.3
await asyncio.sleep(interval)
stat2 = await run_ssh_command(client, "cat /proc/stat")
memory, swap = parse_memory_info(meminfo)
cpu = parse_cpu_info(stat1, stat2, cpuinfo)
return SystemStats(cpu=cpu, memory=memory, swap=swap)
@router.get("/network/{session_id}", response_model=NetworkInfo)
async def get_network_stats(
session_id: str,
state: AppState = Depends(get_state),
user=Depends(get_current_user),
db=Depends(get_db),
) -> NetworkInfo:
"""获取远程服务器网络速度"""
async with resolve_ssh_client(session_id, state, user, db) as client:
net1 = await run_ssh_command(client, "cat /proc/net/dev")
interval = 0.5
await asyncio.sleep(interval)
net2 = await run_ssh_command(client, "cat /proc/net/dev")
return parse_network_stats(net1, net2, interval)
@router.get("/processes/{session_id}", response_model=ProcessList)
async def get_processes(
session_id: str,
state: AppState = Depends(get_state),
user=Depends(get_current_user),
db=Depends(get_db),
) -> ProcessList:
"""获取远程服务器进程列表"""
async with resolve_ssh_client(session_id, state, user, db) as client:
ps_output = await run_ssh_command(
client,
"ps aux 2>/dev/null || ps -ef 2>/dev/null",
timeout=5.0
)
processes = parse_processes(ps_output)
processes.sort(key=lambda x: x.memory_bytes, reverse=True)
return ProcessList(processes=processes[:20])
def parse_ls_output(ls_output: str, base_path: str) -> List[FileInfo]:
"""解析 ls -la 命令输出
格式:
drwxr-xr-x 2 root root 4096 Mar 1 12:00 dirname
-rw-r--r-- 1 root root 1234 Mar 1 12:00 filename
"""
import datetime
files = []
lines = ls_output.strip().splitlines()
current_year = datetime.datetime.now().year
for line in lines:
# 跳过 total 行和空行
if not line or line.startswith("total"):
continue
parts = line.split(None, 8)
if len(parts) < 9:
continue
try:
permissions = parts[0]
owner = parts[2]
group = parts[3]
size = int(parts[4])
# 时间可能是 "Mar 1 12:00" 或 "Mar 1 2024"
month_str = parts[5]
day_str = parts[6]
time_or_year = parts[7]
# 解析时间
month_map = {"Jan": 1, "Feb": 2, "Mar": 3, "Apr": 4, "May": 5, "Jun": 6,
"Jul": 7, "Aug": 8, "Sep": 9, "Oct": 10, "Nov": 11, "Dec": 12}
month = month_map.get(month_str, 1)
day = int(day_str)
if ":" in time_or_year:
# 格式: Mar 1 12:00 (今年)
hour, minute = map(int, time_or_year.split(":"))
modified = f"{current_year}/{month:02d}/{day:02d} {hour:02d}:{minute:02d}"
else:
# 鏍煎紡: Mar 1 2024 (寰€骞?
year = int(time_or_year)
modified = f"{year}/{month:02d}/{day:02d} 00:00"
name = parts[8]
# 璺宠繃 . 鍜?..
if name in (".", ".."):
continue
is_dir = permissions.startswith("d")
# 处理符号链接显示
if " -> " in name:
name = name.split(" -> ")[0]
# 构建完整路径
if base_path == "/":
full_path = f"/{name}"
else:
full_path = f"{base_path}/{name}"
files.append(FileInfo(
name=name,
path=full_path,
is_dir=is_dir,
size=size,
modified=modified,
permissions=permissions,
owner=owner,
group=group,
))
except (ValueError, IndexError):
continue
# 鐩綍鍦ㄥ墠锛屾枃浠跺湪鍚庯紝鍚勮嚜鎸夊悕绉版帓搴?
dirs = sorted([f for f in files if f.is_dir], key=lambda x: x.name.lower())
regular_files = sorted([f for f in files if not f.is_dir], key=lambda x: x.name.lower())
return dirs + regular_files
@router.get("/files/{session_id}", response_model=DirectoryListing)
async def list_directory(
session_id: str,
path: str = "/",
state: AppState = Depends(get_state),
user=Depends(get_current_user),
db=Depends(get_db),
) -> DirectoryListing:
"""列出远程服务器目录内容"""
async with resolve_ssh_client(session_id, state, user, db) as client:
clean_path = sanitize_shell_path(path)
if not clean_path.startswith("/"):
clean_path = "/" + clean_path
ls_output = await run_ssh_command(
client,
f"ls -la '{clean_path}' 2>/dev/null",
timeout=10.0
)
if not ls_output:
raise HTTPException(status_code=404, detail="目录不存在或无权访问")
files = parse_ls_output(ls_output, clean_path)
return DirectoryListing(path=clean_path, files=files)
@router.get("/disks/{session_id}", response_model=DiskList)
async def get_disks(
session_id: str,
state: AppState = Depends(get_state),
user=Depends(get_current_user),
db=Depends(get_db),
) -> DiskList:
"""获取磁盘挂载信息"""
async with resolve_ssh_client(session_id, state, user, db) as client:
df_output = await run_ssh_command(client, "df -B1 | grep '^/'", timeout=5.0)
return DiskList(disks=parse_disks(df_output))
@router.get("/overview/{session_id}", response_model=SystemOverview)
async def get_system_overview(
session_id: str,
state: AppState = Depends(get_state),
user=Depends(get_current_user),
db=Depends(get_db),
) -> SystemOverview:
"""一次返回系统状态、网络、进程和磁盘信息。"""
async with resolve_ssh_client(session_id, state, user, db) as client:
return await collect_system_overview(client)
@router.get("/file/{session_id}")
async def read_file(
session_id: str,
path: str,
force: bool = False,
state: AppState = Depends(get_state),
user=Depends(get_current_user),
db=Depends(get_db),
) -> dict:
"""读取文件内容"""
async with resolve_ssh_client(session_id, state, user, db) as client:
clean_path = sanitize_shell_path(path)
size_check = await run_ssh_command(client, f"stat -c %s '{clean_path}' 2>/dev/null || echo 0")
size = int(size_check.strip() or 0)
if size > 1024 * 1024 and not force:
return {"size": size, "too_large": True}
content = await run_ssh_command(client, f"cat '{clean_path}' 2>/dev/null", timeout=30.0)
return {"content": content, "size": size}
@router.post("/file/{session_id}")
async def write_file(
session_id: str,
path: str,
payload: FileContent,
state: AppState = Depends(get_state),
user=Depends(get_current_user),
db=Depends(get_db),
):
"""写入文件内容"""
async with resolve_ssh_client(session_id, state, user, db) as client:
clean_path = sanitize_shell_path(path)
with tempfile.NamedTemporaryFile(mode='w', delete=False) as tmp:
tmp.write(payload.content)
tmp_path = tmp.name
try:
async with client.start_sftp_client() as sftp:
await sftp.put(tmp_path, clean_path)
finally:
os.unlink(tmp_path)
return {"status": "ok"}
@router.post("/upload/{session_id}")
async def upload_file(
session_id: str,
path: str,
compress: bool = False,
file: UploadFile = File(...),
state: AppState = Depends(get_state),
user=Depends(get_current_user),
db=Depends(get_db),
):
"""上传文件到远程服务器"""
async with resolve_ssh_client(session_id, state, user, db) as client:
clean_path = sanitize_shell_path(path)
safe_filename = sanitize_upload_filename(file.filename)
with tempfile.TemporaryDirectory() as tmpdir:
local_path = ensure_within_directory(tmpdir, os.path.join(tmpdir, safe_filename))
content = await file.read()
with open(local_path, "wb") as f:
f.write(content)
errors: List[str] = []
uploaded = 0
try:
if compress:
tar_path = os.path.join(tmpdir, f"{safe_filename}.tar.gz")
create_tar_gz(tar_path, local_path, safe_filename)
async with client.start_sftp_client() as sftp:
await sftp.put(tar_path, f"{clean_path}/{safe_filename}.tar.gz")
await run_ssh_command(
client,
f"cd '{clean_path}' && tar -xzf '{safe_filename}.tar.gz' && rm -f '{safe_filename}.tar.gz'",
timeout=30.0,
)
uploaded = 1
else:
async with client.start_sftp_client() as sftp:
await sftp.put(local_path, f"{clean_path}/{safe_filename}")
uploaded = 1
except Exception as exc:
errors.append(f"{safe_filename}: {exc}")
if errors:
return build_upload_result(uploaded=uploaded, failed=len(errors), errors=errors)
return build_upload_result(uploaded=uploaded)
@router.post("/upload-targz/{session_id}")
async def upload_targz(
session_id: str,
path: str,
file: UploadFile = File(...),
state: AppState = Depends(get_state),
user=Depends(get_current_user),
db=Depends(get_db),
):
"""上传 tar.gz 压缩包并解压"""
async with resolve_ssh_client(session_id, state, user, db) as client:
clean_path = sanitize_shell_path(path)
with tempfile.NamedTemporaryFile(suffix=".tar.gz", delete=False) as tmp:
content = await file.read()
tmp.write(content)
tmp_path = tmp.name
errors: List[str] = []
uploaded = 0
try:
async with client.start_sftp_client() as sftp:
await sftp.put(tmp_path, f"{clean_path}/upload.tar.gz")
await run_ssh_command(client, f"cd '{clean_path}' && tar -xzf upload.tar.gz && rm -f upload.tar.gz", timeout=120.0)
uploaded = 1
except Exception as exc:
errors.append(f"upload.tar.gz: {exc}")
finally:
os.unlink(tmp_path)
if errors:
return build_upload_result(uploaded=uploaded, failed=len(errors), errors=errors)
return build_upload_result(uploaded=uploaded)
@router.post("/upload-zip/{session_id}")
async def upload_zip(
session_id: str,
path: str,
file: UploadFile = File(...),
state: AppState = Depends(get_state),
user=Depends(get_current_user),
db=Depends(get_db),
):
"""上传 zip 压缩包并解压"""
async with resolve_ssh_client(session_id, state, user, db) as client:
clean_path = sanitize_shell_path(path)
with tempfile.NamedTemporaryFile(suffix=".zip", delete=False) as tmp:
content = await file.read()
tmp.write(content)
tmp_path = tmp.name
errors: List[str] = []
uploaded = 0
try:
async with client.start_sftp_client() as sftp:
await sftp.put(tmp_path, f"{clean_path}/upload.zip")
await run_ssh_command(client, f"cd '{clean_path}' && unzip -q upload.zip && rm -f upload.zip", timeout=120.0)
uploaded = 1
except Exception as exc:
errors.append(f"upload.zip: {exc}")
finally:
os.unlink(tmp_path)
if errors:
return build_upload_result(uploaded=uploaded, failed=len(errors), errors=errors)
return build_upload_result(uploaded=uploaded)
@router.post("/upload-batch/{session_id}")
async def upload_batch(
session_id: str,
path: str,
compress: bool = False,
concurrent: int = 3,
files: List[UploadFile] = File(...),
state: AppState = Depends(get_state),
user=Depends(get_current_user),
db=Depends(get_db),
):
"""批量上传文件(支持文件夹结构)"""
async with resolve_ssh_client(session_id, state, user, db) as client:
clean_path = sanitize_shell_path(path)
with tempfile.TemporaryDirectory() as tmpdir:
written_files = 0
staged_files: List[tuple[str, str]] = []
errors: List[str] = []
for file in files:
relative_path = sanitize_upload_relative_path(file.filename)
file_path = ensure_within_directory(tmpdir, os.path.join(tmpdir, relative_path))
os.makedirs(os.path.dirname(file_path), exist_ok=True)
content = await file.read()
with open(file_path, "wb") as f:
f.write(content)
staged_files.append((relative_path, file_path))
written_files += 1
if compress:
tar_path = os.path.join(tmpdir, "upload.tar.gz")
with tarfile.open(tar_path, "w:gz") as tar:
for relative_path, file_path in staged_files:
tar.add(file_path, arcname=relative_path)
try:
async with client.start_sftp_client() as sftp:
await sftp.put(tar_path, f"{clean_path}/upload.tar.gz")
await run_ssh_command(client, f"cd '{clean_path}' && tar -xzf upload.tar.gz && rm -f upload.tar.gz", timeout=60.0)
except Exception as exc:
errors.append(f"upload.tar.gz: {exc}")
return build_upload_result(uploaded=0, failed=written_files, errors=errors)
return build_upload_result(uploaded=written_files)
uploaded = 0
queue = list(staged_files)
concurrency = max(1, min(concurrent, 8))
semaphore = asyncio.Semaphore(concurrency)
async def upload_one(relative_path: str, file_path: str) -> bool:
remote_path = f"{clean_path}/{relative_path}"
remote_dir = os.path.dirname(remote_path)
try:
async with semaphore:
await run_ssh_command(client, f"mkdir -p '{remote_dir}'")
async with client.start_sftp_client() as sftp:
await sftp.put(file_path, remote_path)
return True
except Exception as exc:
errors.append(f"{relative_path}: {exc}")
return False
results = await asyncio.gather(*(upload_one(relative_path, file_path) for relative_path, file_path in queue))
uploaded = sum(1 for ok in results if ok)
failed = len(errors)
return build_upload_result(uploaded=uploaded, failed=failed, errors=errors)
@router.post("/mkdir/{session_id}")
async def make_directory(
session_id: str,
path: str,
state: AppState = Depends(get_state),
user=Depends(get_current_user),
db=Depends(get_db),
):
"""创建目录"""
async with resolve_ssh_client(session_id, state, user, db) as client:
clean_path = sanitize_shell_path(path)
await run_ssh_command(client, f"mkdir -p '{clean_path}'")
return {"status": "ok"}
@router.post("/touch/{session_id}")
async def touch_file(
session_id: str,
path: str,
state: AppState = Depends(get_state),
user=Depends(get_current_user),
db=Depends(get_db),
):
"""创建文件"""
async with resolve_ssh_client(session_id, state, user, db) as client:
clean_path = sanitize_shell_path(path)
await run_ssh_command(client, f"touch '{clean_path}'")
return {"status": "ok"}
@router.post("/rename/{session_id}")
async def rename_file(
session_id: str,
old_path: str,
new_path: str,
state: AppState = Depends(get_state),
user=Depends(get_current_user),
db=Depends(get_db),
):
"""重命名文件或目录"""
async with resolve_ssh_client(session_id, state, user, db) as client:
clean_old = sanitize_shell_path(old_path)
clean_new = sanitize_shell_path(new_path)
await run_ssh_command(client, f"mv '{clean_old}' '{clean_new}'")
return {"status": "ok"}
@router.post("/chmod/{session_id}")
async def change_mode(
session_id: str,
path: str,
mode: str,
recursive: bool = False,
state: AppState = Depends(get_state),
user=Depends(get_current_user),
db=Depends(get_db),
):
"""修改文件权限"""
async with resolve_ssh_client(session_id, state, user, db) as client:
clean_path = sanitize_shell_path(path)
clean_mode = sanitize_shell_path(mode)
recursive_flag = "-R " if recursive else ""
await run_ssh_command(client, f"chmod {recursive_flag}{clean_mode} '{clean_path}'")
return {"status": "ok"}
@router.delete("/delete/{session_id}")
async def delete_file(
session_id: str,
path: str,
state: AppState = Depends(get_state),
user=Depends(get_current_user),
db=Depends(get_db),
):
"""删除文件或目录"""
async with resolve_ssh_client(session_id, state, user, db) as client:
clean_path = sanitize_shell_path(path)
await run_ssh_command(client, f"rm -rf '{clean_path}'")
return {"status": "ok"}
@router.get("/download/{session_id}")
async def download_file(
session_id: str,
path: str,
state: AppState = Depends(get_state),
user=Depends(get_current_user),
db=Depends(get_db),
):
"""从远程服务器下载文件"""
async with resolve_ssh_client(session_id, state, user, db) as client:
clean_path = sanitize_shell_path(path)
check_result = await run_ssh_command(client, f"[ -d '{clean_path}' ] && echo 'dir' || echo 'file'")
is_dir = check_result.strip() == "dir"
filename = os.path.basename(clean_path)
if is_dir:
tar_name = f"{filename}.tar.gz"
remote_tar = f"/tmp/{tar_name}"
await run_ssh_command(client, f"tar -czf {remote_tar} -C {os.path.dirname(clean_path)} {filename}", timeout=60.0)
with tempfile.NamedTemporaryFile(delete=False) as tmp:
tmp_path = tmp.name
try:
async with client.start_sftp_client() as sftp:
await sftp.get(remote_tar, tmp_path)
await run_ssh_command(client, f"rm {remote_tar}")
with open(tmp_path, "rb") as f:
content = f.read()
encoded_filename = quote(tar_name)
return StreamingResponse(
iter([content]),
media_type="application/gzip",
headers={
"Content-Disposition": f"attachment; filename*=UTF-8''{encoded_filename}",
"Content-Length": str(len(content)),
},
)
finally:
os.unlink(tmp_path)
else:
with tempfile.NamedTemporaryFile(delete=False) as tmp:
tmp_path = tmp.name
try:
async with client.start_sftp_client() as sftp:
await sftp.get(clean_path, tmp_path)
with open(tmp_path, "rb") as f:
content = f.read()
encoded_filename = quote(filename)
return StreamingResponse(
iter([content]),
media_type="application/octet-stream",
headers={
"Content-Disposition": f"attachment; filename*=UTF-8''{encoded_filename}",
"Content-Length": str(len(content)),
},
)
finally:
os.unlink(tmp_path)