ntermqt 0.1.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.
Files changed (52) hide show
  1. nterm/__init__.py +54 -0
  2. nterm/__main__.py +619 -0
  3. nterm/askpass/__init__.py +22 -0
  4. nterm/askpass/server.py +393 -0
  5. nterm/config.py +158 -0
  6. nterm/connection/__init__.py +17 -0
  7. nterm/connection/profile.py +296 -0
  8. nterm/manager/__init__.py +29 -0
  9. nterm/manager/connect_dialog.py +322 -0
  10. nterm/manager/editor.py +262 -0
  11. nterm/manager/io.py +678 -0
  12. nterm/manager/models.py +346 -0
  13. nterm/manager/settings.py +264 -0
  14. nterm/manager/tree.py +493 -0
  15. nterm/resources.py +48 -0
  16. nterm/session/__init__.py +60 -0
  17. nterm/session/askpass_ssh.py +399 -0
  18. nterm/session/base.py +110 -0
  19. nterm/session/interactive_ssh.py +522 -0
  20. nterm/session/pty_transport.py +571 -0
  21. nterm/session/ssh.py +610 -0
  22. nterm/terminal/__init__.py +11 -0
  23. nterm/terminal/bridge.py +83 -0
  24. nterm/terminal/resources/terminal.html +253 -0
  25. nterm/terminal/resources/terminal.js +414 -0
  26. nterm/terminal/resources/xterm-addon-fit.min.js +8 -0
  27. nterm/terminal/resources/xterm-addon-unicode11.min.js +8 -0
  28. nterm/terminal/resources/xterm-addon-web-links.min.js +8 -0
  29. nterm/terminal/resources/xterm.css +209 -0
  30. nterm/terminal/resources/xterm.min.js +8 -0
  31. nterm/terminal/widget.py +380 -0
  32. nterm/theme/__init__.py +10 -0
  33. nterm/theme/engine.py +456 -0
  34. nterm/theme/stylesheet.py +377 -0
  35. nterm/theme/themes/clean.yaml +0 -0
  36. nterm/theme/themes/default.yaml +36 -0
  37. nterm/theme/themes/dracula.yaml +36 -0
  38. nterm/theme/themes/gruvbox_dark.yaml +36 -0
  39. nterm/theme/themes/gruvbox_hybrid.yaml +38 -0
  40. nterm/theme/themes/gruvbox_light.yaml +36 -0
  41. nterm/vault/__init__.py +32 -0
  42. nterm/vault/credential_manager.py +163 -0
  43. nterm/vault/keychain.py +135 -0
  44. nterm/vault/manager_ui.py +962 -0
  45. nterm/vault/profile.py +219 -0
  46. nterm/vault/resolver.py +250 -0
  47. nterm/vault/store.py +642 -0
  48. ntermqt-0.1.0.dist-info/METADATA +327 -0
  49. ntermqt-0.1.0.dist-info/RECORD +52 -0
  50. ntermqt-0.1.0.dist-info/WHEEL +5 -0
  51. ntermqt-0.1.0.dist-info/entry_points.txt +5 -0
  52. ntermqt-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,522 @@
