ntermqt 0.1.7__py3-none-any.whl → 0.1.9__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.
- nterm/parser/api_help_dialog.py +426 -223
- nterm/scripting/api.py +421 -701
- nterm/scripting/models.py +195 -0
- nterm/scripting/platform_data.py +272 -0
- nterm/scripting/platform_utils.py +596 -0
- nterm/scripting/repl.py +527 -131
- nterm/scripting/repl_interactive.py +356 -213
- nterm/scripting/ssh_connection.py +632 -0
- nterm/scripting/test_api_repl.py +290 -0
- {ntermqt-0.1.7.dist-info → ntermqt-0.1.9.dist-info}/METADATA +89 -29
- {ntermqt-0.1.7.dist-info → ntermqt-0.1.9.dist-info}/RECORD +14 -9
- {ntermqt-0.1.7.dist-info → ntermqt-0.1.9.dist-info}/WHEEL +0 -0
- {ntermqt-0.1.7.dist-info → ntermqt-0.1.9.dist-info}/entry_points.txt +0 -0
- {ntermqt-0.1.7.dist-info → ntermqt-0.1.9.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,632 @@
|
|
|
1
|
+
"""
|
|
2
|
+
nterm/scripting/ssh_connection.py
|
|
3
|
+
|
|
4
|
+
Low-level SSH connection and command execution utilities.
|
|
5
|
+
Incorporates ANSI filtering, sophisticated prompt detection, and legacy device support.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import time
|
|
9
|
+
import re
|
|
10
|
+
import paramiko
|
|
11
|
+
from typing import Optional, List, Tuple
|
|
12
|
+
from io import StringIO
|
|
13
|
+
|
|
14
|
+
from ..connection.profile import ConnectionProfile, AuthMethod
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
# =============================================================================
|
|
18
|
+
# ANSI Filtering
|
|
19
|
+
# =============================================================================
|
|
20
|
+
|
|
21
|
+
def filter_ansi_sequences(text: str) -> str:
|
|
22
|
+
"""
|
|
23
|
+
Aggressively filter ANSI escape sequences and control characters.
|
|
24
|
+
|
|
25
|
+
Catches: \\x1b[1;24r, \\x1b[24;1H, \\x1b[2K, \\x1b[?25h, etc.
|
|
26
|
+
|
|
27
|
+
Args:
|
|
28
|
+
text: Input text with potential ANSI sequences
|
|
29
|
+
|
|
30
|
+
Returns:
|
|
31
|
+
Cleaned text
|
|
32
|
+
"""
|
|
33
|
+
if not text:
|
|
34
|
+
return text
|
|
35
|
+
|
|
36
|
+
# Comprehensive regex for all ANSI sequences and control chars
|
|
37
|
+
ansi_pattern = r'\x1b\[[0-9;?]*[a-zA-Z]|\x1b[()][AB012]|\x07|[\x00-\x08\x0B\x0C\x0E-\x1F]'
|
|
38
|
+
return re.sub(ansi_pattern, '', text)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
# =============================================================================
|
|
42
|
+
# Legacy Algorithm Support
|
|
43
|
+
# =============================================================================
|
|
44
|
+
|
|
45
|
+
def configure_legacy_algorithms():
|
|
46
|
+
"""
|
|
47
|
+
Configure Paramiko for legacy algorithm support.
|
|
48
|
+
|
|
49
|
+
Enables older KEX, ciphers, and key types for compatibility with
|
|
50
|
+
legacy network devices (old Cisco IOS, etc.)
|
|
51
|
+
"""
|
|
52
|
+
paramiko.Transport._preferred_kex = (
|
|
53
|
+
# Legacy KEX algorithms first
|
|
54
|
+
"diffie-hellman-group1-sha1",
|
|
55
|
+
"diffie-hellman-group14-sha1",
|
|
56
|
+
"diffie-hellman-group-exchange-sha1",
|
|
57
|
+
"diffie-hellman-group-exchange-sha256",
|
|
58
|
+
# Modern algorithms
|
|
59
|
+
"ecdh-sha2-nistp256",
|
|
60
|
+
"ecdh-sha2-nistp384",
|
|
61
|
+
"ecdh-sha2-nistp521",
|
|
62
|
+
"curve25519-sha256",
|
|
63
|
+
"curve25519-sha256@libssh.org",
|
|
64
|
+
"diffie-hellman-group16-sha512",
|
|
65
|
+
"diffie-hellman-group18-sha512"
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
paramiko.Transport._preferred_ciphers = (
|
|
69
|
+
# Legacy ciphers first
|
|
70
|
+
"aes128-cbc",
|
|
71
|
+
"aes256-cbc",
|
|
72
|
+
"3des-cbc",
|
|
73
|
+
"aes192-cbc",
|
|
74
|
+
# Modern ciphers
|
|
75
|
+
"aes128-ctr",
|
|
76
|
+
"aes192-ctr",
|
|
77
|
+
"aes256-ctr",
|
|
78
|
+
"aes256-gcm@openssh.com",
|
|
79
|
+
"aes128-gcm@openssh.com",
|
|
80
|
+
"chacha20-poly1305@openssh.com",
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
paramiko.Transport._preferred_keys = (
|
|
84
|
+
# Legacy key types first
|
|
85
|
+
"ssh-rsa",
|
|
86
|
+
"ssh-dss",
|
|
87
|
+
# Modern key types
|
|
88
|
+
"ecdsa-sha2-nistp256",
|
|
89
|
+
"ecdsa-sha2-nistp384",
|
|
90
|
+
"ecdsa-sha2-nistp521",
|
|
91
|
+
"ssh-ed25519",
|
|
92
|
+
"rsa-sha2-256",
|
|
93
|
+
"rsa-sha2-512"
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
# Disabled algorithms for RSA-SHA1 fallback
|
|
98
|
+
RSA_SHA1_DISABLED_ALGORITHMS = {
|
|
99
|
+
'pubkeys': ['rsa-sha2-512', 'rsa-sha2-256']
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
# =============================================================================
|
|
104
|
+
# Prompt Detection
|
|
105
|
+
# =============================================================================
|
|
106
|
+
|
|
107
|
+
# Common prompt ending characters
|
|
108
|
+
PROMPT_CHARS = ['#', '>', '$', '%', ':', ']', ')']
|
|
109
|
+
|
|
110
|
+
# Prompt detection patterns (ordered by specificity)
|
|
111
|
+
PROMPT_PATTERNS = [
|
|
112
|
+
r'([^\r\n]*[#>$%])\s*$', # Standard prompts
|
|
113
|
+
r'([A-Za-z0-9\-_.]+[#>$%])\s*$', # Hostname-style prompts
|
|
114
|
+
r'([A-Za-z0-9\-_.@]+[#>$%])\s*$', # user@host style
|
|
115
|
+
r'(\S+[#>$%])\s*$', # Any non-whitespace + prompt char
|
|
116
|
+
]
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _extract_clean_prompt(buffer: str) -> Optional[str]:
|
|
120
|
+
"""
|
|
121
|
+
Extract a clean prompt from buffer, handling repeated prompts.
|
|
122
|
+
|
|
123
|
+
Example: 'device# device# device#' -> 'device#'
|
|
124
|
+
"""
|
|
125
|
+
if not buffer or not buffer.strip():
|
|
126
|
+
return None
|
|
127
|
+
|
|
128
|
+
# Filter ANSI first
|
|
129
|
+
clean_buffer = filter_ansi_sequences(buffer)
|
|
130
|
+
|
|
131
|
+
# Get non-empty lines
|
|
132
|
+
lines = [line.strip() for line in clean_buffer.split('\n') if line.strip()]
|
|
133
|
+
if not lines:
|
|
134
|
+
return None
|
|
135
|
+
|
|
136
|
+
last_line = lines[-1]
|
|
137
|
+
|
|
138
|
+
# Check for simple prompt (no repetition)
|
|
139
|
+
if any(last_line.endswith(char) for char in PROMPT_CHARS) and len(last_line) < 50:
|
|
140
|
+
# Check for repeated pattern
|
|
141
|
+
base_prompt = _extract_base_prompt(last_line)
|
|
142
|
+
if base_prompt:
|
|
143
|
+
return base_prompt
|
|
144
|
+
return last_line
|
|
145
|
+
|
|
146
|
+
# Try each line in reverse
|
|
147
|
+
for line in reversed(lines):
|
|
148
|
+
if any(line.endswith(char) for char in PROMPT_CHARS):
|
|
149
|
+
base_prompt = _extract_base_prompt(line)
|
|
150
|
+
if base_prompt:
|
|
151
|
+
return base_prompt
|
|
152
|
+
if len(line) < 50:
|
|
153
|
+
return line
|
|
154
|
+
|
|
155
|
+
# Regex fallback
|
|
156
|
+
for pattern in PROMPT_PATTERNS:
|
|
157
|
+
match = re.search(pattern, clean_buffer)
|
|
158
|
+
if match:
|
|
159
|
+
return match.group(1).strip()
|
|
160
|
+
|
|
161
|
+
# Last resort
|
|
162
|
+
if lines and len(lines[-1]) < 50:
|
|
163
|
+
return lines[-1]
|
|
164
|
+
|
|
165
|
+
return None
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _extract_base_prompt(text: str) -> Optional[str]:
|
|
169
|
+
"""
|
|
170
|
+
Extract base prompt from text with possible repetitions.
|
|
171
|
+
|
|
172
|
+
Example: 'device# device# device#' -> 'device#'
|
|
173
|
+
"""
|
|
174
|
+
# Check each prompt character
|
|
175
|
+
for char in PROMPT_CHARS:
|
|
176
|
+
if char in text:
|
|
177
|
+
parts = text.split(char)
|
|
178
|
+
if len(parts) > 1:
|
|
179
|
+
# Check if all parts before last are identical
|
|
180
|
+
base_parts = [part.strip() for part in parts[:-1]]
|
|
181
|
+
if base_parts and all(part == base_parts[0] for part in base_parts):
|
|
182
|
+
return base_parts[0] + char
|
|
183
|
+
|
|
184
|
+
# Check for whitespace-separated repetitions
|
|
185
|
+
parts = text.split()
|
|
186
|
+
if len(parts) > 1:
|
|
187
|
+
potential_prompts = [
|
|
188
|
+
part for part in parts
|
|
189
|
+
if any(part.endswith(char) for char in PROMPT_CHARS)
|
|
190
|
+
]
|
|
191
|
+
if len(potential_prompts) > 1 and len(set(potential_prompts)) == 1:
|
|
192
|
+
return potential_prompts[0]
|
|
193
|
+
|
|
194
|
+
return None
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _scrub_prompt(raw_prompt: str) -> str:
|
|
198
|
+
"""
|
|
199
|
+
Clean up a detected prompt to get just the prompt pattern.
|
|
200
|
+
|
|
201
|
+
Handles cases where prompt is mixed with command echo or garbage.
|
|
202
|
+
"""
|
|
203
|
+
lines = raw_prompt.strip().split('\n')
|
|
204
|
+
cleaned_lines = [line.strip() for line in lines if line.strip()]
|
|
205
|
+
|
|
206
|
+
# Look through lines in reverse for prompt-like patterns
|
|
207
|
+
for line in reversed(cleaned_lines):
|
|
208
|
+
if any(line.endswith(char) for char in PROMPT_CHARS):
|
|
209
|
+
# If line has spaces, try to extract just the prompt part
|
|
210
|
+
if ' ' in line:
|
|
211
|
+
parts = line.split()
|
|
212
|
+
# Check last part
|
|
213
|
+
if parts[-1][-1] in '#>$%':
|
|
214
|
+
return parts[-1]
|
|
215
|
+
|
|
216
|
+
# Try splitting by prompt char
|
|
217
|
+
for char in PROMPT_CHARS:
|
|
218
|
+
if char in line:
|
|
219
|
+
prompt_parts = line.split(char)
|
|
220
|
+
if len(prompt_parts) > 1:
|
|
221
|
+
potential = prompt_parts[0] + char
|
|
222
|
+
if len(potential) < 30 and ' ' not in potential[-15:]:
|
|
223
|
+
return potential
|
|
224
|
+
else:
|
|
225
|
+
return line
|
|
226
|
+
|
|
227
|
+
# Regex fallback
|
|
228
|
+
for pattern in PROMPT_PATTERNS:
|
|
229
|
+
match = re.search(pattern, raw_prompt)
|
|
230
|
+
if match:
|
|
231
|
+
return match.group(1)
|
|
232
|
+
|
|
233
|
+
# Last resort
|
|
234
|
+
if cleaned_lines and len(cleaned_lines[-1]) < 50:
|
|
235
|
+
return cleaned_lines[-1]
|
|
236
|
+
|
|
237
|
+
return raw_prompt
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
# =============================================================================
|
|
241
|
+
# Paging Detection (Error Condition)
|
|
242
|
+
# =============================================================================
|
|
243
|
+
|
|
244
|
+
PAGING_PROMPTS = [
|
|
245
|
+
'--More--',
|
|
246
|
+
'-- More --',
|
|
247
|
+
'<--- More --->',
|
|
248
|
+
'Press any key to continue',
|
|
249
|
+
'--more--',
|
|
250
|
+
' --More-- ',
|
|
251
|
+
]
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
class PagingNotDisabledError(Exception):
|
|
255
|
+
"""
|
|
256
|
+
Raised when paging prompt is detected during command execution.
|
|
257
|
+
|
|
258
|
+
This indicates that terminal paging was not properly disabled.
|
|
259
|
+
The caller should ensure 'terminal length 0' (or equivalent) is sent
|
|
260
|
+
before executing commands.
|
|
261
|
+
"""
|
|
262
|
+
pass
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
# =============================================================================
|
|
266
|
+
# Core Functions
|
|
267
|
+
# =============================================================================
|
|
268
|
+
|
|
269
|
+
def wait_for_prompt(
|
|
270
|
+
shell: paramiko.Channel,
|
|
271
|
+
timeout: int = 10,
|
|
272
|
+
initial_wait: float = 0.5,
|
|
273
|
+
) -> str:
|
|
274
|
+
"""
|
|
275
|
+
Wait for device prompt and return detected prompt pattern.
|
|
276
|
+
|
|
277
|
+
Args:
|
|
278
|
+
shell: Active SSH channel
|
|
279
|
+
timeout: Maximum wait time in seconds
|
|
280
|
+
initial_wait: Initial sleep before sending newline
|
|
281
|
+
|
|
282
|
+
Returns:
|
|
283
|
+
Detected prompt string
|
|
284
|
+
"""
|
|
285
|
+
time.sleep(initial_wait)
|
|
286
|
+
|
|
287
|
+
# Clear any pending data
|
|
288
|
+
while shell.recv_ready():
|
|
289
|
+
shell.recv(65536)
|
|
290
|
+
|
|
291
|
+
# Send newline to trigger prompt
|
|
292
|
+
shell.send('\n')
|
|
293
|
+
time.sleep(0.3)
|
|
294
|
+
|
|
295
|
+
output = ""
|
|
296
|
+
end_time = time.time() + timeout
|
|
297
|
+
|
|
298
|
+
while time.time() < end_time:
|
|
299
|
+
if shell.recv_ready():
|
|
300
|
+
chunk = shell.recv(4096).decode('utf-8', errors='ignore')
|
|
301
|
+
output += chunk
|
|
302
|
+
time.sleep(0.1)
|
|
303
|
+
else:
|
|
304
|
+
# Give a moment for any trailing data
|
|
305
|
+
time.sleep(0.2)
|
|
306
|
+
if not shell.recv_ready():
|
|
307
|
+
break
|
|
308
|
+
|
|
309
|
+
# Filter ANSI and extract prompt
|
|
310
|
+
filtered_output = filter_ansi_sequences(output)
|
|
311
|
+
prompt = _extract_clean_prompt(filtered_output)
|
|
312
|
+
|
|
313
|
+
if prompt:
|
|
314
|
+
return _scrub_prompt(prompt)
|
|
315
|
+
|
|
316
|
+
# Fallback: last line
|
|
317
|
+
lines = filtered_output.strip().split('\n')
|
|
318
|
+
if lines:
|
|
319
|
+
last_line = lines[-1].strip()
|
|
320
|
+
if last_line and last_line[-1] in PROMPT_CHARS:
|
|
321
|
+
return last_line
|
|
322
|
+
|
|
323
|
+
# Default fallback
|
|
324
|
+
return '#'
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def send_command(
|
|
328
|
+
shell: paramiko.Channel,
|
|
329
|
+
command: str,
|
|
330
|
+
prompt: str,
|
|
331
|
+
timeout: int = 30,
|
|
332
|
+
) -> str:
|
|
333
|
+
"""
|
|
334
|
+
Send command and collect output until prompt returns.
|
|
335
|
+
|
|
336
|
+
Args:
|
|
337
|
+
shell: Active SSH channel
|
|
338
|
+
command: Command to execute
|
|
339
|
+
prompt: Expected prompt pattern
|
|
340
|
+
timeout: Command timeout in seconds
|
|
341
|
+
|
|
342
|
+
Returns:
|
|
343
|
+
Command output (without echoed command and prompt)
|
|
344
|
+
|
|
345
|
+
Raises:
|
|
346
|
+
PagingNotDisabledError: If paging prompt detected (terminal length not set)
|
|
347
|
+
TimeoutError: If prompt not seen within timeout
|
|
348
|
+
"""
|
|
349
|
+
# Clear any pending input
|
|
350
|
+
time.sleep(0.1)
|
|
351
|
+
while shell.recv_ready():
|
|
352
|
+
shell.recv(65536)
|
|
353
|
+
time.sleep(0.05)
|
|
354
|
+
|
|
355
|
+
# Send command
|
|
356
|
+
command = command.strip()
|
|
357
|
+
shell.send(command + '\n')
|
|
358
|
+
time.sleep(0.3) # Allow device to echo command
|
|
359
|
+
|
|
360
|
+
output = ""
|
|
361
|
+
end_time = time.time() + timeout
|
|
362
|
+
prompt_seen = False
|
|
363
|
+
|
|
364
|
+
while time.time() < end_time:
|
|
365
|
+
if shell.recv_ready():
|
|
366
|
+
chunk = shell.recv(65536).decode('utf-8', errors='ignore')
|
|
367
|
+
|
|
368
|
+
# Filter ANSI immediately
|
|
369
|
+
filtered_chunk = filter_ansi_sequences(chunk)
|
|
370
|
+
output += filtered_chunk
|
|
371
|
+
|
|
372
|
+
# Check for paging prompt - this is an ERROR condition
|
|
373
|
+
for paging_prompt in PAGING_PROMPTS:
|
|
374
|
+
if paging_prompt in output:
|
|
375
|
+
raise PagingNotDisabledError(
|
|
376
|
+
f"Paging prompt '{paging_prompt}' detected. "
|
|
377
|
+
f"Terminal paging was not disabled. "
|
|
378
|
+
f"Ensure 'terminal length 0' (or equivalent) is sent before commands."
|
|
379
|
+
)
|
|
380
|
+
|
|
381
|
+
# Check for final prompt
|
|
382
|
+
if prompt in output:
|
|
383
|
+
prompt_seen = True
|
|
384
|
+
# Give a bit more time for trailing data
|
|
385
|
+
time.sleep(0.1)
|
|
386
|
+
if shell.recv_ready():
|
|
387
|
+
chunk = shell.recv(65536).decode('utf-8', errors='ignore')
|
|
388
|
+
output += filter_ansi_sequences(chunk)
|
|
389
|
+
break
|
|
390
|
+
|
|
391
|
+
time.sleep(0.05)
|
|
392
|
+
else:
|
|
393
|
+
if prompt_seen:
|
|
394
|
+
break
|
|
395
|
+
time.sleep(0.1)
|
|
396
|
+
|
|
397
|
+
if not prompt_seen and prompt not in output:
|
|
398
|
+
# Check one more time
|
|
399
|
+
time.sleep(0.5)
|
|
400
|
+
while shell.recv_ready():
|
|
401
|
+
chunk = shell.recv(65536).decode('utf-8', errors='ignore')
|
|
402
|
+
output += filter_ansi_sequences(chunk)
|
|
403
|
+
|
|
404
|
+
if prompt not in output:
|
|
405
|
+
raise TimeoutError(
|
|
406
|
+
f"Prompt '{prompt}' not seen within {timeout}s. "
|
|
407
|
+
f"Command may still be running or prompt detection failed. "
|
|
408
|
+
f"Output tail: ...{output[-200:] if len(output) > 200 else output}"
|
|
409
|
+
)
|
|
410
|
+
|
|
411
|
+
# Clean up output
|
|
412
|
+
lines = output.split('\n')
|
|
413
|
+
|
|
414
|
+
# Remove first line if it contains the echoed command
|
|
415
|
+
if lines and command.lower() in lines[0].lower():
|
|
416
|
+
lines = lines[1:]
|
|
417
|
+
|
|
418
|
+
# Remove last line if it's the prompt
|
|
419
|
+
if lines and prompt in lines[-1]:
|
|
420
|
+
lines = lines[:-1]
|
|
421
|
+
|
|
422
|
+
# Filter out empty lines and prompt-only lines
|
|
423
|
+
cleaned_lines = []
|
|
424
|
+
for line in lines:
|
|
425
|
+
stripped = line.rstrip('\r\n')
|
|
426
|
+
# Skip prompt lines
|
|
427
|
+
if stripped.strip() == prompt.strip():
|
|
428
|
+
continue
|
|
429
|
+
cleaned_lines.append(stripped)
|
|
430
|
+
|
|
431
|
+
return '\n'.join(cleaned_lines).strip()
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
def connect_ssh(
|
|
435
|
+
hostname: str,
|
|
436
|
+
port: int,
|
|
437
|
+
profile: ConnectionProfile,
|
|
438
|
+
debug: bool = False,
|
|
439
|
+
) -> Tuple[paramiko.SSHClient, paramiko.Channel, str, List[str]]:
|
|
440
|
+
"""
|
|
441
|
+
Establish SSH connection and return client, shell, and prompt.
|
|
442
|
+
|
|
443
|
+
Args:
|
|
444
|
+
hostname: Target hostname or IP
|
|
445
|
+
port: SSH port
|
|
446
|
+
profile: ConnectionProfile with authentication details
|
|
447
|
+
debug: Enable verbose debugging
|
|
448
|
+
|
|
449
|
+
Returns:
|
|
450
|
+
Tuple of (ssh_client, shell_channel, prompt, debug_log)
|
|
451
|
+
|
|
452
|
+
Raises:
|
|
453
|
+
paramiko.AuthenticationException: On auth failure
|
|
454
|
+
ConnectionError: On connection failure
|
|
455
|
+
"""
|
|
456
|
+
debug_log = []
|
|
457
|
+
|
|
458
|
+
def _debug(msg):
|
|
459
|
+
if debug:
|
|
460
|
+
debug_log.append(msg)
|
|
461
|
+
print(f"[DEBUG] {msg}")
|
|
462
|
+
|
|
463
|
+
# Configure legacy algorithm support
|
|
464
|
+
configure_legacy_algorithms()
|
|
465
|
+
|
|
466
|
+
# Prepare connection kwargs
|
|
467
|
+
connect_kwargs = {
|
|
468
|
+
'hostname': hostname,
|
|
469
|
+
'port': port,
|
|
470
|
+
'timeout': 10,
|
|
471
|
+
'allow_agent': False,
|
|
472
|
+
'look_for_keys': False,
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
# Add authentication from profile
|
|
476
|
+
auth_method_used = None
|
|
477
|
+
|
|
478
|
+
if profile.auth_methods:
|
|
479
|
+
first_auth = profile.auth_methods[0]
|
|
480
|
+
connect_kwargs['username'] = first_auth.username
|
|
481
|
+
_debug(f"Username: {first_auth.username}")
|
|
482
|
+
|
|
483
|
+
for auth in profile.auth_methods:
|
|
484
|
+
if auth.method == AuthMethod.PASSWORD:
|
|
485
|
+
connect_kwargs['password'] = auth.password
|
|
486
|
+
auth_method_used = "password"
|
|
487
|
+
_debug("Auth method: password")
|
|
488
|
+
break
|
|
489
|
+
|
|
490
|
+
elif auth.method == AuthMethod.KEY_FILE:
|
|
491
|
+
connect_kwargs['key_filename'] = auth.key_path
|
|
492
|
+
if auth.key_passphrase:
|
|
493
|
+
connect_kwargs['passphrase'] = auth.key_passphrase
|
|
494
|
+
auth_method_used = f"key_file:{auth.key_path}"
|
|
495
|
+
_debug(f"Auth method: key_file ({auth.key_path})")
|
|
496
|
+
break
|
|
497
|
+
|
|
498
|
+
elif auth.method == AuthMethod.KEY_STORED:
|
|
499
|
+
# In-memory key from vault - load via StringIO
|
|
500
|
+
pkey = _load_key_from_content(auth.key_data, auth.key_passphrase)
|
|
501
|
+
if pkey:
|
|
502
|
+
connect_kwargs['pkey'] = pkey
|
|
503
|
+
auth_method_used = "key_stored"
|
|
504
|
+
_debug("Auth method: key_stored (in-memory)")
|
|
505
|
+
break
|
|
506
|
+
|
|
507
|
+
# Detect key type if using key file
|
|
508
|
+
if 'key_filename' in connect_kwargs:
|
|
509
|
+
key_path = connect_kwargs['key_filename']
|
|
510
|
+
key_type, key_bits = _detect_key_type(key_path)
|
|
511
|
+
_debug(f"Key type: {key_type}" + (f" ({key_bits} bits)" if key_bits else ""))
|
|
512
|
+
|
|
513
|
+
# Connection attempts: modern first, then legacy fallback
|
|
514
|
+
attempts = [
|
|
515
|
+
("modern", None),
|
|
516
|
+
("rsa-sha1", RSA_SHA1_DISABLED_ALGORITHMS),
|
|
517
|
+
]
|
|
518
|
+
|
|
519
|
+
last_error = None
|
|
520
|
+
connected = False
|
|
521
|
+
client = None
|
|
522
|
+
|
|
523
|
+
for attempt_name, disabled_algs in attempts:
|
|
524
|
+
client = paramiko.SSHClient()
|
|
525
|
+
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
|
526
|
+
|
|
527
|
+
_debug(f"Attempt: {attempt_name}")
|
|
528
|
+
|
|
529
|
+
attempt_kwargs = connect_kwargs.copy()
|
|
530
|
+
if disabled_algs:
|
|
531
|
+
attempt_kwargs['disabled_algorithms'] = disabled_algs
|
|
532
|
+
_debug(f" disabled_algorithms: {disabled_algs}")
|
|
533
|
+
|
|
534
|
+
try:
|
|
535
|
+
client.connect(**attempt_kwargs)
|
|
536
|
+
connected = True
|
|
537
|
+
|
|
538
|
+
# Log negotiated algorithms
|
|
539
|
+
transport = client.get_transport()
|
|
540
|
+
if transport:
|
|
541
|
+
_debug(f" SUCCESS - cipher: {transport.remote_cipher}, mac: {transport.remote_mac}")
|
|
542
|
+
_debug(f" host_key_type: {transport.host_key_type}")
|
|
543
|
+
|
|
544
|
+
# Set keepalive for long operations
|
|
545
|
+
transport.set_keepalive(30)
|
|
546
|
+
break
|
|
547
|
+
|
|
548
|
+
except paramiko.AuthenticationException as e:
|
|
549
|
+
_debug(f" FAILED (auth): {e}")
|
|
550
|
+
last_error = str(e)
|
|
551
|
+
client.close()
|
|
552
|
+
except paramiko.SSHException as e:
|
|
553
|
+
_debug(f" FAILED (ssh): {e}")
|
|
554
|
+
last_error = str(e)
|
|
555
|
+
client.close()
|
|
556
|
+
except Exception as e:
|
|
557
|
+
_debug(f" FAILED (other): {e}")
|
|
558
|
+
last_error = str(e)
|
|
559
|
+
client.close()
|
|
560
|
+
|
|
561
|
+
if not connected:
|
|
562
|
+
error_detail = f"Connection failed: {last_error}"
|
|
563
|
+
if debug:
|
|
564
|
+
error_detail += f"\n\nDebug log:\n" + "\n".join(debug_log)
|
|
565
|
+
raise paramiko.AuthenticationException(error_detail)
|
|
566
|
+
|
|
567
|
+
# Open interactive shell
|
|
568
|
+
shell = client.invoke_shell(width=200, height=50)
|
|
569
|
+
shell.settimeout(0.5)
|
|
570
|
+
|
|
571
|
+
# Wait for shell initialization
|
|
572
|
+
time.sleep(1.0)
|
|
573
|
+
|
|
574
|
+
# Clear initial banner/MOTD
|
|
575
|
+
while shell.recv_ready():
|
|
576
|
+
shell.recv(65536)
|
|
577
|
+
|
|
578
|
+
# Detect prompt
|
|
579
|
+
prompt = wait_for_prompt(shell)
|
|
580
|
+
_debug(f"Prompt detected: '{prompt}'")
|
|
581
|
+
|
|
582
|
+
return client, shell, prompt, debug_log
|
|
583
|
+
|
|
584
|
+
|
|
585
|
+
def _load_key_from_content(key_content: str, passphrase: Optional[str] = None) -> Optional[paramiko.PKey]:
|
|
586
|
+
"""
|
|
587
|
+
Load private key from string content (for vault-stored keys).
|
|
588
|
+
|
|
589
|
+
Tries Ed25519, RSA, ECDSA in order.
|
|
590
|
+
"""
|
|
591
|
+
key_types = [
|
|
592
|
+
('Ed25519', paramiko.Ed25519Key),
|
|
593
|
+
('RSA', paramiko.RSAKey),
|
|
594
|
+
('ECDSA', paramiko.ECDSAKey),
|
|
595
|
+
]
|
|
596
|
+
|
|
597
|
+
# Add DSA if available (older Paramiko)
|
|
598
|
+
if hasattr(paramiko, 'DSSKey'):
|
|
599
|
+
key_types.append(('DSA', paramiko.DSSKey))
|
|
600
|
+
|
|
601
|
+
for key_name, key_class in key_types:
|
|
602
|
+
try:
|
|
603
|
+
key_io = StringIO(key_content)
|
|
604
|
+
if passphrase:
|
|
605
|
+
return key_class.from_private_key(key_io, password=passphrase)
|
|
606
|
+
else:
|
|
607
|
+
return key_class.from_private_key(key_io)
|
|
608
|
+
except paramiko.ssh_exception.PasswordRequiredException:
|
|
609
|
+
raise ValueError("Private key requires a password but none provided")
|
|
610
|
+
except (paramiko.ssh_exception.SSHException, Exception):
|
|
611
|
+
continue
|
|
612
|
+
|
|
613
|
+
return None
|
|
614
|
+
|
|
615
|
+
|
|
616
|
+
def _detect_key_type(key_path: str) -> Tuple[str, Optional[int]]:
|
|
617
|
+
"""Detect SSH key type and bit length."""
|
|
618
|
+
key_types = [
|
|
619
|
+
('RSA', paramiko.RSAKey),
|
|
620
|
+
('Ed25519', paramiko.Ed25519Key),
|
|
621
|
+
('ECDSA', paramiko.ECDSAKey),
|
|
622
|
+
]
|
|
623
|
+
|
|
624
|
+
for key_name, key_class in key_types:
|
|
625
|
+
try:
|
|
626
|
+
key = key_class.from_private_key_file(key_path)
|
|
627
|
+
bits = key.get_bits() if hasattr(key, 'get_bits') else None
|
|
628
|
+
return key_name, bits
|
|
629
|
+
except Exception:
|
|
630
|
+
continue
|
|
631
|
+
|
|
632
|
+
return "unknown", None
|