mirror of
https://github.com/hansjone/netx.git
synced 2026-10-14 23:40:54 +08:00
Speed up WebCRT and record audited commands from the device echo.
Performance: event-driven stdout pump (no polling thread per socket, no 200ms coalescing stall), keystrokes written immediately, blocking SSH reads, bounded deque queue with batch drain, incremental UTF-8 decode, buffered session log, non-blocking session teardown, shorter login/settle waits, xterm WebGL renderer and idle-time direct writes. Zero-latency fake device: echo p50 11ms -> 2.3ms, command RTT 219ms -> 20ms, bulk output 2.1 -> 8.3 MB/s. Audit: track the device's own input row server-side (webcrt_cmd_audit) and resolve each Enter from the echoed line feed, so Tab, backspace, cursor-key edits, history recall, paste, Ctrl-C and pagers record what actually ran. Password lines are redacted; pending Enters are flushed on close. Hop: stop closing the hop session when the target prints the same prompt as the hop (system-view); recognise the Huawei "closed by the remote host" message. Generic NEs that show a network prompt now get the network key map. Adds bench_webcrt.py, fake_ssh_device.py and e2e_webcrt_input_scenarios.py. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
0d894efe5e
commit
45de8144a3
14 changed files with 2060 additions and 241 deletions
|
|
@ -10,7 +10,9 @@ from netmiko import ConnectHandler
|
||||||
_CLI_HOP_NESTED_END_RE = re.compile(
|
_CLI_HOP_NESTED_END_RE = re.compile(
|
||||||
r"(?is)"
|
r"(?is)"
|
||||||
r"(?:^|\n)\s*(?:"
|
r"(?:^|\n)\s*(?:"
|
||||||
r"connection\s+closed(?:\s+by\s+(?:foreign|remote)\s+host)?"
|
# Huawei VRP: "Info: The connection was closed by the remote host."
|
||||||
|
r"(?:info:\s*)?(?:the\s+)?connection\s+was\s+closed\s+by\s+(?:the\s+)?(?:foreign|remote)\s+host"
|
||||||
|
r"|connection\s+closed(?:\s+by\s+(?:foreign|remote)\s+host)?"
|
||||||
r"|closed\s+by\s+foreign\s+host"
|
r"|closed\s+by\s+foreign\s+host"
|
||||||
r"|connection\s+to\s+\S+\s+closed"
|
r"|connection\s+to\s+\S+\s+closed"
|
||||||
r"|%\s*connection\s+closed(?:\s+by\s+(?:foreign|remote)\s+host)?"
|
r"|%\s*connection\s+closed(?:\s+by\s+(?:foreign|remote)\s+host)?"
|
||||||
|
|
|
||||||
|
|
@ -545,18 +545,26 @@ def _connect_direct(
|
||||||
|
|
||||||
|
|
||||||
def _read_channel(conn: ConnectHandler, wait: float = 0.5, max_loops: int = 40) -> str:
|
def _read_channel(conn: ConnectHandler, wait: float = 0.5, max_loops: int = 40) -> str:
|
||||||
if wait > 0:
|
"""Collect device output.
|
||||||
time.sleep(wait)
|
|
||||||
|
``wait`` is the longest we park for the *first* bytes; once output has arrived the read
|
||||||
|
returns as soon as the channel is quiet again. (It used to sleep the whole ``wait`` and
|
||||||
|
then poll at 50-150ms, which added ~0.1-0.4s of dead time to every login-prompt step.)
|
||||||
|
"""
|
||||||
|
quiet = 0.04 if wait <= 0.15 else 0.12
|
||||||
|
first_deadline = time.monotonic() + max(0.0, wait)
|
||||||
|
got_at: float | None = None
|
||||||
chunks: list[str] = []
|
chunks: list[str] = []
|
||||||
for _ in range(max_loops):
|
reads = 0
|
||||||
|
while reads < max_loops:
|
||||||
try:
|
try:
|
||||||
part = conn.read_channel()
|
part = conn.read_channel()
|
||||||
except Exception:
|
except Exception:
|
||||||
break
|
break
|
||||||
if part is None or part is False:
|
if part is None or part is False:
|
||||||
break
|
part = ""
|
||||||
# MagicMock truthy-but-empty: stop when unit tests leave read_channel unconfigured.
|
# MagicMock truthy-but-empty: stop when unit tests leave read_channel unconfigured.
|
||||||
if not isinstance(part, (str, bytes, bytearray)):
|
elif not isinstance(part, (str, bytes, bytearray)):
|
||||||
text = str(part)
|
text = str(part)
|
||||||
# unittest.mock default str looks like "<MagicMock name=...>"
|
# unittest.mock default str looks like "<MagicMock name=...>"
|
||||||
if text.startswith("<MagicMock") or text.startswith("<Mock"):
|
if text.startswith("<MagicMock") or text.startswith("<Mock"):
|
||||||
|
|
@ -564,10 +572,18 @@ def _read_channel(conn: ConnectHandler, wait: float = 0.5, max_loops: int = 40)
|
||||||
part = text
|
part = text
|
||||||
elif isinstance(part, (bytes, bytearray)):
|
elif isinstance(part, (bytes, bytearray)):
|
||||||
part = bytes(part).decode("utf-8", errors="replace")
|
part = bytes(part).decode("utf-8", errors="replace")
|
||||||
if not part:
|
now = time.monotonic()
|
||||||
|
if part:
|
||||||
|
chunks.append(part)
|
||||||
|
reads += 1
|
||||||
|
got_at = now
|
||||||
|
continue
|
||||||
|
if got_at is None:
|
||||||
|
if now >= first_deadline:
|
||||||
|
break
|
||||||
|
elif now - got_at >= quiet:
|
||||||
break
|
break
|
||||||
chunks.append(part)
|
time.sleep(0.01)
|
||||||
time.sleep(0.05 if wait <= 0.15 else 0.15)
|
|
||||||
return "".join(chunks)
|
return "".join(chunks)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -801,11 +817,11 @@ def _interactive_target_auth(
|
||||||
return
|
return
|
||||||
else:
|
else:
|
||||||
empty_after_pass = 0
|
empty_after_pass = 0
|
||||||
time.sleep(0.12)
|
time.sleep(0.02)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if not buf.strip():
|
if not buf.strip():
|
||||||
time.sleep(0.2)
|
time.sleep(0.02)
|
||||||
continue
|
continue
|
||||||
# Huawei stelnet often reprints the hop ``[sysname]`` after host-key trust and
|
# Huawei stelnet often reprints the hop ``[sysname]`` after host-key trust and
|
||||||
# *before* ``Enter password:``. Do not treat that as target login success.
|
# *before* ``Enter password:``. Do not treat that as target login success.
|
||||||
|
|
@ -826,7 +842,7 @@ def _interactive_target_auth(
|
||||||
if denied:
|
if denied:
|
||||||
raise paramiko.AuthenticationException(f"target_auth_rejected: {denied}")
|
raise paramiko.AuthenticationException(f"target_auth_rejected: {denied}")
|
||||||
return
|
return
|
||||||
time.sleep(0.15)
|
time.sleep(0.02)
|
||||||
denied = _target_auth_failure_snippet(acc, sent_pass=sent_pass)
|
denied = _target_auth_failure_snippet(acc, sent_pass=sent_pass)
|
||||||
if denied:
|
if denied:
|
||||||
raise paramiko.AuthenticationException(f"target_auth_rejected: {denied}")
|
raise paramiko.AuthenticationException(f"target_auth_rejected: {denied}")
|
||||||
|
|
@ -879,7 +895,7 @@ def _huawei_enter_system_view(
|
||||||
for _ in range(8):
|
for _ in range(8):
|
||||||
part = _read_channel(conn, wait=0.2, max_loops=10)
|
part = _read_channel(conn, wait=0.2, max_loops=10)
|
||||||
if not part:
|
if not part:
|
||||||
time.sleep(0.15)
|
time.sleep(0.02)
|
||||||
continue
|
continue
|
||||||
acc += part
|
acc += part
|
||||||
if emit_raw:
|
if emit_raw:
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import queue
|
||||||
import re
|
import re
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
|
from collections import deque
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
@ -44,6 +45,18 @@ def uses_network_cli_keymap(device_type: str = "", vendor: str = "") -> bool:
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
_NETWORK_PROMPT_RE = re.compile(
|
||||||
|
r"^(?:<[^<>\s]{1,63}>"
|
||||||
|
r"|\[[~*]?[\w.\-/:+]{1,80}\]"
|
||||||
|
r"|[A-Za-z0-9][\w.\-/:+]{0,62}(?:\([^()\s]{1,60}\))*[#>])$"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def looks_like_network_prompt(line: str) -> bool:
|
||||||
|
"""``<r1>`` / ``[~r1]`` / ``R2#`` / ``R2(config)#`` — never a Linux ``user@host:~$`` prompt."""
|
||||||
|
return bool(_NETWORK_PROMPT_RE.match(str(line or "").strip()))
|
||||||
|
|
||||||
|
|
||||||
def map_network_cli_keys(
|
def map_network_cli_keys(
|
||||||
data: str,
|
data: str,
|
||||||
*,
|
*,
|
||||||
|
|
@ -56,6 +69,8 @@ def map_network_cli_keys(
|
||||||
text = str(data or "")
|
text = str(data or "")
|
||||||
if not text:
|
if not text:
|
||||||
return text
|
return text
|
||||||
|
if "\x1b" not in text and "\x7f" not in text:
|
||||||
|
return text # plain printable typing: nothing to rewrite
|
||||||
out: list[str] = []
|
out: list[str] = []
|
||||||
i = 0
|
i = 0
|
||||||
n = len(text)
|
n = len(text)
|
||||||
|
|
@ -86,7 +101,7 @@ def channel_return(conn: ConnectHandler | None) -> str:
|
||||||
def map_network_cli_enter(data: str, conn: ConnectHandler | None) -> str:
|
def map_network_cli_enter(data: str, conn: ConnectHandler | None) -> str:
|
||||||
"""Map xterm Enter (\\r) to the device's Netmiko RETURN."""
|
"""Map xterm Enter (\\r) to the device's Netmiko RETURN."""
|
||||||
text = str(data or "")
|
text = str(data or "")
|
||||||
if not text:
|
if not text or ("\r" not in text and "\n" not in text):
|
||||||
return text
|
return text
|
||||||
ret = channel_return(conn)
|
ret = channel_return(conn)
|
||||||
# Replace once: chained replacements expand the CR in Telnet CRLF again.
|
# Replace once: chained replacements expand the CR in Telnet CRLF again.
|
||||||
|
|
@ -367,10 +382,10 @@ def _encode_text(text: str, encoding: str) -> bytes:
|
||||||
|
|
||||||
|
|
||||||
class _BoundedByteQueue:
|
class _BoundedByteQueue:
|
||||||
"""Thread-safe queue that drops oldest chunks when full (backpressure)."""
|
"""Thread-safe chunk queue that drops the oldest chunks when full (backpressure)."""
|
||||||
|
|
||||||
def __init__(self, maxsize: int = 2000) -> None:
|
def __init__(self, maxsize: int = 2000) -> None:
|
||||||
self._q: queue.Queue[bytes | None] = queue.Queue()
|
self._dq: deque[bytes | None] = deque()
|
||||||
self._max = max(8, int(maxsize or 2000))
|
self._max = max(8, int(maxsize or 2000))
|
||||||
self._cond = threading.Condition()
|
self._cond = threading.Condition()
|
||||||
self.dropped = 0
|
self.dropped = 0
|
||||||
|
|
@ -378,13 +393,12 @@ class _BoundedByteQueue:
|
||||||
|
|
||||||
def put(self, item: bytes | None) -> None:
|
def put(self, item: bytes | None) -> None:
|
||||||
with self._cond:
|
with self._cond:
|
||||||
while self._q.qsize() >= self._max:
|
dq = self._dq
|
||||||
try:
|
if item is not None:
|
||||||
self._q.get_nowait()
|
while len(dq) >= self._max:
|
||||||
|
dq.popleft()
|
||||||
self.dropped += 1
|
self.dropped += 1
|
||||||
except queue.Empty:
|
dq.append(item)
|
||||||
break
|
|
||||||
self._q.put(item)
|
|
||||||
self._cond.notify()
|
self._cond.notify()
|
||||||
|
|
||||||
def put_nowait(self, item: bytes | None) -> None:
|
def put_nowait(self, item: bytes | None) -> None:
|
||||||
|
|
@ -392,22 +406,42 @@ class _BoundedByteQueue:
|
||||||
|
|
||||||
def get_nowait(self) -> bytes | None:
|
def get_nowait(self) -> bytes | None:
|
||||||
with self._cond:
|
with self._cond:
|
||||||
return self._q.get_nowait()
|
if not self._dq:
|
||||||
|
raise queue.Empty
|
||||||
|
return self._dq.popleft()
|
||||||
|
|
||||||
def get(self, timeout: float = 0.25) -> bytes | None:
|
def get(self, timeout: float = 0.25) -> bytes | None:
|
||||||
"""Block until a chunk is available or timeout (raises queue.Empty)."""
|
"""Block until a chunk is available or timeout (raises queue.Empty)."""
|
||||||
deadline = time.time() + max(0.0, float(timeout))
|
deadline = time.monotonic() + max(0.0, float(timeout))
|
||||||
with self._cond:
|
with self._cond:
|
||||||
while self._q.empty():
|
while not self._dq:
|
||||||
remaining = deadline - time.time()
|
remaining = deadline - time.monotonic()
|
||||||
if remaining <= 0:
|
if remaining <= 0:
|
||||||
raise queue.Empty
|
raise queue.Empty
|
||||||
self._cond.wait(timeout=remaining)
|
self._cond.wait(timeout=remaining)
|
||||||
return self._q.get_nowait()
|
return self._dq.popleft()
|
||||||
|
|
||||||
|
def drain(self, max_bytes: int = 262144) -> tuple[list[bytes], bool]:
|
||||||
|
"""Pop every queued chunk (up to ~max_bytes). Returns ``(chunks, saw_eof)``."""
|
||||||
|
out: list[bytes] = []
|
||||||
|
total = 0
|
||||||
|
with self._cond:
|
||||||
|
dq = self._dq
|
||||||
|
while dq:
|
||||||
|
item = dq[0]
|
||||||
|
if item is None:
|
||||||
|
dq.popleft()
|
||||||
|
return out, True
|
||||||
|
if out and total + len(item) > max_bytes:
|
||||||
|
break
|
||||||
|
dq.popleft()
|
||||||
|
out.append(item)
|
||||||
|
total += len(item)
|
||||||
|
return out, False
|
||||||
|
|
||||||
def qsize(self) -> int:
|
def qsize(self) -> int:
|
||||||
with self._cond:
|
with self._cond:
|
||||||
return self._q.qsize()
|
return len(self._dq)
|
||||||
|
|
||||||
def take_drop_delta(self) -> int:
|
def take_drop_delta(self) -> int:
|
||||||
"""Return newly dropped chunk count since last call (for client notice)."""
|
"""Return newly dropped chunk count since last call (for client notice)."""
|
||||||
|
|
@ -1254,6 +1288,9 @@ def feed_command_line_buffer(buf: str, data: str, *, max_line: int = 512) -> tup
|
||||||
completed: list[str] = []
|
completed: list[str] = []
|
||||||
limit = max(64, min(int(max_line or 512), 4096))
|
limit = max(64, min(int(max_line or 512), 4096))
|
||||||
raw = str(data or "")
|
raw = str(data or "")
|
||||||
|
if raw.isprintable():
|
||||||
|
# Plain typing (no Enter / BS / ESC): just append.
|
||||||
|
return (cur + raw)[:limit] if len(cur) < limit else cur, completed
|
||||||
i = 0
|
i = 0
|
||||||
while i < len(raw):
|
while i < len(raw):
|
||||||
ch = raw[i]
|
ch = raw[i]
|
||||||
|
|
|
||||||
525
netx_api/webcrt_cmd_audit.py
Normal file
525
netx_api/webcrt_cmd_audit.py
Normal file
|
|
@ -0,0 +1,525 @@
|
||||||
|
"""Server-side command audit for WebCRT: track the device's own input row.
|
||||||
|
|
||||||
|
Why: inferring commands from browser keystrokes / xterm snapshots races with the
|
||||||
|
device echo (fast typing, Tab, history recall, slow VMs) and records fragments such
|
||||||
|
as ``R2#s`` or ``cddontainer/r/``. The device itself always knows what it executes:
|
||||||
|
when it echoes the line feed for our Enter, the *current input row* is exactly the
|
||||||
|
prompt + command it is about to run.
|
||||||
|
|
||||||
|
``LineScreen`` is a tiny single-row VT100 model (CR/BS/CSI cursor + erase).
|
||||||
|
``CommandTracker`` owns one ``LineScreen`` per session and resolves each Enter we
|
||||||
|
send against the first line feed the device echoes back. Everything here is pure
|
||||||
|
(no I/O) and cheap: output before the last line feed is skipped unless an Enter
|
||||||
|
is waiting for its echo.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from collections import deque
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Single-row terminal model
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
_TOKEN_RE = re.compile(
|
||||||
|
r"(\x1b\[[0-?]*[ -/]*[@-~]" # CSI
|
||||||
|
r"|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)" # OSC
|
||||||
|
r"|\x1b[()][0-9A-Za-z]" # charset
|
||||||
|
r"|\x1b[@-Z\\-_0-9=>]" # 2-byte ESC
|
||||||
|
r"|[\x00-\x1f\x7f])" # C0 + DEL
|
||||||
|
)
|
||||||
|
# Incomplete escape at the end of a chunk — carry into the next feed.
|
||||||
|
_PARTIAL_ESC_RE = re.compile(r"\x1b(?:\[[0-?]*[ -/]*|\][^\x07\x1b]*\x1b?|[()])?\Z")
|
||||||
|
_MAX_CARRY = 96
|
||||||
|
|
||||||
|
|
||||||
|
class LineScreen:
|
||||||
|
"""Current input row of a terminal (no scrolling / wrapping)."""
|
||||||
|
|
||||||
|
__slots__ = ("cells", "cur", "_carry")
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.cells: list[str] = []
|
||||||
|
self.cur = 0
|
||||||
|
self._carry = ""
|
||||||
|
|
||||||
|
def reset_row(self) -> None:
|
||||||
|
self.cells = []
|
||||||
|
self.cur = 0
|
||||||
|
|
||||||
|
def text(self) -> str:
|
||||||
|
return "".join(self.cells).rstrip()
|
||||||
|
|
||||||
|
def _put(self, run: str) -> None:
|
||||||
|
if not run:
|
||||||
|
return
|
||||||
|
n = len(run)
|
||||||
|
cells = self.cells
|
||||||
|
cur = self.cur
|
||||||
|
if cur == len(cells):
|
||||||
|
cells.extend(run)
|
||||||
|
else:
|
||||||
|
if cur > len(cells):
|
||||||
|
cells.extend(" " * (cur - len(cells)))
|
||||||
|
cells[cur : cur + n] = run
|
||||||
|
self.cur = cur + n
|
||||||
|
|
||||||
|
def apply_token(self, tok: str) -> None:
|
||||||
|
"""Apply one control token (not LF — the tracker owns line feeds)."""
|
||||||
|
c0 = tok[0]
|
||||||
|
if c0 == "\r":
|
||||||
|
self.cur = 0
|
||||||
|
elif c0 == "\b":
|
||||||
|
if self.cur > 0:
|
||||||
|
self.cur -= 1
|
||||||
|
elif c0 == "\t":
|
||||||
|
self._put(" " * (8 - (self.cur % 8)))
|
||||||
|
elif c0 == "\x1b":
|
||||||
|
if len(tok) >= 3 and tok[1] == "[":
|
||||||
|
self._csi(tok[2:-1], tok[-1])
|
||||||
|
elif tok == "\x1bc":
|
||||||
|
self.reset_row()
|
||||||
|
# every other control byte (BEL, NUL, DEL, SO/SI …) is ignored
|
||||||
|
|
||||||
|
def _csi(self, params: str, final: str) -> None:
|
||||||
|
if final not in "DCKP@XGHfJ":
|
||||||
|
return
|
||||||
|
if params and params[0] in "?>=":
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
first = params.split(";", 1)[0]
|
||||||
|
n = int(first) if first else 0
|
||||||
|
except ValueError:
|
||||||
|
n = 0
|
||||||
|
cells = self.cells
|
||||||
|
if final == "D":
|
||||||
|
self.cur = max(0, self.cur - (n or 1))
|
||||||
|
elif final == "C":
|
||||||
|
self.cur += n or 1
|
||||||
|
elif final == "K":
|
||||||
|
if n == 0:
|
||||||
|
del cells[self.cur :]
|
||||||
|
elif n == 1:
|
||||||
|
for i in range(min(self.cur + 1, len(cells))):
|
||||||
|
cells[i] = " "
|
||||||
|
else:
|
||||||
|
self.reset_row()
|
||||||
|
elif final == "P":
|
||||||
|
del cells[self.cur : self.cur + (n or 1)]
|
||||||
|
elif final == "@":
|
||||||
|
if self.cur > len(cells):
|
||||||
|
cells.extend(" " * (self.cur - len(cells)))
|
||||||
|
cells[self.cur : self.cur] = [" "] * (n or 1)
|
||||||
|
elif final == "X":
|
||||||
|
for i in range(self.cur, min(self.cur + (n or 1), len(cells))):
|
||||||
|
cells[i] = " "
|
||||||
|
elif final == "G":
|
||||||
|
self.cur = max(0, (n or 1) - 1)
|
||||||
|
elif final in "Hf":
|
||||||
|
parts = params.split(";")
|
||||||
|
try:
|
||||||
|
col = int(parts[1]) if len(parts) > 1 and parts[1] else 1
|
||||||
|
except ValueError:
|
||||||
|
col = 1
|
||||||
|
self.cur = max(0, col - 1)
|
||||||
|
elif final == "J":
|
||||||
|
if n in (2, 3):
|
||||||
|
self.reset_row()
|
||||||
|
elif n == 0:
|
||||||
|
del cells[self.cur :]
|
||||||
|
|
||||||
|
def feed_row(self, text: str) -> None:
|
||||||
|
"""Apply text that contains no line feed."""
|
||||||
|
if not text:
|
||||||
|
return
|
||||||
|
if not _has_ctl(text):
|
||||||
|
self._put(text)
|
||||||
|
return
|
||||||
|
parts = _TOKEN_RE.split(text)
|
||||||
|
for i, part in enumerate(parts):
|
||||||
|
if not part:
|
||||||
|
continue
|
||||||
|
if i & 1:
|
||||||
|
self.apply_token(part)
|
||||||
|
else:
|
||||||
|
self._put(part)
|
||||||
|
|
||||||
|
def take_carry(self, text: str) -> str:
|
||||||
|
"""Prepend a carried partial escape; stash a new trailing partial."""
|
||||||
|
if self._carry:
|
||||||
|
text = self._carry + text
|
||||||
|
self._carry = ""
|
||||||
|
if text and "\x1b" in text[-_MAX_CARRY:]:
|
||||||
|
m = _PARTIAL_ESC_RE.search(text, max(0, len(text) - _MAX_CARRY))
|
||||||
|
if m and m.group(0):
|
||||||
|
self._carry = m.group(0)
|
||||||
|
text = text[: m.start()]
|
||||||
|
return text
|
||||||
|
|
||||||
|
|
||||||
|
def _has_ctl(text: str) -> bool:
|
||||||
|
# str.isprintable is a fast C scan; only fall back to token split when needed.
|
||||||
|
return not text.isprintable()
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Prompt splitting
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
_PROMPT_RES: tuple[re.Pattern[str], ...] = (
|
||||||
|
# Huawei / H3C / ZTE user & system view: <r1> [r1] [~r1] [*r1-GigabitEthernet0/0/1]
|
||||||
|
re.compile(r"^<[^<>\s]{1,63}>"),
|
||||||
|
re.compile(r"^\[[~*]?[A-Za-z0-9][\w.\-/:+]{0,79}\]"),
|
||||||
|
# Cisco / Arista / ZTE / Ruijie: R2# R2> R2(config-if)# SW-1(config)#
|
||||||
|
re.compile(r"^[A-Za-z0-9][\w.\-/:+]{0,62}(?:\([^()\s]{1,60}\))*[#>]"),
|
||||||
|
# MikroTik: [admin@MikroTik] > [admin@MikroTik] /ip>
|
||||||
|
re.compile(r"^\[[\w.\-]+@[\w.\-]+\][^\n]{0,40}?>\s?"),
|
||||||
|
)
|
||||||
|
_LINUX_PROMPT_RES: tuple[re.Pattern[str], ...] = (
|
||||||
|
# [user@host dir]$ user@host:~/dir$ (venv) user@host:~#
|
||||||
|
re.compile(r"^(?:\([^()]{1,40}\)\s*)?\[[^\]]{1,80}\]\s?[$#%>]\s?"),
|
||||||
|
re.compile(r"^(?:\([^()]{1,40}\)\s*)?[\w.\-]+@[\w.\-]+(?::|\s)[^\s$#%>]{0,120}\s?[$#%>]\s?"),
|
||||||
|
re.compile(r"^[^\s$#%>]{1,80}[$#%]\s"),
|
||||||
|
)
|
||||||
|
_NOT_PROMPT_RE = re.compile(r"^\[[YyNn]/[YyNn]\]")
|
||||||
|
_PASSWORD_ROW_RE = re.compile(r"(?:password|passwd|passphrase|密码)\s*[::>]\s*\**\s*$", re.IGNORECASE)
|
||||||
|
_PAGER_ROW_RE = re.compile(r"-{2,}\s*more\s*-{2,}|--\s*more\s*--|\(END\)|lines?\s+\d+-\d+", re.IGNORECASE)
|
||||||
|
|
||||||
|
|
||||||
|
def split_prompt(row: str, hint: str = "", *, linux_like: bool = False) -> tuple[str, str] | None:
|
||||||
|
"""Split ``row`` into ``(prompt, command)``; None when no CLI prompt is recognised.
|
||||||
|
|
||||||
|
``prompt`` keeps the whitespace before the command, so ``prompt + command`` is the
|
||||||
|
row exactly as the device displayed it.
|
||||||
|
"""
|
||||||
|
s = row.rstrip()
|
||||||
|
if not s or _NOT_PROMPT_RE.match(s) or _PAGER_ROW_RE.search(s[:80]):
|
||||||
|
return None
|
||||||
|
h = hint.rstrip()
|
||||||
|
if h and s.startswith(h):
|
||||||
|
return _cut(s, len(h))
|
||||||
|
pats = _LINUX_PROMPT_RES + _PROMPT_RES if linux_like else _PROMPT_RES + _LINUX_PROMPT_RES
|
||||||
|
for pat in pats:
|
||||||
|
m = pat.match(s)
|
||||||
|
if m:
|
||||||
|
return _cut(s, m.end())
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _cut(s: str, end: int) -> tuple[str, str]:
|
||||||
|
rest = s[end:]
|
||||||
|
cmd = rest.lstrip()
|
||||||
|
return s[: len(s) - len(cmd)], cmd.rstrip()
|
||||||
|
|
||||||
|
|
||||||
|
def looks_like_password_row(row: str) -> bool:
|
||||||
|
return bool(_PASSWORD_ROW_RE.search(row.rstrip()))
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Command tracker
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class AuditCmd:
|
||||||
|
command: str # prompt + command as the device displayed it
|
||||||
|
prompt: str = ""
|
||||||
|
input: str = "" # command only
|
||||||
|
redacted: bool = False
|
||||||
|
source: str = "stdin"
|
||||||
|
via: str = "device_echo"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class _Pending:
|
||||||
|
t: float
|
||||||
|
typed: str
|
||||||
|
snapshot: str # input row at the time we sent Enter
|
||||||
|
prompt_hint: str
|
||||||
|
client_hints: list[str]
|
||||||
|
source: str
|
||||||
|
# "bare": Enter with nothing typed in this payload (history recall / empty Enter) —
|
||||||
|
# consumes the first line feed whatever it shows.
|
||||||
|
bare: bool = False
|
||||||
|
# Tab can make the device redraw the line after a CRLF before the Enter echo; keep the
|
||||||
|
# latest prompt+command row seen and settle once the real output starts.
|
||||||
|
has_tab: bool = False
|
||||||
|
cand: tuple[str, str] | None = None
|
||||||
|
cand_t: float = 0.0
|
||||||
|
|
||||||
|
|
||||||
|
_TAB_SETTLE_SEC = 1.2
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class CommandTracker:
|
||||||
|
"""Per-session: device input row + FIFO of Enters waiting for their echo."""
|
||||||
|
|
||||||
|
linux_like: bool = False
|
||||||
|
expire_sec: float = 3.0
|
||||||
|
max_command: int = 512
|
||||||
|
screen: LineScreen = field(default_factory=LineScreen)
|
||||||
|
lock: threading.Lock = field(default_factory=threading.Lock)
|
||||||
|
pending: deque[_Pending] = field(default_factory=deque)
|
||||||
|
prompt_hint: str = ""
|
||||||
|
line_started: bool = False
|
||||||
|
password_mode: bool = False
|
||||||
|
# Rows abandoned with Ctrl-C: some devices (IOS, bash) echo that row + CRLF, and that
|
||||||
|
# line feed must not be mistaken for the echo of the next Enter.
|
||||||
|
abandoned: list[tuple[str, float]] = field(default_factory=list)
|
||||||
|
_typed: str = ""
|
||||||
|
|
||||||
|
# ----- device output -------------------------------------------------- #
|
||||||
|
def feed(self, text: str) -> list[AuditCmd]:
|
||||||
|
"""Feed decoded device output; returns commands resolved by line-feed echoes."""
|
||||||
|
out: list[AuditCmd] = []
|
||||||
|
with self.lock:
|
||||||
|
scr = self.screen
|
||||||
|
text = scr.take_carry(text)
|
||||||
|
if not text:
|
||||||
|
return out
|
||||||
|
while text:
|
||||||
|
nl = text.find("\n")
|
||||||
|
if nl < 0:
|
||||||
|
scr.feed_row(text)
|
||||||
|
break
|
||||||
|
self.line_started = False
|
||||||
|
if self.pending:
|
||||||
|
scr.feed_row(text[:nl])
|
||||||
|
self._resolve_one(out)
|
||||||
|
scr.reset_row()
|
||||||
|
text = text[nl + 1 :]
|
||||||
|
continue
|
||||||
|
# Nothing waiting: only the last row matters — skip the bulk.
|
||||||
|
last = text.rfind("\n")
|
||||||
|
scr.reset_row()
|
||||||
|
text = text[last + 1 :]
|
||||||
|
scr.feed_row(text)
|
||||||
|
break
|
||||||
|
self.password_mode = looks_like_password_row(scr.text())
|
||||||
|
if self.line_started and not scr.text():
|
||||||
|
# Row wiped by the device (screen clear / new prompt): start over.
|
||||||
|
self.line_started = False
|
||||||
|
return out
|
||||||
|
|
||||||
|
def _resolve_one(self, out: list[AuditCmd]) -> None:
|
||||||
|
row = self.screen.text()
|
||||||
|
p = self.pending[0]
|
||||||
|
if self.abandoned and not p.bare:
|
||||||
|
now = time.monotonic()
|
||||||
|
self.abandoned = [(r, t) for r, t in self.abandoned if now - t < 5.0]
|
||||||
|
typed = p.typed.replace("\t", "").strip()
|
||||||
|
for i, (ab, _t) in enumerate(self.abandoned):
|
||||||
|
# The device reprints the aborted row; ``startswith`` covers a slow echo
|
||||||
|
# (row snapshot was taken before every typed char came back). A VRP that
|
||||||
|
# keeps the text yields ``garbage`` + our command, which contains ``typed``.
|
||||||
|
if row == ab or (row.startswith(ab) and typed and typed not in row):
|
||||||
|
del self.abandoned[i]
|
||||||
|
return # echo of the aborted line, not our Enter
|
||||||
|
parsed = split_prompt(row, p.prompt_hint, linux_like=self.linux_like)
|
||||||
|
has_cmd = bool(parsed and parsed[1])
|
||||||
|
if p.bare:
|
||||||
|
# A bare Enter (empty line, or executing a recalled history line) is echoed by
|
||||||
|
# the first *prompt* row's line feed; output rows of a command that is still
|
||||||
|
# printing must not consume it (Enter typed ahead during output).
|
||||||
|
if parsed is None:
|
||||||
|
return
|
||||||
|
self.pending.popleft()
|
||||||
|
if has_cmd:
|
||||||
|
out.append(self._make(parsed[0], parsed[1], p.source, "device_echo")) # type: ignore[index]
|
||||||
|
# Enter pressed at a pager left an orphan bare Enter in front of the next
|
||||||
|
# typed line: that line feed belongs to both — do not record it twice.
|
||||||
|
nxt = self.pending[0] if self.pending else None
|
||||||
|
if nxt is not None and not nxt.bare:
|
||||||
|
typed = nxt.typed.replace("\t", "").replace(" ", "").lower()
|
||||||
|
if typed and parsed[1].replace(" ", "").lower().startswith(typed[:3]):
|
||||||
|
self.pending.popleft()
|
||||||
|
return
|
||||||
|
if p.has_tab:
|
||||||
|
if has_cmd:
|
||||||
|
p.cand = (parsed[0], parsed[1]) # type: ignore[index]
|
||||||
|
p.cand_t = time.monotonic()
|
||||||
|
elif p.cand is not None:
|
||||||
|
# Prompt / output row after the echo: the last candidate is the executed line.
|
||||||
|
self._finish_candidate(p, out)
|
||||||
|
return
|
||||||
|
if not has_cmd:
|
||||||
|
# Prompt-only / non-CLI row: earlier output, a prompt reprint — not our echo.
|
||||||
|
return
|
||||||
|
self.pending.popleft()
|
||||||
|
out.append(self._make(parsed[0], parsed[1], p.source, "device_echo")) # type: ignore[index]
|
||||||
|
|
||||||
|
def _finish_candidate(self, p: _Pending, out: list[AuditCmd]) -> None:
|
||||||
|
if self.pending and self.pending[0] is p:
|
||||||
|
self.pending.popleft()
|
||||||
|
if p.cand is not None:
|
||||||
|
out.append(self._make(p.cand[0], p.cand[1], p.source, "device_echo"))
|
||||||
|
p.cand = None
|
||||||
|
|
||||||
|
def _make(self, prompt: str, cmd: str, source: str, via: str) -> AuditCmd:
|
||||||
|
cmd = cmd[: self.max_command]
|
||||||
|
base = prompt.rstrip()
|
||||||
|
# Keep one space after user@host style prompts; network CLIs print none.
|
||||||
|
sep = " " if (len(prompt) > len(base) and (self.linux_like or "@" in base)) else ""
|
||||||
|
return AuditCmd(
|
||||||
|
command=(base + sep + cmd)[: self.max_command],
|
||||||
|
prompt=base,
|
||||||
|
input=cmd,
|
||||||
|
source=source,
|
||||||
|
via=via,
|
||||||
|
)
|
||||||
|
|
||||||
|
def expire(self, now: float | None = None) -> list[AuditCmd]:
|
||||||
|
"""Settle Tab candidates and resolve Enters whose echo never arrived."""
|
||||||
|
out: list[AuditCmd] = []
|
||||||
|
if not self.pending:
|
||||||
|
return out
|
||||||
|
t = time.monotonic() if now is None else now
|
||||||
|
with self.lock:
|
||||||
|
while self.pending:
|
||||||
|
p = self.pending[0]
|
||||||
|
if p.cand is not None and (t - p.cand_t) >= _TAB_SETTLE_SEC:
|
||||||
|
self._finish_candidate(p, out)
|
||||||
|
continue
|
||||||
|
if (t - p.t) >= self.expire_sec:
|
||||||
|
self.pending.popleft()
|
||||||
|
cmd = self._fallback(p)
|
||||||
|
if cmd is not None:
|
||||||
|
out.append(cmd)
|
||||||
|
continue
|
||||||
|
break
|
||||||
|
return out
|
||||||
|
|
||||||
|
def _fallback(self, p: _Pending) -> AuditCmd | None:
|
||||||
|
parsed = split_prompt(p.snapshot, p.prompt_hint, linux_like=self.linux_like)
|
||||||
|
if parsed and parsed[1]:
|
||||||
|
return self._make(parsed[0], parsed[1], p.source, "snapshot")
|
||||||
|
for hint in reversed(p.client_hints):
|
||||||
|
parsed = split_prompt(hint, p.prompt_hint, linux_like=self.linux_like)
|
||||||
|
if parsed and parsed[1]:
|
||||||
|
return self._make(parsed[0], parsed[1], p.source, "client_hint")
|
||||||
|
# Last resort: what the operator typed, under the prompt that was on screen.
|
||||||
|
typed = p.typed.replace("\t", "").strip()
|
||||||
|
if typed and p.prompt_hint:
|
||||||
|
return self._make(p.prompt_hint, typed, p.source, "typed")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def flush_all(self) -> list[AuditCmd]:
|
||||||
|
"""Session closing: resolve every still-pending Enter with the best fallback."""
|
||||||
|
out: list[AuditCmd] = []
|
||||||
|
with self.lock:
|
||||||
|
while self.pending:
|
||||||
|
p = self.pending.popleft()
|
||||||
|
if p.cand is not None:
|
||||||
|
out.append(self._make(p.cand[0], p.cand[1], p.source, "device_echo"))
|
||||||
|
continue
|
||||||
|
cmd = self._fallback(p)
|
||||||
|
if cmd is not None:
|
||||||
|
out.append(cmd)
|
||||||
|
return out
|
||||||
|
|
||||||
|
# ----- operator input -------------------------------------------------- #
|
||||||
|
def on_stdin(
|
||||||
|
self,
|
||||||
|
lines: list[str],
|
||||||
|
*,
|
||||||
|
saw_input: bool,
|
||||||
|
had_enter: bool,
|
||||||
|
client_hints: list[str] | None = None,
|
||||||
|
source: str = "stdin",
|
||||||
|
now: float | None = None,
|
||||||
|
abort: bool = False,
|
||||||
|
abort_text: str = "",
|
||||||
|
) -> list[AuditCmd]:
|
||||||
|
"""Register keystrokes already split into completed ``lines`` by the caller.
|
||||||
|
|
||||||
|
``saw_input``: payload had anything besides bare Enter (captures the prompt).
|
||||||
|
``had_enter``: payload contained at least one line terminator.
|
||||||
|
Returns immediately-final records (password redaction); echo-resolved ones
|
||||||
|
come from ``feed``.
|
||||||
|
"""
|
||||||
|
out: list[AuditCmd] = []
|
||||||
|
t = time.monotonic() if now is None else now
|
||||||
|
with self.lock:
|
||||||
|
row = self.screen.text()
|
||||||
|
if abort:
|
||||||
|
parsed_row = split_prompt(row, self.prompt_hint, linux_like=self.linux_like)
|
||||||
|
if parsed_row and parsed_row[1]:
|
||||||
|
self.abandoned.append((row, t))
|
||||||
|
# What we sent before the Ctrl-C may not have been echoed yet.
|
||||||
|
if abort_text.strip() and self.prompt_hint:
|
||||||
|
self.abandoned.append((self.prompt_hint + abort_text.strip(), t))
|
||||||
|
del self.abandoned[:-6]
|
||||||
|
if saw_input and not self.line_started:
|
||||||
|
# First keystroke of a line: the row is still just the prompt. Prefer the
|
||||||
|
# regex-detected prompt so typed echo never leaks into the hint.
|
||||||
|
if row and not looks_like_password_row(row):
|
||||||
|
known = split_prompt(row, "", linux_like=self.linux_like)
|
||||||
|
if known:
|
||||||
|
self.prompt_hint = known[0]
|
||||||
|
elif row[-1] in "#>$%]":
|
||||||
|
self.prompt_hint = row # exotic CLI prompt
|
||||||
|
else:
|
||||||
|
# "Username:" / "Continue? [y/n]:" … a dialog, not a CLI prompt.
|
||||||
|
self.prompt_hint = ""
|
||||||
|
self.line_started = True
|
||||||
|
if not had_enter:
|
||||||
|
return out
|
||||||
|
hints = [h for h in (client_hints or []) if h]
|
||||||
|
if self.password_mode or looks_like_password_row(row):
|
||||||
|
# Typed secret: never keep it, record a redacted marker.
|
||||||
|
n = max(1, len(lines) or 1)
|
||||||
|
out.extend(AuditCmd(command="***", redacted=True, source=source, via="password") for _ in range(n))
|
||||||
|
self.password_mode = False
|
||||||
|
self.line_started = False
|
||||||
|
return out
|
||||||
|
# No completed text: a bare Enter, possibly executing a recalled history line
|
||||||
|
# whose redraw has not reached us yet.
|
||||||
|
queue_lines = list(lines) or [""]
|
||||||
|
for i, typed in enumerate(queue_lines):
|
||||||
|
self.pending.append(
|
||||||
|
_Pending(
|
||||||
|
t=t,
|
||||||
|
typed=typed,
|
||||||
|
snapshot=row if i == 0 else "",
|
||||||
|
prompt_hint=self.prompt_hint,
|
||||||
|
client_hints=hints[-1:] if i == len(queue_lines) - 1 else [],
|
||||||
|
source=source,
|
||||||
|
bare=not typed,
|
||||||
|
has_tab="\t" in typed,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self.line_started = False
|
||||||
|
# Bound runaway growth (device never echoing).
|
||||||
|
while len(self.pending) > 64:
|
||||||
|
self.pending.popleft()
|
||||||
|
return out
|
||||||
|
|
||||||
|
def immediate(self, lines: list[str], source: str) -> list[AuditCmd]:
|
||||||
|
"""Trusted non-interactive input (post-login commands): record as typed."""
|
||||||
|
return [
|
||||||
|
AuditCmd(command=ln[: self.max_command], input=ln[: self.max_command], source=source, via="trusted")
|
||||||
|
for ln in lines
|
||||||
|
if ln.strip()
|
||||||
|
]
|
||||||
|
|
||||||
|
def current_row(self) -> str:
|
||||||
|
with self.lock:
|
||||||
|
return self.screen.text()
|
||||||
|
|
||||||
|
def seed(self, text: str) -> None:
|
||||||
|
"""Prime the row from the login transcript (prompt printed before the reader)."""
|
||||||
|
self.feed(text)
|
||||||
|
|
||||||
|
def set_row(self, text: str) -> None:
|
||||||
|
"""Force the current input row (e.g. after a duplicate prompt was dropped)."""
|
||||||
|
with self.lock:
|
||||||
|
self.screen.reset_row()
|
||||||
|
self.screen._carry = ""
|
||||||
|
self.screen.feed_row(text)
|
||||||
|
self.line_started = False
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import codecs
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import time
|
import time
|
||||||
|
|
@ -686,6 +687,7 @@ async def websocket_session(websocket: WebSocket, session_id: str) -> None:
|
||||||
replay_text = _decode_bytes(raw_boot, "utf-8")
|
replay_text = _decode_bytes(raw_boot, "utf-8")
|
||||||
sess.bootstrap_replayed = True
|
sess.bootstrap_replayed = True
|
||||||
else:
|
else:
|
||||||
|
sess.flush_session_log()
|
||||||
replay_text = read_session_log_tail(session_id, max_bytes=49152)
|
replay_text = read_session_log_tail(session_id, max_bytes=49152)
|
||||||
if replay_text:
|
if replay_text:
|
||||||
replay_bytes = _encode_text(replay_text, "utf-8")
|
replay_bytes = _encode_text(replay_text, "utf-8")
|
||||||
|
|
@ -699,59 +701,72 @@ async def websocket_session(websocket: WebSocket, session_id: str) -> None:
|
||||||
_log.debug("webcrt bootstrap/replay send failed session=%s", session_id, exc_info=True)
|
_log.debug("webcrt bootstrap/replay send failed session=%s", session_id, exc_info=True)
|
||||||
|
|
||||||
stop = asyncio.Event()
|
stop = asyncio.Event()
|
||||||
stdin_buf: list[str] = []
|
loop = asyncio.get_running_loop()
|
||||||
stdin_audit_lines: list[str] = []
|
io_pool = webcrt_io_executor()
|
||||||
stdin_flush_task: asyncio.Task[None] | None = None
|
wake = asyncio.Event()
|
||||||
|
# Keystrokes are written as they arrive (no timer delay); whatever piled up while a
|
||||||
|
# write was in flight is coalesced into the next one.
|
||||||
|
stdin_q: asyncio.Queue[tuple[str, str | None]] = asyncio.Queue()
|
||||||
|
|
||||||
async def flush_stdin() -> None:
|
async def stdin_writer() -> None:
|
||||||
nonlocal stdin_buf, stdin_audit_lines
|
while True:
|
||||||
if not stdin_buf:
|
data, audit = await stdin_q.get()
|
||||||
return
|
parts = [data]
|
||||||
data = "".join(stdin_buf)
|
audits = [audit] if audit else []
|
||||||
audit_lines = list(stdin_audit_lines)
|
taken = 1
|
||||||
stdin_buf = []
|
while True:
|
||||||
stdin_audit_lines = []
|
try:
|
||||||
|
d2, a2 = stdin_q.get_nowait()
|
||||||
|
except asyncio.QueueEmpty:
|
||||||
|
break
|
||||||
|
parts.append(d2)
|
||||||
|
taken += 1
|
||||||
|
if a2:
|
||||||
|
audits.append(a2)
|
||||||
|
blob = "".join(parts)
|
||||||
|
try:
|
||||||
|
await loop.run_in_executor(
|
||||||
|
io_pool,
|
||||||
|
lambda b=blob, al=audits: sess.write_stdin(
|
||||||
|
b,
|
||||||
|
audit_lines=al or None,
|
||||||
|
audit_line=al[-1] if al else None,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
try:
|
||||||
|
await websocket.send_json(
|
||||||
|
{"type": "status", "state": "error", "message": f"write_failed:{exc}"}
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
stop.set()
|
||||||
|
wake.set()
|
||||||
|
try:
|
||||||
|
await websocket.close(code=4500)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
for _ in range(taken):
|
||||||
|
stdin_q.task_done()
|
||||||
|
|
||||||
|
async def drain_stdin() -> None:
|
||||||
try:
|
try:
|
||||||
await asyncio.get_running_loop().run_in_executor(
|
await asyncio.wait_for(stdin_q.join(), timeout=5.0)
|
||||||
webcrt_io_executor(),
|
except Exception:
|
||||||
lambda: sess.write_stdin(
|
pass
|
||||||
data,
|
|
||||||
audit_lines=audit_lines or None,
|
|
||||||
audit_line=audit_lines[-1] if audit_lines else None,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
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()
|
"""Event-driven stdout pump: the reader thread wakes us, we drain and send at once."""
|
||||||
pending: list[bytes] = []
|
sess.set_notifier(loop, wake)
|
||||||
last_flush = loop.time()
|
dec = None
|
||||||
|
if _normalize_encoding(sess.encoding) != "utf-8":
|
||||||
|
dec = codecs.getincrementaldecoder("gbk")(errors="replace")
|
||||||
|
|
||||||
def _to_browser_bytes(raw: bytes) -> bytes:
|
def _to_browser(raw: bytes) -> bytes:
|
||||||
if _normalize_encoding(sess.encoding) == "utf-8":
|
if dec is None:
|
||||||
return raw
|
return raw
|
||||||
return _decode_bytes(raw, sess.encoding).encode("utf-8", errors="replace")
|
return dec.decode(raw).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
|
|
||||||
|
|
||||||
async def _notify_queue_drops() -> None:
|
async def _notify_queue_drops() -> None:
|
||||||
try:
|
try:
|
||||||
|
|
@ -772,48 +787,48 @@ async def websocket_session(websocket: WebSocket, session_id: str) -> None:
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
while not stop.is_set():
|
wake.set() # output queued before the pump started
|
||||||
# Longer block is cheap now (Condition wait); cuts executor churn when idle.
|
try:
|
||||||
chunk = await loop.run_in_executor(
|
while not stop.is_set():
|
||||||
webcrt_io_executor(), lambda: sess.take_stdout(attach_gen, timeout=0.2)
|
await wake.wait()
|
||||||
)
|
wake.clear()
|
||||||
if chunk == "stale":
|
while not stop.is_set():
|
||||||
break
|
status, data = sess.drain_stdout(attach_gen)
|
||||||
if chunk == "empty":
|
if status == "stale":
|
||||||
if pending and (loop.time() - last_flush) >= 0.016:
|
return
|
||||||
if not await _flush_pending():
|
if data:
|
||||||
|
try:
|
||||||
|
await websocket.send_bytes(_to_browser(data))
|
||||||
|
except Exception:
|
||||||
|
stop.set()
|
||||||
|
return
|
||||||
|
await _notify_queue_drops()
|
||||||
|
if status == "eof":
|
||||||
|
await _notify_queue_drops()
|
||||||
stop.set()
|
stop.set()
|
||||||
|
try:
|
||||||
|
await websocket.send_json(
|
||||||
|
{
|
||||||
|
"type": "status",
|
||||||
|
"state": "closed",
|
||||||
|
"message": sess.close_reason or "device_closed",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return
|
||||||
|
if status != "data":
|
||||||
break
|
break
|
||||||
await _notify_queue_drops()
|
finally:
|
||||||
continue
|
sess.clear_notifier(wake)
|
||||||
if chunk is None:
|
|
||||||
await _flush_pending()
|
|
||||||
await _notify_queue_drops()
|
|
||||||
stop.set()
|
|
||||||
try:
|
|
||||||
await websocket.send_json(
|
|
||||||
{
|
|
||||||
"type": "status",
|
|
||||||
"state": "closed",
|
|
||||||
"message": sess.close_reason or "device_closed",
|
|
||||||
}
|
|
||||||
)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
break
|
|
||||||
pending.append(chunk)
|
|
||||||
if sum(len(p) for p in pending) >= 8192 or (loop.time() - last_flush) >= 0.016:
|
|
||||||
if not await _flush_pending():
|
|
||||||
stop.set()
|
|
||||||
break
|
|
||||||
await _notify_queue_drops()
|
|
||||||
|
|
||||||
reader_task = asyncio.create_task(pump_stdout())
|
reader_task = asyncio.create_task(pump_stdout())
|
||||||
|
writer_task = asyncio.create_task(stdin_writer())
|
||||||
if sess.needs_live_prompt:
|
if sess.needs_live_prompt:
|
||||||
sess.needs_live_prompt = False
|
sess.needs_live_prompt = False
|
||||||
try:
|
try:
|
||||||
await asyncio.get_running_loop().run_in_executor(
|
await loop.run_in_executor(
|
||||||
webcrt_io_executor(),
|
io_pool,
|
||||||
lambda: sess.write_stdin("\r", audit_source="prompt_sync"),
|
lambda: sess.write_stdin("\r", audit_source="prompt_sync"),
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
|
|
@ -829,9 +844,7 @@ async def websocket_session(websocket: WebSocket, session_id: str) -> None:
|
||||||
text = _decode_bytes(bytes(msg_raw["bytes"]), sess.encoding)
|
text = _decode_bytes(bytes(msg_raw["bytes"]), sess.encoding)
|
||||||
except Exception:
|
except Exception:
|
||||||
continue
|
continue
|
||||||
stdin_buf.append(text)
|
stdin_q.put_nowait((text, None))
|
||||||
if stdin_flush_task is None or stdin_flush_task.done():
|
|
||||||
stdin_flush_task = asyncio.create_task(schedule_stdin_flush())
|
|
||||||
continue
|
continue
|
||||||
raw = msg_raw.get("text")
|
raw = msg_raw.get("text")
|
||||||
if raw is None:
|
if raw is None:
|
||||||
|
|
@ -846,23 +859,16 @@ async def websocket_session(websocket: WebSocket, session_id: str) -> None:
|
||||||
if data is None:
|
if data is None:
|
||||||
continue
|
continue
|
||||||
audit_raw = msg.get("audit_line")
|
audit_raw = msg.get("audit_line")
|
||||||
if audit_raw is not None and str(audit_raw).strip():
|
audit = str(audit_raw).strip()[:512] if audit_raw is not None and str(audit_raw).strip() else None
|
||||||
stdin_audit_lines.append(str(audit_raw).strip()[:512])
|
stdin_q.put_nowait((str(data), audit))
|
||||||
stdin_buf.append(str(data))
|
|
||||||
# Coalesce high-frequency keystrokes briefly.
|
|
||||||
if len(stdin_buf) >= 8:
|
|
||||||
if stdin_flush_task and not stdin_flush_task.done():
|
|
||||||
stdin_flush_task.cancel()
|
|
||||||
await flush_stdin()
|
|
||||||
elif stdin_flush_task is None or stdin_flush_task.done():
|
|
||||||
stdin_flush_task = asyncio.create_task(schedule_stdin_flush())
|
|
||||||
elif mtype == "resize":
|
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(webcrt_io_executor(), sess.resize, cols, rows)
|
await loop.run_in_executor(io_pool, sess.resize, cols, rows)
|
||||||
elif mtype == "break":
|
elif mtype == "break":
|
||||||
|
await drain_stdin()
|
||||||
try:
|
try:
|
||||||
await asyncio.get_running_loop().run_in_executor(webcrt_io_executor(), sess.send_break)
|
await loop.run_in_executor(io_pool, sess.send_break)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
await websocket.send_json(
|
await websocket.send_json(
|
||||||
{"type": "status", "state": "error", "message": f"break_failed:{exc}"}
|
{"type": "status", "state": "error", "message": f"break_failed:{exc}"}
|
||||||
|
|
@ -872,7 +878,7 @@ async def websocket_session(websocket: WebSocket, session_id: str) -> None:
|
||||||
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()
|
await drain_stdin()
|
||||||
close_session(
|
close_session(
|
||||||
session_id,
|
session_id,
|
||||||
reason="client_close",
|
reason="client_close",
|
||||||
|
|
@ -885,17 +891,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():
|
wake.set()
|
||||||
stdin_flush_task.cancel()
|
|
||||||
try:
|
try:
|
||||||
await flush_stdin()
|
await drain_stdin()
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
writer_task.cancel()
|
||||||
reader_task.cancel()
|
reader_task.cancel()
|
||||||
try:
|
for t in (writer_task, reader_task):
|
||||||
await reader_task
|
try:
|
||||||
except Exception:
|
await t
|
||||||
pass
|
except BaseException:
|
||||||
|
pass
|
||||||
# Keep device session so UI reconnect / remount can re-attach.
|
# Keep device session so UI reconnect / remount can re-attach.
|
||||||
if get_session(session_id) is not None:
|
if get_session(session_id) is not None:
|
||||||
grace = float(getattr(settings, "webcrt_detach_grace_sec", 120) or 120)
|
grace = float(getattr(settings, "webcrt_detach_grace_sec", 120) or 120)
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,12 @@
|
||||||
"""WebCRT interactive session object (I/O, SFTP, close)."""
|
"""WebCRT interactive session object (I/O, SFTP, close)."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import codecs
|
||||||
import logging
|
import logging
|
||||||
import queue
|
import queue
|
||||||
|
import re
|
||||||
|
import select
|
||||||
|
import socket
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
|
|
@ -16,8 +20,10 @@ from .ne_session_factory import (
|
||||||
extract_cli_prompt_marker,
|
extract_cli_prompt_marker,
|
||||||
should_close_cli_hop_session,
|
should_close_cli_hop_session,
|
||||||
)
|
)
|
||||||
|
from .webcrt_cmd_audit import AuditCmd, CommandTracker
|
||||||
from .webcrt_channel import (
|
from .webcrt_channel import (
|
||||||
_BoundedByteQueue,
|
_BoundedByteQueue,
|
||||||
|
_normalize_encoding,
|
||||||
_audit,
|
_audit,
|
||||||
_decode_bytes,
|
_decode_bytes,
|
||||||
_encode_text,
|
_encode_text,
|
||||||
|
|
@ -31,6 +37,9 @@ from .webcrt_channel import (
|
||||||
|
|
||||||
_log = logging.getLogger("netx.webcrt")
|
_log = logging.getLogger("netx.webcrt")
|
||||||
|
|
||||||
|
# Operator typed an exit-style command (arms the hop prompt-return heuristic).
|
||||||
|
_HOP_EXIT_RE = re.compile(r"(?:^|[\r\n])\s*(?:quit|exit|logout|logoff|q|qu|qui|ex|exi)\s*[\r\n]", re.IGNORECASE)
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class WebcrtSession:
|
class WebcrtSession:
|
||||||
session_id: str
|
session_id: str
|
||||||
|
|
@ -98,6 +107,17 @@ class WebcrtSession:
|
||||||
_last_prompt_line: str = field(default="", repr=False)
|
_last_prompt_line: str = field(default="", repr=False)
|
||||||
_last_audited_command: str = field(default="", repr=False)
|
_last_audited_command: str = field(default="", repr=False)
|
||||||
_last_audited_at: float = field(default=0.0, repr=False)
|
_last_audited_at: float = field(default=0.0, repr=False)
|
||||||
|
# Live audit: device-echo based command tracker (active while the reader runs).
|
||||||
|
_tracker: CommandTracker = field(default_factory=CommandTracker, repr=False)
|
||||||
|
# asyncio wake-up for the WS stdout pump: (loop, event) + "already scheduled" flag.
|
||||||
|
_notify: Any = field(default=None, repr=False)
|
||||||
|
_wake_flag: bool = field(default=False, repr=False)
|
||||||
|
_log_last_flush: float = field(default=0.0, repr=False)
|
||||||
|
_log_lock: threading.Lock = field(default_factory=threading.Lock, repr=False)
|
||||||
|
_hop_exit_until: float = field(default=0.0, repr=False)
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
self._tracker.linux_like = not bool(self.cli_keymap)
|
||||||
|
|
||||||
def touch(self) -> None:
|
def touch(self) -> None:
|
||||||
self.last_activity = time.time()
|
self.last_activity = time.time()
|
||||||
|
|
@ -256,39 +276,55 @@ class WebcrtSession:
|
||||||
self._log_fh = None
|
self._log_fh = None
|
||||||
|
|
||||||
def append_session_log(self, text: str) -> None:
|
def append_session_log(self, text: str) -> None:
|
||||||
if not text or self._log_fh is None:
|
"""Buffered transcript write; flushed by the reader every ~250ms (and on attach)."""
|
||||||
|
fh = self._log_fh
|
||||||
|
if not text or fh is None:
|
||||||
return
|
return
|
||||||
try:
|
try:
|
||||||
self._log_fh.write(text)
|
with self._log_lock:
|
||||||
self._log_fh.flush()
|
fh.write(text)
|
||||||
max_bytes = int(getattr(settings, "webcrt_session_log_max_bytes", 0) or 0)
|
if time.monotonic() - self._log_last_flush >= 0.25:
|
||||||
if max_bytes > 0:
|
self.flush_session_log()
|
||||||
try:
|
|
||||||
pos = int(self._log_fh.tell())
|
|
||||||
except Exception: # noqa: BLE001
|
|
||||||
pos = 0
|
|
||||||
if pos >= max_bytes:
|
|
||||||
self._log_fh.write(
|
|
||||||
f"\n# rotated at {pos} bytes (cap={max_bytes}) ts={_utc_iso()}\n"
|
|
||||||
)
|
|
||||||
self._log_fh.flush()
|
|
||||||
self._log_fh.close()
|
|
||||||
self._log_fh = None
|
|
||||||
# Re-open fresh file (append mode continues same path; rotate by rename).
|
|
||||||
try:
|
|
||||||
path = _session_log_path(self.session_id)
|
|
||||||
rotated = path.with_suffix(path.suffix + f".{int(pos)}.old")
|
|
||||||
if path.exists():
|
|
||||||
path.replace(rotated)
|
|
||||||
except Exception: # noqa: BLE001
|
|
||||||
_log.debug("webcrt session log rotate rename failed", exc_info=True)
|
|
||||||
self.open_session_log()
|
|
||||||
except Exception:
|
except Exception:
|
||||||
_log.warning("webcrt session log append failed session=%s", self.session_id, exc_info=True)
|
_log.warning("webcrt session log append failed session=%s", self.session_id, exc_info=True)
|
||||||
|
|
||||||
def close_session_log(self) -> None:
|
def flush_session_log(self) -> None:
|
||||||
fh = self._log_fh
|
fh = self._log_fh
|
||||||
self._log_fh = None
|
if fh is None:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
with self._log_lock:
|
||||||
|
fh.flush()
|
||||||
|
self._log_last_flush = time.monotonic()
|
||||||
|
max_bytes = int(getattr(settings, "webcrt_session_log_max_bytes", 0) or 0)
|
||||||
|
if max_bytes <= 0:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
pos = int(fh.tell())
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
pos = 0
|
||||||
|
if pos < max_bytes:
|
||||||
|
return
|
||||||
|
fh.write(f"\n# rotated at {pos} bytes (cap={max_bytes}) ts={_utc_iso()}\n")
|
||||||
|
fh.flush()
|
||||||
|
fh.close()
|
||||||
|
self._log_fh = None
|
||||||
|
# Re-open fresh file (rotate by rename).
|
||||||
|
try:
|
||||||
|
path = _session_log_path(self.session_id)
|
||||||
|
rotated = path.with_suffix(path.suffix + f".{int(pos)}.old")
|
||||||
|
if path.exists():
|
||||||
|
path.replace(rotated)
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
_log.debug("webcrt session log rotate rename failed", exc_info=True)
|
||||||
|
self.open_session_log()
|
||||||
|
except Exception:
|
||||||
|
_log.warning("webcrt session log flush failed session=%s", self.session_id, exc_info=True)
|
||||||
|
|
||||||
|
def close_session_log(self) -> None:
|
||||||
|
with self._log_lock:
|
||||||
|
fh = self._log_fh
|
||||||
|
self._log_fh = None
|
||||||
if fh is None:
|
if fh is None:
|
||||||
return
|
return
|
||||||
try:
|
try:
|
||||||
|
|
@ -326,6 +362,57 @@ class WebcrtSession:
|
||||||
return "stale"
|
return "stale"
|
||||||
return chunk # bytes | None
|
return chunk # bytes | None
|
||||||
|
|
||||||
|
# ---- asyncio hand-off (no polling thread per WebSocket) -------------------- #
|
||||||
|
def set_notifier(self, loop: Any, event: Any) -> None:
|
||||||
|
with self._stdout_lock:
|
||||||
|
self._notify = (loop, event)
|
||||||
|
self._wake_flag = False
|
||||||
|
|
||||||
|
def clear_notifier(self, event: Any) -> None:
|
||||||
|
with self._stdout_lock:
|
||||||
|
n = self._notify
|
||||||
|
if n is not None and n[1] is event:
|
||||||
|
self._notify = None
|
||||||
|
|
||||||
|
def bump_attach(self) -> int:
|
||||||
|
"""New WebSocket owns stdout: invalidate the previous pump and wake it."""
|
||||||
|
with self._stdout_lock:
|
||||||
|
self.attach_gen += 1
|
||||||
|
gen = self.attach_gen
|
||||||
|
self._wake(force=True)
|
||||||
|
return gen
|
||||||
|
|
||||||
|
def _wake(self, *, force: bool = False) -> None:
|
||||||
|
"""Called from any thread after queue changes; schedules at most one loop wake-up."""
|
||||||
|
n = self._notify
|
||||||
|
if n is None:
|
||||||
|
return
|
||||||
|
if self._wake_flag and not force:
|
||||||
|
return
|
||||||
|
self._wake_flag = True
|
||||||
|
try:
|
||||||
|
n[0].call_soon_threadsafe(n[1].set)
|
||||||
|
except Exception:
|
||||||
|
self._wake_flag = False
|
||||||
|
self._notify = None
|
||||||
|
|
||||||
|
def drain_stdout(self, attach_gen: int, max_bytes: int = 262144) -> tuple[str, bytes]:
|
||||||
|
"""Non-blocking take for the WS pump.
|
||||||
|
|
||||||
|
Returns ``(status, data)``: ``stale`` (newer WS owns stdout), ``eof`` (reader
|
||||||
|
ended; ``data`` may still hold the final bytes), ``data`` or ``empty``.
|
||||||
|
"""
|
||||||
|
self._wake_flag = False
|
||||||
|
with self._stdout_lock:
|
||||||
|
if attach_gen != self.attach_gen:
|
||||||
|
return "stale", b""
|
||||||
|
chunks, eof = self.out_queue.drain(max_bytes)
|
||||||
|
data = b"".join(chunks)
|
||||||
|
if eof:
|
||||||
|
return "eof", data
|
||||||
|
return ("data" if data else "empty"), data
|
||||||
|
|
||||||
|
# ---- stdin ----------------------------------------------------------------- #
|
||||||
def write_stdin(
|
def write_stdin(
|
||||||
self,
|
self,
|
||||||
data: str,
|
data: str,
|
||||||
|
|
@ -351,24 +438,51 @@ class WebcrtSession:
|
||||||
text = map_network_cli_enter(text, self.conn)
|
text = map_network_cli_enter(text, self.conn)
|
||||||
if not text:
|
if not text:
|
||||||
return
|
return
|
||||||
self._note_stdin_for_audit(
|
if self.cli_hop_guard and (_HOP_EXIT_RE.search(text) or "\x04" in text):
|
||||||
text,
|
self._hop_exit_until = time.monotonic() + 8.0
|
||||||
source=audit_source,
|
live_cmds: list[AuditCmd] = []
|
||||||
audit_line=audit_override,
|
if self._audit_live():
|
||||||
audit_lines=audit_list,
|
# Register Enter first (cheap), send to the device, then publish: the audit
|
||||||
)
|
# row is resolved from the device echo, so nothing here delays the keystroke.
|
||||||
|
hints = list(audit_list or [])
|
||||||
|
if audit_override and (not hints or hints[-1] != audit_override):
|
||||||
|
hints.append(audit_override)
|
||||||
|
live_cmds = self._audit_stdin_live(text, source=str(audit_source or "stdin"), hints=hints)
|
||||||
|
else:
|
||||||
|
self._note_stdin_for_audit(
|
||||||
|
text,
|
||||||
|
source=audit_source,
|
||||||
|
audit_line=audit_override,
|
||||||
|
audit_lines=audit_list,
|
||||||
|
)
|
||||||
|
self._send_raw(text)
|
||||||
|
if live_cmds:
|
||||||
|
self._emit_audit_cmds(live_cmds)
|
||||||
|
self.touch()
|
||||||
|
|
||||||
|
def _send_raw(self, text: str) -> None:
|
||||||
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).
|
||||||
channel = getattr(self.conn, "remote_conn", None)
|
conn = self.conn
|
||||||
|
if conn is None:
|
||||||
|
raise RuntimeError("session_closed")
|
||||||
|
channel = getattr(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 = _encode_text(text, self.encoding)
|
payload = _encode_text(text, self.encoding)
|
||||||
# Paramiko may write partially when the window is full.
|
# Paramiko may write partially when the window is full; the reader
|
||||||
|
# keeps a short channel timeout, so a timed-out send is simply retried.
|
||||||
view = memoryview(payload)
|
view = memoryview(payload)
|
||||||
|
deadline = time.monotonic() + 30.0
|
||||||
while len(view):
|
while len(view):
|
||||||
n = int(channel.send(view) or 0)
|
try:
|
||||||
|
n = int(channel.send(view) or 0)
|
||||||
|
except socket.timeout:
|
||||||
|
if time.monotonic() > deadline or self.closed:
|
||||||
|
raise
|
||||||
|
continue
|
||||||
if n <= 0:
|
if n <= 0:
|
||||||
time.sleep(0.01)
|
time.sleep(0.005)
|
||||||
continue
|
continue
|
||||||
view = view[n:]
|
view = view[n:]
|
||||||
self.bytes_in += len(payload)
|
self.bytes_in += len(payload)
|
||||||
|
|
@ -377,12 +491,79 @@ class WebcrtSession:
|
||||||
channel.write(payload)
|
channel.write(payload)
|
||||||
self.bytes_in += len(payload)
|
self.bytes_in += len(payload)
|
||||||
else:
|
else:
|
||||||
self.conn.write_channel(text)
|
conn.write_channel(text)
|
||||||
self.bytes_in += len(text)
|
self.bytes_in += len(text)
|
||||||
except Exception:
|
except Exception:
|
||||||
|
if self.closed or self.conn is None:
|
||||||
|
raise
|
||||||
self.conn.write_channel(text)
|
self.conn.write_channel(text)
|
||||||
self.bytes_in += len(text)
|
self.bytes_in += len(text)
|
||||||
self.touch()
|
|
||||||
|
# ---- command audit ------------------------------------------------------- #
|
||||||
|
def _audit_live(self) -> bool:
|
||||||
|
"""Device-echo audit is active only while the reader thread feeds the tracker."""
|
||||||
|
t = self._reader
|
||||||
|
return t is not None and t.is_alive()
|
||||||
|
|
||||||
|
def _audit_stdin_live(self, text: str, *, source: str, hints: list[str]) -> list[AuditCmd]:
|
||||||
|
if source == "prompt_sync":
|
||||||
|
return []
|
||||||
|
had_enter = "\r" in text or "\n" in text
|
||||||
|
abort_text = ""
|
||||||
|
with self._cmd_buf_lock:
|
||||||
|
cut = text.find("\x03")
|
||||||
|
if cut >= 0:
|
||||||
|
abort_text, _ = feed_command_line_buffer(self._cmd_buf, text[:cut])
|
||||||
|
self._cmd_buf, buf_lines = feed_command_line_buffer(self._cmd_buf, text)
|
||||||
|
if source == "post_login":
|
||||||
|
return self._tracker.immediate(buf_lines, source) if had_enter else []
|
||||||
|
saw_input = bool(text.strip("\r\n"))
|
||||||
|
return self._tracker.on_stdin(
|
||||||
|
buf_lines,
|
||||||
|
saw_input=saw_input,
|
||||||
|
had_enter=had_enter,
|
||||||
|
client_hints=hints,
|
||||||
|
source=source,
|
||||||
|
abort="\x03" in text,
|
||||||
|
abort_text=abort_text,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _emit_audit_cmds(self, cmds: list[AuditCmd]) -> None:
|
||||||
|
for c in cmds:
|
||||||
|
if not str(c.command).strip():
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
_audit(
|
||||||
|
"command",
|
||||||
|
session_id=self.session_id,
|
||||||
|
ne_id=self.ne_id,
|
||||||
|
ne_name=self.ne_name,
|
||||||
|
ne_ip=self.ne_ip,
|
||||||
|
protocol=self.protocol,
|
||||||
|
owner_user_id=self.owner_user_id,
|
||||||
|
owner_username=self.owner_username,
|
||||||
|
command="***" if c.redacted else str(c.command)[:512],
|
||||||
|
redacted=bool(c.redacted),
|
||||||
|
source=str(c.source or "stdin")[:32],
|
||||||
|
via=c.via,
|
||||||
|
prompt=str(c.prompt or "")[:128],
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
_log.debug("webcrt command audit failed session=%s", self.session_id, exc_info=True)
|
||||||
|
|
||||||
|
def seed_audit_row(self, transcript: str) -> None:
|
||||||
|
"""Prime the input row from the login transcript (prompt printed before the reader)."""
|
||||||
|
try:
|
||||||
|
self._tracker.set_row("")
|
||||||
|
self._tracker.seed(str(transcript or "")[-4000:])
|
||||||
|
except Exception:
|
||||||
|
_log.debug("webcrt audit seed failed session=%s", self.session_id, exc_info=True)
|
||||||
|
|
||||||
|
def reset_audit_row(self, row: str) -> None:
|
||||||
|
try:
|
||||||
|
self._tracker.set_row(row)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
def _note_stdin_for_audit(
|
def _note_stdin_for_audit(
|
||||||
self,
|
self,
|
||||||
|
|
@ -461,6 +642,26 @@ class WebcrtSession:
|
||||||
_log.debug("webcrt command audit failed session=%s", self.session_id, exc_info=True)
|
_log.debug("webcrt command audit failed session=%s", self.session_id, exc_info=True)
|
||||||
|
|
||||||
def _note_stdout_for_audit(self, text: str) -> None:
|
def _note_stdout_for_audit(self, text: str) -> None:
|
||||||
|
"""Feed device output to the audit tracker (live) or the legacy tail buffer."""
|
||||||
|
chunk = str(text or "")
|
||||||
|
if not chunk:
|
||||||
|
return
|
||||||
|
if self._audit_live():
|
||||||
|
cmds = self._tracker.feed(chunk)
|
||||||
|
self._password_mode = self._tracker.password_mode
|
||||||
|
if cmds:
|
||||||
|
self._emit_audit_cmds(cmds)
|
||||||
|
return
|
||||||
|
self._note_stdout_legacy(chunk)
|
||||||
|
|
||||||
|
def _audit_housekeeping(self) -> None:
|
||||||
|
if self._tracker.pending:
|
||||||
|
cmds = self._tracker.expire()
|
||||||
|
if cmds:
|
||||||
|
self._emit_audit_cmds(cmds)
|
||||||
|
self.flush_session_log()
|
||||||
|
|
||||||
|
def _note_stdout_legacy(self, text: str) -> None:
|
||||||
"""Track device prompts so the next typed line can be redacted if it is a password."""
|
"""Track device prompts so the next typed line can be redacted if it is a password."""
|
||||||
chunk = str(text or "")
|
chunk = str(text or "")
|
||||||
if not chunk:
|
if not chunk:
|
||||||
|
|
@ -534,50 +735,74 @@ class WebcrtSession:
|
||||||
)
|
)
|
||||||
self._reader.start()
|
self._reader.start()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _wait_readable(channel: Any, timeout: float) -> None:
|
||||||
|
"""Block until a telnet-style socket is readable (or timeout)."""
|
||||||
|
sock = getattr(channel, "sock", None)
|
||||||
|
try:
|
||||||
|
if sock is not None and sock.fileno() >= 0:
|
||||||
|
select.select([sock], [], [], timeout)
|
||||||
|
return
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
time.sleep(min(timeout, 0.01))
|
||||||
|
|
||||||
def _reader_loop(self) -> None:
|
def _reader_loop(self) -> None:
|
||||||
conn = self.conn
|
conn = self.conn
|
||||||
if conn is None:
|
if conn is None:
|
||||||
self.out_queue.put(None)
|
self.out_queue.put(None)
|
||||||
|
self._wake()
|
||||||
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))
|
poll = max(0.002, float(getattr(settings, "webcrt_reader_poll_sec", 0.01) or 0.01))
|
||||||
|
# Incremental decode: a multi-byte char split across recv() chunks stays intact.
|
||||||
|
dec = codecs.getincrementaldecoder("gbk" if _normalize_encoding(self.encoding) == "gbk" else "utf-8")(
|
||||||
|
errors="replace"
|
||||||
|
)
|
||||||
|
# Paramiko: park in recv() with a short timeout (wakes the moment data arrives)
|
||||||
|
# instead of spin-polling recv_ready().
|
||||||
|
blocking = False
|
||||||
|
if (
|
||||||
|
channel is not None
|
||||||
|
and hasattr(channel, "recv")
|
||||||
|
and hasattr(channel, "recv_ready")
|
||||||
|
and hasattr(channel, "settimeout")
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
channel.settimeout(0.2)
|
||||||
|
blocking = True
|
||||||
|
except Exception:
|
||||||
|
blocking = False
|
||||||
|
last_house = time.monotonic()
|
||||||
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 blocking:
|
||||||
# Paramiko SSH: prefer short blocking recv over fixed spin-sleep.
|
|
||||||
ready = False
|
|
||||||
try:
|
try:
|
||||||
ready = bool(channel.recv_ready())
|
chunk = channel.recv(65536)
|
||||||
except Exception:
|
except socket.timeout:
|
||||||
ready = False
|
chunk = b""
|
||||||
if ready:
|
else:
|
||||||
chunk = channel.recv(16384)
|
if not chunk:
|
||||||
|
break # EOF
|
||||||
|
if not chunk:
|
||||||
|
if channel.recv_ready():
|
||||||
|
continue
|
||||||
|
if getattr(channel, "closed", False) or (
|
||||||
|
hasattr(channel, "exit_status_ready") and channel.exit_status_ready()
|
||||||
|
):
|
||||||
|
break
|
||||||
|
elif channel is not None and hasattr(channel, "recv_ready") and hasattr(channel, "recv"):
|
||||||
|
if channel.recv_ready():
|
||||||
|
chunk = channel.recv(65536)
|
||||||
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:
|
||||||
# Brief block: settimeout + recv wakes sooner than sleep(0.04).
|
time.sleep(poll)
|
||||||
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.
|
||||||
data = channel.read_very_eager()
|
data = channel.read_very_eager()
|
||||||
|
|
@ -588,39 +813,52 @@ class WebcrtSession:
|
||||||
else _encode_text(str(data), self.encoding)
|
else _encode_text(str(data), self.encoding)
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
time.sleep(poll)
|
self._wait_readable(channel, 0.2)
|
||||||
continue
|
|
||||||
else:
|
else:
|
||||||
text = conn.read_channel()
|
text = conn.read_channel()
|
||||||
if text:
|
if text:
|
||||||
chunk = _encode_text(str(text), self.encoding)
|
chunk = _encode_text(str(text), self.encoding)
|
||||||
else:
|
else:
|
||||||
time.sleep(poll)
|
time.sleep(poll)
|
||||||
continue
|
except EOFError:
|
||||||
|
break
|
||||||
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)
|
||||||
|
if channel is not None and getattr(channel, "closed", False):
|
||||||
|
break
|
||||||
time.sleep(0.05)
|
time.sleep(0.05)
|
||||||
continue
|
continue
|
||||||
|
now = time.monotonic()
|
||||||
if chunk:
|
if chunk:
|
||||||
self.touch()
|
self.last_activity = time.time()
|
||||||
self.bytes_out += len(chunk)
|
self.bytes_out += len(chunk)
|
||||||
self.out_queue.put(chunk)
|
self.out_queue.put(chunk)
|
||||||
|
self._wake()
|
||||||
|
decoded = ""
|
||||||
try:
|
try:
|
||||||
decoded = _decode_bytes(chunk, self.encoding)
|
decoded = dec.decode(bytes(chunk))
|
||||||
self.append_session_log(decoded)
|
if decoded:
|
||||||
self._note_stdout_for_audit(decoded)
|
self.append_session_log(decoded)
|
||||||
|
self._note_stdout_for_audit(decoded)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
_log.debug("webcrt reader side-effects failed session=%s", self.session_id, exc_info=True)
|
||||||
if self.cli_hop_guard and self._note_cli_hop_output(chunk):
|
if self.cli_hop_guard and self._note_cli_hop_output(chunk, decoded):
|
||||||
hop_return = True
|
hop_return = True
|
||||||
notice = (
|
notice = (
|
||||||
"\r\n*** WebCRT: 目标会话已结束,已断开代理连接 "
|
"\r\n*** WebCRT: 目标会话已结束,已断开代理连接 "
|
||||||
"(target session ended; closing hop proxy) ***\r\n"
|
"(target session ended; closing hop proxy) ***\r\n"
|
||||||
)
|
)
|
||||||
self.out_queue.put(notice.encode("utf-8", errors="replace"))
|
self.out_queue.put(notice.encode("utf-8", errors="replace"))
|
||||||
|
self._wake()
|
||||||
break
|
break
|
||||||
|
if now - last_house >= 0.25:
|
||||||
|
last_house = now
|
||||||
|
try:
|
||||||
|
self._audit_housekeeping()
|
||||||
|
except Exception:
|
||||||
|
_log.debug("webcrt housekeeping failed session=%s", self.session_id, exc_info=True)
|
||||||
finally:
|
finally:
|
||||||
if hop_return and not self.closed:
|
if hop_return and not self.closed:
|
||||||
try:
|
try:
|
||||||
|
|
@ -630,22 +868,33 @@ class WebcrtSession:
|
||||||
except Exception:
|
except Exception:
|
||||||
self.close("cli_hop_return")
|
self.close("cli_hop_return")
|
||||||
self.out_queue.put(None)
|
self.out_queue.put(None)
|
||||||
|
self._wake()
|
||||||
|
try:
|
||||||
|
self.flush_session_log()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
def _note_cli_hop_output(self, chunk: bytes) -> bool:
|
def _note_cli_hop_output(self, chunk: bytes, text: str | None = None) -> 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:
|
if text is None:
|
||||||
text = _decode_bytes(chunk, self.encoding)
|
try:
|
||||||
except Exception:
|
text = _decode_bytes(chunk, self.encoding)
|
||||||
text = str(chunk)
|
except Exception:
|
||||||
|
text = str(chunk)
|
||||||
self._hop_scan_buf = (self._hop_scan_buf + text)[-12000:]
|
self._hop_scan_buf = (self._hop_scan_buf + text)[-12000:]
|
||||||
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):
|
||||||
self._cli_hop_seen_other_prompt = True
|
self._cli_hop_seen_other_prompt = True
|
||||||
|
# Prompt-only "back on the hop" is ambiguous when hop and target share a hostname
|
||||||
|
# (target ``system-view`` prints the same ``[~r1]`` as the hop). Only trust it
|
||||||
|
# shortly after the operator typed an exit command; the nested "connection closed"
|
||||||
|
# message needs no such gate.
|
||||||
|
armed = self._cli_hop_seen_other_prompt and time.monotonic() <= self._hop_exit_until
|
||||||
return should_close_cli_hop_session(
|
return should_close_cli_hop_session(
|
||||||
self._hop_scan_buf,
|
self._hop_scan_buf,
|
||||||
self.cli_hop_prompt,
|
self.cli_hop_prompt,
|
||||||
seen_other_prompt=self._cli_hop_seen_other_prompt,
|
seen_other_prompt=armed,
|
||||||
)
|
)
|
||||||
|
|
||||||
def run_post_login_commands(self) -> None:
|
def run_post_login_commands(self) -> None:
|
||||||
|
|
@ -660,6 +909,19 @@ class WebcrtSession:
|
||||||
_log.debug("post_login command failed session=%s", self.session_id, exc_info=True)
|
_log.debug("post_login command failed session=%s", self.session_id, exc_info=True)
|
||||||
break
|
break
|
||||||
|
|
||||||
|
def _teardown_conn(self, conn: Any) -> None:
|
||||||
|
self.close_sftp()
|
||||||
|
if conn is None:
|
||||||
|
return
|
||||||
|
for attr in ("remote_conn", "remote_conn_pre", "_netx_jump_client"):
|
||||||
|
obj = getattr(conn, attr, None)
|
||||||
|
if obj is None:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
obj.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
def close(self, reason: str = "closed") -> None:
|
def close(self, reason: str = "closed") -> None:
|
||||||
if self.closed:
|
if self.closed:
|
||||||
return
|
return
|
||||||
|
|
@ -667,14 +929,27 @@ class WebcrtSession:
|
||||||
self.state = "closed"
|
self.state = "closed"
|
||||||
self.close_reason = reason or "closed"
|
self.close_reason = reason or "closed"
|
||||||
self._ready_event.set()
|
self._ready_event.set()
|
||||||
self.close_sftp()
|
# Enters still waiting for a device echo would be lost with the session.
|
||||||
try:
|
try:
|
||||||
close_netmiko_connection(self.conn)
|
pending_cmds = self._tracker.flush_all()
|
||||||
|
if pending_cmds:
|
||||||
|
self._emit_audit_cmds(pending_cmds)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
_log.debug("webcrt audit flush on close failed session=%s", self.session_id, exc_info=True)
|
||||||
|
conn = self.conn
|
||||||
self.conn = None
|
self.conn = None
|
||||||
|
# Tear the transport down off-thread: Netmiko's disconnect() runs vendor cleanup
|
||||||
|
# (prompt probing, several seconds on Huawei) which would stall the event loop /
|
||||||
|
# reaper, and keeps the device VTY occupied longer than needed.
|
||||||
|
threading.Thread(
|
||||||
|
target=self._teardown_conn,
|
||||||
|
args=(conn,),
|
||||||
|
name=f"webcrt-close-{self.session_id[:8]}",
|
||||||
|
daemon=True,
|
||||||
|
).start()
|
||||||
try:
|
try:
|
||||||
self.out_queue.put_nowait(None)
|
self.out_queue.put_nowait(None)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
self._wake(force=True)
|
||||||
self.close_session_log()
|
self.close_session_log()
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,7 @@ from .webcrt_channel import (
|
||||||
_normalize_encoding,
|
_normalize_encoding,
|
||||||
_prime_interactive_channel,
|
_prime_interactive_channel,
|
||||||
_session_log_text,
|
_session_log_text,
|
||||||
|
looks_like_network_prompt,
|
||||||
prepare_bootstrap_output,
|
prepare_bootstrap_output,
|
||||||
uses_network_cli_keymap,
|
uses_network_cli_keymap,
|
||||||
)
|
)
|
||||||
|
|
@ -284,14 +285,16 @@ def _finish_connect(
|
||||||
# Pull post-auth banner/MOTD from the PTY. With interactive no-op session_preparation
|
# Pull post-auth banner/MOTD from the PTY. With interactive no-op session_preparation
|
||||||
# (generic_termserver), Netmiko session_log is often empty — do not discard these bytes.
|
# (generic_termserver), Netmiko session_log is often empty — do not discard these bytes.
|
||||||
# Keep this short: live echo already streamed login; long settle blocks WS stdin.
|
# Keep this short: live echo already streamed login; long settle blocks WS stdin.
|
||||||
|
# Login already ended on a CLI prompt: only a short look for trailing MOTD bytes.
|
||||||
|
prompted_early = _looks_like_cli_prompt(pre_log)
|
||||||
try:
|
try:
|
||||||
early = _capture_raw_channel(conn, duration=0.2)
|
early = _capture_raw_channel(conn, duration=0.06 if prompted_early else 0.2)
|
||||||
except Exception:
|
except Exception:
|
||||||
early = ""
|
early = ""
|
||||||
if early:
|
if early:
|
||||||
sess.push_connect_echo(early)
|
sess.push_connect_echo(early)
|
||||||
seed = f"{pre_log}{early}"
|
seed = f"{pre_log}{early}"
|
||||||
already_prompted = _looks_like_cli_prompt(seed) or _looks_like_cli_prompt(pre_log)
|
already_prompted = _looks_like_cli_prompt(seed) or prompted_early
|
||||||
saw_password_change = bool(
|
saw_password_change = bool(
|
||||||
re.search(
|
re.search(
|
||||||
r"(?i)(change\s*now|please\s*choose|password\s+needs\s+to\s+be\s+changed).{0,80}:",
|
r"(?i)(change\s*now|please\s*choose|password\s+needs\s+to\s+be\s+changed).{0,80}:",
|
||||||
|
|
@ -356,7 +359,7 @@ def _finish_connect(
|
||||||
primed = ""
|
primed = ""
|
||||||
combined = f"{seed}{primed}"
|
combined = f"{seed}{primed}"
|
||||||
# Brief settle only — do not burn seconds here while the terminal shows a prompt.
|
# Brief settle only — do not burn seconds here while the terminal shows a prompt.
|
||||||
settle_sec = 0.12 if already_prompted or _looks_like_cli_prompt(combined) else 0.3
|
settle_sec = 0.05 if already_prompted or _looks_like_cli_prompt(combined) else 0.3
|
||||||
try:
|
try:
|
||||||
more = _capture_raw_channel(conn, duration=settle_sec)
|
more = _capture_raw_channel(conn, duration=settle_sec)
|
||||||
if more:
|
if more:
|
||||||
|
|
@ -395,7 +398,7 @@ def _finish_connect(
|
||||||
if not echoed.strip():
|
if not echoed.strip():
|
||||||
bootstrap = prepare_bootstrap_output(combined)
|
bootstrap = prepare_bootstrap_output(combined)
|
||||||
try:
|
try:
|
||||||
leftover = _capture_raw_channel(conn, duration=0.1)
|
leftover = _capture_raw_channel(conn, duration=0.05)
|
||||||
except Exception:
|
except Exception:
|
||||||
leftover = ""
|
leftover = ""
|
||||||
if (
|
if (
|
||||||
|
|
@ -407,7 +410,7 @@ def _finish_connect(
|
||||||
bootstrap = prepare_bootstrap_output(f"{bootstrap}{leftover}")
|
bootstrap = prepare_bootstrap_output(f"{bootstrap}{leftover}")
|
||||||
else:
|
else:
|
||||||
try:
|
try:
|
||||||
leftover = _capture_raw_channel(conn, duration=0.08)
|
leftover = _capture_raw_channel(conn, duration=0.04)
|
||||||
except Exception:
|
except Exception:
|
||||||
leftover = ""
|
leftover = ""
|
||||||
if (
|
if (
|
||||||
|
|
@ -421,6 +424,19 @@ def _finish_connect(
|
||||||
sess.conn = conn
|
sess.conn = conn
|
||||||
sess.cli_hop_guard = bool(hop_guard)
|
sess.cli_hop_guard = bool(hop_guard)
|
||||||
sess.cli_hop_prompt = str((hop_guard or {}).get("hop_prompt") or "")
|
sess.cli_hop_prompt = str((hop_guard or {}).get("hop_prompt") or "")
|
||||||
|
# Inventory type "generic" (e.g. a quick-connect to a Cisco box) disables the network
|
||||||
|
# key map, so Home/Delete reach IOS/VRP as raw escape sequences. The login prompt tells
|
||||||
|
# us what it really is.
|
||||||
|
if not sess.cli_keymap and str(sess.device_type or "").strip().lower() in (
|
||||||
|
"",
|
||||||
|
"generic",
|
||||||
|
"generic_termserver",
|
||||||
|
"generic_telnet",
|
||||||
|
):
|
||||||
|
last_line = str(echoed or combined or "").replace("\r\n", "\n").replace("\r", "\n").rstrip(" \n").split("\n")[-1]
|
||||||
|
if looks_like_network_prompt(prompt_hint or last_line) or looks_like_network_prompt(last_line):
|
||||||
|
sess.cli_keymap = True
|
||||||
|
sess._tracker.linux_like = False
|
||||||
sess.bootstrap_output = _encode_text(str(bootstrap or ""), sess.encoding)
|
sess.bootstrap_output = _encode_text(str(bootstrap or ""), sess.encoding)
|
||||||
# Nudge Enter on WS attach only when we still need a shell prompt.
|
# Nudge Enter on WS attach only when we still need a shell prompt.
|
||||||
# Never when already at CLI prompt or Username:/Password: (would empty-submit login).
|
# Never when already at CLI prompt or Username:/Password: (would empty-submit login).
|
||||||
|
|
@ -432,6 +448,8 @@ def _finish_connect(
|
||||||
and not _looks_like_login_prompt(final_view)
|
and not _looks_like_login_prompt(final_view)
|
||||||
)
|
)
|
||||||
sess.open_session_log()
|
sess.open_session_log()
|
||||||
|
# Prime the audit tracker with the prompt printed during login (reader starts later).
|
||||||
|
sess.seed_audit_row(echoed or bootstrap or combined)
|
||||||
log_seed = echoed or bootstrap
|
log_seed = echoed or bootstrap
|
||||||
if log_seed:
|
if log_seed:
|
||||||
sess.append_session_log(log_seed if str(log_seed).endswith("\n") else str(log_seed) + "\n")
|
sess.append_session_log(log_seed if str(log_seed).endswith("\n") else str(log_seed) + "\n")
|
||||||
|
|
@ -439,7 +457,7 @@ def _finish_connect(
|
||||||
# Drop late prompt echoes that race into the queue right after reader start.
|
# Drop late prompt echoes that race into the queue right after reader start.
|
||||||
if not prompt_hint and final_view:
|
if not prompt_hint and final_view:
|
||||||
prompt_hint = str(final_view).replace("\r\n", "\n").replace("\r", "\n").strip().split("\n")[-1].strip()
|
prompt_hint = str(final_view).replace("\r\n", "\n").replace("\r", "\n").strip().split("\n")[-1].strip()
|
||||||
settle_deadline = time.time() + (0.2 if already_prompted or saw_password_change else 0.35)
|
settle_deadline = time.time() + (0.08 if already_prompted or saw_password_change else 0.35)
|
||||||
while time.time() < settle_deadline:
|
while time.time() < settle_deadline:
|
||||||
try:
|
try:
|
||||||
chunk = sess.out_queue.get_nowait()
|
chunk = sess.out_queue.get_nowait()
|
||||||
|
|
@ -456,6 +474,8 @@ def _finish_connect(
|
||||||
# A slow VM may emit its FIRST prompt after the reader starts.
|
# A slow VM may emit its FIRST prompt after the reader starts.
|
||||||
# Only drop duplicates when a prompt was already delivered during login.
|
# Only drop duplicates when a prompt was already delivered during login.
|
||||||
if _looks_like_cli_prompt(final_view) and _is_prompt_only_echo(text, prompt_hint):
|
if _looks_like_cli_prompt(final_view) and _is_prompt_only_echo(text, prompt_hint):
|
||||||
|
# Dropped from the browser stream: keep the audit row in step with it.
|
||||||
|
sess.reset_audit_row(prompt_hint)
|
||||||
continue
|
continue
|
||||||
if _looks_like_cli_prompt(text):
|
if _looks_like_cli_prompt(text):
|
||||||
sess.needs_live_prompt = False
|
sess.needs_live_prompt = False
|
||||||
|
|
@ -696,8 +716,7 @@ def mark_attached(session_id: str) -> tuple[WebcrtSession, int]:
|
||||||
raise HTTPException(status_code=404, detail="webcrt_session_not_found")
|
raise HTTPException(status_code=404, detail="webcrt_session_not_found")
|
||||||
# Allow re-attach after brief WS drop (React StrictMode remount / network blip).
|
# Allow re-attach after brief WS drop (React StrictMode remount / network blip).
|
||||||
# Bump generation so the previous WS pump stops and does not steal echo bytes.
|
# Bump generation so the previous WS pump stops and does not steal echo bytes.
|
||||||
sess.attach_gen += 1
|
attach_gen = sess.bump_attach()
|
||||||
attach_gen = sess.attach_gen
|
|
||||||
sess.attached = True
|
sess.attached = True
|
||||||
sess.detach_deadline = None
|
sess.detach_deadline = None
|
||||||
sess.touch()
|
sess.touch()
|
||||||
|
|
|
||||||
239
scripts/bench_webcrt.py
Normal file
239
scripts/bench_webcrt.py
Normal file
|
|
@ -0,0 +1,239 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""WebCRT latency / throughput / audit benchmark against a live netx + lab device.
|
||||||
|
|
||||||
|
python scripts/bench_webcrt.py --ne-id <id> [--prompt-re "<r1>|\\[~?r1\\]"] [--audit]
|
||||||
|
|
||||||
|
Measures (client-side, real WebSocket):
|
||||||
|
* connect_ms create session -> first prompt visible
|
||||||
|
* echo latency single keystroke -> echoed byte (p50/p95/max)
|
||||||
|
* burst echo latency 20 keys sent 3ms apart -> last echo byte
|
||||||
|
* command RTT Enter -> next prompt for a short command
|
||||||
|
* bulk throughput long output command, bytes / seconds
|
||||||
|
With --audit it also types a set of tricky command lines (backspace, tab, history
|
||||||
|
recall, bare Enter) and prints what landed in audit.jsonl for the session.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import statistics
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import websockets
|
||||||
|
|
||||||
|
BASE = os.environ.get("NETX_BASE", "http://127.0.0.1:8890").rstrip("/")
|
||||||
|
USER = os.environ.get("NETX_USER", "admin")
|
||||||
|
PWD = os.environ.get("NETX_PASSWORD", "Admin@1234")
|
||||||
|
|
||||||
|
|
||||||
|
def pct(vals: list[float], p: float) -> float:
|
||||||
|
if not vals:
|
||||||
|
return float("nan")
|
||||||
|
s = sorted(vals)
|
||||||
|
return s[min(len(s) - 1, int(len(s) * p))]
|
||||||
|
|
||||||
|
|
||||||
|
async def main() -> int:
|
||||||
|
ap = argparse.ArgumentParser()
|
||||||
|
ap.add_argument("--ne-id", default="")
|
||||||
|
ap.add_argument("--quick", default="", help="ip:port:user:pass -> quick-connect instead of --ne-id")
|
||||||
|
ap.add_argument("--prompt-re", default=r"(?:<[^>\r\n]+>|\[[^\]\r\n]+\]|[\w.\-]+(?:\([^)]+\))*[#>])\s*$")
|
||||||
|
ap.add_argument("--bulk-cmd", default="display current-configuration")
|
||||||
|
ap.add_argument("--probe-cmd", default="display clock")
|
||||||
|
ap.add_argument("--audit", action="store_true")
|
||||||
|
ap.add_argument("--echo-only", action="store_true", help="only connect + echo latency")
|
||||||
|
ap.add_argument("--audit-cmds", default="")
|
||||||
|
ap.add_argument("--audit-file", default=os.path.join("data", "webcrt", "audit.jsonl"))
|
||||||
|
args = ap.parse_args()
|
||||||
|
prompt_re = re.compile(args.prompt_re)
|
||||||
|
|
||||||
|
c = httpx.Client(base_url=BASE, timeout=60)
|
||||||
|
tok = c.post("/v1/auth/login", json={"username": USER, "password": PWD}).json()["access_token"]
|
||||||
|
h = {"Authorization": f"Bearer {tok}"}
|
||||||
|
t_create = time.perf_counter()
|
||||||
|
if args.quick:
|
||||||
|
ip, port, user, pw = args.quick.split(":", 3)
|
||||||
|
r = c.post("/v1/webcrt/sessions/quick-connect", headers=h, json={
|
||||||
|
"name": "bench", "ip_address": ip, "port": int(port), "protocol": "ssh", "username": user,
|
||||||
|
"password": pw, "save_password": False, "cols": 160, "rows": 50})
|
||||||
|
else:
|
||||||
|
r = c.post("/v1/webcrt/sessions", headers=h, json={"ne_id": args.ne_id, "cols": 160, "rows": 50})
|
||||||
|
r.raise_for_status()
|
||||||
|
sid = r.json()["session_id"]
|
||||||
|
ticket = c.post("/v1/webcrt/ws-ticket", headers=h).json()["ticket"]
|
||||||
|
url = f"{BASE.replace('http', 'ws', 1)}/v1/webcrt/sessions/{sid}/ws?ws_ticket={ticket}"
|
||||||
|
print(f"session={sid}")
|
||||||
|
|
||||||
|
async with websockets.connect(url, max_size=64 * 1024 * 1024) as ws:
|
||||||
|
buf = ""
|
||||||
|
ev = asyncio.Event()
|
||||||
|
last_rx = 0.0
|
||||||
|
rx_bytes = 0
|
||||||
|
|
||||||
|
async def reader() -> None:
|
||||||
|
nonlocal buf, last_rx, rx_bytes
|
||||||
|
dec = __import__("codecs").getincrementaldecoder("utf-8")(errors="replace")
|
||||||
|
async for raw in ws:
|
||||||
|
if isinstance(raw, bytes):
|
||||||
|
buf += dec.decode(raw)
|
||||||
|
rx_bytes += len(raw)
|
||||||
|
last_rx = time.perf_counter()
|
||||||
|
ev.set()
|
||||||
|
else:
|
||||||
|
m = json.loads(raw)
|
||||||
|
if m.get("type") == "status" and m.get("state") in ("error", "closed", "warning"):
|
||||||
|
print("STATUS", m)
|
||||||
|
if m.get("type") == "stdout":
|
||||||
|
buf += m.get("data", "")
|
||||||
|
last_rx = time.perf_counter()
|
||||||
|
ev.set()
|
||||||
|
|
||||||
|
rt = asyncio.create_task(reader())
|
||||||
|
|
||||||
|
def at_prompt() -> bool:
|
||||||
|
tail = re.sub(r"\x1b\[[0-9;?]*[A-Za-z]", "", buf).replace("\r", "\n").rstrip(" ").split("\n")[-1]
|
||||||
|
return bool(prompt_re.search(tail)) and len(tail) < 80
|
||||||
|
|
||||||
|
async def wait_prompt(timeout: float = 60.0, quiet: float = 0.05) -> bool:
|
||||||
|
end = time.perf_counter() + timeout
|
||||||
|
while time.perf_counter() < end:
|
||||||
|
if at_prompt() and time.perf_counter() - last_rx >= quiet:
|
||||||
|
return True
|
||||||
|
await asyncio.sleep(0.005)
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def send(data: str, audit_line: str | None = None) -> None:
|
||||||
|
p = {"type": "stdin", "data": data}
|
||||||
|
if audit_line:
|
||||||
|
p["audit_line"] = audit_line
|
||||||
|
await ws.send(json.dumps(p))
|
||||||
|
|
||||||
|
ok = await wait_prompt(90, 0.3)
|
||||||
|
connect_ms = (time.perf_counter() - t_create) * 1000
|
||||||
|
if not ok:
|
||||||
|
await send("\r")
|
||||||
|
ok = await wait_prompt(15, 0.3)
|
||||||
|
print(f"connect_ms={connect_ms:.0f} prompt={'yes' if ok else 'NO'} tail={buf[-80:]!r}")
|
||||||
|
if not ok:
|
||||||
|
return 2
|
||||||
|
|
||||||
|
# --- single-key echo latency ---
|
||||||
|
lat: list[float] = []
|
||||||
|
for ch in "abcdefghijklmnopqrst":
|
||||||
|
n0 = rx_bytes
|
||||||
|
t0 = time.perf_counter()
|
||||||
|
await send(ch)
|
||||||
|
while rx_bytes == n0 and time.perf_counter() - t0 < 2:
|
||||||
|
await asyncio.sleep(0.0005)
|
||||||
|
lat.append((time.perf_counter() - t0) * 1000)
|
||||||
|
await asyncio.sleep(0.05)
|
||||||
|
await send("\x08" * 20)
|
||||||
|
await asyncio.sleep(0.4)
|
||||||
|
print(f"echo_ms p50={statistics.median(lat):.1f} p95={pct(lat, .95):.1f} max={max(lat):.1f}")
|
||||||
|
|
||||||
|
if args.echo_only:
|
||||||
|
await ws.send(json.dumps({"type": "close"}))
|
||||||
|
await asyncio.sleep(0.3)
|
||||||
|
rt.cancel()
|
||||||
|
return 0
|
||||||
|
|
||||||
|
# --- burst typing: last echo arrival ---
|
||||||
|
await wait_prompt(5, 0.1)
|
||||||
|
n0 = len(buf)
|
||||||
|
t0 = time.perf_counter()
|
||||||
|
for ch in "abcdefghijklmnopqrst":
|
||||||
|
await send(ch)
|
||||||
|
await asyncio.sleep(0.003)
|
||||||
|
t_sent = time.perf_counter()
|
||||||
|
while time.perf_counter() - t_sent < 3 and not buf.endswith("abcdefghijklmnopqrst"):
|
||||||
|
await asyncio.sleep(0.002)
|
||||||
|
print(f"burst_echo_ms={(time.perf_counter() - t_sent) * 1000:.1f} (after last key sent)")
|
||||||
|
await send("\x08" * 20)
|
||||||
|
await asyncio.sleep(0.4)
|
||||||
|
|
||||||
|
# --- command RTT ---
|
||||||
|
rtts: list[float] = []
|
||||||
|
for _ in range(5):
|
||||||
|
await wait_prompt(10, 0.05)
|
||||||
|
n0 = len(buf)
|
||||||
|
t0 = time.perf_counter()
|
||||||
|
await send(args.probe_cmd + "\r")
|
||||||
|
await asyncio.sleep(0.02)
|
||||||
|
await wait_prompt(15, 0.0)
|
||||||
|
# prompt re-appeared after output
|
||||||
|
while len(buf) == n0 and time.perf_counter() - t0 < 10:
|
||||||
|
await asyncio.sleep(0.005)
|
||||||
|
rtts.append((time.perf_counter() - t0) * 1000)
|
||||||
|
print(f"cmd_rtt_ms p50={statistics.median(rtts):.0f} max={max(rtts):.0f} ({args.probe_cmd})")
|
||||||
|
|
||||||
|
# --- bulk ---
|
||||||
|
await wait_prompt(10, 0.2)
|
||||||
|
rx0 = rx_bytes
|
||||||
|
t0 = time.perf_counter()
|
||||||
|
await send(args.bulk_cmd + "\r")
|
||||||
|
await asyncio.sleep(0.2)
|
||||||
|
# page through "More" prompts if any
|
||||||
|
deadline = time.perf_counter() + 90
|
||||||
|
while time.perf_counter() < deadline:
|
||||||
|
tail = buf[-200:]
|
||||||
|
if "More" in tail[-40:]:
|
||||||
|
await send(" ")
|
||||||
|
await asyncio.sleep(0.01)
|
||||||
|
continue
|
||||||
|
if at_prompt() and time.perf_counter() - last_rx > 0.4 and rx_bytes - rx0 > 100:
|
||||||
|
break
|
||||||
|
await asyncio.sleep(0.01)
|
||||||
|
dt = time.perf_counter() - t0 - 0.4
|
||||||
|
print(f"bulk bytes={rx_bytes - rx0} secs={dt:.2f} KB/s={(rx_bytes - rx0) / 1024 / max(dt, 1e-3):.0f}")
|
||||||
|
|
||||||
|
if args.audit:
|
||||||
|
await wait_prompt(10, 0.2)
|
||||||
|
cmds = [__import__("codecs").decode(x, "unicode_escape") for x in args.audit_cmds.split("|") if x] or [
|
||||||
|
"display clock\r",
|
||||||
|
"disX\x08play ver\t\r",
|
||||||
|
"dis ip int br\t\r",
|
||||||
|
"\r",
|
||||||
|
"display clock\r",
|
||||||
|
"\x1b[A\r",
|
||||||
|
"\x1b[A\x1b[A\x1b[B\r",
|
||||||
|
"system-view\r",
|
||||||
|
"sysname r1\r",
|
||||||
|
"quit\r",
|
||||||
|
]
|
||||||
|
for cmd in cmds:
|
||||||
|
await send(cmd)
|
||||||
|
await asyncio.sleep(0.2)
|
||||||
|
await wait_prompt(10, 0.25)
|
||||||
|
if "More" in buf[-40:]:
|
||||||
|
await send(" ")
|
||||||
|
await asyncio.sleep(0.5)
|
||||||
|
|
||||||
|
await ws.send(json.dumps({"type": "close"}))
|
||||||
|
await asyncio.sleep(0.3)
|
||||||
|
rt.cancel()
|
||||||
|
|
||||||
|
time.sleep(0.8)
|
||||||
|
if args.audit:
|
||||||
|
print("--- audit.jsonl commands for session ---")
|
||||||
|
try:
|
||||||
|
with open(args.audit_file, encoding="utf-8") as fh:
|
||||||
|
for line in fh:
|
||||||
|
try:
|
||||||
|
o = json.loads(line)
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
if o.get("event") == "command" and o.get("session_id") == sid:
|
||||||
|
print(repr(o.get("command")), o.get("source"))
|
||||||
|
except OSError as exc:
|
||||||
|
print("cannot read audit:", exc)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(asyncio.run(main()))
|
||||||
189
scripts/e2e_webcrt_input_scenarios.py
Normal file
189
scripts/e2e_webcrt_input_scenarios.py
Normal file
|
|
@ -0,0 +1,189 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Keystroke-level WebCRT audit scenarios against a live device.
|
||||||
|
|
||||||
|
Every key is sent as its own stdin frame (~40ms apart, like a person typing) and the
|
||||||
|
command that landed in audit.jsonl for the session is compared with what the device
|
||||||
|
actually executed.
|
||||||
|
|
||||||
|
python scripts/e2e_webcrt_input_scenarios.py --ne-id <id> --vendor huawei
|
||||||
|
python scripts/e2e_webcrt_input_scenarios.py --ne-id <id> --vendor cisco
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import websockets
|
||||||
|
|
||||||
|
BASE = os.environ.get("NETX_BASE", "http://127.0.0.1:8890").rstrip("/")
|
||||||
|
USER = os.environ.get("NETX_USER", "admin")
|
||||||
|
PWD = os.environ.get("NETX_PASSWORD", "Admin@1234")
|
||||||
|
|
||||||
|
LEFT, RIGHT, UP, DOWN = "\x1b[D", "\x1b[C", "\x1b[A", "\x1b[B"
|
||||||
|
HOME, END, DEL, BS, TAB, CR, CTRL_C = "\x1b[H", "\x1b[F", "\x1b[3~", "\x08", "\t", "\r", "\x03"
|
||||||
|
|
||||||
|
|
||||||
|
def keys(*parts: str | list[str]) -> list[str]:
|
||||||
|
"""Flatten: a plain str is typed char by char, escape sequences stay whole."""
|
||||||
|
out: list[str] = []
|
||||||
|
for p in parts:
|
||||||
|
if isinstance(p, list):
|
||||||
|
out.extend(p)
|
||||||
|
elif p.startswith("\x1b") or p in (BS, TAB, CR, CTRL_C):
|
||||||
|
out.append(p)
|
||||||
|
else:
|
||||||
|
out.extend(list(p))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def scenarios(vendor: str) -> list[tuple[str, list[str], "str | list[str]"]]:
|
||||||
|
"""(name, keystrokes, expected command text after the prompt)."""
|
||||||
|
if vendor == "cisco":
|
||||||
|
full, partial, tabbed, other = "show clock", "show cl", "sh clo", "show version"
|
||||||
|
wrong = "show clxock"
|
||||||
|
else:
|
||||||
|
full, partial, tabbed, other = "display clock", "display cl", "display clo", "display version"
|
||||||
|
wrong = "display clxock"
|
||||||
|
word = full.split()[-1] # clock
|
||||||
|
return [
|
||||||
|
("plain", keys(full, CR), full),
|
||||||
|
("backspace fix", keys(full[:-3] + "x", BS, full[-3:], CR), full),
|
||||||
|
("tab completion", keys(tabbed, TAB, CR), "sh clock" if vendor == "cisco" else full),
|
||||||
|
("left-arrow insert mid-line", keys(full.replace("clock", "cock"), [LEFT] * 3, "l", CR), full),
|
||||||
|
("left-arrow + backspace delete", keys(wrong, [LEFT] * 3, BS, CR), full),
|
||||||
|
("left-arrow + Delete key", keys(wrong, [LEFT] * 4, DEL, CR), full),
|
||||||
|
("left-arrow replace char", keys(full.replace("clock", "clxck"), [LEFT] * 2, BS, "o", CR), full),
|
||||||
|
("home + insert first char", keys(full[1:], HOME, full[0], END, CR), full),
|
||||||
|
("right-arrow after left", keys(full, LEFT, LEFT, RIGHT, RIGHT, CR), full),
|
||||||
|
("up-arrow recall", keys(UP, CR), full),
|
||||||
|
("up-arrow recall then edit tail", keys(UP, [BS] * len(word), other.split()[-1], CR), other),
|
||||||
|
("up up down recall", keys(UP, UP, DOWN, CR), other),
|
||||||
|
("recall + left-arrow delete+insert", keys(UP, [LEFT] * 2, BS, "c", CR), other[:-3] + "con"),
|
||||||
|
("ctrl-c (device decides if line clears)", keys("garbage", CTRL_C, full, CR), ("garbage" + full) if vendor == "huawei" else full),
|
||||||
|
("empty enter x2 (not audited)", keys(CR, CR), ""),
|
||||||
|
("run then recall and run again", keys(full, CR, UP, CR), [full, full]),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
async def run(args: argparse.Namespace) -> int:
|
||||||
|
c = httpx.Client(base_url=BASE, timeout=60)
|
||||||
|
tok = c.post("/v1/auth/login", json={"username": USER, "password": PWD}).json()["access_token"]
|
||||||
|
h = {"Authorization": f"Bearer {tok}"}
|
||||||
|
sid = c.post("/v1/webcrt/sessions", headers=h, json={"ne_id": args.ne_id, "cols": 160, "rows": 50}).json()["session_id"]
|
||||||
|
ticket = c.post("/v1/webcrt/ws-ticket", headers=h).json()["ticket"]
|
||||||
|
url = f"{BASE.replace('http', 'ws', 1)}/v1/webcrt/sessions/{sid}/ws?ws_ticket={ticket}"
|
||||||
|
prompt_re = re.compile(r"(?:<[^>\r\n]+>|\[[^\]\r\n]+\]|[\w.\-]+(?:\([^)]+\))*[#>])\s*$")
|
||||||
|
results: list[tuple[str, str | None, list[str]]] = []
|
||||||
|
|
||||||
|
async with websockets.connect(url, max_size=None) as ws:
|
||||||
|
buf = ""
|
||||||
|
last_rx = 0.0
|
||||||
|
|
||||||
|
async def reader() -> None:
|
||||||
|
nonlocal buf, last_rx
|
||||||
|
try:
|
||||||
|
async for raw in ws:
|
||||||
|
if isinstance(raw, bytes):
|
||||||
|
buf += raw.decode("utf-8", "replace")
|
||||||
|
last_rx = time.perf_counter()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
rt = asyncio.create_task(reader())
|
||||||
|
|
||||||
|
def at_prompt() -> bool:
|
||||||
|
plain = re.sub(r"\x1b\[[0-9;?]*[A-Za-z]", "", buf).replace("\r", "\n")
|
||||||
|
tail = plain.rstrip(" ").split("\n")[-1]
|
||||||
|
return bool(prompt_re.search(tail)) and len(tail) < 80
|
||||||
|
|
||||||
|
async def settle(timeout: float = 20.0, quiet: float = 0.6) -> None:
|
||||||
|
end = time.perf_counter() + timeout
|
||||||
|
while time.perf_counter() < end:
|
||||||
|
if at_prompt() and time.perf_counter() - last_rx >= quiet:
|
||||||
|
return
|
||||||
|
await asyncio.sleep(0.02)
|
||||||
|
|
||||||
|
async def send(data: str) -> None:
|
||||||
|
await ws.send(json.dumps({"type": "stdin", "data": data}))
|
||||||
|
|
||||||
|
await settle(120, 1.0)
|
||||||
|
if not at_prompt():
|
||||||
|
await send("\r")
|
||||||
|
await settle(15, 1.0)
|
||||||
|
sel = [x.strip() for x in args.only.split(",") if x.strip()]
|
||||||
|
for name, ks, expected in scenarios(args.vendor):
|
||||||
|
if sel and not name.startswith(tuple(sel)):
|
||||||
|
continue
|
||||||
|
before = len(read_cmds(sid, args.audit_file))
|
||||||
|
for k in ks:
|
||||||
|
await send(k)
|
||||||
|
await asyncio.sleep(args.key_gap)
|
||||||
|
# Wait for the device (slow VMs echo seconds late), not a fixed delay: first for
|
||||||
|
# output after the last key, then until the expected audit rows exist.
|
||||||
|
sent_at = time.perf_counter()
|
||||||
|
while last_rx < sent_at and time.perf_counter() - sent_at < 15:
|
||||||
|
await asyncio.sleep(0.02)
|
||||||
|
await settle()
|
||||||
|
want = len(expected) if isinstance(expected, list) else (0 if expected == "" else 1)
|
||||||
|
waited = time.perf_counter()
|
||||||
|
while time.perf_counter() - waited < (2.0 if want == 0 else 15.0):
|
||||||
|
if len(read_cmds(sid, args.audit_file)) - before >= want:
|
||||||
|
break
|
||||||
|
await asyncio.sleep(0.1)
|
||||||
|
await asyncio.sleep(1.5) # Tab lines settle ~1.2s; also catches stray extra rows
|
||||||
|
got = read_cmds(sid, args.audit_file)[before:]
|
||||||
|
results.append((name, expected, got))
|
||||||
|
await ws.send(json.dumps({"type": "close"}))
|
||||||
|
await asyncio.sleep(0.5)
|
||||||
|
rt.cancel()
|
||||||
|
|
||||||
|
bad = 0
|
||||||
|
print(f"{'scenario':38} {'result':6} recorded")
|
||||||
|
for name, expected, got in results:
|
||||||
|
tails = [re.sub(r"^(?:<[^>]*>|\[[^\]]*\]|[\w.\-]+(?:\([^)]*\))*[#>])\s*", "", g) for g in got]
|
||||||
|
if isinstance(expected, list):
|
||||||
|
ok = tails == expected
|
||||||
|
elif expected == "":
|
||||||
|
ok = not got
|
||||||
|
else:
|
||||||
|
ok = tails == [expected]
|
||||||
|
bad += 0 if ok else 1
|
||||||
|
print(f"{name:38} {'OK' if ok else 'FAIL':6} {got!r}" + ("" if ok else f" expected {expected!r}"))
|
||||||
|
print(f"\n{len(results) - bad}/{len(results)} scenarios correct")
|
||||||
|
return 1 if bad else 0
|
||||||
|
|
||||||
|
|
||||||
|
def read_cmds(sid: str, path: str) -> list[str]:
|
||||||
|
out: list[str] = []
|
||||||
|
try:
|
||||||
|
with open(path, encoding="utf-8") as fh:
|
||||||
|
for line in fh:
|
||||||
|
if '"event": "command"' not in line:
|
||||||
|
continue
|
||||||
|
o = json.loads(line)
|
||||||
|
if o.get("session_id") == sid:
|
||||||
|
out.append(str(o.get("command") or ""))
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
ap = argparse.ArgumentParser()
|
||||||
|
ap.add_argument("--ne-id", required=True)
|
||||||
|
ap.add_argument("--vendor", choices=["huawei", "cisco"], required=True)
|
||||||
|
ap.add_argument("--only", default="", help="comma-separated scenario name prefixes")
|
||||||
|
ap.add_argument("--key-gap", type=float, default=0.04, help="seconds between keystrokes")
|
||||||
|
ap.add_argument("--audit-file", default=os.path.join("data", "webcrt", "audit.jsonl"))
|
||||||
|
return asyncio.run(run(ap.parse_args()))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
167
scripts/fake_ssh_device.py
Normal file
167
scripts/fake_ssh_device.py
Normal file
|
|
@ -0,0 +1,167 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Local fake network-device SSH server for WebCRT pipeline benchmarks.
|
||||||
|
|
||||||
|
Zero device latency, so a benchmark against it measures netx overhead only.
|
||||||
|
|
||||||
|
python scripts/fake_ssh_device.py [--port 2222] [--user lab] [--password lab]
|
||||||
|
|
||||||
|
Behaviour: Cisco-like ``test#`` prompt, server-side echo with Tab completion
|
||||||
|
(``sh<Tab>`` -> ``show ``), history on Up arrow, ``show clock`` (short output),
|
||||||
|
``bulk <MB>`` (large output) and ``quit``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import socket
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
|
||||||
|
import paramiko
|
||||||
|
|
||||||
|
HOST_KEY = paramiko.RSAKey.generate(2048)
|
||||||
|
PROMPT = b"test#"
|
||||||
|
|
||||||
|
|
||||||
|
class _Server(paramiko.ServerInterface):
|
||||||
|
def __init__(self, user: str, password: str) -> None:
|
||||||
|
self.user, self.password = user, password
|
||||||
|
self.event = threading.Event()
|
||||||
|
|
||||||
|
def check_auth_password(self, username, password): # noqa: ANN001
|
||||||
|
ok = username == self.user and password == self.password
|
||||||
|
return paramiko.AUTH_SUCCESSFUL if ok else paramiko.AUTH_FAILED
|
||||||
|
|
||||||
|
def get_allowed_auths(self, username): # noqa: ANN001
|
||||||
|
return "password"
|
||||||
|
|
||||||
|
def check_channel_request(self, kind, chanid): # noqa: ANN001
|
||||||
|
return paramiko.OPEN_SUCCEEDED if kind == "session" else paramiko.OPEN_FAILED_ADMINISTRATIVELY_PROHIBITED
|
||||||
|
|
||||||
|
def check_channel_pty_request(self, *a): # noqa: ANN002
|
||||||
|
return True
|
||||||
|
|
||||||
|
def check_channel_shell_request(self, channel): # noqa: ANN001
|
||||||
|
self.event.set()
|
||||||
|
return True
|
||||||
|
|
||||||
|
def check_channel_window_change_request(self, *a): # noqa: ANN002
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def _shell(chan: paramiko.Channel) -> None:
|
||||||
|
chan.send(b"\r\nFake device banner\r\n" + PROMPT)
|
||||||
|
line = b""
|
||||||
|
history: list[bytes] = []
|
||||||
|
hidx = 0
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
data = chan.recv(1024)
|
||||||
|
if not data:
|
||||||
|
return
|
||||||
|
i = 0
|
||||||
|
while i < len(data):
|
||||||
|
b = data[i : i + 1]
|
||||||
|
if data[i : i + 3] == b"\x1b[A": # Up
|
||||||
|
if history:
|
||||||
|
hidx = max(0, hidx - 1) if hidx else len(history) - 1
|
||||||
|
chan.send(b"\x08" * len(line) + b" " * len(line) + b"\x08" * len(line))
|
||||||
|
line = history[hidx]
|
||||||
|
chan.send(line)
|
||||||
|
i += 3
|
||||||
|
continue
|
||||||
|
if b == b"\t":
|
||||||
|
if line.startswith(b"sh") and b" " not in line:
|
||||||
|
chan.send(b"ow "[len(line) - 2 :] if len(line) <= 4 else b"")
|
||||||
|
line = b"show "
|
||||||
|
i += 1
|
||||||
|
continue
|
||||||
|
if b in (b"\r", b"\n"):
|
||||||
|
chan.send(b"\r\n")
|
||||||
|
cmd = line.strip()
|
||||||
|
line = b""
|
||||||
|
if cmd:
|
||||||
|
history.append(cmd)
|
||||||
|
hidx = len(history)
|
||||||
|
if cmd == b"quit":
|
||||||
|
chan.close()
|
||||||
|
return
|
||||||
|
if cmd == b"show clock":
|
||||||
|
chan.send(b"2026-10-12 12:00:00\r\nMonday\r\n")
|
||||||
|
elif cmd.startswith(b"bulk"):
|
||||||
|
mb = float(cmd.split()[1]) if len(cmd.split()) > 1 else 1.0
|
||||||
|
row = b"Ethernet1/0/%d unassigned up down --\r\n"
|
||||||
|
sent = 0
|
||||||
|
n = 0
|
||||||
|
buf = []
|
||||||
|
while sent < mb * 1024 * 1024:
|
||||||
|
r = row % (n % 999)
|
||||||
|
buf.append(r)
|
||||||
|
sent += len(r)
|
||||||
|
n += 1
|
||||||
|
if len(buf) >= 400:
|
||||||
|
chan.sendall(b"".join(buf))
|
||||||
|
buf = []
|
||||||
|
if buf:
|
||||||
|
chan.sendall(b"".join(buf))
|
||||||
|
elif cmd:
|
||||||
|
chan.send(b"%% Unknown command\r\n")
|
||||||
|
chan.send(PROMPT)
|
||||||
|
i += 1
|
||||||
|
continue
|
||||||
|
if b in (b"\x08", b"\x7f"):
|
||||||
|
if line:
|
||||||
|
line = line[:-1]
|
||||||
|
chan.send(b"\x08 \x08")
|
||||||
|
i += 1
|
||||||
|
continue
|
||||||
|
line += b
|
||||||
|
chan.send(b)
|
||||||
|
i += 1
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
chan.close()
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _serve(sock: socket.socket, user: str, password: str) -> None:
|
||||||
|
t = paramiko.Transport(sock)
|
||||||
|
t.add_server_key(HOST_KEY)
|
||||||
|
srv = _Server(user, password)
|
||||||
|
try:
|
||||||
|
t.start_server(server=srv)
|
||||||
|
chan = t.accept(30)
|
||||||
|
if chan is None:
|
||||||
|
return
|
||||||
|
srv.event.wait(10)
|
||||||
|
_shell(chan)
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
t.close()
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
ap = argparse.ArgumentParser()
|
||||||
|
ap.add_argument("--port", type=int, default=2222)
|
||||||
|
ap.add_argument("--user", default="lab")
|
||||||
|
ap.add_argument("--password", default="lab")
|
||||||
|
a = ap.parse_args()
|
||||||
|
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||||
|
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||||
|
s.bind(("127.0.0.1", a.port))
|
||||||
|
s.listen(20)
|
||||||
|
print(f"fake device on 127.0.0.1:{a.port} user={a.user}", flush=True)
|
||||||
|
while True:
|
||||||
|
c, _ = s.accept()
|
||||||
|
threading.Thread(target=_serve, args=(c, a.user, a.password), daemon=True).start()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
295
tests/test_webcrt_cmd_audit.py
Normal file
295
tests/test_webcrt_cmd_audit.py
Normal file
|
|
@ -0,0 +1,295 @@
|
||||||
|
"""Device-echo command audit: LineScreen / CommandTracker and the live session wiring."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import time
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
from netx_api.webcrt_cmd_audit import CommandTracker, LineScreen, split_prompt
|
||||||
|
from netx_api.webcrt_session_model import WebcrtSession
|
||||||
|
|
||||||
|
|
||||||
|
def _run(steps, *, linux=False, settle=False):
|
||||||
|
t = CommandTracker(linux_like=linux)
|
||||||
|
res = []
|
||||||
|
for kind, val in steps:
|
||||||
|
if kind == "out":
|
||||||
|
res += t.feed(val)
|
||||||
|
else:
|
||||||
|
lines, saw, enter = val
|
||||||
|
res += t.on_stdin(lines, saw_input=saw, had_enter=enter)
|
||||||
|
if settle:
|
||||||
|
res += t.expire(time.monotonic() + 5.0)
|
||||||
|
return [r.command for r in res]
|
||||||
|
|
||||||
|
|
||||||
|
class LineScreenTests(unittest.TestCase):
|
||||||
|
def _row(self, text: str) -> str:
|
||||||
|
s = LineScreen()
|
||||||
|
s.feed_row(text)
|
||||||
|
return s.text()
|
||||||
|
|
||||||
|
def test_plain_and_cr(self) -> None:
|
||||||
|
self.assertEqual(self._row("abc\rX"), "Xbc")
|
||||||
|
|
||||||
|
def test_backspace_overwrite(self) -> None:
|
||||||
|
self.assertEqual(self._row("disX\x08 \x08play"), "display")
|
||||||
|
|
||||||
|
def test_csi_left_erase_and_delete(self) -> None:
|
||||||
|
self.assertEqual(self._row("<r1>display clock\x1b[13D\x1b[K"), "<r1>")
|
||||||
|
self.assertEqual(self._row("abcdef\x1b[3D\x1b[2P"), "abcf")
|
||||||
|
|
||||||
|
def test_huawei_history_redraw(self) -> None:
|
||||||
|
raw = "<r1>display clock\x1b[13D \x1b[13Ddis ip int brief \x1b[17D \x1b[17Ddisplay clock"
|
||||||
|
self.assertEqual(self._row(raw), "<r1>display clock")
|
||||||
|
|
||||||
|
def test_more_pager_wipe_leaves_blank_row(self) -> None:
|
||||||
|
self.assertEqual(self._row(" ---- More ----\x1b[16D \x1b[16D"), "")
|
||||||
|
|
||||||
|
def test_partial_escape_is_carried_between_chunks(self) -> None:
|
||||||
|
t = CommandTracker()
|
||||||
|
t.feed("<r1>a\x1b[")
|
||||||
|
t.feed("1Db")
|
||||||
|
self.assertEqual(t.current_row(), "<r1>b")
|
||||||
|
|
||||||
|
|
||||||
|
class SplitPromptTests(unittest.TestCase):
|
||||||
|
def test_vendors(self) -> None:
|
||||||
|
self.assertEqual(split_prompt("<r1>display clock"), ("<r1>", "display clock"))
|
||||||
|
self.assertEqual(split_prompt("[~r1-GigabitEthernet0/0/0]ip address 1.1.1.1 24")[1], "ip address 1.1.1.1 24")
|
||||||
|
self.assertEqual(split_prompt("R2(config-if)#no shut"), ("R2(config-if)#", "no shut"))
|
||||||
|
self.assertEqual(split_prompt("[admin@MikroTik] /ip> print")[1], "print")
|
||||||
|
self.assertEqual(split_prompt("[user@host ~]$ ls -l")[1], "ls -l")
|
||||||
|
|
||||||
|
def test_not_a_prompt(self) -> None:
|
||||||
|
self.assertIsNone(split_prompt("[Y/N]:"))
|
||||||
|
self.assertIsNone(split_prompt(" ---- More ----"))
|
||||||
|
self.assertIsNone(split_prompt("Username:admin"))
|
||||||
|
|
||||||
|
|
||||||
|
class TrackerScenarioTests(unittest.TestCase):
|
||||||
|
def test_echo_row_wins_over_lagging_typed_text(self) -> None:
|
||||||
|
got = _run([("out", "<r1>"), ("in", (["show clock"], True, True)), ("out", "sh"), ("out", "ow clock\r\nT\r\n<r1>")])
|
||||||
|
self.assertEqual(got, ["<r1>show clock"])
|
||||||
|
|
||||||
|
def test_backspace_edit(self) -> None:
|
||||||
|
got = _run([("out", "R2#"), ("in", (["shoX"], True, False)), ("out", "shoX\b \bw"),
|
||||||
|
("in", (["show clock"], True, True)), ("out", " clock\r\nT\r\nR2#")])
|
||||||
|
self.assertEqual(got, ["R2#show clock"])
|
||||||
|
|
||||||
|
def test_bare_enter_not_audited(self) -> None:
|
||||||
|
self.assertEqual(_run([("out", "<r1>"), ("in", ([], False, True)), ("out", "\r\n<r1>")]), [])
|
||||||
|
|
||||||
|
def test_history_recall_same_frame_and_separate(self) -> None:
|
||||||
|
a = _run([("out", "<r1>"), ("in", ([], True, True)), ("out", "display clock\r\nx\r\n<r1>")])
|
||||||
|
b = _run([("out", "<r1>"), ("in", ([], True, False)), ("out", "display clock"),
|
||||||
|
("in", ([], False, True)), ("out", "\r\nx\r\n<r1>")])
|
||||||
|
self.assertEqual(a, ["<r1>display clock"])
|
||||||
|
self.assertEqual(b, ["<r1>display clock"])
|
||||||
|
|
||||||
|
def test_tab_redraw_before_enter_echo(self) -> None:
|
||||||
|
# Huawei redraws the line after CRLF on Tab; the executed line is the later row.
|
||||||
|
got = _run([("out", "<r1>"), ("in", (["display ver\t"], True, True)),
|
||||||
|
("out", "\tdisX\x1b[1D \x1b[1Dplay ver\r\n<r1>display version \r\nVRP\r\n<r1>")])
|
||||||
|
self.assertEqual(got, ["<r1>display version"])
|
||||||
|
|
||||||
|
def test_password_redacted(self) -> None:
|
||||||
|
self.assertEqual(_run([("out", "Password:"), ("in", (["secret"], True, True))]), ["***"])
|
||||||
|
|
||||||
|
def test_login_dialog_not_audited(self) -> None:
|
||||||
|
got = _run([("out", "Username:"), ("in", (["admin"], True, True)), ("out", "admin\r\nPassword:")], settle=True)
|
||||||
|
self.assertEqual(got, [])
|
||||||
|
|
||||||
|
def test_multiline_paste_keeps_order_and_skips_empty_echo(self) -> None:
|
||||||
|
got = _run([("out", "R2#"), ("in", (["a", "b"], True, True)), ("out", "a\r\nR2#\r\nR2#b\r\nR2#")])
|
||||||
|
self.assertEqual(got, ["R2#a", "R2#b"])
|
||||||
|
|
||||||
|
def test_pager_enter_is_not_a_command(self) -> None:
|
||||||
|
got = _run([("out", "<r1>"), ("in", (["display cur"], True, True)),
|
||||||
|
("out", "display cur\r\nl1\r\n ---- More ----"), ("in", ([], False, True)),
|
||||||
|
("out", "\x1b[16D \x1b[16D\r\nl2\r\n<r1>")])
|
||||||
|
self.assertEqual(got, ["<r1>display cur"])
|
||||||
|
|
||||||
|
def test_no_echo_falls_back_to_snapshot(self) -> None:
|
||||||
|
self.assertEqual(_run([("out", "R2#show clock"), ("in", ([], False, True))], settle=True), ["R2#show clock"])
|
||||||
|
|
||||||
|
def test_linux_prompt_keeps_single_space(self) -> None:
|
||||||
|
got = _run([("out", "[u@h ~]$ "), ("in", (["ls"], True, True)), ("out", "ls\r\nf\r\n[u@h ~]$ ")], linux=True)
|
||||||
|
self.assertEqual(got, ["[u@h ~]$ ls"])
|
||||||
|
|
||||||
|
def test_enter_typed_ahead_during_output_then_recall(self) -> None:
|
||||||
|
# Up+Enter pressed while the previous command is still printing: output rows must
|
||||||
|
# not swallow the bare Enter.
|
||||||
|
got = _run([("out", "<r1>"), ("in", (["display clock"], True, True)),
|
||||||
|
("in", ([], True, False)), ("in", ([], False, True)),
|
||||||
|
("out", "display clock\r\n2026\r\nSunday\r\n<r1>"),
|
||||||
|
("out", "display clock\r\n2026\r\nSunday\r\n<r1>")])
|
||||||
|
self.assertEqual(got, ["<r1>display clock", "<r1>display clock"])
|
||||||
|
|
||||||
|
def test_pager_enter_then_typed_line_recorded_once(self) -> None:
|
||||||
|
got = _run([("out", "<r1>"), ("in", (["display cur"], True, True)),
|
||||||
|
("out", "display cur\r\nl1\r\n ---- More ----"), ("in", ([], False, True)),
|
||||||
|
("out", "\x1b[16D \x1b[16D\r\nl2\r\n<r1>"),
|
||||||
|
("in", (["display clock"], True, True)), ("out", "display clock\r\nt\r\n<r1>")])
|
||||||
|
self.assertEqual(got, ["<r1>display cur", "<r1>display clock"])
|
||||||
|
|
||||||
|
def test_ctrl_c_echo_does_not_steal_next_enter(self) -> None:
|
||||||
|
# IOS/bash echo the aborted row + CRLF; keys typed meanwhile must still be audited.
|
||||||
|
t = CommandTracker()
|
||||||
|
t.feed("R2#garbage")
|
||||||
|
res = t.on_stdin([], saw_input=True, had_enter=False, abort=True)
|
||||||
|
res += t.on_stdin(["show clock"], saw_input=True, had_enter=True)
|
||||||
|
res += t.feed("\r\nR2#show clock\r\n*22:57:17 UTC\r\nR2#")
|
||||||
|
self.assertEqual([r.command for r in res], ["R2#show clock"])
|
||||||
|
|
||||||
|
def test_ctrl_c_with_slow_echo_still_skips_abort_linefeed(self) -> None:
|
||||||
|
# Only "R2#garb" was echoed when Ctrl-C was typed; the device later prints the
|
||||||
|
# whole aborted row + CRLF, then echoes the line typed meanwhile.
|
||||||
|
t = CommandTracker()
|
||||||
|
t.feed("R2#")
|
||||||
|
t.on_stdin(["garbage"], saw_input=True, had_enter=False)
|
||||||
|
t.feed("garb")
|
||||||
|
res = t.on_stdin([], saw_input=True, had_enter=False, abort=True, abort_text="garbage")
|
||||||
|
res += t.on_stdin(["show clock"], saw_input=True, had_enter=True)
|
||||||
|
res += t.feed("age\r\nR2#show clock\r\n*22:57 UTC\r\nR2#")
|
||||||
|
self.assertEqual([r.command for r in res], ["R2#show clock"])
|
||||||
|
|
||||||
|
def test_ctrl_c_that_keeps_text_is_audited_as_displayed(self) -> None:
|
||||||
|
# VRP ignores Ctrl-C at the prompt: the executed line really is garbage+command.
|
||||||
|
t = CommandTracker()
|
||||||
|
t.feed("<r1>garbage")
|
||||||
|
res = t.on_stdin([], saw_input=True, had_enter=False, abort=True)
|
||||||
|
res += t.on_stdin(["display clock"], saw_input=True, had_enter=True)
|
||||||
|
res += t.feed("display clock\r\nError\r\n<r1>")
|
||||||
|
self.assertEqual([r.command for r in res], ["<r1>garbagedisplay clock"])
|
||||||
|
|
||||||
|
def test_network_prompt_classifier(self) -> None:
|
||||||
|
from netx_api.webcrt_channel import looks_like_network_prompt as f
|
||||||
|
|
||||||
|
for ok in ("R2#", "<r1>", "[~r1]", "R2(config)#"):
|
||||||
|
self.assertTrue(f(ok), ok)
|
||||||
|
for bad in ("[u@h ~]$", "root@h:~#", "~ #", "user@host>"):
|
||||||
|
self.assertFalse(f(bad), bad)
|
||||||
|
|
||||||
|
def test_bulk_output_skips_to_last_row_when_idle(self) -> None:
|
||||||
|
t = CommandTracker()
|
||||||
|
t.feed("".join(f"line {i}\r\n" for i in range(5000)) + "<r1>")
|
||||||
|
self.assertEqual(t.current_row(), "<r1>")
|
||||||
|
|
||||||
|
|
||||||
|
def _live_session(reader_alive: bool = True) -> tuple[WebcrtSession, MagicMock]:
|
||||||
|
conn = MagicMock()
|
||||||
|
conn.RETURN = "\n"
|
||||||
|
conn.remote_conn = MagicMock(spec=["recv_ready", "recv", "exit_status_ready", "resize_pty", "send"])
|
||||||
|
conn.remote_conn.send.side_effect = lambda b: len(b)
|
||||||
|
sess = WebcrtSession(
|
||||||
|
session_id="s-live", ne_id="ne1", ne_name="lab", ne_ip="1.2.3.4", protocol="ssh",
|
||||||
|
cols=80, rows=24, cli_keymap=False, owner_user_id="u1", owner_username="bob", conn=conn,
|
||||||
|
)
|
||||||
|
alive = MagicMock()
|
||||||
|
alive.is_alive.return_value = reader_alive
|
||||||
|
sess._reader = alive
|
||||||
|
return sess, conn
|
||||||
|
|
||||||
|
|
||||||
|
class LiveSessionAuditTests(unittest.TestCase):
|
||||||
|
@patch("netx_api.webcrt_session_model._audit")
|
||||||
|
def test_command_resolved_from_device_echo_not_client_hint(self, mock_audit: MagicMock) -> None:
|
||||||
|
sess, _ = _live_session()
|
||||||
|
sess._note_stdout_for_audit("<r1>")
|
||||||
|
sess.write_stdin("display clock\r", audit_line="<r1>dis") # stale xterm snapshot
|
||||||
|
mock_audit.assert_not_called() # nothing recorded until the device echoes
|
||||||
|
sess._note_stdout_for_audit("display clock\r\n2026-10-12\r\n<r1>")
|
||||||
|
kwargs = mock_audit.call_args.kwargs
|
||||||
|
self.assertEqual(kwargs["command"], "<r1>display clock")
|
||||||
|
self.assertEqual(kwargs["via"], "device_echo")
|
||||||
|
self.assertEqual(kwargs["owner_username"], "bob")
|
||||||
|
|
||||||
|
@patch("netx_api.webcrt_session_model._audit")
|
||||||
|
def test_keystroke_sent_before_audit_work(self, mock_audit: MagicMock) -> None:
|
||||||
|
sess, conn = _live_session()
|
||||||
|
sess._note_stdout_for_audit("<r1>")
|
||||||
|
sess.write_stdin("display clock\r")
|
||||||
|
self.assertTrue(conn.remote_conn.send.called)
|
||||||
|
|
||||||
|
@patch("netx_api.webcrt_session_model._audit")
|
||||||
|
def test_password_prompt_redacted_immediately(self, mock_audit: MagicMock) -> None:
|
||||||
|
sess, _ = _live_session()
|
||||||
|
sess._note_stdout_for_audit("Password:")
|
||||||
|
self.assertTrue(sess._password_mode)
|
||||||
|
sess.write_stdin("hunter2\r")
|
||||||
|
kwargs = mock_audit.call_args.kwargs
|
||||||
|
self.assertEqual(kwargs["command"], "***")
|
||||||
|
self.assertTrue(kwargs["redacted"])
|
||||||
|
|
||||||
|
@patch("netx_api.webcrt_session_model._audit")
|
||||||
|
def test_post_login_is_trusted(self, mock_audit: MagicMock) -> None:
|
||||||
|
sess, _ = _live_session()
|
||||||
|
sess.write_stdin("screen-length 0 temporary\r", audit_source="post_login")
|
||||||
|
kwargs = mock_audit.call_args.kwargs
|
||||||
|
self.assertEqual(kwargs["command"], "screen-length 0 temporary")
|
||||||
|
self.assertEqual(kwargs["source"], "post_login")
|
||||||
|
|
||||||
|
@patch("netx_api.webcrt_session_model._audit")
|
||||||
|
def test_prompt_sync_enter_not_audited(self, mock_audit: MagicMock) -> None:
|
||||||
|
sess, _ = _live_session()
|
||||||
|
sess._note_stdout_for_audit("<r1>display clock")
|
||||||
|
sess.write_stdin("\r", audit_source="prompt_sync")
|
||||||
|
sess._note_stdout_for_audit("\r\n<r1>")
|
||||||
|
mock_audit.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
class StdoutHandOffTests(unittest.TestCase):
|
||||||
|
def test_drain_stdout_batches_and_reports_eof_and_stale(self) -> None:
|
||||||
|
sess, _ = _live_session()
|
||||||
|
sess.attach_gen = 3
|
||||||
|
sess.out_queue.put(b"a")
|
||||||
|
sess.out_queue.put(b"b")
|
||||||
|
self.assertEqual(sess.drain_stdout(2), ("stale", b""))
|
||||||
|
self.assertEqual(sess.drain_stdout(3), ("data", b"ab"))
|
||||||
|
self.assertEqual(sess.drain_stdout(3), ("empty", b""))
|
||||||
|
sess.out_queue.put(b"z")
|
||||||
|
sess.out_queue.put(None)
|
||||||
|
self.assertEqual(sess.drain_stdout(3), ("eof", b"z"))
|
||||||
|
|
||||||
|
def test_wake_is_coalesced(self) -> None:
|
||||||
|
sess, _ = _live_session()
|
||||||
|
loop = MagicMock()
|
||||||
|
sess.set_notifier(loop, MagicMock())
|
||||||
|
sess._wake()
|
||||||
|
sess._wake()
|
||||||
|
sess._wake()
|
||||||
|
self.assertEqual(loop.call_soon_threadsafe.call_count, 1)
|
||||||
|
sess.drain_stdout(sess.attach_gen) # pump consumed -> next put wakes again
|
||||||
|
sess._wake()
|
||||||
|
self.assertEqual(loop.call_soon_threadsafe.call_count, 2)
|
||||||
|
|
||||||
|
def test_reader_keeps_split_utf8_intact(self) -> None:
|
||||||
|
import threading
|
||||||
|
|
||||||
|
sess, conn = _live_session(reader_alive=False)
|
||||||
|
chunks = [b"<r1>\xe4\xbd", b"\xa0\xe5\xa5\xbd\r\n", None]
|
||||||
|
ch = conn.remote_conn
|
||||||
|
ch.recv_ready.return_value = True
|
||||||
|
ch.exit_status_ready.return_value = False
|
||||||
|
seen: list[str] = []
|
||||||
|
it = iter(chunks)
|
||||||
|
|
||||||
|
def _recv(_n: int) -> bytes:
|
||||||
|
nxt = next(it)
|
||||||
|
if nxt is None:
|
||||||
|
sess.closed = True
|
||||||
|
return b""
|
||||||
|
return nxt
|
||||||
|
|
||||||
|
ch.recv.side_effect = _recv
|
||||||
|
sess._note_stdout_for_audit = lambda text: seen.append(text) # type: ignore[method-assign]
|
||||||
|
t = threading.Thread(target=sess._reader_loop, daemon=True)
|
||||||
|
t.start()
|
||||||
|
t.join(timeout=3)
|
||||||
|
self.assertEqual("".join(seen), "<r1>你好\r\n")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
7
web/package-lock.json
generated
7
web/package-lock.json
generated
|
|
@ -12,6 +12,7 @@
|
||||||
"@heroui/styles": "^3.2.5",
|
"@heroui/styles": "^3.2.5",
|
||||||
"@tanstack/react-query": "^5.100.8",
|
"@tanstack/react-query": "^5.100.8",
|
||||||
"@xterm/addon-fit": "^0.11.0",
|
"@xterm/addon-fit": "^0.11.0",
|
||||||
|
"@xterm/addon-webgl": "^0.19.0",
|
||||||
"@xterm/xterm": "^6.0.0",
|
"@xterm/xterm": "^6.0.0",
|
||||||
"@xyflow/react": "^12.11.2",
|
"@xyflow/react": "^12.11.2",
|
||||||
"dagre": "^0.8.5",
|
"dagre": "^0.8.5",
|
||||||
|
|
@ -2065,6 +2066,12 @@
|
||||||
"integrity": "sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g==",
|
"integrity": "sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@xterm/addon-webgl": {
|
||||||
|
"version": "0.19.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@xterm/addon-webgl/-/addon-webgl-0.19.0.tgz",
|
||||||
|
"integrity": "sha512-b3fMOsyLVuCeNJWxolACEUED0vm7qC0cy4wRvf3oURSzDTYVQiGPhTnhWZwIHdvC48Y+oLhvYXnY4XDXPoJo6A==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@xterm/xterm": {
|
"node_modules/@xterm/xterm": {
|
||||||
"version": "6.0.0",
|
"version": "6.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.0.0.tgz",
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@
|
||||||
"@heroui/styles": "^3.2.5",
|
"@heroui/styles": "^3.2.5",
|
||||||
"@tanstack/react-query": "^5.100.8",
|
"@tanstack/react-query": "^5.100.8",
|
||||||
"@xterm/addon-fit": "^0.11.0",
|
"@xterm/addon-fit": "^0.11.0",
|
||||||
|
"@xterm/addon-webgl": "^0.19.0",
|
||||||
"@xterm/xterm": "^6.0.0",
|
"@xterm/xterm": "^6.0.0",
|
||||||
"@xyflow/react": "^12.11.2",
|
"@xyflow/react": "^12.11.2",
|
||||||
"dagre": "^0.8.5",
|
"dagre": "^0.8.5",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { Button, Input } from "@heroui/react";
|
import { Button, Input } from "@heroui/react";
|
||||||
import { forwardRef, useEffect, useImperativeHandle, useRef, useState } from "react";
|
import { forwardRef, useEffect, useImperativeHandle, useRef, useState } from "react";
|
||||||
import { FitAddon } from "@xterm/addon-fit";
|
import { FitAddon } from "@xterm/addon-fit";
|
||||||
|
import { WebglAddon } from "@xterm/addon-webgl";
|
||||||
import { Terminal } from "@xterm/xterm";
|
import { Terminal } from "@xterm/xterm";
|
||||||
import "@xterm/xterm/css/xterm.css";
|
import "@xterm/xterm/css/xterm.css";
|
||||||
import { useI18n } from "../../../i18n";
|
import { useI18n } from "../../../i18n";
|
||||||
|
|
@ -250,13 +251,20 @@ 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.
|
* Server normalizes device encodings (e.g. GBK) to UTF-8 on the wire. Frames are cut at
|
||||||
try {
|
* arbitrary byte boundaries, so decode as a stream (a split multi-byte char must not
|
||||||
return new TextDecoder("utf-8").decode(buf);
|
* turn into U+FFFD).
|
||||||
} catch {
|
*/
|
||||||
return "";
|
function createStdoutDecoder(): (buf: ArrayBuffer) => string {
|
||||||
}
|
const dec = new TextDecoder("utf-8");
|
||||||
|
return (buf) => {
|
||||||
|
try {
|
||||||
|
return dec.decode(buf, { stream: true });
|
||||||
|
} catch {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const PREF_KEY = "netx.webcrt.termPrefs";
|
const PREF_KEY = "netx.webcrt.termPrefs";
|
||||||
|
|
@ -540,6 +548,28 @@ export const WebTerminal = forwardRef<WebTerminalHandle, Props>(function WebTerm
|
||||||
const fit = new FitAddon();
|
const fit = new FitAddon();
|
||||||
term.loadAddon(fit);
|
term.loadAddon(fit);
|
||||||
term.open(host);
|
term.open(host);
|
||||||
|
// GPU renderer: the default DOM renderer is the main cost when a device dumps
|
||||||
|
// thousands of lines. Fall back silently (headless/old GPU/context loss).
|
||||||
|
let webgl: WebglAddon | null = null;
|
||||||
|
try {
|
||||||
|
webgl = new WebglAddon();
|
||||||
|
webgl.onContextLoss(() => {
|
||||||
|
try {
|
||||||
|
webgl?.dispose();
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
webgl = null;
|
||||||
|
});
|
||||||
|
term.loadAddon(webgl);
|
||||||
|
} catch {
|
||||||
|
try {
|
||||||
|
webgl?.dispose();
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
webgl = null;
|
||||||
|
}
|
||||||
termRef.current = term;
|
termRef.current = term;
|
||||||
fitRef.current = fit;
|
fitRef.current = fit;
|
||||||
|
|
||||||
|
|
@ -594,27 +624,31 @@ export const WebTerminal = forwardRef<WebTerminalHandle, Props>(function WebTerm
|
||||||
|
|
||||||
const isActiveSocket = () => !cancelled && !!ws && wsRef.current === ws;
|
const isActiveSocket = () => !cancelled && !!ws && wsRef.current === ws;
|
||||||
|
|
||||||
// Coalesce high-rate stdout into one paint frame to cut xterm write churn.
|
// Echo goes straight to xterm when the terminal is idle (no frame of added latency);
|
||||||
|
// only bursts are coalesced into one paint frame to cut xterm write churn.
|
||||||
let writeBuf = "";
|
let writeBuf = "";
|
||||||
let writeRaf = 0;
|
let writeRaf = 0;
|
||||||
|
let lastWriteAt = 0;
|
||||||
|
const decodeStdout = createStdoutDecoder();
|
||||||
const flushWriteBuf = () => {
|
const flushWriteBuf = () => {
|
||||||
writeRaf = 0;
|
if (writeRaf) {
|
||||||
|
cancelAnimationFrame(writeRaf);
|
||||||
|
writeRaf = 0;
|
||||||
|
}
|
||||||
const chunk = writeBuf;
|
const chunk = writeBuf;
|
||||||
writeBuf = "";
|
writeBuf = "";
|
||||||
if (!chunk || cancelled) return;
|
if (!chunk || cancelled) return;
|
||||||
|
lastWriteAt = performance.now();
|
||||||
term.write(applyKeywordHighlight(chunk, keywordHighlightRef.current));
|
term.write(applyKeywordHighlight(chunk, keywordHighlightRef.current));
|
||||||
};
|
};
|
||||||
const writeStdout = (raw: string) => {
|
const writeStdout = (raw: string) => {
|
||||||
if (!raw || !isActiveSocket()) return;
|
if (!raw || !isActiveSocket()) return;
|
||||||
lastStdoutAtRef.current = performance.now();
|
const now = performance.now();
|
||||||
|
lastStdoutAtRef.current = now;
|
||||||
// Always forward to parent (log buffer / frozen failure transcript); recording flag only gates download.
|
// Always forward to parent (log buffer / frozen failure transcript); recording flag only gates download.
|
||||||
onStdoutRef.current?.(raw);
|
onStdoutRef.current?.(raw);
|
||||||
writeBuf += raw;
|
writeBuf += raw;
|
||||||
if (writeBuf.length >= 16384) {
|
if (writeBuf.length >= 16384 || (!writeRaf && now - lastWriteAt >= 16)) {
|
||||||
if (writeRaf) {
|
|
||||||
cancelAnimationFrame(writeRaf);
|
|
||||||
writeRaf = 0;
|
|
||||||
}
|
|
||||||
flushWriteBuf();
|
flushWriteBuf();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -641,13 +675,13 @@ export const WebTerminal = forwardRef<WebTerminalHandle, Props>(function WebTerm
|
||||||
socket.onmessage = (ev) => {
|
socket.onmessage = (ev) => {
|
||||||
if (!isActiveSocket()) return;
|
if (!isActiveSocket()) return;
|
||||||
if (ev.data instanceof ArrayBuffer) {
|
if (ev.data instanceof ArrayBuffer) {
|
||||||
writeStdout(decodeBytes(ev.data, encodingRef.current));
|
writeStdout(decodeStdout(ev.data));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (typeof Blob !== "undefined" && ev.data instanceof Blob) {
|
if (typeof Blob !== "undefined" && ev.data instanceof Blob) {
|
||||||
void ev.data.arrayBuffer().then((buf) => {
|
void ev.data.arrayBuffer().then((buf) => {
|
||||||
if (!isActiveSocket()) return;
|
if (!isActiveSocket()) return;
|
||||||
writeStdout(decodeBytes(buf, encodingRef.current));
|
writeStdout(decodeStdout(buf));
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -902,6 +936,12 @@ export const WebTerminal = forwardRef<WebTerminalHandle, Props>(function WebTerm
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
|
try {
|
||||||
|
webgl?.dispose();
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
webgl = null;
|
||||||
if (writeRaf) {
|
if (writeRaf) {
|
||||||
cancelAnimationFrame(writeRaf);
|
cancelAnimationFrame(writeRaf);
|
||||||
writeRaf = 0;
|
writeRaf = 0;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue