ntermqt 0.1.7__py3-none-any.whl → 0.1.8__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,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