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.
- nterm/__init__.py +54 -0
- nterm/__main__.py +619 -0
- nterm/askpass/__init__.py +22 -0
- nterm/askpass/server.py +393 -0
- nterm/config.py +158 -0
- nterm/connection/__init__.py +17 -0
- nterm/connection/profile.py +296 -0
- nterm/manager/__init__.py +29 -0
- nterm/manager/connect_dialog.py +322 -0
- nterm/manager/editor.py +262 -0
- nterm/manager/io.py +678 -0
- nterm/manager/models.py +346 -0
- nterm/manager/settings.py +264 -0
- nterm/manager/tree.py +493 -0
- nterm/resources.py +48 -0
- nterm/session/__init__.py +60 -0
- nterm/session/askpass_ssh.py +399 -0
- nterm/session/base.py +110 -0
- nterm/session/interactive_ssh.py +522 -0
- nterm/session/pty_transport.py +571 -0
- nterm/session/ssh.py +610 -0
- nterm/terminal/__init__.py +11 -0
- nterm/terminal/bridge.py +83 -0
- nterm/terminal/resources/terminal.html +253 -0
- nterm/terminal/resources/terminal.js +414 -0
- nterm/terminal/resources/xterm-addon-fit.min.js +8 -0
- nterm/terminal/resources/xterm-addon-unicode11.min.js +8 -0
- nterm/terminal/resources/xterm-addon-web-links.min.js +8 -0
- nterm/terminal/resources/xterm.css +209 -0
- nterm/terminal/resources/xterm.min.js +8 -0
- nterm/terminal/widget.py +380 -0
- nterm/theme/__init__.py +10 -0
- nterm/theme/engine.py +456 -0
- nterm/theme/stylesheet.py +377 -0
- nterm/theme/themes/clean.yaml +0 -0
- nterm/theme/themes/default.yaml +36 -0
- nterm/theme/themes/dracula.yaml +36 -0
- nterm/theme/themes/gruvbox_dark.yaml +36 -0
- nterm/theme/themes/gruvbox_hybrid.yaml +38 -0
- nterm/theme/themes/gruvbox_light.yaml +36 -0
- nterm/vault/__init__.py +32 -0
- nterm/vault/credential_manager.py +163 -0
- nterm/vault/keychain.py +135 -0
- nterm/vault/manager_ui.py +962 -0
- nterm/vault/profile.py +219 -0
- nterm/vault/resolver.py +250 -0
- nterm/vault/store.py +642 -0
- ntermqt-0.1.0.dist-info/METADATA +327 -0
- ntermqt-0.1.0.dist-info/RECORD +52 -0
- ntermqt-0.1.0.dist-info/WHEEL +5 -0
- ntermqt-0.1.0.dist-info/entry_points.txt +5 -0
- 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
|