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
nterm/session/ssh.py
ADDED
|
@@ -0,0 +1,610 @@
|
|
|
1
|
+
"""
|
|
2
|
+
SSH session implementation using Paramiko.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
import socket
|
|
7
|
+
import threading
|
|
8
|
+
import time
|
|
9
|
+
import logging
|
|
10
|
+
import warnings
|
|
11
|
+
from typing import Optional, Callable
|
|
12
|
+
from io import StringIO
|
|
13
|
+
|
|
14
|
+
import paramiko
|
|
15
|
+
|
|
16
|
+
from .base import (
|
|
17
|
+
Session, SessionState, SessionEvent,
|
|
18
|
+
DataReceived, StateChanged, InteractionRequired, BannerReceived
|
|
19
|
+
)
|
|
20
|
+
from ..connection.profile import (
|
|
21
|
+
ConnectionProfile, AuthMethod, AuthConfig, JumpHostConfig
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
logger = logging.getLogger(__name__)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
# =============================================================================
|
|
28
|
+
# Legacy Device Support - Algorithm Configuration
|
|
29
|
+
# =============================================================================
|
|
30
|
+
# These settings provide broad compatibility with older network devices
|
|
31
|
+
# (old Juniper, Cisco IOS, etc.) while still preferring modern algorithms.
|
|
32
|
+
|
|
33
|
+
PREFERRED_CIPHERS = (
|
|
34
|
+
"aes128-ctr",
|
|
35
|
+
"aes192-ctr",
|
|
36
|
+
"aes256-ctr",
|
|
37
|
+
"aes128-gcm@openssh.com",
|
|
38
|
+
"aes256-gcm@openssh.com",
|
|
39
|
+
"chacha20-poly1305@openssh.com",
|
|
40
|
+
"aes128-cbc",
|
|
41
|
+
"aes192-cbc",
|
|
42
|
+
"aes256-cbc",
|
|
43
|
+
"3des-cbc",
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
PREFERRED_KEX = (
|
|
47
|
+
"curve25519-sha256",
|
|
48
|
+
"curve25519-sha256@libssh.org",
|
|
49
|
+
"ecdh-sha2-nistp256",
|
|
50
|
+
"ecdh-sha2-nistp384",
|
|
51
|
+
"ecdh-sha2-nistp521",
|
|
52
|
+
"diffie-hellman-group14-sha256",
|
|
53
|
+
"diffie-hellman-group16-sha512",
|
|
54
|
+
"diffie-hellman-group-exchange-sha256",
|
|
55
|
+
"diffie-hellman-group14-sha1",
|
|
56
|
+
"diffie-hellman-group-exchange-sha1",
|
|
57
|
+
"diffie-hellman-group1-sha1",
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
PREFERRED_KEYS = (
|
|
61
|
+
"rsa-sha2-512",
|
|
62
|
+
"rsa-sha2-256",
|
|
63
|
+
"ssh-rsa",
|
|
64
|
+
"ecdsa-sha2-nistp256",
|
|
65
|
+
"ecdsa-sha2-nistp384",
|
|
66
|
+
"ecdsa-sha2-nistp521",
|
|
67
|
+
"ssh-ed25519",
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
# Disabled algorithms to force RSA SHA-1 signatures
|
|
71
|
+
# Required for old OpenSSH servers (< 7.2) that don't support rsa-sha2-*
|
|
72
|
+
RSA_SHA1_DISABLED_ALGORITHMS = {
|
|
73
|
+
'pubkeys': ['rsa-sha2-256', 'rsa-sha2-512']
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
# Flag to track if we've applied global transport settings
|
|
77
|
+
_transport_configured = False
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _apply_global_transport_settings() -> None:
|
|
81
|
+
"""
|
|
82
|
+
Apply custom transport settings globally to Paramiko for legacy device support.
|
|
83
|
+
|
|
84
|
+
This must be called before creating any SSH connections. It modifies the
|
|
85
|
+
Paramiko Transport class to prefer algorithms compatible with older devices.
|
|
86
|
+
"""
|
|
87
|
+
global _transport_configured
|
|
88
|
+
|
|
89
|
+
if _transport_configured:
|
|
90
|
+
return
|
|
91
|
+
|
|
92
|
+
# Suppress deprecation warnings for legacy algorithms
|
|
93
|
+
warnings.filterwarnings('ignore', category=DeprecationWarning, module='paramiko')
|
|
94
|
+
|
|
95
|
+
try:
|
|
96
|
+
# Get what Paramiko actually supports
|
|
97
|
+
available_ciphers = set(paramiko.Transport._cipher_info.keys())
|
|
98
|
+
available_kex = set(paramiko.Transport._kex_info.keys())
|
|
99
|
+
available_keys = set(paramiko.Transport._key_info.keys())
|
|
100
|
+
|
|
101
|
+
# Filter to only supported algorithms
|
|
102
|
+
ciphers = tuple(c for c in PREFERRED_CIPHERS if c in available_ciphers)
|
|
103
|
+
kex = tuple(k for k in PREFERRED_KEX if k in available_kex)
|
|
104
|
+
keys = tuple(k for k in PREFERRED_KEYS if k in available_keys)
|
|
105
|
+
|
|
106
|
+
# Apply globally to Transport class
|
|
107
|
+
paramiko.Transport._preferred_ciphers = ciphers
|
|
108
|
+
paramiko.Transport._preferred_kex = kex
|
|
109
|
+
paramiko.Transport._preferred_keys = keys
|
|
110
|
+
|
|
111
|
+
logger.info(
|
|
112
|
+
f"Applied global transport settings: "
|
|
113
|
+
f"{len(ciphers)} ciphers, {len(kex)} kex, {len(keys)} keys"
|
|
114
|
+
)
|
|
115
|
+
logger.debug(f"Ciphers: {ciphers}")
|
|
116
|
+
logger.debug(f"KEX: {kex}")
|
|
117
|
+
logger.debug(f"Keys: {keys}")
|
|
118
|
+
|
|
119
|
+
except Exception as e:
|
|
120
|
+
logger.warning(f"Could not apply global transport settings: {e}")
|
|
121
|
+
|
|
122
|
+
_transport_configured = True
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
class SSHSession(Session):
|
|
126
|
+
"""
|
|
127
|
+
SSH session with full reconnection support.
|
|
128
|
+
|
|
129
|
+
Thread-safe. Runs I/O on background thread,
|
|
130
|
+
emits events to be handled on main thread.
|
|
131
|
+
|
|
132
|
+
Supports automatic fallback for:
|
|
133
|
+
- RSA SHA-1 signatures (old OpenSSH < 7.2)
|
|
134
|
+
- Legacy crypto algorithms (old network devices)
|
|
135
|
+
"""
|
|
136
|
+
|
|
137
|
+
READ_BUFFER_SIZE = 65536
|
|
138
|
+
|
|
139
|
+
def __init__(self, profile: ConnectionProfile, vault=None):
|
|
140
|
+
"""
|
|
141
|
+
Initialize SSH session.
|
|
142
|
+
|
|
143
|
+
Args:
|
|
144
|
+
profile: Connection profile with auth and host info
|
|
145
|
+
vault: Optional credential vault for resolving credential_ref
|
|
146
|
+
"""
|
|
147
|
+
# Apply global transport settings on first session creation
|
|
148
|
+
_apply_global_transport_settings()
|
|
149
|
+
|
|
150
|
+
self.profile = profile
|
|
151
|
+
self.vault = vault
|
|
152
|
+
|
|
153
|
+
self._state = SessionState.DISCONNECTED
|
|
154
|
+
self._state_lock = threading.Lock()
|
|
155
|
+
|
|
156
|
+
self._client: Optional[paramiko.SSHClient] = None
|
|
157
|
+
self._channel: Optional[paramiko.Channel] = None
|
|
158
|
+
self._jump_clients: list[paramiko.SSHClient] = []
|
|
159
|
+
|
|
160
|
+
self._event_handler: Optional[Callable[[SessionEvent], None]] = None
|
|
161
|
+
self._read_thread: Optional[threading.Thread] = None
|
|
162
|
+
self._stop_event = threading.Event()
|
|
163
|
+
|
|
164
|
+
self._reconnect_attempt = 0
|
|
165
|
+
self._cols = profile.term_cols
|
|
166
|
+
self._rows = profile.term_rows
|
|
167
|
+
|
|
168
|
+
# Track if we needed RSA SHA-1 fallback (for reconnects)
|
|
169
|
+
self._use_rsa_sha1 = getattr(profile, 'rsa_sha1', False)
|
|
170
|
+
|
|
171
|
+
@property
|
|
172
|
+
def state(self) -> SessionState:
|
|
173
|
+
"""Current session state (thread-safe)."""
|
|
174
|
+
with self._state_lock:
|
|
175
|
+
return self._state
|
|
176
|
+
|
|
177
|
+
@property
|
|
178
|
+
def is_connected(self) -> bool:
|
|
179
|
+
"""Is session currently connected and usable?"""
|
|
180
|
+
return self.state == SessionState.CONNECTED
|
|
181
|
+
|
|
182
|
+
def set_event_handler(self, handler: Callable[[SessionEvent], None]) -> None:
|
|
183
|
+
"""Set callback for session events."""
|
|
184
|
+
self._event_handler = handler
|
|
185
|
+
|
|
186
|
+
def set_auto_reconnect(self, enabled: bool) -> None:
|
|
187
|
+
"""Enable/disable automatic reconnection."""
|
|
188
|
+
self.profile.auto_reconnect = enabled
|
|
189
|
+
|
|
190
|
+
def _emit(self, event: SessionEvent) -> None:
|
|
191
|
+
"""Emit event to handler."""
|
|
192
|
+
if self._event_handler:
|
|
193
|
+
try:
|
|
194
|
+
self._event_handler(event)
|
|
195
|
+
except Exception as e:
|
|
196
|
+
logger.exception(f"Event handler error: {e}")
|
|
197
|
+
|
|
198
|
+
def _set_state(self, new_state: SessionState, message: str = "") -> None:
|
|
199
|
+
"""Update state and emit event."""
|
|
200
|
+
with self._state_lock:
|
|
201
|
+
old_state = self._state
|
|
202
|
+
self._state = new_state
|
|
203
|
+
logger.info(f"Session state: {old_state.name} -> {new_state.name} {message}")
|
|
204
|
+
self._emit(StateChanged(old_state, new_state, message))
|
|
205
|
+
|
|
206
|
+
def connect(self) -> None:
|
|
207
|
+
"""Start connection in background thread."""
|
|
208
|
+
if self.state not in (SessionState.DISCONNECTED, SessionState.FAILED):
|
|
209
|
+
logger.warning(f"Cannot connect from state {self.state}")
|
|
210
|
+
return
|
|
211
|
+
|
|
212
|
+
self._stop_event.clear()
|
|
213
|
+
thread = threading.Thread(target=self._connect_thread, daemon=True)
|
|
214
|
+
thread.start()
|
|
215
|
+
|
|
216
|
+
def _create_client(self) -> paramiko.SSHClient:
|
|
217
|
+
"""Create a new SSHClient with standard settings."""
|
|
218
|
+
client = paramiko.SSHClient()
|
|
219
|
+
client.load_system_host_keys()
|
|
220
|
+
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
|
221
|
+
return client
|
|
222
|
+
|
|
223
|
+
def _connect_thread(self) -> None:
|
|
224
|
+
"""Connection logic - runs on background thread."""
|
|
225
|
+
try:
|
|
226
|
+
self._set_state(SessionState.CONNECTING)
|
|
227
|
+
|
|
228
|
+
# Build jump chain if needed
|
|
229
|
+
sock = None
|
|
230
|
+
if self.profile.jump_hosts:
|
|
231
|
+
sock = self._establish_jump_chain()
|
|
232
|
+
|
|
233
|
+
# Connect to target
|
|
234
|
+
self._set_state(SessionState.AUTHENTICATING)
|
|
235
|
+
|
|
236
|
+
connected = False
|
|
237
|
+
last_error = None
|
|
238
|
+
|
|
239
|
+
for auth in self.profile.auth_methods:
|
|
240
|
+
try:
|
|
241
|
+
logger.info(f"Trying auth method: {auth.method.value}")
|
|
242
|
+
|
|
243
|
+
# Handle agent auth with potential touch prompt
|
|
244
|
+
if auth.method == AuthMethod.AGENT:
|
|
245
|
+
self._emit(InteractionRequired(
|
|
246
|
+
prompt="Authenticate with your security key...",
|
|
247
|
+
interaction_type="touch"
|
|
248
|
+
))
|
|
249
|
+
|
|
250
|
+
self._attempt_connection(auth, sock)
|
|
251
|
+
connected = True
|
|
252
|
+
break
|
|
253
|
+
|
|
254
|
+
except paramiko.AuthenticationException as e:
|
|
255
|
+
last_error = e
|
|
256
|
+
logger.debug(f"Auth method {auth.method} failed: {e}")
|
|
257
|
+
continue
|
|
258
|
+
|
|
259
|
+
except Exception as e:
|
|
260
|
+
last_error = e
|
|
261
|
+
logger.debug(f"Connection error with {auth.method}: {e}")
|
|
262
|
+
continue
|
|
263
|
+
|
|
264
|
+
if not connected:
|
|
265
|
+
raise paramiko.AuthenticationException(
|
|
266
|
+
f"All auth methods failed. Last error: {last_error}"
|
|
267
|
+
)
|
|
268
|
+
|
|
269
|
+
# Open shell channel
|
|
270
|
+
self._channel = self._client.invoke_shell(
|
|
271
|
+
term=self.profile.term_type,
|
|
272
|
+
width=self._cols,
|
|
273
|
+
height=self._rows,
|
|
274
|
+
)
|
|
275
|
+
self._channel.settimeout(0.1)
|
|
276
|
+
|
|
277
|
+
# Set up keepalive
|
|
278
|
+
transport = self._client.get_transport()
|
|
279
|
+
if transport:
|
|
280
|
+
transport.set_keepalive(self.profile.keepalive_interval)
|
|
281
|
+
logger.debug(
|
|
282
|
+
f"Negotiated: cipher={transport.remote_cipher}, "
|
|
283
|
+
f"mac={transport.remote_mac}"
|
|
284
|
+
)
|
|
285
|
+
|
|
286
|
+
self._set_state(SessionState.CONNECTED)
|
|
287
|
+
self._reconnect_attempt = 0
|
|
288
|
+
|
|
289
|
+
# Start read loop
|
|
290
|
+
self._read_loop()
|
|
291
|
+
|
|
292
|
+
except Exception as e:
|
|
293
|
+
logger.exception("Connection failed")
|
|
294
|
+
self._cleanup()
|
|
295
|
+
self._set_state(SessionState.FAILED, str(e))
|
|
296
|
+
|
|
297
|
+
# Auto-reconnect if enabled
|
|
298
|
+
if self.profile.auto_reconnect and not self._stop_event.is_set():
|
|
299
|
+
self._schedule_reconnect()
|
|
300
|
+
|
|
301
|
+
def _attempt_connection(self, auth: AuthConfig, sock=None) -> None:
|
|
302
|
+
"""
|
|
303
|
+
Attempt connection with automatic RSA SHA-1 fallback.
|
|
304
|
+
|
|
305
|
+
For RSA keys on old servers (OpenSSH < 7.2), the server may not
|
|
306
|
+
support rsa-sha2-256/512 signatures. We detect this and retry
|
|
307
|
+
with disabled_algorithms to force legacy ssh-rsa (SHA-1).
|
|
308
|
+
"""
|
|
309
|
+
kwargs = self._auth_config_to_kwargs(auth)
|
|
310
|
+
if sock:
|
|
311
|
+
kwargs['sock'] = sock
|
|
312
|
+
|
|
313
|
+
# If we already know this host needs RSA SHA-1, use it from the start
|
|
314
|
+
if self._use_rsa_sha1:
|
|
315
|
+
kwargs['disabled_algorithms'] = RSA_SHA1_DISABLED_ALGORITHMS
|
|
316
|
+
logger.debug("Using RSA SHA-1 mode (from previous connection)")
|
|
317
|
+
|
|
318
|
+
# Determine if this is RSA key auth (for fallback logic)
|
|
319
|
+
is_rsa_key = False
|
|
320
|
+
pkey = kwargs.get('pkey')
|
|
321
|
+
if pkey and isinstance(pkey, paramiko.RSAKey):
|
|
322
|
+
is_rsa_key = True
|
|
323
|
+
elif auth.method == AuthMethod.KEY_FILE and auth.key_path:
|
|
324
|
+
# Check if it's an RSA key file
|
|
325
|
+
try:
|
|
326
|
+
paramiko.RSAKey.from_private_key_file(auth.key_path)
|
|
327
|
+
is_rsa_key = True
|
|
328
|
+
except:
|
|
329
|
+
pass
|
|
330
|
+
|
|
331
|
+
# First attempt
|
|
332
|
+
try:
|
|
333
|
+
self._client = self._create_client()
|
|
334
|
+
self._client.connect(
|
|
335
|
+
self.profile.hostname,
|
|
336
|
+
port=self.profile.port,
|
|
337
|
+
timeout=self.profile.connect_timeout,
|
|
338
|
+
**kwargs
|
|
339
|
+
)
|
|
340
|
+
return # Success!
|
|
341
|
+
|
|
342
|
+
except paramiko.AuthenticationException as e:
|
|
343
|
+
# Check if this might be an RSA SHA-2 failure
|
|
344
|
+
if is_rsa_key and not self._use_rsa_sha1:
|
|
345
|
+
logger.info(f"RSA auth failed, retrying with SHA-1 fallback: {e}")
|
|
346
|
+
|
|
347
|
+
# Retry with RSA SHA-1
|
|
348
|
+
self._use_rsa_sha1 = True
|
|
349
|
+
kwargs['disabled_algorithms'] = RSA_SHA1_DISABLED_ALGORITHMS
|
|
350
|
+
|
|
351
|
+
self._client = self._create_client()
|
|
352
|
+
self._client.connect(
|
|
353
|
+
self.profile.hostname,
|
|
354
|
+
port=self.profile.port,
|
|
355
|
+
timeout=self.profile.connect_timeout,
|
|
356
|
+
**kwargs
|
|
357
|
+
)
|
|
358
|
+
logger.info("Connected with RSA SHA-1 fallback")
|
|
359
|
+
return
|
|
360
|
+
|
|
361
|
+
# Not RSA or already tried SHA-1, re-raise
|
|
362
|
+
raise
|
|
363
|
+
|
|
364
|
+
def _establish_jump_chain(self) -> paramiko.Channel:
|
|
365
|
+
"""Connect through jump host chain, return channel to final hop."""
|
|
366
|
+
current_sock = None
|
|
367
|
+
|
|
368
|
+
for i, jump in enumerate(self.profile.jump_hosts):
|
|
369
|
+
logger.info(
|
|
370
|
+
f"Connecting to jump host {i+1}/{len(self.profile.jump_hosts)}: "
|
|
371
|
+
f"{jump.hostname}"
|
|
372
|
+
)
|
|
373
|
+
|
|
374
|
+
if jump.requires_touch:
|
|
375
|
+
self._emit(InteractionRequired(
|
|
376
|
+
prompt=jump.touch_prompt,
|
|
377
|
+
interaction_type="touch"
|
|
378
|
+
))
|
|
379
|
+
|
|
380
|
+
jump_client = self._create_client()
|
|
381
|
+
kwargs = self._auth_config_to_kwargs(jump.auth)
|
|
382
|
+
|
|
383
|
+
if current_sock:
|
|
384
|
+
kwargs['sock'] = current_sock
|
|
385
|
+
|
|
386
|
+
# Check for jump-specific RSA SHA-1 flag
|
|
387
|
+
if getattr(jump, 'rsa_sha1', False):
|
|
388
|
+
kwargs['disabled_algorithms'] = RSA_SHA1_DISABLED_ALGORITHMS
|
|
389
|
+
|
|
390
|
+
# Try connection with RSA SHA-1 fallback
|
|
391
|
+
try:
|
|
392
|
+
jump_client.connect(
|
|
393
|
+
jump.hostname,
|
|
394
|
+
port=jump.port,
|
|
395
|
+
timeout=self.profile.connect_timeout,
|
|
396
|
+
banner_timeout=jump.banner_timeout,
|
|
397
|
+
**kwargs
|
|
398
|
+
)
|
|
399
|
+
except paramiko.AuthenticationException as e:
|
|
400
|
+
# Try RSA SHA-1 fallback for jump host
|
|
401
|
+
if 'disabled_algorithms' not in kwargs:
|
|
402
|
+
logger.info(f"Jump host {jump.hostname} auth failed, trying RSA SHA-1")
|
|
403
|
+
kwargs['disabled_algorithms'] = RSA_SHA1_DISABLED_ALGORITHMS
|
|
404
|
+
jump_client = self._create_client()
|
|
405
|
+
jump_client.connect(
|
|
406
|
+
jump.hostname,
|
|
407
|
+
port=jump.port,
|
|
408
|
+
timeout=self.profile.connect_timeout,
|
|
409
|
+
banner_timeout=jump.banner_timeout,
|
|
410
|
+
**kwargs
|
|
411
|
+
)
|
|
412
|
+
else:
|
|
413
|
+
raise
|
|
414
|
+
|
|
415
|
+
self._jump_clients.append(jump_client)
|
|
416
|
+
|
|
417
|
+
# Determine next hop
|
|
418
|
+
if i < len(self.profile.jump_hosts) - 1:
|
|
419
|
+
next_hop = self.profile.jump_hosts[i + 1]
|
|
420
|
+
next_host, next_port = next_hop.hostname, next_hop.port
|
|
421
|
+
else:
|
|
422
|
+
next_host = self.profile.hostname
|
|
423
|
+
next_port = self.profile.port
|
|
424
|
+
|
|
425
|
+
# Open channel to next hop
|
|
426
|
+
transport = jump_client.get_transport()
|
|
427
|
+
current_sock = transport.open_channel(
|
|
428
|
+
'direct-tcpip',
|
|
429
|
+
dest_addr=(next_host, next_port),
|
|
430
|
+
src_addr=('127.0.0.1', 0)
|
|
431
|
+
)
|
|
432
|
+
logger.debug(f"Opened channel to {next_host}:{next_port}")
|
|
433
|
+
|
|
434
|
+
return current_sock
|
|
435
|
+
|
|
436
|
+
def _auth_config_to_kwargs(self, auth: AuthConfig) -> dict:
|
|
437
|
+
"""Convert AuthConfig to paramiko connect kwargs."""
|
|
438
|
+
if auth is None:
|
|
439
|
+
return {}
|
|
440
|
+
|
|
441
|
+
kwargs = {'username': auth.username}
|
|
442
|
+
|
|
443
|
+
# Resolve credential reference if needed
|
|
444
|
+
password = auth.password
|
|
445
|
+
key_data = auth.key_data
|
|
446
|
+
key_passphrase = auth.key_passphrase
|
|
447
|
+
|
|
448
|
+
if auth.credential_ref and self.vault:
|
|
449
|
+
cred = self.vault.get_credential(auth.credential_ref)
|
|
450
|
+
if cred:
|
|
451
|
+
password = password or cred.password
|
|
452
|
+
key_data = key_data or getattr(cred, 'ssh_key', None)
|
|
453
|
+
key_passphrase = key_passphrase or getattr(cred, 'ssh_key_passphrase', None)
|
|
454
|
+
|
|
455
|
+
if auth.method == AuthMethod.PASSWORD:
|
|
456
|
+
kwargs['password'] = password
|
|
457
|
+
kwargs['look_for_keys'] = False
|
|
458
|
+
kwargs['allow_agent'] = False
|
|
459
|
+
|
|
460
|
+
elif auth.method == AuthMethod.AGENT:
|
|
461
|
+
kwargs['allow_agent'] = True
|
|
462
|
+
kwargs['look_for_keys'] = False
|
|
463
|
+
|
|
464
|
+
elif auth.method == AuthMethod.KEY_FILE:
|
|
465
|
+
kwargs['key_filename'] = auth.key_path
|
|
466
|
+
if key_passphrase:
|
|
467
|
+
kwargs['passphrase'] = key_passphrase
|
|
468
|
+
kwargs['allow_agent'] = auth.allow_agent_fallback
|
|
469
|
+
kwargs['look_for_keys'] = False
|
|
470
|
+
|
|
471
|
+
elif auth.method == AuthMethod.KEY_STORED:
|
|
472
|
+
if key_data:
|
|
473
|
+
kwargs['pkey'] = self._load_key_from_string(
|
|
474
|
+
key_data,
|
|
475
|
+
key_passphrase
|
|
476
|
+
)
|
|
477
|
+
kwargs['allow_agent'] = auth.allow_agent_fallback
|
|
478
|
+
kwargs['look_for_keys'] = False
|
|
479
|
+
|
|
480
|
+
elif auth.method == AuthMethod.CERTIFICATE:
|
|
481
|
+
kwargs['key_filename'] = auth.key_path
|
|
482
|
+
kwargs['allow_agent'] = False
|
|
483
|
+
kwargs['look_for_keys'] = False
|
|
484
|
+
|
|
485
|
+
return kwargs
|
|
486
|
+
|
|
487
|
+
def _load_key_from_string(
|
|
488
|
+
self,
|
|
489
|
+
key_data: str,
|
|
490
|
+
passphrase: str = None
|
|
491
|
+
) -> paramiko.PKey:
|
|
492
|
+
"""Load SSH key from string data."""
|
|
493
|
+
key_file = StringIO(key_data)
|
|
494
|
+
|
|
495
|
+
key_classes = [
|
|
496
|
+
paramiko.RSAKey,
|
|
497
|
+
paramiko.Ed25519Key,
|
|
498
|
+
paramiko.ECDSAKey,
|
|
499
|
+
]
|
|
500
|
+
|
|
501
|
+
for key_class in key_classes:
|
|
502
|
+
try:
|
|
503
|
+
key_file.seek(0)
|
|
504
|
+
return key_class.from_private_key(key_file, password=passphrase)
|
|
505
|
+
except (paramiko.SSHException, ValueError):
|
|
506
|
+
continue
|
|
507
|
+
|
|
508
|
+
raise paramiko.SSHException("Unable to parse private key")
|
|
509
|
+
|
|
510
|
+
def _read_loop(self) -> None:
|
|
511
|
+
"""Read data from channel until stopped or disconnected."""
|
|
512
|
+
while not self._stop_event.is_set():
|
|
513
|
+
try:
|
|
514
|
+
if self._channel and self._channel.recv_ready():
|
|
515
|
+
data = self._channel.recv(self.READ_BUFFER_SIZE)
|
|
516
|
+
if data:
|
|
517
|
+
self._emit(DataReceived(data))
|
|
518
|
+
else:
|
|
519
|
+
logger.info("Channel closed by remote")
|
|
520
|
+
break
|
|
521
|
+
elif self._channel and self._channel.closed:
|
|
522
|
+
logger.info("Channel closed")
|
|
523
|
+
break
|
|
524
|
+
else:
|
|
525
|
+
time.sleep(0.01)
|
|
526
|
+
|
|
527
|
+
except socket.timeout:
|
|
528
|
+
continue
|
|
529
|
+
except Exception as e:
|
|
530
|
+
logger.exception("Read error")
|
|
531
|
+
break
|
|
532
|
+
|
|
533
|
+
if not self._stop_event.is_set():
|
|
534
|
+
self._cleanup()
|
|
535
|
+
self._set_state(SessionState.DISCONNECTED, "Connection lost")
|
|
536
|
+
|
|
537
|
+
if self.profile.auto_reconnect:
|
|
538
|
+
self._schedule_reconnect()
|
|
539
|
+
|
|
540
|
+
def _schedule_reconnect(self) -> None:
|
|
541
|
+
"""Schedule a reconnection attempt."""
|
|
542
|
+
if self._reconnect_attempt >= self.profile.reconnect_max_attempts:
|
|
543
|
+
self._set_state(SessionState.FAILED, "Max reconnection attempts reached")
|
|
544
|
+
return
|
|
545
|
+
|
|
546
|
+
delay = self.profile.reconnect_delay * (
|
|
547
|
+
self.profile.reconnect_backoff ** self._reconnect_attempt
|
|
548
|
+
)
|
|
549
|
+
self._reconnect_attempt += 1
|
|
550
|
+
|
|
551
|
+
self._set_state(
|
|
552
|
+
SessionState.RECONNECTING,
|
|
553
|
+
f"Reconnecting in {delay:.1f}s (attempt {self._reconnect_attempt})"
|
|
554
|
+
)
|
|
555
|
+
|
|
556
|
+
def reconnect():
|
|
557
|
+
time.sleep(delay)
|
|
558
|
+
if not self._stop_event.is_set():
|
|
559
|
+
self._connect_thread()
|
|
560
|
+
|
|
561
|
+
thread = threading.Thread(target=reconnect, daemon=True)
|
|
562
|
+
thread.start()
|
|
563
|
+
|
|
564
|
+
def write(self, data: bytes) -> None:
|
|
565
|
+
"""Send data to remote."""
|
|
566
|
+
if self._channel and not self._channel.closed:
|
|
567
|
+
try:
|
|
568
|
+
self._channel.sendall(data)
|
|
569
|
+
except Exception as e:
|
|
570
|
+
logger.error(f"Write error: {e}")
|
|
571
|
+
|
|
572
|
+
def resize(self, cols: int, rows: int) -> None:
|
|
573
|
+
"""Notify remote of terminal resize."""
|
|
574
|
+
self._cols = cols
|
|
575
|
+
self._rows = rows
|
|
576
|
+
if self._channel and not self._channel.closed:
|
|
577
|
+
try:
|
|
578
|
+
self._channel.resize_pty(width=cols, height=rows)
|
|
579
|
+
except Exception as e:
|
|
580
|
+
logger.error(f"Resize error: {e}")
|
|
581
|
+
|
|
582
|
+
def disconnect(self) -> None:
|
|
583
|
+
"""Gracefully disconnect."""
|
|
584
|
+
logger.info("Disconnecting...")
|
|
585
|
+
self._stop_event.set()
|
|
586
|
+
self._cleanup()
|
|
587
|
+
self._set_state(SessionState.DISCONNECTED, "User disconnected")
|
|
588
|
+
|
|
589
|
+
def _cleanup(self) -> None:
|
|
590
|
+
"""Clean up connections."""
|
|
591
|
+
if self._channel:
|
|
592
|
+
try:
|
|
593
|
+
self._channel.close()
|
|
594
|
+
except Exception:
|
|
595
|
+
pass
|
|
596
|
+
self._channel = None
|
|
597
|
+
|
|
598
|
+
if self._client:
|
|
599
|
+
try:
|
|
600
|
+
self._client.close()
|
|
601
|
+
except Exception:
|
|
602
|
+
pass
|
|
603
|
+
self._client = None
|
|
604
|
+
|
|
605
|
+
for client in self._jump_clients:
|
|
606
|
+
try:
|
|
607
|
+
client.close()
|
|
608
|
+
except Exception:
|
|
609
|
+
pass
|
|
610
|
+
self._jump_clients.clear()
|
nterm/terminal/bridge.py
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Bridge between Python and xterm.js via QWebChannel.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from PyQt6.QtCore import QObject, pyqtSignal, pyqtSlot
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class TerminalBridge(QObject):
|
|
9
|
+
"""
|
|
10
|
+
Bridge between Python and xterm.js.
|
|
11
|
+
Exposed to JavaScript via QWebChannel.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
# Signals to JS (Python -> JavaScript)
|
|
15
|
+
write_data = pyqtSignal(str) # base64 encoded data
|
|
16
|
+
apply_theme = pyqtSignal(str) # JSON theme config
|
|
17
|
+
set_font = pyqtSignal(str, int) # family, size
|
|
18
|
+
do_resize = pyqtSignal(int, int) # cols, rows
|
|
19
|
+
show_overlay = pyqtSignal(str, bool) # message, show_spinner
|
|
20
|
+
hide_overlay = pyqtSignal()
|
|
21
|
+
focus_terminal = pyqtSignal()
|
|
22
|
+
clear_terminal = pyqtSignal()
|
|
23
|
+
|
|
24
|
+
# Clipboard signals to JS
|
|
25
|
+
do_copy = pyqtSignal() # trigger copy selection
|
|
26
|
+
do_paste = pyqtSignal(str) # base64 data to paste (confirmed)
|
|
27
|
+
show_paste_confirm = pyqtSignal(str, int) # preview text, line_count
|
|
28
|
+
hide_paste_confirm = pyqtSignal()
|
|
29
|
+
|
|
30
|
+
# Signals from JS (JavaScript -> Python)
|
|
31
|
+
data_from_terminal = pyqtSignal(str) # base64 encoded
|
|
32
|
+
size_changed = pyqtSignal(int, int) # cols, rows
|
|
33
|
+
terminal_ready = pyqtSignal()
|
|
34
|
+
title_changed = pyqtSignal(str)
|
|
35
|
+
|
|
36
|
+
# Clipboard signals from JS
|
|
37
|
+
selection_copied = pyqtSignal(str) # copied text
|
|
38
|
+
paste_requested = pyqtSignal(str) # base64 clipboard content for confirmation
|
|
39
|
+
paste_confirmed = pyqtSignal() # user confirmed multiline paste
|
|
40
|
+
paste_cancelled = pyqtSignal() # user cancelled multiline paste
|
|
41
|
+
|
|
42
|
+
def __init__(self):
|
|
43
|
+
super().__init__()
|
|
44
|
+
|
|
45
|
+
@pyqtSlot(str)
|
|
46
|
+
def onData(self, data_b64: str):
|
|
47
|
+
"""Called from JS when user types."""
|
|
48
|
+
self.data_from_terminal.emit(data_b64)
|
|
49
|
+
|
|
50
|
+
@pyqtSlot(int, int)
|
|
51
|
+
def onResize(self, cols: int, rows: int):
|
|
52
|
+
"""Called from JS on terminal resize."""
|
|
53
|
+
self.size_changed.emit(cols, rows)
|
|
54
|
+
|
|
55
|
+
@pyqtSlot()
|
|
56
|
+
def onReady(self):
|
|
57
|
+
"""Called from JS when xterm.js is initialized."""
|
|
58
|
+
self.terminal_ready.emit()
|
|
59
|
+
|
|
60
|
+
@pyqtSlot(str)
|
|
61
|
+
def onTitleChange(self, title: str):
|
|
62
|
+
"""Called from JS when terminal title changes."""
|
|
63
|
+
self.title_changed.emit(title)
|
|
64
|
+
|
|
65
|
+
@pyqtSlot(str)
|
|
66
|
+
def onSelectionCopied(self, text: str):
|
|
67
|
+
"""Called from JS when selection is copied."""
|
|
68
|
+
self.selection_copied.emit(text)
|
|
69
|
+
|
|
70
|
+
@pyqtSlot(str)
|
|
71
|
+
def onPasteRequested(self, data_b64: str):
|
|
72
|
+
"""Called from JS when paste is requested (for confirmation check)."""
|
|
73
|
+
self.paste_requested.emit(data_b64)
|
|
74
|
+
|
|
75
|
+
@pyqtSlot()
|
|
76
|
+
def onPasteConfirmed(self):
|
|
77
|
+
"""Called from JS when user confirms multiline paste."""
|
|
78
|
+
self.paste_confirmed.emit()
|
|
79
|
+
|
|
80
|
+
@pyqtSlot()
|
|
81
|
+
def onPasteCancelled(self):
|
|
82
|
+
"""Called from JS when user cancels multiline paste."""
|
|
83
|
+
self.paste_cancelled.emit()
|