@neoline/hostbridge 2.0.3 → 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 +24 -15
- package/bin/hostbridge.js +80 -9
- package/docs/ARCHITECTURE.md +62 -0
- package/examples/claude_desktop_config.json +1 -1
- package/examples/codex.config.toml +1 -1
- package/examples/hosts.example.json +1 -0
- package/package.json +2 -1
- package/pyproject.toml +5 -4
- package/src/server_control_mcp/__init__.py +60 -24
- package/src/server_control_mcp/async_lifecycle.py +41 -0
- package/src/server_control_mcp/cli.py +2 -2
- package/src/server_control_mcp/client.py +345 -238
- package/src/server_control_mcp/config.py +18 -6
- package/src/server_control_mcp/daemon.py +309 -412
- package/src/server_control_mcp/doctor.py +41 -5
- package/src/server_control_mcp/hosts.py +252 -24
- package/src/server_control_mcp/mock_ssh.py +97 -960
- package/src/server_control_mcp/mock_ssh_exec.py +299 -0
- package/src/server_control_mcp/mock_ssh_session.py +69 -0
- package/src/server_control_mcp/mock_ssh_sftp.py +604 -0
- package/src/server_control_mcp/mock_ssh_tunnel.py +289 -0
- 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/policy.py +86 -14
- 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/remote_task_agent.py +102 -0
- package/src/server_control_mcp/runtime.py +3 -2
- package/src/server_control_mcp/runtime_services.py +862 -0
- package/src/server_control_mcp/secrets.py +6 -6
- package/src/server_control_mcp/secure_files.py +121 -0
- package/src/server_control_mcp/server.py +53 -20
- package/src/server_control_mcp/services.py +3 -469
- 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 +315 -84
- 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 -362
- package/src/server_control_mcp/transports/pty.py +0 -434
|
@@ -0,0 +1,454 @@
|
|
|
1
|
+
"""Daemon-loop-owned native AsyncSSH TCP forwarding provider."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
from collections.abc import AsyncGenerator, Callable
|
|
7
|
+
from contextlib import suppress
|
|
8
|
+
|
|
9
|
+
import asyncssh
|
|
10
|
+
|
|
11
|
+
from .async_lifecycle import finish_task_before_cancellation
|
|
12
|
+
from .hosts import ForwardingPolicy, HostConfig
|
|
13
|
+
from .transports.base import ByteStream, ExecResult, ShellAttachment, ShellReadResult, TransferResult, TransportError
|
|
14
|
+
from .transports.ssh import AsyncSshConnection
|
|
15
|
+
from .tunnel_manager import TunnelBackendError
|
|
16
|
+
|
|
17
|
+
DEFAULT_RECEIVE_WINDOW = 2 * 1024 * 1024
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class _ConnectionObserver(asyncssh.SSHClient):
|
|
21
|
+
def __init__(self, lost: Callable[[BaseException | None], None]) -> None:
|
|
22
|
+
self._lost = lost
|
|
23
|
+
|
|
24
|
+
def connection_lost(self, exc: Exception | None) -> None:
|
|
25
|
+
self._lost(exc)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class NativeSshTunnelEndpoint(asyncssh.SSHTCPSession[bytes]):
|
|
29
|
+
def __init__(
|
|
30
|
+
self,
|
|
31
|
+
provider_stream_id: int,
|
|
32
|
+
*,
|
|
33
|
+
receive_window: int,
|
|
34
|
+
closed_handler: Callable[[int], None],
|
|
35
|
+
write_stall_handler: Callable[[], None],
|
|
36
|
+
) -> None:
|
|
37
|
+
if receive_window <= 0:
|
|
38
|
+
raise ValueError("receive_window must be positive")
|
|
39
|
+
self.provider_stream_id = provider_stream_id
|
|
40
|
+
self._receive_window = receive_window
|
|
41
|
+
self._high_water = max(1, receive_window * 3 // 4)
|
|
42
|
+
self._low_water = receive_window // 2
|
|
43
|
+
self._closed_handler = closed_handler
|
|
44
|
+
self._write_stall_handler = write_stall_handler
|
|
45
|
+
self._channel: asyncssh.SSHTCPChannel[bytes] | None = None
|
|
46
|
+
self._buffer = bytearray()
|
|
47
|
+
self._read_ready = asyncio.Event()
|
|
48
|
+
self._write_ready = asyncio.Event()
|
|
49
|
+
self._write_ready.set()
|
|
50
|
+
self._remote_eof = False
|
|
51
|
+
self._local_eof = False
|
|
52
|
+
self._reading_paused = False
|
|
53
|
+
self._error: TunnelBackendError | None = None
|
|
54
|
+
self._closed = False
|
|
55
|
+
|
|
56
|
+
@property
|
|
57
|
+
def closed(self) -> bool:
|
|
58
|
+
return self._closed
|
|
59
|
+
|
|
60
|
+
@property
|
|
61
|
+
def queued_bytes(self) -> int:
|
|
62
|
+
return len(self._buffer)
|
|
63
|
+
|
|
64
|
+
def connection_made(self, chan: asyncssh.SSHTCPChannel[bytes]) -> None:
|
|
65
|
+
self._channel = chan
|
|
66
|
+
|
|
67
|
+
def session_started(self) -> None:
|
|
68
|
+
return None
|
|
69
|
+
|
|
70
|
+
def data_received(self, data: bytes, datatype: asyncssh.DataType) -> None: # noqa: ARG002
|
|
71
|
+
if self._closed or not data:
|
|
72
|
+
return
|
|
73
|
+
payload = bytes(data)
|
|
74
|
+
if len(self._buffer) + len(payload) > self._receive_window:
|
|
75
|
+
self._buffer.clear()
|
|
76
|
+
error = TunnelBackendError("native SSH stream exceeded its receive window")
|
|
77
|
+
self._error = error
|
|
78
|
+
if self._channel is not None:
|
|
79
|
+
self._channel.abort()
|
|
80
|
+
self._finish()
|
|
81
|
+
return
|
|
82
|
+
self._buffer.extend(payload)
|
|
83
|
+
if len(self._buffer) >= self._high_water and not self._reading_paused and self._channel is not None:
|
|
84
|
+
self._channel.pause_reading()
|
|
85
|
+
self._reading_paused = True
|
|
86
|
+
self._read_ready.set()
|
|
87
|
+
|
|
88
|
+
def eof_received(self) -> bool:
|
|
89
|
+
self._remote_eof = True
|
|
90
|
+
self._read_ready.set()
|
|
91
|
+
return True
|
|
92
|
+
|
|
93
|
+
def connection_lost(self, exc: Exception | None) -> None:
|
|
94
|
+
if exc is not None and self._error is None:
|
|
95
|
+
self._error = TunnelBackendError(str(exc) or "native SSH stream connection lost")
|
|
96
|
+
self._remote_eof = True
|
|
97
|
+
self._finish()
|
|
98
|
+
|
|
99
|
+
def pause_writing(self) -> None:
|
|
100
|
+
if self._write_ready.is_set():
|
|
101
|
+
self._write_stall_handler()
|
|
102
|
+
self._write_ready.clear()
|
|
103
|
+
|
|
104
|
+
def resume_writing(self) -> None:
|
|
105
|
+
self._write_ready.set()
|
|
106
|
+
|
|
107
|
+
async def send(self, data: bytes) -> None:
|
|
108
|
+
if not isinstance(data, bytes):
|
|
109
|
+
raise TypeError("tunnel stream data must be bytes")
|
|
110
|
+
if not data:
|
|
111
|
+
return
|
|
112
|
+
if self._local_eof:
|
|
113
|
+
raise TunnelBackendError("native SSH stream input is closed")
|
|
114
|
+
self._raise_if_failed()
|
|
115
|
+
await self._write_ready.wait()
|
|
116
|
+
self._raise_if_failed()
|
|
117
|
+
channel = self._require_channel()
|
|
118
|
+
channel.write(data)
|
|
119
|
+
await asyncio.sleep(0)
|
|
120
|
+
|
|
121
|
+
async def send_eof(self) -> None:
|
|
122
|
+
if self._local_eof or self._closed:
|
|
123
|
+
return
|
|
124
|
+
self._raise_if_failed()
|
|
125
|
+
self._local_eof = True
|
|
126
|
+
self._require_channel().write_eof()
|
|
127
|
+
|
|
128
|
+
async def receive(self, max_bytes: int = 64 * 1024) -> bytes | None:
|
|
129
|
+
if not isinstance(max_bytes, int) or isinstance(max_bytes, bool) or max_bytes <= 0:
|
|
130
|
+
raise ValueError("max_bytes must be a positive integer")
|
|
131
|
+
while not (self._buffer or self._remote_eof or self._error is not None or self._closed):
|
|
132
|
+
self._read_ready.clear()
|
|
133
|
+
await self._read_ready.wait()
|
|
134
|
+
if self._error is not None:
|
|
135
|
+
raise self._error
|
|
136
|
+
if self._buffer:
|
|
137
|
+
size = min(max_bytes, len(self._buffer))
|
|
138
|
+
data = bytes(self._buffer[:size])
|
|
139
|
+
del self._buffer[:size]
|
|
140
|
+
self._resume_reading_if_ready()
|
|
141
|
+
return data
|
|
142
|
+
return None
|
|
143
|
+
|
|
144
|
+
async def close(self) -> None:
|
|
145
|
+
if self._closed:
|
|
146
|
+
return
|
|
147
|
+
if self._channel is not None:
|
|
148
|
+
self._channel.close()
|
|
149
|
+
self._remote_eof = True
|
|
150
|
+
self._finish()
|
|
151
|
+
|
|
152
|
+
async def reset(self, reason: str) -> None:
|
|
153
|
+
if self._closed:
|
|
154
|
+
return
|
|
155
|
+
self._error = TunnelBackendError(reason or "native SSH stream reset")
|
|
156
|
+
if self._channel is not None:
|
|
157
|
+
self._channel.abort()
|
|
158
|
+
self._remote_eof = True
|
|
159
|
+
self._finish()
|
|
160
|
+
|
|
161
|
+
def backend_lost(self, error: BaseException | None) -> None:
|
|
162
|
+
if self._closed:
|
|
163
|
+
return
|
|
164
|
+
if error is not None:
|
|
165
|
+
self._error = TunnelBackendError(str(error) or "native SSH connection lost")
|
|
166
|
+
if self._channel is not None:
|
|
167
|
+
self._channel.abort()
|
|
168
|
+
self._remote_eof = True
|
|
169
|
+
self._finish()
|
|
170
|
+
|
|
171
|
+
def _require_channel(self) -> asyncssh.SSHTCPChannel[bytes]:
|
|
172
|
+
if self._channel is None:
|
|
173
|
+
raise TunnelBackendError("native SSH stream is not open")
|
|
174
|
+
return self._channel
|
|
175
|
+
|
|
176
|
+
def _raise_if_failed(self) -> None:
|
|
177
|
+
if self._error is not None:
|
|
178
|
+
raise self._error
|
|
179
|
+
if self._closed:
|
|
180
|
+
raise TunnelBackendError("native SSH stream is closed")
|
|
181
|
+
|
|
182
|
+
def _resume_reading_if_ready(self) -> None:
|
|
183
|
+
if self._reading_paused and len(self._buffer) <= self._low_water and self._channel is not None:
|
|
184
|
+
self._channel.resume_reading()
|
|
185
|
+
self._reading_paused = False
|
|
186
|
+
|
|
187
|
+
def _finish(self) -> None:
|
|
188
|
+
if self._closed:
|
|
189
|
+
return
|
|
190
|
+
self._closed = True
|
|
191
|
+
self._write_ready.set()
|
|
192
|
+
self._read_ready.set()
|
|
193
|
+
self._closed_handler(self.provider_stream_id)
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
class NativeSshHostRuntime:
|
|
197
|
+
provider_name = "native_ssh"
|
|
198
|
+
capabilities = AsyncSshConnection.capabilities
|
|
199
|
+
|
|
200
|
+
@property
|
|
201
|
+
def agent_pid(self) -> int | None:
|
|
202
|
+
return None
|
|
203
|
+
|
|
204
|
+
def __init__(
|
|
205
|
+
self,
|
|
206
|
+
host: HostConfig,
|
|
207
|
+
*,
|
|
208
|
+
generation: int,
|
|
209
|
+
connect_timeout: float = 30,
|
|
210
|
+
receive_window: int = DEFAULT_RECEIVE_WINDOW,
|
|
211
|
+
) -> None:
|
|
212
|
+
if generation <= 0:
|
|
213
|
+
raise ValueError("generation must be positive")
|
|
214
|
+
if connect_timeout <= 0:
|
|
215
|
+
raise ValueError("connect_timeout must be positive")
|
|
216
|
+
if receive_window <= 0:
|
|
217
|
+
raise ValueError("receive_window must be positive")
|
|
218
|
+
self.host = host
|
|
219
|
+
self.generation = generation
|
|
220
|
+
self.connect_timeout = float(connect_timeout)
|
|
221
|
+
self.receive_window = receive_window
|
|
222
|
+
self._connection: asyncssh.SSHClientConnection | None = None
|
|
223
|
+
self._operations: AsyncSshConnection | None = None
|
|
224
|
+
self._closed_future: asyncio.Future[BaseException | None] | None = None
|
|
225
|
+
self._endpoints: dict[int, NativeSshTunnelEndpoint] = {}
|
|
226
|
+
self._next_stream_id = 1
|
|
227
|
+
self._closing = False
|
|
228
|
+
self._close_lock = asyncio.Lock()
|
|
229
|
+
self._window_stalls = 0
|
|
230
|
+
|
|
231
|
+
@property
|
|
232
|
+
def queued_bytes(self) -> int:
|
|
233
|
+
return sum(endpoint.queued_bytes for endpoint in self._endpoints.values())
|
|
234
|
+
|
|
235
|
+
@property
|
|
236
|
+
def window_stalls(self) -> int:
|
|
237
|
+
return self._window_stalls
|
|
238
|
+
|
|
239
|
+
@property
|
|
240
|
+
def protocol_failures(self) -> int:
|
|
241
|
+
return 0
|
|
242
|
+
|
|
243
|
+
def _record_window_stall(self) -> None:
|
|
244
|
+
self._window_stalls += 1
|
|
245
|
+
|
|
246
|
+
async def start(self) -> None:
|
|
247
|
+
if self._connection is not None:
|
|
248
|
+
return
|
|
249
|
+
ssh = self.host.ssh
|
|
250
|
+
if ssh is None:
|
|
251
|
+
raise TunnelBackendError("native SSH tunnel requires an SSH host configuration")
|
|
252
|
+
self._closed_future = asyncio.get_running_loop().create_future()
|
|
253
|
+
observer = _ConnectionObserver(self._connection_lost)
|
|
254
|
+
options: dict[str, object] = {
|
|
255
|
+
"port": ssh.port,
|
|
256
|
+
"username": ssh.username,
|
|
257
|
+
"tunnel": ssh.proxy_jump,
|
|
258
|
+
"password": self.host.secrets.get("password"),
|
|
259
|
+
"connect_timeout": self.connect_timeout,
|
|
260
|
+
"encoding": None,
|
|
261
|
+
"client_factory": lambda: observer,
|
|
262
|
+
}
|
|
263
|
+
if ssh.identity_file is not None:
|
|
264
|
+
options["client_keys"] = [str(ssh.identity_file)]
|
|
265
|
+
try:
|
|
266
|
+
self._connection = await asyncssh.connect(ssh.host, **options)
|
|
267
|
+
self._operations = AsyncSshConnection(self._connection)
|
|
268
|
+
except asyncio.CancelledError:
|
|
269
|
+
raise
|
|
270
|
+
except Exception as exc:
|
|
271
|
+
raise TunnelBackendError(f"native SSH connection failed: {exc}") from exc
|
|
272
|
+
|
|
273
|
+
async def exec(
|
|
274
|
+
self,
|
|
275
|
+
command: str,
|
|
276
|
+
*,
|
|
277
|
+
stdin: ByteStream | None,
|
|
278
|
+
timeout: float,
|
|
279
|
+
output_limit: int,
|
|
280
|
+
) -> ExecResult:
|
|
281
|
+
return await self._require_operations().exec(
|
|
282
|
+
command,
|
|
283
|
+
stdin=stdin,
|
|
284
|
+
timeout=timeout,
|
|
285
|
+
output_limit=output_limit,
|
|
286
|
+
)
|
|
287
|
+
|
|
288
|
+
async def upload(
|
|
289
|
+
self,
|
|
290
|
+
chunks: ByteStream,
|
|
291
|
+
remote_path: str,
|
|
292
|
+
*,
|
|
293
|
+
mode: int,
|
|
294
|
+
expected_size: int | None,
|
|
295
|
+
expected_sha256: str | None,
|
|
296
|
+
) -> TransferResult:
|
|
297
|
+
try:
|
|
298
|
+
return await self._require_operations().upload(
|
|
299
|
+
chunks,
|
|
300
|
+
remote_path,
|
|
301
|
+
mode=mode,
|
|
302
|
+
expected_size=expected_size,
|
|
303
|
+
expected_sha256=expected_sha256,
|
|
304
|
+
)
|
|
305
|
+
except TransportError as exc:
|
|
306
|
+
raise TunnelBackendError(str(exc)) from exc
|
|
307
|
+
|
|
308
|
+
async def download(self, remote_path: str, *, chunk_size: int) -> AsyncGenerator[bytes, None]:
|
|
309
|
+
async for chunk in self._require_operations().download(remote_path, chunk_size=chunk_size):
|
|
310
|
+
yield chunk
|
|
311
|
+
|
|
312
|
+
async def shell_write(self, shell_id: str, data: bytes) -> None:
|
|
313
|
+
await self._require_operations().shell_write(shell_id, data)
|
|
314
|
+
|
|
315
|
+
async def shell_read(self, shell_id: str, *, timeout: float | None, max_bytes: int) -> ShellReadResult:
|
|
316
|
+
return await self._require_operations().shell_read(shell_id, timeout=timeout, max_bytes=max_bytes)
|
|
317
|
+
|
|
318
|
+
async def attach_shell(self, shell_id: str, stdin: ByteStream, *, max_bytes: int) -> ShellAttachment:
|
|
319
|
+
return await self._require_operations().attach_shell(shell_id, stdin, max_bytes=max_bytes)
|
|
320
|
+
|
|
321
|
+
async def shell_close(self, shell_id: str) -> None:
|
|
322
|
+
await self._require_operations().shell_close(shell_id)
|
|
323
|
+
|
|
324
|
+
async def open_stream(
|
|
325
|
+
self,
|
|
326
|
+
host: str,
|
|
327
|
+
port: int,
|
|
328
|
+
policy: ForwardingPolicy,
|
|
329
|
+
) -> NativeSshTunnelEndpoint:
|
|
330
|
+
if self._closing or self._connection is None or self._backend_closed():
|
|
331
|
+
raise TunnelBackendError("native SSH runtime is not connected")
|
|
332
|
+
if policy != self.host.forwarding:
|
|
333
|
+
raise TunnelBackendError("native SSH stream policy does not match the host runtime")
|
|
334
|
+
stream_id = self._next_stream_id
|
|
335
|
+
self._next_stream_id += 1
|
|
336
|
+
endpoint = NativeSshTunnelEndpoint(
|
|
337
|
+
stream_id,
|
|
338
|
+
receive_window=self.receive_window,
|
|
339
|
+
closed_handler=self._endpoint_closed,
|
|
340
|
+
write_stall_handler=self._record_window_stall,
|
|
341
|
+
)
|
|
342
|
+
try:
|
|
343
|
+
_, session = await self._connection.create_connection(
|
|
344
|
+
lambda: endpoint,
|
|
345
|
+
host,
|
|
346
|
+
port,
|
|
347
|
+
orig_host="",
|
|
348
|
+
orig_port=0,
|
|
349
|
+
encoding=None,
|
|
350
|
+
)
|
|
351
|
+
except asyncio.CancelledError:
|
|
352
|
+
await endpoint.reset("native SSH stream open cancelled")
|
|
353
|
+
raise
|
|
354
|
+
except Exception as exc:
|
|
355
|
+
await endpoint.reset(str(exc) or "native SSH stream open failed")
|
|
356
|
+
raise TunnelBackendError(f"native SSH destination open failed: {exc}") from exc
|
|
357
|
+
if session is not endpoint:
|
|
358
|
+
await endpoint.reset("native SSH returned an unexpected stream session")
|
|
359
|
+
raise TunnelBackendError("native SSH returned an unexpected stream session")
|
|
360
|
+
if self._closing or self._backend_closed():
|
|
361
|
+
message = self._backend_failure_message()
|
|
362
|
+
await endpoint.reset(message)
|
|
363
|
+
raise TunnelBackendError(message)
|
|
364
|
+
self._endpoints[stream_id] = endpoint
|
|
365
|
+
return endpoint
|
|
366
|
+
|
|
367
|
+
async def wait_closed(self) -> BaseException | None:
|
|
368
|
+
if self._closed_future is None:
|
|
369
|
+
raise TunnelBackendError("native SSH runtime has not started")
|
|
370
|
+
return await asyncio.shield(self._closed_future)
|
|
371
|
+
|
|
372
|
+
async def close(self) -> None:
|
|
373
|
+
async with self._close_lock:
|
|
374
|
+
self._closing = True
|
|
375
|
+
cleanup = asyncio.create_task(self._finish_close())
|
|
376
|
+
await finish_task_before_cancellation(cleanup)
|
|
377
|
+
|
|
378
|
+
def abort(self) -> None:
|
|
379
|
+
self._closing = True
|
|
380
|
+
error = TunnelBackendError("native SSH runtime cleanup timed out")
|
|
381
|
+
for endpoint in list(self._endpoints.values()):
|
|
382
|
+
with suppress(Exception):
|
|
383
|
+
endpoint.backend_lost(error)
|
|
384
|
+
self._endpoints.clear()
|
|
385
|
+
connection = self._connection
|
|
386
|
+
self._connection = None
|
|
387
|
+
self._operations = None
|
|
388
|
+
abort_error: Exception | None = None
|
|
389
|
+
if connection is not None:
|
|
390
|
+
try:
|
|
391
|
+
connection.abort()
|
|
392
|
+
except Exception as exc:
|
|
393
|
+
try:
|
|
394
|
+
connection.close()
|
|
395
|
+
except Exception as fallback_error:
|
|
396
|
+
fallback_error.add_note(f"connection abort failed: {exc}")
|
|
397
|
+
abort_error = fallback_error
|
|
398
|
+
if self._closed_future is not None and not self._closed_future.done():
|
|
399
|
+
with suppress(Exception):
|
|
400
|
+
self._closed_future.set_result(error)
|
|
401
|
+
if abort_error is not None:
|
|
402
|
+
raise abort_error
|
|
403
|
+
|
|
404
|
+
async def _finish_close(self) -> None:
|
|
405
|
+
endpoints = list(self._endpoints.values())
|
|
406
|
+
await asyncio.gather(*(endpoint.close() for endpoint in endpoints), return_exceptions=True)
|
|
407
|
+
for endpoint in endpoints:
|
|
408
|
+
self._endpoints.pop(endpoint.provider_stream_id, None)
|
|
409
|
+
connection = self._connection
|
|
410
|
+
self._operations = None
|
|
411
|
+
if connection is not None:
|
|
412
|
+
connection.close()
|
|
413
|
+
with suppress(Exception):
|
|
414
|
+
await connection.wait_closed()
|
|
415
|
+
if self._connection is connection:
|
|
416
|
+
self._connection = None
|
|
417
|
+
if self._closed_future is not None and not self._closed_future.done():
|
|
418
|
+
self._closed_future.set_result(None)
|
|
419
|
+
|
|
420
|
+
def _connection_lost(self, error: BaseException | None) -> None:
|
|
421
|
+
if error is None and not self._closing:
|
|
422
|
+
error = TunnelBackendError("native SSH connection closed")
|
|
423
|
+
for endpoint in list(self._endpoints.values()):
|
|
424
|
+
endpoint.backend_lost(error)
|
|
425
|
+
self._endpoints.clear()
|
|
426
|
+
if self._closed_future is not None and not self._closed_future.done():
|
|
427
|
+
self._closed_future.set_result(error)
|
|
428
|
+
|
|
429
|
+
def _endpoint_closed(self, stream_id: int) -> None:
|
|
430
|
+
self._endpoints.pop(stream_id, None)
|
|
431
|
+
|
|
432
|
+
def _require_operations(self) -> AsyncSshConnection:
|
|
433
|
+
if self._closing or self._backend_closed() or self._operations is None:
|
|
434
|
+
raise TunnelBackendError("native SSH runtime is not connected")
|
|
435
|
+
return self._operations
|
|
436
|
+
|
|
437
|
+
def _backend_closed(self) -> bool:
|
|
438
|
+
return self._closed_future is not None and self._closed_future.done()
|
|
439
|
+
|
|
440
|
+
def _backend_failure_message(self) -> str:
|
|
441
|
+
if self._closed_future is None or not self._closed_future.done():
|
|
442
|
+
return "native SSH connection closed"
|
|
443
|
+
error = self._closed_future.result()
|
|
444
|
+
return str(error) if error is not None else "native SSH connection closed"
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
def native_ssh_provider_factory(
|
|
448
|
+
host: HostConfig,
|
|
449
|
+
provider_name: str,
|
|
450
|
+
generation: int,
|
|
451
|
+
) -> NativeSshHostRuntime:
|
|
452
|
+
if provider_name != "native_ssh":
|
|
453
|
+
raise TunnelBackendError(f"native SSH factory cannot create provider {provider_name!r}")
|
|
454
|
+
return NativeSshHostRuntime(host, generation=generation)
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""Strict tunnel provider registry owned by the HostBridge daemon."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from .hosts import HostConfig
|
|
6
|
+
from .tunnel_manager import TunnelBackendError, TunnelProviderRuntime
|
|
7
|
+
from .tunnel_native_ssh import native_ssh_provider_factory
|
|
8
|
+
from .tunnel_pty_agent import pty_agent_provider_factory
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def tunnel_provider_factory(
|
|
12
|
+
host: HostConfig,
|
|
13
|
+
provider_name: str,
|
|
14
|
+
generation: int,
|
|
15
|
+
) -> TunnelProviderRuntime:
|
|
16
|
+
if provider_name == "native_ssh":
|
|
17
|
+
return native_ssh_provider_factory(host, provider_name, generation)
|
|
18
|
+
if provider_name == "pty_agent":
|
|
19
|
+
return pty_agent_provider_factory(host, provider_name, generation)
|
|
20
|
+
raise TunnelBackendError(f"unsupported tunnel provider {provider_name!r}")
|