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,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
|