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.
- remote_debug_mcp/__init__.py +3 -0
- remote_debug_mcp/__main__.py +5 -0
- remote_debug_mcp/config.example.yaml +30 -0
- remote_debug_mcp/config_loader.py +216 -0
- remote_debug_mcp/server.py +741 -0
- remote_debug_mcp/sessions.py +1173 -0
- remote_debug_mcp-0.2.0.dist-info/METADATA +315 -0
- remote_debug_mcp-0.2.0.dist-info/RECORD +11 -0
- remote_debug_mcp-0.2.0.dist-info/WHEEL +4 -0
- remote_debug_mcp-0.2.0.dist-info/entry_points.txt +2 -0
- remote_debug_mcp-0.2.0.dist-info/licenses/LICENSE +21 -0
|
@@ -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
|