Files
wehub-resource-sync 91e75e620b
CI: cua-driver distro-compat matrix / debian:12 (glibc 2.36) (push) Has been cancelled
CI: SPDX Headers / Check SPDX headers (warn-only) (push) Has been cancelled
CD: Docs MCP Server / build (linux/amd64) (push) Has been cancelled
CD: Docs MCP Server / build (linux/arm64) (push) Has been cancelled
CD: Docs MCP Server / merge (push) Has been cancelled
CI: cua-driver distro-compat matrix / Resolve release version (push) Has been cancelled
CI: cua-driver distro-compat matrix / fedora:41 (glibc 2.40) (push) Has been cancelled
CI: cua-driver distro-compat matrix / rockylinux:9 (glibc 2.34) (push) Has been cancelled
CI: cua-driver distro-compat matrix / ubuntu:22.04 (glibc 2.35) (push) Has been cancelled
CI: cua-driver distro-compat matrix / ubuntu:24.04 (glibc 2.39) (push) Has been cancelled
CI: cua-driver distro-compat matrix / Distro compat summary (push) Has been cancelled
CI: Rust Linux unit / Rust Linux unit and compile (push) Has been cancelled
CI: Rust Windows unit / Rust Windows unit and compile (push) Has been cancelled
CI: Nix Linux Rust source / Nix / compositor build (push) Has been cancelled
CI: Nix Linux Rust source / Nix / driver package (push) Has been cancelled
CI: Nix Linux Rust source / Nix / Rust unit tests (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:03:19 +08:00

213 lines
7.2 KiB
Python

"""SSH transport — shell commands and file transfer over SSH.
Screenshots are not natively supported; pair with VNCSSHTransport for
full desktop interaction over SSH-tunneled VNC.
"""
from __future__ import annotations
import logging
import socket
import threading
from typing import TYPE_CHECKING, Any, Dict, List, Optional
if TYPE_CHECKING:
from cua_sandbox.interfaces.tunnel import TunnelInfo
import paramiko
from cua_sandbox.transport.base import Transport
logger = logging.getLogger(__name__)
class SSHTransport(Transport):
"""Transport that executes commands over SSH using paramiko."""
def __init__(
self,
host: str,
port: int = 22,
username: str = "admin",
password: Optional[str] = "admin",
key_filename: Optional[str] = None,
environment: str = "linux",
):
self._host = host
self._port = port
self._username = username
self._password = password
self._key_filename = key_filename
self._environment = environment
self._client: Optional[paramiko.SSHClient] = None
self._tunnels: List[_SSHTunnel] = []
async def connect(self) -> None:
import asyncio
loop = asyncio.get_event_loop()
self._client = paramiko.SSHClient()
self._client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
await loop.run_in_executor(
None,
lambda: self._client.connect(
self._host,
port=self._port,
username=self._username,
password=self._password,
key_filename=self._key_filename,
timeout=30,
look_for_keys=False,
allow_agent=False,
),
)
logger.info(f"SSH connected to {self._host}:{self._port}")
async def disconnect(self) -> None:
for t in list(self._tunnels):
t.stop()
self._tunnels.clear()
if self._client:
self._client.close()
self._client = None
async def send(self, action: str, **params: Any) -> Any:
import asyncio
if not self._client:
raise RuntimeError("SSH not connected")
if action in ("shell", "run_command"):
command = params.get("command", "")
loop = asyncio.get_event_loop()
_, stdout, stderr = await loop.run_in_executor(
None, lambda: self._client.exec_command(command, timeout=params.get("timeout", 30))
)
out = await loop.run_in_executor(None, stdout.read)
err = await loop.run_in_executor(None, stderr.read)
exit_code = stdout.channel.recv_exit_status()
return {
"stdout": out.decode(errors="replace"),
"stderr": err.decode(errors="replace"),
"returncode": exit_code,
}
raise NotImplementedError(f"SSH transport does not support action: {action}")
async def screenshot(self, format: str = "png", quality: int = 95) -> bytes:
raise NotImplementedError(
"SSH transport does not support screenshots. "
"Use VNCSSHTransport for screenshot support over SSH-tunneled VNC."
)
async def get_screen_size(self) -> Dict[str, int]:
raise NotImplementedError("SSH transport does not support screen size queries.")
async def get_environment(self) -> str:
return self._environment
# ── Tunnel ────────────────────────────────────────────────────────────────
async def forward_tunnel(self, sandbox_port: int) -> "TunnelInfo":
"""Open an SSH local-forward from a free host port to *sandbox_port* on the remote."""
import asyncio
from cua_sandbox.interfaces.tunnel import TunnelInfo
if not self._client:
raise RuntimeError("SSH not connected")
loop = asyncio.get_event_loop()
tunnel = await loop.run_in_executor(
None, lambda: _SSHTunnel.start(self._client, "localhost", sandbox_port)
)
self._tunnels.append(tunnel)
info = TunnelInfo(host="localhost", port=tunnel.local_port, sandbox_port=sandbox_port)
return info
async def close_tunnel(self, info: "TunnelInfo") -> None:
import asyncio
to_close = [t for t in self._tunnels if t.local_port == info.port]
loop = asyncio.get_event_loop()
for t in to_close:
await loop.run_in_executor(None, t.stop)
self._tunnels.remove(t)
class _SSHTunnel:
"""Minimal SSH local-forward: binds a random localhost port, pipes to remote."""
def __init__(self, local_port: int, server_sock: socket.socket):
self.local_port = local_port
self._server_sock = server_sock
self._stop_event = threading.Event()
self._thread = threading.Thread(target=self._serve, daemon=True)
@classmethod
def start(
cls,
ssh_client: paramiko.SSHClient,
remote_host: str,
remote_port: int,
) -> "_SSHTunnel":
server_sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
server_sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
server_sock.bind(("127.0.0.1", 0))
local_port = server_sock.getsockname()[1]
server_sock.listen(5)
server_sock.settimeout(1.0)
tunnel = cls(local_port, server_sock)
tunnel._ssh_client = ssh_client
tunnel._remote_host = remote_host
tunnel._remote_port = remote_port
tunnel._thread.start()
return tunnel
def stop(self) -> None:
self._stop_event.set()
self._server_sock.close()
def _serve(self) -> None:
while not self._stop_event.is_set():
try:
client_sock, _ = self._server_sock.accept()
except OSError:
break
transport = self._ssh_client.get_transport()
if transport is None:
client_sock.close()
break
try:
channel = transport.open_channel(
"direct-tcpip",
(self._remote_host, self._remote_port),
client_sock.getpeername(),
)
except Exception:
client_sock.close()
continue
threading.Thread(target=self._pipe, args=(client_sock, channel), daemon=True).start()
@staticmethod
def _pipe(sock: socket.socket, channel: paramiko.Channel) -> None:
import select
channel.setblocking(False)
sock.setblocking(False)
try:
while True:
r, _, _ = select.select([sock, channel], [], [], 1.0)
if sock in r:
data = sock.recv(4096)
if not data:
break
channel.sendall(data)
if channel in r:
data = channel.recv(4096)
if not data:
break
sock.sendall(data)
finally:
sock.close()
channel.close()