feat(files): POSIX paths, elevation, recursive chmod, access hints and docs
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -9,12 +9,17 @@ W2: Replaces paramiko pool.py with asyncssh throughout the codebase.
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import secrets
|
||||
import shlex
|
||||
import time
|
||||
from contextlib import asynccontextmanager
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Optional, Dict
|
||||
from typing import AsyncIterator, Dict, Optional
|
||||
|
||||
import asyncssh
|
||||
from server.domain.models import Server
|
||||
from server.utils.files_elevation import FilesElevation
|
||||
from server.utils.posix_paths import posix_dirname
|
||||
from server.infrastructure.database.crypto import decrypt_value
|
||||
|
||||
logger = logging.getLogger("nexus.asyncssh_pool")
|
||||
@@ -256,6 +261,131 @@ class AsyncSSHPool:
|
||||
ssh_pool = AsyncSSHPool()
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def sftp_session(conn: asyncssh.SSHClientConnection) -> AsyncIterator[asyncssh.SFTPClient]:
|
||||
"""Open an SFTP client on a pooled SSH connection (asyncssh 2.x: start_sftp_client)."""
|
||||
async with await conn.start_sftp_client() as sftp:
|
||||
yield sftp
|
||||
|
||||
|
||||
async def sftp_write_bytes(sftp: asyncssh.SFTPClient, remote_path: str, data: bytes) -> None:
|
||||
"""Write in-memory bytes to a remote file (asyncssh put() only accepts local paths)."""
|
||||
async with sftp.open(remote_path, "wb") as remote_file:
|
||||
await remote_file.write(data)
|
||||
|
||||
|
||||
def _sftp_permission_denied(exc: Exception) -> bool:
|
||||
if isinstance(exc, asyncssh.PermissionDenied):
|
||||
return True
|
||||
if isinstance(exc, asyncssh.SFTPError):
|
||||
# SSH_FX_PERMISSION_DENIED = 3
|
||||
if getattr(exc, "code", None) == 3:
|
||||
return True
|
||||
return "permission denied" in str(exc).lower()
|
||||
|
||||
|
||||
async def _run_tee_stdin(
|
||||
conn: asyncssh.SSHClientConnection,
|
||||
command: str,
|
||||
data: bytes,
|
||||
) -> asyncssh.SSHCompletedProcess:
|
||||
"""Feed raw bytes to stdin; default SSH session encoding expects str, not bytes."""
|
||||
return await conn.run(command, input=data, encoding=None, check=False)
|
||||
|
||||
|
||||
async def _ensure_remote_parent_dir(
|
||||
conn: asyncssh.SSHClientConnection,
|
||||
remote_path: str,
|
||||
*,
|
||||
use_sudo: bool,
|
||||
) -> None:
|
||||
parent = posix_dirname(remote_path)
|
||||
if not parent or parent == "/":
|
||||
return
|
||||
prefix = "sudo -n " if use_sudo else ""
|
||||
await conn.run(f"{prefix}mkdir -p {shlex.quote(parent)}", check=False)
|
||||
|
||||
|
||||
async def _ssh_write_bytes_via_tee(
|
||||
conn: asyncssh.SSHClientConnection,
|
||||
remote_path: str,
|
||||
data: bytes,
|
||||
*,
|
||||
use_sudo: bool,
|
||||
) -> None:
|
||||
"""Shell write fallback when SFTP cannot open the file (e.g. file not writable but directory is)."""
|
||||
prefix = "sudo -n " if use_sudo else ""
|
||||
await _ensure_remote_parent_dir(conn, remote_path, use_sudo=use_sudo)
|
||||
|
||||
direct = await _run_tee_stdin(
|
||||
conn,
|
||||
f"{prefix}tee {shlex.quote(remote_path)}",
|
||||
data,
|
||||
)
|
||||
if direct.exit_status == 0:
|
||||
return
|
||||
|
||||
tmp_path = f"/tmp/nexus-write-{secrets.token_hex(12)}"
|
||||
staged = await _run_tee_stdin(
|
||||
conn,
|
||||
f"{prefix}tee {shlex.quote(tmp_path)}",
|
||||
data,
|
||||
)
|
||||
if staged.exit_status != 0:
|
||||
stderr = (staged.stderr or staged.stdout or direct.stderr or "")[:500]
|
||||
raise PermissionError(stderr or "tee 写入失败")
|
||||
|
||||
moved = await conn.run(
|
||||
f"{prefix}mv -f {shlex.quote(tmp_path)} {shlex.quote(remote_path)}",
|
||||
check=False,
|
||||
)
|
||||
if moved.exit_status != 0:
|
||||
await conn.run(f"{prefix}rm -f {shlex.quote(tmp_path)}", check=False)
|
||||
stderr = (moved.stderr or "")[:500]
|
||||
raise PermissionError(stderr or "无法覆盖目标文件(目录可能不可写)")
|
||||
|
||||
|
||||
def _sudo_attempt_order(elevation: FilesElevation | None) -> tuple[bool, ...]:
|
||||
mode = elevation if elevation is not None else FilesElevation.AUTO
|
||||
if mode == FilesElevation.OFF:
|
||||
return (False,)
|
||||
if mode == FilesElevation.ALWAYS:
|
||||
return (True,)
|
||||
return (False, True)
|
||||
|
||||
|
||||
async def remote_write_bytes(
|
||||
conn: asyncssh.SSHClientConnection,
|
||||
remote_path: str,
|
||||
data: bytes,
|
||||
*,
|
||||
elevation: FilesElevation | None = None,
|
||||
) -> None:
|
||||
"""Write bytes to a remote path: SFTP first, then SSH tee / tmp+mv (incl. optional sudo -n)."""
|
||||
eff = elevation if elevation is not None else FilesElevation.AUTO
|
||||
skip_sftp = eff == FilesElevation.ALWAYS
|
||||
|
||||
if not skip_sftp:
|
||||
try:
|
||||
async with await conn.start_sftp_client() as sftp:
|
||||
await sftp_write_bytes(sftp, remote_path, data)
|
||||
return
|
||||
except (asyncssh.PermissionDenied, asyncssh.SFTPError) as exc:
|
||||
if not _sftp_permission_denied(exc):
|
||||
raise
|
||||
|
||||
last_error: Exception | None = None
|
||||
for use_sudo in _sudo_attempt_order(eff):
|
||||
try:
|
||||
await _ssh_write_bytes_via_tee(conn, remote_path, data, use_sudo=use_sudo)
|
||||
return
|
||||
except PermissionError as exc:
|
||||
last_error = exc
|
||||
|
||||
reason = str(last_error) if last_error else "Permission denied"
|
||||
raise asyncssh.SFTPError(3, reason)
|
||||
|
||||
|
||||
async def exec_ssh_command(server: Server, command: str, timeout: int = 30) -> dict:
|
||||
"""Execute a command via SSH using the asyncssh connection pool.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user