netx/netx_api/webcrt_sftp.py
oliver accd28c9a1 Harden WebCRT interactive sessions for more CRT-like device login.
Improve async connect, binary stdout coalescing, paste throttling with progress, SSH keepalive, login prompt settle, and optional SFTP while keeping core terminal UX focused.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-01 19:01:44 +08:00

189 lines
5.9 KiB
Python

"""Lightweight SFTP helpers for WebCRT (SSH targets only; separate from interactive PTY)."""
from __future__ import annotations
import logging
import posixpath
from typing import Any
import paramiko
from fastapi import HTTPException
from sqlalchemy.orm import Session
from .cli_resolve import resolve_cli_target
from .config import settings
from .ne_crypto import CredentialCryptoError
from .webcrt_service import _webcrt_creds_ready
_log = logging.getLogger("netx.webcrt.sftp")
def _require_ssh_direct(creds: dict[str, Any], device: dict[str, Any]) -> None:
protocol = str(device.get("protocol") or creds.get("protocol") or "ssh").lower()
if protocol != "ssh":
raise HTTPException(status_code=400, detail="sftp_requires_ssh")
if creds.get("hop_enabled"):
# Keep v1 simple: SFTP only for direct SSH (no hop/proxy jump).
raise HTTPException(status_code=400, detail="sftp_hop_not_supported")
def _open_sftp(creds: dict[str, Any]) -> tuple[paramiko.SSHClient, paramiko.SFTPClient]:
timeout = int(settings.ne_connect_timeout_sec or 30)
client = paramiko.SSHClient()
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
try:
client.connect(
str(creds["ip_address"]),
port=int(creds.get("port") or 22),
username=str(creds["username"]),
password=str(creds["password"]),
timeout=timeout,
banner_timeout=timeout,
auth_timeout=timeout,
look_for_keys=False,
allow_agent=False,
)
sftp = client.open_sftp()
return client, sftp
except HTTPException:
try:
client.close()
except Exception:
pass
raise
except Exception as exc:
try:
client.close()
except Exception:
pass
raise HTTPException(status_code=502, detail=f"sftp_connect_failed:{exc}") from exc
def _resolve(db: Session, *, managed_ne_id: str | None, ume_ne_id: str | None) -> tuple[dict[str, Any], dict[str, Any]]:
try:
creds, device = resolve_cli_target(db, managed_ne_id=managed_ne_id, ume_ne_id=ume_ne_id)
except HTTPException:
raise
except CredentialCryptoError as exc:
raise HTTPException(status_code=400, detail=str(exc) or "credential_crypto_error") from exc
except Exception as exc:
raise HTTPException(status_code=400, detail=f"credential_error:{exc}") from exc
if not _webcrt_creds_ready(creds):
raise HTTPException(status_code=400, detail="credentials_incomplete")
_require_ssh_direct(creds, device)
return creds, device
def sftp_list(
db: Session,
*,
managed_ne_id: str | None,
ume_ne_id: str | None,
path: str = ".",
) -> dict[str, Any]:
creds, device = _resolve(db, managed_ne_id=managed_ne_id, ume_ne_id=ume_ne_id)
remote = str(path or ".").strip() or "."
client, sftp = _open_sftp(creds)
try:
entries = []
for attr in sftp.listdir_attr(remote):
mode = int(getattr(attr, "st_mode", 0) or 0)
is_dir = bool(mode & 0o40000)
entries.append(
{
"name": attr.filename,
"size": int(getattr(attr, "st_size", 0) or 0),
"mtime": int(getattr(attr, "st_mtime", 0) or 0),
"is_dir": is_dir,
}
)
entries.sort(key=lambda x: (not x["is_dir"], str(x["name"]).lower()))
return {
"ne_id": str(device.get("id") or ""),
"ne_name": str(device.get("name") or ""),
"path": remote,
"items": entries,
}
except HTTPException:
raise
except Exception as exc:
raise HTTPException(status_code=502, detail=f"sftp_list_failed:{exc}") from exc
finally:
try:
sftp.close()
except Exception:
pass
try:
client.close()
except Exception:
pass
def sftp_download(
db: Session,
*,
managed_ne_id: str | None,
ume_ne_id: str | None,
path: str,
) -> tuple[bytes, str]:
creds, _device = _resolve(db, managed_ne_id=managed_ne_id, ume_ne_id=ume_ne_id)
remote = str(path or "").strip()
if not remote or remote.endswith("/"):
raise HTTPException(status_code=400, detail="sftp_path_required")
client, sftp = _open_sftp(creds)
try:
with sftp.open(remote, "rb") as fh:
data = fh.read(8 * 1024 * 1024 + 1)
if len(data) > 8 * 1024 * 1024:
raise HTTPException(status_code=413, detail="sftp_file_too_large")
return data, posixpath.basename(remote) or "download.bin"
except HTTPException:
raise
except Exception as exc:
raise HTTPException(status_code=502, detail=f"sftp_download_failed:{exc}") from exc
finally:
try:
sftp.close()
except Exception:
pass
try:
client.close()
except Exception:
pass
def sftp_upload(
db: Session,
*,
managed_ne_id: str | None,
ume_ne_id: str | None,
remote_path: str,
data: bytes,
) -> dict[str, Any]:
creds, device = _resolve(db, managed_ne_id=managed_ne_id, ume_ne_id=ume_ne_id)
remote = str(remote_path or "").strip()
if not remote:
raise HTTPException(status_code=400, detail="sftp_path_required")
client, sftp = _open_sftp(creds)
try:
with sftp.open(remote, "wb") as fh:
fh.write(data)
return {
"ok": True,
"ne_id": str(device.get("id") or ""),
"path": remote,
"size": len(data),
}
except HTTPException:
raise
except Exception as exc:
raise HTTPException(status_code=502, detail=f"sftp_upload_failed:{exc}") from exc
finally:
try:
sftp.close()
except Exception:
pass
try:
client.close()
except Exception:
pass