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>
This commit is contained in:
oliver 2026-08-01 19:01:44 +08:00
parent cb865254e4
commit accd28c9a1
13 changed files with 1884 additions and 224 deletions

View file

@ -88,6 +88,17 @@ class Settings(BaseSettings):
webcrt_connect_timeout_sec: int = 90
webcrt_attach_timeout_sec: int = 60
webcrt_data_dir: str = "data/webcrt"
# SSH transport keepalive interval (seconds); 0 disables.
webcrt_keepalive_sec: int = 30
# Device anti-idle CLI nudge (0 = off). Keep off: NEs close idle VTY themselves.
webcrt_anti_idle_sec: int = 0
webcrt_anti_idle_payload: str = " "
# Cap stdout queue depth (drop oldest when full) to protect memory.
webcrt_out_queue_max: int = 2000
# Persist per-session transcripts under webcrt_data_dir/sessions/.
webcrt_session_log_enabled: bool = True
# Reader: short blocking wait instead of fixed 40ms spin (seconds).
webcrt_reader_poll_sec: float = 0.01
# Local app login / audit (lab defaults; override in production)
auth_enabled: bool = True
# Stable default so JWT survives restarts without .env. Override in production.

View file

@ -311,6 +311,7 @@ def _netmiko_over_ssh_client(
session_timeout: int | None,
session_log: Any = None,
interactive: bool = False,
keepalive: int | None = None,
) -> ConnectHandler:
"""Netmiko session over an already-authenticated SSH client (bastion protocol proxy)."""
base_cls = _netmiko_driver_class(device_type)
@ -340,6 +341,7 @@ def _netmiko_over_ssh_client(
enable_secret=enable_secret,
session_timeout=session_timeout,
session_log=session_log,
keepalive=keepalive,
)
return _PreauthSession(**dev)
@ -354,6 +356,7 @@ def _base_connect_kwargs(
enable_secret: str,
session_timeout: int | None = None,
session_log: Any = None,
keepalive: int | None = None,
) -> dict[str, Any]:
timeout = int(settings.ne_connect_timeout_sec or 30)
dev: dict[str, Any] = {
@ -368,6 +371,9 @@ def _base_connect_kwargs(
}
if session_timeout is not None:
dev["session_timeout"] = session_timeout
if keepalive is not None and int(keepalive) > 0:
# Paramiko/Netmiko SSH transport keepalive (seconds between null packets).
dev["keepalive"] = int(keepalive)
secret = str(enable_secret or "").strip()
if secret:
dev["secret"] = secret
@ -382,6 +388,7 @@ def _connect_direct(
session_timeout: int | None = None,
session_log: Any = None,
interactive: bool = False,
keepalive: int | None = None,
) -> ConnectHandler:
device_type = normalize_netmiko_device_type(creds["device_type"], creds["protocol"])
dev = _base_connect_kwargs(
@ -393,6 +400,7 @@ def _connect_direct(
enable_secret=str(creds.get("enable_secret") or ""),
session_timeout=session_timeout,
session_log=session_log,
keepalive=keepalive,
)
return _build_netmiko_connection(dev, interactive=interactive)
@ -602,6 +610,7 @@ def _connect_via_cli_hop(
cols: int | None = None,
rows: int | None = None,
interactive: bool = False,
keepalive: int | None = None,
) -> ConnectHandler:
"""Login to ZTE/Huawei/Cisco hop NE, run CLI jump command, then target secondary auth."""
hop_host = str(creds.get("hop_host") or "").strip()
@ -621,6 +630,7 @@ def _connect_via_cli_hop(
enable_secret="",
session_timeout=session_timeout or 180,
session_log=session_log,
keepalive=keepalive,
)
conn = _build_netmiko_connection(hop_dev, interactive=interactive)
try:
@ -674,6 +684,7 @@ def _connect_via_bastion(
session_timeout: int | None = None,
session_log: Any = None,
interactive: bool = False,
keepalive: int | None = None,
) -> ConnectHandler:
"""SSH to bastion with composite username; bastion proxies to target (protocol proxy)."""
hop_host = str(creds.get("hop_host") or "").strip()
@ -707,6 +718,7 @@ def _connect_via_bastion(
session_timeout=session_timeout or 180,
session_log=session_log,
interactive=interactive,
keepalive=keepalive,
)
except Exception:
if ssh_client is not None:
@ -738,6 +750,7 @@ def _connect_via_linux_hop(
session_timeout: int | None = None,
session_log: Any = None,
interactive: bool = False,
keepalive: int | None = None,
) -> ConnectHandler:
"""SSH to Linux bastion, then direct-tcpip tunnel to target (classic ProxyJump-style)."""
hop_host = str(creds.get("hop_host") or "").strip()
@ -791,6 +804,7 @@ def _connect_via_linux_hop(
enable_secret=str(creds.get("enable_secret") or ""),
session_timeout=session_timeout,
session_log=session_log,
keepalive=keepalive,
)
dev["sock"] = channel
conn = _build_netmiko_connection(dev, interactive=interactive)
@ -822,12 +836,16 @@ def open_netmiko_connection(
cols: int | None = None,
rows: int | None = None,
interactive: bool = False,
keepalive: int | None = None,
) -> ConnectHandler:
"""Open a Netmiko connection to the target NE (direct or via configured hop).
``interactive=True`` (WebCRT) skips Netmiko's automatic ``terminal length`` /
``terminal width`` (and vendor equivalents). Collection / MCP keep the default.
"""
ka = keepalive
if ka is None and interactive:
ka = int(getattr(settings, "webcrt_keepalive_sec", 0) or 0) or None
if creds.get("hop_enabled"):
vendor = _hop_vendor(creds)
if vendor == "linux":
@ -836,6 +854,7 @@ def open_netmiko_connection(
session_timeout=session_timeout,
session_log=session_log,
interactive=interactive,
keepalive=ka,
)
if vendor == "bastion":
return _connect_via_bastion(
@ -843,6 +862,7 @@ def open_netmiko_connection(
session_timeout=session_timeout,
session_log=session_log,
interactive=interactive,
keepalive=ka,
)
return _connect_via_cli_hop(
creds,
@ -851,10 +871,12 @@ def open_netmiko_connection(
cols=cols,
rows=rows,
interactive=interactive,
keepalive=ka,
)
return _connect_direct(
creds,
session_timeout=session_timeout,
session_log=session_log,
interactive=interactive,
keepalive=ka,
)

View file

@ -7,7 +7,7 @@ import json
import logging
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Request, WebSocket, WebSocketDisconnect
from fastapi import APIRouter, Depends, File, Form, HTTPException, Request, UploadFile, WebSocket, WebSocketDisconnect
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
@ -21,6 +21,9 @@ from .webcrt_service import (
get_session,
list_sessions,
mark_attached,
wait_session_ready,
_decode_bytes,
_normalize_encoding,
)
_log = logging.getLogger("netx.webcrt.router")
@ -33,6 +36,22 @@ class WebcrtSessionCreate(BaseModel):
ume_ne_id: str | None = Field(default=None)
cols: int = Field(default=80, ge=20, le=500)
rows: int = Field(default=24, ge=5, le=200)
encoding: str = Field(default="utf-8")
post_login_commands: list[str] = Field(default_factory=list)
# Default async so UI can open WS while connect runs; tests may force sync via service API.
async_connect: bool = Field(default=True)
class WebcrtSftpListBody(BaseModel):
ne_id: str | None = Field(default=None)
ume_ne_id: str | None = Field(default=None)
path: str = Field(default=".")
class WebcrtSftpDownloadBody(BaseModel):
ne_id: str | None = Field(default=None)
ume_ne_id: str | None = Field(default=None)
path: str
def _client_label(request: Request | None = None, websocket: WebSocket | None = None) -> str:
@ -66,6 +85,9 @@ def api_create_session(
cols=body.cols,
rows=body.rows,
client=_client_label(request=request),
encoding=body.encoding,
post_login_commands=list(body.post_login_commands or [])[:20],
async_connect=bool(body.async_connect),
)
@ -74,6 +96,61 @@ def api_close_session(session_id: str, request: Request) -> dict[str, Any]:
return close_session(session_id, reason="client_delete", client=_client_label(request=request))
@router.post("/sftp/list")
def api_sftp_list(body: WebcrtSftpListBody, db: Session = Depends(get_db)) -> dict[str, Any]:
from .webcrt_sftp import sftp_list
mid = str(body.ne_id or "").strip()
uid = str(body.ume_ne_id or "").strip()
if bool(mid) == bool(uid):
raise HTTPException(status_code=400, detail="exactly_one_of_ne_id_or_ume_ne_id_required")
return sftp_list(db, managed_ne_id=mid or None, ume_ne_id=uid or None, path=body.path)
@router.post("/sftp/download")
def api_sftp_download(body: WebcrtSftpDownloadBody, db: Session = Depends(get_db)) -> Any:
from fastapi.responses import Response
from .webcrt_sftp import sftp_download
mid = str(body.ne_id or "").strip()
uid = str(body.ume_ne_id or "").strip()
if bool(mid) == bool(uid):
raise HTTPException(status_code=400, detail="exactly_one_of_ne_id_or_ume_ne_id_required")
data, filename = sftp_download(db, managed_ne_id=mid or None, ume_ne_id=uid or None, path=body.path)
return Response(
content=data,
media_type="application/octet-stream",
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
)
@router.post("/sftp/upload")
async def api_sftp_upload(
db: Session = Depends(get_db),
ne_id: str | None = Form(default=None),
ume_ne_id: str | None = Form(default=None),
remote_path: str = Form(...),
file: UploadFile = File(...),
) -> dict[str, Any]:
from .webcrt_sftp import sftp_upload
mid = str(ne_id or "").strip()
uid = str(ume_ne_id or "").strip()
if bool(mid) == bool(uid):
raise HTTPException(status_code=400, detail="exactly_one_of_ne_id_or_ume_ne_id_required")
content = await file.read()
if len(content) > 8 * 1024 * 1024:
raise HTTPException(status_code=413, detail="sftp_file_too_large")
return sftp_upload(
db,
managed_ne_id=mid or None,
ume_ne_id=uid or None,
remote_path=remote_path,
data=content,
)
@router.websocket("/sessions/{session_id}/ws")
async def websocket_session(websocket: WebSocket, session_id: str) -> None:
if bool(settings.auth_enabled):
@ -90,8 +167,6 @@ async def websocket_session(websocket: WebSocket, session_id: str) -> None:
if resolved is None:
await websocket.close(code=4401)
return
websocket.state.auth_user = resolved[0]
websocket.state.auth_via = resolved[1]
await websocket.accept()
attach_gen = 0
@ -105,12 +180,13 @@ async def websocket_session(websocket: WebSocket, session_id: str) -> None:
await websocket.send_json(
{
"type": "status",
"state": "connected",
"state": "connecting" if sess.state == "connecting" else "connected",
"session_id": sess.session_id,
"ne_id": sess.ne_id,
"ne_name": sess.ne_name,
"ne_ip": sess.ne_ip,
"protocol": sess.protocol,
"encoding": sess.encoding,
"cols": sess.cols,
"rows": sess.rows,
"device_type": sess.device_type,
@ -119,30 +195,122 @@ async def websocket_session(websocket: WebSocket, session_id: str) -> None:
}
)
# Wait for async connect without blocking the event loop.
if sess.state == "connecting":
loop = asyncio.get_running_loop()
try:
await loop.run_in_executor(
None,
lambda: wait_session_ready(
session_id,
timeout=max(30, int(settings.webcrt_connect_timeout_sec or 90)) + 15,
),
)
sess = get_session(session_id) or sess
except HTTPException as exc:
await websocket.send_json(
{"type": "status", "state": "error", "message": str(exc.detail)}
)
await websocket.close(code=4502)
return
await websocket.send_json(
{
"type": "status",
"state": "connected",
"session_id": sess.session_id,
"ne_id": sess.ne_id,
"ne_name": sess.ne_name,
"ne_ip": sess.ne_ip,
"protocol": sess.protocol,
"encoding": sess.encoding,
"cols": sess.cols,
"rows": sess.rows,
"device_type": sess.device_type,
"vendor": sess.vendor,
"cli_hop": bool(sess.cli_hop_guard),
"connect_ms": (
int((sess.connect_finished_at - sess.connect_started_at) * 1000)
if sess.connect_finished_at
else None
),
}
)
# Replay full login transcript (kept for StrictMode remount / brief reconnect).
bootstrap = bytes(sess.bootstrap_output or b"")
if bootstrap:
try:
await websocket.send_json(
{"type": "stdout", "data": bootstrap.decode("utf-8", errors="replace")}
)
if _normalize_encoding(sess.encoding) != "utf-8":
bootstrap = _decode_bytes(bootstrap, sess.encoding).encode("utf-8", errors="replace")
await websocket.send_bytes(bootstrap)
except Exception:
_log.debug("webcrt bootstrap send failed session=%s", session_id, exc_info=True)
try:
await websocket.send_json(
{"type": "stdout", "data": _decode_bytes(bytes(sess.bootstrap_output or b""), sess.encoding)}
)
except Exception:
_log.debug("webcrt bootstrap send failed session=%s", session_id, exc_info=True)
stop = asyncio.Event()
stdin_buf: list[str] = []
stdin_flush_task: asyncio.Task[None] | None = None
async def flush_stdin() -> None:
nonlocal stdin_buf
if not stdin_buf:
return
data = "".join(stdin_buf)
stdin_buf = []
try:
await asyncio.get_running_loop().run_in_executor(None, sess.write_stdin, data)
except Exception as exc:
await websocket.send_json(
{"type": "status", "state": "error", "message": f"write_failed:{exc}"}
)
stop.set()
async def schedule_stdin_flush() -> None:
await asyncio.sleep(0.008)
await flush_stdin()
async def pump_stdout() -> None:
loop = asyncio.get_running_loop()
pending: list[bytes] = []
last_flush = loop.time()
def _to_browser_bytes(raw: bytes) -> bytes:
if _normalize_encoding(sess.encoding) == "utf-8":
return raw
return _decode_bytes(raw, sess.encoding).encode("utf-8", errors="replace")
async def _flush_pending() -> bool:
nonlocal pending, last_flush
if not pending:
return True
blob = _to_browser_bytes(b"".join(pending))
pending = []
last_flush = loop.time()
try:
await websocket.send_bytes(blob)
return True
except Exception:
return False
while not stop.is_set():
chunk = await loop.run_in_executor(
None, lambda: sess.take_stdout(attach_gen, timeout=0.25)
None, lambda: sess.take_stdout(attach_gen, timeout=0.05)
)
if chunk == "stale":
# Newer WS owns the session (StrictMode remount); exit without stealing bytes.
break
if chunk == "empty":
if pending and (loop.time() - last_flush) >= 0.016:
if not await _flush_pending():
stop.set()
break
continue
if chunk is None:
await _flush_pending()
stop.set()
try:
await websocket.send_json(
@ -155,12 +323,11 @@ async def websocket_session(websocket: WebSocket, session_id: str) -> None:
except Exception:
pass
break
try:
text = chunk.decode("utf-8", errors="replace")
await websocket.send_json({"type": "stdout", "data": text})
except Exception:
stop.set()
break
pending.append(chunk)
if sum(len(p) for p in pending) >= 8192 or (loop.time() - last_flush) >= 0.016:
if not await _flush_pending():
stop.set()
break
reader_task = asyncio.create_task(pump_stdout())
if sess.needs_live_prompt:
@ -171,35 +338,56 @@ async def websocket_session(websocket: WebSocket, session_id: str) -> None:
_log.debug("webcrt live prompt sync failed session=%s", session_id, exc_info=True)
try:
while not stop.is_set():
raw = await websocket.receive_text()
msg_raw = await websocket.receive()
if msg_raw.get("type") == "websocket.disconnect":
break
if "bytes" in msg_raw and msg_raw["bytes"] is not None:
# Binary stdin: decode with session encoding.
try:
text = _decode_bytes(bytes(msg_raw["bytes"]), sess.encoding)
except Exception:
continue
stdin_buf.append(text)
if stdin_flush_task is None or stdin_flush_task.done():
stdin_flush_task = asyncio.create_task(schedule_stdin_flush())
continue
raw = msg_raw.get("text")
if raw is None:
continue
try:
msg = json.loads(raw)
except json.JSONDecodeError:
# Treat plain text as stdin.
msg = {"type": "stdin", "data": raw}
mtype = str(msg.get("type") or "").strip().lower()
if mtype == "stdin":
data = msg.get("data")
if data is None:
continue
try:
await asyncio.get_running_loop().run_in_executor(
None, sess.write_stdin, str(data)
)
except Exception as exc:
await websocket.send_json(
{"type": "status", "state": "error", "message": f"write_failed:{exc}"}
)
break
stdin_buf.append(str(data))
# Coalesce high-frequency keystrokes briefly.
if len(stdin_buf) >= 8:
if stdin_flush_task and not stdin_flush_task.done():
stdin_flush_task.cancel()
await flush_stdin()
elif stdin_flush_task is None or stdin_flush_task.done():
stdin_flush_task = asyncio.create_task(schedule_stdin_flush())
elif mtype == "resize":
cols = int(msg.get("cols") or sess.cols)
rows = int(msg.get("rows") or sess.rows)
await asyncio.get_running_loop().run_in_executor(None, sess.resize, cols, rows)
elif mtype == "break":
try:
await asyncio.get_running_loop().run_in_executor(None, sess.send_break)
except Exception as exc:
await websocket.send_json(
{"type": "status", "state": "error", "message": f"break_failed:{exc}"}
)
elif mtype == "ping":
sess.touch()
await websocket.send_json({"type": "pong"})
elif mtype == "close":
stop.set()
await flush_stdin()
close_session(
session_id,
reason="client_close",
@ -212,13 +400,18 @@ async def websocket_session(websocket: WebSocket, session_id: str) -> None:
_log.exception("webcrt ws error session=%s", session_id)
finally:
stop.set()
if stdin_flush_task and not stdin_flush_task.done():
stdin_flush_task.cancel()
try:
await flush_stdin()
except Exception:
pass
reader_task.cancel()
try:
await reader_task
except Exception:
pass
# Keep device session briefly so React remount / blip can re-attach.
# Only the current attach_gen may detach — older StrictMode sockets must not.
if get_session(session_id) is not None:
detach_session(
session_id,

View file

@ -121,7 +121,7 @@ def map_network_cli_enter(data: str, conn: ConnectHandler | None) -> str:
return text.replace("\r\n", ret).replace("\r", ret)
def _drain_channel(conn: ConnectHandler, *, rounds: int = 10, wait: float = 0.12) -> str:
def _drain_channel(conn: ConnectHandler, *, rounds: int = 6, wait: float = 0.06) -> str:
"""Read whatever is already sitting on the channel after login."""
chunks: list[str] = []
empty_streak = 0
@ -173,7 +173,11 @@ def normalize_cli_transcript(text: str) -> str:
lines = s.split("\n")
while lines and not str(lines[-1]).strip():
lines.pop()
while len(lines) >= 2 and lines[-1] == lines[-2] and _looks_like_cli_prompt(lines[-1]):
# Drop blank lines immediately before a final prompt (banner\n\nR2# -> banner\nR2#).
while len(lines) >= 2 and not str(lines[-2]).strip() and _looks_like_cli_prompt(lines[-1]):
lines.pop(-2)
# Collapse trailing duplicate prompt lines (slow VMs often echo R2# several times).
while len(lines) >= 2 and str(lines[-1]).strip() == str(lines[-2]).strip() and _looks_like_cli_prompt(lines[-1]):
lines.pop()
return "\n".join(lines)
@ -187,25 +191,131 @@ def prepare_bootstrap_output(text: str) -> str:
return normalize_cli_transcript(text)
def _prime_interactive_channel(conn: ConnectHandler) -> None:
"""Send one RETURN after Netmiko login so the interactive channel is fully ready."""
def _drain_raw_channel(conn: ConnectHandler, *, duration: float = 0.5) -> None:
"""Discard leftover bytes on the live channel (SSH/Telnet) after login priming."""
channel = getattr(conn, "remote_conn", None)
if channel is None:
try:
_drain_channel(conn, rounds=max(2, int(duration / 0.05)), wait=0.05)
except Exception:
pass
return
end = time.time() + max(0.1, float(duration))
while time.time() < end:
got = False
try:
if hasattr(channel, "recv_ready") and hasattr(channel, "recv") and channel.recv_ready():
channel.recv(65535)
got = True
elif hasattr(channel, "read_very_eager"):
data = channel.read_very_eager()
if data:
got = True
else:
part = conn.read_channel()
if part:
got = True
except Exception:
break
if not got:
time.sleep(0.04)
def _prime_interactive_channel(conn: ConnectHandler, *, already_prompted: bool = False) -> None:
"""Send one RETURN after Netmiko login so the interactive channel is fully ready.
Skip the sync Enter when the login transcript already ends with a CLI prompt —
otherwise slow Cisco VMs accumulate duplicate ``R2#`` lines in the bootstrap.
"""
try:
_drain_channel(conn, rounds=4, wait=0.05)
_drain_raw_channel(conn, duration=0.15)
except Exception:
pass
try:
conn.write_channel(channel_return(conn))
except Exception:
if not already_prompted:
try:
conn.write_channel("\n")
conn.write_channel(channel_return(conn))
except Exception:
return
try:
conn.write_channel("\n")
except Exception:
return
try:
_drain_channel(conn, rounds=6, wait=0.08)
except Exception:
pass
try:
_drain_channel(conn, rounds=8, wait=0.1)
_drain_raw_channel(conn, duration=0.35)
except Exception:
pass
def _is_prompt_only_echo(text: str, prompt_hint: str = "") -> bool:
"""True when chunk is only whitespace / CR / a repeated prompt (safe to drop after bootstrap)."""
s = str(text or "").replace("\r\n", "\n").replace("\r", "\n").strip()
if not s:
return True
hint = str(prompt_hint or "").strip()
if hint and s == hint:
return True
# Single-line prompt echo only.
if "\n" not in s and _looks_like_cli_prompt(s):
return True
if hint and all(line.strip() in ("", hint) for line in s.split("\n")):
return True
return False
def _normalize_encoding(name: str) -> str:
enc = str(name or "utf-8").strip().lower().replace("_", "-")
if enc in ("gbk", "gb2312", "gb18030", "cp936"):
return "gbk"
return "utf-8"
def _decode_bytes(data: bytes, encoding: str) -> str:
enc = _normalize_encoding(encoding)
try:
return data.decode(enc, errors="replace")
except Exception:
return data.decode("utf-8", errors="replace")
def _encode_text(text: str, encoding: str) -> bytes:
enc = _normalize_encoding(encoding)
try:
return text.encode(enc, errors="replace")
except Exception:
return text.encode("utf-8", errors="replace")
class _BoundedByteQueue:
"""Thread-safe queue that drops oldest chunks when full (backpressure)."""
def __init__(self, maxsize: int = 2000) -> None:
self._q: queue.Queue[bytes | None] = queue.Queue()
self._max = max(8, int(maxsize or 2000))
self._lock = threading.Lock()
self.dropped = 0
def put(self, item: bytes | None) -> None:
with self._lock:
while self._q.qsize() >= self._max:
try:
self._q.get_nowait()
self.dropped += 1
except queue.Empty:
break
self._q.put(item)
def put_nowait(self, item: bytes | None) -> None:
self.put(item)
def get_nowait(self) -> bytes | None:
return self._q.get_nowait()
def qsize(self) -> int:
return self._q.qsize()
def _utc_now() -> datetime:
return datetime.now(timezone.utc)
@ -221,6 +331,12 @@ def webcrt_data_root() -> Path:
return root.resolve()
def _session_log_path(session_id: str) -> Path:
folder = webcrt_data_root() / "sessions"
folder.mkdir(parents=True, exist_ok=True)
return folder / f"{session_id}.log"
def _audit(event: str, **fields: Any) -> None:
record = {"ts": _utc_iso(), "event": event, **fields}
try:
@ -244,6 +360,7 @@ class WebcrtSession:
device_type: str = ""
vendor: str = ""
cli_keymap: bool = True
encoding: str = "utf-8"
conn: ConnectHandler | None = None
created_at: float = field(default_factory=time.time)
last_activity: float = field(default_factory=time.time)
@ -251,24 +368,69 @@ class WebcrtSession:
detach_deadline: float | None = None
closed: bool = False
close_reason: str = ""
# connecting | ready | error | closed
state: str = "ready"
connect_error: str = ""
connect_started_at: float = field(default_factory=time.time)
connect_finished_at: float | None = None
bootstrap_output: bytes = b""
needs_live_prompt: bool = True
# React StrictMode remounts open a second WS before the first fully tears down.
# Only the newest attach_gen may consume out_queue / mark detach.
attach_gen: int = 0
out_queue: queue.Queue[bytes | None] = field(default_factory=queue.Queue)
out_queue: _BoundedByteQueue = field(
default_factory=lambda: _BoundedByteQueue(int(getattr(settings, "webcrt_out_queue_max", 2000) or 2000))
)
# Vendor CLI hop (Huawei/ZTE/Cisco): close when nested target session returns to hop.
cli_hop_guard: bool = False
cli_hop_prompt: str = ""
post_login_commands: list[str] = field(default_factory=list)
bytes_in: int = 0
bytes_out: int = 0
_reader: threading.Thread | None = field(default=None, repr=False)
_write_lock: threading.Lock = field(default_factory=threading.Lock, repr=False)
_stdout_lock: threading.Lock = field(default_factory=threading.Lock, repr=False)
_hop_scan_buf: str = field(default="", repr=False)
_cli_hop_seen_other_prompt: bool = field(default=False, repr=False)
_log_fh: Any = field(default=None, repr=False)
_ready_event: threading.Event = field(default_factory=threading.Event, repr=False)
def touch(self) -> None:
self.last_activity = time.time()
def open_session_log(self) -> None:
if not bool(getattr(settings, "webcrt_session_log_enabled", True)):
return
if self._log_fh is not None:
return
try:
self._log_fh = _session_log_path(self.session_id).open("a", encoding="utf-8", errors="replace")
self._log_fh.write(f"# session={self.session_id} ne={self.ne_id} ip={self.ne_ip} ts={_utc_iso()}\n")
self._log_fh.flush()
except Exception:
_log.debug("webcrt session log open failed", exc_info=True)
self._log_fh = None
def append_session_log(self, text: str) -> None:
if not text or self._log_fh is None:
return
try:
self._log_fh.write(text)
self._log_fh.flush()
except Exception:
pass
def close_session_log(self) -> None:
fh = self._log_fh
self._log_fh = None
if fh is None:
return
try:
fh.write(f"\n# closed reason={self.close_reason} ts={_utc_iso()}\n")
fh.close()
except Exception:
pass
def take_stdout(self, attach_gen: int, *, timeout: float = 0.25) -> bytes | None | str:
"""Exclusive stdout take for one WS attach generation.
@ -295,7 +457,7 @@ class WebcrtSession:
return chunk # bytes | None
if time.time() >= deadline:
return "empty"
time.sleep(0.02)
time.sleep(0.005)
def write_stdin(self, data: str) -> None:
if self.closed or self.conn is None:
@ -315,12 +477,10 @@ class WebcrtSession:
return
with self._write_lock:
# Prefer raw channel I/O for interactive typing (char echo / backspace).
# Netmiko write_channel is fine for automation but can feel "half-duplex"
# on some Telnet/VRP sessions when used keystroke-by-keystroke.
channel = getattr(self.conn, "remote_conn", None)
try:
if channel is not None and hasattr(channel, "send") and callable(channel.send):
payload = text.encode(getattr(self.conn, "encoding", None) or "utf-8", errors="replace")
payload = _encode_text(text, self.encoding)
# Paramiko may write partially when the window is full.
view = memoryview(payload)
while len(view):
@ -329,14 +489,47 @@ class WebcrtSession:
time.sleep(0.01)
continue
view = view[n:]
self.bytes_in += len(payload)
elif channel is not None and hasattr(channel, "write") and callable(channel.write):
encoding = getattr(self.conn, "encoding", None) or "utf-8"
channel.write(text.encode(encoding, errors="replace") if isinstance(text, str) else text)
payload = _encode_text(text, self.encoding)
channel.write(payload)
self.bytes_in += len(payload)
else:
self.conn.write_channel(text)
self.bytes_in += len(text)
except Exception:
self.conn.write_channel(text)
self.bytes_in += len(text)
self.touch()
def send_break(self) -> None:
"""Send SSH break / Telnet IAC BREAK to interrupt paging or hung commands."""
if self.closed or self.conn is None:
raise RuntimeError("session_closed")
channel = getattr(self.conn, "remote_conn", None)
with self._write_lock:
sent = False
if channel is not None and hasattr(channel, "send_break") and callable(channel.send_break):
try:
channel.send_break(0)
sent = True
except Exception:
_log.debug("send_break failed session=%s", self.session_id, exc_info=True)
if not sent and channel is not None and hasattr(channel, "send") and callable(channel.send):
# Telnet IAC BREAK = 255 243
try:
channel.send(b"\xff\xf3")
sent = True
except Exception:
pass
if not sent:
# Fallback: Ctrl-C often interrupts device CLI more-pages.
try:
self.conn.write_channel("\x03")
except Exception:
raise RuntimeError("break_failed")
self.touch()
def resize(self, cols: int, rows: int) -> None:
if self.closed or self.conn is None:
return
@ -369,53 +562,76 @@ class WebcrtSession:
return
channel = getattr(conn, "remote_conn", None)
hop_return = False
poll = max(0.002, float(getattr(settings, "webcrt_reader_poll_sec", 0.01) or 0.01))
try:
while not self.closed:
chunk = b""
try:
if channel is not None and hasattr(channel, "recv_ready") and hasattr(channel, "recv"):
# Paramiko SSH: raw bytes keep ANSI / backspace echo intact.
if channel.recv_ready():
chunk = channel.recv(4096)
# Paramiko SSH: prefer short blocking recv over fixed spin-sleep.
ready = False
try:
ready = bool(channel.recv_ready())
except Exception:
ready = False
if ready:
chunk = channel.recv(16384)
if not chunk:
break
elif hasattr(channel, "exit_status_ready") and channel.exit_status_ready():
break
else:
time.sleep(0.04)
continue
# Brief block: settimeout + recv wakes sooner than sleep(0.04).
prev_timeout = None
try:
prev_timeout = channel.gettimeout()
except Exception:
prev_timeout = None
try:
channel.settimeout(poll)
chunk = channel.recv(16384)
except Exception:
chunk = b""
finally:
try:
channel.settimeout(prev_timeout)
except Exception:
pass
if not chunk:
continue
elif channel is not None and hasattr(channel, "read_very_eager"):
# Telnet: do NOT use conn.read_channel() — Netmiko strips ANSI
# escape codes, which removes Huawei backspace echo (\x1b[1D \x1b[1D).
# Telnet: do NOT use conn.read_channel() — Netmiko strips ANSI.
data = channel.read_very_eager()
if data:
chunk = (
data
if isinstance(data, (bytes, bytearray))
else str(data).encode(
getattr(conn, "encoding", None) or "utf-8",
errors="replace",
)
else _encode_text(str(data), self.encoding)
)
else:
time.sleep(0.04)
time.sleep(poll)
continue
else:
text = conn.read_channel()
if text:
chunk = text.encode("utf-8", errors="replace")
chunk = _encode_text(str(text), self.encoding)
else:
time.sleep(0.04)
time.sleep(poll)
continue
except Exception as exc:
if self.closed:
break
_log.debug("webcrt reader error session=%s: %s", self.session_id, exc)
time.sleep(0.1)
time.sleep(0.05)
continue
if chunk:
self.touch()
self.bytes_out += len(chunk)
self.out_queue.put(chunk)
try:
self.append_session_log(_decode_bytes(chunk, self.encoding))
except Exception:
pass
if self.cli_hop_guard and self._note_cli_hop_output(chunk):
hop_return = True
notice = (
@ -426,7 +642,6 @@ class WebcrtSession:
break
finally:
if hop_return and not self.closed:
# Prefer registry close for audit + remove; fall back to local close.
try:
close_session(self.session_id, reason="cli_hop_return")
except Exception:
@ -436,12 +651,10 @@ class WebcrtSession:
def _note_cli_hop_output(self, chunk: bytes) -> bool:
"""Accumulate stdout and return True when nested CLI hop has returned to proxy."""
try:
text = chunk.decode("utf-8", errors="replace")
text = _decode_bytes(chunk, self.encoding)
except Exception:
text = str(chunk)
self._hop_scan_buf = (self._hop_scan_buf + text)[-12000:]
# Track a prompt that differs from the hop so same-sysname labs still need
# an explicit nested-close message before we tear down.
marker = str(self.cli_hop_prompt or "").strip()
last = extract_cli_prompt_marker(self._hop_scan_buf)
if last and (not marker or last != marker):
@ -452,11 +665,25 @@ class WebcrtSession:
seen_other_prompt=self._cli_hop_seen_other_prompt,
)
def run_post_login_commands(self) -> None:
cmds = [str(c).rstrip("\r\n") for c in (self.post_login_commands or []) if str(c).strip()]
if not cmds or self.closed or self.conn is None:
return
for cmd in cmds[:20]:
try:
self.write_stdin(cmd + "\r")
time.sleep(0.15)
except Exception:
_log.debug("post_login command failed session=%s", self.session_id, exc_info=True)
break
def close(self, reason: str = "closed") -> None:
if self.closed:
return
self.closed = True
self.state = "closed"
self.close_reason = reason or "closed"
self._ready_event.set()
try:
close_netmiko_connection(self.conn)
except Exception:
@ -466,6 +693,7 @@ class WebcrtSession:
self.out_queue.put_nowait(None)
except Exception:
pass
self.close_session_log()
def _ensure_reaper() -> None:
@ -490,16 +718,32 @@ def _reaper_loop() -> None:
def _reap_sessions() -> None:
idle = max(60, int(settings.webcrt_idle_timeout_sec or 1800))
attach = max(10, int(settings.webcrt_attach_timeout_sec or 60))
anti_idle = max(0, int(getattr(settings, "webcrt_anti_idle_sec", 0) or 0))
anti_payload = str(getattr(settings, "webcrt_anti_idle_payload", " ") or " ")
now = time.time()
to_close: list[tuple[WebcrtSession, str]] = []
to_nudge: list[WebcrtSession] = []
with _sessions_lock:
for sess in list(_sessions.values()):
if sess.closed:
_sessions.pop(sess.session_id, None)
continue
if sess.state == "connecting":
# Connecting sessions use connect timeout, not attach timeout alone.
connect_budget = max(30, int(settings.webcrt_connect_timeout_sec or 90)) + 30
if (now - sess.connect_started_at) > connect_budget:
to_close.append((sess, "connect_timeout"))
continue
if sess.attached:
if (now - sess.last_activity) > idle:
to_close.append((sess, "idle_timeout"))
elif (
anti_idle > 0
and sess.state == "ready"
and sess.conn is not None
and (now - sess.last_activity) >= anti_idle
):
to_nudge.append(sess)
continue
# Not attached: either never attached, or briefly detached for reconnect.
if sess.detach_deadline is not None:
@ -509,6 +753,18 @@ def _reap_sessions() -> None:
to_close.append((sess, "attach_timeout"))
elif (now - sess.last_activity) > idle:
to_close.append((sess, "idle_timeout"))
for sess in to_nudge:
try:
# Touch without changing visible prompt when payload is empty/null-ish.
payload = anti_payload
if payload == "\\0":
payload = "\x00"
if payload:
sess.write_stdin(payload)
else:
sess.touch()
except Exception:
_log.debug("webcrt anti-idle failed session=%s", sess.session_id, exc_info=True)
for sess, reason in to_close:
close_session(sess.session_id, reason=reason)
@ -526,6 +782,21 @@ def get_session(session_id: str) -> WebcrtSession | None:
return sess
def wait_session_ready(session_id: str, *, timeout: float = 120.0) -> WebcrtSession:
"""Block until async connect finishes (ready or error). Used by tests and WS."""
deadline = time.time() + max(1.0, float(timeout))
while time.time() < deadline:
sess = get_session(session_id)
if sess is None:
raise HTTPException(status_code=404, detail="webcrt_session_not_found")
if sess.state == "ready":
return sess
if sess.state == "error":
raise HTTPException(status_code=502, detail=sess.connect_error or "connect_failed")
sess._ready_event.wait(timeout=0.25)
raise HTTPException(status_code=504, detail="connect_timeout")
def _webcrt_creds_ready(creds: dict[str, Any]) -> bool:
"""True when WebCRT can open a session with the resolved credentials.
@ -547,6 +818,133 @@ def _webcrt_creds_ready(creds: dict[str, Any]) -> bool:
return bool(str(creds.get("password") or ""))
def _finish_connect(
sess: WebcrtSession,
*,
creds: dict[str, Any],
device: dict[str, Any],
connect_timeout: int,
client: str,
) -> None:
log_buf = io.BytesIO()
try:
conn = open_netmiko_connection(
creds,
session_timeout=connect_timeout,
session_log=log_buf,
cols=sess.cols,
rows=sess.rows,
interactive=True,
)
except Exception as exc:
partial = _session_log_text(log_buf).strip()
from .ne_cli_errors import format_cli_failure
classified = format_cli_failure(exc, partial)
detail = f"connect_failed:{classified}"
if partial:
detail = f"{detail}\n--- device transcript ---\n{partial[-4000:]}"
sess.state = "error"
sess.connect_error = detail
sess.connect_finished_at = time.time()
sess._ready_event.set()
_audit(
"session_open_failed",
session_id=sess.session_id,
ne_id=sess.ne_id,
ne_ip=sess.ne_ip,
source=str(device.get("source") or ""),
client=client or "",
error=str(exc)[:500],
transcript_len=len(partial),
)
return
channel = getattr(conn, "remote_conn", None)
if channel is not None and hasattr(channel, "resize_pty"):
try:
channel.resize_pty(width=sess.cols, height=sess.rows)
except Exception:
pass
pre_log = _session_log_text(log_buf)
already_prompted = _looks_like_cli_prompt(pre_log)
_prime_interactive_channel(conn, already_prompted=already_prompted)
bootstrap = prepare_bootstrap_output(_session_log_text(log_buf))
if not bootstrap.strip():
try:
more = _drain_channel(conn, rounds=6, wait=0.08)
except Exception:
more = ""
if more:
bootstrap = prepare_bootstrap_output(more)
# Slow Cisco VMs leave extra prompt bytes; discard before the live reader starts.
try:
_drain_raw_channel(conn, duration=0.55)
except Exception:
pass
hop_guard = get_cli_hop_guard(conn)
sess.conn = conn
sess.cli_hop_guard = bool(hop_guard)
sess.cli_hop_prompt = str((hop_guard or {}).get("hop_prompt") or "")
sess.bootstrap_output = _encode_text(str(bootstrap or ""), sess.encoding)
# Bootstrap already ends at a prompt → never nudge another Enter on WS attach.
sess.needs_live_prompt = not _looks_like_cli_prompt(bootstrap)
sess.open_session_log()
if bootstrap:
sess.append_session_log(bootstrap if bootstrap.endswith("\n") else bootstrap + "\n")
sess.start_reader()
# Drop late prompt echoes that race into the queue right after reader start.
prompt_hint = ""
if bootstrap:
prompt_hint = str(bootstrap).replace("\r\n", "\n").replace("\r", "\n").strip().split("\n")[-1].strip()
settle_deadline = time.time() + 0.45
while time.time() < settle_deadline:
try:
chunk = sess.out_queue.get_nowait()
except queue.Empty:
time.sleep(0.02)
continue
if chunk is None:
sess.out_queue.put(None)
break
try:
text = _decode_bytes(chunk, sess.encoding)
except Exception:
text = ""
if _is_prompt_only_echo(text, prompt_hint):
continue
# Non-prompt data: put back and stop settling.
sess.out_queue.put(chunk)
break
try:
sess.run_post_login_commands()
except Exception:
_log.debug("post_login failed session=%s", sess.session_id, exc_info=True)
sess.state = "ready"
sess.connect_finished_at = time.time()
sess._ready_event.set()
elapsed_ms = int((sess.connect_finished_at - sess.connect_started_at) * 1000)
_audit(
"session_created",
session_id=sess.session_id,
ne_id=sess.ne_id,
ne_name=sess.ne_name,
ne_ip=sess.ne_ip,
protocol=sess.protocol,
encoding=sess.encoding,
source=str(device.get("source") or ""),
hop_enabled=bool(creds.get("hop_enabled")),
hop_vendor=str(creds.get("hop_vendor") or "") if creds.get("hop_enabled") else "",
cli_hop_guard=bool(hop_guard),
cli_hop_prompt=str((hop_guard or {}).get("hop_prompt") or ""),
client=client or "",
connect_ms=elapsed_ms,
active=active_session_count(),
)
def create_session(
db: Session,
*,
@ -555,6 +953,9 @@ def create_session(
cols: int = 80,
rows: int = 24,
client: str = "",
encoding: str = "utf-8",
post_login_commands: list[str] | None = None,
async_connect: bool = True,
) -> dict[str, Any]:
from .cli_resolve import resolve_cli_target
@ -588,63 +989,8 @@ def create_session(
device_type = str(device.get("device_type") or creds.get("device_type") or "")
vendor = str(device.get("vendor") or creds.get("vendor") or "")
cli_keymap = uses_network_cli_keymap(device_type, vendor)
enc = _normalize_encoding(encoding)
# Capture real Telnet/SSH login I/O (banner, Username/Password, prompts).
log_buf = io.BytesIO()
try:
conn = open_netmiko_connection(
creds,
session_timeout=connect_timeout,
session_log=log_buf,
cols=c,
rows=r,
interactive=True,
)
except Exception as exc:
partial = _session_log_text(log_buf).strip()
_audit(
"session_open_failed",
session_id=session_id,
ne_id=target_id,
ne_ip=target_ip,
source=str(device.get("source") or ""),
client=client or "",
error=str(exc)[:500],
transcript_len=len(partial),
)
from .ne_cli_errors import format_cli_failure
classified = format_cli_failure(exc, partial)
detail = f"connect_failed:{classified}"
if partial:
# Keep detail bounded; UI surfaces this on open failure.
detail = f"{detail}\n--- device transcript ---\n{partial[-4000:]}"
raise HTTPException(status_code=502, detail=detail) from exc
channel = getattr(conn, "remote_conn", None)
if channel is not None and hasattr(channel, "resize_pty"):
try:
channel.resize_pty(width=c, height=r)
except Exception:
pass
# Prefer session_log (full login transcript including final prompt).
_prime_interactive_channel(conn)
bootstrap = prepare_bootstrap_output(_session_log_text(log_buf))
if not bootstrap.strip():
try:
more = _drain_channel(conn, rounds=6, wait=0.1)
except Exception:
more = ""
if more:
bootstrap = prepare_bootstrap_output(more)
# Discard any unread bytes so the live reader starts clean.
try:
_drain_channel(conn, rounds=3, wait=0.05)
except Exception:
pass
hop_guard = get_cli_hop_guard(conn)
sess = WebcrtSession(
session_id=session_id,
ne_id=target_id,
@ -656,34 +1002,51 @@ def create_session(
device_type=device_type,
vendor=vendor,
cli_keymap=cli_keymap,
conn=conn,
bootstrap_output=str(bootstrap or "").encode("utf-8", errors="replace"),
# Only nudge a live prompt when transcript has no recognizable prompt yet.
needs_live_prompt=not _looks_like_cli_prompt(bootstrap),
cli_hop_guard=bool(hop_guard),
cli_hop_prompt=str((hop_guard or {}).get("hop_prompt") or ""),
encoding=enc,
state="connecting",
post_login_commands=list(post_login_commands or [])[:20],
)
# Keep bootstrap for WS attach replay; do not rely solely on out_queue (StrictMode remount).
sess.start_reader()
with _sessions_lock:
_sessions[session_id] = sess
_audit(
"session_created",
"session_connecting",
session_id=session_id,
ne_id=sess.ne_id,
ne_name=sess.ne_name,
ne_ip=sess.ne_ip,
protocol=sess.protocol,
source=str(device.get("source") or ""),
hop_enabled=bool(creds.get("hop_enabled")),
hop_vendor=str(creds.get("hop_vendor") or "") if creds.get("hop_enabled") else "",
cli_hop_guard=bool(hop_guard),
cli_hop_prompt=str((hop_guard or {}).get("hop_prompt") or ""),
encoding=enc,
client=client or "",
active=active_session_count(),
async_connect=bool(async_connect),
)
if async_connect:
t = threading.Thread(
target=_finish_connect,
kwargs={
"sess": sess,
"creds": creds,
"device": device,
"connect_timeout": connect_timeout,
"client": client or "",
},
name=f"webcrt-connect-{session_id[:8]}",
daemon=True,
)
t.start()
else:
_finish_connect(
sess,
creds=creds,
device=device,
connect_timeout=connect_timeout,
client=client or "",
)
if sess.state == "error":
with _sessions_lock:
_sessions.pop(session_id, None)
raise HTTPException(status_code=502, detail=sess.connect_error or "connect_failed")
return {
"session_id": session_id,
"ne_id": sess.ne_id,
@ -693,8 +1056,10 @@ def create_session(
"protocol": sess.protocol,
"cols": sess.cols,
"rows": sess.rows,
"encoding": enc,
"state": sess.state,
"ws_path": f"/v1/webcrt/sessions/{session_id}/ws",
"cli_hop": bool(hop_guard),
"cli_hop": bool(sess.cli_hop_guard),
}
@ -715,6 +1080,7 @@ def mark_attached(session_id: str) -> tuple[WebcrtSession, int]:
ne_id=sess.ne_id,
ne_ip=sess.ne_ip,
attach_gen=attach_gen,
state=sess.state,
)
return sess, attach_gen
@ -769,6 +1135,9 @@ def close_session(session_id: str, *, reason: str = "closed", client: str = "")
ne_ip=sess.ne_ip,
reason=reason,
client=client or "",
bytes_in=sess.bytes_in,
bytes_out=sess.bytes_out,
queue_dropped=getattr(sess.out_queue, "dropped", 0),
active=active_session_count(),
)
return {"ok": True, "session_id": session_id, "closed": True, "reason": reason}
@ -783,9 +1152,20 @@ def list_sessions() -> dict[str, Any]:
"ne_name": s.ne_name,
"ne_ip": s.ne_ip,
"protocol": s.protocol,
"encoding": s.encoding,
"state": s.state,
"attached": s.attached,
"created_at": datetime.fromtimestamp(s.created_at, tz=timezone.utc).isoformat(),
"last_activity": datetime.fromtimestamp(s.last_activity, tz=timezone.utc).isoformat(),
"bytes_in": s.bytes_in,
"bytes_out": s.bytes_out,
"queue_depth": s.out_queue.qsize(),
"queue_dropped": getattr(s.out_queue, "dropped", 0),
"connect_ms": (
int((s.connect_finished_at - s.connect_started_at) * 1000)
if s.connect_finished_at
else None
),
}
for s in _sessions.values()
if not s.closed
@ -794,5 +1174,7 @@ def list_sessions() -> dict[str, Any]:
"total": len(items),
"max_sessions": max(1, int(settings.webcrt_max_sessions or 20)),
"idle_timeout_sec": max(60, int(settings.webcrt_idle_timeout_sec or 1800)),
"keepalive_sec": int(getattr(settings, "webcrt_keepalive_sec", 30) or 0),
"anti_idle_sec": int(getattr(settings, "webcrt_anti_idle_sec", 0) or 0),
"items": items,
}

189
netx_api/webcrt_sftp.py Normal file
View file

@ -0,0 +1,189 @@
"""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

View file

@ -15,10 +15,13 @@ class _FakeConn:
def __init__(self) -> None:
self.written: list[str] = []
self.RETURN = "\n"
self.remote_conn = MagicMock(spec=["recv_ready", "recv", "exit_status_ready", "resize_pty"])
self.remote_conn = MagicMock(spec=["recv_ready", "recv", "exit_status_ready", "resize_pty", "send_break", "send"])
self.remote_conn.recv_ready.return_value = False
self.remote_conn.exit_status_ready.return_value = False
self.remote_conn.resize_pty = MagicMock()
self.remote_conn.send_break = MagicMock()
# No send by default so write_stdin uses write_channel in unit tests.
del self.remote_conn.send
def write_channel(self, data: str) -> None:
self.written.append(data)
@ -68,6 +71,9 @@ class WebcrtServiceTests(unittest.TestCase):
self.assertEqual(conn.written[-1], "\x1b[D")
sess.resize(120, 40)
conn.remote_conn.resize_pty.assert_called_with(width=120, height=40)
conn.remote_conn.send_break = MagicMock()
sess.send_break()
conn.remote_conn.send_break.assert_called()
sess.close("test")
self.assertTrue(sess.closed)
@ -90,6 +96,11 @@ class WebcrtServiceTests(unittest.TestCase):
self.assertEqual(svc.normalize_cli_transcript("banner\nR2#R2#"), "banner\nR2#")
self.assertEqual(svc.prepare_bootstrap_output("login\nR2#\nR2#"), "login\nR2#")
self.assertTrue(svc.prepare_bootstrap_output("login\nR2#").endswith("R2#"))
# Slow VM / prime Enter can leave three identical prompts.
self.assertEqual(svc.prepare_bootstrap_output("banner\nR2#\nR2#\nR2#"), "banner\nR2#")
self.assertEqual(svc.prepare_bootstrap_output("banner\nR2#\n\nR2#"), "banner\nR2#")
self.assertTrue(svc._is_prompt_only_echo("\r\nR2#\r\n", "R2#"))
self.assertFalse(svc._is_prompt_only_echo("R2#show clock\r\n", "R2#"))
@patch.object(svc, "_audit")
@patch.object(svc, "open_netmiko_connection")
@ -120,11 +131,12 @@ class WebcrtServiceTests(unittest.TestCase):
db = MagicMock()
with patch.object(svc.settings, "webcrt_max_sessions", 1):
out = svc.create_session(db, ne_id="ne-a", cols=80, rows=24, client="test")
out = svc.create_session(db, ne_id="ne-a", cols=80, rows=24, client="test", async_connect=False)
self.assertIn("session_id", out)
self.assertEqual(out.get("state"), "ready")
self.assertFalse(out.get("cli_hop"))
with self.assertRaises(HTTPException) as ctx:
svc.create_session(db, ne_id="ne-a", cols=80, rows=24, client="test")
svc.create_session(db, ne_id="ne-a", cols=80, rows=24, client="test", async_connect=False)
self.assertEqual(ctx.exception.status_code, 429)
@patch.object(svc, "_audit")
@ -172,7 +184,7 @@ class WebcrtServiceTests(unittest.TestCase):
mock_open.side_effect = _open_with_log
db = MagicMock()
out = svc.create_session(db, ne_id="ne-hop", cols=100, rows=30, client="test")
out = svc.create_session(db, ne_id="ne-hop", cols=100, rows=30, client="test", async_connect=False)
mock_open.assert_called_once()
called_creds = mock_open.call_args.args[0]
self.assertTrue(called_creds["hop_enabled"])
@ -222,13 +234,15 @@ class WebcrtServiceTests(unittest.TestCase):
)
mock_open.side_effect = lambda *a, **k: _FakeConn()
mock_guard.return_value = {"hop_prompt": "<HOP>", "hop_vendor": "huawei", "hop_host": "1.1.1.1"}
out = svc.create_session(MagicMock(), ne_id="ne-cli-hop", cols=100, rows=30, client="test")
self.assertTrue(out.get("cli_hop"))
self.assertEqual(mock_open.call_args.kwargs.get("cols"), 100)
self.assertEqual(mock_open.call_args.kwargs.get("rows"), 30)
out = svc.create_session(
MagicMock(), ne_id="ne-cli-hop", cols=100, rows=30, client="test", async_connect=False
)
sess = svc.get_session(out["session_id"])
assert sess is not None
self.assertTrue(sess.cli_hop_guard)
self.assertTrue(out.get("cli_hop") or sess.cli_hop_guard)
self.assertEqual(mock_open.call_args.kwargs.get("cols"), 100)
self.assertEqual(mock_open.call_args.kwargs.get("rows"), 30)
self.assertEqual(sess.cli_hop_prompt, "<HOP>")
svc.close_session(out["session_id"], reason="test")
@ -265,7 +279,9 @@ class WebcrtServiceTests(unittest.TestCase):
},
)
mock_open.return_value = _FakeConn()
out = svc.create_session(MagicMock(), ne_id="ne-bastion", cols=80, rows=24, client="test")
out = svc.create_session(
MagicMock(), ne_id="ne-bastion", cols=80, rows=24, client="test", async_connect=False
)
mock_open.assert_called_once()
self.assertEqual(out["ne_id"], "ne-bastion")
svc.close_session(out["session_id"], reason="test")
@ -466,6 +482,19 @@ class WebcrtServiceTests(unittest.TestCase):
sess._note_cli_hop_output(b"Connection closed by foreign host\r\n<HUAWEI>\r\n")
)
def test_bounded_queue_drops_oldest(self) -> None:
q = svc._BoundedByteQueue(maxsize=8)
for i in range(10):
q.put(str(i).encode())
self.assertGreaterEqual(q.dropped, 2)
first = q.get_nowait()
self.assertEqual(first, b"2")
def test_normalize_encoding(self) -> None:
self.assertEqual(svc._normalize_encoding("GBK"), "gbk")
self.assertEqual(svc._normalize_encoding("utf8"), "utf-8")
self.assertEqual(svc._encode_text("测", "gbk")[:1], b"\xb2")
if __name__ == "__main__":
unittest.main()

View file

@ -139,12 +139,14 @@ src/
## WebCRT
- API:`POST /v1/webcrt/sessions`(`ne_id` 或 `ume_ne_id`)、`WS /v1/webcrt/sessions/{id}/ws`、`DELETE /v1/webcrt/sessions/{id}`
- API:`POST /v1/webcrt/sessions`(`ne_id` 或 `ume_ne_id`;可选 `encoding`/`post_login_commands`/`async_connect`)、`WS /v1/webcrt/sessions/{id}/ws`、`DELETE /v1/webcrt/sessions/{id}`
- SFTP(直连 SSH):`POST /v1/webcrt/sftp/list|download|upload`
- 目标列表复用 `/v1/cli/targets`(托管 + UME,搜索分页;`source=all|managed|ume`)
- 凭据:托管走网元自身账号;UME 走 CLI 连接模板(`resolve_cli_target`)
- 前端:CRT 风格左右分栏(会话管理 + 多标签终端)
- 审计:`NETX_WEBCRT_DATA_DIR`(默认 `data/webcrt/audit.jsonl`)
- 限流:`NETX_WEBCRT_MAX_SESSIONS`、`NETX_WEBCRT_IDLE_TIMEOUT_SEC`
- 前端:CRT 风格左右分栏(收藏/最近 + 多标签终端);粘贴节流、选区复制、Break、Ctrl+F、Button Bar、广播、编码/字号
- WS:stdout 二进制帧合批;stdin 短合批;建连可异步(POST 立即返回,进度经 WS `connecting`→`connected`)
- 审计 / 会话日志:`NETX_WEBCRT_DATA_DIR`(`audit.jsonl` + `sessions/*.log`)
- 限流 / 保活:`NETX_WEBCRT_MAX_SESSIONS`、`NETX_WEBCRT_IDLE_TIMEOUT_SEC`、`NETX_WEBCRT_KEEPALIVE_SEC`、`NETX_WEBCRT_ANTI_IDLE_SEC`
## Toast

View file

@ -1,4 +1,4 @@
import { forwardRef, useEffect, useImperativeHandle, useRef } from "react";
import { forwardRef, useEffect, useImperativeHandle, useRef, useState } from "react";
import { FitAddon } from "@xterm/addon-fit";
import { Terminal } from "@xterm/xterm";
import "@xterm/xterm/css/xterm.css";
@ -6,9 +6,14 @@ import "@xterm/xterm/css/xterm.css";
export type WebTerminalHandle = {
clear: () => void;
copyAll: () => Promise<string>;
copySelection: () => Promise<string>;
getText: () => string;
fit: () => void;
focus: () => void;
sendBreak: () => void;
sendText: (data: string, opts?: { throttle?: boolean }) => void;
findNext: (term: string) => void;
findPrevious: (term: string) => void;
};
type Props = {
@ -16,6 +21,9 @@ type Props = {
title?: string;
recording?: boolean;
autoFocus?: boolean;
encoding?: string;
pasteDelayMs?: number;
copyOnSelect?: boolean;
onStatus?: (state: string, message?: string) => void;
onReady?: () => void;
onStdout?: (data: string) => void;
@ -35,11 +43,10 @@ function serializeTerminal(term: Terminal): string {
function isSidebarSearchTarget(target: EventTarget | null): boolean {
if (!(target instanceof HTMLElement)) return false;
if (target.tagName === "INPUT" || target.tagName === "TEXTAREA" || target.tagName === "SELECT") {
// xterm's hidden textarea must still receive keys normally.
if (target.classList.contains("xterm-helper-textarea")) return false;
return true;
}
return Boolean(target.closest(".webcrt-sidebar__search"));
return Boolean(target.closest(".webcrt-sidebar__search") || target.closest(".webcrt-ctx") || target.closest(".webcrt-find"));
}
function isXtermTextarea(target: EventTarget | null): boolean {
@ -49,7 +56,7 @@ function isXtermTextarea(target: EventTarget | null): boolean {
/** Map a browser key event to the bytes xterm/onData would normally emit. */
function keyEventToStdin(e: KeyboardEvent): string | null {
if (e.ctrlKey || e.altKey || e.metaKey) return null;
if (e.key === "Backspace") return "\x08"; // BS — common SecureCRT/VT default
if (e.key === "Backspace") return "\x08";
if (e.key === "Enter") return "\r";
if (e.key === "Tab") return "\t";
if (e.key === "Escape") return "\x1b";
@ -64,19 +71,65 @@ function keyEventToStdin(e: KeyboardEvent): string | null {
return null;
}
function decodeBytes(buf: ArrayBuffer, _encoding: string): string {
// Server normalizes device encodings (e.g. GBK) to UTF-8 on the wire.
try {
return new TextDecoder("utf-8").decode(buf);
} catch {
return "";
}
}
const PREF_KEY = "netx.webcrt.termPrefs";
function loadPrefs(): { copyOnSelect: boolean; pasteDelayMs: number } {
try {
const raw = localStorage.getItem(PREF_KEY);
if (!raw) return { copyOnSelect: true, pasteDelayMs: 40 };
const j = JSON.parse(raw) as Partial<{ copyOnSelect: boolean; pasteDelayMs: number }>;
return {
copyOnSelect: j.copyOnSelect !== false,
pasteDelayMs: Math.max(0, Math.min(200, Number(j.pasteDelayMs) || 40)),
};
} catch {
return { copyOnSelect: true, pasteDelayMs: 40 };
}
}
export const WebTerminal = forwardRef<WebTerminalHandle, Props>(function WebTerminal(
{ wsUrl, title, recording, autoFocus = true, onStatus, onReady, onStdout },
{
wsUrl,
title,
recording,
autoFocus = true,
encoding = "utf-8",
pasteDelayMs,
copyOnSelect,
onStatus,
onReady,
onStdout,
},
ref,
) {
const hostRef = useRef<HTMLDivElement | null>(null);
const termRef = useRef<Terminal | null>(null);
const fitRef = useRef<FitAddon | null>(null);
const wsRef = useRef<WebSocket | null>(null);
const findIndexRef = useRef(0);
const onStatusRef = useRef(onStatus);
const onReadyRef = useRef(onReady);
const onStdoutRef = useRef(onStdout);
const recordingRef = useRef(!!recording);
const autoFocusRef = useRef(autoFocus);
const encodingRef = useRef(encoding);
const pasteDelayRef = useRef(pasteDelayMs ?? loadPrefs().pasteDelayMs);
const copyOnSelectRef = useRef(copyOnSelect ?? loadPrefs().copyOnSelect);
const pasteQueueRef = useRef<Promise<void>>(Promise.resolve());
const [ctxMenu, setCtxMenu] = useState<{ x: number; y: number } | null>(null);
const [findOpen, setFindOpen] = useState(false);
const [findQuery, setFindQuery] = useState("");
const [pasteStatus, setPasteStatus] = useState<{ done: number; total: number } | null>(null);
const findInputRef = useRef<HTMLInputElement | null>(null);
useEffect(() => {
onStatusRef.current = onStatus;
@ -92,6 +145,18 @@ export const WebTerminal = forwardRef<WebTerminalHandle, Props>(function WebTerm
autoFocusRef.current = autoFocus;
}, [autoFocus]);
useEffect(() => {
encodingRef.current = encoding;
}, [encoding]);
useEffect(() => {
if (pasteDelayMs != null) pasteDelayRef.current = pasteDelayMs;
}, [pasteDelayMs]);
useEffect(() => {
if (copyOnSelect != null) copyOnSelectRef.current = copyOnSelect;
}, [copyOnSelect]);
const focusTerminal = () => {
try {
termRef.current?.focus();
@ -100,6 +165,46 @@ export const WebTerminal = forwardRef<WebTerminalHandle, Props>(function WebTerm
}
};
const sendJson = (payload: Record<string, unknown>) => {
const ws = wsRef.current;
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify(payload));
}
};
const sendStdinImmediate = (data: string) => {
if (!data) return;
sendJson({ type: "stdin", data });
};
/** SecureCRT-like paste: send line-by-line with delay; show progress while sending. */
const sendStdinThrottled = (data: string) => {
if (!data) return;
const delay = pasteDelayRef.current;
if (delay <= 0 || data.length < 8) {
sendStdinImmediate(data);
return;
}
pasteQueueRef.current = pasteQueueRef.current.then(async () => {
const lines = data.replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n");
const total = Math.max(1, lines.length);
setPasteStatus({ done: 0, total });
try {
for (let i = 0; i < lines.length; i += 1) {
const line = lines[i];
const chunk = i < lines.length - 1 ? `${line}\r` : line;
if (chunk) sendStdinImmediate(chunk);
setPasteStatus({ done: i + 1, total });
if (i < lines.length - 1) {
await new Promise((r) => window.setTimeout(r, delay));
}
}
} finally {
setPasteStatus(null);
}
});
};
useImperativeHandle(ref, () => ({
clear: () => {
termRef.current?.clear();
@ -112,6 +217,13 @@ export const WebTerminal = forwardRef<WebTerminalHandle, Props>(function WebTerm
}
return text;
},
copySelection: async () => {
const text = termRef.current?.getSelection() || "";
if (text && navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(text);
}
return text;
},
getText: () => (termRef.current ? serializeTerminal(termRef.current) : ""),
fit: () => {
try {
@ -121,6 +233,39 @@ export const WebTerminal = forwardRef<WebTerminalHandle, Props>(function WebTerm
}
},
focus: focusTerminal,
sendBreak: () => sendJson({ type: "break" }),
sendText: (data: string, opts?: { throttle?: boolean }) => {
if (opts?.throttle) sendStdinThrottled(data);
else sendStdinImmediate(data);
},
findNext: (q: string) => {
const term = termRef.current;
if (!term || !q) return;
const text = serializeTerminal(term).toLowerCase();
const needle = q.toLowerCase();
let idx = text.indexOf(needle, findIndexRef.current + 1);
if (idx < 0) idx = text.indexOf(needle);
if (idx >= 0) {
findIndexRef.current = idx;
// Approximate scroll: each buffer line ~1 row.
const line = text.slice(0, idx).split("\n").length - 1;
term.scrollToLine(Math.max(0, line - 2));
}
},
findPrevious: (q: string) => {
const term = termRef.current;
if (!term || !q) return;
const text = serializeTerminal(term).toLowerCase();
const needle = q.toLowerCase();
const before = text.slice(0, Math.max(0, findIndexRef.current));
let idx = before.lastIndexOf(needle);
if (idx < 0) idx = text.lastIndexOf(needle);
if (idx >= 0) {
findIndexRef.current = idx;
const line = text.slice(0, idx).split("\n").length - 1;
term.scrollToLine(Math.max(0, line - 2));
}
},
}));
useEffect(() => {
@ -131,10 +276,12 @@ export const WebTerminal = forwardRef<WebTerminalHandle, Props>(function WebTerm
cursorBlink: true,
fontSize: 13,
fontFamily: 'Consolas, "Courier New", monospace',
scrollback: 10000,
theme: {
background: "#0b1220",
foreground: "#e2e8f0",
cursor: "#e2e8f0",
selectionBackground: "#334155",
},
convertEol: true,
});
@ -155,7 +302,6 @@ export const WebTerminal = forwardRef<WebTerminalHandle, Props>(function WebTerm
if (!autoFocusRef.current) return;
focusTerminal();
};
// Focus immediately so the first keystroke is not lost to the sidebar/body.
maybeFocus();
requestAnimationFrame(() => {
doFit();
@ -167,22 +313,10 @@ export const WebTerminal = forwardRef<WebTerminalHandle, Props>(function WebTerm
});
const ws = new WebSocket(wsUrl);
ws.binaryType = "arraybuffer";
wsRef.current = ws;
onStatusRef.current?.("connecting");
const sendJson = (payload: Record<string, unknown>) => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify(payload));
}
};
const sendStdin = (data: string) => {
if (!data) return;
sendJson({ type: "stdin", data });
};
// Display only what the device echoes (chars, Tab completion, BS erase, etc.).
// No local echo / local erase — those desync on Tab complete and prompt redraw.
const writeStdout = (raw: string) => {
if (raw) term.write(raw);
if (recordingRef.current) onStdoutRef.current?.(raw);
@ -201,6 +335,18 @@ export const WebTerminal = forwardRef<WebTerminalHandle, Props>(function WebTerm
};
ws.onmessage = (ev) => {
if (ev.data instanceof ArrayBuffer) {
writeStdout(decodeBytes(ev.data, encodingRef.current));
maybeFocus();
return;
}
if (typeof Blob !== "undefined" && ev.data instanceof Blob) {
void ev.data.arrayBuffer().then((buf) => {
writeStdout(decodeBytes(buf, encodingRef.current));
maybeFocus();
});
return;
}
try {
const msg = JSON.parse(String(ev.data || "{}")) as {
type?: string;
@ -215,7 +361,7 @@ export const WebTerminal = forwardRef<WebTerminalHandle, Props>(function WebTerm
}
if (msg.type === "status") {
onStatusRef.current?.(String(msg.state || ""), msg.message);
if (msg.state === "connected") {
if (msg.state === "connected" || msg.state === "connecting") {
maybeFocus();
return;
}
@ -243,27 +389,41 @@ export const WebTerminal = forwardRef<WebTerminalHandle, Props>(function WebTerm
}
};
// When focused, xterm onData sends keystrokes.
const dataDisposable = term.onData((data) => {
// Normalize Backspace: xterm emits DEL(0x7f); devices expect BS(0x08) like default SecureCRT.
const normalized = data.replace(/\x7f/g, "\x08");
sendStdin(normalized);
// Large pastes from xterm arrive as one onData blob.
if (normalized.length > 32 || normalized.includes("\r") || normalized.includes("\n")) {
sendStdinThrottled(normalized);
} else {
sendStdinImmediate(normalized);
}
});
const selDisposable = term.onSelectionChange(() => {
if (!copyOnSelectRef.current) return;
const sel = term.getSelection();
if (sel && navigator.clipboard?.writeText) {
void navigator.clipboard.writeText(sel).catch(() => undefined);
}
});
// When NOT focused (sidebar still focused after click), capture keys and forward
// so the first character / Backspace are not lost to the browser.
const onKeyDownCapture = (e: KeyboardEvent) => {
if (!autoFocusRef.current) return;
if (host.closest("[hidden]")) return;
if (isSidebarSearchTarget(e.target)) return;
// Always block browser "Backspace = history back" while this session pane is active.
if ((e.ctrlKey || e.metaKey) && (e.key === "f" || e.key === "F")) {
e.preventDefault();
setFindOpen(true);
window.setTimeout(() => findInputRef.current?.focus(), 0);
return;
}
if (e.key === "Backspace") {
e.preventDefault();
}
if (isXtermTextarea(e.target)) {
// Let xterm onData handle it; we only prevented browser back above.
maybeFocus();
return;
}
@ -274,10 +434,31 @@ export const WebTerminal = forwardRef<WebTerminalHandle, Props>(function WebTerm
e.preventDefault();
e.stopPropagation();
maybeFocus();
sendStdin(data);
sendStdinImmediate(data);
};
window.addEventListener("keydown", onKeyDownCapture, true);
const onContextMenu = (e: MouseEvent) => {
e.preventDefault();
setCtxMenu({ x: e.clientX, y: e.clientY });
};
host.addEventListener("contextmenu", onContextMenu);
const onPasteCapture = (e: ClipboardEvent) => {
if (!autoFocusRef.current) return;
if (host.closest("[hidden]")) return;
if (isSidebarSearchTarget(e.target)) return;
const text = e.clipboardData?.getData("text") || "";
if (!text) return;
// Intercept paste so we always apply delay (even if focus is odd).
if (document.activeElement === term.textarea || host.contains(document.activeElement) || autoFocusRef.current) {
e.preventDefault();
sendStdinThrottled(text);
maybeFocus();
}
};
window.addEventListener("paste", onPasteCapture, true);
const pingTimer = window.setInterval(() => {
sendJson({ type: "ping" });
}, 25000);
@ -297,8 +478,11 @@ export const WebTerminal = forwardRef<WebTerminalHandle, Props>(function WebTerm
window.clearInterval(pingTimer);
window.removeEventListener("resize", onWinResize);
window.removeEventListener("keydown", onKeyDownCapture, true);
window.removeEventListener("paste", onPasteCapture, true);
host.removeEventListener("contextmenu", onContextMenu);
ro?.disconnect();
dataDisposable.dispose();
selDisposable.dispose();
try {
ws.close();
} catch {
@ -311,13 +495,147 @@ export const WebTerminal = forwardRef<WebTerminalHandle, Props>(function WebTerm
};
}, [wsUrl, title]);
const pasteFromClipboard = async () => {
try {
const text = await navigator.clipboard.readText();
sendStdinThrottled(text);
} catch {
/* ignore */
}
setCtxMenu(null);
focusTerminal();
};
const copySelection = async () => {
const text = termRef.current?.getSelection() || "";
if (text && navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(text);
}
setCtxMenu(null);
};
return (
<div
className="webcrt-term"
ref={hostRef}
onMouseDown={() => {
focusTerminal();
}}
/>
<div className="webcrt-term-wrap">
{pasteStatus ? (
<div className="webcrt-paste-status" role="status" aria-live="polite">
粘贴中 {pasteStatus.done}/{pasteStatus.total} 行…
</div>
) : null}
{findOpen ? (
<div className="webcrt-find">
<input
ref={findInputRef}
type="search"
value={findQuery}
placeholder="Find…"
onChange={(e) => setFindQuery(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
const term = termRef.current;
if (!term || !findQuery) return;
const text = serializeTerminal(term).toLowerCase();
const needle = findQuery.toLowerCase();
if (e.shiftKey) {
const before = text.slice(0, Math.max(0, findIndexRef.current));
let idx = before.lastIndexOf(needle);
if (idx < 0) idx = text.lastIndexOf(needle);
if (idx >= 0) {
findIndexRef.current = idx;
term.scrollToLine(Math.max(0, text.slice(0, idx).split("\n").length - 3));
}
} else {
let idx = text.indexOf(needle, findIndexRef.current + 1);
if (idx < 0) idx = text.indexOf(needle);
if (idx >= 0) {
findIndexRef.current = idx;
term.scrollToLine(Math.max(0, text.slice(0, idx).split("\n").length - 3));
}
}
} else if (e.key === "Escape") {
setFindOpen(false);
focusTerminal();
}
}}
/>
<button
type="button"
onClick={() => {
const term = termRef.current;
if (!term || !findQuery) return;
const text = serializeTerminal(term).toLowerCase();
const needle = findQuery.toLowerCase();
const before = text.slice(0, Math.max(0, findIndexRef.current));
let idx = before.lastIndexOf(needle);
if (idx < 0) idx = text.lastIndexOf(needle);
if (idx >= 0) {
findIndexRef.current = idx;
term.scrollToLine(Math.max(0, text.slice(0, idx).split("\n").length - 3));
}
}}
>
↑
</button>
<button
type="button"
onClick={() => {
const term = termRef.current;
if (!term || !findQuery) return;
const text = serializeTerminal(term).toLowerCase();
const needle = findQuery.toLowerCase();
let idx = text.indexOf(needle, findIndexRef.current + 1);
if (idx < 0) idx = text.indexOf(needle);
if (idx >= 0) {
findIndexRef.current = idx;
term.scrollToLine(Math.max(0, text.slice(0, idx).split("\n").length - 3));
}
}}
>
↓
</button>
<button
type="button"
onClick={() => {
setFindOpen(false);
focusTerminal();
}}
>
×
</button>
</div>
) : null}
<div
className="webcrt-term"
ref={hostRef}
onMouseDown={() => {
focusTerminal();
setCtxMenu(null);
}}
/>
{ctxMenu ? (
<div
className="webcrt-ctx"
style={{ left: ctxMenu.x, top: ctxMenu.y }}
onMouseDown={(e) => e.stopPropagation()}
>
<button type="button" onClick={() => void copySelection()}>
Copy
</button>
<button type="button" onClick={() => void pasteFromClipboard()}>
Paste
</button>
<button
type="button"
onClick={() => {
termRef.current?.clear();
termRef.current?.write("\x1b[H\x1b[2J");
setCtxMenu(null);
}}
>
Clear
</button>
</div>
) : null}
</div>
);
});

View file

@ -939,6 +939,25 @@ const en = {
copied: "Copied to clipboard",
copyEmpty: "Terminal is empty",
copyFailed: "Copy failed",
break: "Break",
breakSent: "Break sent",
broadcast: "Broadcast",
broadcastSent: "Sent to all sessions",
postLogin: "Post-login cmds",
},
favorites: "Favorites",
recent: "Recent",
favorite: "Toggle favorite",
encoding: "Encoding",
fontSize: "Font",
broadcastPh: "Send to all tabs…",
buttonBarHint: "One per line: label|command",
postLoginPrompt: "Post-login commands (one per line, this device)",
postLoginSaved: "Post-login commands saved",
sftp: {
refresh: "Refresh",
upload: "Upload",
uploaded: "Upload complete",
},
err: {
sessionLimit: "Session limit reached; close other terminals first",
@ -946,6 +965,8 @@ const en = {
credsIncomplete: "Incomplete username/password on this NE",
cliProfile: "UME NEs require a CLI connect profile (default account / hop)",
connectFailed: "Device login failed: {{detail}}",
sftpHop: "SFTP does not support hop devices yet (direct SSH only)",
sftpSsh: "SFTP requires SSH",
},
},
topology: {

View file

@ -936,6 +936,25 @@ const zh = {
copied: "已复制到剪贴板",
copyEmpty: "终端内容为空",
copyFailed: "复制失败",
break: "Break",
breakSent: "已发送 Break",
broadcast: "广播",
broadcastSent: "已广播到全部会话",
postLogin: "登录后命令",
},
favorites: "收藏",
recent: "最近",
favorite: "收藏/取消",
encoding: "编码",
fontSize: "字号",
broadcastPh: "发送到全部标签…",
buttonBarHint: "每行:按钮名|命令",
postLoginPrompt: "登录后自动执行(每行一条,仅本设备)",
postLoginSaved: "登录后命令已保存",
sftp: {
refresh: "刷新",
upload: "上传",
uploaded: "上传成功",
},
err: {
sessionLimit: "并发会话已达上限,请先关闭其他终端",
@ -943,6 +962,8 @@ const zh = {
credsIncomplete: "网元账号或密码不完整",
cliProfile: "UME 网元需要先配置 CLI 连接模板(默认账号/跳板)",
connectFailed: "登录设备失败:{{detail}}",
sftpHop: "SFTP 暂不支持跳板设备,请使用直连 SSH",
sftpSsh: "SFTP 仅支持 SSH 协议",
},
},
topology: {

View file

@ -2289,7 +2289,8 @@ pre {
display: none !important;
}
.webcrt-main__pane .webcrt-term {
.webcrt-main__pane .webcrt-term,
.webcrt-main__pane .webcrt-term-wrap {
flex: 1;
width: 100%;
height: auto !important;
@ -2332,6 +2333,284 @@ pre {
display: none;
}
.webcrt-term-wrap {
position: relative;
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
height: 100%;
}
.webcrt-term-wrap .webcrt-term {
flex: 1;
min-height: 0;
}
.webcrt-paste-status {
position: absolute;
top: 8px;
right: 10px;
z-index: 4;
padding: 4px 10px;
font-size: 12px;
color: #e2e8f0;
background: rgba(15, 23, 42, 0.92);
border: 1px solid #475569;
border-radius: 2px;
pointer-events: none;
}
.webcrt-find {
display: flex;
gap: 4px;
align-items: center;
padding: 4px 8px;
background: #1e293b;
border-bottom: 1px solid #334155;
z-index: 2;
}
.webcrt-find input {
flex: 1;
min-width: 0;
background: #0f172a;
border: 1px solid #475569;
color: #e2e8f0;
padding: 2px 8px;
font-size: 12px;
}
.webcrt-find button {
background: #334155;
border: 1px solid #475569;
color: #e2e8f0;
padding: 2px 8px;
cursor: pointer;
}
.webcrt-ctx {
position: fixed;
z-index: 50;
min-width: 120px;
background: #1e293b;
border: 1px solid #475569;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35);
display: flex;
flex-direction: column;
padding: 4px 0;
}
.webcrt-ctx button {
background: transparent;
border: 0;
color: #e2e8f0;
text-align: left;
padding: 6px 14px;
cursor: pointer;
font-size: 12px;
}
.webcrt-ctx button:hover {
background: #334155;
}
.webcrt-tree__row {
display: flex;
align-items: stretch;
gap: 0;
}
.webcrt-tree__fav {
flex: 0 0 22px;
display: flex;
align-items: center;
justify-content: center;
background: transparent;
border: 0;
color: #94a3b8;
cursor: pointer;
padding: 0;
}
.webcrt-tree__fav:hover {
color: #fbbf24;
}
.webcrt-tree__row .webcrt-tree__item {
flex: 1;
min-width: 0;
}
.webcrt-sidebar__pins {
max-height: 28%;
overflow: auto;
border-bottom: 1px solid #334155;
padding-bottom: 4px;
}
.webcrt-sidebar__pin-title {
font-size: 11px;
color: #94a3b8;
padding: 6px 10px 2px;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.webcrt-sidebar__prefs {
display: flex;
gap: 8px;
padding: 8px 10px;
border-top: 1px solid #334155;
font-size: 12px;
color: #cbd5e1;
}
.webcrt-sidebar__prefs label {
display: flex;
flex-direction: column;
gap: 2px;
flex: 1;
}
.webcrt-sidebar__prefs select,
.webcrt-sidebar__prefs input {
background: #0f172a;
border: 1px solid #475569;
color: #e2e8f0;
padding: 2px 6px;
font-size: 12px;
}
.webcrt-buttonbar {
display: flex;
flex-wrap: wrap;
gap: 4px;
align-items: center;
padding: 4px 8px;
background: #1e293b;
border-bottom: 1px solid #334155;
}
.webcrt-buttonbar__btn {
background: #334155;
border: 1px solid #475569;
color: #e2e8f0;
padding: 2px 8px;
font-size: 11px;
cursor: pointer;
border-radius: 2px;
}
.webcrt-buttonbar__btn:hover {
background: #475569;
}
.webcrt-broadcast {
display: flex;
gap: 4px;
margin-left: auto;
align-items: center;
}
.webcrt-broadcast input {
width: 160px;
background: #0f172a;
border: 1px solid #475569;
color: #e2e8f0;
padding: 2px 6px;
font-size: 11px;
}
.webcrt-broadcast button {
background: #0ea5e9;
border: 0;
color: #0f172a;
padding: 2px 8px;
font-size: 11px;
cursor: pointer;
}
.webcrt-buttonbar-edit {
padding: 6px 8px;
background: #0f172a;
border-bottom: 1px solid #334155;
}
.webcrt-buttonbar-edit textarea {
width: 100%;
background: #1e293b;
border: 1px solid #475569;
color: #e2e8f0;
font-family: Consolas, monospace;
font-size: 12px;
padding: 6px;
}
.webcrt-sftp {
position: absolute;
right: 0;
top: 0;
bottom: 0;
width: min(320px, 40%);
background: #0f172a;
border-left: 1px solid #334155;
z-index: 3;
display: flex;
flex-direction: column;
}
.webcrt-sftp__bar {
display: flex;
gap: 4px;
padding: 6px;
border-bottom: 1px solid #334155;
}
.webcrt-sftp__bar input {
flex: 1;
min-width: 0;
background: #1e293b;
border: 1px solid #475569;
color: #e2e8f0;
padding: 2px 6px;
font-size: 12px;
}
.webcrt-sftp__bar button,
.webcrt-sftp__upload {
background: #334155;
border: 1px solid #475569;
color: #e2e8f0;
padding: 2px 8px;
font-size: 11px;
cursor: pointer;
}
.webcrt-sftp__list {
list-style: none;
margin: 0;
padding: 0;
overflow: auto;
flex: 1;
}
.webcrt-sftp__list button {
width: 100%;
text-align: left;
background: transparent;
border: 0;
border-bottom: 1px solid #1e293b;
color: #e2e8f0;
padding: 6px 10px;
font-size: 12px;
cursor: pointer;
}
.webcrt-sftp__list button:hover {
background: #1e293b;
}
@media (max-width: 900px) {
.webcrt-shell {
grid-template-columns: 1fr;

View file

@ -9,6 +9,9 @@ import {
createWebcrtSession,
fetchCliTargets,
fetchManagedNeById,
webcrtSftpDownload,
webcrtSftpList,
webcrtSftpUpload,
webcrtWsUrl,
} from "../services/api";
import { pageCount } from "../utils/display";
@ -50,6 +53,8 @@ function webcrtErrorMessage(err: unknown, t: (key: string, vars?: Record<string,
if (raw.includes("credentials_incomplete") || raw.includes("cli_username_required")) return t("webcrt.err.credsIncomplete");
if (raw.includes("cli_connect_profile_not_configured")) return t("webcrt.err.cliProfile");
if (raw.includes("connect_failed")) return t("webcrt.err.connectFailed", { detail: raw.replace(/^.*connect_failed:/, "") });
if (raw.includes("sftp_hop_not_supported")) return t("webcrt.err.sftpHop");
if (raw.includes("sftp_requires_ssh")) return t("webcrt.err.sftpSsh");
return raw;
}
@ -76,6 +81,9 @@ export function WebcrtPage() {
const [page, setPage] = useState(1);
const [tabs, setTabs] = useState<TermTab[]>([]);
const [activeTabKey, setActiveTabKey] = useState("");
const [sftpOpen, setSftpOpen] = useState(false);
const [sftpPath, setSftpPath] = useState(".");
const [sftpItems, setSftpItems] = useState<Array<{ name: string; size: number; mtime: number; is_dir: boolean }>>([]);
const connectingKeysRef = useRef<Set<string>>(new Set());
const tabsRef = useRef<TermTab[]>([]);
tabsRef.current = tabs;
@ -145,8 +153,18 @@ export function WebcrtPage() {
const rows = Math.max(24, Math.floor((window.innerHeight - 180) / 18));
const body =
target.source === "ume"
? { ume_ne_id: target.ume_ne_id || target.id, cols, rows }
: { ne_id: target.id, cols, rows };
? {
ume_ne_id: target.ume_ne_id || target.id,
cols,
rows,
async_connect: true,
}
: {
ne_id: target.id,
cols,
rows,
async_connect: true,
};
const sess = await createWebcrtSession(body);
const wsUrl = webcrtWsUrl(sess.session_id);
updateTab(key, {
@ -228,7 +246,9 @@ export function WebcrtPage() {
const tab = tabsRef.current.find((x) => x.key === activeTabKey);
if (!tab) return;
try {
const text = await termRefs.current.get(tab.key)?.copyAll();
const handle = termRefs.current.get(tab.key);
const sel = await handle?.copySelection();
const text = sel || (await handle?.copyAll());
if (text) showOk(t("webcrt.actions.copied"));
else showError(t("webcrt.actions.copyEmpty"));
} catch {
@ -236,6 +256,22 @@ export function WebcrtPage() {
}
}, [activeTabKey, showOk, showError, t]);
const refreshSftp = useCallback(async () => {
const tab = tabsRef.current.find((x) => x.key === activeTabKey);
if (!tab) return;
try {
const body =
tab.target.source === "ume"
? { ume_ne_id: tab.target.ume_ne_id || tab.target.id, path: sftpPath }
: { ne_id: tab.target.id, path: sftpPath };
const res = await webcrtSftpList(body);
setSftpItems(res.items || []);
setSftpPath(res.path || sftpPath);
} catch (err) {
showError(webcrtErrorMessage(err, t));
}
}, [activeTabKey, showError, sftpPath, t]);
// Auto-connect from /webcrt?ne_id=...
useEffect(() => {
if (!presetNeId) return;
@ -292,6 +328,35 @@ export function WebcrtPage() {
const activeTab = tabs.find((x) => x.key === activeTabKey) || null;
const renderDeviceRow = (row: CliTargetItem) => {
const key = targetKey(row);
const tab = tabs.find((x) => x.key === key);
const isConnecting = tab?.status === "connecting" || connectingKeysRef.current.has(key);
return (
<li key={key}>
<button
type="button"
className={`webcrt-tree__item${activeTabKey === key ? " is-active" : ""}`}
onMouseDown={(e) => e.preventDefault()}
onClick={() => void openTarget(row)}
title={`${deviceLabel(row)}\n${row.ip_address}\n${row.source}`}
>
<span className="webcrt-tree__icon" aria-hidden>
<ComputerIcon />
</span>
<span className="webcrt-tree__label">
<span className="webcrt-tree__name">{deviceLabel(row)}</span>
<span className="webcrt-tree__meta">
{row.ip_address || row.source}
{isConnecting ? ` · ${t("webcrt.status.connecting")}` : ""}
{tab?.status === "connected" ? ` · ${t("webcrt.status.connected")}` : ""}
</span>
</span>
</button>
</li>
);
};
return (
<div className="webcrt-shell">
<aside className="webcrt-sidebar">
@ -326,39 +391,7 @@ export function WebcrtPage() {
{!targetsQuery.isLoading && items.length === 0 ? (
<div className="webcrt-tree__empty">{t("webcrt.empty")}</div>
) : null}
<ul className="webcrt-tree__list">
{items.map((row) => {
const key = targetKey(row);
const tab = tabs.find((x) => x.key === key);
const isConnecting = tab?.status === "connecting" || connectingKeysRef.current.has(key);
return (
<li key={key}>
<button
type="button"
className={`webcrt-tree__item${activeTabKey === key ? " is-active" : ""}`}
onMouseDown={(e) => {
// Keep focus off the list button so Enter/Backspace go to the CRT.
e.preventDefault();
}}
onClick={() => void openTarget(row)}
title={`${deviceLabel(row)}\n${row.ip_address}\n${row.source}`}
>
<span className="webcrt-tree__icon" aria-hidden>
<ComputerIcon />
</span>
<span className="webcrt-tree__label">
<span className="webcrt-tree__name">{deviceLabel(row)}</span>
<span className="webcrt-tree__meta">
{row.ip_address || row.source}
{isConnecting ? ` · ${t("webcrt.status.connecting")}` : ""}
{tab?.status === "connected" ? ` · ${t("webcrt.status.connected")}` : ""}
</span>
</span>
</button>
</li>
);
})}
</ul>
<ul className="webcrt-tree__list">{items.map((row) => renderDeviceRow(row))}</ul>
</div>
<div className="webcrt-sidebar__pager">
<span>{t("common.pagerMeta", { total, page, pages })}</span>
@ -435,6 +468,16 @@ export function WebcrtPage() {
<span aria-hidden>⧉</span>
{t("webcrt.actions.copy")}
</button>
<button
type="button"
className={`webcrt-action-btn${sftpOpen ? " is-active" : ""}`}
onClick={() => {
setSftpOpen((v) => !v);
if (!sftpOpen) void refreshSftp();
}}
>
SFTP
</button>
</div>
) : null}
<div className="webcrt-main__body">
@ -476,6 +519,8 @@ export function WebcrtPage() {
if (activeTabKey === tab.key) {
window.setTimeout(() => termRefs.current.get(tab.key)?.focus(), 40);
}
} else if (state === "connecting") {
updateTab(tab.key, { status: "connecting" });
} else if (state === "error") updateTab(tab.key, { status: "error" });
else if (state === "closed") updateTab(tab.key, { status: "closed" });
}}
@ -483,6 +528,89 @@ export function WebcrtPage() {
) : null}
</div>
))}
{sftpOpen && activeTab ? (
<div className="webcrt-sftp">
<div className="webcrt-sftp__bar">
<input value={sftpPath} onChange={(e) => setSftpPath(e.target.value)} />
<button type="button" onClick={() => void refreshSftp()}>
{t("webcrt.sftp.refresh")}
</button>
<label className="webcrt-sftp__upload">
{t("webcrt.sftp.upload")}
<input
type="file"
hidden
onChange={(e) => {
const file = e.target.files?.[0];
if (!file || !activeTab) return;
const remote = `${sftpPath.replace(/\/$/, "")}/${file.name}`.replace(/^\.\//, "");
void (async () => {
try {
const body =
activeTab.target.source === "ume"
? {
ume_ne_id: activeTab.target.ume_ne_id || activeTab.target.id,
remote_path: remote,
file,
}
: { ne_id: activeTab.target.id, remote_path: remote, file };
await webcrtSftpUpload(body);
showOk(t("webcrt.sftp.uploaded"));
await refreshSftp();
} catch (err) {
showError(webcrtErrorMessage(err, t));
}
})();
e.target.value = "";
}}
/>
</label>
</div>
<ul className="webcrt-sftp__list">
{sftpItems.map((it) => (
<li key={it.name}>
<button
type="button"
onClick={() => {
if (it.is_dir) {
const next = `${sftpPath.replace(/\/$/, "")}/${it.name}`.replace(/^\.\//, "");
setSftpPath(next);
window.setTimeout(() => void refreshSftp(), 0);
return;
}
void (async () => {
try {
const body =
activeTab.target.source === "ume"
? {
ume_ne_id: activeTab.target.ume_ne_id || activeTab.target.id,
path: `${sftpPath.replace(/\/$/, "")}/${it.name}`,
}
: {
ne_id: activeTab.target.id,
path: `${sftpPath.replace(/\/$/, "")}/${it.name}`,
};
const blob = await webcrtSftpDownload(body);
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = it.name;
a.click();
URL.revokeObjectURL(url);
} catch (err) {
showError(webcrtErrorMessage(err, t));
}
})();
}}
>
{it.is_dir ? "📁" : "📄"} {it.name}
{!it.is_dir ? ` (${it.size})` : ""}
</button>
</li>
))}
</ul>
</div>
) : null}
</div>
</>
) : (

View file

@ -459,6 +459,8 @@ export type WebcrtSessionCreateResult = {
cols: number;
rows: number;
ws_path: string;
encoding?: string;
state?: string;
/** True when session used a vendor CLI hop (nested stelnet/telnet). */
cli_hop?: boolean;
};
@ -468,6 +470,9 @@ export const createWebcrtSession = (body: {
ume_ne_id?: string;
cols?: number;
rows?: number;
encoding?: string;
post_login_commands?: string[];
async_connect?: boolean;
}) => apiPost<WebcrtSessionCreateResult>("/v1/webcrt/sessions", body);
export const closeWebcrtSession = (sessionId: string) =>
@ -475,6 +480,66 @@ export const closeWebcrtSession = (sessionId: string) =>
`/v1/webcrt/sessions/${encodeURIComponent(sessionId)}`,
);
export type WebcrtSftpListResult = {
ne_id: string;
ne_name: string;
path: string;
items: Array<{ name: string; size: number; mtime: number; is_dir: boolean }>;
};
export const webcrtSftpList = (body: { ne_id?: string; ume_ne_id?: string; path?: string }) =>
apiPost<WebcrtSftpListResult>("/v1/webcrt/sftp/list", body);
export async function webcrtSftpDownload(body: {
ne_id?: string;
ume_ne_id?: string;
path: string;
}): Promise<Blob> {
const path = "/v1/webcrt/sftp/download";
const res = await fetch(path, {
method: "POST",
headers: authHeaders({ "Content-Type": "application/json" }),
body: JSON.stringify(body),
});
if (res.status === 401) {
handleUnauthorized(path);
throw new Error("401 unauthorized");
}
if (!res.ok) {
const text = await res.text();
throw new Error(text || `HTTP ${res.status}`);
}
return res.blob();
}
export async function webcrtSftpUpload(body: {
ne_id?: string;
ume_ne_id?: string;
remote_path: string;
file: File;
}): Promise<{ ok: boolean; path: string; size: number }> {
const path = "/v1/webcrt/sftp/upload";
const fd = new FormData();
if (body.ne_id) fd.append("ne_id", body.ne_id);
if (body.ume_ne_id) fd.append("ume_ne_id", body.ume_ne_id);
fd.append("remote_path", body.remote_path);
fd.append("file", body.file);
const res = await fetch(path, {
method: "POST",
headers: authHeaders(),
body: fd,
});
if (res.status === 401) {
handleUnauthorized(path);
throw new Error("401 unauthorized");
}
if (!res.ok) {
const text = await res.text();
throw new Error(text || `HTTP ${res.status}`);
}
return (await res.json()) as { ok: boolean; path: string; size: number };
}
export const webcrtWsUrl = (sessionId: string): string => {
const proto = window.location.protocol === "https:" ? "wss:" : "ws:";
const path = `/v1/webcrt/sessions/${encodeURIComponent(sessionId)}/ws`;