1
+ """
2
+ Interactive SSH session using native ssh binary.
3
+
4
+ Uses the system's ssh client for full interactive authentication support,
5
+ including YubiKey/FIDO2 prompts, keyboard-interactive auth, and banners.
6
+ Works on both Unix and Windows (with OpenSSH installed).
7
+ """
8
+
9
+ from __future__ import annotations
10
+ import os
11
+ import shutil
12
+ import threading
13
+ import time
14
+ import logging
15
+ from typing import Optional, Callable
16
+ from pathlib import Path
17
+
18
+ from .base import (
19
+ Session, SessionState, SessionEvent,
20
+ DataReceived, StateChanged, InteractionRequired
21
+ )
22
+ from .pty_transport import create_pty, is_pty_available, PTYTransport, IS_WINDOWS
23
+ from ..connection.profile import ConnectionProfile, AuthMethod
24
+
25
+ logger = logging.getLogger(__name__)
26
+
27
+
28
+ class InteractiveSSHSession(Session):
29
+ """
30
+ SSH session using native ssh binary with PTY.
31
+
32
+ This session type spawns the system's ssh client in a pseudo-terminal,
33
+ providing full interactive authentication support including:
34
+
35
+ - SSH agent authentication (YubiKey touch prompts visible)
36
+ - Keyboard-interactive authentication (MFA prompts)
37
+ - Full banner display
38
+ - ProxyJump for jump hosts
39
+
40
+ Works on:
41
+ - Linux/macOS: Uses pty module
42
+ - Windows 10+: Uses pywinpty (ConPTY)
43
+
44
+ Requirements:
45
+ - ssh binary in PATH (OpenSSH)
46
+ - pywinpty package on Windows
47
+ """
48
+
49
+ def __init__(self, profile: ConnectionProfile):
50
+ """
51
+ Initialize interactive SSH session.
52
+
53
+ Args:
54
+ profile: Connection profile with host and auth info
55
+ """
56
+ self.profile = profile
57
+
58
+ self._state = SessionState.DISCONNECTED
59
+ self._state_lock = threading.Lock()
60
+
61
+ self._pty: Optional[PTYTransport] = None
62
+ self._read_thread: Optional[threading.Thread] = None
63
+ self._stop_event = threading.Event()
64
+ self._event_handler: Optional[Callable[[SessionEvent], None]] = None
65
+
66
+ self._cols = profile.term_cols
67
+ self._rows = profile.term_rows
68
+
69
+ self._reconnect_attempt = 0
70
+
71
+ @property
72
+ def state(self) -> SessionState:
73
+ """Current session state."""
74
+ with self._state_lock:
75
+ return self._state
76
+
77
+ @property
78
+ def is_connected(self) -> bool:
79
+ """Is session currently connected and usable?"""
80
+ return self.state == SessionState.CONNECTED
81
+
82
+ def set_event_handler(self, handler: Callable[[SessionEvent], None]) -> None:
83
+ """Set callback for session events."""
84
+ self._event_handler = handler
85
+
86
+ def _emit(self, event: SessionEvent) -> None:
87
+ """Emit event to handler."""
88
+ if self._event_handler:
89
+ try:
90
+ self._event_handler(event)
91
+ except Exception as e:
92
+ logger.exception(f"Event handler error: {e}")
93
+
94
+ def _set_state(self, new_state: SessionState, message: str = "") -> None:
95
+ """Update state and emit event."""
96
+ with self._state_lock:
97
+ old_state = self._state
98
+ self._state = new_state
99
+ logger.info(f"Session state: {old_state.name} -> {new_state.name} {message}")
100
+ self._emit(StateChanged(old_state, new_state, message))
101
+
102
+ def connect(self) -> None:
103
+ """Start SSH connection."""
104
+ if self.state not in (SessionState.DISCONNECTED, SessionState.FAILED):
105
+ logger.warning(f"Cannot connect from state {self.state}")
106
+ return
107
+
108
+ # Check prerequisites
109
+ if not is_pty_available():
110
+ self._set_state(
111
+ SessionState.FAILED,
112
+ "PTY not available. On Windows, install pywinpty."
113
+ )
114
+ return
115
+
116
+ ssh_path = self._find_ssh()
117
+ if not ssh_path:
118
+ self._set_state(
119
+ SessionState.FAILED,
120
+ "SSH not found. Please install OpenSSH."
121
+ )
122
+ return
123
+
124
+ self._stop_event.clear()
125
+ thread = threading.Thread(target=self._connect_thread, daemon=True)
126
+ thread.start()
127
+
128
+ def _find_ssh(self) -> Optional[str]:
129
+ """Find ssh executable in PATH."""
130
+ # On Windows, might be ssh.exe
131
+ names = ['ssh.exe', 'ssh'] if IS_WINDOWS else ['ssh']
132
+
133
+ for name in names:
134
+ path = shutil.which(name)
135
+ if path:
136
+ logger.debug(f"Found SSH: {path}")
137
+ return path
138
+
139
+ # Check common Windows locations
140
+ if IS_WINDOWS:
141
+ common_paths = [
142
+ Path(os.environ.get('SystemRoot', 'C:\\Windows')) / 'System32' / 'OpenSSH' / 'ssh.exe',
143
+ Path(os.environ.get('ProgramFiles', 'C:\\Program Files')) / 'OpenSSH' / 'ssh.exe',
144
+ Path(os.environ.get('ProgramFiles', 'C:\\Program Files')) / 'Git' / 'usr' / 'bin' / 'ssh.exe',
145
+ ]
146
+ for p in common_paths:
147
+ if p.exists():
148
+ logger.debug(f"Found SSH: {p}")
149
+ return str(p)
150
+
151
+ return None
152
+
153
+ def _build_ssh_command(self) -> list[str]:
154
+ """Build ssh command with options from profile."""
155
+ cmd = ['ssh']
156
+
157
+ # Force PTY allocation
158
+ cmd.append('-tt')
159
+
160
+ # Jump hosts via ProxyJump
161
+ if self.profile.jump_hosts:
162
+ jump_specs = []
163
+ for jump in self.profile.jump_hosts:
164
+ if jump.auth and jump.auth.username:
165
+ spec = f"{jump.auth.username}@{jump.hostname}"
166
+ else:
167
+ spec = jump.hostname
168
+ if jump.port != 22:
169
+ spec += f":{jump.port}"
170
+ jump_specs.append(spec)
171
+
172
+ cmd.extend(['-J', ','.join(jump_specs)])
173
+
174
+ # Connection options
175
+ cmd.extend([
176
+ '-o', 'StrictHostKeyChecking=accept-new',
177
+ '-o', f'ConnectTimeout={int(self.profile.connect_timeout)}',
178
+ '-o', f'ServerAliveInterval={self.profile.keepalive_interval}',
179
+ '-o', f'ServerAliveCountMax={self.profile.keepalive_count_max}',
180
+ ])
181
+
182
+ # Handle specific auth methods
183
+ if self.profile.auth_methods:
184
+ auth = self.profile.auth_methods[0]
185
+
186
+ # Key file if specified
187
+ if auth.method == AuthMethod.KEY_FILE and auth.key_path:
188
+ cmd.extend(['-i', auth.key_path])
189
+
190
+ # Disable password auth if using agent/key only
191
+ if auth.method == AuthMethod.AGENT:
192
+ cmd.extend([
193
+ '-o', 'PasswordAuthentication=no',
194
+ '-o', 'PreferredAuthentications=publickey',
195
+ ])
196
+ elif auth.method == AuthMethod.PASSWORD:
197
+ # For password auth, we'd need sshpass or expect
198
+ # Just let SSH prompt - user can type password
199
+ cmd.extend([
200
+ '-o', 'PreferredAuthentications=keyboard-interactive,password',
201
+ ])
202
+
203
+ # Port if non-standard
204
+ if self.profile.port != 22:
205
+ cmd.extend(['-p', str(self.profile.port)])
206
+
207
+ # Build user@host
208
+ if self.profile.auth_methods and self.profile.auth_methods[0].username:
209
+ username = self.profile.auth_methods[0].username
210
+ cmd.append(f'{username}@{self.profile.hostname}')
211
+ else:
212
+ cmd.append(self.profile.hostname)
213
+
214
+ return cmd
215
+
216
+ def _connect_thread(self) -> None:
217
+ """Connection thread - spawns ssh and handles I/O."""
218
+ try:
219
+ self._set_state(SessionState.CONNECTING)
220
+
221
+ # Build command
222
+ cmd = self._build_ssh_command()
223
+ logger.info(f"SSH command: {' '.join(cmd)}")
224
+
225
+ # Create PTY and spawn SSH
226
+ self._pty = create_pty()
227
+ self._pty.spawn(cmd)
228
+ self._pty.resize(self._cols, self._rows)
229
+
230
+ # Note: We go straight to CONNECTED because the terminal
231
+ # will show the authentication prompts interactively
232
+ self._set_state(SessionState.CONNECTED)
233
+ self._reconnect_attempt = 0
234
+
235
+ # Read loop
236
+ self._read_loop()
237
+
238
+ except Exception as e:
239
+ logger.exception("Connection failed")
240
+ self._cleanup()
241
+ self._set_state(SessionState.FAILED, str(e))
242
+
243
+ if self.profile.auto_reconnect and not self._stop_event.is_set():
244
+ self._schedule_reconnect()
245
+
246
+ def _read_loop(self) -> None:
247
+ """Read from PTY and emit data events."""
248
+ while not self._stop_event.is_set():
249
+ if not self._pty:
250
+ break
251
+
252
+ if not self._pty.is_alive:
253
+ exit_code = self._pty.exit_code
254
+ logger.info(f"SSH process exited with code {exit_code}")
255
+ break
256
+
257
+ data = self._pty.read(8192)
258
+ if data:
259
+ self._emit(DataReceived(data))
260
+ else:
261
+ # No data, small sleep to avoid busy-wait
262
+ time.sleep(0.01)
263
+
264
+ # Connection ended
265
+ if not self._stop_event.is_set():
266
+ exit_code = self._pty.exit_code if self._pty else None
267
+ self._cleanup()
268
+
269
+ if exit_code == 0:
270
+ self._set_state(SessionState.DISCONNECTED, "Connection closed")
271
+ else:
272
+ self._set_state(
273
+ SessionState.DISCONNECTED,
274
+ f"SSH exited with code {exit_code}"
275
+ )
276
+
277
+ if self.profile.auto_reconnect:
278
+ self._schedule_reconnect()
279
+
280
+ def _schedule_reconnect(self) -> None:
281
+ """Schedule a reconnection attempt."""
282
+ if self._reconnect_attempt >= self.profile.reconnect_max_attempts:
283
+ self._set_state(SessionState.FAILED, "Max reconnection attempts reached")
284
+ return
285
+
286
+ delay = self.profile.reconnect_delay * (
287
+ self.profile.reconnect_backoff ** self._reconnect_attempt
288
+ )
289
+ self._reconnect_attempt += 1
290
+
291
+ self._set_state(
292
+ SessionState.RECONNECTING,
293
+ f"Reconnecting in {delay:.1f}s (attempt {self._reconnect_attempt})"
294
+ )
295
+
296
+ def reconnect():
297
+ time.sleep(delay)
298
+ if not self._stop_event.is_set():
299
+ self._connect_thread()
300
+
301
+ thread = threading.Thread(target=reconnect, daemon=True)
302
+ thread.start()
303
+
304
+ def write(self, data: bytes) -> None:
305
+ """Send data to SSH process."""
306
+ if self._pty:
307
+ self._pty.write(data)
308
+
309
+ def resize(self, cols: int, rows: int) -> None:
310
+ """Resize terminal."""
311
+ self._cols = cols
312
+ self._rows = rows
313
+ if self._pty:
314
+ self._pty.resize(cols, rows)
315
+
316
+ def disconnect(self) -> None:
317
+ """Disconnect session."""
318
+ logger.info("Disconnecting...")
319
+ self._stop_event.set()
320
+ self._cleanup()
321
+ self._set_state(SessionState.DISCONNECTED, "User disconnected")
322
+
323
+ def _cleanup(self) -> None:
324
+ """Clean up PTY."""
325
+ if self._pty:
326
+ try:
327
+ self._pty.close()
328
+ except Exception as e:
329
+ logger.debug(f"Cleanup error: {e}")
330
+ self._pty = None
331
+
332
+
333
+ class HybridSSHSession(Session):
334
+ """
335
+ Hybrid session: Interactive auth, then Paramiko for programmatic control.
336
+
337
+ Uses native SSH with ControlMaster for initial authentication,
338
+ then subsequent connections reuse the authenticated socket.
339
+
340
+ Benefits:
341
+ - Full interactive auth (YubiKey, MFA, banners)
342
+ - Programmatic control after authentication
343
+ - Connection multiplexing
344
+
345
+ Note: ControlMaster is Unix-only.
346
+ """
347
+
348
+ def __init__(self, profile: ConnectionProfile):
349
+ self.profile = profile
350
+
351
+ self._state = SessionState.DISCONNECTED
352
+ self._pty: Optional[PTYTransport] = None
353
+ self._control_path: Optional[str] = None
354
+ self._event_handler: Optional[Callable[[SessionEvent], None]] = None
355
+ self._stop_event = threading.Event()
356
+
357
+ self._cols = profile.term_cols
358
+ self._rows = profile.term_rows
359
+
360
+ @property
361
+ def state(self) -> SessionState:
362
+ return self._state
363
+
364
+ @property
365
+ def is_connected(self) -> bool:
366
+ return self._state == SessionState.CONNECTED
367
+
368
+ def set_event_handler(self, handler: Callable[[SessionEvent], None]) -> None:
369
+ self._event_handler = handler
370
+
371
+ def _emit(self, event: SessionEvent) -> None:
372
+ if self._event_handler:
373
+ self._event_handler(event)
374
+
375
+ def _set_state(self, new_state: SessionState, message: str = "") -> None:
376
+ old_state = self._state
377
+ self._state = new_state
378
+ self._emit(StateChanged(old_state, new_state, message))
379
+
380
+ def connect(self) -> None:
381
+ """Start connection with ControlMaster."""
382
+ if IS_WINDOWS:
383
+ self._set_state(
384
+ SessionState.FAILED,
385
+ "HybridSSHSession requires Unix (ControlMaster not available on Windows)"
386
+ )
387
+ return
388
+
389
+ self._stop_event.clear()
390
+ thread = threading.Thread(target=self._connect_thread, daemon=True)
391
+ thread.start()
392
+
393
+ def _connect_thread(self) -> None:
394
+ """Connection thread."""
395
+ try:
396
+ self._set_state(SessionState.CONNECTING)
397
+
398
+ # Create control socket path
399
+ self._control_path = f"/tmp/nterm-{os.getpid()}-{self.profile.hostname}"
400
+
401
+ # Build SSH command with ControlMaster
402
+ cmd = self._build_control_command()
403
+ logger.info(f"SSH command: {' '.join(cmd)}")
404
+
405
+ # Spawn
406
+ self._pty = create_pty()
407
+ self._pty.spawn(cmd)
408
+ self._pty.resize(self._cols, self._rows)
409
+
410
+ self._set_state(SessionState.CONNECTED)
411
+
412
+ # Read loop
413
+ self._read_loop()
414
+
415
+ except Exception as e:
416
+ logger.exception("Connection failed")
417
+ self._cleanup()
418
+ self._set_state(SessionState.FAILED, str(e))
419
+
420
+ def _build_control_command(self) -> list[str]:
421
+ """Build SSH command with ControlMaster."""
422
+ cmd = ['ssh', '-tt']
423
+
424
+ # ControlMaster settings
425
+ cmd.extend([
426
+ '-o', 'ControlMaster=auto',
427
+ '-o', f'ControlPath={self._control_path}',
428
+ '-o', 'ControlPersist=600', # Keep socket for 10 minutes
429
+ ])
430
+
431
+ # Jump hosts
432
+ if self.profile.jump_hosts:
433
+ jump_specs = []
434
+ for jump in self.profile.jump_hosts:
435
+ if jump.auth and jump.auth.username:
436
+ spec = f"{jump.auth.username}@{jump.hostname}"
437
+ else:
438
+ spec = jump.hostname
439
+ if jump.port != 22:
440
+ spec += f":{jump.port}"
441
+ jump_specs.append(spec)
442
+ cmd.extend(['-J', ','.join(jump_specs)])
443
+
444
+ # Standard options
445
+ cmd.extend([
446
+ '-o', 'StrictHostKeyChecking=accept-new',
447
+ '-o', f'ConnectTimeout={int(self.profile.connect_timeout)}',
448
+ ])
449
+
450
+ # Port
451
+ if self.profile.port != 22:
452
+ cmd.extend(['-p', str(self.profile.port)])
453
+
454
+ # User@host
455
+ if self.profile.auth_methods:
456
+ username = self.profile.auth_methods[0].username
457
+ cmd.append(f'{username}@{self.profile.hostname}')
458
+ else:
459
+ cmd.append(self.profile.hostname)
460
+
461
+ return cmd
462
+
463
+ def _read_loop(self) -> None:
464
+ """Read loop."""
465
+ while not self._stop_event.is_set():
466
+ if not self._pty or not self._pty.is_alive:
467
+ break
468
+
469
+ data = self._pty.read(8192)
470
+ if data:
471
+ self._emit(DataReceived(data))
472
+ else:
473
+ time.sleep(0.01)
474
+
475
+ if not self._stop_event.is_set():
476
+ self._cleanup()
477
+ self._set_state(SessionState.DISCONNECTED, "Connection closed")
478
+
479
+ def open_channel(self, hostname: str, port: int = 22) -> Optional[object]:
480
+ """
481
+ Open a new channel through the ControlMaster socket.
482
+
483
+ This can be used to create additional connections without
484
+ re-authenticating.
485
+
486
+ Returns a Paramiko-compatible channel or None.
487
+ """
488
+ if not self._control_path or not os.path.exists(self._control_path):
489
+ logger.error("ControlMaster socket not available")
490
+ return None
491
+
492
+ # Would use Paramiko with ProxyCommand here
493
+ # For now, this is a placeholder
494
+ logger.info(f"Would open channel to {hostname}:{port} via {self._control_path}")
495
+ return None
496
+
497
+ def write(self, data: bytes) -> None:
498
+ if self._pty:
499
+ self._pty.write(data)
500
+
501
+ def resize(self, cols: int, rows: int) -> None:
502
+ self._cols = cols
503
+ self._rows = rows
504
+ if self._pty:
505
+ self._pty.resize(cols, rows)
506
+
507
+ def disconnect(self) -> None:
508
+ self._stop_event.set()
509
+ self._cleanup()
510
+ self._set_state(SessionState.DISCONNECTED, "User disconnected")
511
+
512
+ def _cleanup(self) -> None:
513
+ if self._pty:
514
+ self._pty.close()
515
+ self._pty = None
516
+
517
+ # Clean up control socket
518
+ if self._control_path and os.path.exists(self._control_path):
519
+ try:
520
+ os.remove(self._control_path)
521
+ except:
522
+ pass