@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,506 @@
|
|
|
1
|
+
"""HostBridge service operations exposed over multiplexed RPC streams."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import hashlib
|
|
7
|
+
from collections.abc import AsyncGenerator, Awaitable, Callable
|
|
8
|
+
from dataclasses import asdict
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from . import __version__
|
|
12
|
+
from .async_lifecycle import finish_task_before_cancellation
|
|
13
|
+
from .mux_protocol import MUX_PROTOCOL_VERSION
|
|
14
|
+
from .mux_records import MAX_RECORD_PAYLOAD_BYTES, MuxRecord, MuxRecordChannel, encode_record
|
|
15
|
+
from .mux_rpc import MuxRpcFailure
|
|
16
|
+
from .mux_stream import MuxRpcStream
|
|
17
|
+
from .runtime_services import AsyncHostBridgeServices
|
|
18
|
+
from .services import ServiceError
|
|
19
|
+
from .tunnel_manager import HostTunnelManager, TunnelError
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class HostBridgeMuxService:
|
|
23
|
+
def __init__(self, services: AsyncHostBridgeServices, *, tunnel_manager: HostTunnelManager | None = None) -> None:
|
|
24
|
+
self.services = services
|
|
25
|
+
self.tunnel_manager = tunnel_manager
|
|
26
|
+
self._unary_methods = {
|
|
27
|
+
"system.hello",
|
|
28
|
+
"system.health",
|
|
29
|
+
"tunnel.health",
|
|
30
|
+
"system.shutdown",
|
|
31
|
+
"host.list",
|
|
32
|
+
"session.open",
|
|
33
|
+
"session.list",
|
|
34
|
+
"session.close",
|
|
35
|
+
"session.release_mock_ssh",
|
|
36
|
+
"session.close_all",
|
|
37
|
+
"task.start",
|
|
38
|
+
"task.status",
|
|
39
|
+
"task.cancel",
|
|
40
|
+
"file.write_text",
|
|
41
|
+
"file.read_text",
|
|
42
|
+
}
|
|
43
|
+
self._stream_handlers: dict[
|
|
44
|
+
str,
|
|
45
|
+
Callable[[dict[str, object], MuxRpcStream], Awaitable[dict[str, object]]],
|
|
46
|
+
] = {
|
|
47
|
+
"exec.start": self._exec,
|
|
48
|
+
"transfer.upload": self._upload,
|
|
49
|
+
"transfer.download": self._download,
|
|
50
|
+
"shell.write": self._shell_write,
|
|
51
|
+
"shell.read": self._shell_read,
|
|
52
|
+
"shell.attach": self._shell_attach,
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async def handle_unary_async(
|
|
56
|
+
self,
|
|
57
|
+
method: str,
|
|
58
|
+
params: dict[str, object],
|
|
59
|
+
) -> dict[str, object]:
|
|
60
|
+
try:
|
|
61
|
+
return await self._dispatch_unary(method, params)
|
|
62
|
+
except asyncio.CancelledError:
|
|
63
|
+
raise
|
|
64
|
+
except MuxRpcFailure:
|
|
65
|
+
raise
|
|
66
|
+
except ServiceError as exc:
|
|
67
|
+
raise _service_failure(exc) from exc
|
|
68
|
+
except (TypeError, ValueError) as exc:
|
|
69
|
+
raise MuxRpcFailure("invalid_request", str(exc)) from exc
|
|
70
|
+
|
|
71
|
+
async def handle_stream(
|
|
72
|
+
self,
|
|
73
|
+
method: str,
|
|
74
|
+
params: dict[str, object],
|
|
75
|
+
stream: MuxRpcStream,
|
|
76
|
+
client_connection_id: str,
|
|
77
|
+
) -> dict[str, object]:
|
|
78
|
+
handler = None if method == "tcp.connect" else self._stream_handlers.get(method)
|
|
79
|
+
if handler is None:
|
|
80
|
+
if method == "tcp.connect":
|
|
81
|
+
try:
|
|
82
|
+
return await self._tcp_connect(params, stream, client_connection_id)
|
|
83
|
+
except asyncio.CancelledError:
|
|
84
|
+
raise
|
|
85
|
+
except MuxRpcFailure:
|
|
86
|
+
raise
|
|
87
|
+
except TunnelError as exc:
|
|
88
|
+
raise MuxRpcFailure(exc.code, str(exc), retryable=exc.retryable) from exc
|
|
89
|
+
except (TypeError, ValueError) as exc:
|
|
90
|
+
raise MuxRpcFailure("invalid_request", str(exc)) from exc
|
|
91
|
+
if method in self._unary_methods:
|
|
92
|
+
raise MuxRpcFailure("invalid_request", f"method {method} does not accept a binary stream")
|
|
93
|
+
raise MuxRpcFailure("not_found", f"unknown method {method}")
|
|
94
|
+
try:
|
|
95
|
+
return await handler(params, stream)
|
|
96
|
+
except asyncio.CancelledError:
|
|
97
|
+
raise
|
|
98
|
+
except MuxRpcFailure:
|
|
99
|
+
raise
|
|
100
|
+
except ServiceError as exc:
|
|
101
|
+
raise _service_failure(exc) from exc
|
|
102
|
+
except TunnelError as exc:
|
|
103
|
+
raise MuxRpcFailure(exc.code, str(exc), retryable=exc.retryable) from exc
|
|
104
|
+
except (TypeError, ValueError) as exc:
|
|
105
|
+
raise MuxRpcFailure("invalid_request", str(exc)) from exc
|
|
106
|
+
|
|
107
|
+
async def _dispatch_unary(self, method: str, params: dict[str, object]) -> dict[str, object]:
|
|
108
|
+
if method in self._stream_handlers or method == "tcp.connect":
|
|
109
|
+
raise MuxRpcFailure("invalid_request", f"method {method} requires a binary stream")
|
|
110
|
+
if method not in self._unary_methods:
|
|
111
|
+
raise MuxRpcFailure("not_found", f"unknown method {method}")
|
|
112
|
+
if method == "system.hello":
|
|
113
|
+
return {
|
|
114
|
+
"service": "hostbridge",
|
|
115
|
+
"protocol": MUX_PROTOCOL_VERSION,
|
|
116
|
+
"version": __version__,
|
|
117
|
+
"capabilities": ["sessions", "exec", "binary_streams", "persistent_mux"],
|
|
118
|
+
}
|
|
119
|
+
if method == "system.health":
|
|
120
|
+
return {"status": "ok", "protocol": MUX_PROTOCOL_VERSION}
|
|
121
|
+
if method == "host.list":
|
|
122
|
+
return {"hosts": self.services.hosts.describe()}
|
|
123
|
+
if method == "tunnel.health":
|
|
124
|
+
return self._tunnel_health(params)
|
|
125
|
+
if method == "system.shutdown":
|
|
126
|
+
return await self.services.close_all(force=True)
|
|
127
|
+
if method == "session.open":
|
|
128
|
+
return await self._session_open(params)
|
|
129
|
+
if method == "session.list":
|
|
130
|
+
return {"sessions": await self.services.list_sessions()}
|
|
131
|
+
if method == "session.close":
|
|
132
|
+
return await self._session_close(params)
|
|
133
|
+
if method == "session.release_mock_ssh":
|
|
134
|
+
return await self._session_release_mock_ssh(params)
|
|
135
|
+
if method == "session.close_all":
|
|
136
|
+
return await self.services.close_all(owner=_optional_owner(params))
|
|
137
|
+
if method == "task.start":
|
|
138
|
+
task = await self.services.start_task(
|
|
139
|
+
_required_string(params, "session_id"),
|
|
140
|
+
_required_string(params, "command"),
|
|
141
|
+
owner=_optional_owner(params),
|
|
142
|
+
)
|
|
143
|
+
return asdict(task)
|
|
144
|
+
if method == "task.status":
|
|
145
|
+
return await self.services.task_status(
|
|
146
|
+
_required_string(params, "session_id"),
|
|
147
|
+
_required_string(params, "task_id"),
|
|
148
|
+
owner=_optional_owner(params),
|
|
149
|
+
tail_lines=_integer(params.get("tail_lines", 80), "tail_lines"),
|
|
150
|
+
)
|
|
151
|
+
if method == "task.cancel":
|
|
152
|
+
return await self.services.cancel_task(
|
|
153
|
+
_required_string(params, "session_id"),
|
|
154
|
+
_required_string(params, "task_id"),
|
|
155
|
+
owner=_optional_owner(params),
|
|
156
|
+
)
|
|
157
|
+
if method == "file.write_text":
|
|
158
|
+
return await self._file_write_text(params)
|
|
159
|
+
return await self._file_read_text(params)
|
|
160
|
+
|
|
161
|
+
def _tunnel_health(self, params: dict[str, object]) -> dict[str, object]:
|
|
162
|
+
if params:
|
|
163
|
+
raise ValueError("tunnel.health does not accept parameters")
|
|
164
|
+
manager = self.tunnel_manager
|
|
165
|
+
if manager is None:
|
|
166
|
+
raise MuxRpcFailure("not_found", "TCP tunnel service is unavailable")
|
|
167
|
+
hosts = []
|
|
168
|
+
for description in self.services.hosts.describe():
|
|
169
|
+
host_id = description.get("id")
|
|
170
|
+
if not isinstance(host_id, str):
|
|
171
|
+
raise MuxRpcFailure("internal", "host registry returned an invalid host id")
|
|
172
|
+
hosts.append(manager.health(host_id))
|
|
173
|
+
return {"hosts": hosts}
|
|
174
|
+
|
|
175
|
+
async def _session_open(self, params: dict[str, object]) -> dict[str, object]:
|
|
176
|
+
connect_timeout = params.get("connect_timeout")
|
|
177
|
+
if connect_timeout is not None and (
|
|
178
|
+
not isinstance(connect_timeout, int | float)
|
|
179
|
+
or isinstance(connect_timeout, bool)
|
|
180
|
+
or not 0 < connect_timeout <= 180
|
|
181
|
+
):
|
|
182
|
+
raise ValueError("connect_timeout must be between 0 and 180 seconds")
|
|
183
|
+
session = await self.services.open_session(
|
|
184
|
+
_required_string(params, "host_id"),
|
|
185
|
+
owner=_optional_owner(params),
|
|
186
|
+
connect_timeout=float(connect_timeout) if connect_timeout is not None else None,
|
|
187
|
+
)
|
|
188
|
+
return session.describe()
|
|
189
|
+
|
|
190
|
+
async def _session_close(self, params: dict[str, object]) -> dict[str, object]:
|
|
191
|
+
if "force" in params:
|
|
192
|
+
raise ValueError("force close is not available over the public protocol")
|
|
193
|
+
session_id = _required_string(params, "session_id")
|
|
194
|
+
await self.services.close_session(session_id, owner=_optional_owner(params))
|
|
195
|
+
return {"session_id": session_id, "closed": True}
|
|
196
|
+
|
|
197
|
+
async def _session_release_mock_ssh(self, params: dict[str, object]) -> dict[str, object]:
|
|
198
|
+
unknown = sorted(set(params) - {"session_id"})
|
|
199
|
+
if unknown:
|
|
200
|
+
raise ValueError(f"unknown session.release_mock_ssh fields: {', '.join(unknown)}")
|
|
201
|
+
session_id = _required_string(params, "session_id")
|
|
202
|
+
await self.services.close_mock_ssh_session(session_id)
|
|
203
|
+
return {"session_id": session_id, "closed": True}
|
|
204
|
+
|
|
205
|
+
async def _file_write_text(self, params: dict[str, object]) -> dict[str, object]:
|
|
206
|
+
content = params.get("content")
|
|
207
|
+
if not isinstance(content, str):
|
|
208
|
+
raise ValueError("content must be a string")
|
|
209
|
+
result = await self.services.write_text(
|
|
210
|
+
_required_string(params, "session_id"),
|
|
211
|
+
_required_string(params, "remote_path"),
|
|
212
|
+
content,
|
|
213
|
+
mode=_integer(params.get("mode", 0o644), "mode"),
|
|
214
|
+
max_bytes=_positive_integer(params.get("max_bytes", 4 * 1024 * 1024), "max_bytes"),
|
|
215
|
+
owner=_optional_owner(params),
|
|
216
|
+
)
|
|
217
|
+
return _transfer_result(result.bytes_transferred, result.sha256)
|
|
218
|
+
|
|
219
|
+
async def _file_read_text(self, params: dict[str, object]) -> dict[str, object]:
|
|
220
|
+
content = await self.services.read_text(
|
|
221
|
+
_required_string(params, "session_id"),
|
|
222
|
+
_required_string(params, "remote_path"),
|
|
223
|
+
max_bytes=_positive_integer(params.get("max_bytes", 4 * 1024 * 1024), "max_bytes"),
|
|
224
|
+
owner=_optional_owner(params),
|
|
225
|
+
)
|
|
226
|
+
return {"content": content, "bytes_transferred": len(content.encode("utf-8"))}
|
|
227
|
+
|
|
228
|
+
async def _exec(
|
|
229
|
+
self,
|
|
230
|
+
params: dict[str, object],
|
|
231
|
+
stream: MuxRpcStream,
|
|
232
|
+
) -> dict[str, object]:
|
|
233
|
+
timeout = _positive_number(params.get("timeout", 60), "timeout")
|
|
234
|
+
output_limit = _positive_integer(params.get("output_limit", 1_000_000), "output_limit")
|
|
235
|
+
session_id = _required_string(params, "session_id")
|
|
236
|
+
command = _required_string(params, "command")
|
|
237
|
+
owner = _optional_owner(params)
|
|
238
|
+
await stream.accept()
|
|
239
|
+
result = await self.services.exec(
|
|
240
|
+
session_id,
|
|
241
|
+
command,
|
|
242
|
+
stdin=_receive_chunks(stream),
|
|
243
|
+
timeout=timeout,
|
|
244
|
+
output_limit=output_limit,
|
|
245
|
+
owner=owner,
|
|
246
|
+
)
|
|
247
|
+
await _send_records(stream, MuxRecordChannel.STDOUT, result.stdout)
|
|
248
|
+
await _send_records(stream, MuxRecordChannel.STDERR, result.stderr)
|
|
249
|
+
return {
|
|
250
|
+
"session_id": result.session_id,
|
|
251
|
+
"exit_code": result.exit_code,
|
|
252
|
+
"timed_out": result.timed_out,
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
async def _upload(
|
|
256
|
+
self,
|
|
257
|
+
params: dict[str, object],
|
|
258
|
+
stream: MuxRpcStream,
|
|
259
|
+
) -> dict[str, object]:
|
|
260
|
+
expected_size_value = params.get("expected_size")
|
|
261
|
+
expected_size = (
|
|
262
|
+
None if expected_size_value is None else _non_negative_integer(expected_size_value, "expected_size")
|
|
263
|
+
)
|
|
264
|
+
expected_sha256 = params.get("expected_sha256")
|
|
265
|
+
if expected_sha256 is not None and not isinstance(expected_sha256, str):
|
|
266
|
+
raise ValueError("expected_sha256 must be a string or null")
|
|
267
|
+
session_id = _required_string(params, "session_id")
|
|
268
|
+
remote_path = _required_string(params, "remote_path")
|
|
269
|
+
mode = _integer(params.get("mode", 0o644), "mode")
|
|
270
|
+
owner = _optional_owner(params)
|
|
271
|
+
await stream.accept()
|
|
272
|
+
result = await self.services.upload(
|
|
273
|
+
session_id,
|
|
274
|
+
_receive_chunks(stream),
|
|
275
|
+
remote_path,
|
|
276
|
+
mode=mode,
|
|
277
|
+
expected_size=expected_size,
|
|
278
|
+
expected_sha256=expected_sha256,
|
|
279
|
+
owner=owner,
|
|
280
|
+
)
|
|
281
|
+
return _transfer_result(result.bytes_transferred, result.sha256)
|
|
282
|
+
|
|
283
|
+
async def _download(
|
|
284
|
+
self,
|
|
285
|
+
params: dict[str, object],
|
|
286
|
+
stream: MuxRpcStream,
|
|
287
|
+
) -> dict[str, object]:
|
|
288
|
+
chunk_size = _positive_integer(params.get("chunk_size", 256 * 1024), "chunk_size")
|
|
289
|
+
if not 4096 <= chunk_size <= 1024 * 1024:
|
|
290
|
+
raise ValueError("chunk_size must be between 4096 and 1048576 bytes")
|
|
291
|
+
session_id = _required_string(params, "session_id")
|
|
292
|
+
remote_path = _required_string(params, "remote_path")
|
|
293
|
+
owner = _optional_owner(params)
|
|
294
|
+
await stream.accept()
|
|
295
|
+
digest = hashlib.sha256()
|
|
296
|
+
transferred = 0
|
|
297
|
+
async for chunk in self.services.download(
|
|
298
|
+
session_id,
|
|
299
|
+
remote_path,
|
|
300
|
+
chunk_size=chunk_size,
|
|
301
|
+
owner=owner,
|
|
302
|
+
):
|
|
303
|
+
await stream.send(chunk)
|
|
304
|
+
digest.update(chunk)
|
|
305
|
+
transferred += len(chunk)
|
|
306
|
+
return _transfer_result(transferred, digest.hexdigest())
|
|
307
|
+
|
|
308
|
+
async def _shell_write(
|
|
309
|
+
self,
|
|
310
|
+
params: dict[str, object],
|
|
311
|
+
stream: MuxRpcStream,
|
|
312
|
+
) -> dict[str, object]:
|
|
313
|
+
max_bytes = _positive_integer(params.get("max_bytes", 1024 * 1024), "max_bytes")
|
|
314
|
+
session_id = _required_string(params, "session_id")
|
|
315
|
+
owner = _optional_owner(params)
|
|
316
|
+
await stream.accept()
|
|
317
|
+
data = bytearray()
|
|
318
|
+
async for chunk in _receive_chunks(stream):
|
|
319
|
+
data.extend(chunk)
|
|
320
|
+
if len(data) > max_bytes:
|
|
321
|
+
raise ServiceError("resource_limit", f"shell write exceeds {max_bytes} bytes")
|
|
322
|
+
written = await self.services.shell_write(session_id, bytes(data), owner=owner)
|
|
323
|
+
return {"written": written}
|
|
324
|
+
|
|
325
|
+
async def _shell_read(
|
|
326
|
+
self,
|
|
327
|
+
params: dict[str, object],
|
|
328
|
+
stream: MuxRpcStream,
|
|
329
|
+
) -> dict[str, object]:
|
|
330
|
+
timeout = _non_negative_number(params.get("timeout", 0.2), "timeout")
|
|
331
|
+
max_bytes = _positive_integer(params.get("max_bytes", 4096), "max_bytes")
|
|
332
|
+
session_id = _required_string(params, "session_id")
|
|
333
|
+
owner = _optional_owner(params)
|
|
334
|
+
await stream.accept()
|
|
335
|
+
result = await self.services.shell_read(
|
|
336
|
+
session_id,
|
|
337
|
+
timeout=timeout,
|
|
338
|
+
max_bytes=max_bytes,
|
|
339
|
+
owner=owner,
|
|
340
|
+
)
|
|
341
|
+
await stream.send(result.data)
|
|
342
|
+
return {"alive": result.alive, "bytes": len(result.data)}
|
|
343
|
+
|
|
344
|
+
async def _shell_attach(
|
|
345
|
+
self,
|
|
346
|
+
params: dict[str, object],
|
|
347
|
+
stream: MuxRpcStream,
|
|
348
|
+
) -> dict[str, object]:
|
|
349
|
+
max_bytes = _positive_integer(params.get("max_bytes", 64 * 1024), "max_bytes")
|
|
350
|
+
session_id = _required_string(params, "session_id")
|
|
351
|
+
owner = _optional_owner(params)
|
|
352
|
+
await stream.accept()
|
|
353
|
+
transferred = 0
|
|
354
|
+
attachment = await self.services.attach_shell(
|
|
355
|
+
session_id,
|
|
356
|
+
_receive_chunks(stream),
|
|
357
|
+
max_bytes=max_bytes,
|
|
358
|
+
owner=owner,
|
|
359
|
+
)
|
|
360
|
+
async for chunk in attachment:
|
|
361
|
+
await stream.send(chunk)
|
|
362
|
+
transferred += len(chunk)
|
|
363
|
+
terminal = await attachment.finish()
|
|
364
|
+
return {**terminal.to_mapping(), "bytes": transferred}
|
|
365
|
+
|
|
366
|
+
async def _tcp_connect(
|
|
367
|
+
self,
|
|
368
|
+
params: dict[str, object],
|
|
369
|
+
stream: MuxRpcStream,
|
|
370
|
+
client_connection_id: str,
|
|
371
|
+
) -> dict[str, object]:
|
|
372
|
+
manager = self.tunnel_manager
|
|
373
|
+
if manager is None:
|
|
374
|
+
raise MuxRpcFailure("not_found", "TCP tunnel service is unavailable")
|
|
375
|
+
allowed = {"host_id", "destination_host", "destination_port"}
|
|
376
|
+
unknown = sorted(set(params) - allowed)
|
|
377
|
+
if unknown:
|
|
378
|
+
raise ValueError(f"unknown tcp.connect fields: {', '.join(unknown)}")
|
|
379
|
+
host_id = _required_string(params, "host_id")
|
|
380
|
+
destination_host = _required_string(params, "destination_host")
|
|
381
|
+
destination_port = _positive_integer(params.get("destination_port"), "destination_port")
|
|
382
|
+
handle = await manager.open_stream(
|
|
383
|
+
host_id,
|
|
384
|
+
client_connection_id,
|
|
385
|
+
stream.stream_id,
|
|
386
|
+
destination_host,
|
|
387
|
+
destination_port,
|
|
388
|
+
)
|
|
389
|
+
await stream.accept()
|
|
390
|
+
bytes_sent = 0
|
|
391
|
+
bytes_received = 0
|
|
392
|
+
tasks: list[asyncio.Future[Any]] = []
|
|
393
|
+
clean = False
|
|
394
|
+
|
|
395
|
+
async def upstream() -> None:
|
|
396
|
+
nonlocal bytes_sent
|
|
397
|
+
while chunk := await stream.receive():
|
|
398
|
+
await handle.send(chunk)
|
|
399
|
+
bytes_sent += len(chunk)
|
|
400
|
+
await handle.send_eof()
|
|
401
|
+
|
|
402
|
+
async def downstream() -> None:
|
|
403
|
+
nonlocal bytes_received
|
|
404
|
+
while chunk := await handle.receive():
|
|
405
|
+
await stream.send(chunk)
|
|
406
|
+
bytes_received += len(chunk)
|
|
407
|
+
|
|
408
|
+
try:
|
|
409
|
+
bridge = asyncio.gather(
|
|
410
|
+
asyncio.create_task(upstream(), name=f"hostbridge-tcp-upstream-{stream.stream_id}"),
|
|
411
|
+
asyncio.create_task(downstream(), name=f"hostbridge-tcp-downstream-{stream.stream_id}"),
|
|
412
|
+
)
|
|
413
|
+
terminal = asyncio.create_task(
|
|
414
|
+
handle.wait_closed(),
|
|
415
|
+
name=f"hostbridge-tcp-terminal-{stream.stream_id}",
|
|
416
|
+
)
|
|
417
|
+
tasks = [bridge, terminal]
|
|
418
|
+
done, _pending = await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED)
|
|
419
|
+
if terminal in done:
|
|
420
|
+
await terminal
|
|
421
|
+
raise TunnelError("connection_lost", "destination stream closed before both directions drained")
|
|
422
|
+
await bridge
|
|
423
|
+
clean = True
|
|
424
|
+
return {"bytes_sent": bytes_sent, "bytes_received": bytes_received}
|
|
425
|
+
finally:
|
|
426
|
+
|
|
427
|
+
async def finish_bridge() -> None:
|
|
428
|
+
for task in tasks:
|
|
429
|
+
if not task.done():
|
|
430
|
+
task.cancel()
|
|
431
|
+
if tasks:
|
|
432
|
+
await asyncio.gather(*tasks, return_exceptions=True)
|
|
433
|
+
if clean:
|
|
434
|
+
await handle.close()
|
|
435
|
+
else:
|
|
436
|
+
await handle.reset("daemon client tunnel stream closed")
|
|
437
|
+
|
|
438
|
+
cleanup = asyncio.create_task(
|
|
439
|
+
finish_bridge(),
|
|
440
|
+
name=f"hostbridge-tcp-cleanup-{stream.stream_id}",
|
|
441
|
+
)
|
|
442
|
+
await finish_task_before_cancellation(cleanup)
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
async def _send_records(stream: MuxRpcStream, channel: MuxRecordChannel, data: bytes) -> None:
|
|
446
|
+
for offset in range(0, len(data), MAX_RECORD_PAYLOAD_BYTES):
|
|
447
|
+
await stream.send(encode_record(MuxRecord(channel, data[offset : offset + MAX_RECORD_PAYLOAD_BYTES])))
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
async def _receive_chunks(stream: MuxRpcStream) -> AsyncGenerator[bytes, None]:
|
|
451
|
+
while chunk := await stream.receive():
|
|
452
|
+
yield chunk
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
def _service_failure(error: ServiceError) -> MuxRpcFailure:
|
|
456
|
+
return MuxRpcFailure(error.code, str(error), retryable=error.retryable, details=error.details)
|
|
457
|
+
|
|
458
|
+
|
|
459
|
+
def _required_string(params: dict[str, object], name: str) -> str:
|
|
460
|
+
value = params.get(name)
|
|
461
|
+
if not isinstance(value, str) or not value:
|
|
462
|
+
raise ValueError(f"{name} must be a non-empty string")
|
|
463
|
+
return value
|
|
464
|
+
|
|
465
|
+
|
|
466
|
+
def _optional_owner(params: dict[str, object]) -> str | None:
|
|
467
|
+
owner = params.get("owner")
|
|
468
|
+
if owner is not None and (not isinstance(owner, str) or not owner):
|
|
469
|
+
raise ValueError("owner must be a non-empty string or null")
|
|
470
|
+
return owner
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
def _integer(value: object, name: str) -> int:
|
|
474
|
+
if not isinstance(value, int) or isinstance(value, bool):
|
|
475
|
+
raise ValueError(f"{name} must be an integer")
|
|
476
|
+
return value
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
def _positive_integer(value: object, name: str) -> int:
|
|
480
|
+
parsed = _integer(value, name)
|
|
481
|
+
if parsed <= 0:
|
|
482
|
+
raise ValueError(f"{name} must be positive")
|
|
483
|
+
return parsed
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
def _non_negative_integer(value: object, name: str) -> int:
|
|
487
|
+
parsed = _integer(value, name)
|
|
488
|
+
if parsed < 0:
|
|
489
|
+
raise ValueError(f"{name} must be non-negative")
|
|
490
|
+
return parsed
|
|
491
|
+
|
|
492
|
+
|
|
493
|
+
def _positive_number(value: object, name: str) -> float:
|
|
494
|
+
if not isinstance(value, int | float) or isinstance(value, bool) or value <= 0:
|
|
495
|
+
raise ValueError(f"{name} must be positive")
|
|
496
|
+
return float(value)
|
|
497
|
+
|
|
498
|
+
|
|
499
|
+
def _non_negative_number(value: object, name: str) -> float:
|
|
500
|
+
if not isinstance(value, int | float) or isinstance(value, bool) or value < 0:
|
|
501
|
+
raise ValueError(f"{name} must be non-negative")
|
|
502
|
+
return float(value)
|
|
503
|
+
|
|
504
|
+
|
|
505
|
+
def _transfer_result(bytes_transferred: int, sha256: str) -> dict[str, object]:
|
|
506
|
+
return {"bytes_transferred": bytes_transferred, "sha256": sha256.lower()}
|