@neoline/hostbridge 2.0.3 → 2.0.4
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 +15 -8
- package/bin/hostbridge.js +80 -9
- 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 +1 -1
- package/pyproject.toml +5 -4
- package/src/server_control_mcp/__init__.py +58 -23
- package/src/server_control_mcp/cli.py +1 -1
- package/src/server_control_mcp/client.py +99 -7
- package/src/server_control_mcp/config.py +16 -5
- package/src/server_control_mcp/daemon.py +239 -49
- package/src/server_control_mcp/doctor.py +8 -4
- package/src/server_control_mcp/hosts.py +118 -24
- package/src/server_control_mcp/mock_ssh.py +53 -954
- package/src/server_control_mcp/mock_ssh_exec.py +210 -0
- package/src/server_control_mcp/mock_ssh_session.py +68 -0
- package/src/server_control_mcp/mock_ssh_sftp.py +506 -0
- package/src/server_control_mcp/mock_ssh_tunnel.py +592 -0
- package/src/server_control_mcp/policy.py +86 -14
- package/src/server_control_mcp/protocol.py +18 -17
- package/src/server_control_mcp/remote_task_agent.py +102 -0
- package/src/server_control_mcp/remote_tunnel_agent.py +143 -0
- package/src/server_control_mcp/runtime.py +3 -2
- 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 +30 -12
- package/src/server_control_mcp/services.py +363 -165
- package/src/server_control_mcp/transports/pty.py +114 -33
- package/src/server_control_mcp/transports/ssh.py +308 -24
|
@@ -1,115 +1,28 @@
|
|
|
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
|
-
import sys
|
|
11
|
-
import tempfile
|
|
12
|
-
import threading
|
|
13
|
-
import time
|
|
14
|
-
import uuid
|
|
15
|
-
from collections import deque
|
|
16
4
|
from collections.abc import Awaitable
|
|
17
|
-
from contextlib import suppress
|
|
18
|
-
from dataclasses import dataclass, field
|
|
19
|
-
from functools import partial
|
|
20
5
|
from pathlib import Path
|
|
21
6
|
from typing import Any, cast
|
|
22
7
|
|
|
23
8
|
import asyncssh
|
|
24
9
|
|
|
25
10
|
from .client import HostBridgeClient, HostBridgeError
|
|
11
|
+
from .mock_ssh_exec import HostBridgeSSHProcess
|
|
12
|
+
from .mock_ssh_session import HostBridgeSessionFactory
|
|
13
|
+
from .mock_ssh_sftp import HostBridgeSFTPServer
|
|
14
|
+
from .mock_ssh_tunnel import TunnelConnector
|
|
15
|
+
from .secure_files import create_private_file, ensure_private_directory, replace_private_file, validate_private_file
|
|
26
16
|
|
|
27
17
|
DEFAULT_PORT_RANGE = range(2222, 2300)
|
|
28
18
|
DEFAULT_COMMAND_TIMEOUT = 60
|
|
29
19
|
DEFAULT_MAX_TRANSFER_BYTES = 1024 * 1024 * 1024
|
|
30
|
-
EXEC_STDIN_PROBE_TIMEOUT = 0.05
|
|
31
|
-
STDIN_READING_COMMANDS = [
|
|
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))
|
|
107
20
|
|
|
108
21
|
|
|
109
22
|
class HostBridgeSSHServer(asyncssh.SSHServer):
|
|
110
|
-
def __init__(self, authorized_public_key: str,
|
|
23
|
+
def __init__(self, authorized_public_key: str, tunnel_connector: TunnelConnector | None = None) -> None:
|
|
111
24
|
self._authorized_public_key = authorized_public_key.strip()
|
|
112
|
-
self.
|
|
25
|
+
self._tunnel_connector = tunnel_connector
|
|
113
26
|
|
|
114
27
|
def begin_auth(self, username: str) -> bool: # noqa: ARG002
|
|
115
28
|
return True
|
|
@@ -130,851 +43,29 @@ class HostBridgeSSHServer(asyncssh.SSHServer):
|
|
|
130
43
|
orig_host: str, # noqa: ARG002
|
|
131
44
|
orig_port: int, # noqa: ARG002
|
|
132
45
|
) -> Awaitable[asyncssh.SSHTCPSession[bytes]] | bool:
|
|
133
|
-
if self.
|
|
46
|
+
if self._tunnel_connector is None:
|
|
134
47
|
return False
|
|
135
48
|
try:
|
|
136
|
-
self.
|
|
49
|
+
self._tunnel_connector.validate_destination(dest_host, dest_port)
|
|
137
50
|
except ValueError as exc:
|
|
138
51
|
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)
|
|
52
|
+
return self._tunnel_connector.connect(dest_host, dest_port)
|
|
958
53
|
|
|
959
54
|
|
|
960
55
|
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)
|
|
56
|
+
ensure_private_directory(key_dir)
|
|
964
57
|
host_key_path = key_dir / "ssh_host_ed25519_key"
|
|
965
58
|
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()
|
|
59
|
+
for path in (host_key_path, client_key_path):
|
|
60
|
+
if path.exists():
|
|
61
|
+
validate_private_file(path)
|
|
62
|
+
continue
|
|
63
|
+
private_key = asyncssh.generate_private_key("ssh-ed25519")
|
|
64
|
+
create_private_file(path, private_key.export_private_key())
|
|
65
|
+
client_key = asyncssh.import_private_key(client_key_path.read_bytes())
|
|
66
|
+
public_key_path = key_dir / "client_ed25519_key.pub"
|
|
67
|
+
replace_private_file(public_key_path, client_key.export_public_key())
|
|
68
|
+
public_key = public_key_path.read_text(encoding="utf-8").strip()
|
|
978
69
|
return host_key_path, client_key_path, public_key
|
|
979
70
|
|
|
980
71
|
|
|
@@ -1019,7 +110,9 @@ async def create_server_on_available_port(
|
|
|
1019
110
|
continue
|
|
1020
111
|
if port is not None:
|
|
1021
112
|
raise RuntimeError(f"port {port} is not available on {listen_host}") from last_error
|
|
1022
|
-
raise RuntimeError(
|
|
113
|
+
raise RuntimeError(
|
|
114
|
+
f"no free port found in {DEFAULT_PORT_RANGE.start}-{DEFAULT_PORT_RANGE.stop - 1}"
|
|
115
|
+
) from last_error
|
|
1023
116
|
|
|
1024
117
|
|
|
1025
118
|
def install_ssh_config_block(
|
|
@@ -1085,13 +178,15 @@ def _remove_managed_block(existing: str, begin: str, end: str) -> str:
|
|
|
1085
178
|
stop += len(end)
|
|
1086
179
|
while stop < len(existing) and existing[stop] in "\r\n":
|
|
1087
180
|
stop += 1
|
|
1088
|
-
return
|
|
181
|
+
return (
|
|
182
|
+
existing[:start].rstrip()
|
|
183
|
+
+ ("\n" if existing[:start].strip() and existing[stop:].strip() else "")
|
|
184
|
+
+ existing[stop:].lstrip()
|
|
185
|
+
)
|
|
1089
186
|
|
|
1090
187
|
|
|
1091
188
|
def _write_text_atomic(path: Path, text: str) -> None:
|
|
1092
|
-
|
|
1093
|
-
tmp_path.write_text(text, encoding="utf-8")
|
|
1094
|
-
tmp_path.replace(path)
|
|
189
|
+
replace_private_file(path, text.encode("utf-8"))
|
|
1095
190
|
|
|
1096
191
|
|
|
1097
192
|
class _suppress_chmod_error:
|
|
@@ -1138,36 +233,40 @@ async def serve_mock_ssh(
|
|
|
1138
233
|
client = HostBridgeClient()
|
|
1139
234
|
client.hello()
|
|
1140
235
|
session_factory = HostBridgeSessionFactory(client, host_id, connect_timeout)
|
|
1141
|
-
|
|
236
|
+
tunnel_connector = TunnelConnector(client, session_factory, connect_timeout=connect_timeout)
|
|
1142
237
|
server, selected_port = await create_server_on_available_port(
|
|
1143
|
-
server_factory=lambda: HostBridgeSSHServer(public_key,
|
|
238
|
+
server_factory=lambda: HostBridgeSSHServer(public_key, tunnel_connector),
|
|
1144
239
|
listen_host=listen_host,
|
|
1145
240
|
port=port,
|
|
1146
241
|
server_host_keys=[str(host_key_path)],
|
|
1147
242
|
process_factory=HostBridgeSSHProcess(client, session_factory, command_timeout, output_limit),
|
|
1148
243
|
sftp_factory=lambda chan: HostBridgeSFTPServer(chan, client, session_factory, command_timeout, output_limit),
|
|
1149
244
|
)
|
|
1150
|
-
installed_config_path: Path | None = None
|
|
1151
|
-
if install_ssh_config:
|
|
1152
|
-
installed_config_path = install_ssh_config_block(
|
|
1153
|
-
host_alias=ssh_host_alias,
|
|
1154
|
-
listen_host=listen_host,
|
|
1155
|
-
port=selected_port,
|
|
1156
|
-
client_key_path=client_key_path,
|
|
1157
|
-
config_path=ssh_config_path,
|
|
1158
|
-
)
|
|
1159
|
-
print(f"hostbridge mock-ssh forwarding host '{host_id}'", flush=True)
|
|
1160
|
-
print(f"listening on ssh://{listen_host}:{selected_port}", flush=True)
|
|
1161
|
-
print(f"ssh host alias: {ssh_host_alias}", flush=True)
|
|
1162
|
-
if installed_config_path is not None:
|
|
1163
|
-
print(f"ssh config: {installed_config_path}", flush=True)
|
|
1164
|
-
print(f"identity file: {client_key_path}", flush=True)
|
|
1165
|
-
print("supports SSH exec, shell, SFTP, SCP, and TCP forwarding; press Ctrl-C to stop", flush=True)
|
|
1166
245
|
try:
|
|
246
|
+
installed_config_path: Path | None = None
|
|
247
|
+
if install_ssh_config:
|
|
248
|
+
installed_config_path = install_ssh_config_block(
|
|
249
|
+
host_alias=ssh_host_alias,
|
|
250
|
+
listen_host=listen_host,
|
|
251
|
+
port=selected_port,
|
|
252
|
+
client_key_path=client_key_path,
|
|
253
|
+
config_path=ssh_config_path,
|
|
254
|
+
)
|
|
255
|
+
print(f"hostbridge mock-ssh forwarding host '{host_id}'", flush=True)
|
|
256
|
+
print(f"listening on ssh://{listen_host}:{selected_port}", flush=True)
|
|
257
|
+
print(f"ssh host alias: {ssh_host_alias}", flush=True)
|
|
258
|
+
if installed_config_path is not None:
|
|
259
|
+
print(f"ssh config: {installed_config_path}", flush=True)
|
|
260
|
+
print(f"identity file: {client_key_path}", flush=True)
|
|
261
|
+
print("supports SSH exec, shell, SFTP, SCP, and TCP forwarding; press Ctrl-C to stop", flush=True)
|
|
1167
262
|
await server.wait_closed()
|
|
1168
263
|
return 0
|
|
1169
264
|
finally:
|
|
1170
|
-
|
|
265
|
+
try:
|
|
266
|
+
server.close()
|
|
267
|
+
await server.wait_closed()
|
|
268
|
+
finally:
|
|
269
|
+
session_factory.shutdown()
|
|
1171
270
|
|
|
1172
271
|
|
|
1173
272
|
def run_mock_ssh(**kwargs: object) -> int:
|