remote-debug-mcp 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1173 @@
1
+ import os
2
+ import re
3
+ import time
4
+ import hashlib
5
+ import base64
6
+ import threading
7
+ from collections import deque
8
+ from dataclasses import dataclass, field
9
+ from typing import Optional, Literal
10
+
11
+ import pexpect
12
+
13
+
14
+ DEFAULT_MAX_RETRIES = 3
15
+ DEFAULT_RETRY_BACKOFF = 1.0
16
+ DEFAULT_BUFFER_SIZE = 65536
17
+ _ANSI_RE = re.compile(rb'\x1b\[[0-9;?]*[a-zA-Z]')
18
+ MAX_LINE_COUNT = 900000
19
+
20
+
21
+ @dataclass
22
+ class ConnectionParams:
23
+ host: str
24
+ port: int
25
+ username: str = ""
26
+ password: str = ""
27
+ key_file: str = ""
28
+ connect_timeout: int = 30
29
+ max_retries: int = DEFAULT_MAX_RETRIES
30
+ retry_backoff: float = DEFAULT_RETRY_BACKOFF
31
+
32
+
33
+ @dataclass
34
+ class SSHSession:
35
+ session_id: str
36
+ params: ConnectionParams
37
+ platform: Literal["linux", "windows", "unknown"] = "unknown"
38
+ child: Optional[pexpect.spawn] = None
39
+ connected: bool = False
40
+ reconnect_count: int = 0
41
+ last_error: str = ""
42
+ powershell_available: bool = True
43
+ use_pty: bool = False
44
+ io_lock: threading.Lock = field(default_factory=threading.Lock)
45
+ created_at: float = field(default_factory=time.time)
46
+
47
+ def close(self):
48
+ if self.child:
49
+ try:
50
+ self.child.sendline("exit")
51
+ except Exception:
52
+ pass
53
+ try:
54
+ self.child.close()
55
+ except Exception:
56
+ pass
57
+ self.child = None
58
+ self.connected = False
59
+
60
+
61
+ @dataclass
62
+ class TelnetSession:
63
+ session_id: str
64
+ params: ConnectionParams
65
+ child: Optional[pexpect.spawn] = None
66
+ connected: bool = False
67
+ reconnect_count: int = 0
68
+ last_error: str = ""
69
+ buffer: bytes = b""
70
+ buffer_max_size: int = DEFAULT_BUFFER_SIZE
71
+ read_cursor: int = 0
72
+ lines: deque = field(default_factory=deque)
73
+ line_count: int = 0
74
+ read_line_cursor: int = 0
75
+ monitor_active: bool = False
76
+ monitor_thread: Optional[threading.Thread] = None
77
+ output_file: Optional[str] = None
78
+ io_lock: threading.Lock = field(default_factory=threading.Lock)
79
+ created_at: float = field(default_factory=time.time)
80
+
81
+ def close(self):
82
+ self.monitor_active = False
83
+ if self.monitor_thread and self.monitor_thread.is_alive():
84
+ self.monitor_thread.join(timeout=2)
85
+ if self.child and self.child.isalive():
86
+ try:
87
+ self.child.sendline("exit")
88
+ self.child.close()
89
+ except Exception:
90
+ pass
91
+ self.child = None
92
+ self.connected = False
93
+
94
+
95
+ class SessionManager:
96
+ def __init__(self):
97
+ self._ssh_sessions: dict[str, SSHSession] = {}
98
+ self._telnet_sessions: dict[str, TelnetSession] = {}
99
+ self._lock = threading.Lock()
100
+
101
+ # ================================================================
102
+ # SSH: 底层连接 (raw pexpect.spawn,不依赖 pxssh)
103
+ # ================================================================
104
+
105
+
106
+ def _ssh_spawn(self, params: ConnectionParams,
107
+ use_pty: bool = False) -> pexpect.spawn:
108
+ """构建 SSH 命令,encoding=None 获取原始字节避免编码转换损失。"""
109
+ ssh_args = [
110
+ "ssh",
111
+ "-t" if use_pty else "-T",
112
+ "-o", "StrictHostKeyChecking=no",
113
+ "-o", "UserKnownHostsFile=/dev/null",
114
+ "-o", "PreferredAuthentications=password",
115
+ "-p", str(params.port),
116
+ ]
117
+ if params.key_file:
118
+ ssh_args += ["-i", params.key_file]
119
+ ssh_args.append(f"{params.username}@{params.host}")
120
+
121
+ child = pexpect.spawn(ssh_args[0], ssh_args[1:],
122
+ timeout=params.connect_timeout)
123
+
124
+ idx = child.expect(
125
+ [b"password:", b"Password:",
126
+ b"Are you sure you want to continue connecting",
127
+ pexpect.TIMEOUT, pexpect.EOF],
128
+ timeout=30,
129
+ )
130
+ if idx in [0, 1]:
131
+ child.sendline(params.password)
132
+ elif idx == 2:
133
+ child.sendline("yes")
134
+ idx = child.expect(
135
+ [b"password:", b"Password:", pexpect.TIMEOUT, pexpect.EOF],
136
+ timeout=10,
137
+ )
138
+ if idx in [0, 1]:
139
+ child.sendline(params.password)
140
+ elif idx == 2:
141
+ raise ConnectionError("SSH auth timeout after host key confirmation")
142
+ elif idx == 3:
143
+ raise ConnectionError("SSH connection closed after host key confirmation")
144
+ elif idx == 3:
145
+ child.close()
146
+ raise ConnectionError(f"SSH connection timeout to {params.host}:{params.port}")
147
+ elif idx == 4:
148
+ child.close()
149
+ raise ConnectionError(f"SSH connection refused/closed by {params.host}:{params.port}")
150
+
151
+ return child
152
+
153
+ def _detect_and_setup_prompt(self, child: pexpect.spawn,
154
+ session: SSHSession) -> str:
155
+ """
156
+ 连接成功后检测远程平台,Windows 则设置工作目录。
157
+ encoding=None,所有 I/O 操作用原始字节。
158
+ 返回平台类型: "linux" | "windows"
159
+ """
160
+ platform = "unknown"
161
+
162
+ time.sleep(0.3)
163
+ child.sendline("echo __MCP_PLATFORM_DETECT__ && uname -s 2>/dev/null || echo __WINDOWS__ && echo __MCP_DETECT_DONE__")
164
+ try:
165
+ child.expect("__MCP_DETECT_DONE__", timeout=8)
166
+ output = child.before
167
+ if output is None:
168
+ output = b""
169
+ elif isinstance(output, str):
170
+ output = output.encode("utf-8", errors="replace")
171
+ if b"__WINDOWS__" in output:
172
+ platform = "windows"
173
+ else:
174
+ platform = "linux"
175
+ except pexpect.TIMEOUT:
176
+ child.sendline("ver 2>nul && echo __MCP_DETECT_DONE__")
177
+ try:
178
+ child.expect("__MCP_DETECT_DONE__", timeout=5)
179
+ platform = "windows"
180
+ except pexpect.TIMEOUT:
181
+ pass
182
+
183
+ time.sleep(0.3)
184
+ try:
185
+ child.read_nonblocking(99999, timeout=0.5)
186
+ except Exception:
187
+ pass
188
+
189
+ if platform == "windows":
190
+ if not self._setup_windows_workspace(child):
191
+ child.close()
192
+ session.use_pty = True
193
+ session.powershell_available = False
194
+ child2 = self._ssh_spawn(session.params, use_pty=True)
195
+ session.child = child2
196
+ session.platform = "windows"
197
+ time.sleep(0.5)
198
+ try:
199
+ child2.read_nonblocking(99999, timeout=0.5)
200
+ except Exception:
201
+ pass
202
+ if self._setup_windows_workspace_pty(child2, session):
203
+ return "windows"
204
+ child2.close()
205
+ session.powershell_available = False
206
+ child3 = self._ssh_spawn(session.params)
207
+ session.child = child3
208
+ session.use_pty = False
209
+ session.platform = "windows"
210
+ time.sleep(0.3)
211
+ try:
212
+ child3.read_nonblocking(99999, timeout=0.5)
213
+ except Exception:
214
+ pass
215
+ child3.sendline(
216
+ "mkdir D:\\remote_debug 2>nul & echo __WORKSPACE_READY__"
217
+ )
218
+ try:
219
+ child3.expect("__WORKSPACE_READY__", timeout=10)
220
+ except (pexpect.TIMEOUT, pexpect.EOF):
221
+ pass
222
+ time.sleep(0.3)
223
+ try:
224
+ child3.read_nonblocking(99999, timeout=0.3)
225
+ except Exception:
226
+ pass
227
+ return "windows"
228
+
229
+ return platform
230
+
231
+ def _setup_windows_workspace(self, child: pexpect.spawn) -> bool:
232
+ """
233
+ 尝试切换到 PowerShell 并创建 D:\\remote_debug 工作目录。
234
+ 返回 True 表示 PowerShell 可用且命令输出正常,
235
+ False 表示需要回退到 -t PTY 模式。
236
+ """
237
+ child.sendline("powershell")
238
+ time.sleep(1.5)
239
+ try:
240
+ child.read_nonblocking(99999, timeout=0.5)
241
+ except Exception:
242
+ pass
243
+
244
+ child.sendline(
245
+ "New-Item -ItemType Directory -Force -Path D:\\remote_debug | Out-Null;"
246
+ " Set-Location D:\\remote_debug; echo __WORKSPACE_READY__"
247
+ )
248
+ try:
249
+ child.expect("__WORKSPACE_READY__", timeout=10)
250
+ except pexpect.TIMEOUT:
251
+ pass
252
+ except pexpect.EOF:
253
+ return False
254
+
255
+ time.sleep(0.3)
256
+ try:
257
+ child.read_nonblocking(99999, timeout=0.3)
258
+ except Exception:
259
+ pass
260
+
261
+ if not self._verify_shell(child):
262
+ return False
263
+ try:
264
+ child.read_nonblocking(99999, timeout=0.3)
265
+ except Exception:
266
+ pass
267
+ return True
268
+
269
+ def _setup_windows_workspace_pty(self, child: pexpect.spawn,
270
+ session: SSHSession) -> bool:
271
+ child.send("powershell\r")
272
+ time.sleep(2.0)
273
+ try:
274
+ child.read_nonblocking(99999, timeout=0.5)
275
+ except Exception:
276
+ pass
277
+ child.send(
278
+ "New-Item -ItemType Directory -Force -Path D:\\remote_debug | Out-Null;"
279
+ " echo __PTY_READY__\r"
280
+ )
281
+ try:
282
+ child.expect("__PTY_READY__", timeout=15)
283
+ session.powershell_available = True
284
+ time.sleep(0.3)
285
+ child.send("chcp 65001\r")
286
+ time.sleep(0.3)
287
+ try:
288
+ child.read_nonblocking(99999, timeout=0.3)
289
+ except Exception:
290
+ pass
291
+ return True
292
+ except (pexpect.TIMEOUT, pexpect.EOF):
293
+ return False
294
+
295
+ def _verify_shell(self, child: pexpect.spawn) -> bool:
296
+ marker = "__MCP_ALIVE__"
297
+ child.sendline(f"echo {marker}")
298
+ try:
299
+ child.expect(marker, timeout=10)
300
+ return True
301
+ except (pexpect.TIMEOUT, pexpect.EOF):
302
+ return False
303
+
304
+ def _do_ssh_connect(self, session: SSHSession) -> str:
305
+ try:
306
+ child = self._ssh_spawn(session.params, use_pty=session.use_pty)
307
+ session.child = child
308
+ session.platform = self._detect_and_setup_prompt(child, session)
309
+ session.connected = True
310
+ session.reconnect_count = 0
311
+ session.last_error = ""
312
+ mode = "pty" if session.use_pty else "no-pty"
313
+ shell = "powershell" if session.powershell_available else "cmd"
314
+ enc = "gbk" if session.platform == "windows" else "utf-8"
315
+ return (f"SSH connected: {session.params.username}@"
316
+ f"{session.params.host}:{session.params.port} "
317
+ f"[{session.platform}] [{mode}] [{shell}] [{enc}] "
318
+ f"[session={session.session_id}]")
319
+ except Exception as e:
320
+ session.last_error = str(e)
321
+ session.close()
322
+ raise
323
+
324
+ def ssh_connect(self, session_id: str, host: str, port: int,
325
+ username: str, password: str,
326
+ max_retries: int = DEFAULT_MAX_RETRIES) -> str:
327
+ params = ConnectionParams(
328
+ host=host, port=port, username=username, password=password,
329
+ max_retries=max_retries,
330
+ )
331
+ with self._lock:
332
+ if session_id in self._ssh_sessions:
333
+ self._ssh_sessions[session_id].close()
334
+ session = SSHSession(session_id=session_id, params=params)
335
+ self._ssh_sessions[session_id] = session
336
+ try:
337
+ return self._do_ssh_connect(session)
338
+ except Exception as e:
339
+ return f"SSH connection failed [{session_id}]: {e}"
340
+
341
+ def ssh_connect_key(self, session_id: str, host: str, port: int,
342
+ username: str, key_file: str,
343
+ max_retries: int = DEFAULT_MAX_RETRIES) -> str:
344
+ params = ConnectionParams(
345
+ host=host, port=port, username=username, key_file=key_file,
346
+ max_retries=max_retries,
347
+ )
348
+ with self._lock:
349
+ if session_id in self._ssh_sessions:
350
+ self._ssh_sessions[session_id].close()
351
+ session = SSHSession(session_id=session_id, params=params)
352
+ self._ssh_sessions[session_id] = session
353
+ try:
354
+ return self._do_ssh_connect(session)
355
+ except Exception as e:
356
+ return f"SSH key connection failed [{session_id}]: {e}"
357
+
358
+ # ================================================================
359
+ # SSH: 命令执行
360
+ # ================================================================
361
+
362
+ def _ssh_execute_inner(self, session: SSHSession, command: str,
363
+ timeout: int) -> str:
364
+ child = session.child
365
+ marker = f"__MCP_CMD_{int(time.time() * 1000)}__"
366
+ marker_bytes = marker.encode("utf-8")
367
+
368
+ full_cmd = f"{command}; echo {marker}"
369
+ if session.platform == "windows":
370
+ if not session.powershell_available:
371
+ full_cmd = f"{command} & echo {marker}"
372
+ enc = "utf-8" if session.use_pty else "gbk"
373
+ full_cmd_bytes = full_cmd.encode(enc, errors="replace")
374
+ else:
375
+ full_cmd_bytes = full_cmd.encode("utf-8")
376
+
377
+ with session.io_lock:
378
+ try:
379
+ child.read_nonblocking(99999, timeout=0.3)
380
+ except Exception:
381
+ pass
382
+
383
+ if session.platform == "windows" and not session.powershell_available and not session.use_pty:
384
+ return self._ssh_execute_one_shot(session, command, marker, timeout)
385
+ else:
386
+ if session.use_pty:
387
+ child.send(b"\x03")
388
+ time.sleep(0.3)
389
+ child.send(full_cmd_bytes + b"\r")
390
+ else:
391
+ child.send(full_cmd_bytes + b"\n")
392
+
393
+ if session.use_pty:
394
+ deadline = time.time() + timeout
395
+ all_data = b""
396
+ marker_found = False
397
+ target_hits = 1
398
+ while time.time() < deadline:
399
+ time.sleep(0.1)
400
+ try:
401
+ chunk = child.read_nonblocking(99999, timeout=1.0)
402
+ if chunk:
403
+ all_data += chunk
404
+ if all_data.count(marker_bytes) >= target_hits:
405
+ marker_found = True
406
+ break
407
+ except pexpect.TIMEOUT:
408
+ continue
409
+ except pexpect.EOF:
410
+ break
411
+ else:
412
+ deadline = time.time() + timeout
413
+ all_data = b""
414
+ marker_found = False
415
+ target_hits = 2 if session.platform == "windows" else 1
416
+ while time.time() < deadline:
417
+ time.sleep(0.1)
418
+ try:
419
+ chunk = child.read_nonblocking(99999, timeout=0.3)
420
+ if chunk:
421
+ all_data += chunk
422
+ if all_data.count(marker_bytes) >= target_hits:
423
+ marker_found = True
424
+ break
425
+ except pexpect.TIMEOUT:
426
+ continue
427
+ except pexpect.EOF:
428
+ break
429
+
430
+ if not marker_found:
431
+ return f"[TIMEOUT] Command exceeded {timeout}s: {command}"
432
+
433
+ parts = all_data.rsplit(marker_bytes, 1)
434
+ raw = parts[0] if len(parts) > 0 else b""
435
+ if session.use_pty:
436
+ raw = _ANSI_RE.sub(b"", raw)
437
+
438
+ if session.platform == "windows":
439
+ out_enc = "utf-8" if session.use_pty else "gbk"
440
+ try:
441
+ output = raw.decode(out_enc)
442
+ except (UnicodeDecodeError, LookupError):
443
+ output = raw.decode("utf-8", errors="replace")
444
+ else:
445
+ output = raw.decode("utf-8", errors="replace")
446
+
447
+ output = output.replace("\r\n", "\n").replace("\r", "\n").strip()
448
+ cmd_prefix = command.strip()
449
+ lines = output.split("\n")
450
+ cleaned = []
451
+ for line in lines:
452
+ s = line.strip()
453
+ if not s:
454
+ continue
455
+ if s == "^C":
456
+ continue
457
+ if marker in s:
458
+ continue
459
+ if cmd_prefix and s.startswith(cmd_prefix):
460
+ continue
461
+ cleaned.append(s)
462
+ return "\n".join(cleaned).strip()
463
+
464
+ def _ssh_execute_one_shot(self, session: SSHSession, command: str,
465
+ marker: str, timeout: int) -> str:
466
+ """Windows without PowerShell: per-command SSH spawn for reliable output."""
467
+ params = session.params
468
+ ssh_args = [
469
+ "ssh", "-T",
470
+ "-o", "StrictHostKeyChecking=no",
471
+ "-o", "UserKnownHostsFile=/dev/null",
472
+ "-o", "PreferredAuthentications=password",
473
+ "-p", str(params.port),
474
+ ]
475
+ if params.key_file:
476
+ ssh_args += ["-i", params.key_file]
477
+ remote_cmd = f"{command} & echo {marker}"
478
+ ssh_args += [f"{params.username}@{params.host}", remote_cmd]
479
+
480
+ try:
481
+ child = pexpect.spawn(ssh_args[0], ssh_args[1:],
482
+ timeout=timeout + 5)
483
+ idx = child.expect(
484
+ [b"password:", b"Password:", pexpect.EOF],
485
+ timeout=params.connect_timeout,
486
+ )
487
+ if idx in [0, 1]:
488
+ child.sendline(params.password)
489
+ child.expect(marker, timeout=timeout)
490
+ raw = child.before or b""
491
+ child.close()
492
+ elif idx == 2:
493
+ raw = child.before or b""
494
+ child.close()
495
+ else:
496
+ child.close()
497
+ return f"[TIMEOUT] One-shot SSH connection timeout"
498
+
499
+ try:
500
+ output = raw.decode("gbk", errors="replace")
501
+ except Exception:
502
+ output = raw.decode("utf-8", errors="replace")
503
+
504
+ output = output.replace("\r\n", "\n").replace("\r", "\n").strip()
505
+ parts = output.split(marker)
506
+ raw_out = parts[0] if len(parts) > 0 else ""
507
+ cleaned = []
508
+ cmd_prefix = command.strip()
509
+ for line in raw_out.split("\n"):
510
+ s = line.strip()
511
+ if not s:
512
+ continue
513
+ if cmd_prefix and s.startswith(cmd_prefix):
514
+ continue
515
+ cleaned.append(s)
516
+ return "\n".join(cleaned).strip()
517
+ except pexpect.EOF:
518
+ return f"[ERROR] SSH connection closed for one-shot command"
519
+ except Exception as e:
520
+ return f"[ERROR] One-shot execution failed: {e}"
521
+
522
+ def ssh_execute(self, session_id: str, command: str,
523
+ timeout: int = 30) -> str:
524
+ session = self._ssh_sessions.get(session_id)
525
+ if not session:
526
+ return f"SSH session not found: {session_id}"
527
+ if not session.connected:
528
+ return f"SSH session not connected: {session_id}"
529
+
530
+ try:
531
+ return self._ssh_execute_inner(session, command, timeout)
532
+ except (pexpect.EOF, OSError) as e:
533
+ return self._try_reconnect_ssh(session, command, timeout, str(e))
534
+ except Exception as e:
535
+ session.connected = False
536
+ session.last_error = str(e)
537
+ return f"SSH execute error [{session_id}]: {e}"
538
+
539
+ def _try_reconnect_ssh(self, session: SSHSession, command: str,
540
+ timeout: int, error_msg: str) -> str:
541
+ if session.reconnect_count >= session.params.max_retries:
542
+ session.connected = False
543
+ session.last_error = error_msg
544
+ return (f"SSH connection lost [{session.session_id}]: "
545
+ f"{error_msg} (max retries exceeded)")
546
+
547
+ session.reconnect_count += 1
548
+ backoff = session.params.retry_backoff * (2 ** (session.reconnect_count - 1))
549
+ time.sleep(backoff)
550
+
551
+ try:
552
+ session.connected = False
553
+ try:
554
+ session.child.close()
555
+ except Exception:
556
+ pass
557
+ session.child = None
558
+
559
+ self._do_ssh_connect(session)
560
+ result = self._ssh_execute_inner(session, command, timeout)
561
+ return f"[Reconnected after {session.reconnect_count} retries]\n{result}"
562
+ except Exception as e:
563
+ return self._try_reconnect_ssh(session, command, timeout, str(e))
564
+
565
+ # ================================================================
566
+ # SSH: 断开与会话列表
567
+ # ================================================================
568
+
569
+ def ssh_disconnect(self, session_id: str) -> str:
570
+ with self._lock:
571
+ session = self._ssh_sessions.pop(session_id, None)
572
+ if session:
573
+ session.close()
574
+ return f"SSH disconnected: {session_id}"
575
+ return f"SSH session not found: {session_id}"
576
+
577
+ def ssh_list(self) -> str:
578
+ lines = []
579
+ for sid, s in self._ssh_sessions.items():
580
+ status = "connected" if s.connected else "disconnected"
581
+ plat = f" {s.platform}" if s.platform != "unknown" else ""
582
+ lines.append(
583
+ f" [{sid}] {s.params.username}@{s.params.host}:"
584
+ f"{s.params.port}{plat} ({status})"
585
+ )
586
+ return "\n".join(lines) if lines else "No SSH sessions."
587
+
588
+ # ================================================================
589
+ # SSH: 文件传输 (SCP 优先 → SFTP 兜底)
590
+ # ================================================================
591
+
592
+ def _normalize_remote_path(self, session: SSHSession, remote_path: str) -> str:
593
+ """根据远程平台规范化路径格式。
594
+ Windows 路径转为 /D:/path 格式供 SCP/SFTP 使用。"""
595
+ if session.platform == "windows":
596
+ remote_path = remote_path.replace("\\", "/")
597
+ if not remote_path.startswith("/"):
598
+ remote_path = "/" + remote_path
599
+ return remote_path
600
+
601
+ def _denormalize_for_md5(self, session: SSHSession,
602
+ remote_path: str) -> str:
603
+ """将 SCP 归一化路径转回远程平台原生格式,供 MD5 命令使用。"""
604
+ if session.platform == "windows":
605
+ if remote_path.startswith("/") and len(remote_path) > 3:
606
+ if remote_path[1].isalpha() and remote_path[2] == ":":
607
+ native = remote_path[1:] # /D:/... → D:/...
608
+ native = native.replace("/", "\\")
609
+ return native
610
+ return remote_path.replace("/", "\\")
611
+ return remote_path
612
+
613
+ @staticmethod
614
+ def _compute_local_md5(file_path: str) -> str:
615
+ with open(file_path, "rb") as f:
616
+ return hashlib.md5(f.read()).hexdigest()
617
+
618
+ def _compute_remote_md5(self, session: SSHSession,
619
+ remote_path: str) -> Optional[str]:
620
+ native_path = self._denormalize_for_md5(session, remote_path)
621
+ try:
622
+ if session.platform == "windows":
623
+ if session.powershell_available:
624
+ cmd = (f"Get-FileHash -Path \"{native_path}\""
625
+ f" -Algorithm MD5 | Select-Object -ExpandProperty Hash")
626
+ else:
627
+ cmd = f"certutil -hashfile \"{native_path}\" MD5"
628
+ else:
629
+ cmd = f"md5sum \"{native_path}\" | cut -d' ' -f1"
630
+
631
+ output = self._ssh_execute_inner(session, cmd, timeout=15)
632
+ output = output.strip().upper()
633
+ hex_chars = set("0123456789ABCDEF")
634
+ for i in range(len(output) - 31):
635
+ if all(c in hex_chars for c in output[i:i + 32]):
636
+ return output[i:i + 32]
637
+ return None
638
+ except Exception:
639
+ return None
640
+
641
+ def _verify_md5(self, session: SSHSession, local_path: str,
642
+ remote_path: str) -> str:
643
+ local_md5 = self._compute_local_md5(local_path).upper()
644
+ remote_md5 = self._compute_remote_md5(session, remote_path)
645
+ if remote_md5 is None:
646
+ return (f"(MD5 verify skipped: cannot read remote hash) "
647
+ f"local={local_md5}")
648
+ if local_md5 == remote_md5:
649
+ return f"MD5 OK: {local_md5}"
650
+ else:
651
+ return f"MD5 MISMATCH! local={local_md5} remote={remote_md5}"
652
+
653
+ def ssh_upload(self, session_id: str, local_path: str,
654
+ remote_path: str) -> str:
655
+ session = self._ssh_sessions.get(session_id)
656
+ if not session:
657
+ return f"SSH session not found: {session_id}"
658
+ if not os.path.exists(local_path):
659
+ return f"Local file not found: {local_path}"
660
+
661
+ native_remote = remote_path
662
+ remote_path = self._normalize_remote_path(session, remote_path)
663
+
664
+ result = self._scp_transfer(
665
+ session, local_path,
666
+ f"{session.params.username}@{session.params.host}:{remote_path}",
667
+ )
668
+ if "OK" in result:
669
+ md5 = self._verify_md5(session, local_path, native_remote)
670
+ return f"{result} [{md5}]"
671
+
672
+ result2 = self._sftp_transfer(session, local_path, remote_path, put=True)
673
+ if "OK" in result2:
674
+ md5 = self._verify_md5(session, local_path, native_remote)
675
+ return f"{result2} [{md5}]"
676
+
677
+ return f"SSH upload failed [{session_id}]: SCP({result}) / SFTP({result2})"
678
+
679
+ def ssh_download(self, session_id: str, remote_path: str,
680
+ local_path: str) -> str:
681
+ session = self._ssh_sessions.get(session_id)
682
+ if not session:
683
+ return f"SSH session not found: {session_id}"
684
+
685
+ native_remote = remote_path
686
+ remote_path = self._normalize_remote_path(session, remote_path)
687
+
688
+ result = self._scp_transfer(
689
+ session,
690
+ f"{session.params.username}@{session.params.host}:{remote_path}",
691
+ local_path,
692
+ )
693
+ if "OK" in result:
694
+ md5 = self._verify_md5(session, local_path, native_remote)
695
+ return f"{result} [{md5}]"
696
+
697
+ result2 = self._sftp_transfer(session, local_path, remote_path, put=False)
698
+ if "OK" in result2:
699
+ md5 = self._verify_md5(session, local_path, native_remote)
700
+ return f"{result2} [{md5}]"
701
+
702
+ return f"SSH download failed [{session_id}]: SCP({result}) / SFTP({result2})"
703
+
704
+ def _scp_transfer(self, session: SSHSession, src: str,
705
+ dst: str) -> str:
706
+ """SCP 传输,使用 pexpect 直连(兼容密码认证)。"""
707
+ password = session.params.password
708
+ port = session.params.port
709
+ args = [
710
+ "scp", "-P", str(port),
711
+ "-o", "StrictHostKeyChecking=no",
712
+ "-o", "UserKnownHostsFile=/dev/null",
713
+ src, dst,
714
+ ]
715
+ try:
716
+ child = pexpect.spawn(args[0], args[1:],
717
+ timeout=60, encoding="utf-8",
718
+ codec_errors="replace")
719
+ i = child.expect(
720
+ ["password:", "Password:",
721
+ "Are you sure you want to continue connecting",
722
+ pexpect.EOF, pexpect.TIMEOUT],
723
+ timeout=15,
724
+ )
725
+ if i in [0, 1] and password:
726
+ child.sendline(password)
727
+ child.expect(pexpect.EOF, timeout=60)
728
+ elif i == 2:
729
+ child.sendline("yes")
730
+ idx = child.expect(["password:", "Password:", pexpect.EOF], timeout=15)
731
+ if idx in [0, 1] and password:
732
+ child.sendline(password)
733
+ child.expect(pexpect.EOF, timeout=60)
734
+
735
+ child.close()
736
+ if child.exitstatus == 0:
737
+ return f"SCP transfer OK [{session.session_id}]: {src} -> {dst}"
738
+ output = (child.before or "")[:500]
739
+ return f"SCP failed (exit={child.exitstatus}) [{session.session_id}]: {output}"
740
+ except Exception as e:
741
+ return f"SCP error [{session.session_id}]: {e}"
742
+ except Exception as e:
743
+ return f"SCP error [{session.session_id}]: {e}"
744
+
745
+ def _sftp_transfer(self, session: SSHSession, local_path: str,
746
+ remote_path: str, put: bool = True) -> str:
747
+ """SFTP 传输。上传前自动创建父目录(UTF-8 编码,兼容中文)。"""
748
+ password = session.params.password
749
+ port = session.params.port
750
+ host = session.params.host
751
+ user = session.params.username
752
+ args = [
753
+ "sftp", "-P", str(port),
754
+ "-o", "StrictHostKeyChecking=no",
755
+ "-o", "UserKnownHostsFile=/dev/null",
756
+ "-o", "BatchMode=no",
757
+ f"{user}@{host}",
758
+ ]
759
+ try:
760
+ child = pexpect.spawn(args[0], args[1:],
761
+ timeout=60, encoding="utf-8",
762
+ codec_errors="replace")
763
+ idx = child.expect(
764
+ ["password:", "Password:", "sftp>", pexpect.TIMEOUT, pexpect.EOF],
765
+ timeout=15,
766
+ )
767
+ if idx in [0, 1] and password:
768
+ child.sendline(password)
769
+ child.expect("sftp>", timeout=10)
770
+ elif idx != 2:
771
+ child.close()
772
+ return f"SFTP auth failed [{session.session_id}]"
773
+
774
+ if put:
775
+ self._sftp_mkdirs(child, remote_path)
776
+ child.sendline(f'put "{local_path}" "{remote_path}"')
777
+ else:
778
+ child.sendline(f'get "{remote_path}" "{local_path}"')
779
+
780
+ idx = child.expect(["sftp>", pexpect.TIMEOUT], timeout=30)
781
+ output = child.before or ""
782
+ child.sendline("bye")
783
+ child.expect(pexpect.EOF, timeout=5)
784
+ child.close()
785
+
786
+ if "not found" in output.lower() or "no such file" in output.lower():
787
+ return f"SFTP not found [{session.session_id}]: {remote_path}"
788
+ if "error" in output.lower() or "couldn't" in output.lower():
789
+ return f"SFTP error [{session.session_id}]: {output[-200:]}"
790
+ if not put and not os.path.exists(local_path):
791
+ return f"SFTP no local file [{session.session_id}]: {local_path}"
792
+ return f"SFTP transfer OK [{session.session_id}]: {local_path} -> {remote_path}"
793
+ except Exception as e:
794
+ return f"SFTP error [{session.session_id}]: {e}"
795
+
796
+ @staticmethod
797
+ def _sftp_mkdirs(child, remote_path: str):
798
+ """通过 SFTP mkdir 逐级创建远程父目录(UTF-8 编码,兼容中文)。"""
799
+ parent = remote_path.rsplit("/", 1)[0]
800
+ if not parent or parent == remote_path:
801
+ return
802
+ parts = parent.lstrip("/").split("/")
803
+ current = ""
804
+ for part in parts:
805
+ if not part:
806
+ continue
807
+ if current:
808
+ current += "/" + part
809
+ else:
810
+ current = "/" + part
811
+ child.sendline(f'mkdir "{current}"')
812
+ child.expect(["sftp>", pexpect.TIMEOUT], timeout=5)
813
+
814
+ # ================================================================
815
+ # Telnet: 连接
816
+ # ================================================================
817
+
818
+ def _do_telnet_connect(self, session: TelnetSession) -> str:
819
+ try:
820
+ child = pexpect.spawn(
821
+ "telnet",
822
+ [session.params.host, str(session.params.port)],
823
+ timeout=session.params.connect_timeout,
824
+ encoding="utf-8",
825
+ )
826
+ idx = child.expect(
827
+ ["Escape character is", "login:", "Login:", "Username:",
828
+ "User:", "username:", pexpect.TIMEOUT, pexpect.EOF],
829
+ timeout=session.params.connect_timeout,
830
+ )
831
+ if idx in [6, 7]:
832
+ child.close()
833
+ raise ConnectionError("Telnet connection timeout or EOF")
834
+
835
+ if idx in [1, 2, 3, 4, 5] and session.params.username:
836
+ child.sendline(session.params.username)
837
+ child.expect(["Password:", "password:"], timeout=10)
838
+ child.sendline(session.params.password)
839
+ child.expect(["$", "#", ">", ":", pexpect.TIMEOUT], timeout=10)
840
+
841
+ session.child = child
842
+ session.connected = True
843
+ session.reconnect_count = 0
844
+ session.last_error = ""
845
+
846
+ return (
847
+ f"Telnet connected: "
848
+ f"{session.params.host}:{session.params.port} "
849
+ f"[session={session.session_id}]"
850
+ )
851
+ except Exception as e:
852
+ session.last_error = str(e)
853
+ session.close()
854
+ raise
855
+
856
+ def telnet_connect(self, session_id: str, host: str, port: int,
857
+ username: str = "", password: str = "",
858
+ timeout: int = 15,
859
+ buffer_max_size: int = DEFAULT_BUFFER_SIZE,
860
+ max_retries: int = DEFAULT_MAX_RETRIES) -> str:
861
+ params = ConnectionParams(
862
+ host=host, port=port, username=username, password=password,
863
+ connect_timeout=timeout, max_retries=max_retries,
864
+ )
865
+ with self._lock:
866
+ if session_id in self._telnet_sessions:
867
+ self._telnet_sessions[session_id].close()
868
+ session = TelnetSession(
869
+ session_id=session_id, params=params,
870
+ buffer_max_size=buffer_max_size,
871
+ )
872
+ self._telnet_sessions[session_id] = session
873
+
874
+ try:
875
+ return self._do_telnet_connect(session)
876
+ except Exception as e:
877
+ return f"Telnet connection failed [{session_id}]: {e}"
878
+
879
+ # ================================================================
880
+ # Telnet: 数据收发
881
+ # ================================================================
882
+
883
+ def _telnet_expect_data(self, child: pexpect.spawn,
884
+ timeout: float) -> bytes:
885
+ try:
886
+ idx = child.expect([r".+", pexpect.TIMEOUT, pexpect.EOF],
887
+ timeout=min(timeout, 1.0))
888
+ if idx == 0:
889
+ out = child.after
890
+ if isinstance(out, str):
891
+ out = out.encode("utf-8", errors="replace")
892
+ return out
893
+ except Exception:
894
+ pass
895
+ return b""
896
+
897
+ def _append_to_buffer(self, session: TelnetSession, data: bytes):
898
+ session.buffer += data
899
+ if len(session.buffer) > session.buffer_max_size:
900
+ overflow = len(session.buffer) - session.buffer_max_size
901
+ session.buffer = session.buffer[overflow:]
902
+ session.read_cursor = max(0, session.read_cursor - overflow)
903
+
904
+ def _read_new_data(self, session: TelnetSession,
905
+ encoding: str = "utf-8") -> str:
906
+ if session.read_cursor >= len(session.buffer):
907
+ return ""
908
+ raw = session.buffer[session.read_cursor:]
909
+ session.read_cursor = len(session.buffer)
910
+ return self._encode_bytes(raw, encoding)
911
+
912
+ def _read_all_data(self, session: TelnetSession,
913
+ encoding: str = "utf-8") -> str:
914
+ raw = session.buffer
915
+ session.read_cursor = len(session.buffer)
916
+ return self._encode_bytes(raw, encoding)
917
+
918
+ @staticmethod
919
+ def _encode_bytes(data: bytes, encoding: str) -> str:
920
+ if encoding == "base64":
921
+ return base64.b64encode(data).decode()
922
+ elif encoding == "hex":
923
+ return data.hex()
924
+ else:
925
+ return data.decode(encoding, errors="replace")
926
+
927
+ def telnet_send(self, session_id: str, data: str,
928
+ timeout: int = 0) -> str:
929
+ session = self._telnet_sessions.get(session_id)
930
+ if not session or not session.connected:
931
+ return f"Telnet session not found or not connected: {session_id}"
932
+ try:
933
+ marker = None
934
+ with session.io_lock:
935
+ if timeout > 0:
936
+ try:
937
+ session.child.expect([pexpect.TIMEOUT, pexpect.EOF],
938
+ timeout=0.3)
939
+ except Exception:
940
+ pass
941
+ if data == "__CTRL_C__":
942
+ os.write(session.child.child_fd, b"\x03")
943
+ elif data == "__CTRL_D__":
944
+ os.write(session.child.child_fd, b"\x04")
945
+ elif data == "__CTRL_Z__":
946
+ os.write(session.child.child_fd, b"\x1a")
947
+ else:
948
+ if timeout > 0:
949
+ marker = f"__MCP_{int(time.time() * 1000000)}__"
950
+ data = data.rstrip("\r") + f"; echo {marker}\r"
951
+ elif not data.endswith("\r"):
952
+ data += "\r"
953
+ session.child.send(data)
954
+
955
+ if timeout <= 0:
956
+ return f"Data sent to [{session_id}]"
957
+
958
+ if marker:
959
+ try:
960
+ session.child.expect(marker, timeout=2)
961
+ except pexpect.TIMEOUT:
962
+ pass
963
+ try:
964
+ session.child.expect(marker, timeout=timeout)
965
+ except pexpect.TIMEOUT:
966
+ pass
967
+ else:
968
+ session.child.expect([pexpect.TIMEOUT, pexpect.EOF],
969
+ timeout=timeout)
970
+ output = session.child.before
971
+ if output is None:
972
+ return ""
973
+ if isinstance(output, bytes):
974
+ output = output.decode("utf-8", errors="replace")
975
+ return output.replace("\r\n", "\n").strip()
976
+ except (pexpect.EOF, OSError) as e:
977
+ self._try_reconnect_telnet(session, str(e), silent=(timeout <= 0))
978
+ return (f"Telnet send [{session_id}]: reconnected "
979
+ f"after {session.reconnect_count} retries")
980
+ except Exception as e:
981
+ session.connected = False
982
+ session.last_error = str(e)
983
+ return f"Telnet send error [{session_id}]: {e}"
984
+
985
+ def telnet_listen(self, session_id: str, duration: int = 10,
986
+ encoding: str = "utf-8") -> str:
987
+ session = self._telnet_sessions.get(session_id)
988
+ if not session or not session.connected:
989
+ return f"Telnet session not found or not connected: {session_id}"
990
+
991
+ try:
992
+ end_time = time.time() + duration
993
+ while time.time() < end_time:
994
+ remaining = max(0.1, end_time - time.time())
995
+ if session.monitor_active:
996
+ time.sleep(remaining)
997
+ break
998
+ data = self._telnet_expect_data(session.child, remaining)
999
+ if data:
1000
+ self._append_to_buffer(session, data)
1001
+ self._append_to_lines(session, data)
1002
+ else:
1003
+ time.sleep(0.05)
1004
+
1005
+ if session.monitor_active:
1006
+ new_lines = list(session.lines)[session.read_line_cursor:]
1007
+ session.read_line_cursor = len(session.lines)
1008
+ result = self._encode_lines(new_lines, encoding)
1009
+ else:
1010
+ result = self._read_new_data(session, encoding)
1011
+ return result if result else "(no data received)"
1012
+ except (pexpect.EOF, OSError) as e:
1013
+ partial = self._read_new_data(session, encoding)
1014
+ if partial:
1015
+ return f"{partial}\n[Connection lost: {e}]"
1016
+ return f"Telnet connection lost [{session_id}]: {e}"
1017
+ except Exception as e:
1018
+ return f"Telnet listen error [{session_id}]: {e}"
1019
+
1020
+ def _append_to_lines(self, session: TelnetSession, data: bytes):
1021
+ """将原始字节数据拆行追加到行列缓存。"""
1022
+ for line in data.split(b"\n"):
1023
+ line = line.rstrip(b"\r")
1024
+ if line:
1025
+ session.lines.append(line)
1026
+ session.line_count += 1
1027
+
1028
+ @staticmethod
1029
+ def _encode_lines(lines: list, encoding: str) -> str:
1030
+ """将字节行列表按指定编码转换为字符串。"""
1031
+ encoded = []
1032
+ for line in lines:
1033
+ s = SessionManager._encode_bytes(line, encoding)
1034
+ encoded.append(s)
1035
+ return "\n".join(encoded)
1036
+
1037
+ # ================================================================
1038
+ # Telnet: 后台持续监听
1039
+ # ================================================================
1040
+
1041
+ def _telnet_background_reader(self, session: TelnetSession):
1042
+ """后台线程:持续从 PTY 读取数据写入行缓存和文件。"""
1043
+ while session.monitor_active and session.child and session.child.isalive():
1044
+ try:
1045
+ with session.io_lock:
1046
+ data = session.child.read_nonblocking(4096, timeout=0.3)
1047
+ if data:
1048
+ if not isinstance(data, bytes):
1049
+ data = data.encode("utf-8", errors="replace")
1050
+ self._append_to_lines(session, data)
1051
+ if session.output_file:
1052
+ try:
1053
+ with open(session.output_file, "ab") as f:
1054
+ f.write(data)
1055
+ except Exception:
1056
+ pass
1057
+ except pexpect.TIMEOUT:
1058
+ continue
1059
+ except (pexpect.EOF, OSError):
1060
+ session.connected = False
1061
+ break
1062
+ except Exception:
1063
+ time.sleep(0.1)
1064
+ session.monitor_active = False
1065
+
1066
+ def telnet_start_monitor(self, session_id: str,
1067
+ output_file: str = "") -> str:
1068
+ """启动后台监听,数据持续写入行缓存,可选输出到文件。"""
1069
+ session = self._telnet_sessions.get(session_id)
1070
+ if not session or not session.connected:
1071
+ return f"Telnet session not found or not connected: {session_id}"
1072
+ if session.monitor_active:
1073
+ return f"Telnet monitor already active [{session_id}]"
1074
+
1075
+ session.lines = deque(maxlen=MAX_LINE_COUNT)
1076
+ session.line_count = 0
1077
+ session.read_line_cursor = 0
1078
+ session.monitor_active = True
1079
+ if output_file:
1080
+ session.output_file = output_file
1081
+
1082
+ session.monitor_thread = threading.Thread(
1083
+ target=self._telnet_background_reader,
1084
+ args=(session,),
1085
+ daemon=True,
1086
+ )
1087
+ session.monitor_thread.start()
1088
+ return f"Telnet monitor started [{session_id}]"
1089
+
1090
+ def telnet_stop_monitor(self, session_id: str) -> str:
1091
+ """停止后台监听。"""
1092
+ session = self._telnet_sessions.get(session_id)
1093
+ if not session:
1094
+ return f"Telnet session not found: {session_id}"
1095
+ if not session.monitor_active:
1096
+ return f"Telnet monitor not active [{session_id}]"
1097
+
1098
+ session.monitor_active = False
1099
+ return f"Telnet monitor stopped [{session_id}]: {session.line_count} lines"
1100
+
1101
+ # ================================================================
1102
+ # Telnet: 重连 / 断开 / 列表
1103
+ # ================================================================
1104
+
1105
+ def _try_reconnect_telnet(self, session: TelnetSession,
1106
+ error_msg: str = "",
1107
+ silent: bool = False) -> str:
1108
+ if session.reconnect_count >= session.params.max_retries:
1109
+ session.connected = False
1110
+ session.last_error = error_msg
1111
+ return (f"Telnet connection lost [{session.session_id}]: "
1112
+ f"{error_msg} (max retries exceeded)")
1113
+
1114
+ session.reconnect_count += 1
1115
+ backoff = session.params.retry_backoff * (2 ** (session.reconnect_count - 1))
1116
+ time.sleep(backoff)
1117
+
1118
+ try:
1119
+ session.connected = False
1120
+ try:
1121
+ session.child.close()
1122
+ except Exception:
1123
+ pass
1124
+ session.child = None
1125
+
1126
+ self._do_telnet_connect(session)
1127
+ return (f"Telnet reconnected [{session.session_id}] "
1128
+ f"after {session.reconnect_count} retries")
1129
+ except Exception as e:
1130
+ if not silent:
1131
+ return self._try_reconnect_telnet(session, str(e))
1132
+ session.connected = False
1133
+ return f"Telnet reconnect failed [{session.session_id}]: {e}"
1134
+
1135
+ def telnet_disconnect(self, session_id: str) -> str:
1136
+ with self._lock:
1137
+ session = self._telnet_sessions.pop(session_id, None)
1138
+ if session:
1139
+ session.close()
1140
+ return f"Telnet disconnected: {session_id}"
1141
+ return f"Telnet session not found: {session_id}"
1142
+
1143
+ def telnet_list(self) -> str:
1144
+ lines = []
1145
+ for sid, s in self._telnet_sessions.items():
1146
+ status = "connected" if s.connected else "disconnected"
1147
+ buf_kb = len(s.buffer) / 1024
1148
+ mon = " [monitor]" if s.monitor_active else ""
1149
+ line_info = f" lines={len(s.lines)}" if s.monitor_active else ""
1150
+ lines.append(
1151
+ f" [{sid}] {s.params.host}:{s.params.port} ({status}) "
1152
+ f"buffer={buf_kb:.1f}KB{mon}{line_info}"
1153
+ )
1154
+ return "\n".join(lines) if lines else "No Telnet sessions."
1155
+
1156
+ # ================================================================
1157
+ # 汇总
1158
+ # ================================================================
1159
+
1160
+ def list_all(self) -> str:
1161
+ ssh = self.ssh_list()
1162
+ telnet = self.telnet_list()
1163
+ return f"SSH Sessions:\n{ssh}\n\nTelnet Sessions:\n{telnet}"
1164
+
1165
+
1166
+ _manager: Optional[SessionManager] = None
1167
+
1168
+
1169
+ def get_manager() -> SessionManager:
1170
+ global _manager
1171
+ if _manager is None:
1172
+ _manager = SessionManager()
1173
+ return _manager