@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
|
@@ -1,115 +1,32 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
import asyncio
|
|
4
|
-
import base64
|
|
5
|
-
import binascii
|
|
6
|
-
import json
|
|
7
|
-
import posixpath
|
|
8
|
-
import re
|
|
9
|
-
import shlex
|
|
10
4
|
import sys
|
|
11
|
-
import tempfile
|
|
12
|
-
import threading
|
|
13
|
-
import time
|
|
14
|
-
import uuid
|
|
15
|
-
from collections import deque
|
|
16
5
|
from collections.abc import Awaitable
|
|
17
|
-
from contextlib import suppress
|
|
18
|
-
from dataclasses import dataclass, field
|
|
19
|
-
from functools import partial
|
|
20
6
|
from pathlib import Path
|
|
21
7
|
from typing import Any, cast
|
|
22
8
|
|
|
23
9
|
import asyncssh
|
|
24
10
|
|
|
25
11
|
from .client import HostBridgeClient, HostBridgeError
|
|
12
|
+
from .mock_ssh_exec import HostBridgeSSHProcess
|
|
13
|
+
from .mock_ssh_session import HostBridgeSessionFactory
|
|
14
|
+
from .mock_ssh_sftp import HostBridgeSFTPServer
|
|
15
|
+
from .mock_ssh_tunnel import TunnelConnector
|
|
16
|
+
from .mux_rpc import MuxRpcClient
|
|
17
|
+
from .secure_files import create_private_file, ensure_private_directory, replace_private_file, validate_private_file
|
|
26
18
|
|
|
27
19
|
DEFAULT_PORT_RANGE = range(2222, 2300)
|
|
28
20
|
DEFAULT_COMMAND_TIMEOUT = 60
|
|
29
21
|
DEFAULT_MAX_TRANSFER_BYTES = 1024 * 1024 * 1024
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
re.compile(r"\bcat\s*(?:>|>>)"),
|
|
33
|
-
re.compile(r"\btee(?:\s|$)"),
|
|
34
|
-
re.compile(r"(?:^|\s)(?:/bin/)?(?:sh|bash|dash|zsh)\b[^\n;]*\s-s(?:\s|$)"),
|
|
35
|
-
re.compile(r"(?:^|\s)(?:python[\d.]*|node)(?:\s+\S+)*\s+-($|\s)"),
|
|
36
|
-
re.compile(r"(?:^|\s)tar\s+(?:-[A-Za-z]*x[A-Za-z]*|[A-Za-z]*x[A-Za-z]*)(?:\s|$)"),
|
|
37
|
-
re.compile(r"(?:^|\s)tar\s+.*(?:^|\s)f\s*-(?:\s|$)"),
|
|
38
|
-
re.compile(r"(?:^|\s)dd\s+.*\bof="),
|
|
39
|
-
re.compile(r"(?:^|\s)scp(?:\s+\S+)*\s+-[A-Za-z]*(?:t|f)[A-Za-z]*(?:\s|$)"),
|
|
40
|
-
]
|
|
41
|
-
MAX_IDLE_BACKEND_SESSIONS = 4
|
|
42
|
-
TUNNEL_FRAME_BYTES = 2048
|
|
43
|
-
TUNNEL_READ_BYTES = 32 * 1024
|
|
44
|
-
TUNNEL_MAX_BUFFER_BYTES = 128 * 1024
|
|
45
|
-
|
|
46
|
-
_REMOTE_TUNNEL_SCRIPT = r"""
|
|
47
|
-
import base64
|
|
48
|
-
import os
|
|
49
|
-
import socket
|
|
50
|
-
import sys
|
|
51
|
-
import threading
|
|
52
|
-
|
|
53
|
-
host, port, prefix = sys.argv[1], int(sys.argv[2]), sys.argv[3]
|
|
54
|
-
write_lock = threading.Lock()
|
|
55
|
-
|
|
56
|
-
def emit(kind, payload=b""):
|
|
57
|
-
suffix = ":" + base64.b64encode(payload).decode("ascii") if payload else ""
|
|
58
|
-
with write_lock:
|
|
59
|
-
print(prefix + ":" + kind + suffix, flush=True)
|
|
60
|
-
|
|
61
|
-
try:
|
|
62
|
-
connection = socket.create_connection((host, port), timeout=15)
|
|
63
|
-
connection.settimeout(None)
|
|
64
|
-
except Exception as exc:
|
|
65
|
-
emit("ERROR", str(exc).encode("utf-8", errors="replace"))
|
|
66
|
-
raise SystemExit(1)
|
|
67
|
-
|
|
68
|
-
emit("READY")
|
|
69
|
-
|
|
70
|
-
def receive():
|
|
71
|
-
try:
|
|
72
|
-
while True:
|
|
73
|
-
data = connection.recv(2048)
|
|
74
|
-
if not data:
|
|
75
|
-
emit("EOF")
|
|
76
|
-
os._exit(0)
|
|
77
|
-
emit("DATA", data)
|
|
78
|
-
except Exception as exc:
|
|
79
|
-
emit("ERROR", str(exc).encode("utf-8", errors="replace"))
|
|
80
|
-
os._exit(1)
|
|
81
|
-
|
|
82
|
-
receiver = threading.Thread(target=receive, daemon=True)
|
|
83
|
-
receiver.start()
|
|
84
|
-
try:
|
|
85
|
-
for raw_line in sys.stdin.buffer:
|
|
86
|
-
line = raw_line.strip()
|
|
87
|
-
if line == (prefix + ":EOF").encode("ascii"):
|
|
88
|
-
connection.shutdown(socket.SHUT_WR)
|
|
89
|
-
receiver.join()
|
|
90
|
-
break
|
|
91
|
-
data_prefix = (prefix + ":DATA:").encode("ascii")
|
|
92
|
-
if line.startswith(data_prefix):
|
|
93
|
-
connection.sendall(base64.b64decode(line[len(data_prefix):], validate=True))
|
|
94
|
-
finally:
|
|
95
|
-
connection.close()
|
|
96
|
-
"""
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
def _to_bytes(chunk: str | bytes) -> bytes:
|
|
100
|
-
if isinstance(chunk, bytes):
|
|
101
|
-
return chunk
|
|
102
|
-
return chunk.encode("utf-8")
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
def _command_reads_stdin(command: str | None) -> bool:
|
|
106
|
-
return bool(command and any(pattern.search(command) for pattern in STDIN_READING_COMMANDS))
|
|
22
|
+
DEFAULT_SFTP_MAX_OPEN_HANDLES = 16
|
|
23
|
+
DEFAULT_SFTP_MAX_TEMP_BYTES = 1024 * 1024 * 1024
|
|
107
24
|
|
|
108
25
|
|
|
109
26
|
class HostBridgeSSHServer(asyncssh.SSHServer):
|
|
110
|
-
def __init__(self, authorized_public_key: str,
|
|
27
|
+
def __init__(self, authorized_public_key: str, tunnel_connector: TunnelConnector | None = None) -> None:
|
|
111
28
|
self._authorized_public_key = authorized_public_key.strip()
|
|
112
|
-
self.
|
|
29
|
+
self._tunnel_connector = tunnel_connector
|
|
113
30
|
|
|
114
31
|
def begin_auth(self, username: str) -> bool: # noqa: ARG002
|
|
115
32
|
return True
|
|
@@ -130,851 +47,29 @@ class HostBridgeSSHServer(asyncssh.SSHServer):
|
|
|
130
47
|
orig_host: str, # noqa: ARG002
|
|
131
48
|
orig_port: int, # noqa: ARG002
|
|
132
49
|
) -> Awaitable[asyncssh.SSHTCPSession[bytes]] | bool:
|
|
133
|
-
if self.
|
|
50
|
+
if self._tunnel_connector is None:
|
|
134
51
|
return False
|
|
135
52
|
try:
|
|
136
|
-
self.
|
|
53
|
+
self._tunnel_connector.validate_destination(dest_host, dest_port)
|
|
137
54
|
except ValueError as exc:
|
|
138
55
|
raise asyncssh.ChannelOpenError(asyncssh.OPEN_CONNECT_FAILED, str(exc)) from exc
|
|
139
|
-
return self.
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
class HostBridgeSSHProcess:
|
|
143
|
-
def __init__(self, client: HostBridgeClient, session_factory: HostBridgeSessionFactory, timeout: int, output_limit: int) -> None:
|
|
144
|
-
self._client = client
|
|
145
|
-
self._session_factory = session_factory
|
|
146
|
-
self._timeout = timeout
|
|
147
|
-
self._output_limit = output_limit
|
|
148
|
-
|
|
149
|
-
async def __call__(self, process: asyncssh.SSHServerProcess[bytes]) -> None:
|
|
150
|
-
session_id = self._session_factory.open()
|
|
151
|
-
reusable = False
|
|
152
|
-
try:
|
|
153
|
-
reusable = await self._run(process, session_id)
|
|
154
|
-
except asyncio.CancelledError:
|
|
155
|
-
self._set_exit_status(process, 255)
|
|
156
|
-
return
|
|
157
|
-
finally:
|
|
158
|
-
self._session_factory.close(session_id, reusable=reusable)
|
|
159
|
-
|
|
160
|
-
async def _run(self, process: asyncssh.SSHServerProcess[bytes], session_id: str) -> bool:
|
|
161
|
-
command = process.command
|
|
162
|
-
if not command:
|
|
163
|
-
await self._run_shell(process, session_id)
|
|
164
|
-
return False
|
|
165
|
-
|
|
166
|
-
loop = asyncio.get_running_loop()
|
|
167
|
-
try:
|
|
168
|
-
stdin_data = await self._read_exec_stdin(process, command)
|
|
169
|
-
result = await loop.run_in_executor(
|
|
170
|
-
None,
|
|
171
|
-
lambda: self._client.exec(
|
|
172
|
-
session_id,
|
|
173
|
-
command,
|
|
174
|
-
stdin=stdin_data,
|
|
175
|
-
timeout=self._timeout,
|
|
176
|
-
output_limit=self._output_limit,
|
|
177
|
-
owner="mock-ssh",
|
|
178
|
-
),
|
|
179
|
-
)
|
|
180
|
-
if result.stdout:
|
|
181
|
-
process.stdout.write(result.stdout)
|
|
182
|
-
if result.stderr:
|
|
183
|
-
process.stderr.write(result.stderr)
|
|
184
|
-
self._set_exit_status(process, 124 if result.timed_out else int(result.exit_code or 0))
|
|
185
|
-
return not result.timed_out
|
|
186
|
-
except Exception as exc:
|
|
187
|
-
print(f"hostbridge mock-ssh exec failed: {exc}", file=sys.stderr, flush=True)
|
|
188
|
-
with suppress(Exception):
|
|
189
|
-
process.stderr.write(f"hostbridge mock-ssh error: {exc}\n".encode("utf-8", errors="replace"))
|
|
190
|
-
status = 124 if isinstance(exc, HostBridgeError) and exc.code == "timeout" else 1
|
|
191
|
-
self._set_exit_status(process, status)
|
|
192
|
-
return False
|
|
193
|
-
|
|
194
|
-
@staticmethod
|
|
195
|
-
def _set_exit_status(process: asyncssh.SSHServerProcess[bytes], status: int) -> None:
|
|
196
|
-
with suppress(Exception):
|
|
197
|
-
process.exit(status)
|
|
198
|
-
|
|
199
|
-
async def _read_exec_stdin(self, process: asyncssh.SSHServerProcess[bytes], command: str) -> bytes | None:
|
|
200
|
-
waits_for_eof = _command_reads_stdin(command)
|
|
201
|
-
if process.stdin.at_eof():
|
|
202
|
-
return b"" if waits_for_eof else None
|
|
203
|
-
try:
|
|
204
|
-
first_chunk = await asyncio.wait_for(
|
|
205
|
-
process.stdin.read(4096),
|
|
206
|
-
timeout=self._timeout if waits_for_eof else EXEC_STDIN_PROBE_TIMEOUT,
|
|
207
|
-
)
|
|
208
|
-
except TimeoutError:
|
|
209
|
-
if waits_for_eof:
|
|
210
|
-
raise HostBridgeError("timeout", "timed out waiting for mock-ssh exec stdin") from None
|
|
211
|
-
return None
|
|
212
|
-
if not first_chunk:
|
|
213
|
-
return b"" if waits_for_eof else None
|
|
214
|
-
|
|
215
|
-
first_chunk_bytes = _to_bytes(first_chunk)
|
|
216
|
-
total = len(first_chunk_bytes)
|
|
217
|
-
if total > self._output_limit:
|
|
218
|
-
raise HostBridgeError("resource_limit", f"mock-ssh exec stdin exceeds limit ({self._output_limit} bytes)")
|
|
219
|
-
chunks = [first_chunk_bytes]
|
|
220
|
-
while not process.stdin.at_eof():
|
|
221
|
-
chunk = await asyncio.wait_for(process.stdin.read(4096), timeout=self._timeout)
|
|
222
|
-
if not chunk:
|
|
223
|
-
break
|
|
224
|
-
chunk_bytes = _to_bytes(chunk)
|
|
225
|
-
total += len(chunk_bytes)
|
|
226
|
-
if total > self._output_limit:
|
|
227
|
-
raise HostBridgeError("resource_limit", f"mock-ssh exec stdin exceeds limit ({self._output_limit} bytes)")
|
|
228
|
-
chunks.append(chunk_bytes)
|
|
229
|
-
return b"".join(chunks)
|
|
230
|
-
|
|
231
|
-
async def _run_shell(self, process: asyncssh.SSHServerProcess[bytes], session_id: str) -> None:
|
|
232
|
-
loop = asyncio.get_running_loop()
|
|
233
|
-
stop = asyncio.Event()
|
|
234
|
-
input_done = asyncio.Event()
|
|
235
|
-
|
|
236
|
-
async def pump_input() -> None:
|
|
237
|
-
try:
|
|
238
|
-
while not process.stdin.at_eof():
|
|
239
|
-
data = await process.stdin.read(4096)
|
|
240
|
-
if not data:
|
|
241
|
-
break
|
|
242
|
-
await loop.run_in_executor(
|
|
243
|
-
None,
|
|
244
|
-
partial(
|
|
245
|
-
self._client.shell_write,
|
|
246
|
-
session_id,
|
|
247
|
-
_to_bytes(data),
|
|
248
|
-
owner="mock-ssh",
|
|
249
|
-
),
|
|
250
|
-
)
|
|
251
|
-
finally:
|
|
252
|
-
input_done.set()
|
|
253
|
-
|
|
254
|
-
async def pump_output() -> None:
|
|
255
|
-
try:
|
|
256
|
-
idle_after_input = 0
|
|
257
|
-
while not stop.is_set():
|
|
258
|
-
result = await loop.run_in_executor(
|
|
259
|
-
None,
|
|
260
|
-
lambda: self._client.shell_read(
|
|
261
|
-
session_id,
|
|
262
|
-
timeout=0.2,
|
|
263
|
-
max_bytes=4096,
|
|
264
|
-
owner="mock-ssh",
|
|
265
|
-
),
|
|
266
|
-
)
|
|
267
|
-
if result.data:
|
|
268
|
-
idle_after_input = 0
|
|
269
|
-
process.stdout.write(result.data)
|
|
270
|
-
elif input_done.is_set():
|
|
271
|
-
idle_after_input += 1
|
|
272
|
-
if idle_after_input >= 5:
|
|
273
|
-
stop.set()
|
|
274
|
-
break
|
|
275
|
-
if not result.alive:
|
|
276
|
-
stop.set()
|
|
277
|
-
break
|
|
278
|
-
except Exception as exc:
|
|
279
|
-
print(f"hostbridge mock-ssh shell failed: {exc}", file=sys.stderr, flush=True)
|
|
280
|
-
with suppress(Exception):
|
|
281
|
-
process.stderr.write(f"hostbridge mock-ssh shell error: {exc}\n".encode("utf-8", errors="replace"))
|
|
282
|
-
stop.set()
|
|
283
|
-
|
|
284
|
-
tasks = [asyncio.create_task(pump_input()), asyncio.create_task(pump_output())]
|
|
285
|
-
await stop.wait()
|
|
286
|
-
for task in tasks:
|
|
287
|
-
task.cancel()
|
|
288
|
-
await asyncio.gather(*tasks, return_exceptions=True)
|
|
289
|
-
self._set_exit_status(process, 0)
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
@dataclass
|
|
293
|
-
class HostBridgeSFTPHandle:
|
|
294
|
-
path: str
|
|
295
|
-
readable: bool = False
|
|
296
|
-
writable: bool = False
|
|
297
|
-
data: bytes = b""
|
|
298
|
-
writes: dict[int, bytes] = field(default_factory=dict)
|
|
299
|
-
mode: int = 0o644
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
@dataclass(frozen=True, slots=True)
|
|
303
|
-
class _TextCommandResult:
|
|
304
|
-
output: str
|
|
305
|
-
exit_code: int | None
|
|
306
|
-
timed_out: bool
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
class HostBridgeSessionFactory:
|
|
310
|
-
def __init__(self, client: HostBridgeClient, host_id: str, connect_timeout: int = 60) -> None:
|
|
311
|
-
self._client = client
|
|
312
|
-
self._host_id = host_id
|
|
313
|
-
self._connect_timeout = connect_timeout
|
|
314
|
-
self._lock = threading.Lock()
|
|
315
|
-
self._idle: list[str] = []
|
|
316
|
-
self._leased: set[str] = set()
|
|
317
|
-
self._shutdown = False
|
|
318
|
-
|
|
319
|
-
def open(self) -> str:
|
|
320
|
-
with self._lock:
|
|
321
|
-
if self._shutdown:
|
|
322
|
-
raise RuntimeError("mock-ssh session factory is shut down")
|
|
323
|
-
if self._idle:
|
|
324
|
-
session_id = self._idle.pop()
|
|
325
|
-
self._leased.add(session_id)
|
|
326
|
-
return session_id
|
|
327
|
-
session = self._client.open_session(
|
|
328
|
-
self._host_id,
|
|
329
|
-
owner="mock-ssh",
|
|
330
|
-
timeout=self._connect_timeout + 5,
|
|
331
|
-
)
|
|
332
|
-
session_id = session.session_id
|
|
333
|
-
with self._lock:
|
|
334
|
-
if not self._shutdown:
|
|
335
|
-
self._leased.add(session_id)
|
|
336
|
-
return session_id
|
|
337
|
-
self._close_backend(session_id)
|
|
338
|
-
raise RuntimeError("mock-ssh session factory is shut down")
|
|
339
|
-
|
|
340
|
-
def close(self, session_id: str, *, reusable: bool = True) -> None:
|
|
341
|
-
should_close = False
|
|
342
|
-
with self._lock:
|
|
343
|
-
if session_id not in self._leased:
|
|
344
|
-
return
|
|
345
|
-
self._leased.remove(session_id)
|
|
346
|
-
if reusable and not self._shutdown and len(self._idle) < MAX_IDLE_BACKEND_SESSIONS:
|
|
347
|
-
self._idle.append(session_id)
|
|
348
|
-
else:
|
|
349
|
-
should_close = True
|
|
350
|
-
if not should_close:
|
|
351
|
-
return
|
|
352
|
-
self._close_backend(session_id)
|
|
353
|
-
|
|
354
|
-
def shutdown(self) -> None:
|
|
355
|
-
with self._lock:
|
|
356
|
-
self._shutdown = True
|
|
357
|
-
session_ids = [*self._idle, *self._leased]
|
|
358
|
-
self._idle.clear()
|
|
359
|
-
self._leased.clear()
|
|
360
|
-
for session_id in session_ids:
|
|
361
|
-
self._close_backend(session_id)
|
|
362
|
-
|
|
363
|
-
def _close_backend(self, session_id: str) -> None:
|
|
364
|
-
last_error: Exception | None = None
|
|
365
|
-
for attempt in range(2):
|
|
366
|
-
try:
|
|
367
|
-
self._client.close_session(session_id, owner="mock-ssh", force=True)
|
|
368
|
-
return
|
|
369
|
-
except HostBridgeError as exc:
|
|
370
|
-
if exc.code == "not_found":
|
|
371
|
-
return
|
|
372
|
-
last_error = exc
|
|
373
|
-
except Exception as exc:
|
|
374
|
-
last_error = exc
|
|
375
|
-
if attempt == 0:
|
|
376
|
-
time.sleep(0.05)
|
|
377
|
-
print(
|
|
378
|
-
f"hostbridge mock-ssh failed to close backend session {session_id}: {last_error}",
|
|
379
|
-
file=sys.stderr,
|
|
380
|
-
flush=True,
|
|
381
|
-
)
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
class _TunnelFrameReader:
|
|
385
|
-
def __init__(self, client: HostBridgeClient, session_id: str, prefix: str) -> None:
|
|
386
|
-
self._client = client
|
|
387
|
-
self._session_id = session_id
|
|
388
|
-
self._prefix = prefix.encode("ascii") + b":"
|
|
389
|
-
self._buffer = bytearray()
|
|
390
|
-
self._pending: deque[tuple[str, bytes]] = deque()
|
|
391
|
-
|
|
392
|
-
async def next(self) -> tuple[str, bytes]:
|
|
393
|
-
while not self._pending:
|
|
394
|
-
result = await asyncio.to_thread(
|
|
395
|
-
self._client.shell_read,
|
|
396
|
-
self._session_id,
|
|
397
|
-
timeout=0.2,
|
|
398
|
-
max_bytes=4096,
|
|
399
|
-
owner="mock-ssh",
|
|
400
|
-
)
|
|
401
|
-
if result.data:
|
|
402
|
-
self._buffer.extend(result.data)
|
|
403
|
-
self._parse_lines()
|
|
404
|
-
if not result.alive and not self._pending:
|
|
405
|
-
raise HostBridgeError("connection_lost", "remote TCP tunnel closed unexpectedly", retryable=True)
|
|
406
|
-
return self._pending.popleft()
|
|
407
|
-
|
|
408
|
-
def _parse_lines(self) -> None:
|
|
409
|
-
while b"\n" in self._buffer:
|
|
410
|
-
raw_line, _, remainder = self._buffer.partition(b"\n")
|
|
411
|
-
self._buffer = bytearray(remainder)
|
|
412
|
-
line = raw_line.rstrip(b"\r")
|
|
413
|
-
marker = line.find(self._prefix)
|
|
414
|
-
if marker < 0:
|
|
415
|
-
continue
|
|
416
|
-
frame = line[marker + len(self._prefix) :]
|
|
417
|
-
kind, separator, encoded = frame.partition(b":")
|
|
418
|
-
try:
|
|
419
|
-
kind_text = kind.decode("ascii")
|
|
420
|
-
except UnicodeDecodeError:
|
|
421
|
-
continue
|
|
422
|
-
if kind_text not in {"READY", "DATA", "EOF", "ERROR", "CLOSED"}:
|
|
423
|
-
continue
|
|
424
|
-
payload = b""
|
|
425
|
-
if separator:
|
|
426
|
-
try:
|
|
427
|
-
payload = base64.b64decode(encoded, validate=True)
|
|
428
|
-
except (binascii.Error, ValueError) as exc:
|
|
429
|
-
raise HostBridgeError("protocol_mismatch", "remote TCP tunnel returned invalid base64") from exc
|
|
430
|
-
self._pending.append((kind_text, payload))
|
|
431
|
-
if len(self._buffer) > TUNNEL_MAX_BUFFER_BYTES:
|
|
432
|
-
marker = self._buffer.rfind(self._prefix)
|
|
433
|
-
self._buffer = self._buffer[marker:] if marker >= 0 else bytearray()
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
class HostBridgeTCPForwarder:
|
|
437
|
-
def __init__(
|
|
438
|
-
self,
|
|
439
|
-
client: HostBridgeClient,
|
|
440
|
-
session_factory: HostBridgeSessionFactory,
|
|
441
|
-
*,
|
|
442
|
-
connect_timeout: int,
|
|
443
|
-
) -> None:
|
|
444
|
-
self._client = client
|
|
445
|
-
self._session_factory = session_factory
|
|
446
|
-
self._connect_timeout = connect_timeout
|
|
447
|
-
|
|
448
|
-
@staticmethod
|
|
449
|
-
def validate_destination(dest_host: str, dest_port: int) -> None:
|
|
450
|
-
if not isinstance(dest_host, str) or not dest_host.strip() or "\x00" in dest_host or len(dest_host) > 253:
|
|
451
|
-
raise ValueError("TCP forwarding destination host is invalid")
|
|
452
|
-
if not isinstance(dest_port, int) or isinstance(dest_port, bool) or not 1 <= dest_port <= 65535:
|
|
453
|
-
raise ValueError("TCP forwarding destination port must be between 1 and 65535")
|
|
454
|
-
|
|
455
|
-
async def connect(self, dest_host: str, dest_port: int) -> asyncssh.SSHTCPSession[bytes]:
|
|
456
|
-
self.validate_destination(dest_host, dest_port)
|
|
457
|
-
session_id = await asyncio.to_thread(self._session_factory.open)
|
|
458
|
-
prefix = f"__HB_TUNNEL_{uuid.uuid4().hex}__"
|
|
459
|
-
frames = _TunnelFrameReader(self._client, session_id, prefix)
|
|
460
|
-
try:
|
|
461
|
-
await self._start_remote(session_id, dest_host, dest_port, prefix)
|
|
462
|
-
kind, payload = await asyncio.wait_for(frames.next(), timeout=self._connect_timeout)
|
|
463
|
-
if kind == "ERROR":
|
|
464
|
-
raise HostBridgeError("connection_lost", payload.decode("utf-8", errors="replace"), retryable=True)
|
|
465
|
-
if kind != "READY":
|
|
466
|
-
raise HostBridgeError("protocol_mismatch", f"remote TCP tunnel returned {kind} before READY")
|
|
467
|
-
except asyncio.CancelledError:
|
|
468
|
-
await asyncio.to_thread(self._session_factory.close, session_id, reusable=False)
|
|
469
|
-
raise
|
|
470
|
-
except Exception as exc:
|
|
471
|
-
await asyncio.to_thread(self._session_factory.close, session_id, reusable=False)
|
|
472
|
-
raise asyncssh.ChannelOpenError(asyncssh.OPEN_CONNECT_FAILED, str(exc)) from exc
|
|
473
|
-
return HostBridgeTCPSession(
|
|
474
|
-
self,
|
|
475
|
-
session_id=session_id,
|
|
476
|
-
prefix=prefix,
|
|
477
|
-
frames=frames,
|
|
478
|
-
dest_host=dest_host,
|
|
479
|
-
dest_port=dest_port,
|
|
480
|
-
)
|
|
481
|
-
|
|
482
|
-
async def _start_remote(self, session_id: str, dest_host: str, dest_port: int, prefix: str) -> None:
|
|
483
|
-
command = (
|
|
484
|
-
f"__hb_prefix={shlex.quote(prefix)}; stty -echo; "
|
|
485
|
-
f"python3 -u -c {shlex.quote(_REMOTE_TUNNEL_SCRIPT)} "
|
|
486
|
-
f"{shlex.quote(dest_host)} {dest_port} \"$__hb_prefix\"; "
|
|
487
|
-
"__hb_status=$?; stty echo; printf '\n%s:CLOSED\n' \"$__hb_prefix\"\n"
|
|
488
|
-
)
|
|
489
|
-
await asyncio.to_thread(
|
|
490
|
-
self._client.shell_write,
|
|
491
|
-
session_id,
|
|
492
|
-
command.encode("utf-8"),
|
|
493
|
-
owner="mock-ssh",
|
|
494
|
-
)
|
|
495
|
-
|
|
496
|
-
async def _send_data(self, session_id: str, prefix: str, payload: bytes) -> None:
|
|
497
|
-
lines = [
|
|
498
|
-
f"{prefix}:DATA:{base64.b64encode(payload[offset : offset + TUNNEL_FRAME_BYTES]).decode('ascii')}\n"
|
|
499
|
-
for offset in range(0, len(payload), TUNNEL_FRAME_BYTES)
|
|
500
|
-
]
|
|
501
|
-
await self._send_lines(session_id, lines)
|
|
502
|
-
|
|
503
|
-
async def _send_lines(self, session_id: str, lines: list[str]) -> None:
|
|
504
|
-
await asyncio.to_thread(
|
|
505
|
-
self._client.shell_write,
|
|
506
|
-
session_id,
|
|
507
|
-
"".join(lines).encode("ascii"),
|
|
508
|
-
owner="mock-ssh",
|
|
509
|
-
)
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
_TUNNEL_INPUT_EOF = object()
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
class HostBridgeTCPSession(asyncssh.SSHTCPSession[bytes]):
|
|
517
|
-
def __init__(
|
|
518
|
-
self,
|
|
519
|
-
forwarder: HostBridgeTCPForwarder,
|
|
520
|
-
*,
|
|
521
|
-
session_id: str,
|
|
522
|
-
prefix: str,
|
|
523
|
-
frames: _TunnelFrameReader,
|
|
524
|
-
dest_host: str,
|
|
525
|
-
dest_port: int,
|
|
526
|
-
) -> None:
|
|
527
|
-
self._forwarder = forwarder
|
|
528
|
-
self._session_id = session_id
|
|
529
|
-
self._prefix = prefix
|
|
530
|
-
self._frames = frames
|
|
531
|
-
self._dest_host = dest_host
|
|
532
|
-
self._dest_port = dest_port
|
|
533
|
-
self._channel: asyncssh.SSHTCPChannel[bytes] | None = None
|
|
534
|
-
self._input: asyncio.Queue[bytes | object] = asyncio.Queue()
|
|
535
|
-
self._write_ready = asyncio.Event()
|
|
536
|
-
self._write_ready.set()
|
|
537
|
-
self._runner: asyncio.Task[None] | None = None
|
|
538
|
-
self._orphan_cleanup: asyncio.Task[None] | None = None
|
|
539
|
-
self._released = False
|
|
540
|
-
self._finished = asyncio.Event()
|
|
541
|
-
|
|
542
|
-
def connection_made(self, chan: asyncssh.SSHTCPChannel[bytes]) -> None:
|
|
543
|
-
self._channel = chan
|
|
544
|
-
|
|
545
|
-
def session_started(self) -> None:
|
|
546
|
-
self._runner = asyncio.create_task(self._run())
|
|
547
|
-
self._runner.add_done_callback(self._runner_done)
|
|
548
|
-
|
|
549
|
-
def data_received(self, data: bytes, datatype: asyncssh.DataType) -> None: # noqa: ARG002
|
|
550
|
-
if data:
|
|
551
|
-
self._input.put_nowait(bytes(data))
|
|
552
|
-
|
|
553
|
-
def eof_received(self) -> bool:
|
|
554
|
-
self._input.put_nowait(_TUNNEL_INPUT_EOF)
|
|
555
|
-
return True
|
|
556
|
-
|
|
557
|
-
def connection_lost(self, exc: Exception | None) -> None: # noqa: ARG002
|
|
558
|
-
self.abort()
|
|
559
|
-
|
|
560
|
-
def pause_writing(self) -> None:
|
|
561
|
-
self._write_ready.clear()
|
|
562
|
-
|
|
563
|
-
def resume_writing(self) -> None:
|
|
564
|
-
self._write_ready.set()
|
|
565
|
-
|
|
566
|
-
def abort(self) -> None:
|
|
567
|
-
if self._runner is not None and self._runner is not asyncio.current_task() and not self._runner.done():
|
|
568
|
-
self._runner.cancel()
|
|
569
|
-
elif self._runner is None:
|
|
570
|
-
self._schedule_orphan_cleanup()
|
|
571
|
-
|
|
572
|
-
async def wait_finished(self) -> None:
|
|
573
|
-
await self._finished.wait()
|
|
574
|
-
|
|
575
|
-
def _runner_done(self, task: asyncio.Task[None]) -> None: # noqa: ARG002
|
|
576
|
-
if not self._finished.is_set():
|
|
577
|
-
self._schedule_orphan_cleanup()
|
|
578
|
-
|
|
579
|
-
def _schedule_orphan_cleanup(self) -> None:
|
|
580
|
-
if self._orphan_cleanup is None:
|
|
581
|
-
self._orphan_cleanup = asyncio.create_task(self._finish_orphaned_session())
|
|
582
|
-
|
|
583
|
-
async def _finish_orphaned_session(self) -> None:
|
|
584
|
-
await self._release(reusable=False)
|
|
585
|
-
if self._channel is not None:
|
|
586
|
-
with suppress(Exception):
|
|
587
|
-
self._channel.close()
|
|
588
|
-
self._finished.set()
|
|
589
|
-
|
|
590
|
-
async def _run(self) -> None:
|
|
591
|
-
input_task = asyncio.create_task(self._pump_input())
|
|
592
|
-
output_task = asyncio.create_task(self._pump_output())
|
|
593
|
-
reusable = False
|
|
594
|
-
try:
|
|
595
|
-
done, _ = await asyncio.wait({input_task, output_task}, return_when=asyncio.FIRST_COMPLETED)
|
|
596
|
-
if output_task in done:
|
|
597
|
-
input_task.cancel()
|
|
598
|
-
await asyncio.gather(input_task, return_exceptions=True)
|
|
599
|
-
await output_task
|
|
600
|
-
else:
|
|
601
|
-
await input_task
|
|
602
|
-
await output_task
|
|
603
|
-
reusable = True
|
|
604
|
-
except asyncio.CancelledError:
|
|
605
|
-
raise
|
|
606
|
-
except Exception as exc:
|
|
607
|
-
print(
|
|
608
|
-
f"hostbridge mock-ssh TCP forwarding to {self._dest_host}:{self._dest_port} closed: {exc}",
|
|
609
|
-
file=sys.stderr,
|
|
610
|
-
flush=True,
|
|
611
|
-
)
|
|
612
|
-
finally:
|
|
613
|
-
for task in (input_task, output_task):
|
|
614
|
-
if not task.done():
|
|
615
|
-
task.cancel()
|
|
616
|
-
await asyncio.gather(input_task, output_task, return_exceptions=True)
|
|
617
|
-
await self._release(reusable=reusable)
|
|
618
|
-
if self._channel is not None:
|
|
619
|
-
with suppress(Exception):
|
|
620
|
-
self._channel.close()
|
|
621
|
-
self._finished.set()
|
|
622
|
-
|
|
623
|
-
async def _release(self, *, reusable: bool) -> None:
|
|
624
|
-
if self._released:
|
|
625
|
-
return
|
|
626
|
-
self._released = True
|
|
627
|
-
await asyncio.to_thread(
|
|
628
|
-
self._forwarder._session_factory.close,
|
|
629
|
-
self._session_id,
|
|
630
|
-
reusable=reusable,
|
|
631
|
-
)
|
|
632
|
-
|
|
633
|
-
async def _pump_input(self) -> None:
|
|
634
|
-
while True:
|
|
635
|
-
item = await self._input.get()
|
|
636
|
-
if item is _TUNNEL_INPUT_EOF:
|
|
637
|
-
await self._forwarder._send_lines(self._session_id, [f"{self._prefix}:EOF\n"])
|
|
638
|
-
return
|
|
639
|
-
await self._forwarder._send_data(self._session_id, self._prefix, cast(bytes, item))
|
|
640
|
-
|
|
641
|
-
async def _pump_output(self) -> None:
|
|
642
|
-
if self._channel is None:
|
|
643
|
-
raise RuntimeError("TCP tunnel channel was not initialized")
|
|
644
|
-
saw_eof = False
|
|
645
|
-
while True:
|
|
646
|
-
kind, payload = await self._frames.next()
|
|
647
|
-
if kind == "DATA":
|
|
648
|
-
await self._write_ready.wait()
|
|
649
|
-
self._channel.write(payload)
|
|
650
|
-
elif kind == "EOF":
|
|
651
|
-
self._channel.write_eof()
|
|
652
|
-
saw_eof = True
|
|
653
|
-
elif kind == "CLOSED":
|
|
654
|
-
if not saw_eof:
|
|
655
|
-
raise HostBridgeError("protocol_mismatch", "remote TCP tunnel closed without EOF")
|
|
656
|
-
return
|
|
657
|
-
elif kind == "ERROR":
|
|
658
|
-
raise HostBridgeError(
|
|
659
|
-
"connection_lost",
|
|
660
|
-
payload.decode("utf-8", errors="replace") or "remote TCP tunnel failed",
|
|
661
|
-
retryable=True,
|
|
662
|
-
)
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
class HostBridgeSFTPServer(asyncssh.SFTPServer):
|
|
666
|
-
def __init__(
|
|
667
|
-
self,
|
|
668
|
-
chan: asyncssh.SSHServerChannel,
|
|
669
|
-
client: HostBridgeClient,
|
|
670
|
-
session_factory: HostBridgeSessionFactory,
|
|
671
|
-
timeout: int,
|
|
672
|
-
max_bytes: int,
|
|
673
|
-
) -> None:
|
|
674
|
-
super().__init__(chan)
|
|
675
|
-
self._client = client
|
|
676
|
-
self._session_factory = session_factory
|
|
677
|
-
self._session_id = session_factory.open()
|
|
678
|
-
self._timeout = timeout
|
|
679
|
-
self._max_bytes = max_bytes
|
|
680
|
-
|
|
681
|
-
def exit(self) -> None:
|
|
682
|
-
self._session_factory.close(self._session_id)
|
|
683
|
-
|
|
684
|
-
def open(self, path: bytes, pflags: int, attrs: asyncssh.SFTPAttrs) -> HostBridgeSFTPHandle:
|
|
685
|
-
remote_path = self._path(path)
|
|
686
|
-
readable = bool(pflags & asyncssh.FXF_READ)
|
|
687
|
-
writable = bool(pflags & asyncssh.FXF_WRITE)
|
|
688
|
-
mode = attrs.permissions if attrs.permissions is not None else 0o644
|
|
689
|
-
data = b""
|
|
690
|
-
if readable or (writable and not (pflags & asyncssh.FXF_TRUNC) and not (pflags & asyncssh.FXF_CREAT)):
|
|
691
|
-
try:
|
|
692
|
-
data = self._download(remote_path)
|
|
693
|
-
except asyncssh.SFTPNoSuchFile:
|
|
694
|
-
if not writable:
|
|
695
|
-
raise
|
|
696
|
-
data = b""
|
|
697
|
-
return HostBridgeSFTPHandle(remote_path, readable=readable, writable=writable, data=data, mode=mode)
|
|
698
|
-
|
|
699
|
-
def read(self, file_obj: object, offset: int, size: int) -> bytes:
|
|
700
|
-
handle = self._handle(file_obj)
|
|
701
|
-
if not handle.readable:
|
|
702
|
-
raise asyncssh.SFTPPermissionDenied("file is not open for reading")
|
|
703
|
-
return handle.data[offset : offset + size]
|
|
704
|
-
|
|
705
|
-
def write(self, file_obj: object, offset: int, data: bytes) -> int:
|
|
706
|
-
handle = self._handle(file_obj)
|
|
707
|
-
if not handle.writable:
|
|
708
|
-
raise asyncssh.SFTPPermissionDenied("file is not open for writing")
|
|
709
|
-
handle.writes[int(offset)] = bytes(data)
|
|
710
|
-
return len(data)
|
|
711
|
-
|
|
712
|
-
def close(self, file_obj: object) -> None:
|
|
713
|
-
handle = self._handle(file_obj)
|
|
714
|
-
if handle.writable:
|
|
715
|
-
self._upload(handle)
|
|
716
|
-
|
|
717
|
-
def stat(self, path: bytes) -> asyncssh.SFTPAttrs:
|
|
718
|
-
return self._stat(self._path(path), follow=True)
|
|
719
|
-
|
|
720
|
-
def lstat(self, path: bytes) -> asyncssh.SFTPAttrs:
|
|
721
|
-
return self._stat(self._path(path), follow=False)
|
|
722
|
-
|
|
723
|
-
def fstat(self, file_obj: object) -> asyncssh.SFTPAttrs:
|
|
724
|
-
handle = self._handle(file_obj)
|
|
725
|
-
if handle.writable and handle.writes:
|
|
726
|
-
size = len(handle.data)
|
|
727
|
-
for offset, chunk in handle.writes.items():
|
|
728
|
-
size = max(size, offset + len(chunk))
|
|
729
|
-
return asyncssh.SFTPAttrs(size=size, permissions=handle.mode, type=asyncssh.FILEXFER_TYPE_REGULAR)
|
|
730
|
-
return self._stat(handle.path, follow=True)
|
|
731
|
-
|
|
732
|
-
def fsetstat(self, file_obj: object, attrs: asyncssh.SFTPAttrs) -> None:
|
|
733
|
-
handle = self._handle(file_obj)
|
|
734
|
-
if attrs.permissions is not None:
|
|
735
|
-
handle.mode = attrs.permissions
|
|
736
|
-
if attrs.atime is not None or attrs.mtime is not None:
|
|
737
|
-
# Applied after upload in close(); the handle may not exist remotely yet.
|
|
738
|
-
return
|
|
739
|
-
|
|
740
|
-
async def scandir(self, path: bytes):
|
|
741
|
-
for item in self._listdir(self._path(path)):
|
|
742
|
-
yield item
|
|
743
|
-
|
|
744
|
-
def mkdir(self, path: bytes, attrs: asyncssh.SFTPAttrs) -> None:
|
|
745
|
-
mode = attrs.permissions if attrs.permissions is not None else 0o755
|
|
746
|
-
self._checked_run(f"mkdir -p -m {shlex.quote(format(mode & 0o7777, 'o'))} -- {shlex.quote(self._path(path))}")
|
|
747
|
-
|
|
748
|
-
def rmdir(self, path: bytes) -> None:
|
|
749
|
-
self._checked_run(f"rmdir -- {shlex.quote(self._path(path))}")
|
|
750
|
-
|
|
751
|
-
def remove(self, path: bytes) -> None:
|
|
752
|
-
self._checked_run(f"rm -f -- {shlex.quote(self._path(path))}")
|
|
753
|
-
|
|
754
|
-
def rename(self, oldpath: bytes, newpath: bytes) -> None:
|
|
755
|
-
self._checked_run(f"mv -- {shlex.quote(self._path(oldpath))} {shlex.quote(self._path(newpath))}")
|
|
756
|
-
|
|
757
|
-
def realpath(self, path: bytes) -> bytes:
|
|
758
|
-
script = (
|
|
759
|
-
"import os,sys; "
|
|
760
|
-
"path=sys.argv[1]; "
|
|
761
|
-
"parent=os.path.dirname(path) or '/'; "
|
|
762
|
-
"base=os.path.basename(path); "
|
|
763
|
-
"print(os.path.join(os.path.realpath(parent), base) if not os.path.exists(path) else os.path.realpath(path))"
|
|
764
|
-
)
|
|
765
|
-
command = "python3 -c " + shlex.quote(script) + " " + shlex.quote(self._path(path))
|
|
766
|
-
result = self._checked_run(command)
|
|
767
|
-
return (result.output.strip() or self._path(path)).encode("utf-8")
|
|
768
|
-
|
|
769
|
-
def setstat(self, path: bytes, attrs: asyncssh.SFTPAttrs) -> None:
|
|
770
|
-
remote_path = self._path(path)
|
|
771
|
-
if attrs.permissions is not None:
|
|
772
|
-
self._checked_run(f"chmod {shlex.quote(format(attrs.permissions & 0o7777, 'o'))} -- {shlex.quote(remote_path)}")
|
|
773
|
-
if attrs.atime is not None or attrs.mtime is not None:
|
|
774
|
-
script = "import os,sys; os.utime(sys.argv[1], (int(sys.argv[2]), int(sys.argv[3])))"
|
|
775
|
-
current = self._stat(remote_path, follow=True)
|
|
776
|
-
atime = int(attrs.atime if attrs.atime is not None else current.atime or time.time())
|
|
777
|
-
mtime = int(attrs.mtime if attrs.mtime is not None else current.mtime or time.time())
|
|
778
|
-
self._checked_run(f"python3 -c {shlex.quote(script)} {shlex.quote(remote_path)} {atime} {mtime}")
|
|
779
|
-
|
|
780
|
-
def _download(self, remote_path: str) -> bytes:
|
|
781
|
-
temporary_path: Path | None = None
|
|
782
|
-
try:
|
|
783
|
-
with tempfile.NamedTemporaryFile(prefix="hostbridge-sftp-", delete=False) as temporary:
|
|
784
|
-
temporary_path = Path(temporary.name)
|
|
785
|
-
result = self._client.download_file(
|
|
786
|
-
self._session_id,
|
|
787
|
-
remote_path,
|
|
788
|
-
temporary_path,
|
|
789
|
-
timeout=self._timeout,
|
|
790
|
-
owner="mock-ssh",
|
|
791
|
-
)
|
|
792
|
-
if result.bytes_transferred > self._max_bytes:
|
|
793
|
-
raise HostBridgeError("resource_limit", f"file exceeds mock-ssh limit ({self._max_bytes} bytes)")
|
|
794
|
-
return temporary_path.read_bytes()
|
|
795
|
-
except HostBridgeError as exc:
|
|
796
|
-
raise self._sftp_error(exc) from exc
|
|
797
|
-
finally:
|
|
798
|
-
if temporary_path is not None:
|
|
799
|
-
temporary_path.unlink(missing_ok=True)
|
|
800
|
-
|
|
801
|
-
def _upload(self, handle: HostBridgeSFTPHandle) -> None:
|
|
802
|
-
size = len(handle.data)
|
|
803
|
-
for offset, chunk in handle.writes.items():
|
|
804
|
-
size = max(size, offset + len(chunk))
|
|
805
|
-
data = bytearray(handle.data)
|
|
806
|
-
if len(data) < size:
|
|
807
|
-
data.extend(b"\x00" * (size - len(data)))
|
|
808
|
-
for offset, chunk in sorted(handle.writes.items()):
|
|
809
|
-
data[offset : offset + len(chunk)] = chunk
|
|
810
|
-
if len(data) > self._max_bytes:
|
|
811
|
-
raise asyncssh.SFTPFailure(f"file exceeds mock-ssh limit ({self._max_bytes} bytes)")
|
|
812
|
-
temporary_path: Path | None = None
|
|
813
|
-
try:
|
|
814
|
-
with tempfile.NamedTemporaryFile(prefix="hostbridge-sftp-", delete=False) as temporary:
|
|
815
|
-
temporary.write(data)
|
|
816
|
-
temporary_path = Path(temporary.name)
|
|
817
|
-
self._client.upload_file(
|
|
818
|
-
self._session_id,
|
|
819
|
-
temporary_path,
|
|
820
|
-
handle.path,
|
|
821
|
-
mode=handle.mode & 0o7777,
|
|
822
|
-
timeout=self._timeout,
|
|
823
|
-
owner="mock-ssh",
|
|
824
|
-
)
|
|
825
|
-
except HostBridgeError as exc:
|
|
826
|
-
print(f"hostbridge mock-ssh sftp upload failed: {exc}", file=sys.stderr, flush=True)
|
|
827
|
-
raise self._sftp_error(exc) from exc
|
|
828
|
-
finally:
|
|
829
|
-
if temporary_path is not None:
|
|
830
|
-
temporary_path.unlink(missing_ok=True)
|
|
831
|
-
|
|
832
|
-
def _stat(self, remote_path: str, *, follow: bool) -> asyncssh.SFTPAttrs:
|
|
833
|
-
script = r"""
|
|
834
|
-
import json, os, stat, sys
|
|
835
|
-
path = sys.argv[1]
|
|
836
|
-
st = os.stat(path) if sys.argv[2] == "1" else os.lstat(path)
|
|
837
|
-
print(json.dumps({
|
|
838
|
-
"size": st.st_size,
|
|
839
|
-
"permissions": st.st_mode,
|
|
840
|
-
"uid": st.st_uid,
|
|
841
|
-
"gid": st.st_gid,
|
|
842
|
-
"atime": int(st.st_atime),
|
|
843
|
-
"mtime": int(st.st_mtime),
|
|
844
|
-
"type": "dir" if stat.S_ISDIR(st.st_mode) else "link" if stat.S_ISLNK(st.st_mode) else "file",
|
|
845
|
-
}))
|
|
846
|
-
"""
|
|
847
|
-
result = self._checked_run(f"python3 -c {shlex.quote(script)} {shlex.quote(remote_path)} {'1' if follow else '0'}")
|
|
848
|
-
return self._attrs(json.loads(result.output))
|
|
849
|
-
|
|
850
|
-
def _listdir(self, remote_path: str) -> list[asyncssh.SFTPName]:
|
|
851
|
-
script = r"""
|
|
852
|
-
import json, os, stat, sys, time
|
|
853
|
-
path = sys.argv[1]
|
|
854
|
-
items = []
|
|
855
|
-
for name in os.listdir(path):
|
|
856
|
-
full = os.path.join(path, name)
|
|
857
|
-
st = os.lstat(full)
|
|
858
|
-
item_type = "dir" if stat.S_ISDIR(st.st_mode) else "link" if stat.S_ISLNK(st.st_mode) else "file"
|
|
859
|
-
items.append({
|
|
860
|
-
"filename": name,
|
|
861
|
-
"longname": name,
|
|
862
|
-
"attrs": {
|
|
863
|
-
"size": st.st_size,
|
|
864
|
-
"permissions": st.st_mode,
|
|
865
|
-
"uid": st.st_uid,
|
|
866
|
-
"gid": st.st_gid,
|
|
867
|
-
"atime": int(st.st_atime),
|
|
868
|
-
"mtime": int(st.st_mtime),
|
|
869
|
-
"type": item_type,
|
|
870
|
-
},
|
|
871
|
-
})
|
|
872
|
-
print(json.dumps(items))
|
|
873
|
-
"""
|
|
874
|
-
result = self._checked_run(f"python3 -c {shlex.quote(script)} {shlex.quote(remote_path)}")
|
|
875
|
-
return [
|
|
876
|
-
asyncssh.SFTPName(item["filename"], item["longname"], self._attrs(item["attrs"]))
|
|
877
|
-
for item in json.loads(result.output or "[]")
|
|
878
|
-
]
|
|
879
|
-
|
|
880
|
-
def _attrs(self, data: dict[str, object]) -> asyncssh.SFTPAttrs:
|
|
881
|
-
permissions = self._int_value(data.get("permissions"), 0)
|
|
882
|
-
raw_type = data.get("type")
|
|
883
|
-
file_type = {
|
|
884
|
-
"file": asyncssh.FILEXFER_TYPE_REGULAR,
|
|
885
|
-
"dir": asyncssh.FILEXFER_TYPE_DIRECTORY,
|
|
886
|
-
"link": asyncssh.FILEXFER_TYPE_SYMLINK,
|
|
887
|
-
}.get(str(raw_type), asyncssh.FILEXFER_TYPE_UNKNOWN)
|
|
888
|
-
return asyncssh.SFTPAttrs(
|
|
889
|
-
type=file_type,
|
|
890
|
-
size=self._int_value(data.get("size"), 0),
|
|
891
|
-
uid=self._int_value(data.get("uid"), 0),
|
|
892
|
-
gid=self._int_value(data.get("gid"), 0),
|
|
893
|
-
permissions=permissions,
|
|
894
|
-
atime=self._int_value(data.get("atime"), int(time.time())),
|
|
895
|
-
mtime=self._int_value(data.get("mtime"), int(time.time())),
|
|
896
|
-
)
|
|
897
|
-
|
|
898
|
-
@staticmethod
|
|
899
|
-
def _int_value(value: object, default: int) -> int:
|
|
900
|
-
if value is None:
|
|
901
|
-
return default
|
|
902
|
-
if isinstance(value, bool) or not isinstance(value, int | float | str):
|
|
903
|
-
raise asyncssh.SFTPFailure("remote metadata contains a non-numeric field")
|
|
904
|
-
try:
|
|
905
|
-
return int(value)
|
|
906
|
-
except ValueError as exc:
|
|
907
|
-
raise asyncssh.SFTPFailure("remote metadata contains an invalid numeric field") from exc
|
|
908
|
-
|
|
909
|
-
def _checked_run(self, command: str):
|
|
910
|
-
try:
|
|
911
|
-
result = self._client.exec(
|
|
912
|
-
self._session_id,
|
|
913
|
-
command,
|
|
914
|
-
timeout=self._timeout,
|
|
915
|
-
owner="mock-ssh",
|
|
916
|
-
)
|
|
917
|
-
except HostBridgeError as exc:
|
|
918
|
-
raise self._sftp_error(exc) from exc
|
|
919
|
-
output = result.stdout.decode("utf-8", errors="replace")
|
|
920
|
-
stderr = result.stderr.decode("utf-8", errors="replace")
|
|
921
|
-
if result.timed_out:
|
|
922
|
-
print(f"hostbridge mock-ssh sftp command timed out: {command}", file=sys.stderr, flush=True)
|
|
923
|
-
raise asyncssh.SFTPFailure("remote command timed out")
|
|
924
|
-
if result.exit_code not in (0, None):
|
|
925
|
-
error_output = "\n".join(part for part in (output, stderr) if part).strip()
|
|
926
|
-
error = self._sftp_error(
|
|
927
|
-
HostBridgeError("remote_error", error_output or f"remote command exited {result.exit_code}")
|
|
928
|
-
)
|
|
929
|
-
if isinstance(error, asyncssh.SFTPNoSuchFile):
|
|
930
|
-
raise error
|
|
931
|
-
print(
|
|
932
|
-
f"hostbridge mock-ssh sftp command failed exit={result.exit_code}: {command}\n{error_output}",
|
|
933
|
-
file=sys.stderr,
|
|
934
|
-
flush=True,
|
|
935
|
-
)
|
|
936
|
-
raise error
|
|
937
|
-
return _TextCommandResult(output, result.exit_code, result.timed_out)
|
|
938
|
-
|
|
939
|
-
def _path(self, path: bytes) -> str:
|
|
940
|
-
text = path.decode("utf-8", "surrogateescape")
|
|
941
|
-
if not text:
|
|
942
|
-
return "."
|
|
943
|
-
return posixpath.normpath(text)
|
|
944
|
-
|
|
945
|
-
def _handle(self, file_obj: object) -> HostBridgeSFTPHandle:
|
|
946
|
-
if not isinstance(file_obj, HostBridgeSFTPHandle):
|
|
947
|
-
raise asyncssh.SFTPInvalidHandle("invalid HostBridge SFTP handle")
|
|
948
|
-
return file_obj
|
|
949
|
-
|
|
950
|
-
def _sftp_error(self, exc: HostBridgeError) -> Exception:
|
|
951
|
-
text = str(exc)
|
|
952
|
-
lowered = text.lower()
|
|
953
|
-
if "no such file" in lowered or "not found" in lowered or "filenotfounderror" in lowered:
|
|
954
|
-
return asyncssh.SFTPNoSuchFile(text)
|
|
955
|
-
if "permission denied" in lowered:
|
|
956
|
-
return asyncssh.SFTPPermissionDenied(text)
|
|
957
|
-
return asyncssh.SFTPFailure(text)
|
|
56
|
+
return self._tunnel_connector.connect(dest_host, dest_port)
|
|
958
57
|
|
|
959
58
|
|
|
960
59
|
def ensure_keypair(key_dir: Path) -> tuple[Path, Path, str]:
|
|
961
|
-
key_dir
|
|
962
|
-
with _suppress_chmod_error(key_dir):
|
|
963
|
-
key_dir.chmod(0o700)
|
|
60
|
+
ensure_private_directory(key_dir)
|
|
964
61
|
host_key_path = key_dir / "ssh_host_ed25519_key"
|
|
965
62
|
client_key_path = key_dir / "client_ed25519_key"
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
client_key_path.chmod(0o600)
|
|
977
|
-
public_key = (key_dir / "client_ed25519_key.pub").read_text(encoding="utf-8").strip()
|
|
63
|
+
for path in (host_key_path, client_key_path):
|
|
64
|
+
if path.exists():
|
|
65
|
+
validate_private_file(path)
|
|
66
|
+
continue
|
|
67
|
+
private_key = asyncssh.generate_private_key("ssh-ed25519")
|
|
68
|
+
create_private_file(path, private_key.export_private_key())
|
|
69
|
+
client_key = asyncssh.import_private_key(client_key_path.read_bytes())
|
|
70
|
+
public_key_path = key_dir / "client_ed25519_key.pub"
|
|
71
|
+
replace_private_file(public_key_path, client_key.export_public_key())
|
|
72
|
+
public_key = public_key_path.read_text(encoding="utf-8").strip()
|
|
978
73
|
return host_key_path, client_key_path, public_key
|
|
979
74
|
|
|
980
75
|
|
|
@@ -1019,7 +114,9 @@ async def create_server_on_available_port(
|
|
|
1019
114
|
continue
|
|
1020
115
|
if port is not None:
|
|
1021
116
|
raise RuntimeError(f"port {port} is not available on {listen_host}") from last_error
|
|
1022
|
-
raise RuntimeError(
|
|
117
|
+
raise RuntimeError(
|
|
118
|
+
f"no free port found in {DEFAULT_PORT_RANGE.start}-{DEFAULT_PORT_RANGE.stop - 1}"
|
|
119
|
+
) from last_error
|
|
1023
120
|
|
|
1024
121
|
|
|
1025
122
|
def install_ssh_config_block(
|
|
@@ -1085,13 +182,15 @@ def _remove_managed_block(existing: str, begin: str, end: str) -> str:
|
|
|
1085
182
|
stop += len(end)
|
|
1086
183
|
while stop < len(existing) and existing[stop] in "\r\n":
|
|
1087
184
|
stop += 1
|
|
1088
|
-
return
|
|
185
|
+
return (
|
|
186
|
+
existing[:start].rstrip()
|
|
187
|
+
+ ("\n" if existing[:start].strip() and existing[stop:].strip() else "")
|
|
188
|
+
+ existing[stop:].lstrip()
|
|
189
|
+
)
|
|
1089
190
|
|
|
1090
191
|
|
|
1091
192
|
def _write_text_atomic(path: Path, text: str) -> None:
|
|
1092
|
-
|
|
1093
|
-
tmp_path.write_text(text, encoding="utf-8")
|
|
1094
|
-
tmp_path.replace(path)
|
|
193
|
+
replace_private_file(path, text.encode("utf-8"))
|
|
1095
194
|
|
|
1096
195
|
|
|
1097
196
|
class _suppress_chmod_error:
|
|
@@ -1130,7 +229,7 @@ async def serve_mock_ssh(
|
|
|
1130
229
|
if config_path is not None:
|
|
1131
230
|
raise HostBridgeError(
|
|
1132
231
|
"invalid_request",
|
|
1133
|
-
"mock-ssh
|
|
232
|
+
"mock-ssh uses the daemon's config; restart the daemon with the intended config",
|
|
1134
233
|
)
|
|
1135
234
|
key_dir = key_dir or default_key_dir(host_id)
|
|
1136
235
|
ssh_host_alias = ssh_host_alias or default_ssh_host_alias(host_id)
|
|
@@ -1138,36 +237,74 @@ async def serve_mock_ssh(
|
|
|
1138
237
|
client = HostBridgeClient()
|
|
1139
238
|
client.hello()
|
|
1140
239
|
session_factory = HostBridgeSessionFactory(client, host_id, connect_timeout)
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
if install_ssh_config:
|
|
1152
|
-
installed_config_path = install_ssh_config_block(
|
|
1153
|
-
host_alias=ssh_host_alias,
|
|
240
|
+
|
|
241
|
+
async def connect_mux_client() -> MuxRpcClient:
|
|
242
|
+
return await MuxRpcClient.connect_unix(client.socket_path)
|
|
243
|
+
|
|
244
|
+
tunnel_connector = TunnelConnector(host_id, connect_mux_client, connect_timeout=connect_timeout)
|
|
245
|
+
server: asyncssh.SSHAcceptor | None = None
|
|
246
|
+
try:
|
|
247
|
+
await tunnel_connector.start()
|
|
248
|
+
server, selected_port = await create_server_on_available_port(
|
|
249
|
+
server_factory=lambda: HostBridgeSSHServer(public_key, tunnel_connector),
|
|
1154
250
|
listen_host=listen_host,
|
|
1155
|
-
port=
|
|
1156
|
-
|
|
1157
|
-
|
|
251
|
+
port=port,
|
|
252
|
+
server_host_keys=[str(host_key_path)],
|
|
253
|
+
process_factory=HostBridgeSSHProcess(client, session_factory, command_timeout, output_limit),
|
|
254
|
+
sftp_factory=lambda chan: HostBridgeSFTPServer(
|
|
255
|
+
chan,
|
|
256
|
+
client,
|
|
257
|
+
session_factory,
|
|
258
|
+
command_timeout,
|
|
259
|
+
output_limit,
|
|
260
|
+
DEFAULT_SFTP_MAX_OPEN_HANDLES,
|
|
261
|
+
DEFAULT_SFTP_MAX_TEMP_BYTES,
|
|
262
|
+
),
|
|
1158
263
|
)
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
264
|
+
installed_config_path: Path | None = None
|
|
265
|
+
if install_ssh_config:
|
|
266
|
+
installed_config_path = install_ssh_config_block(
|
|
267
|
+
host_alias=ssh_host_alias,
|
|
268
|
+
listen_host=listen_host,
|
|
269
|
+
port=selected_port,
|
|
270
|
+
client_key_path=client_key_path,
|
|
271
|
+
config_path=ssh_config_path,
|
|
272
|
+
)
|
|
273
|
+
print(f"hostbridge mock-ssh forwarding host '{host_id}'", flush=True)
|
|
274
|
+
print(f"listening on ssh://{listen_host}:{selected_port}", flush=True)
|
|
275
|
+
print(f"ssh host alias: {ssh_host_alias}", flush=True)
|
|
276
|
+
if installed_config_path is not None:
|
|
277
|
+
print(f"ssh config: {installed_config_path}", flush=True)
|
|
278
|
+
print(f"identity file: {client_key_path}", flush=True)
|
|
279
|
+
print("supports SSH exec, shell, SFTP, SCP, and TCP forwarding; press Ctrl-C to stop", flush=True)
|
|
1167
280
|
await server.wait_closed()
|
|
1168
281
|
return 0
|
|
1169
282
|
finally:
|
|
1170
|
-
|
|
283
|
+
primary_error = sys.exception()
|
|
284
|
+
cleanup_errors: list[BaseException] = []
|
|
285
|
+
if server is not None:
|
|
286
|
+
try:
|
|
287
|
+
server.close()
|
|
288
|
+
await server.wait_closed()
|
|
289
|
+
except BaseException as exc:
|
|
290
|
+
cleanup_errors.append(exc)
|
|
291
|
+
try:
|
|
292
|
+
await tunnel_connector.close()
|
|
293
|
+
except BaseException as exc:
|
|
294
|
+
cleanup_errors.append(exc)
|
|
295
|
+
try:
|
|
296
|
+
await asyncio.to_thread(session_factory.shutdown)
|
|
297
|
+
except BaseException as exc:
|
|
298
|
+
cleanup_errors.append(exc)
|
|
299
|
+
try:
|
|
300
|
+
await asyncio.to_thread(client.close)
|
|
301
|
+
except BaseException as exc:
|
|
302
|
+
cleanup_errors.append(exc)
|
|
303
|
+
if cleanup_errors:
|
|
304
|
+
if primary_error is None:
|
|
305
|
+
raise cleanup_errors[0]
|
|
306
|
+
for error in cleanup_errors:
|
|
307
|
+
primary_error.add_note(f"mock-ssh cleanup failed: {error!r}")
|
|
1171
308
|
|
|
1172
309
|
|
|
1173
310
|
def run_mock_ssh(**kwargs: object) -> int:
|