mirror of
https://github.com/hansjone/netx.git
synced 2026-10-09 03:10:46 +08:00
Dedupe WebCRT command audit on double-fired Enter.
xterm and keydown can both emit carriage return for one keypress; skip duplicate Enter frames on the client and collapse identical audits within 450ms on the server. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
7ca57278fe
commit
52424dce37
3 changed files with 58 additions and 1 deletions
|
|
@ -96,10 +96,25 @@ class WebcrtSession:
|
||||||
_password_mode: bool = field(default=False, repr=False)
|
_password_mode: bool = field(default=False, repr=False)
|
||||||
_stdout_tail: str = field(default="", repr=False)
|
_stdout_tail: str = field(default="", repr=False)
|
||||||
_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_at: float = field(default=0.0, repr=False)
|
||||||
|
|
||||||
def touch(self) -> None:
|
def touch(self) -> None:
|
||||||
self.last_activity = time.time()
|
self.last_activity = time.time()
|
||||||
|
|
||||||
|
def _should_emit_command_audit(self, cmd: str, *, dedup_sec: float = 0.45) -> bool:
|
||||||
|
"""Drop duplicate audits from double-fired Enter (same command within dedup_sec)."""
|
||||||
|
norm = str(cmd or "").strip()
|
||||||
|
if not norm:
|
||||||
|
return False
|
||||||
|
now = time.time()
|
||||||
|
with self._cmd_buf_lock:
|
||||||
|
if norm == self._last_audited_command and (now - self._last_audited_at) < dedup_sec:
|
||||||
|
return False
|
||||||
|
self._last_audited_command = norm
|
||||||
|
self._last_audited_at = now
|
||||||
|
return True
|
||||||
|
|
||||||
def push_connect_echo(self, text: str) -> None:
|
def push_connect_echo(self, text: str) -> None:
|
||||||
"""Queue login transcript for the WS wait-loop (thread-safe)."""
|
"""Queue login transcript for the WS wait-loop (thread-safe)."""
|
||||||
chunk = str(text or "")
|
chunk = str(text or "")
|
||||||
|
|
@ -398,6 +413,8 @@ class WebcrtSession:
|
||||||
for cmd in lines:
|
for cmd in lines:
|
||||||
if not str(cmd).strip():
|
if not str(cmd).strip():
|
||||||
continue
|
continue
|
||||||
|
if not self._should_emit_command_audit(cmd):
|
||||||
|
continue
|
||||||
try:
|
try:
|
||||||
_audit(
|
_audit(
|
||||||
"command",
|
"command",
|
||||||
|
|
|
||||||
|
|
@ -282,6 +282,30 @@ class SessionCommandAuditTests(unittest.TestCase):
|
||||||
sess.write_stdin("\r", audit_line="AL5458-ACC-6120HS(config-if-loopback127)#")
|
sess.write_stdin("\r", audit_line="AL5458-ACC-6120HS(config-if-loopback127)#")
|
||||||
mock_audit.assert_not_called()
|
mock_audit.assert_not_called()
|
||||||
|
|
||||||
|
@patch("netx_api.webcrt_session_model._audit")
|
||||||
|
def test_duplicate_enter_same_command_deduped(self, mock_audit: MagicMock) -> None:
|
||||||
|
conn = MagicMock()
|
||||||
|
conn.RETURN = "\n"
|
||||||
|
conn.remote_conn = MagicMock(spec=["recv_ready", "recv", "exit_status_ready", "resize_pty"])
|
||||||
|
del conn.remote_conn.send
|
||||||
|
conn.write_channel = MagicMock()
|
||||||
|
|
||||||
|
sess = WebcrtSession(
|
||||||
|
session_id="s-dedup",
|
||||||
|
ne_id="ne1",
|
||||||
|
ne_name="lab",
|
||||||
|
ne_ip="1.2.3.4",
|
||||||
|
protocol="ssh",
|
||||||
|
cols=80,
|
||||||
|
rows=24,
|
||||||
|
cli_keymap=False,
|
||||||
|
conn=conn,
|
||||||
|
)
|
||||||
|
line = "AL5458-ACC-6120HS(config)#intt"
|
||||||
|
sess.write_stdin("\r", audit_line=line)
|
||||||
|
sess.write_stdin("\r", audit_line=line)
|
||||||
|
self.assertEqual(mock_audit.call_count, 1)
|
||||||
|
|
||||||
@patch("netx_api.webcrt_session_model._audit")
|
@patch("netx_api.webcrt_session_model._audit")
|
||||||
def test_password_mode_redacts_command(self, mock_audit: MagicMock) -> None:
|
def test_password_mode_redacts_command(self, mock_audit: MagicMock) -> None:
|
||||||
conn = MagicMock()
|
conn = MagicMock()
|
||||||
|
|
|
||||||
|
|
@ -269,6 +269,8 @@ export const WebTerminal = forwardRef<WebTerminalHandle, Props>(function WebTerm
|
||||||
const pasteBridgeOpenRef = useRef(false);
|
const pasteBridgeOpenRef = useRef(false);
|
||||||
pasteBridgeOpenRef.current = pasteBridgeOpen;
|
pasteBridgeOpenRef.current = pasteBridgeOpen;
|
||||||
const findInputRef = useRef<HTMLInputElement | null>(null);
|
const findInputRef = useRef<HTMLInputElement | null>(null);
|
||||||
|
/** Suppress duplicate Enter frames (keydown + xterm onData both fire). */
|
||||||
|
const enterHandledAtRef = useRef(0);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
onStatusRef.current = onStatus;
|
onStatusRef.current = onStatus;
|
||||||
|
|
@ -338,6 +340,16 @@ export const WebTerminal = forwardRef<WebTerminalHandle, Props>(function WebTerm
|
||||||
sendJson(payload);
|
sendJson(payload);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const sendEnterStdin = (term: Terminal, explicitAuditLine?: string) => {
|
||||||
|
enterHandledAtRef.current = performance.now();
|
||||||
|
sendStdinWithAudit("\r", explicitAuditLine ?? auditLineForEnter(term));
|
||||||
|
};
|
||||||
|
|
||||||
|
const isDuplicateEnterFrame = (data: string): boolean => {
|
||||||
|
if (!/^[\r\n]+$/.test(data)) return false;
|
||||||
|
return performance.now() - enterHandledAtRef.current < 120;
|
||||||
|
};
|
||||||
|
|
||||||
const sendStdinImmediate = (data: string) => {
|
const sendStdinImmediate = (data: string) => {
|
||||||
sendStdinWithAudit(data);
|
sendStdinWithAudit(data);
|
||||||
};
|
};
|
||||||
|
|
@ -705,11 +717,15 @@ export const WebTerminal = forwardRef<WebTerminalHandle, Props>(function WebTerm
|
||||||
|
|
||||||
const dataDisposable = term.onData((data) => {
|
const dataDisposable = term.onData((data) => {
|
||||||
const normalized = data.replace(/\x7f/g, "\x08");
|
const normalized = data.replace(/\x7f/g, "\x08");
|
||||||
|
if (isDuplicateEnterFrame(normalized)) return;
|
||||||
// Capture visible line before Enter moves the cursor to the next row.
|
// Capture visible line before Enter moves the cursor to the next row.
|
||||||
const auditLine =
|
const auditLine =
|
||||||
normalized.includes("\r") || normalized.includes("\n")
|
normalized.includes("\r") || normalized.includes("\n")
|
||||||
? auditLineForEnter(term)
|
? auditLineForEnter(term)
|
||||||
: undefined;
|
: undefined;
|
||||||
|
if (normalized.includes("\r") || normalized.includes("\n")) {
|
||||||
|
enterHandledAtRef.current = performance.now();
|
||||||
|
}
|
||||||
// Large pastes from xterm arrive as one onData blob.
|
// Large pastes from xterm arrive as one onData blob.
|
||||||
if (normalized.length > 32 || normalized.includes("\r") || normalized.includes("\n")) {
|
if (normalized.length > 32 || normalized.includes("\r") || normalized.includes("\n")) {
|
||||||
sendStdinThrottled(normalized, auditLine);
|
sendStdinThrottled(normalized, auditLine);
|
||||||
|
|
@ -783,7 +799,7 @@ export const WebTerminal = forwardRef<WebTerminalHandle, Props>(function WebTerm
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
maybeFocus();
|
maybeFocus();
|
||||||
if (e.key === "Enter") {
|
if (e.key === "Enter") {
|
||||||
sendStdinWithAudit("\r", auditLineForEnter(term));
|
sendEnterStdin(term, auditLineForEnter(term));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
sendStdinWithAudit(data);
|
sendStdinWithAudit(data);
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue