netx/netx_api/webcrt_service.py
oliver 873f2bfe34 feat(webcrt): add CRT-style browser terminal for managed and UME NEs
Ship an ops WebCRT module with xterm.js UI, WebSocket session bridge reusing hop/bastion login, searchable paged targets, and session audit limits.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-29 17:23:45 +08:00

378 lines
12 KiB
Python

"""Interactive WebCRT sessions: bridge browser WebSocket <-> Netmiko device channel."""
from __future__ import annotations
import json
import logging
import queue
import threading
import time
import uuid
from dataclasses import dataclass, field
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
from fastapi import HTTPException
from netmiko import ConnectHandler
from sqlalchemy.orm import Session
from .config import settings
from .ne_crypto import CredentialCryptoError
from .ne_session_factory import close_netmiko_connection, open_netmiko_connection
_log = logging.getLogger("netx.webcrt")
_sessions_lock = threading.Lock()
_sessions: dict[str, "WebcrtSession"] = {}
_reaper_started = False
def _utc_now() -> datetime:
return datetime.now(timezone.utc)
def _utc_iso() -> str:
return _utc_now().isoformat()
def webcrt_data_root() -> Path:
root = Path(str(settings.webcrt_data_dir or "data/webcrt"))
root.mkdir(parents=True, exist_ok=True)
return root.resolve()
def _audit(event: str, **fields: Any) -> None:
record = {"ts": _utc_iso(), "event": event, **fields}
try:
path = webcrt_data_root() / "audit.jsonl"
with path.open("a", encoding="utf-8") as fh:
fh.write(json.dumps(record, ensure_ascii=False) + "\n")
except Exception:
_log.exception("webcrt audit write failed")
_log.info("webcrt.%s %s", event, {k: v for k, v in fields.items() if k != "detail"})
@dataclass
class WebcrtSession:
session_id: str
ne_id: str
ne_name: str
ne_ip: str
protocol: str
cols: int
rows: int
conn: ConnectHandler | None = None
created_at: float = field(default_factory=time.time)
last_activity: float = field(default_factory=time.time)
attached: bool = False
closed: bool = False
close_reason: str = ""
out_queue: queue.Queue[bytes | None] = field(default_factory=queue.Queue)
_reader: threading.Thread | None = field(default=None, repr=False)
_write_lock: threading.Lock = field(default_factory=threading.Lock, repr=False)
def touch(self) -> None:
self.last_activity = time.time()
def write_stdin(self, data: str) -> None:
if self.closed or self.conn is None:
raise RuntimeError("session_closed")
text = str(data or "")
if not text:
return
with self._write_lock:
self.conn.write_channel(text)
self.touch()
def resize(self, cols: int, rows: int) -> None:
if self.closed or self.conn is None:
return
c = max(20, min(500, int(cols or 80)))
r = max(5, min(200, int(rows or 24)))
self.cols = c
self.rows = r
channel = getattr(self.conn, "remote_conn", None)
if channel is not None and hasattr(channel, "resize_pty"):
try:
channel.resize_pty(width=c, height=r)
except Exception:
_log.debug("resize_pty failed session=%s", self.session_id, exc_info=True)
self.touch()
def start_reader(self) -> None:
if self._reader and self._reader.is_alive():
return
self._reader = threading.Thread(
target=self._reader_loop,
name=f"webcrt-reader-{self.session_id[:8]}",
daemon=True,
)
self._reader.start()
def _reader_loop(self) -> None:
conn = self.conn
if conn is None:
self.out_queue.put(None)
return
channel = getattr(conn, "remote_conn", None)
try:
while not self.closed:
chunk = b""
try:
if channel is not None and hasattr(channel, "recv_ready") and hasattr(channel, "recv"):
if channel.recv_ready():
chunk = channel.recv(4096)
if not chunk:
break
elif hasattr(channel, "exit_status_ready") and channel.exit_status_ready():
break
else:
time.sleep(0.04)
continue
else:
text = conn.read_channel()
if text:
chunk = text.encode("utf-8", errors="replace")
else:
time.sleep(0.04)
continue
except Exception as exc:
if self.closed:
break
_log.debug("webcrt reader error session=%s: %s", self.session_id, exc)
time.sleep(0.1)
continue
if chunk:
self.touch()
self.out_queue.put(chunk)
finally:
self.out_queue.put(None)
def close(self, reason: str = "closed") -> None:
if self.closed:
return
self.closed = True
self.close_reason = reason or "closed"
try:
close_netmiko_connection(self.conn)
except Exception:
pass
self.conn = None
try:
self.out_queue.put_nowait(None)
except Exception:
pass
def _ensure_reaper() -> None:
global _reaper_started
with _sessions_lock:
if _reaper_started:
return
_reaper_started = True
t = threading.Thread(target=_reaper_loop, name="webcrt-reaper", daemon=True)
t.start()
def _reaper_loop() -> None:
while True:
try:
_reap_sessions()
except Exception:
_log.exception("webcrt reaper failed")
time.sleep(5)
def _reap_sessions() -> None:
idle = max(60, int(settings.webcrt_idle_timeout_sec or 1800))
attach = max(10, int(settings.webcrt_attach_timeout_sec or 60))
now = time.time()
to_close: list[tuple[WebcrtSession, str]] = []
with _sessions_lock:
for sess in list(_sessions.values()):
if sess.closed:
_sessions.pop(sess.session_id, None)
continue
if not sess.attached and (now - sess.created_at) > attach:
to_close.append((sess, "attach_timeout"))
elif (now - sess.last_activity) > idle:
to_close.append((sess, "idle_timeout"))
for sess, reason in to_close:
close_session(sess.session_id, reason=reason)
def active_session_count() -> int:
with _sessions_lock:
return sum(1 for s in _sessions.values() if not s.closed)
def get_session(session_id: str) -> WebcrtSession | None:
with _sessions_lock:
sess = _sessions.get(session_id)
if sess is None or sess.closed:
return None
return sess
def create_session(
db: Session,
*,
ne_id: str | None = None,
ume_ne_id: str | None = None,
cols: int = 80,
rows: int = 24,
client: str = "",
) -> dict[str, Any]:
from .cli_resolve import resolve_cli_target
_ensure_reaper()
max_sessions = max(1, int(settings.webcrt_max_sessions or 20))
if active_session_count() >= max_sessions:
raise HTTPException(status_code=429, detail="webcrt_session_limit")
mid = str(ne_id or "").strip()
uid = str(ume_ne_id or "").strip()
try:
creds, device = resolve_cli_target(db, managed_ne_id=mid or None, ume_ne_id=uid or None)
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 str(creds.get("username") or "").strip() or not str(creds.get("password") or ""):
raise HTTPException(status_code=400, detail="credentials_incomplete")
session_id = str(uuid.uuid4())
c = max(20, min(500, int(cols or 80)))
r = max(5, min(200, int(rows or 24)))
connect_timeout = max(30, int(settings.webcrt_connect_timeout_sec or 90))
target_id = str(device.get("id") or mid or uid)
target_ip = str(device.get("ip_address") or "")
target_name = str(device.get("name") or target_ip)
protocol = str(device.get("protocol") or creds.get("protocol") or "ssh")
try:
conn = open_netmiko_connection(creds, session_timeout=connect_timeout)
except Exception as exc:
_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],
)
raise HTTPException(status_code=502, detail=f"connect_failed:{exc}") 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
try:
leftover = conn.read_channel()
except Exception:
leftover = ""
sess = WebcrtSession(
session_id=session_id,
ne_id=target_id,
ne_name=target_name,
ne_ip=target_ip,
protocol=protocol,
cols=c,
rows=r,
conn=conn,
)
if leftover:
sess.out_queue.put(str(leftover).encode("utf-8", errors="replace"))
sess.start_reader()
with _sessions_lock:
_sessions[session_id] = sess
_audit(
"session_created",
session_id=session_id,
ne_id=sess.ne_id,
ne_name=sess.ne_name,
ne_ip=sess.ne_ip,
protocol=sess.protocol,
source=str(device.get("source") or ""),
hop_enabled=bool(creds.get("hop_enabled")),
hop_vendor=str(creds.get("hop_vendor") or "") if creds.get("hop_enabled") else "",
client=client or "",
active=active_session_count(),
)
return {
"session_id": session_id,
"ne_id": sess.ne_id,
"ne_name": sess.ne_name,
"ne_ip": sess.ne_ip,
"source": str(device.get("source") or ""),
"protocol": sess.protocol,
"cols": sess.cols,
"rows": sess.rows,
"ws_path": f"/v1/webcrt/sessions/{session_id}/ws",
}
def mark_attached(session_id: str) -> WebcrtSession:
sess = get_session(session_id)
if sess is None:
raise HTTPException(status_code=404, detail="webcrt_session_not_found")
if sess.attached:
raise HTTPException(status_code=409, detail="webcrt_session_already_attached")
sess.attached = True
sess.touch()
_audit("session_attached", session_id=session_id, ne_id=sess.ne_id, ne_ip=sess.ne_ip)
return sess
def close_session(session_id: str, *, reason: str = "closed", client: str = "") -> dict[str, Any]:
with _sessions_lock:
sess = _sessions.pop(session_id, None)
if sess is None:
return {"ok": True, "session_id": session_id, "closed": False}
if not sess.closed:
sess.close(reason)
_audit(
"session_closed",
session_id=session_id,
ne_id=sess.ne_id,
ne_ip=sess.ne_ip,
reason=reason,
client=client or "",
active=active_session_count(),
)
return {"ok": True, "session_id": session_id, "closed": True, "reason": reason}
def list_sessions() -> dict[str, Any]:
with _sessions_lock:
items = [
{
"session_id": s.session_id,
"ne_id": s.ne_id,
"ne_name": s.ne_name,
"ne_ip": s.ne_ip,
"protocol": s.protocol,
"attached": s.attached,
"created_at": datetime.fromtimestamp(s.created_at, tz=timezone.utc).isoformat(),
"last_activity": datetime.fromtimestamp(s.last_activity, tz=timezone.utc).isoformat(),
}
for s in _sessions.values()
if not s.closed
]
return {
"total": len(items),
"max_sessions": max(1, int(settings.webcrt_max_sessions or 20)),
"idle_timeout_sec": max(60, int(settings.webcrt_idle_timeout_sec or 1800)),
"items": items,
}