@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
|
@@ -0,0 +1,769 @@
|
|
|
1
|
+
"""Event-driven TCP forwarding service used by the remote PTY agent."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import hashlib
|
|
7
|
+
import ipaddress
|
|
8
|
+
import os
|
|
9
|
+
import signal
|
|
10
|
+
import socket
|
|
11
|
+
import sys
|
|
12
|
+
import tempfile
|
|
13
|
+
from collections import deque
|
|
14
|
+
from collections.abc import Sequence
|
|
15
|
+
from contextlib import suppress
|
|
16
|
+
|
|
17
|
+
from .async_lifecycle import finish_task_before_cancellation, stop_input_after_peer_eof
|
|
18
|
+
from .mux_records import MAX_RECORD_PAYLOAD_BYTES, MuxRecord, MuxRecordChannel, encode_record
|
|
19
|
+
from .mux_rpc import MuxRpcFailure, serve_mux_rpc
|
|
20
|
+
from .mux_stream import MuxRpcStream
|
|
21
|
+
|
|
22
|
+
DEFAULT_MAX_AGENT_STREAMS = 128
|
|
23
|
+
DEFAULT_CONNECT_TIMEOUT = 30.0
|
|
24
|
+
DEFAULT_CLEANUP_TIMEOUT = 5.0
|
|
25
|
+
MAX_CONNECT_TIMEOUT = 180.0
|
|
26
|
+
TCP_CHUNK_BYTES = 64 * 1024
|
|
27
|
+
MAX_EXEC_OUTPUT_BYTES = 16 * 1024 * 1024
|
|
28
|
+
MAX_EXEC_TIMEOUT = 24 * 60 * 60
|
|
29
|
+
MAX_TRANSFER_BYTES = 1024 * 1024 * 1024
|
|
30
|
+
MAX_TRANSFER_CHUNK_BYTES = 1024 * 1024
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class RemoteMuxAgent:
|
|
34
|
+
def __init__(
|
|
35
|
+
self,
|
|
36
|
+
*,
|
|
37
|
+
max_streams: int = DEFAULT_MAX_AGENT_STREAMS,
|
|
38
|
+
cleanup_timeout_seconds: float = DEFAULT_CLEANUP_TIMEOUT,
|
|
39
|
+
) -> None:
|
|
40
|
+
if not isinstance(max_streams, int) or isinstance(max_streams, bool) or max_streams <= 0:
|
|
41
|
+
raise ValueError("max_streams must be a positive integer")
|
|
42
|
+
if (
|
|
43
|
+
not isinstance(cleanup_timeout_seconds, (int, float))
|
|
44
|
+
or isinstance(cleanup_timeout_seconds, bool)
|
|
45
|
+
or cleanup_timeout_seconds <= 0
|
|
46
|
+
):
|
|
47
|
+
raise ValueError("cleanup_timeout_seconds must be positive")
|
|
48
|
+
self.max_streams = max_streams
|
|
49
|
+
self.cleanup_timeout_seconds = float(cleanup_timeout_seconds)
|
|
50
|
+
self.active_streams = 0
|
|
51
|
+
self.peak_streams = 0
|
|
52
|
+
self._shells: dict[str, asyncio.subprocess.Process] = {}
|
|
53
|
+
self._shell_read_locks: dict[str, asyncio.Lock] = {}
|
|
54
|
+
self._shell_write_locks: dict[str, asyncio.Lock] = {}
|
|
55
|
+
self._shell_creation_lock = asyncio.Lock()
|
|
56
|
+
|
|
57
|
+
async def serve(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
|
|
58
|
+
try:
|
|
59
|
+
await serve_mux_rpc(
|
|
60
|
+
reader,
|
|
61
|
+
writer,
|
|
62
|
+
self._handle_unary,
|
|
63
|
+
max_handlers=self.max_streams,
|
|
64
|
+
stream_handler=self._handle_stream,
|
|
65
|
+
)
|
|
66
|
+
finally:
|
|
67
|
+
await asyncio.gather(
|
|
68
|
+
*(self._close_shell(shell_id) for shell_id in tuple(self._shells)), return_exceptions=True
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
async def _handle_unary(self, method: str, params: dict[str, object]) -> dict[str, object]:
|
|
72
|
+
raise MuxRpcFailure("not_found", f"remote agent does not support unary method {method!r}")
|
|
73
|
+
|
|
74
|
+
async def _handle_stream(
|
|
75
|
+
self,
|
|
76
|
+
method: str,
|
|
77
|
+
params: dict[str, object],
|
|
78
|
+
stream: MuxRpcStream,
|
|
79
|
+
) -> dict[str, object]:
|
|
80
|
+
if method not in {
|
|
81
|
+
"tcp.connect",
|
|
82
|
+
"exec.start",
|
|
83
|
+
"transfer.upload",
|
|
84
|
+
"transfer.download",
|
|
85
|
+
"shell.write",
|
|
86
|
+
"shell.read",
|
|
87
|
+
"shell.attach",
|
|
88
|
+
"shell.close",
|
|
89
|
+
}:
|
|
90
|
+
raise MuxRpcFailure("not_found", f"remote agent does not support stream method {method!r}")
|
|
91
|
+
if self.active_streams >= self.max_streams:
|
|
92
|
+
raise MuxRpcFailure("busy", "remote agent stream capacity is exhausted", retryable=True)
|
|
93
|
+
self.active_streams += 1
|
|
94
|
+
self.peak_streams = max(self.peak_streams, self.active_streams)
|
|
95
|
+
if method == "exec.start":
|
|
96
|
+
try:
|
|
97
|
+
return await _run_exec(stream, params, self.cleanup_timeout_seconds)
|
|
98
|
+
finally:
|
|
99
|
+
self.active_streams -= 1
|
|
100
|
+
if method == "transfer.upload":
|
|
101
|
+
try:
|
|
102
|
+
return await _receive_upload(stream, params)
|
|
103
|
+
finally:
|
|
104
|
+
self.active_streams -= 1
|
|
105
|
+
if method == "transfer.download":
|
|
106
|
+
try:
|
|
107
|
+
return await _send_download(stream, params)
|
|
108
|
+
finally:
|
|
109
|
+
self.active_streams -= 1
|
|
110
|
+
if method == "shell.write":
|
|
111
|
+
try:
|
|
112
|
+
return await self._shell_write(stream, params)
|
|
113
|
+
finally:
|
|
114
|
+
self.active_streams -= 1
|
|
115
|
+
if method == "shell.read":
|
|
116
|
+
try:
|
|
117
|
+
return await self._shell_read(stream, params)
|
|
118
|
+
finally:
|
|
119
|
+
self.active_streams -= 1
|
|
120
|
+
if method == "shell.attach":
|
|
121
|
+
try:
|
|
122
|
+
return await self._shell_attach(stream, params)
|
|
123
|
+
finally:
|
|
124
|
+
self.active_streams -= 1
|
|
125
|
+
if method == "shell.close":
|
|
126
|
+
try:
|
|
127
|
+
shell_id = _shell_id(params)
|
|
128
|
+
await stream.accept()
|
|
129
|
+
while await stream.receive(TCP_CHUNK_BYTES):
|
|
130
|
+
raise MuxRpcFailure("invalid_request", "shell.close does not accept data")
|
|
131
|
+
await self._close_shell(shell_id)
|
|
132
|
+
return {"closed": True}
|
|
133
|
+
finally:
|
|
134
|
+
self.active_streams -= 1
|
|
135
|
+
writer: asyncio.StreamWriter | None = None
|
|
136
|
+
try:
|
|
137
|
+
destination = await _resolve_destination(params)
|
|
138
|
+
try:
|
|
139
|
+
async with asyncio.timeout(destination.timeout):
|
|
140
|
+
reader, writer = await asyncio.open_connection(
|
|
141
|
+
destination.address,
|
|
142
|
+
destination.port,
|
|
143
|
+
family=destination.family,
|
|
144
|
+
)
|
|
145
|
+
except TimeoutError as exc:
|
|
146
|
+
raise MuxRpcFailure("timeout", "destination connection timed out", retryable=True) from exc
|
|
147
|
+
except OSError as exc:
|
|
148
|
+
raise MuxRpcFailure(
|
|
149
|
+
"connection_lost",
|
|
150
|
+
f"destination connection failed: {exc}",
|
|
151
|
+
retryable=True,
|
|
152
|
+
) from exc
|
|
153
|
+
await stream.accept()
|
|
154
|
+
return await _bridge_tcp(stream, reader, writer)
|
|
155
|
+
finally:
|
|
156
|
+
if writer is not None:
|
|
157
|
+
writer.close()
|
|
158
|
+
with suppress(Exception):
|
|
159
|
+
async with asyncio.timeout(self.cleanup_timeout_seconds):
|
|
160
|
+
await writer.wait_closed()
|
|
161
|
+
self.active_streams -= 1
|
|
162
|
+
|
|
163
|
+
async def _shell_write(self, stream: MuxRpcStream, params: dict[str, object]) -> dict[str, object]:
|
|
164
|
+
allowed = {"shell_id", "max_bytes"}
|
|
165
|
+
unknown = sorted(set(params) - allowed)
|
|
166
|
+
if unknown:
|
|
167
|
+
raise MuxRpcFailure("invalid_request", f"unknown shell.write fields: {', '.join(unknown)}")
|
|
168
|
+
shell_id = _shell_id(params)
|
|
169
|
+
max_bytes = _bounded_integer(params.get("max_bytes", 1024 * 1024), "max_bytes", maximum=MAX_TRANSFER_BYTES)
|
|
170
|
+
process = await self._ensure_shell(shell_id)
|
|
171
|
+
if process.stdin is None:
|
|
172
|
+
raise MuxRpcFailure("connection_lost", "shell stdin is unavailable", retryable=True)
|
|
173
|
+
await stream.accept()
|
|
174
|
+
written = 0
|
|
175
|
+
async with self._shell_write_locks[shell_id]:
|
|
176
|
+
while chunk := await stream.receive(TCP_CHUNK_BYTES):
|
|
177
|
+
written += len(chunk)
|
|
178
|
+
if written > max_bytes:
|
|
179
|
+
raise MuxRpcFailure("resource_limit", f"shell write exceeds {max_bytes} bytes")
|
|
180
|
+
process.stdin.write(chunk)
|
|
181
|
+
await process.stdin.drain()
|
|
182
|
+
return {"written": written}
|
|
183
|
+
|
|
184
|
+
async def _shell_read(self, stream: MuxRpcStream, params: dict[str, object]) -> dict[str, object]:
|
|
185
|
+
allowed = {"shell_id", "timeout", "max_bytes"}
|
|
186
|
+
unknown = sorted(set(params) - allowed)
|
|
187
|
+
if unknown:
|
|
188
|
+
raise MuxRpcFailure("invalid_request", f"unknown shell.read fields: {', '.join(unknown)}")
|
|
189
|
+
shell_id = _shell_id(params)
|
|
190
|
+
timeout_value = params.get("timeout")
|
|
191
|
+
timeout = (
|
|
192
|
+
None
|
|
193
|
+
if timeout_value is None
|
|
194
|
+
else _bounded_non_negative_number(timeout_value, "timeout", maximum=MAX_EXEC_TIMEOUT)
|
|
195
|
+
)
|
|
196
|
+
max_bytes = _bounded_integer(params.get("max_bytes", 4096), "max_bytes", maximum=MAX_TRANSFER_BYTES)
|
|
197
|
+
process = await self._ensure_shell(shell_id)
|
|
198
|
+
if process.stdout is None:
|
|
199
|
+
raise MuxRpcFailure("connection_lost", "shell stdout is unavailable", retryable=True)
|
|
200
|
+
await stream.accept()
|
|
201
|
+
while await stream.receive(TCP_CHUNK_BYTES):
|
|
202
|
+
raise MuxRpcFailure("invalid_request", "shell.read does not accept data")
|
|
203
|
+
async with self._shell_read_locks[shell_id]:
|
|
204
|
+
if timeout is None:
|
|
205
|
+
data = await process.stdout.read(max_bytes)
|
|
206
|
+
else:
|
|
207
|
+
try:
|
|
208
|
+
async with asyncio.timeout(timeout):
|
|
209
|
+
data = await process.stdout.read(max_bytes)
|
|
210
|
+
except TimeoutError:
|
|
211
|
+
data = b""
|
|
212
|
+
await stream.send(bytes(data))
|
|
213
|
+
return {"alive": process.returncode is None, "bytes": len(data)}
|
|
214
|
+
|
|
215
|
+
async def _shell_attach(self, stream: MuxRpcStream, params: dict[str, object]) -> dict[str, object]:
|
|
216
|
+
allowed = {"shell_id", "max_bytes"}
|
|
217
|
+
unknown = sorted(set(params) - allowed)
|
|
218
|
+
if unknown:
|
|
219
|
+
raise MuxRpcFailure("invalid_request", f"unknown shell.attach fields: {', '.join(unknown)}")
|
|
220
|
+
shell_id = _shell_id(params)
|
|
221
|
+
max_bytes = _bounded_integer(params.get("max_bytes", 64 * 1024), "max_bytes", maximum=MAX_TRANSFER_BYTES)
|
|
222
|
+
process = await self._ensure_shell(shell_id)
|
|
223
|
+
if process.stdin is None or process.stdout is None:
|
|
224
|
+
raise MuxRpcFailure("connection_lost", "shell pipes are unavailable", retryable=True)
|
|
225
|
+
shell_stdin = process.stdin
|
|
226
|
+
shell_stdout = process.stdout
|
|
227
|
+
await stream.accept()
|
|
228
|
+
|
|
229
|
+
async def send_input() -> None:
|
|
230
|
+
async with self._shell_write_locks[shell_id]:
|
|
231
|
+
while chunk := await stream.receive(TCP_CHUNK_BYTES):
|
|
232
|
+
if len(chunk) > max_bytes:
|
|
233
|
+
raise MuxRpcFailure("resource_limit", f"shell input chunk exceeds {max_bytes} bytes")
|
|
234
|
+
shell_stdin.write(chunk)
|
|
235
|
+
await shell_stdin.drain()
|
|
236
|
+
shell_stdin.write_eof()
|
|
237
|
+
with suppress(BrokenPipeError, ConnectionResetError):
|
|
238
|
+
await shell_stdin.drain()
|
|
239
|
+
|
|
240
|
+
input_task = asyncio.create_task(send_input(), name=f"hostbridge-remote-shell-input-{shell_id}")
|
|
241
|
+
transferred = 0
|
|
242
|
+
try:
|
|
243
|
+
async with self._shell_read_locks[shell_id]:
|
|
244
|
+
while data := await shell_stdout.read(max_bytes):
|
|
245
|
+
await stream.send(bytes(data))
|
|
246
|
+
transferred += len(data)
|
|
247
|
+
await stop_input_after_peer_eof(input_task)
|
|
248
|
+
returncode = await process.wait()
|
|
249
|
+
if returncode < 0:
|
|
250
|
+
try:
|
|
251
|
+
signal_name = signal.Signals(-returncode).name
|
|
252
|
+
except ValueError:
|
|
253
|
+
signal_name = f"SIG{-returncode}"
|
|
254
|
+
return {"exit_code": None, "signal": signal_name, "bytes": transferred}
|
|
255
|
+
return {"exit_code": returncode, "signal": None, "bytes": transferred}
|
|
256
|
+
finally:
|
|
257
|
+
if not input_task.done():
|
|
258
|
+
input_task.cancel()
|
|
259
|
+
cleanup = asyncio.gather(input_task, return_exceptions=True)
|
|
260
|
+
await finish_task_before_cancellation(cleanup)
|
|
261
|
+
|
|
262
|
+
async def _ensure_shell(self, shell_id: str) -> asyncio.subprocess.Process:
|
|
263
|
+
async with self._shell_creation_lock:
|
|
264
|
+
process = self._shells.get(shell_id)
|
|
265
|
+
if process is not None and process.returncode is None:
|
|
266
|
+
return process
|
|
267
|
+
process = await asyncio.create_subprocess_exec(
|
|
268
|
+
"/bin/sh",
|
|
269
|
+
stdin=asyncio.subprocess.PIPE,
|
|
270
|
+
stdout=asyncio.subprocess.PIPE,
|
|
271
|
+
stderr=asyncio.subprocess.STDOUT,
|
|
272
|
+
start_new_session=True,
|
|
273
|
+
)
|
|
274
|
+
self._shells[shell_id] = process
|
|
275
|
+
self._shell_read_locks[shell_id] = asyncio.Lock()
|
|
276
|
+
self._shell_write_locks[shell_id] = asyncio.Lock()
|
|
277
|
+
return process
|
|
278
|
+
|
|
279
|
+
async def _close_shell(self, shell_id: str) -> None:
|
|
280
|
+
async with self._shell_creation_lock:
|
|
281
|
+
process = self._shells.pop(shell_id, None)
|
|
282
|
+
self._shell_read_locks.pop(shell_id, None)
|
|
283
|
+
self._shell_write_locks.pop(shell_id, None)
|
|
284
|
+
if process is not None:
|
|
285
|
+
await _terminate_process(process, self.cleanup_timeout_seconds)
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
class _CombinedOutput:
|
|
289
|
+
def __init__(self, limit: int) -> None:
|
|
290
|
+
self._limit = limit
|
|
291
|
+
self._segments: deque[tuple[MuxRecordChannel, bytes]] = deque()
|
|
292
|
+
self._size = 0
|
|
293
|
+
|
|
294
|
+
def append(self, channel: MuxRecordChannel, chunk: bytes) -> None:
|
|
295
|
+
if not chunk:
|
|
296
|
+
return
|
|
297
|
+
self._segments.append((channel, bytes(chunk)))
|
|
298
|
+
self._size += len(chunk)
|
|
299
|
+
excess = self._size - self._limit
|
|
300
|
+
while excess > 0:
|
|
301
|
+
segment_channel, segment = self._segments[0]
|
|
302
|
+
if len(segment) <= excess:
|
|
303
|
+
self._segments.popleft()
|
|
304
|
+
self._size -= len(segment)
|
|
305
|
+
excess -= len(segment)
|
|
306
|
+
else:
|
|
307
|
+
self._segments[0] = (segment_channel, segment[excess:])
|
|
308
|
+
self._size -= excess
|
|
309
|
+
excess = 0
|
|
310
|
+
|
|
311
|
+
async def send(self, stream: MuxRpcStream) -> None:
|
|
312
|
+
for channel, segment in self._segments:
|
|
313
|
+
for offset in range(0, len(segment), MAX_RECORD_PAYLOAD_BYTES):
|
|
314
|
+
payload = segment[offset : offset + MAX_RECORD_PAYLOAD_BYTES]
|
|
315
|
+
await stream.send(encode_record(MuxRecord(channel, payload)))
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
async def _run_exec(
|
|
319
|
+
stream: MuxRpcStream,
|
|
320
|
+
params: dict[str, object],
|
|
321
|
+
cleanup_timeout_seconds: float,
|
|
322
|
+
) -> dict[str, object]:
|
|
323
|
+
allowed = {"command", "timeout", "output_limit"}
|
|
324
|
+
unknown = sorted(set(params) - allowed)
|
|
325
|
+
if unknown:
|
|
326
|
+
raise MuxRpcFailure("invalid_request", f"unknown exec.start fields: {', '.join(unknown)}")
|
|
327
|
+
command = _required_string(params, "command")
|
|
328
|
+
timeout = _bounded_number(params.get("timeout", 60), "timeout", maximum=MAX_EXEC_TIMEOUT)
|
|
329
|
+
output_limit = _bounded_integer(
|
|
330
|
+
params.get("output_limit", 1_000_000),
|
|
331
|
+
"output_limit",
|
|
332
|
+
maximum=MAX_EXEC_OUTPUT_BYTES,
|
|
333
|
+
)
|
|
334
|
+
try:
|
|
335
|
+
process = await asyncio.create_subprocess_exec(
|
|
336
|
+
"/bin/sh",
|
|
337
|
+
"-lc",
|
|
338
|
+
command,
|
|
339
|
+
stdin=asyncio.subprocess.PIPE,
|
|
340
|
+
stdout=asyncio.subprocess.PIPE,
|
|
341
|
+
stderr=asyncio.subprocess.PIPE,
|
|
342
|
+
start_new_session=True,
|
|
343
|
+
)
|
|
344
|
+
except OSError as exc:
|
|
345
|
+
raise MuxRpcFailure("connection_lost", f"failed to start command: {exc}", retryable=True) from exc
|
|
346
|
+
stdin_writer = process.stdin
|
|
347
|
+
stdout_reader = process.stdout
|
|
348
|
+
stderr_reader = process.stderr
|
|
349
|
+
if stdin_writer is None or stdout_reader is None or stderr_reader is None:
|
|
350
|
+
await _terminate_process(process, cleanup_timeout_seconds)
|
|
351
|
+
raise MuxRpcFailure("internal", "command subprocess pipes are unavailable")
|
|
352
|
+
await stream.accept()
|
|
353
|
+
output = _CombinedOutput(output_limit)
|
|
354
|
+
|
|
355
|
+
async def write_stdin() -> None:
|
|
356
|
+
try:
|
|
357
|
+
while chunk := await stream.receive(TCP_CHUNK_BYTES):
|
|
358
|
+
stdin_writer.write(chunk)
|
|
359
|
+
await stdin_writer.drain()
|
|
360
|
+
if stdin_writer.can_write_eof():
|
|
361
|
+
stdin_writer.write_eof()
|
|
362
|
+
await stdin_writer.drain()
|
|
363
|
+
else:
|
|
364
|
+
stdin_writer.close()
|
|
365
|
+
except (BrokenPipeError, ConnectionResetError):
|
|
366
|
+
return
|
|
367
|
+
|
|
368
|
+
async def read_output(reader: asyncio.StreamReader, channel: MuxRecordChannel) -> None:
|
|
369
|
+
while chunk := await reader.read(TCP_CHUNK_BYTES):
|
|
370
|
+
output.append(channel, chunk)
|
|
371
|
+
|
|
372
|
+
tasks = [
|
|
373
|
+
asyncio.create_task(write_stdin(), name=f"remote-agent-exec-stdin-{stream.stream_id}"),
|
|
374
|
+
asyncio.create_task(
|
|
375
|
+
read_output(stdout_reader, MuxRecordChannel.STDOUT),
|
|
376
|
+
name=f"remote-agent-exec-stdout-{stream.stream_id}",
|
|
377
|
+
),
|
|
378
|
+
asyncio.create_task(
|
|
379
|
+
read_output(stderr_reader, MuxRecordChannel.STDERR),
|
|
380
|
+
name=f"remote-agent-exec-stderr-{stream.stream_id}",
|
|
381
|
+
),
|
|
382
|
+
]
|
|
383
|
+
timed_out = False
|
|
384
|
+
try:
|
|
385
|
+
async with asyncio.timeout(timeout):
|
|
386
|
+
await asyncio.gather(process.wait(), *tasks)
|
|
387
|
+
except TimeoutError:
|
|
388
|
+
timed_out = True
|
|
389
|
+
await _terminate_process(process, cleanup_timeout_seconds)
|
|
390
|
+
except BaseException:
|
|
391
|
+
await _terminate_process(process, cleanup_timeout_seconds)
|
|
392
|
+
raise
|
|
393
|
+
finally:
|
|
394
|
+
for task in tasks:
|
|
395
|
+
if not task.done():
|
|
396
|
+
task.cancel()
|
|
397
|
+
await asyncio.gather(*tasks, return_exceptions=True)
|
|
398
|
+
await output.send(stream)
|
|
399
|
+
return {
|
|
400
|
+
"exit_code": None if timed_out else process.returncode,
|
|
401
|
+
"timed_out": timed_out,
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
async def _terminate_process(process: asyncio.subprocess.Process, timeout: float) -> None:
|
|
406
|
+
cleanup = asyncio.create_task(
|
|
407
|
+
_terminate_process_owned(process, timeout),
|
|
408
|
+
name=f"remote-agent-process-reap-{process.pid}",
|
|
409
|
+
)
|
|
410
|
+
cancellation: asyncio.CancelledError | None = None
|
|
411
|
+
while not cleanup.done():
|
|
412
|
+
try:
|
|
413
|
+
await asyncio.shield(cleanup)
|
|
414
|
+
except asyncio.CancelledError as exc:
|
|
415
|
+
cancellation = exc
|
|
416
|
+
cleanup.result()
|
|
417
|
+
if cancellation is not None:
|
|
418
|
+
raise cancellation
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
async def _terminate_process_owned(process: asyncio.subprocess.Process, timeout: float) -> None:
|
|
422
|
+
if process.returncode is not None:
|
|
423
|
+
await process.wait()
|
|
424
|
+
return
|
|
425
|
+
with suppress(ProcessLookupError):
|
|
426
|
+
os.killpg(process.pid, signal.SIGTERM)
|
|
427
|
+
try:
|
|
428
|
+
async with asyncio.timeout(timeout):
|
|
429
|
+
await process.wait()
|
|
430
|
+
except TimeoutError:
|
|
431
|
+
with suppress(ProcessLookupError):
|
|
432
|
+
os.killpg(process.pid, signal.SIGKILL)
|
|
433
|
+
await process.wait()
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
async def _receive_upload(stream: MuxRpcStream, params: dict[str, object]) -> dict[str, object]:
|
|
437
|
+
allowed = {"remote_path", "mode", "expected_size", "expected_sha256", "max_bytes"}
|
|
438
|
+
unknown = sorted(set(params) - allowed)
|
|
439
|
+
if unknown:
|
|
440
|
+
raise MuxRpcFailure("invalid_request", f"unknown transfer.upload fields: {', '.join(unknown)}")
|
|
441
|
+
remote_path = _remote_path(params, "remote_path")
|
|
442
|
+
mode = _file_mode(params.get("mode", 0o644))
|
|
443
|
+
max_bytes = _bounded_integer(
|
|
444
|
+
params.get("max_bytes", MAX_TRANSFER_BYTES),
|
|
445
|
+
"max_bytes",
|
|
446
|
+
maximum=MAX_TRANSFER_BYTES,
|
|
447
|
+
)
|
|
448
|
+
expected_size_value = params.get("expected_size")
|
|
449
|
+
expected_size = (
|
|
450
|
+
None
|
|
451
|
+
if expected_size_value is None
|
|
452
|
+
else _non_negative_integer(expected_size_value, "expected_size", maximum=max_bytes)
|
|
453
|
+
)
|
|
454
|
+
expected_sha256 = _optional_sha256(params.get("expected_sha256"))
|
|
455
|
+
parent = os.path.dirname(remote_path) or "."
|
|
456
|
+
prefix = f".{os.path.basename(remote_path)}.hostbridge-"
|
|
457
|
+
try:
|
|
458
|
+
descriptor, temporary = tempfile.mkstemp(prefix=prefix, dir=parent)
|
|
459
|
+
except OSError as exc:
|
|
460
|
+
raise MuxRpcFailure("remote_error", f"failed to create upload temporary file: {exc}") from exc
|
|
461
|
+
transferred = 0
|
|
462
|
+
digest = hashlib.sha256()
|
|
463
|
+
file = os.fdopen(descriptor, "wb", buffering=0)
|
|
464
|
+
try:
|
|
465
|
+
os.fchmod(file.fileno(), 0o600)
|
|
466
|
+
await stream.accept()
|
|
467
|
+
while chunk := await stream.receive(TCP_CHUNK_BYTES):
|
|
468
|
+
transferred += len(chunk)
|
|
469
|
+
if transferred > max_bytes:
|
|
470
|
+
raise MuxRpcFailure("resource_limit", f"upload exceeds {max_bytes} bytes")
|
|
471
|
+
await asyncio.to_thread(file.write, chunk)
|
|
472
|
+
digest.update(chunk)
|
|
473
|
+
if expected_size is not None and transferred != expected_size:
|
|
474
|
+
raise MuxRpcFailure(
|
|
475
|
+
"integrity_error",
|
|
476
|
+
f"upload size mismatch: expected {expected_size}, received {transferred}",
|
|
477
|
+
)
|
|
478
|
+
actual_sha256 = digest.hexdigest()
|
|
479
|
+
if expected_sha256 is not None and actual_sha256 != expected_sha256:
|
|
480
|
+
raise MuxRpcFailure(
|
|
481
|
+
"integrity_error",
|
|
482
|
+
f"upload SHA-256 mismatch: expected {expected_sha256}, received {actual_sha256}",
|
|
483
|
+
)
|
|
484
|
+
await asyncio.to_thread(os.fsync, file.fileno())
|
|
485
|
+
file.close()
|
|
486
|
+
os.chmod(temporary, mode)
|
|
487
|
+
os.replace(temporary, remote_path)
|
|
488
|
+
except BaseException:
|
|
489
|
+
file.close()
|
|
490
|
+
with suppress(FileNotFoundError):
|
|
491
|
+
os.unlink(temporary)
|
|
492
|
+
raise
|
|
493
|
+
return {"bytes_transferred": transferred, "sha256": digest.hexdigest()}
|
|
494
|
+
|
|
495
|
+
|
|
496
|
+
async def _send_download(stream: MuxRpcStream, params: dict[str, object]) -> dict[str, object]:
|
|
497
|
+
allowed = {"remote_path", "chunk_size", "max_bytes"}
|
|
498
|
+
unknown = sorted(set(params) - allowed)
|
|
499
|
+
if unknown:
|
|
500
|
+
raise MuxRpcFailure("invalid_request", f"unknown transfer.download fields: {', '.join(unknown)}")
|
|
501
|
+
remote_path = _remote_path(params, "remote_path")
|
|
502
|
+
chunk_size = _bounded_integer(
|
|
503
|
+
params.get("chunk_size", 256 * 1024),
|
|
504
|
+
"chunk_size",
|
|
505
|
+
maximum=MAX_TRANSFER_CHUNK_BYTES,
|
|
506
|
+
)
|
|
507
|
+
if chunk_size < 4096:
|
|
508
|
+
raise MuxRpcFailure("invalid_request", "chunk_size must be at least 4096")
|
|
509
|
+
max_bytes = _bounded_integer(
|
|
510
|
+
params.get("max_bytes", MAX_TRANSFER_BYTES),
|
|
511
|
+
"max_bytes",
|
|
512
|
+
maximum=MAX_TRANSFER_BYTES,
|
|
513
|
+
)
|
|
514
|
+
transferred = 0
|
|
515
|
+
digest = hashlib.sha256()
|
|
516
|
+
try:
|
|
517
|
+
with open(remote_path, "rb", buffering=0) as file: # noqa: PTH123
|
|
518
|
+
await stream.accept()
|
|
519
|
+
while chunk := await asyncio.to_thread(file.read, chunk_size):
|
|
520
|
+
transferred += len(chunk)
|
|
521
|
+
if transferred > max_bytes:
|
|
522
|
+
raise MuxRpcFailure("resource_limit", f"download exceeds {max_bytes} bytes")
|
|
523
|
+
digest.update(chunk)
|
|
524
|
+
await stream.send(chunk)
|
|
525
|
+
except OSError as exc:
|
|
526
|
+
raise MuxRpcFailure("remote_error", f"failed to read download source: {exc}") from exc
|
|
527
|
+
return {"bytes_transferred": transferred, "sha256": digest.hexdigest()}
|
|
528
|
+
|
|
529
|
+
|
|
530
|
+
class _PipeWriterProtocol(asyncio.streams.FlowControlMixin):
|
|
531
|
+
def __init__(self, loop: asyncio.AbstractEventLoop) -> None:
|
|
532
|
+
super().__init__(loop=loop)
|
|
533
|
+
self._closed = loop.create_future()
|
|
534
|
+
|
|
535
|
+
def connection_lost(self, exc: Exception | None) -> None:
|
|
536
|
+
super().connection_lost(exc)
|
|
537
|
+
if self._closed.done():
|
|
538
|
+
return
|
|
539
|
+
if exc is None:
|
|
540
|
+
self._closed.set_result(None)
|
|
541
|
+
else:
|
|
542
|
+
self._closed.set_exception(exc)
|
|
543
|
+
|
|
544
|
+
def _get_close_waiter(self, stream: object) -> asyncio.Future[None]: # noqa: ARG002
|
|
545
|
+
return self._closed
|
|
546
|
+
|
|
547
|
+
|
|
548
|
+
async def run_stdio_agent() -> int:
|
|
549
|
+
loop = asyncio.get_running_loop()
|
|
550
|
+
reader = asyncio.StreamReader()
|
|
551
|
+
reader_protocol = asyncio.StreamReaderProtocol(reader)
|
|
552
|
+
read_transport, _ = await loop.connect_read_pipe(lambda: reader_protocol, sys.stdin.buffer)
|
|
553
|
+
write_protocol = _PipeWriterProtocol(loop)
|
|
554
|
+
write_transport, _ = await loop.connect_write_pipe(lambda: write_protocol, sys.stdout.buffer)
|
|
555
|
+
writer = asyncio.StreamWriter(write_transport, write_protocol, reader, loop)
|
|
556
|
+
try:
|
|
557
|
+
await RemoteMuxAgent().serve(reader, writer)
|
|
558
|
+
finally:
|
|
559
|
+
read_transport.close()
|
|
560
|
+
return 0
|
|
561
|
+
|
|
562
|
+
|
|
563
|
+
class _Destination:
|
|
564
|
+
__slots__ = ("address", "family", "port", "timeout")
|
|
565
|
+
|
|
566
|
+
def __init__(self, address: str, family: socket.AddressFamily, port: int, timeout: float) -> None:
|
|
567
|
+
self.address = address
|
|
568
|
+
self.family = family
|
|
569
|
+
self.port = port
|
|
570
|
+
self.timeout = timeout
|
|
571
|
+
|
|
572
|
+
|
|
573
|
+
async def _resolve_destination(params: dict[str, object]) -> _Destination:
|
|
574
|
+
allowed = {
|
|
575
|
+
"host",
|
|
576
|
+
"port",
|
|
577
|
+
"allow_hostnames",
|
|
578
|
+
"allow_cidrs",
|
|
579
|
+
"allow_ports",
|
|
580
|
+
"connect_timeout",
|
|
581
|
+
}
|
|
582
|
+
unknown = sorted(set(params) - allowed)
|
|
583
|
+
if unknown:
|
|
584
|
+
raise MuxRpcFailure("invalid_request", f"unknown tcp.connect fields: {', '.join(unknown)}")
|
|
585
|
+
|
|
586
|
+
host = _required_string(params, "host")
|
|
587
|
+
port = _port(params.get("port"), "port")
|
|
588
|
+
allow_ports = _ports(params.get("allow_ports"), "allow_ports")
|
|
589
|
+
if port not in allow_ports:
|
|
590
|
+
raise MuxRpcFailure("denied", f"destination port {port} is not allowed")
|
|
591
|
+
networks = _networks(params.get("allow_cidrs"))
|
|
592
|
+
hostnames = _strings(params.get("allow_hostnames"), "allow_hostnames")
|
|
593
|
+
timeout = _timeout(params.get("connect_timeout", DEFAULT_CONNECT_TIMEOUT))
|
|
594
|
+
|
|
595
|
+
try:
|
|
596
|
+
literal = ipaddress.ip_address(host)
|
|
597
|
+
except ValueError:
|
|
598
|
+
literal = None
|
|
599
|
+
|
|
600
|
+
if literal is not None:
|
|
601
|
+
_require_allowed_address(literal, networks)
|
|
602
|
+
family = socket.AF_INET6 if literal.version == 6 else socket.AF_INET
|
|
603
|
+
return _Destination(str(literal), family, port, timeout)
|
|
604
|
+
|
|
605
|
+
normalized_host = host.rstrip(".").casefold()
|
|
606
|
+
if normalized_host not in {value.rstrip(".").casefold() for value in hostnames}:
|
|
607
|
+
raise MuxRpcFailure("denied", f"destination hostname {host!r} is not allowed")
|
|
608
|
+
try:
|
|
609
|
+
records = await asyncio.get_running_loop().getaddrinfo(
|
|
610
|
+
host,
|
|
611
|
+
port,
|
|
612
|
+
family=socket.AF_UNSPEC,
|
|
613
|
+
type=socket.SOCK_STREAM,
|
|
614
|
+
proto=socket.IPPROTO_TCP,
|
|
615
|
+
)
|
|
616
|
+
except OSError as exc:
|
|
617
|
+
raise MuxRpcFailure("connection_lost", f"destination resolution failed: {exc}", retryable=True) from exc
|
|
618
|
+
if not records:
|
|
619
|
+
raise MuxRpcFailure("connection_lost", "destination resolution returned no addresses", retryable=True)
|
|
620
|
+
|
|
621
|
+
resolved: list[tuple[socket.AddressFamily, ipaddress.IPv4Address | ipaddress.IPv6Address]] = []
|
|
622
|
+
for family, _socktype, _protocol, _canonical_name, sockaddr in records:
|
|
623
|
+
if family not in {socket.AF_INET, socket.AF_INET6}:
|
|
624
|
+
continue
|
|
625
|
+
address = ipaddress.ip_address(sockaddr[0])
|
|
626
|
+
_require_allowed_address(address, networks)
|
|
627
|
+
resolved.append((family, address))
|
|
628
|
+
if not resolved:
|
|
629
|
+
raise MuxRpcFailure("connection_lost", "destination resolution returned no TCP addresses", retryable=True)
|
|
630
|
+
family, address = resolved[0]
|
|
631
|
+
return _Destination(str(address), family, port, timeout)
|
|
632
|
+
|
|
633
|
+
|
|
634
|
+
async def _bridge_tcp(
|
|
635
|
+
stream: MuxRpcStream,
|
|
636
|
+
reader: asyncio.StreamReader,
|
|
637
|
+
writer: asyncio.StreamWriter,
|
|
638
|
+
) -> dict[str, object]:
|
|
639
|
+
bytes_sent = 0
|
|
640
|
+
bytes_received = 0
|
|
641
|
+
|
|
642
|
+
async def upstream() -> None:
|
|
643
|
+
nonlocal bytes_sent
|
|
644
|
+
while chunk := await stream.receive(TCP_CHUNK_BYTES):
|
|
645
|
+
writer.write(chunk)
|
|
646
|
+
await writer.drain()
|
|
647
|
+
bytes_sent += len(chunk)
|
|
648
|
+
if writer.can_write_eof():
|
|
649
|
+
writer.write_eof()
|
|
650
|
+
await writer.drain()
|
|
651
|
+
|
|
652
|
+
async def downstream() -> None:
|
|
653
|
+
nonlocal bytes_received
|
|
654
|
+
while chunk := await reader.read(TCP_CHUNK_BYTES):
|
|
655
|
+
await stream.send(chunk)
|
|
656
|
+
bytes_received += len(chunk)
|
|
657
|
+
|
|
658
|
+
try:
|
|
659
|
+
async with asyncio.TaskGroup() as group:
|
|
660
|
+
group.create_task(upstream(), name=f"remote-agent-upstream-{stream.stream_id}")
|
|
661
|
+
group.create_task(downstream(), name=f"remote-agent-downstream-{stream.stream_id}")
|
|
662
|
+
except* (ConnectionError, OSError) as group:
|
|
663
|
+
error = group.exceptions[0]
|
|
664
|
+
raise MuxRpcFailure("connection_lost", f"destination stream failed: {error}", retryable=True) from error
|
|
665
|
+
return {"bytes_sent": bytes_sent, "bytes_received": bytes_received}
|
|
666
|
+
|
|
667
|
+
|
|
668
|
+
def _required_string(params: dict[str, object], name: str) -> str:
|
|
669
|
+
value = params.get(name)
|
|
670
|
+
if not isinstance(value, str) or not value:
|
|
671
|
+
raise MuxRpcFailure("invalid_request", f"{name} must be a non-empty string")
|
|
672
|
+
return value
|
|
673
|
+
|
|
674
|
+
|
|
675
|
+
def _strings(value: object, name: str) -> tuple[str, ...]:
|
|
676
|
+
if not isinstance(value, list) or not all(isinstance(item, str) and item for item in value):
|
|
677
|
+
raise MuxRpcFailure("invalid_request", f"{name} must be a list of non-empty strings")
|
|
678
|
+
return tuple(value)
|
|
679
|
+
|
|
680
|
+
|
|
681
|
+
def _ports(value: object, name: str) -> tuple[int, ...]:
|
|
682
|
+
if not isinstance(value, list):
|
|
683
|
+
raise MuxRpcFailure("invalid_request", f"{name} must be a list of ports")
|
|
684
|
+
return tuple(_port(item, name) for item in value)
|
|
685
|
+
|
|
686
|
+
|
|
687
|
+
def _port(value: object, name: str) -> int:
|
|
688
|
+
if not isinstance(value, int) or isinstance(value, bool) or not 1 <= value <= 65535:
|
|
689
|
+
raise MuxRpcFailure("invalid_request", f"{name} must contain ports between 1 and 65535")
|
|
690
|
+
return value
|
|
691
|
+
|
|
692
|
+
|
|
693
|
+
def _networks(value: object) -> tuple[ipaddress.IPv4Network | ipaddress.IPv6Network, ...]:
|
|
694
|
+
values = _strings(value, "allow_cidrs")
|
|
695
|
+
try:
|
|
696
|
+
return tuple(ipaddress.ip_network(item, strict=True) for item in values)
|
|
697
|
+
except ValueError as exc:
|
|
698
|
+
raise MuxRpcFailure("invalid_request", f"allow_cidrs contains an invalid network: {exc}") from exc
|
|
699
|
+
|
|
700
|
+
|
|
701
|
+
def _require_allowed_address(
|
|
702
|
+
address: ipaddress.IPv4Address | ipaddress.IPv6Address,
|
|
703
|
+
networks: Sequence[ipaddress.IPv4Network | ipaddress.IPv6Network],
|
|
704
|
+
) -> None:
|
|
705
|
+
if not any(address.version == network.version and address in network for network in networks):
|
|
706
|
+
raise MuxRpcFailure("denied", f"resolved destination address {address} is not allowed")
|
|
707
|
+
|
|
708
|
+
|
|
709
|
+
def _timeout(value: object) -> float:
|
|
710
|
+
if not isinstance(value, int | float) or isinstance(value, bool) or not 0 < float(value) <= MAX_CONNECT_TIMEOUT:
|
|
711
|
+
raise MuxRpcFailure("invalid_request", f"connect_timeout must be between 0 and {MAX_CONNECT_TIMEOUT:g}")
|
|
712
|
+
return float(value)
|
|
713
|
+
|
|
714
|
+
|
|
715
|
+
def _bounded_number(value: object, name: str, *, maximum: float) -> float:
|
|
716
|
+
if not isinstance(value, int | float) or isinstance(value, bool) or not 0 < float(value) <= maximum:
|
|
717
|
+
raise MuxRpcFailure("invalid_request", f"{name} must be between 0 and {maximum:g}")
|
|
718
|
+
return float(value)
|
|
719
|
+
|
|
720
|
+
|
|
721
|
+
def _bounded_non_negative_number(value: object, name: str, *, maximum: float) -> float:
|
|
722
|
+
if not isinstance(value, int | float) or isinstance(value, bool) or not 0 <= float(value) <= maximum:
|
|
723
|
+
raise MuxRpcFailure("invalid_request", f"{name} must be between 0 and {maximum:g}")
|
|
724
|
+
return float(value)
|
|
725
|
+
|
|
726
|
+
|
|
727
|
+
def _bounded_integer(value: object, name: str, *, maximum: int) -> int:
|
|
728
|
+
if not isinstance(value, int) or isinstance(value, bool) or not 0 < value <= maximum:
|
|
729
|
+
raise MuxRpcFailure("invalid_request", f"{name} must be between 1 and {maximum}")
|
|
730
|
+
return value
|
|
731
|
+
|
|
732
|
+
|
|
733
|
+
def _non_negative_integer(value: object, name: str, *, maximum: int) -> int:
|
|
734
|
+
if not isinstance(value, int) or isinstance(value, bool) or not 0 <= value <= maximum:
|
|
735
|
+
raise MuxRpcFailure("invalid_request", f"{name} must be between 0 and {maximum}")
|
|
736
|
+
return value
|
|
737
|
+
|
|
738
|
+
|
|
739
|
+
def _file_mode(value: object) -> int:
|
|
740
|
+
if not isinstance(value, int) or isinstance(value, bool) or not 0 <= value <= 0o7777:
|
|
741
|
+
raise MuxRpcFailure("invalid_request", "mode must be an integer between 0 and 07777")
|
|
742
|
+
return value
|
|
743
|
+
|
|
744
|
+
|
|
745
|
+
def _shell_id(params: dict[str, object]) -> str:
|
|
746
|
+
shell_id = _required_string(params, "shell_id")
|
|
747
|
+
if len(shell_id) > 128 or any(
|
|
748
|
+
character not in "0123456789abcdefghijklmnopqrstuvwxyz-_" for character in shell_id.lower()
|
|
749
|
+
):
|
|
750
|
+
raise MuxRpcFailure("invalid_request", "shell_id contains unsupported characters")
|
|
751
|
+
return shell_id
|
|
752
|
+
|
|
753
|
+
|
|
754
|
+
def _optional_sha256(value: object) -> str | None:
|
|
755
|
+
if value is None:
|
|
756
|
+
return None
|
|
757
|
+
if not isinstance(value, str) or len(value) != 64:
|
|
758
|
+
raise MuxRpcFailure("invalid_request", "expected_sha256 must be 64 hexadecimal characters or null")
|
|
759
|
+
normalized = value.lower()
|
|
760
|
+
if any(character not in "0123456789abcdef" for character in normalized):
|
|
761
|
+
raise MuxRpcFailure("invalid_request", "expected_sha256 must be 64 hexadecimal characters or null")
|
|
762
|
+
return normalized
|
|
763
|
+
|
|
764
|
+
|
|
765
|
+
def _remote_path(params: dict[str, object], name: str) -> str:
|
|
766
|
+
value = _required_string(params, name)
|
|
767
|
+
if "\x00" in value:
|
|
768
|
+
raise MuxRpcFailure("invalid_request", f"{name} must not contain NUL bytes")
|
|
769
|
+
return value
|