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,393 @@
1
+ """
2
+ SSH_ASKPASS server for GUI-based SSH authentication.
3
+
4
+ This module implements the SSH_ASKPASS protocol to capture authentication
5
+ prompts (passwords, YubiKey touch requests, etc.) and route them to the GUI.
6
+
7
+ How it works:
8
+ 1. AskpassServer listens on a Unix socket
9
+ 2. SSH_ASKPASS env var points to our helper script
10
+ 3. When SSH needs input, it calls the helper
11
+ 4. Helper connects to socket, sends prompt
12
+ 5. Server emits signal to GUI
13
+ 6. GUI responds (user types password or touches YubiKey)
14
+ 7. Response sent back through socket to helper
15
+ 8. Helper prints response to stdout for SSH
16
+ """
17
+
18
+ from __future__ import annotations
19
+ import os
20
+ import sys
21
+ import socket
22
+ import threading
23
+ import tempfile
24
+ import json
25
+ import logging
26
+ import stat
27
+ from pathlib import Path
28
+ from typing import Optional, Callable
29
+ from dataclasses import dataclass
30
+
31
+ logger = logging.getLogger(__name__)
32
+
33
+ # Path to the helper script (will be created alongside this module)
34
+ HELPER_SCRIPT_NAME = "nterm_askpass_helper.py"
35
+
36
+
37
+ @dataclass
38
+ class AskpassRequest:
39
+ """Request from SSH for user input."""
40
+ prompt: str
41
+ is_password: bool # True if input should be hidden
42
+ is_confirmation: bool # True if just needs confirmation (YubiKey touch)
43
+
44
+
45
+ @dataclass
46
+ class AskpassResponse:
47
+ """Response to send back to SSH."""
48
+ success: bool
49
+ value: str = "" # Password or empty for confirmation
50
+ error: str = ""
51
+
52
+
53
+ class AskpassServer:
54
+ """
55
+ Server that handles SSH_ASKPASS requests.
56
+
57
+ Usage:
58
+ server = AskpassServer()
59
+ server.set_handler(my_callback) # Called when SSH needs input
60
+ server.start()
61
+
62
+ # Use server.socket_path and server.helper_path in SSH environment
63
+ env = server.get_env()
64
+
65
+ # ... run SSH ...
66
+
67
+ server.stop()
68
+ """
69
+
70
+ def __init__(self):
71
+ self._socket_path: Optional[str] = None
72
+ self._helper_path: Optional[str] = None
73
+ self._server_socket: Optional[socket.socket] = None
74
+ self._thread: Optional[threading.Thread] = None
75
+ self._running = False
76
+ self._handler: Optional[Callable[[AskpassRequest], AskpassResponse]] = None
77
+ self._temp_dir: Optional[tempfile.TemporaryDirectory] = None
78
+
79
+ @property
80
+ def socket_path(self) -> Optional[str]:
81
+ """Path to the Unix socket."""
82
+ return self._socket_path
83
+
84
+ @property
85
+ def helper_path(self) -> Optional[str]:
86
+ """Path to the askpass helper script."""
87
+ return self._helper_path
88
+
89
+ def set_handler(self, handler: Callable[[AskpassRequest], AskpassResponse]) -> None:
90
+ """
91
+ Set the callback for handling askpass requests.
92
+
93
+ The handler receives an AskpassRequest and should return an AskpassResponse.
94
+ This will typically show a dialog or terminal prompt in the GUI.
95
+ """
96
+ self._handler = handler
97
+
98
+ def start(self) -> None:
99
+ """Start the askpass server."""
100
+ if self._running:
101
+ return
102
+
103
+ # Create temp directory for socket and helper
104
+ self._temp_dir = tempfile.TemporaryDirectory(prefix="nterm_askpass_")
105
+ temp_path = Path(self._temp_dir.name)
106
+
107
+ # Create Unix socket
108
+ self._socket_path = str(temp_path / "askpass.sock")
109
+ self._server_socket = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
110
+ self._server_socket.bind(self._socket_path)
111
+ self._server_socket.listen(5)
112
+ self._server_socket.settimeout(1.0) # Allow periodic check for shutdown
113
+
114
+ # Create helper script
115
+ self._helper_path = str(temp_path / HELPER_SCRIPT_NAME)
116
+ self._create_helper_script()
117
+
118
+ # Start server thread
119
+ self._running = True
120
+ self._thread = threading.Thread(target=self._server_loop, daemon=True)
121
+ self._thread.start()
122
+
123
+ logger.info(f"Askpass server started: {self._socket_path}")
124
+
125
+ def stop(self) -> None:
126
+ """Stop the askpass server."""
127
+ self._running = False
128
+
129
+ if self._server_socket:
130
+ try:
131
+ self._server_socket.close()
132
+ except:
133
+ pass
134
+ self._server_socket = None
135
+
136
+ if self._thread:
137
+ self._thread.join(timeout=2.0)
138
+ self._thread = None
139
+
140
+ if self._temp_dir:
141
+ try:
142
+ self._temp_dir.cleanup()
143
+ except:
144
+ pass
145
+ self._temp_dir = None
146
+
147
+ self._socket_path = None
148
+ self._helper_path = None
149
+
150
+ logger.info("Askpass server stopped")
151
+
152
+ def get_env(self) -> dict:
153
+ """
154
+ Get environment variables to set for SSH.
155
+
156
+ Returns dict with SSH_ASKPASS, SSH_ASKPASS_REQUIRE, and NTERM_ASKPASS_SOCK.
157
+ """
158
+ if not self._running:
159
+ raise RuntimeError("Askpass server not running")
160
+
161
+ return {
162
+ 'SSH_ASKPASS': self._helper_path,
163
+ 'SSH_ASKPASS_REQUIRE': 'force', # Use askpass even with TTY
164
+ 'NTERM_ASKPASS_SOCK': self._socket_path,
165
+ 'DISPLAY': os.environ.get('DISPLAY', ':0'), # Required for SSH_ASKPASS
166
+ }
167
+
168
+ def _create_helper_script(self) -> None:
169
+ """Create the askpass helper script."""
170
+ script = f'''#!/usr/bin/env python3
171
+ """
172
+ SSH_ASKPASS helper script - communicates with nterm askpass server.
173
+ This script is called by SSH when it needs user input.
174
+ """
175
+ import os
176
+ import sys
177
+ import socket
178
+ import json
179
+
180
+ def main():
181
+ # Get prompt from command line (SSH passes it as argv[1])
182
+ prompt = sys.argv[1] if len(sys.argv) > 1 else "Password: "
183
+
184
+ # Get socket path from environment
185
+ sock_path = os.environ.get('NTERM_ASKPASS_SOCK')
186
+ if not sock_path:
187
+ print("NTERM_ASKPASS_SOCK not set", file=sys.stderr)
188
+ sys.exit(1)
189
+
190
+ # Determine request type from prompt
191
+ prompt_lower = prompt.lower()
192
+ is_password = 'password' in prompt_lower or 'passphrase' in prompt_lower
193
+ is_confirmation = (
194
+ 'confirm' in prompt_lower or
195
+ 'touch' in prompt_lower or
196
+ 'presence' in prompt_lower or
197
+ 'yubikey' in prompt_lower or
198
+ 'security key' in prompt_lower or
199
+ 'tap' in prompt_lower
200
+ )
201
+
202
+ # Connect to server
203
+ try:
204
+ sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
205
+ sock.connect(sock_path)
206
+
207
+ # Send request
208
+ request = {{
209
+ 'prompt': prompt,
210
+ 'is_password': is_password,
211
+ 'is_confirmation': is_confirmation,
212
+ }}
213
+ sock.sendall(json.dumps(request).encode() + b'\\n')
214
+
215
+ # Read response
216
+ data = b''
217
+ while True:
218
+ chunk = sock.recv(4096)
219
+ if not chunk:
220
+ break
221
+ data += chunk
222
+ if b'\\n' in data:
223
+ break
224
+
225
+ sock.close()
226
+
227
+ response = json.loads(data.decode().strip())
228
+
229
+ if response.get('success'):
230
+ # Print value to stdout (SSH reads this)
231
+ print(response.get('value', ''))
232
+ sys.exit(0)
233
+ else:
234
+ print(response.get('error', 'Authentication cancelled'), file=sys.stderr)
235
+ sys.exit(1)
236
+
237
+ except Exception as e:
238
+ print(f"Askpass error: {{e}}", file=sys.stderr)
239
+ sys.exit(1)
240
+
241
+ if __name__ == '__main__':
242
+ main()
243
+ '''
244
+
245
+ with open(self._helper_path, 'w') as f:
246
+ f.write(script)
247
+
248
+ # Make executable
249
+ os.chmod(self._helper_path, stat.S_IRWXU | stat.S_IRGRP | stat.S_IXGRP)
250
+
251
+ logger.debug(f"Created askpass helper: {self._helper_path}")
252
+
253
+ def _server_loop(self) -> None:
254
+ """Main server loop - accepts connections and handles requests."""
255
+ while self._running:
256
+ try:
257
+ client, _ = self._server_socket.accept()
258
+ except socket.timeout:
259
+ continue
260
+ except OSError:
261
+ break
262
+
263
+ # Handle client in same thread (requests are sequential)
264
+ try:
265
+ self._handle_client(client)
266
+ except Exception as e:
267
+ logger.exception(f"Error handling askpass client: {e}")
268
+ finally:
269
+ try:
270
+ client.close()
271
+ except:
272
+ pass
273
+
274
+ def _handle_client(self, client: socket.socket) -> None:
275
+ """Handle a single askpass request."""
276
+ # Read request
277
+ data = b''
278
+ client.settimeout(30.0) # Timeout for reading
279
+ while True:
280
+ chunk = client.recv(4096)
281
+ if not chunk:
282
+ return
283
+ data += chunk
284
+ if b'\n' in data:
285
+ break
286
+
287
+ # Parse request
288
+ try:
289
+ req_data = json.loads(data.decode().strip())
290
+ request = AskpassRequest(
291
+ prompt=req_data.get('prompt', 'Password: '),
292
+ is_password=req_data.get('is_password', True),
293
+ is_confirmation=req_data.get('is_confirmation', False),
294
+ )
295
+ except (json.JSONDecodeError, KeyError) as e:
296
+ logger.error(f"Invalid askpass request: {e}")
297
+ response = AskpassResponse(success=False, error="Invalid request")
298
+ client.sendall(json.dumps({'success': False, 'error': str(e)}).encode() + b'\n')
299
+ return
300
+
301
+ logger.info(f"Askpass request: {request.prompt}")
302
+
303
+ # Call handler
304
+ if self._handler:
305
+ try:
306
+ response = self._handler(request)
307
+ except Exception as e:
308
+ logger.exception(f"Askpass handler error: {e}")
309
+ response = AskpassResponse(success=False, error=str(e))
310
+ else:
311
+ logger.warning("No askpass handler set")
312
+ response = AskpassResponse(success=False, error="No handler")
313
+
314
+ # Send response
315
+ resp_data = {
316
+ 'success': response.success,
317
+ 'value': response.value,
318
+ 'error': response.error,
319
+ }
320
+ client.sendall(json.dumps(resp_data).encode() + b'\n')
321
+
322
+
323
+ class BlockingAskpassHandler:
324
+ """
325
+ Simple blocking handler that waits for GUI to provide response.
326
+
327
+ Usage with Qt:
328
+ handler = BlockingAskpassHandler()
329
+ server.set_handler(handler.handle_request)
330
+
331
+ # In your Qt code, connect to handler.request_received signal
332
+ # and call handler.provide_response() when user responds
333
+ """
334
+
335
+ def __init__(self):
336
+ self._pending_request: Optional[AskpassRequest] = None
337
+ self._response: Optional[AskpassResponse] = None
338
+ self._event = threading.Event()
339
+ self._lock = threading.Lock()
340
+
341
+ # Callback for when request is received (call from Qt signal)
342
+ self.on_request: Optional[Callable[[AskpassRequest], None]] = None
343
+
344
+ def handle_request(self, request: AskpassRequest) -> AskpassResponse:
345
+ """
346
+ Handle an askpass request. Blocks until provide_response() is called.
347
+ Called from askpass server thread.
348
+ """
349
+ with self._lock:
350
+ self._pending_request = request
351
+ self._response = None
352
+ self._event.clear()
353
+
354
+ # Notify GUI (if callback set)
355
+ if self.on_request:
356
+ try:
357
+ self.on_request(request)
358
+ except Exception as e:
359
+ logger.exception(f"on_request callback error: {e}")
360
+
361
+ # Wait for response (with timeout)
362
+ if not self._event.wait(timeout=120.0): # 2 minute timeout
363
+ return AskpassResponse(success=False, error="Timeout waiting for response")
364
+
365
+ with self._lock:
366
+ response = self._response or AskpassResponse(success=False, error="No response")
367
+ self._pending_request = None
368
+ self._response = None
369
+ return response
370
+
371
+ def provide_response(self, success: bool, value: str = "", error: str = "") -> None:
372
+ """
373
+ Provide response to pending request. Call from GUI thread.
374
+ """
375
+ with self._lock:
376
+ self._response = AskpassResponse(success=success, value=value, error=error)
377
+ self._event.set()
378
+
379
+ def cancel(self) -> None:
380
+ """Cancel pending request."""
381
+ self.provide_response(success=False, error="Cancelled by user")
382
+
383
+ @property
384
+ def has_pending_request(self) -> bool:
385
+ """Check if there's a pending request."""
386
+ with self._lock:
387
+ return self._pending_request is not None
388
+
389
+ @property
390
+ def pending_prompt(self) -> Optional[str]:
391
+ """Get the pending request prompt, if any."""
392
+ with self._lock:
393
+ return self._pending_request.prompt if self._pending_request else None
nterm/config.py ADDED
@@ -0,0 +1,158 @@
1
+ """
2
+ Persistent application settings for nterm.
3
+ Stored in ~/.nterm/config.json
4
+ """
5
+
6
+ from __future__ import annotations
7
+ import json
8
+ import logging
9
+ from dataclasses import dataclass, field, asdict
10
+ from pathlib import Path
11
+ from typing import Optional
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+ # Default config location
16
+ DEFAULT_CONFIG_DIR = Path.home() / ".nterm"
17
+ DEFAULT_CONFIG_FILE = DEFAULT_CONFIG_DIR / "config.json"
18
+
19
+
20
+ @dataclass
21
+ class AppSettings:
22
+ """
23
+ Application settings that persist across sessions.
24
+ """
25
+ # Appearance
26
+ theme_name: str = "catppuccin_mocha"
27
+ font_size: int = 14
28
+
29
+ # Terminal behavior
30
+ multiline_paste_threshold: int = 1
31
+ scrollback_lines: int = 10000
32
+
33
+ # Connection defaults
34
+ default_term_type: str = "xterm-256color"
35
+ default_keepalive_interval: int = 30
36
+ auto_reconnect: bool = True
37
+
38
+ # Window state
39
+ window_width: int = 1200
40
+ window_height: int = 800
41
+ window_x: Optional[int] = None
42
+ window_y: Optional[int] = None
43
+ window_maximized: bool = False
44
+
45
+ # Session tree state
46
+ tree_width: int = 250
47
+
48
+ # Recent connections (just names/refs, not credentials)
49
+ recent_profiles: list[str] = field(default_factory=list)
50
+ max_recent: int = 10
51
+
52
+ def to_dict(self) -> dict:
53
+ """Serialize to dict."""
54
+ return asdict(self)
55
+
56
+ @classmethod
57
+ def from_dict(cls, data: dict) -> AppSettings:
58
+ """Deserialize from dict, ignoring unknown keys."""
59
+ valid_fields = {f.name for f in cls.__dataclass_fields__.values()}
60
+ filtered = {k: v for k, v in data.items() if k in valid_fields}
61
+ return cls(**filtered)
62
+
63
+ def add_recent_profile(self, profile_name: str) -> None:
64
+ """Add a profile to recent list (moves to front if exists)."""
65
+ if profile_name in self.recent_profiles:
66
+ self.recent_profiles.remove(profile_name)
67
+ self.recent_profiles.insert(0, profile_name)
68
+ self.recent_profiles = self.recent_profiles[:self.max_recent]
69
+
70
+
71
+ class SettingsManager:
72
+ """
73
+ Manages loading and saving application settings.
74
+
75
+ Usage:
76
+ manager = SettingsManager()
77
+ settings = manager.settings
78
+
79
+ # Modify settings
80
+ settings.theme_name = "dracula"
81
+ settings.font_size = 16
82
+
83
+ # Save
84
+ manager.save()
85
+ """
86
+
87
+ def __init__(self, config_path: Path = None):
88
+ self._config_path = config_path or DEFAULT_CONFIG_FILE
89
+ self._settings: Optional[AppSettings] = None
90
+
91
+ @property
92
+ def settings(self) -> AppSettings:
93
+ """Get current settings, loading from disk if needed."""
94
+ if self._settings is None:
95
+ self._settings = self.load()
96
+ return self._settings
97
+
98
+ @property
99
+ def config_dir(self) -> Path:
100
+ """Get the config directory path."""
101
+ return self._config_path.parent
102
+
103
+ def load(self) -> AppSettings:
104
+ """Load settings from disk, or return defaults."""
105
+ if self._config_path.exists():
106
+ try:
107
+ data = json.loads(self._config_path.read_text())
108
+ logger.debug(f"Loaded settings from {self._config_path}")
109
+ return AppSettings.from_dict(data)
110
+ except (json.JSONDecodeError, TypeError) as e:
111
+ logger.warning(f"Failed to load settings: {e}, using defaults")
112
+ return AppSettings()
113
+ else:
114
+ logger.debug("No settings file found, using defaults")
115
+ return AppSettings()
116
+
117
+ def save(self) -> None:
118
+ """Save current settings to disk."""
119
+ if self._settings is None:
120
+ return
121
+
122
+ # Ensure directory exists
123
+ self._config_path.parent.mkdir(parents=True, exist_ok=True)
124
+
125
+ try:
126
+ self._config_path.write_text(
127
+ json.dumps(self._settings.to_dict(), indent=2)
128
+ )
129
+ logger.debug(f"Saved settings to {self._config_path}")
130
+ except OSError as e:
131
+ logger.error(f"Failed to save settings: {e}")
132
+
133
+ def reset(self) -> AppSettings:
134
+ """Reset to default settings (does not save automatically)."""
135
+ self._settings = AppSettings()
136
+ return self._settings
137
+
138
+
139
+ # Global instance for convenience
140
+ _manager: Optional[SettingsManager] = None
141
+
142
+
143
+ def get_settings_manager() -> SettingsManager:
144
+ """Get the global settings manager instance."""
145
+ global _manager
146
+ if _manager is None:
147
+ _manager = SettingsManager()
148
+ return _manager
149
+
150
+
151
+ def get_settings() -> AppSettings:
152
+ """Convenience function to get current settings."""
153
+ return get_settings_manager().settings
154
+
155
+
156
+ def save_settings() -> None:
157
+ """Convenience function to save current settings."""
158
+ get_settings_manager().save()
@@ -0,0 +1,17 @@
1
+ """
2
+ Connection profiles and authentication configuration.
3
+ """
4
+
5
+ from .profile import (
6
+ AuthMethod,
7
+ AuthConfig,
8
+ JumpHostConfig,
9
+ ConnectionProfile,
10
+ )
11
+
12
+ __all__ = [
13
+ "AuthMethod",
14
+ "AuthConfig",
15
+ "JumpHostConfig",
16
+ "ConnectionProfile",
17
+ ]