@neoline/hostbridge 2.0.4 → 2.0.5
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.
- package/README.md +11 -9
- package/docs/ARCHITECTURE.md +62 -0
- package/package.json +2 -1
- package/pyproject.toml +1 -1
- package/src/server_control_mcp/__init__.py +4 -3
- package/src/server_control_mcp/async_lifecycle.py +41 -0
- package/src/server_control_mcp/cli.py +1 -1
- package/src/server_control_mcp/client.py +302 -287
- package/src/server_control_mcp/config.py +2 -1
- package/src/server_control_mcp/daemon.py +233 -526
- package/src/server_control_mcp/doctor.py +34 -2
- package/src/server_control_mcp/hosts.py +136 -2
- package/src/server_control_mcp/mock_ssh.py +52 -14
- package/src/server_control_mcp/mock_ssh_exec.py +147 -58
- package/src/server_control_mcp/mock_ssh_session.py +3 -2
- package/src/server_control_mcp/mock_ssh_sftp.py +126 -28
- package/src/server_control_mcp/mock_ssh_tunnel.py +141 -444
- package/src/server_control_mcp/mux_connection.py +326 -0
- package/src/server_control_mcp/mux_daemon.py +186 -0
- package/src/server_control_mcp/mux_protocol.py +279 -0
- package/src/server_control_mcp/mux_records.py +71 -0
- package/src/server_control_mcp/mux_rpc.py +1779 -0
- package/src/server_control_mcp/mux_service.py +506 -0
- package/src/server_control_mcp/mux_stream.py +283 -0
- package/src/server_control_mcp/mux_sync_client.py +224 -0
- package/src/server_control_mcp/remote_agent_bundle.py +56 -0
- package/src/server_control_mcp/remote_mux_agent.py +769 -0
- package/src/server_control_mcp/runtime_services.py +862 -0
- package/src/server_control_mcp/server.py +33 -18
- package/src/server_control_mcp/services.py +2 -666
- package/src/server_control_mcp/transports/__init__.py +4 -8
- package/src/server_control_mcp/transports/base.py +60 -38
- package/src/server_control_mcp/transports/ssh.py +206 -259
- package/src/server_control_mcp/tunnel_manager.py +1245 -0
- package/src/server_control_mcp/tunnel_native_ssh.py +454 -0
- package/src/server_control_mcp/tunnel_providers.py +20 -0
- package/src/server_control_mcp/tunnel_pty_agent.py +909 -0
- package/src/server_control_mcp/protocol.py +0 -363
- package/src/server_control_mcp/remote_tunnel_agent.py +0 -143
- package/src/server_control_mcp/transports/pty.py +0 -515
|
@@ -4,20 +4,31 @@ from __future__ import annotations
|
|
|
4
4
|
|
|
5
5
|
import asyncio
|
|
6
6
|
import hashlib
|
|
7
|
-
import
|
|
7
|
+
import math
|
|
8
8
|
import uuid
|
|
9
9
|
from collections import deque
|
|
10
|
-
from collections.abc import AsyncGenerator,
|
|
10
|
+
from collections.abc import AsyncGenerator, Sequence
|
|
11
11
|
from contextlib import suppress
|
|
12
12
|
from dataclasses import dataclass
|
|
13
|
-
from pathlib import Path
|
|
14
|
-
from typing import TypeVar
|
|
15
13
|
|
|
16
14
|
import asyncssh
|
|
17
15
|
|
|
18
|
-
from
|
|
19
|
-
|
|
20
|
-
|
|
16
|
+
from ..async_lifecycle import (
|
|
17
|
+
finish_task_before_cancellation,
|
|
18
|
+
finish_task_despite_cancellation,
|
|
19
|
+
stop_input_after_peer_eof,
|
|
20
|
+
)
|
|
21
|
+
from .base import (
|
|
22
|
+
ByteStream,
|
|
23
|
+
ExecResult,
|
|
24
|
+
ShellAttachment,
|
|
25
|
+
ShellReadResult,
|
|
26
|
+
ShellTerminalResult,
|
|
27
|
+
TransferResult,
|
|
28
|
+
TransportCapabilities,
|
|
29
|
+
TransportError,
|
|
30
|
+
iterate_byte_stream,
|
|
31
|
+
)
|
|
21
32
|
|
|
22
33
|
|
|
23
34
|
class _CombinedOutputBuffer:
|
|
@@ -62,18 +73,36 @@ class SshConnectionConfig:
|
|
|
62
73
|
password: str | None = None
|
|
63
74
|
|
|
64
75
|
|
|
65
|
-
|
|
76
|
+
DEFAULT_SFTP_CLEANUP_TIMEOUT_SECONDS = 5.0
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class AsyncSshConnection:
|
|
66
80
|
capabilities = TransportCapabilities(
|
|
67
81
|
separate_stderr=True,
|
|
68
82
|
native_binary_transfer=True,
|
|
69
83
|
parallel_channels=8,
|
|
70
84
|
)
|
|
71
85
|
|
|
72
|
-
def __init__(
|
|
86
|
+
def __init__(
|
|
87
|
+
self,
|
|
88
|
+
connection: asyncssh.SSHClientConnection,
|
|
89
|
+
*,
|
|
90
|
+
sftp_cleanup_timeout: float = DEFAULT_SFTP_CLEANUP_TIMEOUT_SECONDS,
|
|
91
|
+
) -> None:
|
|
92
|
+
if (
|
|
93
|
+
not isinstance(sftp_cleanup_timeout, (int, float))
|
|
94
|
+
or isinstance(sftp_cleanup_timeout, bool)
|
|
95
|
+
or not math.isfinite(sftp_cleanup_timeout)
|
|
96
|
+
or sftp_cleanup_timeout <= 0
|
|
97
|
+
):
|
|
98
|
+
raise ValueError("sftp_cleanup_timeout must be a finite positive number")
|
|
73
99
|
self._connection = connection
|
|
100
|
+
self._sftp_cleanup_timeout = float(sftp_cleanup_timeout)
|
|
101
|
+
self._shells: dict[str, asyncssh.SSHClientProcess[bytes]] = {}
|
|
102
|
+
self._shell_creation_lock = asyncio.Lock()
|
|
74
103
|
|
|
75
104
|
@classmethod
|
|
76
|
-
async def connect(cls, config: SshConnectionConfig, *, connect_timeout: float | None = None) ->
|
|
105
|
+
async def connect(cls, config: SshConnectionConfig, *, connect_timeout: float | None = None) -> AsyncSshConnection:
|
|
77
106
|
if not config.host.strip():
|
|
78
107
|
raise ValueError("SSH host must not be empty")
|
|
79
108
|
if not 1 <= config.port <= 65535:
|
|
@@ -95,7 +124,7 @@ class _AsyncSshTransport:
|
|
|
95
124
|
self,
|
|
96
125
|
command: str,
|
|
97
126
|
*,
|
|
98
|
-
stdin:
|
|
127
|
+
stdin: ByteStream | None,
|
|
99
128
|
timeout: float,
|
|
100
129
|
output_limit: int,
|
|
101
130
|
) -> ExecResult:
|
|
@@ -109,15 +138,8 @@ class _AsyncSshTransport:
|
|
|
109
138
|
self._connection.create_process(command, encoding=None)
|
|
110
139
|
)
|
|
111
140
|
try:
|
|
112
|
-
process = await
|
|
141
|
+
process = await open_task
|
|
113
142
|
except asyncio.CancelledError:
|
|
114
|
-
self._connection.abort()
|
|
115
|
-
close_task = asyncio.create_task(self._connection.wait_closed())
|
|
116
|
-
while not close_task.done():
|
|
117
|
-
try:
|
|
118
|
-
await asyncio.shield(close_task)
|
|
119
|
-
except asyncio.CancelledError:
|
|
120
|
-
continue
|
|
121
143
|
if not open_task.done():
|
|
122
144
|
open_task.cancel()
|
|
123
145
|
await asyncio.gather(open_task, return_exceptions=True)
|
|
@@ -134,17 +156,13 @@ class _AsyncSshTransport:
|
|
|
134
156
|
stdout, stderr = output.outputs()
|
|
135
157
|
return ExecResult(process.exit_status, stdout, stderr)
|
|
136
158
|
except TimeoutError:
|
|
137
|
-
|
|
159
|
+
cleanup = asyncio.create_task(self._abort_process(process, tasks))
|
|
160
|
+
await finish_task_before_cancellation(cleanup)
|
|
138
161
|
stdout, stderr = output.outputs()
|
|
139
162
|
return ExecResult(None, stdout, stderr, timed_out=True)
|
|
140
163
|
except BaseException:
|
|
141
164
|
cleanup = asyncio.create_task(self._abort_process(process, tasks))
|
|
142
|
-
|
|
143
|
-
try:
|
|
144
|
-
await asyncio.shield(cleanup)
|
|
145
|
-
except asyncio.CancelledError:
|
|
146
|
-
continue
|
|
147
|
-
await cleanup
|
|
165
|
+
await finish_task_despite_cancellation(cleanup)
|
|
148
166
|
raise
|
|
149
167
|
|
|
150
168
|
@staticmethod
|
|
@@ -157,10 +175,10 @@ class _AsyncSshTransport:
|
|
|
157
175
|
wait_task = tasks[-1]
|
|
158
176
|
try:
|
|
159
177
|
await asyncio.wait_for(asyncio.shield(wait_task), timeout=1)
|
|
160
|
-
except
|
|
178
|
+
except Exception:
|
|
161
179
|
process.kill()
|
|
162
180
|
process.close()
|
|
163
|
-
with suppress(
|
|
181
|
+
with suppress(Exception):
|
|
164
182
|
await asyncio.wait_for(asyncio.shield(wait_task), timeout=1)
|
|
165
183
|
for task in tasks:
|
|
166
184
|
if not task.done():
|
|
@@ -168,15 +186,9 @@ class _AsyncSshTransport:
|
|
|
168
186
|
await asyncio.gather(*tasks, return_exceptions=True)
|
|
169
187
|
|
|
170
188
|
@staticmethod
|
|
171
|
-
async def _write_stdin(process: asyncssh.SSHClientProcess[bytes], stdin:
|
|
189
|
+
async def _write_stdin(process: asyncssh.SSHClientProcess[bytes], stdin: ByteStream | None) -> None:
|
|
172
190
|
if stdin is not None:
|
|
173
|
-
|
|
174
|
-
while True:
|
|
175
|
-
present, chunk = await asyncio.to_thread(_next_chunk, iterator)
|
|
176
|
-
if not present:
|
|
177
|
-
break
|
|
178
|
-
if not isinstance(chunk, bytes):
|
|
179
|
-
raise TypeError("stdin chunks must be bytes")
|
|
191
|
+
async for chunk in iterate_byte_stream(stdin, label="stdin"):
|
|
180
192
|
process.stdin.write(chunk)
|
|
181
193
|
await process.stdin.drain()
|
|
182
194
|
process.stdin.write_eof()
|
|
@@ -189,81 +201,100 @@ class _AsyncSshTransport:
|
|
|
189
201
|
break
|
|
190
202
|
output.append(bytes(chunk), stderr=stderr)
|
|
191
203
|
|
|
192
|
-
async def upload_file(
|
|
193
|
-
self,
|
|
194
|
-
local_path: Path | str,
|
|
195
|
-
remote_path: str,
|
|
196
|
-
*,
|
|
197
|
-
chunk_size: int = 256 * 1024,
|
|
198
|
-
mode: int = 0o644,
|
|
199
|
-
) -> TransferResult:
|
|
200
|
-
source = Path(local_path)
|
|
201
|
-
self._validate_chunk_size(chunk_size)
|
|
202
|
-
self._validate_remote_path(remote_path)
|
|
203
|
-
temporary = f"{remote_path}.hostbridge-{uuid.uuid4().hex}.tmp"
|
|
204
|
-
digest = hashlib.sha256()
|
|
205
|
-
transferred = 0
|
|
206
|
-
sftp = await self._connection.start_sftp_client()
|
|
207
|
-
try:
|
|
208
|
-
local_file = source.open("rb")
|
|
209
|
-
try:
|
|
210
|
-
async with sftp.open(temporary, "wb") as remote_file:
|
|
211
|
-
while True:
|
|
212
|
-
chunk = await asyncio.to_thread(local_file.read, chunk_size)
|
|
213
|
-
if not chunk:
|
|
214
|
-
break
|
|
215
|
-
await remote_file.write(chunk)
|
|
216
|
-
digest.update(chunk)
|
|
217
|
-
transferred += len(chunk)
|
|
218
|
-
finally:
|
|
219
|
-
local_file.close()
|
|
220
|
-
await sftp.chmod(temporary, mode)
|
|
221
|
-
await sftp.rename(temporary, remote_path)
|
|
222
|
-
except Exception:
|
|
223
|
-
with suppress(Exception):
|
|
224
|
-
await sftp.remove(temporary)
|
|
225
|
-
raise
|
|
226
|
-
finally:
|
|
227
|
-
sftp.exit()
|
|
228
|
-
return TransferResult(transferred, digest.hexdigest())
|
|
229
|
-
|
|
230
204
|
async def upload(
|
|
231
205
|
self,
|
|
232
|
-
chunks:
|
|
206
|
+
chunks: ByteStream,
|
|
233
207
|
remote_path: str,
|
|
234
208
|
*,
|
|
235
209
|
mode: int,
|
|
236
210
|
expected_size: int | None,
|
|
211
|
+
expected_sha256: str | None,
|
|
237
212
|
) -> TransferResult:
|
|
238
213
|
self._validate_remote_path(remote_path)
|
|
239
214
|
temporary = f"{remote_path}.hostbridge-{uuid.uuid4().hex}.tmp"
|
|
240
215
|
digest = hashlib.sha256()
|
|
241
216
|
transferred = 0
|
|
242
|
-
iterator = iter(chunks)
|
|
243
217
|
sftp = await self._connection.start_sftp_client()
|
|
244
218
|
try:
|
|
245
219
|
async with sftp.open(temporary, "wb", encoding=None) as remote_file:
|
|
246
|
-
|
|
247
|
-
present, chunk = await asyncio.to_thread(_next_chunk, iterator)
|
|
248
|
-
if not present:
|
|
249
|
-
break
|
|
250
|
-
if not isinstance(chunk, bytes):
|
|
251
|
-
raise TypeError("upload chunks must be bytes")
|
|
220
|
+
async for chunk in iterate_byte_stream(chunks, label="upload"):
|
|
252
221
|
await remote_file.write(chunk)
|
|
253
222
|
digest.update(chunk)
|
|
254
223
|
transferred += len(chunk)
|
|
255
224
|
if expected_size is not None and transferred != expected_size:
|
|
256
225
|
raise TransportError(f"upload size mismatch: expected {expected_size}, received {transferred}")
|
|
226
|
+
actual_sha256 = digest.hexdigest()
|
|
227
|
+
if expected_sha256 is not None and actual_sha256.lower() != expected_sha256.lower():
|
|
228
|
+
raise TransportError(
|
|
229
|
+
f"upload SHA-256 mismatch: expected {expected_sha256.lower()}, received {actual_sha256}"
|
|
230
|
+
)
|
|
257
231
|
await sftp.chmod(temporary, mode)
|
|
258
232
|
await sftp.rename(temporary, remote_path)
|
|
259
|
-
except BaseException:
|
|
260
|
-
|
|
261
|
-
|
|
233
|
+
except BaseException as primary_error:
|
|
234
|
+
cleanup = asyncio.create_task(self._finish_sftp(sftp, temporary=temporary))
|
|
235
|
+
try:
|
|
236
|
+
await finish_task_despite_cancellation(cleanup)
|
|
237
|
+
except BaseException as cleanup_error:
|
|
238
|
+
primary_error.add_note(f"SFTP temporary cleanup failed: {cleanup_error}")
|
|
262
239
|
raise
|
|
263
|
-
|
|
264
|
-
|
|
240
|
+
cleanup = asyncio.create_task(self._finish_sftp(sftp))
|
|
241
|
+
await finish_task_before_cancellation(cleanup)
|
|
265
242
|
return TransferResult(transferred, digest.hexdigest())
|
|
266
243
|
|
|
244
|
+
async def _finish_sftp(self, sftp: asyncssh.SFTPClient, *, temporary: str | None = None) -> None:
|
|
245
|
+
removed = await self._close_sftp_channel(sftp, temporary=temporary)
|
|
246
|
+
if temporary is None or removed:
|
|
247
|
+
return
|
|
248
|
+
|
|
249
|
+
replacement: asyncssh.SFTPClient | None = None
|
|
250
|
+
try:
|
|
251
|
+
async with asyncio.timeout(self._sftp_cleanup_timeout):
|
|
252
|
+
replacement = await self._connection.start_sftp_client()
|
|
253
|
+
except Exception as exc:
|
|
254
|
+
self._abort_connection()
|
|
255
|
+
raise TransportError(f"could not reopen SFTP to remove temporary file {temporary!r}") from exc
|
|
256
|
+
|
|
257
|
+
if await self._close_sftp_channel(replacement, temporary=temporary):
|
|
258
|
+
return
|
|
259
|
+
self._abort_connection()
|
|
260
|
+
raise TransportError(f"could not remove SFTP temporary file {temporary!r}")
|
|
261
|
+
|
|
262
|
+
async def _close_sftp_channel(self, sftp: asyncssh.SFTPClient, *, temporary: str | None) -> bool:
|
|
263
|
+
exit_sent = False
|
|
264
|
+
removed = temporary is None
|
|
265
|
+
try:
|
|
266
|
+
async with asyncio.timeout(self._sftp_cleanup_timeout):
|
|
267
|
+
if temporary is not None:
|
|
268
|
+
try:
|
|
269
|
+
await sftp.remove(temporary)
|
|
270
|
+
except asyncssh.SFTPNoSuchFile:
|
|
271
|
+
removed = True
|
|
272
|
+
else:
|
|
273
|
+
removed = True
|
|
274
|
+
sftp.exit()
|
|
275
|
+
exit_sent = True
|
|
276
|
+
with suppress(Exception):
|
|
277
|
+
await sftp.wait_closed()
|
|
278
|
+
except Exception:
|
|
279
|
+
pass
|
|
280
|
+
finally:
|
|
281
|
+
if not exit_sent:
|
|
282
|
+
with suppress(Exception):
|
|
283
|
+
sftp.exit()
|
|
284
|
+
return removed
|
|
285
|
+
|
|
286
|
+
def _abort_connection(self) -> None:
|
|
287
|
+
try:
|
|
288
|
+
self._connection.abort()
|
|
289
|
+
except Exception as exc:
|
|
290
|
+
try:
|
|
291
|
+
self._connection.close()
|
|
292
|
+
except Exception as fallback_error:
|
|
293
|
+
fallback_error.add_note(f"SSH connection abort failed: {exc}")
|
|
294
|
+
raise TransportError(
|
|
295
|
+
"could not terminate SSH connection after SFTP cleanup failure"
|
|
296
|
+
) from fallback_error
|
|
297
|
+
|
|
267
298
|
async def download(self, remote_path: str, *, chunk_size: int) -> AsyncGenerator[bytes, None]:
|
|
268
299
|
self._validate_chunk_size(chunk_size)
|
|
269
300
|
self._validate_remote_path(remote_path)
|
|
@@ -276,45 +307,8 @@ class _AsyncSshTransport:
|
|
|
276
307
|
break
|
|
277
308
|
yield raw_chunk.encode("utf-8") if isinstance(raw_chunk, str) else bytes(raw_chunk)
|
|
278
309
|
finally:
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
async def download_file(
|
|
282
|
-
self,
|
|
283
|
-
remote_path: str,
|
|
284
|
-
local_path: Path | str,
|
|
285
|
-
*,
|
|
286
|
-
chunk_size: int = 256 * 1024,
|
|
287
|
-
) -> TransferResult:
|
|
288
|
-
destination = Path(local_path)
|
|
289
|
-
self._validate_chunk_size(chunk_size)
|
|
290
|
-
self._validate_remote_path(remote_path)
|
|
291
|
-
temporary = destination.with_name(f".{destination.name}.hostbridge-{uuid.uuid4().hex}.tmp")
|
|
292
|
-
digest = hashlib.sha256()
|
|
293
|
-
transferred = 0
|
|
294
|
-
sftp = await self._connection.start_sftp_client()
|
|
295
|
-
try:
|
|
296
|
-
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
297
|
-
local_file = temporary.open("wb")
|
|
298
|
-
try:
|
|
299
|
-
async with sftp.open(remote_path, "rb") as remote_file:
|
|
300
|
-
while True:
|
|
301
|
-
raw_chunk = await remote_file.read(chunk_size)
|
|
302
|
-
if not raw_chunk:
|
|
303
|
-
break
|
|
304
|
-
chunk = raw_chunk.encode("utf-8") if isinstance(raw_chunk, str) else bytes(raw_chunk)
|
|
305
|
-
await asyncio.to_thread(local_file.write, chunk)
|
|
306
|
-
digest.update(chunk)
|
|
307
|
-
transferred += len(chunk)
|
|
308
|
-
finally:
|
|
309
|
-
local_file.close()
|
|
310
|
-
temporary.chmod(0o600)
|
|
311
|
-
temporary.replace(destination)
|
|
312
|
-
except Exception:
|
|
313
|
-
temporary.unlink(missing_ok=True)
|
|
314
|
-
raise
|
|
315
|
-
finally:
|
|
316
|
-
sftp.exit()
|
|
317
|
-
return TransferResult(transferred, digest.hexdigest())
|
|
310
|
+
cleanup = asyncio.create_task(self._finish_sftp(sftp))
|
|
311
|
+
await finish_task_before_cancellation(cleanup)
|
|
318
312
|
|
|
319
313
|
@staticmethod
|
|
320
314
|
def _validate_chunk_size(chunk_size: int) -> None:
|
|
@@ -326,154 +320,107 @@ class _AsyncSshTransport:
|
|
|
326
320
|
if not isinstance(remote_path, str) or not remote_path.strip() or "\x00" in remote_path:
|
|
327
321
|
raise ValueError("remote_path must be a non-empty path without NUL bytes")
|
|
328
322
|
|
|
329
|
-
async def shell_write(self, data: bytes) -> None:
|
|
323
|
+
async def shell_write(self, shell_id: str, data: bytes) -> None:
|
|
330
324
|
if not isinstance(data, bytes):
|
|
331
325
|
raise TypeError("shell data must be bytes")
|
|
332
|
-
process = await self._ensure_shell()
|
|
326
|
+
process = await self._ensure_shell(shell_id)
|
|
333
327
|
process.stdin.write(data)
|
|
334
328
|
await process.stdin.drain()
|
|
335
329
|
|
|
336
|
-
async def shell_read(self, *, timeout: float, max_bytes: int) -> ShellReadResult:
|
|
337
|
-
if timeout < 0:
|
|
330
|
+
async def shell_read(self, shell_id: str, *, timeout: float | None, max_bytes: int) -> ShellReadResult:
|
|
331
|
+
if timeout is not None and timeout < 0:
|
|
338
332
|
raise ValueError("timeout must be non-negative")
|
|
339
333
|
if max_bytes <= 0:
|
|
340
334
|
raise ValueError("max_bytes must be positive")
|
|
341
|
-
process = await self._ensure_shell()
|
|
342
|
-
|
|
343
|
-
data = await
|
|
344
|
-
|
|
345
|
-
data = b""
|
|
346
|
-
return ShellReadResult(bytes(data), process.exit_status is None and not self._connection.is_closed())
|
|
347
|
-
|
|
348
|
-
async def _ensure_shell(self) -> asyncssh.SSHClientProcess[bytes]:
|
|
349
|
-
shell = getattr(self, "_shell", None)
|
|
350
|
-
if shell is None or shell.exit_status is not None:
|
|
351
|
-
shell = await self._connection.create_process(term_type="xterm", encoding=None)
|
|
352
|
-
self._shell = shell
|
|
353
|
-
return shell
|
|
354
|
-
|
|
355
|
-
async def close(self) -> None:
|
|
356
|
-
shell = getattr(self, "_shell", None)
|
|
357
|
-
if shell is not None:
|
|
358
|
-
shell.terminate()
|
|
359
|
-
shell.close()
|
|
360
|
-
with suppress(Exception):
|
|
361
|
-
await asyncio.wait_for(shell.wait_closed(), timeout=1)
|
|
362
|
-
self._connection.close()
|
|
363
|
-
await self._connection.wait_closed()
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
def _next_chunk(iterator: Iterator[bytes]) -> tuple[bool, bytes]:
|
|
367
|
-
try:
|
|
368
|
-
return True, next(iterator)
|
|
369
|
-
except StopIteration:
|
|
370
|
-
return False, b""
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
class _EventLoopThread:
|
|
374
|
-
def __init__(self) -> None:
|
|
375
|
-
self.loop = asyncio.new_event_loop()
|
|
376
|
-
self.thread = threading.Thread(target=self._run, name="hostbridge-ssh", daemon=True)
|
|
377
|
-
self.started = threading.Event()
|
|
378
|
-
self.thread.start()
|
|
379
|
-
self.started.wait()
|
|
380
|
-
|
|
381
|
-
def _run(self) -> None:
|
|
382
|
-
asyncio.set_event_loop(self.loop)
|
|
383
|
-
self.started.set()
|
|
384
|
-
self.loop.run_forever()
|
|
385
|
-
self.loop.run_until_complete(self.loop.shutdown_asyncgens())
|
|
386
|
-
self.loop.close()
|
|
387
|
-
|
|
388
|
-
def run(self, awaitable: Awaitable[T]) -> T:
|
|
389
|
-
if not self.thread.is_alive():
|
|
390
|
-
raise TransportError("SSH transport is closed")
|
|
391
|
-
return asyncio.run_coroutine_threadsafe(_await_value(awaitable), self.loop).result()
|
|
392
|
-
|
|
393
|
-
def stop(self) -> None:
|
|
394
|
-
if self.thread.is_alive():
|
|
395
|
-
self.loop.call_soon_threadsafe(self.loop.stop)
|
|
396
|
-
self.thread.join(timeout=2)
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
class SshTransport:
|
|
400
|
-
capabilities = _AsyncSshTransport.capabilities
|
|
401
|
-
|
|
402
|
-
def __init__(self, core: _AsyncSshTransport, runner: _EventLoopThread) -> None:
|
|
403
|
-
self._core = core
|
|
404
|
-
self._runner = runner
|
|
405
|
-
self._close_lock = threading.Lock()
|
|
406
|
-
self._closed = False
|
|
407
|
-
|
|
408
|
-
@classmethod
|
|
409
|
-
def connect(cls, config: SshConnectionConfig, *, connect_timeout: float | None = None) -> SshTransport:
|
|
410
|
-
runner = _EventLoopThread()
|
|
411
|
-
try:
|
|
412
|
-
core = runner.run(_AsyncSshTransport.connect(config, connect_timeout=connect_timeout))
|
|
413
|
-
except BaseException:
|
|
414
|
-
runner.stop()
|
|
415
|
-
raise
|
|
416
|
-
return cls(core, runner)
|
|
417
|
-
|
|
418
|
-
def exec(
|
|
419
|
-
self,
|
|
420
|
-
command: str,
|
|
421
|
-
*,
|
|
422
|
-
stdin: Iterable[bytes] | None,
|
|
423
|
-
timeout: float,
|
|
424
|
-
output_limit: int,
|
|
425
|
-
) -> ExecResult:
|
|
426
|
-
return self._run(self._core.exec(command, stdin=stdin, timeout=timeout, output_limit=output_limit))
|
|
427
|
-
|
|
428
|
-
def upload(
|
|
429
|
-
self,
|
|
430
|
-
chunks: Iterable[bytes],
|
|
431
|
-
remote_path: str,
|
|
432
|
-
*,
|
|
433
|
-
mode: int,
|
|
434
|
-
expected_size: int | None,
|
|
435
|
-
) -> TransferResult:
|
|
436
|
-
return self._run(self._core.upload(chunks, remote_path, mode=mode, expected_size=expected_size))
|
|
437
|
-
|
|
438
|
-
def download(self, remote_path: str, *, chunk_size: int) -> Iterator[bytes]:
|
|
439
|
-
stream = self._core.download(remote_path, chunk_size=chunk_size)
|
|
440
|
-
|
|
441
|
-
def chunks() -> Iterator[bytes]:
|
|
335
|
+
process = await self._ensure_shell(shell_id)
|
|
336
|
+
if timeout is None:
|
|
337
|
+
data = await process.stdout.read(max_bytes)
|
|
338
|
+
else:
|
|
442
339
|
try:
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
return
|
|
448
|
-
finally:
|
|
449
|
-
self._run(stream.aclose())
|
|
450
|
-
|
|
451
|
-
return chunks()
|
|
340
|
+
data = await asyncio.wait_for(process.stdout.read(max_bytes), timeout=timeout)
|
|
341
|
+
except TimeoutError:
|
|
342
|
+
data = b""
|
|
343
|
+
return ShellReadResult(bytes(data), process.exit_status is None and not self._connection.is_closed())
|
|
452
344
|
|
|
453
|
-
def
|
|
454
|
-
self.
|
|
345
|
+
async def attach_shell(self, shell_id: str, stdin: ByteStream, *, max_bytes: int) -> ShellAttachment:
|
|
346
|
+
process = await self._ensure_shell(shell_id)
|
|
347
|
+
terminal = asyncio.get_running_loop().create_future()
|
|
455
348
|
|
|
456
|
-
|
|
457
|
-
|
|
349
|
+
async def send_input() -> None:
|
|
350
|
+
async for chunk in iterate_byte_stream(stdin, label="shell input"):
|
|
351
|
+
if len(chunk) > max_bytes:
|
|
352
|
+
raise TransportError(f"shell input chunk exceeds {max_bytes} bytes")
|
|
353
|
+
process.stdin.write(chunk)
|
|
354
|
+
await process.stdin.drain()
|
|
355
|
+
process.stdin.write_eof()
|
|
458
356
|
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
if self._closed:
|
|
462
|
-
return
|
|
463
|
-
self._closed = True
|
|
357
|
+
async def output() -> AsyncGenerator[bytes, None]:
|
|
358
|
+
input_task = asyncio.create_task(send_input(), name=f"hostbridge-native-shell-input-{shell_id}")
|
|
464
359
|
try:
|
|
465
|
-
|
|
360
|
+
while data := await process.stdout.read(max_bytes):
|
|
361
|
+
yield bytes(data)
|
|
362
|
+
await stop_input_after_peer_eof(input_task)
|
|
363
|
+
await process.wait_closed()
|
|
364
|
+
terminal.set_result(_ssh_shell_terminal(process))
|
|
365
|
+
except BaseException as exc:
|
|
366
|
+
if not terminal.done():
|
|
367
|
+
terminal.set_exception(exc)
|
|
368
|
+
terminal.exception()
|
|
369
|
+
raise
|
|
466
370
|
finally:
|
|
467
|
-
|
|
371
|
+
if not input_task.done():
|
|
372
|
+
input_task.cancel()
|
|
373
|
+
cleanup = asyncio.gather(input_task, return_exceptions=True)
|
|
374
|
+
await finish_task_before_cancellation(cleanup)
|
|
375
|
+
|
|
376
|
+
return ShellAttachment(output(), terminal)
|
|
377
|
+
|
|
378
|
+
async def _ensure_shell(self, shell_id: str) -> asyncssh.SSHClientProcess[bytes]:
|
|
379
|
+
if not shell_id:
|
|
380
|
+
raise ValueError("shell_id must not be empty")
|
|
381
|
+
async with self._shell_creation_lock:
|
|
382
|
+
shell = self._shells.get(shell_id)
|
|
383
|
+
if shell is None or shell.exit_status is not None:
|
|
384
|
+
shell = await self._connection.create_process(term_type="xterm", encoding=None)
|
|
385
|
+
self._shells[shell_id] = shell
|
|
386
|
+
return shell
|
|
387
|
+
|
|
388
|
+
async def shell_close(self, shell_id: str) -> None:
|
|
389
|
+
async with self._shell_creation_lock:
|
|
390
|
+
shell = self._shells.pop(shell_id, None)
|
|
391
|
+
if shell is None:
|
|
392
|
+
return
|
|
393
|
+
cleanup = asyncio.create_task(self._close_shell_process(shell))
|
|
394
|
+
await finish_task_before_cancellation(cleanup)
|
|
468
395
|
|
|
469
|
-
|
|
396
|
+
@staticmethod
|
|
397
|
+
async def _close_shell_process(shell: asyncssh.SSHClientProcess[bytes]) -> None:
|
|
398
|
+
shell.terminate()
|
|
399
|
+
shell.close()
|
|
470
400
|
try:
|
|
471
|
-
|
|
472
|
-
except
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
401
|
+
await asyncio.wait_for(shell.wait_closed(), timeout=1)
|
|
402
|
+
except TimeoutError:
|
|
403
|
+
shell.kill()
|
|
404
|
+
shell.close()
|
|
405
|
+
with suppress(Exception):
|
|
406
|
+
await asyncio.wait_for(shell.wait_closed(), timeout=1)
|
|
407
|
+
except Exception:
|
|
408
|
+
pass
|
|
409
|
+
|
|
410
|
+
async def close(self) -> None:
|
|
411
|
+
await asyncio.gather(*(self.shell_close(shell_id) for shell_id in tuple(self._shells)), return_exceptions=True)
|
|
412
|
+
self._connection.close()
|
|
413
|
+
await self._connection.wait_closed()
|
|
476
414
|
|
|
477
415
|
|
|
478
|
-
|
|
479
|
-
|
|
416
|
+
def _ssh_shell_terminal(process: asyncssh.SSHClientProcess[bytes]) -> ShellTerminalResult:
|
|
417
|
+
exit_status = process.exit_status
|
|
418
|
+
raw_signal = process.exit_signal
|
|
419
|
+
signal_name: str | None = None
|
|
420
|
+
if isinstance(raw_signal, tuple) and raw_signal and isinstance(raw_signal[0], str):
|
|
421
|
+
signal_name = raw_signal[0]
|
|
422
|
+
elif isinstance(raw_signal, str):
|
|
423
|
+
signal_name = raw_signal
|
|
424
|
+
if signal_name:
|
|
425
|
+
return ShellTerminalResult(None, signal_name)
|
|
426
|
+
return ShellTerminalResult(exit_status, None)
|