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,399 @@
1
+ """
2
+ SSH session using SSH_ASKPASS for GUI-based authentication.
3
+
4
+ This session type uses the native ssh binary with SSH_ASKPASS to capture
5
+ all authentication prompts (passwords, YubiKey touches, MFA) and route
6
+ them to the GUI application.
7
+
8
+ This is the recommended approach for GUI applications as it uses the
9
+ official SSH mechanism for non-terminal authentication rather than
10
+ trying to capture /dev/tty.
11
+ """
12
+
13
+ from __future__ import annotations
14
+ import os
15
+ import shutil
16
+ import subprocess
17
+ import threading
18
+ import time
19
+ import logging
20
+ from typing import Optional, Callable
21
+ from pathlib import Path
22
+
23
+ from .base import (
24
+ Session, SessionState, SessionEvent,
25
+ DataReceived, StateChanged, InteractionRequired
26
+ )
27
+ from .pty_transport import create_pty, is_pty_available, PTYTransport, IS_WINDOWS
28
+ from ..connection.profile import ConnectionProfile, AuthMethod
29
+ from ..askpass import AskpassServer, AskpassRequest, AskpassResponse, BlockingAskpassHandler
30
+
31
+ logger = logging.getLogger(__name__)
32
+
33
+
34
+ class AskpassSSHSession(Session):
35
+ """
36
+ SSH session using SSH_ASKPASS for authentication prompts.
37
+
38
+ This session type uses the SSH_ASKPASS mechanism to capture
39
+ authentication prompts and route them to the GUI. This is how
40
+ tools like git-gui, seahorse, and ksshaskpass handle SSH auth.
41
+
42
+ Features:
43
+ - Password prompts appear in GUI
44
+ - YubiKey/FIDO2 touch prompts appear in GUI
45
+ - Keyboard-interactive MFA prompts appear in GUI
46
+ - SSH banners displayed in terminal
47
+ - ProxyJump for jump hosts
48
+
49
+ The session emits InteractionRequired events when SSH needs input.
50
+ The application must respond by calling provide_askpass_response().
51
+
52
+ Works on:
53
+ - Linux/macOS: Full support
54
+ - Windows: Requires pywinpty
55
+ """
56
+
57
+ def __init__(self, profile: ConnectionProfile):
58
+ """
59
+ Initialize askpass SSH session.
60
+
61
+ Args:
62
+ profile: Connection profile with host and auth info
63
+ """
64
+ self.profile = profile
65
+
66
+ self._state = SessionState.DISCONNECTED
67
+ self._state_lock = threading.Lock()
68
+
69
+ self._pty: Optional[PTYTransport] = None
70
+ self._stop_event = threading.Event()
71
+ self._event_handler: Optional[Callable[[SessionEvent], None]] = None
72
+
73
+ self._cols = profile.term_cols
74
+ self._rows = profile.term_rows
75
+
76
+ self._reconnect_attempt = 0
77
+
78
+ # Askpass components
79
+ self._askpass_server: Optional[AskpassServer] = None
80
+ self._askpass_handler: Optional[BlockingAskpassHandler] = None
81
+
82
+ @property
83
+ def state(self) -> SessionState:
84
+ """Current session state."""
85
+ with self._state_lock:
86
+ return self._state
87
+
88
+ @property
89
+ def is_connected(self) -> bool:
90
+ """Is session currently connected and usable?"""
91
+ return self.state == SessionState.CONNECTED
92
+
93
+ def set_event_handler(self, handler: Callable[[SessionEvent], None]) -> None:
94
+ """Set callback for session events."""
95
+ self._event_handler = handler
96
+
97
+ def _emit(self, event: SessionEvent) -> None:
98
+ """Emit event to handler."""
99
+ if self._event_handler:
100
+ try:
101
+ self._event_handler(event)
102
+ except Exception as e:
103
+ logger.exception(f"Event handler error: {e}")
104
+
105
+ def _set_state(self, new_state: SessionState, message: str = "") -> None:
106
+ """Update state and emit event."""
107
+ with self._state_lock:
108
+ old_state = self._state
109
+ self._state = new_state
110
+ logger.info(f"Session state: {old_state.name} -> {new_state.name} {message}")
111
+ self._emit(StateChanged(old_state, new_state, message))
112
+
113
+ def connect(self) -> None:
114
+ """Start SSH connection."""
115
+ if self.state not in (SessionState.DISCONNECTED, SessionState.FAILED):
116
+ logger.warning(f"Cannot connect from state {self.state}")
117
+ return
118
+
119
+ # Check prerequisites
120
+ if not is_pty_available():
121
+ self._set_state(
122
+ SessionState.FAILED,
123
+ "PTY not available. On Windows, install pywinpty."
124
+ )
125
+ return
126
+
127
+ ssh_path = self._find_ssh()
128
+ if not ssh_path:
129
+ self._set_state(
130
+ SessionState.FAILED,
131
+ "SSH not found. Please install OpenSSH."
132
+ )
133
+ return
134
+
135
+ # Start askpass server
136
+ self._start_askpass_server()
137
+
138
+ self._stop_event.clear()
139
+ thread = threading.Thread(target=self._connect_thread, daemon=True)
140
+ thread.start()
141
+
142
+ def _start_askpass_server(self) -> None:
143
+ """Start the askpass server for handling auth prompts."""
144
+ self._askpass_handler = BlockingAskpassHandler()
145
+ self._askpass_handler.on_request = self._on_askpass_request
146
+
147
+ self._askpass_server = AskpassServer()
148
+ self._askpass_server.set_handler(self._askpass_handler.handle_request)
149
+ self._askpass_server.start()
150
+
151
+ logger.info("Askpass server started")
152
+
153
+ def _stop_askpass_server(self) -> None:
154
+ """Stop the askpass server."""
155
+ if self._askpass_server:
156
+ self._askpass_server.stop()
157
+ self._askpass_server = None
158
+ self._askpass_handler = None
159
+
160
+ def _on_askpass_request(self, request: AskpassRequest) -> None:
161
+ """
162
+ Called when SSH needs authentication input.
163
+ Emits InteractionRequired event to the GUI.
164
+ """
165
+ # Determine interaction type
166
+ if request.is_confirmation:
167
+ interaction_type = "yubikey_touch"
168
+ elif request.is_password:
169
+ interaction_type = "password"
170
+ else:
171
+ interaction_type = "input"
172
+
173
+ logger.info(f"Askpass request: {request.prompt} (type={interaction_type})")
174
+
175
+ # Emit event to GUI
176
+ self._emit(InteractionRequired(request.prompt, interaction_type))
177
+
178
+ def provide_askpass_response(self, success: bool, value: str = "", error: str = "") -> None:
179
+ """
180
+ Provide response to pending askpass request.
181
+
182
+ Call this from the GUI when user provides input or touches YubiKey.
183
+
184
+ Args:
185
+ success: True if user provided input, False if cancelled
186
+ value: The password/input value (empty for YubiKey touch)
187
+ error: Error message if not successful
188
+ """
189
+ if self._askpass_handler:
190
+ self._askpass_handler.provide_response(success, value, error)
191
+
192
+ def cancel_askpass(self) -> None:
193
+ """Cancel pending askpass request."""
194
+ if self._askpass_handler:
195
+ self._askpass_handler.cancel()
196
+
197
+ def _find_ssh(self) -> Optional[str]:
198
+ """Find ssh executable in PATH."""
199
+ names = ['ssh.exe', 'ssh'] if IS_WINDOWS else ['ssh']
200
+
201
+ for name in names:
202
+ path = shutil.which(name)
203
+ if path:
204
+ logger.debug(f"Found SSH: {path}")
205
+ return path
206
+
207
+ # Check common Windows locations
208
+ if IS_WINDOWS:
209
+ common_paths = [
210
+ Path(os.environ.get('SystemRoot', 'C:\\Windows')) / 'System32' / 'OpenSSH' / 'ssh.exe',
211
+ Path(os.environ.get('ProgramFiles', 'C:\\Program Files')) / 'OpenSSH' / 'ssh.exe',
212
+ Path(os.environ.get('ProgramFiles', 'C:\\Program Files')) / 'Git' / 'usr' / 'bin' / 'ssh.exe',
213
+ ]
214
+ for p in common_paths:
215
+ if p.exists():
216
+ logger.debug(f"Found SSH: {p}")
217
+ return str(p)
218
+
219
+ return None
220
+
221
+ def _build_ssh_command(self) -> list[str]:
222
+ """Build ssh command with options from profile."""
223
+ cmd = ['ssh']
224
+
225
+ # Force PTY allocation
226
+ cmd.append('-tt')
227
+
228
+ # Jump hosts via ProxyJump
229
+ if self.profile.jump_hosts:
230
+ jump_specs = []
231
+ for jump in self.profile.jump_hosts:
232
+ if jump.auth and jump.auth.username:
233
+ spec = f"{jump.auth.username}@{jump.hostname}"
234
+ else:
235
+ spec = jump.hostname
236
+ if jump.port != 22:
237
+ spec += f":{jump.port}"
238
+ jump_specs.append(spec)
239
+
240
+ cmd.extend(['-J', ','.join(jump_specs)])
241
+
242
+ # Connection options
243
+ cmd.extend([
244
+ '-o', 'StrictHostKeyChecking=accept-new',
245
+ '-o', f'ConnectTimeout={int(self.profile.connect_timeout)}',
246
+ '-o', f'ServerAliveInterval={self.profile.keepalive_interval}',
247
+ '-o', f'ServerAliveCountMax={self.profile.keepalive_count_max}',
248
+ ])
249
+
250
+ # Handle specific auth methods
251
+ if self.profile.auth_methods:
252
+ auth = self.profile.auth_methods[0]
253
+
254
+ # Key file if specified
255
+ if auth.method == AuthMethod.KEY_FILE and auth.key_path:
256
+ cmd.extend(['-i', auth.key_path])
257
+
258
+ # Disable password auth if using agent/key only
259
+ if auth.method == AuthMethod.AGENT:
260
+ cmd.extend([
261
+ '-o', 'PreferredAuthentications=publickey',
262
+ ])
263
+
264
+ # Port if non-standard
265
+ if self.profile.port != 22:
266
+ cmd.extend(['-p', str(self.profile.port)])
267
+
268
+ # Build user@host
269
+ if self.profile.auth_methods and self.profile.auth_methods[0].username:
270
+ username = self.profile.auth_methods[0].username
271
+ cmd.append(f'{username}@{self.profile.hostname}')
272
+ else:
273
+ cmd.append(self.profile.hostname)
274
+
275
+ return cmd
276
+
277
+ def _connect_thread(self) -> None:
278
+ """Connection thread - spawns ssh and handles I/O."""
279
+ try:
280
+ self._set_state(SessionState.CONNECTING)
281
+
282
+ # Build command
283
+ cmd = self._build_ssh_command()
284
+ logger.info(f"SSH command: {' '.join(cmd)}")
285
+
286
+ # Build environment with askpass
287
+ env = os.environ.copy()
288
+ env['TERM'] = 'xterm-256color'
289
+
290
+ if self._askpass_server:
291
+ env.update(self._askpass_server.get_env())
292
+ logger.info(f"SSH_ASKPASS={env.get('SSH_ASKPASS')}")
293
+
294
+ # Create PTY and spawn SSH
295
+ self._pty = create_pty(use_pexpect=False) # Don't use pexpect with askpass
296
+ self._pty.spawn(cmd, env=env)
297
+ self._pty.resize(self._cols, self._rows)
298
+
299
+ self._set_state(SessionState.CONNECTED)
300
+ self._reconnect_attempt = 0
301
+
302
+ # Read loop
303
+ self._read_loop()
304
+
305
+ except Exception as e:
306
+ logger.exception("Connection failed")
307
+ self._cleanup()
308
+ self._set_state(SessionState.FAILED, str(e))
309
+
310
+ if self.profile.auto_reconnect and not self._stop_event.is_set():
311
+ self._schedule_reconnect()
312
+
313
+ def _read_loop(self) -> None:
314
+ """Read from PTY and emit data events."""
315
+ while not self._stop_event.is_set():
316
+ if not self._pty:
317
+ break
318
+
319
+ if not self._pty.is_alive:
320
+ exit_code = self._pty.exit_code
321
+ logger.info(f"SSH process exited with code {exit_code}")
322
+ break
323
+
324
+ data = self._pty.read(8192)
325
+ if data:
326
+ self._emit(DataReceived(data))
327
+ else:
328
+ time.sleep(0.01)
329
+
330
+ # Connection ended
331
+ if not self._stop_event.is_set():
332
+ exit_code = self._pty.exit_code if self._pty else None
333
+ self._cleanup()
334
+
335
+ if exit_code == 0:
336
+ self._set_state(SessionState.DISCONNECTED, "Connection closed")
337
+ else:
338
+ self._set_state(
339
+ SessionState.DISCONNECTED,
340
+ f"SSH exited with code {exit_code}"
341
+ )
342
+
343
+ if self.profile.auto_reconnect:
344
+ self._schedule_reconnect()
345
+
346
+ def _schedule_reconnect(self) -> None:
347
+ """Schedule a reconnection attempt."""
348
+ if self._reconnect_attempt >= self.profile.reconnect_max_attempts:
349
+ self._set_state(SessionState.FAILED, "Max reconnection attempts reached")
350
+ return
351
+
352
+ delay = self.profile.reconnect_delay * (
353
+ self.profile.reconnect_backoff ** self._reconnect_attempt
354
+ )
355
+ self._reconnect_attempt += 1
356
+
357
+ self._set_state(
358
+ SessionState.RECONNECTING,
359
+ f"Reconnecting in {delay:.1f}s (attempt {self._reconnect_attempt})"
360
+ )
361
+
362
+ def reconnect():
363
+ time.sleep(delay)
364
+ if not self._stop_event.is_set():
365
+ self._start_askpass_server() # Restart askpass server
366
+ self._connect_thread()
367
+
368
+ thread = threading.Thread(target=reconnect, daemon=True)
369
+ thread.start()
370
+
371
+ def write(self, data: bytes) -> None:
372
+ """Send data to SSH process."""
373
+ if self._pty:
374
+ self._pty.write(data)
375
+
376
+ def resize(self, cols: int, rows: int) -> None:
377
+ """Resize terminal."""
378
+ self._cols = cols
379
+ self._rows = rows
380
+ if self._pty:
381
+ self._pty.resize(cols, rows)
382
+
383
+ def disconnect(self) -> None:
384
+ """Disconnect session."""
385
+ logger.info("Disconnecting...")
386
+ self._stop_event.set()
387
+ self._cleanup()
388
+ self._set_state(SessionState.DISCONNECTED, "User disconnected")
389
+
390
+ def _cleanup(self) -> None:
391
+ """Clean up PTY and askpass server."""
392
+ if self._pty:
393
+ try:
394
+ self._pty.close()
395
+ except Exception as e:
396
+ logger.debug(f"PTY cleanup error: {e}")
397
+ self._pty = None
398
+
399
+ self._stop_askpass_server()
nterm/session/base.py ADDED
@@ -0,0 +1,110 @@
1
+ """
2
+ Abstract session interface.
3
+ """
4
+
5
+ from __future__ import annotations
6
+ from abc import ABC, abstractmethod
7
+ from enum import Enum, auto
8
+ from dataclasses import dataclass
9
+ from typing import Optional, Callable
10
+
11
+
12
+ class SessionState(Enum):
13
+ """Session lifecycle states."""
14
+ DISCONNECTED = auto()
15
+ CONNECTING = auto()
16
+ AUTHENTICATING = auto()
17
+ CONNECTED = auto()
18
+ RECONNECTING = auto()
19
+ FAILED = auto()
20
+
21
+
22
+ @dataclass
23
+ class SessionEvent:
24
+ """Base class for session events."""
25
+ pass
26
+
27
+
28
+ @dataclass
29
+ class DataReceived(SessionEvent):
30
+ """Data received from remote."""
31
+ data: bytes
32
+
33
+
34
+ @dataclass
35
+ class StateChanged(SessionEvent):
36
+ """Session state changed."""
37
+ old_state: SessionState
38
+ new_state: SessionState
39
+ message: str = ""
40
+
41
+
42
+ @dataclass
43
+ class InteractionRequired(SessionEvent):
44
+ """User interaction needed."""
45
+ prompt: str
46
+ interaction_type: str # "touch", "password", "keyboard_interactive"
47
+
48
+
49
+ @dataclass
50
+ class BannerReceived(SessionEvent):
51
+ """SSH banner received."""
52
+ banner: str
53
+
54
+
55
+ class Session(ABC):
56
+ """
57
+ Abstract session interface.
58
+
59
+ Handles connection lifecycle, data I/O, and reconnection.
60
+ Terminal widget talks to this, doesn't know about SSH/Telnet/Serial.
61
+ """
62
+
63
+ @property
64
+ @abstractmethod
65
+ def state(self) -> SessionState:
66
+ """Current session state."""
67
+ pass
68
+
69
+ @property
70
+ @abstractmethod
71
+ def is_connected(self) -> bool:
72
+ """Is session currently connected and usable?"""
73
+ pass
74
+
75
+ @abstractmethod
76
+ def connect(self) -> None:
77
+ """
78
+ Initiate connection.
79
+ Async - fires state change events as it progresses.
80
+ """
81
+ pass
82
+
83
+ @abstractmethod
84
+ def disconnect(self) -> None:
85
+ """Gracefully disconnect."""
86
+ pass
87
+
88
+ @abstractmethod
89
+ def write(self, data: bytes) -> None:
90
+ """Send data to remote."""
91
+ pass
92
+
93
+ @abstractmethod
94
+ def resize(self, cols: int, rows: int) -> None:
95
+ """Notify remote of terminal resize."""
96
+ pass
97
+
98
+ @abstractmethod
99
+ def set_event_handler(self, handler: Callable[[SessionEvent], None]) -> None:
100
+ """Set callback for session events."""
101
+ pass
102
+
103
+ def set_auto_reconnect(self, enabled: bool) -> None:
104
+ """
105
+ Enable/disable automatic reconnection.
106
+
107
+ Override in subclasses that support auto-reconnect.
108
+ Default implementation does nothing.
109
+ """
110
+ pass