mirror of
https://github.com/hansjone/netx.git
synced 2026-10-09 06:40:45 +08:00
Improve WebCRT UX: real login transcript, session tools, and first-input sync.
Capture Netmiko session_log for login replay, open NE terminals in a fresh tab, tidy the device list UI with reconnect/log/clear/copy, and paint a live prompt after attach so the first keystrokes behave normally. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
30f2db9708
commit
89ff0a7212
11 changed files with 593 additions and 183 deletions
|
|
@ -252,6 +252,7 @@ def _netmiko_over_ssh_client(
|
|||
password: str,
|
||||
enable_secret: str,
|
||||
session_timeout: int | None,
|
||||
session_log: Any = None,
|
||||
) -> ConnectHandler:
|
||||
"""Netmiko session over an already-authenticated SSH client (bastion protocol proxy)."""
|
||||
base_cls = _netmiko_driver_class(device_type)
|
||||
|
|
@ -278,6 +279,7 @@ def _netmiko_over_ssh_client(
|
|||
password=password,
|
||||
enable_secret=enable_secret,
|
||||
session_timeout=session_timeout,
|
||||
session_log=session_log,
|
||||
)
|
||||
return _PreauthSession(**dev)
|
||||
|
||||
|
|
@ -291,6 +293,7 @@ def _base_connect_kwargs(
|
|||
password: str,
|
||||
enable_secret: str,
|
||||
session_timeout: int | None = None,
|
||||
session_log: Any = None,
|
||||
) -> dict[str, Any]:
|
||||
timeout = int(settings.ne_connect_timeout_sec or 30)
|
||||
dev: dict[str, Any] = {
|
||||
|
|
@ -308,10 +311,17 @@ def _base_connect_kwargs(
|
|||
secret = str(enable_secret or "").strip()
|
||||
if secret:
|
||||
dev["secret"] = secret
|
||||
if session_log is not None:
|
||||
dev["session_log"] = session_log
|
||||
return dev
|
||||
|
||||
|
||||
def _connect_direct(creds: dict[str, Any], *, session_timeout: int | None = None) -> ConnectHandler:
|
||||
def _connect_direct(
|
||||
creds: dict[str, Any],
|
||||
*,
|
||||
session_timeout: int | None = None,
|
||||
session_log: Any = None,
|
||||
) -> ConnectHandler:
|
||||
device_type = normalize_netmiko_device_type(creds["device_type"], creds["protocol"])
|
||||
dev = _base_connect_kwargs(
|
||||
device_type=device_type,
|
||||
|
|
@ -321,6 +331,7 @@ def _connect_direct(creds: dict[str, Any], *, session_timeout: int | None = None
|
|||
password=str(creds["password"]),
|
||||
enable_secret=str(creds.get("enable_secret") or ""),
|
||||
session_timeout=session_timeout,
|
||||
session_log=session_log,
|
||||
)
|
||||
return ConnectHandler(**dev)
|
||||
|
||||
|
|
@ -391,7 +402,12 @@ def _hop_netmiko_device_type(vendor: str, hop_protocol: str) -> str:
|
|||
return normalize_netmiko_device_type(base, hop_protocol)
|
||||
|
||||
|
||||
def _connect_via_cli_hop(creds: dict[str, Any], *, session_timeout: int | None = None) -> ConnectHandler:
|
||||
def _connect_via_cli_hop(
|
||||
creds: dict[str, Any],
|
||||
*,
|
||||
session_timeout: int | None = None,
|
||||
session_log: Any = None,
|
||||
) -> ConnectHandler:
|
||||
"""Login to ZTE/Huawei/Cisco hop NE, run CLI jump command, then target secondary auth."""
|
||||
hop_host = str(creds.get("hop_host") or "").strip()
|
||||
hop_user = str(creds.get("hop_username") or "").strip()
|
||||
|
|
@ -409,6 +425,7 @@ def _connect_via_cli_hop(creds: dict[str, Any], *, session_timeout: int | None =
|
|||
password=hop_pass,
|
||||
enable_secret="",
|
||||
session_timeout=session_timeout or 180,
|
||||
session_log=session_log,
|
||||
)
|
||||
conn = ConnectHandler(**hop_dev)
|
||||
try:
|
||||
|
|
@ -425,7 +442,12 @@ def _connect_via_cli_hop(creds: dict[str, Any], *, session_timeout: int | None =
|
|||
raise
|
||||
|
||||
|
||||
def _connect_via_bastion(creds: dict[str, Any], *, session_timeout: int | None = None) -> ConnectHandler:
|
||||
def _connect_via_bastion(
|
||||
creds: dict[str, Any],
|
||||
*,
|
||||
session_timeout: int | None = None,
|
||||
session_log: Any = None,
|
||||
) -> ConnectHandler:
|
||||
"""SSH to bastion with composite username; bastion proxies to target (protocol proxy)."""
|
||||
hop_host = str(creds.get("hop_host") or "").strip()
|
||||
hop_user = str(creds.get("hop_username") or "").strip()
|
||||
|
|
@ -456,6 +478,7 @@ def _connect_via_bastion(creds: dict[str, Any], *, session_timeout: int | None =
|
|||
password=hop_pass,
|
||||
enable_secret=str(creds.get("enable_secret") or ""),
|
||||
session_timeout=session_timeout or 180,
|
||||
session_log=session_log,
|
||||
)
|
||||
except Exception:
|
||||
if ssh_client is not None:
|
||||
|
|
@ -481,7 +504,12 @@ def _connect_via_bastion(creds: dict[str, Any], *, session_timeout: int | None =
|
|||
return conn
|
||||
|
||||
|
||||
def _connect_via_linux_hop(creds: dict[str, Any], *, session_timeout: int | None = None) -> ConnectHandler:
|
||||
def _connect_via_linux_hop(
|
||||
creds: dict[str, Any],
|
||||
*,
|
||||
session_timeout: int | None = None,
|
||||
session_log: Any = None,
|
||||
) -> ConnectHandler:
|
||||
"""SSH to Linux bastion, then direct-tcpip tunnel to target (classic ProxyJump-style)."""
|
||||
hop_host = str(creds.get("hop_host") or "").strip()
|
||||
hop_user = str(creds.get("hop_username") or "").strip()
|
||||
|
|
@ -533,6 +561,7 @@ def _connect_via_linux_hop(creds: dict[str, Any], *, session_timeout: int | None
|
|||
password=str(creds["password"]),
|
||||
enable_secret=str(creds.get("enable_secret") or ""),
|
||||
session_timeout=session_timeout,
|
||||
session_log=session_log,
|
||||
)
|
||||
dev["sock"] = channel
|
||||
conn = ConnectHandler(**dev)
|
||||
|
|
@ -556,13 +585,18 @@ def close_netmiko_connection(conn: ConnectHandler | None) -> None:
|
|||
pass
|
||||
|
||||
|
||||
def open_netmiko_connection(creds: dict[str, Any], *, session_timeout: int | None = None) -> ConnectHandler:
|
||||
def open_netmiko_connection(
|
||||
creds: dict[str, Any],
|
||||
*,
|
||||
session_timeout: int | None = None,
|
||||
session_log: Any = None,
|
||||
) -> ConnectHandler:
|
||||
"""Open a Netmiko connection to the target NE (direct or via configured hop)."""
|
||||
if creds.get("hop_enabled"):
|
||||
vendor = _hop_vendor(creds)
|
||||
if vendor == "linux":
|
||||
return _connect_via_linux_hop(creds, session_timeout=session_timeout)
|
||||
return _connect_via_linux_hop(creds, session_timeout=session_timeout, session_log=session_log)
|
||||
if vendor == "bastion":
|
||||
return _connect_via_bastion(creds, session_timeout=session_timeout)
|
||||
return _connect_via_cli_hop(creds, session_timeout=session_timeout)
|
||||
return _connect_direct(creds, session_timeout=session_timeout)
|
||||
return _connect_via_bastion(creds, session_timeout=session_timeout, session_log=session_log)
|
||||
return _connect_via_cli_hop(creds, session_timeout=session_timeout, session_log=session_log)
|
||||
return _connect_direct(creds, session_timeout=session_timeout, session_log=session_log)
|
||||
|
|
|
|||
|
|
@ -98,7 +98,7 @@ async def websocket_session(websocket: WebSocket, session_id: str) -> None:
|
|||
}
|
||||
)
|
||||
|
||||
# Replay post-login banner/prompt so the UI is not blank until the user presses Enter.
|
||||
# Replay login transcript once, then draw a live prompt so first keystrokes match later lines.
|
||||
bootstrap = bytes(sess.bootstrap_output or b"")
|
||||
if bootstrap:
|
||||
try:
|
||||
|
|
@ -107,12 +107,8 @@ async def websocket_session(websocket: WebSocket, session_id: str) -> None:
|
|||
)
|
||||
except Exception:
|
||||
_log.debug("webcrt bootstrap send failed session=%s", session_id, exc_info=True)
|
||||
else:
|
||||
# Last resort: ask the device to redraw the prompt into the live reader.
|
||||
try:
|
||||
await asyncio.get_running_loop().run_in_executor(None, sess.write_stdin, "\r")
|
||||
except Exception:
|
||||
pass
|
||||
# Only replay login banner on the first attach (StrictMode remount / blip).
|
||||
sess.bootstrap_output = b""
|
||||
|
||||
stop = asyncio.Event()
|
||||
|
||||
|
|
@ -141,6 +137,12 @@ async def websocket_session(websocket: WebSocket, session_id: str) -> None:
|
|||
break
|
||||
|
||||
reader_task = asyncio.create_task(pump_stdout())
|
||||
if sess.needs_live_prompt:
|
||||
sess.needs_live_prompt = False
|
||||
try:
|
||||
await asyncio.get_running_loop().run_in_executor(None, sess.write_stdin, "\r")
|
||||
except Exception:
|
||||
_log.debug("webcrt live prompt sync failed session=%s", session_id, exc_info=True)
|
||||
try:
|
||||
while not stop.is_set():
|
||||
raw = await websocket.receive_text()
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import json
|
||||
import logging
|
||||
import queue
|
||||
|
|
@ -77,6 +78,29 @@ def map_network_cli_keys(data: str) -> str:
|
|||
i += 1
|
||||
return "".join(out)
|
||||
|
||||
|
||||
def channel_return(conn: ConnectHandler | None) -> str:
|
||||
"""Netmiko line ending for this session (SSH usually \\n, Telnet often \\r\\n)."""
|
||||
if conn is None:
|
||||
return "\n"
|
||||
ret = getattr(conn, "RETURN", None)
|
||||
if isinstance(ret, str) and ret:
|
||||
return ret
|
||||
return "\n"
|
||||
|
||||
|
||||
def map_network_cli_enter(data: str, conn: ConnectHandler | None) -> str:
|
||||
"""Map xterm Enter (\\r) to the device's Netmiko RETURN."""
|
||||
text = str(data or "")
|
||||
if not text:
|
||||
return text
|
||||
ret = channel_return(conn)
|
||||
if ret == "\r":
|
||||
return text
|
||||
# Prefer replacing CRLF first so Telnet RETURN \\r\\n does not double-expand.
|
||||
return text.replace("\r\n", ret).replace("\r", ret)
|
||||
|
||||
|
||||
def _drain_channel(conn: ConnectHandler, *, rounds: int = 10, wait: float = 0.12) -> str:
|
||||
"""Read whatever is already sitting on the channel after login."""
|
||||
chunks: list[str] = []
|
||||
|
|
@ -97,6 +121,19 @@ def _drain_channel(conn: ConnectHandler, *, rounds: int = 10, wait: float = 0.12
|
|||
return "".join(chunks)
|
||||
|
||||
|
||||
def _session_log_text(buf: io.BytesIO | None) -> str:
|
||||
"""Decode Netmiko session_log buffer into display text."""
|
||||
if buf is None:
|
||||
return ""
|
||||
try:
|
||||
raw = buf.getvalue()
|
||||
except Exception:
|
||||
return ""
|
||||
if isinstance(raw, bytes):
|
||||
return raw.decode("utf-8", errors="replace")
|
||||
return str(raw or "")
|
||||
|
||||
|
||||
def _looks_like_cli_prompt(text: str) -> bool:
|
||||
s = str(text or "").rstrip()
|
||||
if not s:
|
||||
|
|
@ -105,6 +142,61 @@ def _looks_like_cli_prompt(text: str) -> bool:
|
|||
return bool(re.search(r"(?:[>\]]|#)\s*$", s)) or bool(re.search(r"<[^>\r\n]+>\s*$", s))
|
||||
|
||||
|
||||
# Cisco/Netmiko often yields "R2#R2#" when a sync Enter is appended without a newline.
|
||||
_GLUED_PROMPT_RE = re.compile(r"(?<=[#>])(?=(?:[A-Za-z0-9][\w.\-:]{0,62})[#>])")
|
||||
|
||||
|
||||
def normalize_cli_transcript(text: str) -> str:
|
||||
"""Normalize login transcript for xterm (convertEol) and un-glue prompts."""
|
||||
s = str(text or "").replace("\r\n", "\n").replace("\r", "\n")
|
||||
s = _GLUED_PROMPT_RE.sub("\n", s)
|
||||
lines = s.split("\n")
|
||||
while lines and not str(lines[-1]).strip():
|
||||
lines.pop()
|
||||
while len(lines) >= 2 and lines[-1] == lines[-2] and _looks_like_cli_prompt(lines[-1]):
|
||||
lines.pop()
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def strip_trailing_prompt_lines(text: str) -> str:
|
||||
"""Remove final prompt line(s) so a live RETURN can paint the interactive prompt."""
|
||||
lines = str(text or "").split("\n")
|
||||
while lines and not str(lines[-1]).strip():
|
||||
lines.pop()
|
||||
while lines and _looks_like_cli_prompt(lines[-1]):
|
||||
lines.pop()
|
||||
while lines and not str(lines[-1]).strip():
|
||||
lines.pop()
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def prepare_bootstrap_output(text: str) -> str:
|
||||
"""Login transcript for UI replay; ends with newline, without the final prompt."""
|
||||
body = strip_trailing_prompt_lines(normalize_cli_transcript(text))
|
||||
if not body:
|
||||
return ""
|
||||
return body if body.endswith("\n") else body + "\n"
|
||||
|
||||
|
||||
def _prime_interactive_channel(conn: ConnectHandler) -> None:
|
||||
"""Send one RETURN after Netmiko login so the interactive channel is fully ready."""
|
||||
try:
|
||||
_drain_channel(conn, rounds=4, wait=0.05)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
conn.write_channel(channel_return(conn))
|
||||
except Exception:
|
||||
try:
|
||||
conn.write_channel("\n")
|
||||
except Exception:
|
||||
return
|
||||
try:
|
||||
_drain_channel(conn, rounds=8, wait=0.1)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
|
||||
def _utc_now() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
|
@ -151,6 +243,7 @@ class WebcrtSession:
|
|||
closed: bool = False
|
||||
close_reason: str = ""
|
||||
bootstrap_output: bytes = b""
|
||||
needs_live_prompt: bool = True
|
||||
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)
|
||||
|
|
@ -166,26 +259,26 @@ class WebcrtSession:
|
|||
return
|
||||
if self.cli_keymap:
|
||||
text = map_network_cli_keys(text)
|
||||
text = map_network_cli_enter(text, self.conn)
|
||||
if not text:
|
||||
return
|
||||
with self._write_lock:
|
||||
# Prefer raw channel write so control bytes are not altered.
|
||||
channel = getattr(self.conn, "remote_conn", None)
|
||||
try:
|
||||
if channel is not None and hasattr(channel, "send") and callable(channel.send):
|
||||
payload = text.encode(getattr(self.conn, "encoding", None) or "utf-8", errors="replace")
|
||||
channel.send(payload)
|
||||
elif channel is not None and hasattr(channel, "write") and callable(channel.write):
|
||||
encoding = getattr(self.conn, "encoding", None) or "utf-8"
|
||||
if isinstance(text, str):
|
||||
channel.write(text.encode(encoding, errors="replace"))
|
||||
else:
|
||||
channel.write(text)
|
||||
else:
|
||||
self.conn.write_channel(text)
|
||||
except Exception:
|
||||
# Fallback to netmiko helper.
|
||||
# Network CLIs: use Netmiko write_channel so RETURN/encoding match the driver.
|
||||
if self.cli_keymap:
|
||||
self.conn.write_channel(text)
|
||||
else:
|
||||
channel = getattr(self.conn, "remote_conn", None)
|
||||
try:
|
||||
if channel is not None and hasattr(channel, "send") and callable(channel.send):
|
||||
payload = text.encode(getattr(self.conn, "encoding", None) or "utf-8", errors="replace")
|
||||
channel.send(payload)
|
||||
elif channel is not None and hasattr(channel, "write") and callable(channel.write):
|
||||
encoding = getattr(self.conn, "encoding", None) or "utf-8"
|
||||
channel.write(text.encode(encoding, errors="replace") if isinstance(text, str) else text)
|
||||
else:
|
||||
self.conn.write_channel(text)
|
||||
except Exception:
|
||||
self.conn.write_channel(text)
|
||||
self.touch()
|
||||
def resize(self, cols: int, rows: int) -> None:
|
||||
if self.closed or self.conn is None:
|
||||
|
|
@ -367,9 +460,14 @@ def create_session(
|
|||
vendor = str(device.get("vendor") or creds.get("vendor") or "")
|
||||
cli_keymap = uses_network_cli_keymap(device_type, vendor)
|
||||
|
||||
# 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)
|
||||
conn = open_netmiko_connection(
|
||||
creds, session_timeout=connect_timeout, session_log=log_buf
|
||||
)
|
||||
except Exception as exc:
|
||||
partial = _session_log_text(log_buf).strip()
|
||||
_audit(
|
||||
"session_open_failed",
|
||||
session_id=session_id,
|
||||
|
|
@ -378,8 +476,13 @@ def create_session(
|
|||
source=str(device.get("source") or ""),
|
||||
client=client or "",
|
||||
error=str(exc)[:500],
|
||||
transcript_len=len(partial),
|
||||
)
|
||||
raise HTTPException(status_code=502, detail=f"connect_failed:{exc}") from exc
|
||||
detail = f"connect_failed:{exc}"
|
||||
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"):
|
||||
|
|
@ -388,20 +491,23 @@ def create_session(
|
|||
except Exception:
|
||||
pass
|
||||
|
||||
# Prefer session_log (full login transcript). Prime channel, then strip the final
|
||||
# prompt so WebSocket attach can paint a live interactive prompt (first keystrokes
|
||||
# then behave like subsequent lines).
|
||||
_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:
|
||||
leftover = _drain_channel(conn, rounds=8, wait=0.1)
|
||||
_drain_channel(conn, rounds=3, wait=0.05)
|
||||
except Exception:
|
||||
leftover = ""
|
||||
# Netmiko often consumes the login banner; nudge Enter once to surface the prompt.
|
||||
if not _looks_like_cli_prompt(leftover):
|
||||
try:
|
||||
conn.write_channel("\r")
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
leftover = (leftover or "") + _drain_channel(conn, rounds=8, wait=0.12)
|
||||
except Exception:
|
||||
pass
|
||||
pass
|
||||
|
||||
sess = WebcrtSession(
|
||||
session_id=session_id,
|
||||
|
|
@ -415,7 +521,8 @@ def create_session(
|
|||
vendor=vendor,
|
||||
cli_keymap=cli_keymap,
|
||||
conn=conn,
|
||||
bootstrap_output=str(leftover or "").encode("utf-8", errors="replace"),
|
||||
bootstrap_output=str(bootstrap or "").encode("utf-8", errors="replace"),
|
||||
needs_live_prompt=True,
|
||||
)
|
||||
# Keep bootstrap for WS attach replay; do not rely solely on out_queue (StrictMode remount).
|
||||
sess.start_reader()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue