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

View file

@ -7,7 +7,7 @@ import json
import logging import logging
from typing import Any 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 pydantic import BaseModel, Field
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@ -21,6 +21,9 @@ from .webcrt_service import (
get_session, get_session,
list_sessions, list_sessions,
mark_attached, mark_attached,
wait_session_ready,
_decode_bytes,
_normalize_encoding,
) )
_log = logging.getLogger("netx.webcrt.router") _log = logging.getLogger("netx.webcrt.router")
@ -33,6 +36,22 @@ class WebcrtSessionCreate(BaseModel):
ume_ne_id: str | None = Field(default=None) ume_ne_id: str | None = Field(default=None)
cols: int = Field(default=80, ge=20, le=500) cols: int = Field(default=80, ge=20, le=500)
rows: int = Field(default=24, ge=5, le=200) 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: def _client_label(request: Request | None = None, websocket: WebSocket | None = None) -> str:
@ -66,6 +85,9 @@ def api_create_session(
cols=body.cols, cols=body.cols,
rows=body.rows, rows=body.rows,
client=_client_label(request=request), 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)) 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") @router.websocket("/sessions/{session_id}/ws")
async def websocket_session(websocket: WebSocket, session_id: str) -> None: async def websocket_session(websocket: WebSocket, session_id: str) -> None:
if bool(settings.auth_enabled): if bool(settings.auth_enabled):
@ -90,8 +167,6 @@ async def websocket_session(websocket: WebSocket, session_id: str) -> None:
if resolved is None: if resolved is None:
await websocket.close(code=4401) await websocket.close(code=4401)
return return
websocket.state.auth_user = resolved[0]
websocket.state.auth_via = resolved[1]
await websocket.accept() await websocket.accept()
attach_gen = 0 attach_gen = 0
@ -105,12 +180,13 @@ async def websocket_session(websocket: WebSocket, session_id: str) -> None:
await websocket.send_json( await websocket.send_json(
{ {
"type": "status", "type": "status",
"state": "connected", "state": "connecting" if sess.state == "connecting" else "connected",
"session_id": sess.session_id, "session_id": sess.session_id,
"ne_id": sess.ne_id, "ne_id": sess.ne_id,
"ne_name": sess.ne_name, "ne_name": sess.ne_name,
"ne_ip": sess.ne_ip, "ne_ip": sess.ne_ip,
"protocol": sess.protocol, "protocol": sess.protocol,
"encoding": sess.encoding,
"cols": sess.cols, "cols": sess.cols,
"rows": sess.rows, "rows": sess.rows,
"device_type": sess.device_type, "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). # Replay full login transcript (kept for StrictMode remount / brief reconnect).
bootstrap = bytes(sess.bootstrap_output or b"") bootstrap = bytes(sess.bootstrap_output or b"")
if bootstrap: if bootstrap:
try: try:
await websocket.send_json( if _normalize_encoding(sess.encoding) != "utf-8":
{"type": "stdout", "data": bootstrap.decode("utf-8", errors="replace")} bootstrap = _decode_bytes(bootstrap, sess.encoding).encode("utf-8", errors="replace")
) await websocket.send_bytes(bootstrap)
except Exception: 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() 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: async def pump_stdout() -> None:
loop = asyncio.get_running_loop() 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(): while not stop.is_set():
chunk = await loop.run_in_executor( 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": if chunk == "stale":
# Newer WS owns the session (StrictMode remount); exit without stealing bytes.
break break
if chunk == "empty": if chunk == "empty":
if pending and (loop.time() - last_flush) >= 0.016:
if not await _flush_pending():
stop.set()
break
continue continue
if chunk is None: if chunk is None:
await _flush_pending()
stop.set() stop.set()
try: try:
await websocket.send_json( await websocket.send_json(
@ -155,12 +323,11 @@ async def websocket_session(websocket: WebSocket, session_id: str) -> None:
except Exception: except Exception:
pass pass
break break
try: pending.append(chunk)
text = chunk.decode("utf-8", errors="replace") if sum(len(p) for p in pending) >= 8192 or (loop.time() - last_flush) >= 0.016:
await websocket.send_json({"type": "stdout", "data": text}) if not await _flush_pending():
except Exception: stop.set()
stop.set() break
break
reader_task = asyncio.create_task(pump_stdout()) reader_task = asyncio.create_task(pump_stdout())
if sess.needs_live_prompt: 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) _log.debug("webcrt live prompt sync failed session=%s", session_id, exc_info=True)
try: try:
while not stop.is_set(): 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: try:
msg = json.loads(raw) msg = json.loads(raw)
except json.JSONDecodeError: except json.JSONDecodeError:
# Treat plain text as stdin.
msg = {"type": "stdin", "data": raw} msg = {"type": "stdin", "data": raw}
mtype = str(msg.get("type") or "").strip().lower() mtype = str(msg.get("type") or "").strip().lower()
if mtype == "stdin": if mtype == "stdin":
data = msg.get("data") data = msg.get("data")
if data is None: if data is None:
continue continue
try: stdin_buf.append(str(data))
await asyncio.get_running_loop().run_in_executor( # Coalesce high-frequency keystrokes briefly.
None, sess.write_stdin, str(data) if len(stdin_buf) >= 8:
) if stdin_flush_task and not stdin_flush_task.done():
except Exception as exc: stdin_flush_task.cancel()
await websocket.send_json( await flush_stdin()
{"type": "status", "state": "error", "message": f"write_failed:{exc}"} elif stdin_flush_task is None or stdin_flush_task.done():
) stdin_flush_task = asyncio.create_task(schedule_stdin_flush())
break
elif mtype == "resize": elif mtype == "resize":
cols = int(msg.get("cols") or sess.cols) cols = int(msg.get("cols") or sess.cols)
rows = int(msg.get("rows") or sess.rows) rows = int(msg.get("rows") or sess.rows)
await asyncio.get_running_loop().run_in_executor(None, sess.resize, cols, 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": elif mtype == "ping":
sess.touch() sess.touch()
await websocket.send_json({"type": "pong"}) await websocket.send_json({"type": "pong"})
elif mtype == "close": elif mtype == "close":
stop.set() stop.set()
await flush_stdin()
close_session( close_session(
session_id, session_id,
reason="client_close", 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) _log.exception("webcrt ws error session=%s", session_id)
finally: finally:
stop.set() 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() reader_task.cancel()
try: try:
await reader_task await reader_task
except Exception: except Exception:
pass pass
# Keep device session briefly so React remount / blip can re-attach. # 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: if get_session(session_id) is not None:
detach_session( detach_session(
session_id, 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) 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.""" """Read whatever is already sitting on the channel after login."""
chunks: list[str] = [] chunks: list[str] = []
empty_streak = 0 empty_streak = 0
@ -173,7 +173,11 @@ def normalize_cli_transcript(text: str) -> str:
lines = s.split("\n") lines = s.split("\n")
while lines and not str(lines[-1]).strip(): while lines and not str(lines[-1]).strip():
lines.pop() 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() lines.pop()
return "\n".join(lines) return "\n".join(lines)
@ -187,25 +191,131 @@ def prepare_bootstrap_output(text: str) -> str:
return normalize_cli_transcript(text) return normalize_cli_transcript(text)
def _prime_interactive_channel(conn: ConnectHandler) -> None: def _drain_raw_channel(conn: ConnectHandler, *, duration: float = 0.5) -> None:
"""Send one RETURN after Netmiko login so the interactive channel is fully ready.""" """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: try:
_drain_channel(conn, rounds=4, wait=0.05) _drain_raw_channel(conn, duration=0.15)
except Exception: except Exception:
pass pass
try: if not already_prompted:
conn.write_channel(channel_return(conn))
except Exception:
try: try:
conn.write_channel("\n") conn.write_channel(channel_return(conn))
except Exception: except Exception:
return try:
conn.write_channel("\n")
except Exception:
return
try:
_drain_channel(conn, rounds=6, wait=0.08)
except Exception:
pass
try: try:
_drain_channel(conn, rounds=8, wait=0.1) _drain_raw_channel(conn, duration=0.35)
except Exception: except Exception:
pass 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: def _utc_now() -> datetime:
return datetime.now(timezone.utc) return datetime.now(timezone.utc)
@ -221,6 +331,12 @@ def webcrt_data_root() -> Path:
return root.resolve() 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: def _audit(event: str, **fields: Any) -> None:
record = {"ts": _utc_iso(), "event": event, **fields} record = {"ts": _utc_iso(), "event": event, **fields}
try: try:
@ -244,6 +360,7 @@ class WebcrtSession:
device_type: str = "" device_type: str = ""
vendor: str = "" vendor: str = ""
cli_keymap: bool = True cli_keymap: bool = True
encoding: str = "utf-8"
conn: ConnectHandler | None = None conn: ConnectHandler | None = None
created_at: float = field(default_factory=time.time) created_at: float = field(default_factory=time.time)
last_activity: 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 detach_deadline: float | None = None
closed: bool = False closed: bool = False
close_reason: str = "" 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"" bootstrap_output: bytes = b""
needs_live_prompt: bool = True needs_live_prompt: bool = True
# React StrictMode remounts open a second WS before the first fully tears down. # React StrictMode remounts open a second WS before the first fully tears down.
# Only the newest attach_gen may consume out_queue / mark detach. # Only the newest attach_gen may consume out_queue / mark detach.
attach_gen: int = 0 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. # Vendor CLI hop (Huawei/ZTE/Cisco): close when nested target session returns to hop.
cli_hop_guard: bool = False cli_hop_guard: bool = False
cli_hop_prompt: str = "" 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) _reader: threading.Thread | None = field(default=None, repr=False)
_write_lock: threading.Lock = field(default_factory=threading.Lock, repr=False) _write_lock: threading.Lock = field(default_factory=threading.Lock, repr=False)
_stdout_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) _hop_scan_buf: str = field(default="", repr=False)
_cli_hop_seen_other_prompt: bool = field(default=False, 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: def touch(self) -> None:
self.last_activity = time.time() 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: def take_stdout(self, attach_gen: int, *, timeout: float = 0.25) -> bytes | None | str:
"""Exclusive stdout take for one WS attach generation. """Exclusive stdout take for one WS attach generation.
@ -295,7 +457,7 @@ class WebcrtSession:
return chunk # bytes | None return chunk # bytes | None
if time.time() >= deadline: if time.time() >= deadline:
return "empty" return "empty"
time.sleep(0.02) time.sleep(0.005)
def write_stdin(self, data: str) -> None: def write_stdin(self, data: str) -> None:
if self.closed or self.conn is None: if self.closed or self.conn is None:
@ -315,12 +477,10 @@ class WebcrtSession:
return return
with self._write_lock: with self._write_lock:
# Prefer raw channel I/O for interactive typing (char echo / backspace). # 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) channel = getattr(self.conn, "remote_conn", None)
try: try:
if channel is not None and hasattr(channel, "send") and callable(channel.send): 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. # Paramiko may write partially when the window is full.
view = memoryview(payload) view = memoryview(payload)
while len(view): while len(view):
@ -329,14 +489,47 @@ class WebcrtSession:
time.sleep(0.01) time.sleep(0.01)
continue continue
view = view[n:] view = view[n:]
self.bytes_in += len(payload)
elif channel is not None and hasattr(channel, "write") and callable(channel.write): elif channel is not None and hasattr(channel, "write") and callable(channel.write):
encoding = getattr(self.conn, "encoding", None) or "utf-8" payload = _encode_text(text, self.encoding)
channel.write(text.encode(encoding, errors="replace") if isinstance(text, str) else text) channel.write(payload)
self.bytes_in += len(payload)
else: else:
self.conn.write_channel(text) self.conn.write_channel(text)
self.bytes_in += len(text)
except Exception: except Exception:
self.conn.write_channel(text) self.conn.write_channel(text)
self.bytes_in += len(text)
self.touch() 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: def resize(self, cols: int, rows: int) -> None:
if self.closed or self.conn is None: if self.closed or self.conn is None:
return return
@ -369,53 +562,76 @@ class WebcrtSession:
return return
channel = getattr(conn, "remote_conn", None) channel = getattr(conn, "remote_conn", None)
hop_return = False hop_return = False
poll = max(0.002, float(getattr(settings, "webcrt_reader_poll_sec", 0.01) or 0.01))
try: try:
while not self.closed: while not self.closed:
chunk = b"" chunk = b""
try: try:
if channel is not None and hasattr(channel, "recv_ready") and hasattr(channel, "recv"): if channel is not None and hasattr(channel, "recv_ready") and hasattr(channel, "recv"):
# Paramiko SSH: raw bytes keep ANSI / backspace echo intact. # Paramiko SSH: prefer short blocking recv over fixed spin-sleep.
if channel.recv_ready(): ready = False
chunk = channel.recv(4096) try:
ready = bool(channel.recv_ready())
except Exception:
ready = False
if ready:
chunk = channel.recv(16384)
if not chunk: if not chunk:
break break
elif hasattr(channel, "exit_status_ready") and channel.exit_status_ready(): elif hasattr(channel, "exit_status_ready") and channel.exit_status_ready():
break break
else: else:
time.sleep(0.04) # Brief block: settimeout + recv wakes sooner than sleep(0.04).
continue 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"): elif channel is not None and hasattr(channel, "read_very_eager"):
# Telnet: do NOT use conn.read_channel() — Netmiko strips ANSI # Telnet: do NOT use conn.read_channel() — Netmiko strips ANSI.
# escape codes, which removes Huawei backspace echo (\x1b[1D \x1b[1D).
data = channel.read_very_eager() data = channel.read_very_eager()
if data: if data:
chunk = ( chunk = (
data data
if isinstance(data, (bytes, bytearray)) if isinstance(data, (bytes, bytearray))
else str(data).encode( else _encode_text(str(data), self.encoding)
getattr(conn, "encoding", None) or "utf-8",
errors="replace",
)
) )
else: else:
time.sleep(0.04) time.sleep(poll)
continue continue
else: else:
text = conn.read_channel() text = conn.read_channel()
if text: if text:
chunk = text.encode("utf-8", errors="replace") chunk = _encode_text(str(text), self.encoding)
else: else:
time.sleep(0.04) time.sleep(poll)
continue continue
except Exception as exc: except Exception as exc:
if self.closed: if self.closed:
break break
_log.debug("webcrt reader error session=%s: %s", self.session_id, exc) _log.debug("webcrt reader error session=%s: %s", self.session_id, exc)
time.sleep(0.1) time.sleep(0.05)
continue continue
if chunk: if chunk:
self.touch() self.touch()
self.bytes_out += len(chunk)
self.out_queue.put(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): if self.cli_hop_guard and self._note_cli_hop_output(chunk):
hop_return = True hop_return = True
notice = ( notice = (
@ -426,7 +642,6 @@ class WebcrtSession:
break break
finally: finally:
if hop_return and not self.closed: if hop_return and not self.closed:
# Prefer registry close for audit + remove; fall back to local close.
try: try:
close_session(self.session_id, reason="cli_hop_return") close_session(self.session_id, reason="cli_hop_return")
except Exception: except Exception:
@ -436,12 +651,10 @@ class WebcrtSession:
def _note_cli_hop_output(self, chunk: bytes) -> bool: def _note_cli_hop_output(self, chunk: bytes) -> bool:
"""Accumulate stdout and return True when nested CLI hop has returned to proxy.""" """Accumulate stdout and return True when nested CLI hop has returned to proxy."""
try: try:
text = chunk.decode("utf-8", errors="replace") text = _decode_bytes(chunk, self.encoding)
except Exception: except Exception:
text = str(chunk) text = str(chunk)
self._hop_scan_buf = (self._hop_scan_buf + text)[-12000:] 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() marker = str(self.cli_hop_prompt or "").strip()
last = extract_cli_prompt_marker(self._hop_scan_buf) last = extract_cli_prompt_marker(self._hop_scan_buf)
if last and (not marker or last != marker): if last and (not marker or last != marker):
@ -452,11 +665,25 @@ class WebcrtSession:
seen_other_prompt=self._cli_hop_seen_other_prompt, 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: def close(self, reason: str = "closed") -> None:
if self.closed: if self.closed:
return return
self.closed = True self.closed = True
self.state = "closed"
self.close_reason = reason or "closed" self.close_reason = reason or "closed"
self._ready_event.set()
try: try:
close_netmiko_connection(self.conn) close_netmiko_connection(self.conn)
except Exception: except Exception:
@ -466,6 +693,7 @@ class WebcrtSession:
self.out_queue.put_nowait(None) self.out_queue.put_nowait(None)
except Exception: except Exception:
pass pass
self.close_session_log()
def _ensure_reaper() -> None: def _ensure_reaper() -> None:
@ -490,16 +718,32 @@ def _reaper_loop() -> None:
def _reap_sessions() -> None: def _reap_sessions() -> None:
idle = max(60, int(settings.webcrt_idle_timeout_sec or 1800)) idle = max(60, int(settings.webcrt_idle_timeout_sec or 1800))
attach = max(10, int(settings.webcrt_attach_timeout_sec or 60)) 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() now = time.time()
to_close: list[tuple[WebcrtSession, str]] = [] to_close: list[tuple[WebcrtSession, str]] = []
to_nudge: list[WebcrtSession] = []
with _sessions_lock: with _sessions_lock:
for sess in list(_sessions.values()): for sess in list(_sessions.values()):
if sess.closed: if sess.closed:
_sessions.pop(sess.session_id, None) _sessions.pop(sess.session_id, None)
continue 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 sess.attached:
if (now - sess.last_activity) > idle: if (now - sess.last_activity) > idle:
to_close.append((sess, "idle_timeout")) 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 continue
# Not attached: either never attached, or briefly detached for reconnect. # Not attached: either never attached, or briefly detached for reconnect.
if sess.detach_deadline is not None: if sess.detach_deadline is not None:
@ -509,6 +753,18 @@ def _reap_sessions() -> None:
to_close.append((sess, "attach_timeout")) to_close.append((sess, "attach_timeout"))
elif (now - sess.last_activity) > idle: elif (now - sess.last_activity) > idle:
to_close.append((sess, "idle_timeout")) 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: for sess, reason in to_close:
close_session(sess.session_id, reason=reason) close_session(sess.session_id, reason=reason)
@ -526,6 +782,21 @@ def get_session(session_id: str) -> WebcrtSession | None:
return sess 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: def _webcrt_creds_ready(creds: dict[str, Any]) -> bool:
"""True when WebCRT can open a session with the resolved credentials. """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 "")) 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( def create_session(
db: Session, db: Session,
*, *,
@ -555,6 +953,9 @@ def create_session(
cols: int = 80, cols: int = 80,
rows: int = 24, rows: int = 24,
client: str = "", client: str = "",
encoding: str = "utf-8",
post_login_commands: list[str] | None = None,
async_connect: bool = True,
) -> dict[str, Any]: ) -> dict[str, Any]:
from .cli_resolve import resolve_cli_target 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 "") device_type = str(device.get("device_type") or creds.get("device_type") or "")
vendor = str(device.get("vendor") or creds.get("vendor") or "") vendor = str(device.get("vendor") or creds.get("vendor") or "")
cli_keymap = uses_network_cli_keymap(device_type, vendor) 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( sess = WebcrtSession(
session_id=session_id, session_id=session_id,
ne_id=target_id, ne_id=target_id,
@ -656,34 +1002,51 @@ def create_session(
device_type=device_type, device_type=device_type,
vendor=vendor, vendor=vendor,
cli_keymap=cli_keymap, cli_keymap=cli_keymap,
conn=conn, encoding=enc,
bootstrap_output=str(bootstrap or "").encode("utf-8", errors="replace"), state="connecting",
# Only nudge a live prompt when transcript has no recognizable prompt yet. post_login_commands=list(post_login_commands or [])[:20],
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 ""),
) )
# Keep bootstrap for WS attach replay; do not rely solely on out_queue (StrictMode remount).
sess.start_reader()
with _sessions_lock: with _sessions_lock:
_sessions[session_id] = sess _sessions[session_id] = sess
_audit( _audit(
"session_created", "session_connecting",
session_id=session_id, session_id=session_id,
ne_id=sess.ne_id, ne_id=sess.ne_id,
ne_name=sess.ne_name,
ne_ip=sess.ne_ip, ne_ip=sess.ne_ip,
protocol=sess.protocol, protocol=sess.protocol,
source=str(device.get("source") or ""), encoding=enc,
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 "", 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 { return {
"session_id": session_id, "session_id": session_id,
"ne_id": sess.ne_id, "ne_id": sess.ne_id,
@ -693,8 +1056,10 @@ def create_session(
"protocol": sess.protocol, "protocol": sess.protocol,
"cols": sess.cols, "cols": sess.cols,
"rows": sess.rows, "rows": sess.rows,
"encoding": enc,
"state": sess.state,
"ws_path": f"/v1/webcrt/sessions/{session_id}/ws", "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_id=sess.ne_id,
ne_ip=sess.ne_ip, ne_ip=sess.ne_ip,
attach_gen=attach_gen, attach_gen=attach_gen,
state=sess.state,
) )
return sess, attach_gen return sess, attach_gen
@ -769,6 +1135,9 @@ def close_session(session_id: str, *, reason: str = "closed", client: str = "")
ne_ip=sess.ne_ip, ne_ip=sess.ne_ip,
reason=reason, reason=reason,
client=client or "", 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(), active=active_session_count(),
) )
return {"ok": True, "session_id": session_id, "closed": True, "reason": reason} 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_name": s.ne_name,
"ne_ip": s.ne_ip, "ne_ip": s.ne_ip,
"protocol": s.protocol, "protocol": s.protocol,
"encoding": s.encoding,
"state": s.state,
"attached": s.attached, "attached": s.attached,
"created_at": datetime.fromtimestamp(s.created_at, tz=timezone.utc).isoformat(), "created_at": datetime.fromtimestamp(s.created_at, tz=timezone.utc).isoformat(),
"last_activity": datetime.fromtimestamp(s.last_activity, 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() for s in _sessions.values()
if not s.closed if not s.closed
@ -794,5 +1174,7 @@ def list_sessions() -> dict[str, Any]:
"total": len(items), "total": len(items),
"max_sessions": max(1, int(settings.webcrt_max_sessions or 20)), "max_sessions": max(1, int(settings.webcrt_max_sessions or 20)),
"idle_timeout_sec": max(60, int(settings.webcrt_idle_timeout_sec or 1800)), "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, "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: def __init__(self) -> None:
self.written: list[str] = [] self.written: list[str] = []
self.RETURN = "\n" 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.recv_ready.return_value = False
self.remote_conn.exit_status_ready.return_value = False self.remote_conn.exit_status_ready.return_value = False
self.remote_conn.resize_pty = MagicMock() 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: def write_channel(self, data: str) -> None:
self.written.append(data) self.written.append(data)
@ -68,6 +71,9 @@ class WebcrtServiceTests(unittest.TestCase):
self.assertEqual(conn.written[-1], "\x1b[D") self.assertEqual(conn.written[-1], "\x1b[D")
sess.resize(120, 40) sess.resize(120, 40)
conn.remote_conn.resize_pty.assert_called_with(width=120, height=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") sess.close("test")
self.assertTrue(sess.closed) 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.normalize_cli_transcript("banner\nR2#R2#"), "banner\nR2#")
self.assertEqual(svc.prepare_bootstrap_output("login\nR2#\nR2#"), "login\nR2#") self.assertEqual(svc.prepare_bootstrap_output("login\nR2#\nR2#"), "login\nR2#")
self.assertTrue(svc.prepare_bootstrap_output("login\nR2#").endswith("R2#")) 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, "_audit")
@patch.object(svc, "open_netmiko_connection") @patch.object(svc, "open_netmiko_connection")
@ -120,11 +131,12 @@ class WebcrtServiceTests(unittest.TestCase):
db = MagicMock() db = MagicMock()
with patch.object(svc.settings, "webcrt_max_sessions", 1): 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.assertIn("session_id", out)
self.assertEqual(out.get("state"), "ready")
self.assertFalse(out.get("cli_hop")) self.assertFalse(out.get("cli_hop"))
with self.assertRaises(HTTPException) as ctx: 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) self.assertEqual(ctx.exception.status_code, 429)
@patch.object(svc, "_audit") @patch.object(svc, "_audit")
@ -172,7 +184,7 @@ class WebcrtServiceTests(unittest.TestCase):
mock_open.side_effect = _open_with_log mock_open.side_effect = _open_with_log
db = MagicMock() 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() mock_open.assert_called_once()
called_creds = mock_open.call_args.args[0] called_creds = mock_open.call_args.args[0]
self.assertTrue(called_creds["hop_enabled"]) self.assertTrue(called_creds["hop_enabled"])
@ -222,13 +234,15 @@ class WebcrtServiceTests(unittest.TestCase):
) )
mock_open.side_effect = lambda *a, **k: _FakeConn() mock_open.side_effect = lambda *a, **k: _FakeConn()
mock_guard.return_value = {"hop_prompt": "<HOP>", "hop_vendor": "huawei", "hop_host": "1.1.1.1"} 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") out = svc.create_session(
self.assertTrue(out.get("cli_hop")) MagicMock(), ne_id="ne-cli-hop", cols=100, rows=30, client="test", async_connect=False
self.assertEqual(mock_open.call_args.kwargs.get("cols"), 100) )
self.assertEqual(mock_open.call_args.kwargs.get("rows"), 30)
sess = svc.get_session(out["session_id"]) sess = svc.get_session(out["session_id"])
assert sess is not None assert sess is not None
self.assertTrue(sess.cli_hop_guard) 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>") self.assertEqual(sess.cli_hop_prompt, "<HOP>")
svc.close_session(out["session_id"], reason="test") svc.close_session(out["session_id"], reason="test")
@ -265,7 +279,9 @@ class WebcrtServiceTests(unittest.TestCase):
}, },
) )
mock_open.return_value = _FakeConn() 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() mock_open.assert_called_once()
self.assertEqual(out["ne_id"], "ne-bastion") self.assertEqual(out["ne_id"], "ne-bastion")
svc.close_session(out["session_id"], reason="test") 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") 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__": if __name__ == "__main__":
unittest.main() unittest.main()

View file

@ -139,12 +139,14 @@ src/
## WebCRT ## 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`) - 目标列表复用 `/v1/cli/targets`(托管 + UME,搜索分页;`source=all|managed|ume`)
- 凭据:托管走网元自身账号;UME 走 CLI 连接模板(`resolve_cli_target`) - 凭据:托管走网元自身账号;UME 走 CLI 连接模板(`resolve_cli_target`)
- 前端:CRT 风格左右分栏(会话管理 + 多标签终端) - 前端:CRT 风格左右分栏(收藏/最近 + 多标签终端);粘贴节流、选区复制、Break、Ctrl+F、Button Bar、广播、编码/字号
- 审计:`NETX_WEBCRT_DATA_DIR`(默认 `data/webcrt/audit.jsonl`) - WS:stdout 二进制帧合批;stdin 短合批;建连可异步(POST 立即返回,进度经 WS `connecting`→`connected`)
- 限流:`NETX_WEBCRT_MAX_SESSIONS`、`NETX_WEBCRT_IDLE_TIMEOUT_SEC` - 审计 / 会话日志:`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 ## 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 { FitAddon } from "@xterm/addon-fit";
import { Terminal } from "@xterm/xterm"; import { Terminal } from "@xterm/xterm";
import "@xterm/xterm/css/xterm.css"; import "@xterm/xterm/css/xterm.css";
@ -6,9 +6,14 @@ import "@xterm/xterm/css/xterm.css";
export type WebTerminalHandle = { export type WebTerminalHandle = {
clear: () => void; clear: () => void;
copyAll: () => Promise<string>; copyAll: () => Promise<string>;
copySelection: () => Promise<string>;
getText: () => string; getText: () => string;
fit: () => void; fit: () => void;
focus: () => void; focus: () => void;
sendBreak: () => void;
sendText: (data: string, opts?: { throttle?: boolean }) => void;
findNext: (term: string) => void;
findPrevious: (term: string) => void;
}; };
type Props = { type Props = {
@ -16,6 +21,9 @@ type Props = {
title?: string; title?: string;
recording?: boolean; recording?: boolean;
autoFocus?: boolean; autoFocus?: boolean;
encoding?: string;
pasteDelayMs?: number;
copyOnSelect?: boolean;
onStatus?: (state: string, message?: string) => void; onStatus?: (state: string, message?: string) => void;
onReady?: () => void; onReady?: () => void;
onStdout?: (data: string) => void; onStdout?: (data: string) => void;
@ -35,11 +43,10 @@ function serializeTerminal(term: Terminal): string {
function isSidebarSearchTarget(target: EventTarget | null): boolean { function isSidebarSearchTarget(target: EventTarget | null): boolean {
if (!(target instanceof HTMLElement)) return false; if (!(target instanceof HTMLElement)) return false;
if (target.tagName === "INPUT" || target.tagName === "TEXTAREA" || target.tagName === "SELECT") { 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; if (target.classList.contains("xterm-helper-textarea")) return false;
return true; 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 { 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. */ /** Map a browser key event to the bytes xterm/onData would normally emit. */
function keyEventToStdin(e: KeyboardEvent): string | null { function keyEventToStdin(e: KeyboardEvent): string | null {
if (e.ctrlKey || e.altKey || e.metaKey) return 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 === "Enter") return "\r";
if (e.key === "Tab") return "\t"; if (e.key === "Tab") return "\t";
if (e.key === "Escape") return "\x1b"; if (e.key === "Escape") return "\x1b";
@ -64,19 +71,65 @@ function keyEventToStdin(e: KeyboardEvent): string | null {
return 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( 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, ref,
) { ) {
const hostRef = useRef<HTMLDivElement | null>(null); const hostRef = useRef<HTMLDivElement | null>(null);
const termRef = useRef<Terminal | null>(null); const termRef = useRef<Terminal | null>(null);
const fitRef = useRef<FitAddon | null>(null); const fitRef = useRef<FitAddon | null>(null);
const wsRef = useRef<WebSocket | null>(null); const wsRef = useRef<WebSocket | null>(null);
const findIndexRef = useRef(0);
const onStatusRef = useRef(onStatus); const onStatusRef = useRef(onStatus);
const onReadyRef = useRef(onReady); const onReadyRef = useRef(onReady);
const onStdoutRef = useRef(onStdout); const onStdoutRef = useRef(onStdout);
const recordingRef = useRef(!!recording); const recordingRef = useRef(!!recording);
const autoFocusRef = useRef(autoFocus); 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(() => { useEffect(() => {
onStatusRef.current = onStatus; onStatusRef.current = onStatus;
@ -92,6 +145,18 @@ export const WebTerminal = forwardRef<WebTerminalHandle, Props>(function WebTerm
autoFocusRef.current = autoFocus; autoFocusRef.current = autoFocus;
}, [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 = () => { const focusTerminal = () => {
try { try {
termRef.current?.focus(); 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, () => ({ useImperativeHandle(ref, () => ({
clear: () => { clear: () => {
termRef.current?.clear(); termRef.current?.clear();
@ -112,6 +217,13 @@ export const WebTerminal = forwardRef<WebTerminalHandle, Props>(function WebTerm
} }
return text; 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) : ""), getText: () => (termRef.current ? serializeTerminal(termRef.current) : ""),
fit: () => { fit: () => {
try { try {
@ -121,6 +233,39 @@ export const WebTerminal = forwardRef<WebTerminalHandle, Props>(function WebTerm
} }
}, },
focus: focusTerminal, 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(() => { useEffect(() => {
@ -131,10 +276,12 @@ export const WebTerminal = forwardRef<WebTerminalHandle, Props>(function WebTerm
cursorBlink: true, cursorBlink: true,
fontSize: 13, fontSize: 13,
fontFamily: 'Consolas, "Courier New", monospace', fontFamily: 'Consolas, "Courier New", monospace',
scrollback: 10000,
theme: { theme: {
background: "#0b1220", background: "#0b1220",
foreground: "#e2e8f0", foreground: "#e2e8f0",
cursor: "#e2e8f0", cursor: "#e2e8f0",
selectionBackground: "#334155",
}, },
convertEol: true, convertEol: true,
}); });
@ -155,7 +302,6 @@ export const WebTerminal = forwardRef<WebTerminalHandle, Props>(function WebTerm
if (!autoFocusRef.current) return; if (!autoFocusRef.current) return;
focusTerminal(); focusTerminal();
}; };
// Focus immediately so the first keystroke is not lost to the sidebar/body.
maybeFocus(); maybeFocus();
requestAnimationFrame(() => { requestAnimationFrame(() => {
doFit(); doFit();
@ -167,22 +313,10 @@ export const WebTerminal = forwardRef<WebTerminalHandle, Props>(function WebTerm
}); });
const ws = new WebSocket(wsUrl); const ws = new WebSocket(wsUrl);
ws.binaryType = "arraybuffer";
wsRef.current = ws; wsRef.current = ws;
onStatusRef.current?.("connecting"); 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) => { const writeStdout = (raw: string) => {
if (raw) term.write(raw); if (raw) term.write(raw);
if (recordingRef.current) onStdoutRef.current?.(raw); if (recordingRef.current) onStdoutRef.current?.(raw);
@ -201,6 +335,18 @@ export const WebTerminal = forwardRef<WebTerminalHandle, Props>(function WebTerm
}; };
ws.onmessage = (ev) => { 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 { try {
const msg = JSON.parse(String(ev.data || "{}")) as { const msg = JSON.parse(String(ev.data || "{}")) as {
type?: string; type?: string;
@ -215,7 +361,7 @@ export const WebTerminal = forwardRef<WebTerminalHandle, Props>(function WebTerm
} }
if (msg.type === "status") { if (msg.type === "status") {
onStatusRef.current?.(String(msg.state || ""), msg.message); onStatusRef.current?.(String(msg.state || ""), msg.message);
if (msg.state === "connected") { if (msg.state === "connected" || msg.state === "connecting") {
maybeFocus(); maybeFocus();
return; return;
} }
@ -243,27 +389,41 @@ export const WebTerminal = forwardRef<WebTerminalHandle, Props>(function WebTerm
} }
}; };
// When focused, xterm onData sends keystrokes.
const dataDisposable = term.onData((data) => { 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"); 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) => { const onKeyDownCapture = (e: KeyboardEvent) => {
if (!autoFocusRef.current) return; if (!autoFocusRef.current) return;
if (host.closest("[hidden]")) return; if (host.closest("[hidden]")) return;
if (isSidebarSearchTarget(e.target)) 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") { if (e.key === "Backspace") {
e.preventDefault(); e.preventDefault();
} }
if (isXtermTextarea(e.target)) { if (isXtermTextarea(e.target)) {
// Let xterm onData handle it; we only prevented browser back above.
maybeFocus(); maybeFocus();
return; return;
} }
@ -274,10 +434,31 @@ export const WebTerminal = forwardRef<WebTerminalHandle, Props>(function WebTerm
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
maybeFocus(); maybeFocus();
sendStdin(data); sendStdinImmediate(data);
}; };
window.addEventListener("keydown", onKeyDownCapture, true); 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(() => { const pingTimer = window.setInterval(() => {
sendJson({ type: "ping" }); sendJson({ type: "ping" });
}, 25000); }, 25000);
@ -297,8 +478,11 @@ export const WebTerminal = forwardRef<WebTerminalHandle, Props>(function WebTerm
window.clearInterval(pingTimer); window.clearInterval(pingTimer);
window.removeEventListener("resize", onWinResize); window.removeEventListener("resize", onWinResize);
window.removeEventListener("keydown", onKeyDownCapture, true); window.removeEventListener("keydown", onKeyDownCapture, true);
window.removeEventListener("paste", onPasteCapture, true);
host.removeEventListener("contextmenu", onContextMenu);
ro?.disconnect(); ro?.disconnect();
dataDisposable.dispose(); dataDisposable.dispose();
selDisposable.dispose();
try { try {
ws.close(); ws.close();
} catch { } catch {
@ -311,13 +495,147 @@ export const WebTerminal = forwardRef<WebTerminalHandle, Props>(function WebTerm
}; };
}, [wsUrl, title]); }, [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 ( return (
<div <div className="webcrt-term-wrap">
className="webcrt-term" {pasteStatus ? (
ref={hostRef} <div className="webcrt-paste-status" role="status" aria-live="polite">
onMouseDown={() => { 粘贴中 {pasteStatus.done}/{pasteStatus.total} 行…
focusTerminal(); </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", copied: "Copied to clipboard",
copyEmpty: "Terminal is empty", copyEmpty: "Terminal is empty",
copyFailed: "Copy failed", 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: { err: {
sessionLimit: "Session limit reached; close other terminals first", sessionLimit: "Session limit reached; close other terminals first",
@ -946,6 +965,8 @@ const en = {
credsIncomplete: "Incomplete username/password on this NE", credsIncomplete: "Incomplete username/password on this NE",
cliProfile: "UME NEs require a CLI connect profile (default account / hop)", cliProfile: "UME NEs require a CLI connect profile (default account / hop)",
connectFailed: "Device login failed: {{detail}}", connectFailed: "Device login failed: {{detail}}",
sftpHop: "SFTP does not support hop devices yet (direct SSH only)",
sftpSsh: "SFTP requires SSH",
}, },
}, },
topology: { topology: {

View file

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

View file

@ -2289,7 +2289,8 @@ pre {
display: none !important; display: none !important;
} }
.webcrt-main__pane .webcrt-term { .webcrt-main__pane .webcrt-term,
.webcrt-main__pane .webcrt-term-wrap {
flex: 1; flex: 1;
width: 100%; width: 100%;
height: auto !important; height: auto !important;
@ -2332,6 +2333,284 @@ pre {
display: none; 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) { @media (max-width: 900px) {
.webcrt-shell { .webcrt-shell {
grid-template-columns: 1fr; grid-template-columns: 1fr;

View file

@ -9,6 +9,9 @@ import {
createWebcrtSession, createWebcrtSession,
fetchCliTargets, fetchCliTargets,
fetchManagedNeById, fetchManagedNeById,
webcrtSftpDownload,
webcrtSftpList,
webcrtSftpUpload,
webcrtWsUrl, webcrtWsUrl,
} from "../services/api"; } from "../services/api";
import { pageCount } from "../utils/display"; 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("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("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("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; return raw;
} }
@ -76,6 +81,9 @@ export function WebcrtPage() {
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const [tabs, setTabs] = useState<TermTab[]>([]); const [tabs, setTabs] = useState<TermTab[]>([]);
const [activeTabKey, setActiveTabKey] = useState(""); 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 connectingKeysRef = useRef<Set<string>>(new Set());
const tabsRef = useRef<TermTab[]>([]); const tabsRef = useRef<TermTab[]>([]);
tabsRef.current = tabs; tabsRef.current = tabs;
@ -145,8 +153,18 @@ export function WebcrtPage() {
const rows = Math.max(24, Math.floor((window.innerHeight - 180) / 18)); const rows = Math.max(24, Math.floor((window.innerHeight - 180) / 18));
const body = const body =
target.source === "ume" 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 sess = await createWebcrtSession(body);
const wsUrl = webcrtWsUrl(sess.session_id); const wsUrl = webcrtWsUrl(sess.session_id);
updateTab(key, { updateTab(key, {
@ -228,7 +246,9 @@ export function WebcrtPage() {
const tab = tabsRef.current.find((x) => x.key === activeTabKey); const tab = tabsRef.current.find((x) => x.key === activeTabKey);
if (!tab) return; if (!tab) return;
try { 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")); if (text) showOk(t("webcrt.actions.copied"));
else showError(t("webcrt.actions.copyEmpty")); else showError(t("webcrt.actions.copyEmpty"));
} catch { } catch {
@ -236,6 +256,22 @@ export function WebcrtPage() {
} }
}, [activeTabKey, showOk, showError, t]); }, [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=... // Auto-connect from /webcrt?ne_id=...
useEffect(() => { useEffect(() => {
if (!presetNeId) return; if (!presetNeId) return;
@ -292,6 +328,35 @@ export function WebcrtPage() {
const activeTab = tabs.find((x) => x.key === activeTabKey) || null; 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 ( return (
<div className="webcrt-shell"> <div className="webcrt-shell">
<aside className="webcrt-sidebar"> <aside className="webcrt-sidebar">
@ -326,39 +391,7 @@ export function WebcrtPage() {
{!targetsQuery.isLoading && items.length === 0 ? ( {!targetsQuery.isLoading && items.length === 0 ? (
<div className="webcrt-tree__empty">{t("webcrt.empty")}</div> <div className="webcrt-tree__empty">{t("webcrt.empty")}</div>
) : null} ) : null}
<ul className="webcrt-tree__list"> <ul className="webcrt-tree__list">{items.map((row) => renderDeviceRow(row))}</ul>
{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>
</div> </div>
<div className="webcrt-sidebar__pager"> <div className="webcrt-sidebar__pager">
<span>{t("common.pagerMeta", { total, page, pages })}</span> <span>{t("common.pagerMeta", { total, page, pages })}</span>
@ -435,6 +468,16 @@ export function WebcrtPage() {
<span aria-hidden>⧉</span> <span aria-hidden>⧉</span>
{t("webcrt.actions.copy")} {t("webcrt.actions.copy")}
</button> </button>
<button
type="button"
className={`webcrt-action-btn${sftpOpen ? " is-active" : ""}`}
onClick={() => {
setSftpOpen((v) => !v);
if (!sftpOpen) void refreshSftp();
}}
>
SFTP
</button>
</div> </div>
) : null} ) : null}
<div className="webcrt-main__body"> <div className="webcrt-main__body">
@ -476,6 +519,8 @@ export function WebcrtPage() {
if (activeTabKey === tab.key) { if (activeTabKey === tab.key) {
window.setTimeout(() => termRefs.current.get(tab.key)?.focus(), 40); 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 === "error") updateTab(tab.key, { status: "error" });
else if (state === "closed") updateTab(tab.key, { status: "closed" }); else if (state === "closed") updateTab(tab.key, { status: "closed" });
}} }}
@ -483,6 +528,89 @@ export function WebcrtPage() {
) : null} ) : null}
</div> </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> </div>
</> </>
) : ( ) : (

View file

@ -459,6 +459,8 @@ export type WebcrtSessionCreateResult = {
cols: number; cols: number;
rows: number; rows: number;
ws_path: string; ws_path: string;
encoding?: string;
state?: string;
/** True when session used a vendor CLI hop (nested stelnet/telnet). */ /** True when session used a vendor CLI hop (nested stelnet/telnet). */
cli_hop?: boolean; cli_hop?: boolean;
}; };
@ -468,6 +470,9 @@ export const createWebcrtSession = (body: {
ume_ne_id?: string; ume_ne_id?: string;
cols?: number; cols?: number;
rows?: number; rows?: number;
encoding?: string;
post_login_commands?: string[];
async_connect?: boolean;
}) => apiPost<WebcrtSessionCreateResult>("/v1/webcrt/sessions", body); }) => apiPost<WebcrtSessionCreateResult>("/v1/webcrt/sessions", body);
export const closeWebcrtSession = (sessionId: string) => export const closeWebcrtSession = (sessionId: string) =>
@ -475,6 +480,66 @@ export const closeWebcrtSession = (sessionId: string) =>
`/v1/webcrt/sessions/${encodeURIComponent(sessionId)}`, `/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 => { export const webcrtWsUrl = (sessionId: string): string => {
const proto = window.location.protocol === "https:" ? "wss:" : "ws:"; const proto = window.location.protocol === "https:" ? "wss:" : "ws:";
const path = `/v1/webcrt/sessions/${encodeURIComponent(sessionId)}/ws`; const path = `/v1/webcrt/sessions/${encodeURIComponent(sessionId)}/ws`;