py-web-ssh 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.
- py_web_ssh-0.1.0.dist-info/METADATA +75 -0
- py_web_ssh-0.1.0.dist-info/RECORD +16 -0
- py_web_ssh-0.1.0.dist-info/WHEEL +4 -0
- py_web_ssh-0.1.0.dist-info/entry_points.txt +3 -0
- py_web_ssh-0.1.0.dist-info/licenses/LICENSE +21 -0
- webssh/__init__.py +5 -0
- webssh/app.py +200 -0
- webssh/files.py +189 -0
- webssh/history.py +66 -0
- webssh/models.py +76 -0
- webssh/session.py +345 -0
- webssh/ssh_client.py +288 -0
- webssh/static/app.js +316 -0
- webssh/static/index.html +120 -0
- webssh/static/logs.html +44 -0
- webssh/static/styles.css +267 -0
webssh/session.py
ADDED
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import base64
|
|
5
|
+
import threading
|
|
6
|
+
import time
|
|
7
|
+
import traceback
|
|
8
|
+
import uuid
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from datetime import datetime, timezone
|
|
11
|
+
from collections.abc import Callable
|
|
12
|
+
from typing import Any, Literal
|
|
13
|
+
|
|
14
|
+
import paramiko
|
|
15
|
+
from starlette.websockets import WebSocket
|
|
16
|
+
|
|
17
|
+
from .history import OutputChunk, OutputHistory
|
|
18
|
+
from .models import ConnectRequest, LogEntry, SessionSummary
|
|
19
|
+
from .ssh_client import ConnectedClient, connect_ssh
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
SessionState = Literal["connecting", "connected", "closing", "closed", "error"]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(eq=False)
|
|
26
|
+
class BrowserConnection:
|
|
27
|
+
loop: asyncio.AbstractEventLoop
|
|
28
|
+
queue: asyncio.Queue[dict[str, Any]]
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class TerminalSession:
|
|
32
|
+
def __init__(self, config: ConnectRequest, clock: Callable[[], float] = time.monotonic) -> None:
|
|
33
|
+
self.id = str(uuid.uuid4())
|
|
34
|
+
self.config = config
|
|
35
|
+
self._clock = clock
|
|
36
|
+
self.created_at = datetime.now(timezone.utc)
|
|
37
|
+
self.updated_at = self.created_at
|
|
38
|
+
self.state: SessionState = "connecting"
|
|
39
|
+
self.history = OutputHistory(config.scrollback_bytes)
|
|
40
|
+
self._logs: list[LogEntry] = []
|
|
41
|
+
self._clients: set[BrowserConnection] = set()
|
|
42
|
+
self._lock = threading.RLock()
|
|
43
|
+
self._channel_lock = threading.RLock()
|
|
44
|
+
self._stop = threading.Event()
|
|
45
|
+
self._thread = threading.Thread(target=self._run, name=f"ssh-session-{self.id}", daemon=True)
|
|
46
|
+
self._connected: ConnectedClient | None = None
|
|
47
|
+
self._channel: paramiko.Channel | None = None
|
|
48
|
+
self._snapshot: tuple[int, bytes, datetime] | None = None
|
|
49
|
+
self._last_client_detached_at: float | None = self._clock()
|
|
50
|
+
self._reaped = False
|
|
51
|
+
|
|
52
|
+
def start(self) -> None:
|
|
53
|
+
self._thread.start()
|
|
54
|
+
|
|
55
|
+
def attach(self, websocket: WebSocket) -> BrowserConnection:
|
|
56
|
+
connection = BrowserConnection(loop=asyncio.get_running_loop(), queue=asyncio.Queue(maxsize=512))
|
|
57
|
+
with self._lock:
|
|
58
|
+
if self._reaped:
|
|
59
|
+
raise RuntimeError("Session was cleaned up after being idle.")
|
|
60
|
+
self._clients.add(connection)
|
|
61
|
+
self._last_client_detached_at = None
|
|
62
|
+
return connection
|
|
63
|
+
|
|
64
|
+
def detach(self, connection: BrowserConnection) -> None:
|
|
65
|
+
with self._lock:
|
|
66
|
+
self._clients.discard(connection)
|
|
67
|
+
if not self._clients:
|
|
68
|
+
self._last_client_detached_at = self._clock()
|
|
69
|
+
|
|
70
|
+
def summary(self) -> SessionSummary:
|
|
71
|
+
with self._lock:
|
|
72
|
+
return SessionSummary(
|
|
73
|
+
session_id=self.id,
|
|
74
|
+
state=self.state,
|
|
75
|
+
created_at=self.created_at,
|
|
76
|
+
updated_at=self.updated_at,
|
|
77
|
+
config=self.config.sanitized(),
|
|
78
|
+
output_next_seq=self.history.next_seq,
|
|
79
|
+
output_earliest_seq=self.history.earliest_seq,
|
|
80
|
+
has_snapshot=self._snapshot is not None,
|
|
81
|
+
connected_clients=len(self._clients),
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
def logs(self) -> list[LogEntry]:
|
|
85
|
+
with self._lock:
|
|
86
|
+
return list(self._logs)
|
|
87
|
+
|
|
88
|
+
def log(self, level: str, message: str, details: str | None = None) -> None:
|
|
89
|
+
entry = LogEntry(level=level, message=message, details=details)
|
|
90
|
+
with self._lock:
|
|
91
|
+
self._logs.append(entry)
|
|
92
|
+
self.updated_at = entry.timestamp
|
|
93
|
+
self._broadcast(
|
|
94
|
+
{
|
|
95
|
+
"type": "log",
|
|
96
|
+
"entry": entry.model_dump(mode="json"),
|
|
97
|
+
}
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
def replay_payload(self) -> dict[str, Any]:
|
|
101
|
+
with self._lock:
|
|
102
|
+
snapshot_seq: int | None = None
|
|
103
|
+
snapshot_b64: str | None = None
|
|
104
|
+
warning: str | None = None
|
|
105
|
+
if self._snapshot is not None:
|
|
106
|
+
snapshot_seq, snapshot, _created_at = self._snapshot
|
|
107
|
+
snapshot_b64 = base64.b64encode(snapshot).decode("ascii")
|
|
108
|
+
elif self.history.earliest_seq > 0:
|
|
109
|
+
warning = (
|
|
110
|
+
"The server trimmed early terminal bytes before any browser snapshot was saved. "
|
|
111
|
+
"Replay may be incomplete; reconnect sooner or increase scrollback_bytes."
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
since_seq = snapshot_seq
|
|
115
|
+
if since_seq is not None and since_seq < self.history.earliest_seq:
|
|
116
|
+
warning = (
|
|
117
|
+
"The server trimmed terminal bytes older than the latest browser snapshot. "
|
|
118
|
+
"Replay may be incomplete; increase scrollback_bytes for longer disconnected runs."
|
|
119
|
+
)
|
|
120
|
+
chunks = self.history.since()
|
|
121
|
+
else:
|
|
122
|
+
chunks = self.history.since(since_seq)
|
|
123
|
+
|
|
124
|
+
return {
|
|
125
|
+
"type": "replay",
|
|
126
|
+
"state": self.state,
|
|
127
|
+
"snapshot_seq": snapshot_seq,
|
|
128
|
+
"snapshot": snapshot_b64,
|
|
129
|
+
"history_earliest_seq": self.history.earliest_seq,
|
|
130
|
+
"history_next_seq": self.history.next_seq,
|
|
131
|
+
"chunks": [self._chunk_payload(chunk) for chunk in chunks],
|
|
132
|
+
"logs": [entry.model_dump(mode="json") for entry in self._logs[-200:]],
|
|
133
|
+
"warning": warning,
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
def send_input(self, data: bytes) -> None:
|
|
137
|
+
with self._channel_lock:
|
|
138
|
+
if self._channel is None or self.state != "connected":
|
|
139
|
+
raise RuntimeError("SSH channel is not connected.")
|
|
140
|
+
self._channel.sendall(data)
|
|
141
|
+
|
|
142
|
+
def resize(self, cols: int, rows: int) -> None:
|
|
143
|
+
with self._channel_lock:
|
|
144
|
+
if self._channel is not None and self.state == "connected":
|
|
145
|
+
self._channel.resize_pty(width=cols, height=rows)
|
|
146
|
+
|
|
147
|
+
def save_snapshot(self, seq: int, snapshot: bytes) -> None:
|
|
148
|
+
with self._lock:
|
|
149
|
+
if seq <= self.history.next_seq:
|
|
150
|
+
self._snapshot = (seq, snapshot, datetime.now(timezone.utc))
|
|
151
|
+
|
|
152
|
+
def close(self, reason: str = "Client requested disconnect.") -> None:
|
|
153
|
+
self.log("info", reason, None)
|
|
154
|
+
self._set_state("closing")
|
|
155
|
+
self._stop.set()
|
|
156
|
+
with self._channel_lock:
|
|
157
|
+
if self._channel is not None:
|
|
158
|
+
self._channel.close()
|
|
159
|
+
if self._connected is not None:
|
|
160
|
+
self._connected.client.close()
|
|
161
|
+
|
|
162
|
+
def mark_reaped_if_idle(self, now: float, idle_timeout_seconds: float) -> bool:
|
|
163
|
+
with self._lock:
|
|
164
|
+
if self._clients or self._last_client_detached_at is None or self._reaped:
|
|
165
|
+
return False
|
|
166
|
+
if now - self._last_client_detached_at < idle_timeout_seconds:
|
|
167
|
+
return False
|
|
168
|
+
self._reaped = True
|
|
169
|
+
return True
|
|
170
|
+
|
|
171
|
+
@property
|
|
172
|
+
def ssh_client(self) -> paramiko.SSHClient:
|
|
173
|
+
if self._connected is None or self.state not in ("connected", "closing", "closed"):
|
|
174
|
+
raise RuntimeError("SSH connection is not ready.")
|
|
175
|
+
return self._connected.client
|
|
176
|
+
|
|
177
|
+
def _run(self) -> None:
|
|
178
|
+
try:
|
|
179
|
+
self.log("info", "Creating SSH connection.", None)
|
|
180
|
+
self._connected = connect_ssh(self.config, self.log)
|
|
181
|
+
channel = self._connected.transport.open_session()
|
|
182
|
+
channel.get_pty(
|
|
183
|
+
term=self.config.term,
|
|
184
|
+
width=self.config.size.cols,
|
|
185
|
+
height=self.config.size.rows,
|
|
186
|
+
)
|
|
187
|
+
channel.invoke_shell()
|
|
188
|
+
channel.settimeout(0.0)
|
|
189
|
+
with self._channel_lock:
|
|
190
|
+
self._channel = channel
|
|
191
|
+
self._set_state("connected")
|
|
192
|
+
self.log("info", "Interactive SSH terminal is ready.", None)
|
|
193
|
+
|
|
194
|
+
while not self._stop.is_set():
|
|
195
|
+
try:
|
|
196
|
+
if channel.recv_ready():
|
|
197
|
+
data = channel.recv(64 * 1024)
|
|
198
|
+
if not data:
|
|
199
|
+
break
|
|
200
|
+
self._append_output(data)
|
|
201
|
+
elif channel.exit_status_ready():
|
|
202
|
+
break
|
|
203
|
+
else:
|
|
204
|
+
time.sleep(0.01)
|
|
205
|
+
except paramiko.ssh_exception.SSHException:
|
|
206
|
+
raise
|
|
207
|
+
except Exception as exc:
|
|
208
|
+
if self._stop.is_set():
|
|
209
|
+
break
|
|
210
|
+
if "timed out" not in str(exc).lower():
|
|
211
|
+
raise
|
|
212
|
+
|
|
213
|
+
if self._stop.is_set():
|
|
214
|
+
self._set_state("closed")
|
|
215
|
+
self.log("info", "SSH terminal closed by request.", None)
|
|
216
|
+
else:
|
|
217
|
+
self._set_state("closed")
|
|
218
|
+
self.log("warning", "SSH terminal channel closed by the remote server.", None)
|
|
219
|
+
except Exception as exc:
|
|
220
|
+
self._set_state("error")
|
|
221
|
+
self.log("error", f"SSH session failed: {exc}", traceback.format_exc())
|
|
222
|
+
self._append_output(f"\r\n[py-web-ssh] SSH session failed: {exc}\r\n".encode("utf-8"))
|
|
223
|
+
finally:
|
|
224
|
+
with self._channel_lock:
|
|
225
|
+
try:
|
|
226
|
+
if self._channel is not None:
|
|
227
|
+
self._channel.close()
|
|
228
|
+
finally:
|
|
229
|
+
self._channel = None
|
|
230
|
+
if self._connected is not None:
|
|
231
|
+
self._connected.client.close()
|
|
232
|
+
if self.state not in ("error", "closed"):
|
|
233
|
+
self._set_state("closed")
|
|
234
|
+
|
|
235
|
+
def _append_output(self, data: bytes) -> None:
|
|
236
|
+
with self._lock:
|
|
237
|
+
chunk = self.history.append(data)
|
|
238
|
+
self.updated_at = datetime.now(timezone.utc)
|
|
239
|
+
self._broadcast(self._chunk_payload(chunk) | {"type": "output"})
|
|
240
|
+
|
|
241
|
+
def _chunk_payload(self, chunk: OutputChunk) -> dict[str, Any]:
|
|
242
|
+
return {
|
|
243
|
+
"seq": chunk.seq,
|
|
244
|
+
"data": base64.b64encode(chunk.data).decode("ascii"),
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
def _set_state(self, state: SessionState) -> None:
|
|
248
|
+
with self._lock:
|
|
249
|
+
self.state = state
|
|
250
|
+
self.updated_at = datetime.now(timezone.utc)
|
|
251
|
+
self._broadcast({"type": "status", "state": state})
|
|
252
|
+
|
|
253
|
+
def _broadcast(self, message: dict[str, Any]) -> None:
|
|
254
|
+
with self._lock:
|
|
255
|
+
clients = list(self._clients)
|
|
256
|
+
for client in clients:
|
|
257
|
+
try:
|
|
258
|
+
client.loop.call_soon_threadsafe(self._queue_message, client, message)
|
|
259
|
+
except RuntimeError:
|
|
260
|
+
self.detach(client)
|
|
261
|
+
|
|
262
|
+
def _queue_message(self, client: BrowserConnection, message: dict[str, Any]) -> None:
|
|
263
|
+
try:
|
|
264
|
+
client.queue.put_nowait(message)
|
|
265
|
+
except asyncio.QueueFull:
|
|
266
|
+
try:
|
|
267
|
+
client.queue.get_nowait()
|
|
268
|
+
except asyncio.QueueEmpty:
|
|
269
|
+
pass
|
|
270
|
+
client.queue.put_nowait(
|
|
271
|
+
{
|
|
272
|
+
"type": "warning",
|
|
273
|
+
"message": "Browser message queue overflowed; an output frame was dropped.",
|
|
274
|
+
}
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
class SessionManager:
|
|
279
|
+
def __init__(
|
|
280
|
+
self,
|
|
281
|
+
idle_timeout_seconds: float = 300.0,
|
|
282
|
+
cleanup_interval_seconds: float = 30.0,
|
|
283
|
+
autostart_reaper: bool = True,
|
|
284
|
+
clock: Callable[[], float] = time.monotonic,
|
|
285
|
+
) -> None:
|
|
286
|
+
self._sessions: dict[str, TerminalSession] = {}
|
|
287
|
+
self._lock = threading.RLock()
|
|
288
|
+
self._idle_timeout_seconds = idle_timeout_seconds
|
|
289
|
+
self._cleanup_interval_seconds = cleanup_interval_seconds
|
|
290
|
+
self._clock = clock
|
|
291
|
+
self._stop_reaper = threading.Event()
|
|
292
|
+
self._reaper_thread: threading.Thread | None = None
|
|
293
|
+
if autostart_reaper and idle_timeout_seconds > 0:
|
|
294
|
+
self._reaper_thread = threading.Thread(
|
|
295
|
+
target=self._run_reaper,
|
|
296
|
+
name="ssh-session-reaper",
|
|
297
|
+
daemon=True,
|
|
298
|
+
)
|
|
299
|
+
self._reaper_thread.start()
|
|
300
|
+
|
|
301
|
+
def create(self, config: ConnectRequest) -> TerminalSession:
|
|
302
|
+
session = TerminalSession(config, clock=self._clock)
|
|
303
|
+
with self._lock:
|
|
304
|
+
self._sessions[session.id] = session
|
|
305
|
+
session.start()
|
|
306
|
+
return session
|
|
307
|
+
|
|
308
|
+
def get(self, session_id: str) -> TerminalSession | None:
|
|
309
|
+
with self._lock:
|
|
310
|
+
return self._sessions.get(session_id)
|
|
311
|
+
|
|
312
|
+
def list(self) -> list[TerminalSession]:
|
|
313
|
+
with self._lock:
|
|
314
|
+
return list(self._sessions.values())
|
|
315
|
+
|
|
316
|
+
def close(self, session_id: str, reason: str = "Client requested disconnect.") -> bool:
|
|
317
|
+
session = self.get(session_id)
|
|
318
|
+
if session is None:
|
|
319
|
+
return False
|
|
320
|
+
session.close(reason)
|
|
321
|
+
return True
|
|
322
|
+
|
|
323
|
+
def cleanup_expired(self) -> list[str]:
|
|
324
|
+
now = self._clock()
|
|
325
|
+
expired: list[tuple[str, TerminalSession]] = []
|
|
326
|
+
with self._lock:
|
|
327
|
+
for session_id, session in list(self._sessions.items()):
|
|
328
|
+
if session.mark_reaped_if_idle(now, self._idle_timeout_seconds):
|
|
329
|
+
expired.append((session_id, session))
|
|
330
|
+
self._sessions.pop(session_id, None)
|
|
331
|
+
|
|
332
|
+
for session_id, session in expired:
|
|
333
|
+
session.close(
|
|
334
|
+
"No browser reconnected for 300 seconds; SSH session is being cleaned up."
|
|
335
|
+
)
|
|
336
|
+
return [session_id for session_id, _session in expired]
|
|
337
|
+
|
|
338
|
+
def stop_reaper(self) -> None:
|
|
339
|
+
self._stop_reaper.set()
|
|
340
|
+
if self._reaper_thread is not None:
|
|
341
|
+
self._reaper_thread.join(timeout=2.0)
|
|
342
|
+
|
|
343
|
+
def _run_reaper(self) -> None:
|
|
344
|
+
while not self._stop_reaper.wait(self._cleanup_interval_seconds):
|
|
345
|
+
self.cleanup_expired()
|
webssh/ssh_client.py
ADDED
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import io
|
|
4
|
+
import socket
|
|
5
|
+
import traceback
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Callable
|
|
9
|
+
|
|
10
|
+
import paramiko
|
|
11
|
+
|
|
12
|
+
from .models import ConnectRequest
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
LogCallback = Callable[[str, str, str | None], None]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass
|
|
19
|
+
class ConnectedClient:
|
|
20
|
+
client: paramiko.SSHClient
|
|
21
|
+
transport: paramiko.Transport
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def connect_ssh(config: ConnectRequest, log: LogCallback) -> ConnectedClient:
|
|
25
|
+
"""Open an SSH connection, including legacy algorithms Paramiko still supports."""
|
|
26
|
+
|
|
27
|
+
sock = socket.create_connection((config.host, config.port), timeout=config.timeout_seconds)
|
|
28
|
+
try:
|
|
29
|
+
transport = paramiko.Transport(sock, disabled_algorithms={})
|
|
30
|
+
if config.legacy_algorithms:
|
|
31
|
+
_enable_legacy_algorithms(transport, log)
|
|
32
|
+
|
|
33
|
+
log("info", f"Starting SSH handshake with {config.host}:{config.port}.", None)
|
|
34
|
+
transport.start_client(timeout=config.timeout_seconds)
|
|
35
|
+
if config.keepalive_seconds:
|
|
36
|
+
transport.set_keepalive(config.keepalive_seconds)
|
|
37
|
+
|
|
38
|
+
if config.strict_host_key:
|
|
39
|
+
_check_known_host(config, transport, log)
|
|
40
|
+
else:
|
|
41
|
+
log("warning", "Strict host key checking is disabled for this browser session.", None)
|
|
42
|
+
|
|
43
|
+
_authenticate(transport, config, log)
|
|
44
|
+
|
|
45
|
+
client = paramiko.SSHClient()
|
|
46
|
+
client._transport = transport # Paramiko exposes no public constructor for this case.
|
|
47
|
+
log("info", "SSH authentication succeeded.", None)
|
|
48
|
+
return ConnectedClient(client=client, transport=transport)
|
|
49
|
+
except Exception:
|
|
50
|
+
sock.close()
|
|
51
|
+
raise
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _enable_legacy_algorithms(transport: paramiko.Transport, log: LogCallback) -> None:
|
|
55
|
+
options = transport.get_security_options()
|
|
56
|
+
preferred = {
|
|
57
|
+
"kex": (
|
|
58
|
+
"curve25519-sha256@libssh.org",
|
|
59
|
+
"ecdh-sha2-nistp256",
|
|
60
|
+
"ecdh-sha2-nistp384",
|
|
61
|
+
"ecdh-sha2-nistp521",
|
|
62
|
+
"diffie-hellman-group16-sha512",
|
|
63
|
+
"diffie-hellman-group14-sha256",
|
|
64
|
+
"diffie-hellman-group-exchange-sha256",
|
|
65
|
+
"diffie-hellman-group14-sha1",
|
|
66
|
+
"diffie-hellman-group-exchange-sha1",
|
|
67
|
+
"diffie-hellman-group1-sha1",
|
|
68
|
+
),
|
|
69
|
+
"ciphers": (
|
|
70
|
+
"aes128-ctr",
|
|
71
|
+
"aes192-ctr",
|
|
72
|
+
"aes256-ctr",
|
|
73
|
+
"aes128-gcm@openssh.com",
|
|
74
|
+
"aes256-gcm@openssh.com",
|
|
75
|
+
"aes128-cbc",
|
|
76
|
+
"aes192-cbc",
|
|
77
|
+
"aes256-cbc",
|
|
78
|
+
"3des-cbc",
|
|
79
|
+
),
|
|
80
|
+
"digests": (
|
|
81
|
+
"hmac-sha2-256",
|
|
82
|
+
"hmac-sha2-512",
|
|
83
|
+
"hmac-sha1",
|
|
84
|
+
"hmac-sha1-96",
|
|
85
|
+
"hmac-md5",
|
|
86
|
+
"hmac-md5-96",
|
|
87
|
+
),
|
|
88
|
+
"key_types": (
|
|
89
|
+
"ssh-ed25519",
|
|
90
|
+
"ecdsa-sha2-nistp256",
|
|
91
|
+
"ecdsa-sha2-nistp384",
|
|
92
|
+
"ecdsa-sha2-nistp521",
|
|
93
|
+
"rsa-sha2-512",
|
|
94
|
+
"rsa-sha2-256",
|
|
95
|
+
"ssh-rsa",
|
|
96
|
+
"ssh-dss",
|
|
97
|
+
),
|
|
98
|
+
"pubkeys": (
|
|
99
|
+
"ssh-ed25519",
|
|
100
|
+
"ecdsa-sha2-nistp256",
|
|
101
|
+
"ecdsa-sha2-nistp384",
|
|
102
|
+
"ecdsa-sha2-nistp521",
|
|
103
|
+
"rsa-sha2-512",
|
|
104
|
+
"rsa-sha2-256",
|
|
105
|
+
"ssh-rsa",
|
|
106
|
+
"ssh-dss",
|
|
107
|
+
),
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
changed: list[str] = []
|
|
111
|
+
unavailable: list[str] = []
|
|
112
|
+
for attr, wanted in preferred.items():
|
|
113
|
+
current = _current_algorithms(options, attr)
|
|
114
|
+
supported = _supported_algorithms(options, attr)
|
|
115
|
+
wanted_supported = [item for item in wanted if item in supported]
|
|
116
|
+
unavailable.extend(f"{attr}:{item}" for item in wanted if item not in supported)
|
|
117
|
+
merged = tuple(dict.fromkeys(wanted_supported + list(current)))
|
|
118
|
+
try:
|
|
119
|
+
_set_algorithms(options, attr, merged)
|
|
120
|
+
changed.append(f"{attr}={','.join(merged)}")
|
|
121
|
+
except Exception as exc: # pragma: no cover - depends on Paramiko build/runtime.
|
|
122
|
+
log("warning", f"Could not set SSH {attr} algorithms: {exc}", None)
|
|
123
|
+
|
|
124
|
+
details = "\n".join(changed)
|
|
125
|
+
if unavailable:
|
|
126
|
+
details += "\n\nUnavailable in this Paramiko runtime:\n" + "\n".join(unavailable)
|
|
127
|
+
log("debug", "Paramiko security options prepared for broad legacy compatibility.", details)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _current_algorithms(options: paramiko.transport.SecurityOptions, attr: str) -> tuple[str, ...]:
|
|
131
|
+
if attr == "pubkeys":
|
|
132
|
+
return tuple(options._transport._preferred_pubkeys)
|
|
133
|
+
return tuple(getattr(options, attr))
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _set_algorithms(
|
|
137
|
+
options: paramiko.transport.SecurityOptions,
|
|
138
|
+
attr: str,
|
|
139
|
+
algorithms: tuple[str, ...],
|
|
140
|
+
) -> None:
|
|
141
|
+
if attr == "pubkeys":
|
|
142
|
+
options._transport._preferred_pubkeys = algorithms
|
|
143
|
+
else:
|
|
144
|
+
setattr(options, attr, algorithms)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _supported_algorithms(options: paramiko.transport.SecurityOptions, attr: str) -> set[str]:
|
|
148
|
+
if attr == "pubkeys":
|
|
149
|
+
return set(options._transport._key_info.keys())
|
|
150
|
+
|
|
151
|
+
transport = options._transport
|
|
152
|
+
info_attr = {
|
|
153
|
+
"kex": "_kex_info",
|
|
154
|
+
"ciphers": "_cipher_info",
|
|
155
|
+
"digests": "_mac_info",
|
|
156
|
+
"key_types": "_key_info",
|
|
157
|
+
}[attr]
|
|
158
|
+
return set(getattr(transport, info_attr).keys())
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _check_known_host(config: ConnectRequest, transport: paramiko.Transport, log: LogCallback) -> None:
|
|
162
|
+
host_key = transport.get_remote_server_key()
|
|
163
|
+
known_hosts = paramiko.HostKeys()
|
|
164
|
+
paths = [
|
|
165
|
+
Path.home() / ".ssh" / "known_hosts",
|
|
166
|
+
Path.home() / ".ssh" / "known_hosts2",
|
|
167
|
+
]
|
|
168
|
+
for path in paths:
|
|
169
|
+
if path.exists():
|
|
170
|
+
known_hosts.load(str(path))
|
|
171
|
+
|
|
172
|
+
candidates = [config.host, f"[{config.host}]:{config.port}"]
|
|
173
|
+
for candidate in candidates:
|
|
174
|
+
if known_hosts.check(candidate, host_key):
|
|
175
|
+
log("info", f"Host key verified for {candidate}.", None)
|
|
176
|
+
return
|
|
177
|
+
|
|
178
|
+
raise paramiko.SSHException(
|
|
179
|
+
"Strict host key checking failed. The remote host key is not present in "
|
|
180
|
+
"~/.ssh/known_hosts for this server and port."
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _authenticate(transport: paramiko.Transport, config: ConnectRequest, log: LogCallback) -> None:
|
|
185
|
+
username = config.username
|
|
186
|
+
errors: list[str] = []
|
|
187
|
+
|
|
188
|
+
if config.private_key:
|
|
189
|
+
try:
|
|
190
|
+
pkey = _load_private_key(config.private_key, config.private_key_passphrase)
|
|
191
|
+
log("info", f"Trying private key authentication using {pkey.get_name()}.", None)
|
|
192
|
+
transport.auth_publickey(username, pkey)
|
|
193
|
+
except Exception as exc:
|
|
194
|
+
errors.append(f"private key: {exc}")
|
|
195
|
+
log("warning", f"Private key authentication failed: {exc}", traceback.format_exc())
|
|
196
|
+
if transport.is_authenticated():
|
|
197
|
+
return
|
|
198
|
+
|
|
199
|
+
if config.allow_agent:
|
|
200
|
+
try:
|
|
201
|
+
for key in paramiko.Agent().get_keys():
|
|
202
|
+
try:
|
|
203
|
+
log("info", f"Trying SSH agent key {key.get_name()}.", None)
|
|
204
|
+
transport.auth_publickey(username, key)
|
|
205
|
+
except Exception as exc:
|
|
206
|
+
errors.append(f"agent key {key.get_name()}: {exc}")
|
|
207
|
+
log("debug", f"SSH agent key failed: {exc}", None)
|
|
208
|
+
if transport.is_authenticated():
|
|
209
|
+
return
|
|
210
|
+
except Exception as exc:
|
|
211
|
+
errors.append(f"agent: {exc}")
|
|
212
|
+
log("warning", f"SSH agent authentication failed: {exc}", traceback.format_exc())
|
|
213
|
+
|
|
214
|
+
if config.look_for_keys:
|
|
215
|
+
for key in _load_default_private_keys(config.private_key_passphrase, log):
|
|
216
|
+
try:
|
|
217
|
+
log("info", f"Trying local key file {key.get_name()}.", None)
|
|
218
|
+
transport.auth_publickey(username, key)
|
|
219
|
+
except Exception as exc:
|
|
220
|
+
errors.append(f"local key {key.get_name()}: {exc}")
|
|
221
|
+
log("debug", f"Local key failed: {exc}", None)
|
|
222
|
+
if transport.is_authenticated():
|
|
223
|
+
return
|
|
224
|
+
|
|
225
|
+
if config.password is not None:
|
|
226
|
+
try:
|
|
227
|
+
log("info", "Trying password authentication.", None)
|
|
228
|
+
transport.auth_password(username, config.password, fallback=True)
|
|
229
|
+
except Exception as exc:
|
|
230
|
+
errors.append(f"password: {exc}")
|
|
231
|
+
log("warning", f"Password authentication failed: {exc}", traceback.format_exc())
|
|
232
|
+
if transport.is_authenticated():
|
|
233
|
+
return
|
|
234
|
+
|
|
235
|
+
try:
|
|
236
|
+
log("info", "Trying keyboard-interactive authentication with the supplied password.", None)
|
|
237
|
+
transport.auth_interactive(username, lambda _title, _instructions, prompts: [config.password] * len(prompts))
|
|
238
|
+
except Exception as exc:
|
|
239
|
+
errors.append(f"keyboard-interactive: {exc}")
|
|
240
|
+
log("warning", f"Keyboard-interactive authentication failed: {exc}", traceback.format_exc())
|
|
241
|
+
if transport.is_authenticated():
|
|
242
|
+
return
|
|
243
|
+
|
|
244
|
+
try:
|
|
245
|
+
log("info", "Trying none authentication.", None)
|
|
246
|
+
transport.auth_none(username)
|
|
247
|
+
except Exception as exc:
|
|
248
|
+
errors.append(f"none: {exc}")
|
|
249
|
+
log("debug", f"None authentication failed: {exc}", None)
|
|
250
|
+
if transport.is_authenticated():
|
|
251
|
+
return
|
|
252
|
+
|
|
253
|
+
joined = "\n".join(errors) if errors else "No authentication method was accepted."
|
|
254
|
+
raise paramiko.AuthenticationException(f"SSH authentication failed:\n{joined}")
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def _load_private_key(text: str, passphrase: str | None) -> paramiko.PKey:
|
|
258
|
+
errors: list[str] = []
|
|
259
|
+
key_classes = [
|
|
260
|
+
paramiko.Ed25519Key,
|
|
261
|
+
paramiko.ECDSAKey,
|
|
262
|
+
paramiko.RSAKey,
|
|
263
|
+
paramiko.DSSKey,
|
|
264
|
+
]
|
|
265
|
+
for key_class in key_classes:
|
|
266
|
+
try:
|
|
267
|
+
return key_class.from_private_key(io.StringIO(text), password=passphrase)
|
|
268
|
+
except Exception as exc:
|
|
269
|
+
errors.append(f"{key_class.__name__}: {exc}")
|
|
270
|
+
raise paramiko.SSHException("Could not parse private key. Tried: " + "; ".join(errors))
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def _load_default_private_keys(passphrase: str | None, log: LogCallback) -> list[paramiko.PKey]:
|
|
274
|
+
candidates = [
|
|
275
|
+
Path.home() / ".ssh" / "id_ed25519",
|
|
276
|
+
Path.home() / ".ssh" / "id_ecdsa",
|
|
277
|
+
Path.home() / ".ssh" / "id_rsa",
|
|
278
|
+
Path.home() / ".ssh" / "id_dsa",
|
|
279
|
+
]
|
|
280
|
+
keys: list[paramiko.PKey] = []
|
|
281
|
+
for path in candidates:
|
|
282
|
+
if not path.exists():
|
|
283
|
+
continue
|
|
284
|
+
try:
|
|
285
|
+
keys.append(_load_private_key(path.read_text(encoding="utf-8"), passphrase))
|
|
286
|
+
except Exception as exc:
|
|
287
|
+
log("debug", f"Could not load local key {path}: {exc}", None)
|
|
288
|
+
return keys
|