@neoline/hostbridge 2.0.2 → 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 +62 -605
- 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,54 +1,28 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
import asyncio
|
|
4
|
-
import
|
|
5
|
-
import posixpath
|
|
6
|
-
import re
|
|
7
|
-
import shlex
|
|
8
|
-
import sys
|
|
9
|
-
import tempfile
|
|
10
|
-
import threading
|
|
11
|
-
import time
|
|
12
|
-
from contextlib import suppress
|
|
13
|
-
from dataclasses import dataclass, field
|
|
14
|
-
from functools import partial
|
|
4
|
+
from collections.abc import Awaitable
|
|
15
5
|
from pathlib import Path
|
|
16
6
|
from typing import Any, cast
|
|
17
7
|
|
|
18
8
|
import asyncssh
|
|
19
9
|
|
|
20
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
|
|
21
16
|
|
|
22
17
|
DEFAULT_PORT_RANGE = range(2222, 2300)
|
|
23
18
|
DEFAULT_COMMAND_TIMEOUT = 60
|
|
24
19
|
DEFAULT_MAX_TRANSFER_BYTES = 1024 * 1024 * 1024
|
|
25
|
-
EXEC_STDIN_PROBE_TIMEOUT = 0.05
|
|
26
|
-
STDIN_READING_COMMANDS = [
|
|
27
|
-
re.compile(r"\bcat\s*(?:>|>>)"),
|
|
28
|
-
re.compile(r"\btee(?:\s|$)"),
|
|
29
|
-
re.compile(r"(?:^|\s)(?:/bin/)?(?:sh|bash|dash|zsh)\b[^\n;]*\s-s(?:\s|$)"),
|
|
30
|
-
re.compile(r"(?:^|\s)(?:python[\d.]*|node)(?:\s+\S+)*\s+-($|\s)"),
|
|
31
|
-
re.compile(r"(?:^|\s)tar\s+(?:-[A-Za-z]*x[A-Za-z]*|[A-Za-z]*x[A-Za-z]*)(?:\s|$)"),
|
|
32
|
-
re.compile(r"(?:^|\s)tar\s+.*(?:^|\s)f\s*-(?:\s|$)"),
|
|
33
|
-
re.compile(r"(?:^|\s)dd\s+.*\bof="),
|
|
34
|
-
re.compile(r"(?:^|\s)scp(?:\s+\S+)*\s+-[A-Za-z]*(?:t|f)[A-Za-z]*(?:\s|$)"),
|
|
35
|
-
]
|
|
36
|
-
MAX_IDLE_BACKEND_SESSIONS = 4
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
def _to_bytes(chunk: str | bytes) -> bytes:
|
|
40
|
-
if isinstance(chunk, bytes):
|
|
41
|
-
return chunk
|
|
42
|
-
return chunk.encode("utf-8")
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
def _command_reads_stdin(command: str | None) -> bool:
|
|
46
|
-
return bool(command and any(pattern.search(command) for pattern in STDIN_READING_COMMANDS))
|
|
47
20
|
|
|
48
21
|
|
|
49
22
|
class HostBridgeSSHServer(asyncssh.SSHServer):
|
|
50
|
-
def __init__(self, authorized_public_key: str) -> None:
|
|
23
|
+
def __init__(self, authorized_public_key: str, tunnel_connector: TunnelConnector | None = None) -> None:
|
|
51
24
|
self._authorized_public_key = authorized_public_key.strip()
|
|
25
|
+
self._tunnel_connector = tunnel_connector
|
|
52
26
|
|
|
53
27
|
def begin_auth(self, username: str) -> bool: # noqa: ARG002
|
|
54
28
|
return True
|
|
@@ -62,562 +36,36 @@ class HostBridgeSSHServer(asyncssh.SSHServer):
|
|
|
62
36
|
def validate_public_key(self, username: str, key: asyncssh.SSHKey) -> bool: # noqa: ARG002
|
|
63
37
|
return key.export_public_key().decode("utf-8").strip() == self._authorized_public_key
|
|
64
38
|
|
|
65
|
-
|
|
66
|
-
class HostBridgeSSHProcess:
|
|
67
|
-
def __init__(self, client: HostBridgeClient, session_factory: HostBridgeSessionFactory, timeout: int, output_limit: int) -> None:
|
|
68
|
-
self._client = client
|
|
69
|
-
self._session_factory = session_factory
|
|
70
|
-
self._timeout = timeout
|
|
71
|
-
self._output_limit = output_limit
|
|
72
|
-
|
|
73
|
-
async def __call__(self, process: asyncssh.SSHServerProcess[bytes]) -> None:
|
|
74
|
-
session_id = self._session_factory.open()
|
|
75
|
-
reusable = False
|
|
76
|
-
try:
|
|
77
|
-
reusable = await self._run(process, session_id)
|
|
78
|
-
except asyncio.CancelledError:
|
|
79
|
-
self._set_exit_status(process, 255)
|
|
80
|
-
return
|
|
81
|
-
finally:
|
|
82
|
-
self._session_factory.close(session_id, reusable=reusable)
|
|
83
|
-
|
|
84
|
-
async def _run(self, process: asyncssh.SSHServerProcess[bytes], session_id: str) -> bool:
|
|
85
|
-
command = process.command
|
|
86
|
-
if not command:
|
|
87
|
-
await self._run_shell(process, session_id)
|
|
88
|
-
return False
|
|
89
|
-
|
|
90
|
-
loop = asyncio.get_running_loop()
|
|
91
|
-
try:
|
|
92
|
-
stdin_data = await self._read_exec_stdin(process, command)
|
|
93
|
-
result = await loop.run_in_executor(
|
|
94
|
-
None,
|
|
95
|
-
lambda: self._client.exec(
|
|
96
|
-
session_id,
|
|
97
|
-
command,
|
|
98
|
-
stdin=stdin_data,
|
|
99
|
-
timeout=self._timeout,
|
|
100
|
-
output_limit=self._output_limit,
|
|
101
|
-
owner="mock-ssh",
|
|
102
|
-
),
|
|
103
|
-
)
|
|
104
|
-
if result.stdout:
|
|
105
|
-
process.stdout.write(result.stdout)
|
|
106
|
-
if result.stderr:
|
|
107
|
-
process.stderr.write(result.stderr)
|
|
108
|
-
self._set_exit_status(process, 124 if result.timed_out else int(result.exit_code or 0))
|
|
109
|
-
return not result.timed_out
|
|
110
|
-
except Exception as exc:
|
|
111
|
-
print(f"hostbridge mock-ssh exec failed: {exc}", file=sys.stderr, flush=True)
|
|
112
|
-
with suppress(Exception):
|
|
113
|
-
process.stderr.write(f"hostbridge mock-ssh error: {exc}\n".encode("utf-8", errors="replace"))
|
|
114
|
-
status = 124 if isinstance(exc, HostBridgeError) and exc.code == "timeout" else 1
|
|
115
|
-
self._set_exit_status(process, status)
|
|
116
|
-
return False
|
|
117
|
-
|
|
118
|
-
@staticmethod
|
|
119
|
-
def _set_exit_status(process: asyncssh.SSHServerProcess[bytes], status: int) -> None:
|
|
120
|
-
with suppress(Exception):
|
|
121
|
-
process.exit(status)
|
|
122
|
-
|
|
123
|
-
async def _read_exec_stdin(self, process: asyncssh.SSHServerProcess[bytes], command: str) -> bytes | None:
|
|
124
|
-
waits_for_eof = _command_reads_stdin(command)
|
|
125
|
-
if process.stdin.at_eof():
|
|
126
|
-
return b"" if waits_for_eof else None
|
|
127
|
-
try:
|
|
128
|
-
first_chunk = await asyncio.wait_for(
|
|
129
|
-
process.stdin.read(4096),
|
|
130
|
-
timeout=self._timeout if waits_for_eof else EXEC_STDIN_PROBE_TIMEOUT,
|
|
131
|
-
)
|
|
132
|
-
except TimeoutError:
|
|
133
|
-
if waits_for_eof:
|
|
134
|
-
raise HostBridgeError("timeout", "timed out waiting for mock-ssh exec stdin") from None
|
|
135
|
-
return None
|
|
136
|
-
if not first_chunk:
|
|
137
|
-
return b"" if waits_for_eof else None
|
|
138
|
-
|
|
139
|
-
first_chunk_bytes = _to_bytes(first_chunk)
|
|
140
|
-
total = len(first_chunk_bytes)
|
|
141
|
-
if total > self._output_limit:
|
|
142
|
-
raise HostBridgeError("resource_limit", f"mock-ssh exec stdin exceeds limit ({self._output_limit} bytes)")
|
|
143
|
-
chunks = [first_chunk_bytes]
|
|
144
|
-
while not process.stdin.at_eof():
|
|
145
|
-
chunk = await asyncio.wait_for(process.stdin.read(4096), timeout=self._timeout)
|
|
146
|
-
if not chunk:
|
|
147
|
-
break
|
|
148
|
-
chunk_bytes = _to_bytes(chunk)
|
|
149
|
-
total += len(chunk_bytes)
|
|
150
|
-
if total > self._output_limit:
|
|
151
|
-
raise HostBridgeError("resource_limit", f"mock-ssh exec stdin exceeds limit ({self._output_limit} bytes)")
|
|
152
|
-
chunks.append(chunk_bytes)
|
|
153
|
-
return b"".join(chunks)
|
|
154
|
-
|
|
155
|
-
async def _run_shell(self, process: asyncssh.SSHServerProcess[bytes], session_id: str) -> None:
|
|
156
|
-
loop = asyncio.get_running_loop()
|
|
157
|
-
stop = asyncio.Event()
|
|
158
|
-
input_done = asyncio.Event()
|
|
159
|
-
|
|
160
|
-
async def pump_input() -> None:
|
|
161
|
-
try:
|
|
162
|
-
while not process.stdin.at_eof():
|
|
163
|
-
data = await process.stdin.read(4096)
|
|
164
|
-
if not data:
|
|
165
|
-
break
|
|
166
|
-
await loop.run_in_executor(
|
|
167
|
-
None,
|
|
168
|
-
partial(
|
|
169
|
-
self._client.shell_write,
|
|
170
|
-
session_id,
|
|
171
|
-
_to_bytes(data),
|
|
172
|
-
owner="mock-ssh",
|
|
173
|
-
),
|
|
174
|
-
)
|
|
175
|
-
finally:
|
|
176
|
-
input_done.set()
|
|
177
|
-
|
|
178
|
-
async def pump_output() -> None:
|
|
179
|
-
try:
|
|
180
|
-
idle_after_input = 0
|
|
181
|
-
while not stop.is_set():
|
|
182
|
-
result = await loop.run_in_executor(
|
|
183
|
-
None,
|
|
184
|
-
lambda: self._client.shell_read(
|
|
185
|
-
session_id,
|
|
186
|
-
timeout=0.2,
|
|
187
|
-
max_bytes=4096,
|
|
188
|
-
owner="mock-ssh",
|
|
189
|
-
),
|
|
190
|
-
)
|
|
191
|
-
if result.data:
|
|
192
|
-
idle_after_input = 0
|
|
193
|
-
process.stdout.write(result.data)
|
|
194
|
-
elif input_done.is_set():
|
|
195
|
-
idle_after_input += 1
|
|
196
|
-
if idle_after_input >= 5:
|
|
197
|
-
stop.set()
|
|
198
|
-
break
|
|
199
|
-
if not result.alive:
|
|
200
|
-
stop.set()
|
|
201
|
-
break
|
|
202
|
-
except Exception as exc:
|
|
203
|
-
print(f"hostbridge mock-ssh shell failed: {exc}", file=sys.stderr, flush=True)
|
|
204
|
-
with suppress(Exception):
|
|
205
|
-
process.stderr.write(f"hostbridge mock-ssh shell error: {exc}\n".encode("utf-8", errors="replace"))
|
|
206
|
-
stop.set()
|
|
207
|
-
|
|
208
|
-
tasks = [asyncio.create_task(pump_input()), asyncio.create_task(pump_output())]
|
|
209
|
-
await stop.wait()
|
|
210
|
-
for task in tasks:
|
|
211
|
-
task.cancel()
|
|
212
|
-
await asyncio.gather(*tasks, return_exceptions=True)
|
|
213
|
-
self._set_exit_status(process, 0)
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
@dataclass
|
|
217
|
-
class HostBridgeSFTPHandle:
|
|
218
|
-
path: str
|
|
219
|
-
readable: bool = False
|
|
220
|
-
writable: bool = False
|
|
221
|
-
data: bytes = b""
|
|
222
|
-
writes: dict[int, bytes] = field(default_factory=dict)
|
|
223
|
-
mode: int = 0o644
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
@dataclass(frozen=True, slots=True)
|
|
227
|
-
class _TextCommandResult:
|
|
228
|
-
output: str
|
|
229
|
-
exit_code: int | None
|
|
230
|
-
timed_out: bool
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
class HostBridgeSessionFactory:
|
|
234
|
-
def __init__(self, client: HostBridgeClient, host_id: str, connect_timeout: int = 60) -> None:
|
|
235
|
-
self._client = client
|
|
236
|
-
self._host_id = host_id
|
|
237
|
-
self._connect_timeout = connect_timeout
|
|
238
|
-
self._lock = threading.Lock()
|
|
239
|
-
self._idle: list[str] = []
|
|
240
|
-
self._leased: set[str] = set()
|
|
241
|
-
self._shutdown = False
|
|
242
|
-
|
|
243
|
-
def open(self) -> str:
|
|
244
|
-
with self._lock:
|
|
245
|
-
if self._shutdown:
|
|
246
|
-
raise RuntimeError("mock-ssh session factory is shut down")
|
|
247
|
-
if self._idle:
|
|
248
|
-
session_id = self._idle.pop()
|
|
249
|
-
self._leased.add(session_id)
|
|
250
|
-
return session_id
|
|
251
|
-
session = self._client.open_session(
|
|
252
|
-
self._host_id,
|
|
253
|
-
owner="mock-ssh",
|
|
254
|
-
timeout=self._connect_timeout + 5,
|
|
255
|
-
)
|
|
256
|
-
session_id = session.session_id
|
|
257
|
-
with self._lock:
|
|
258
|
-
if not self._shutdown:
|
|
259
|
-
self._leased.add(session_id)
|
|
260
|
-
return session_id
|
|
261
|
-
self._close_backend(session_id)
|
|
262
|
-
raise RuntimeError("mock-ssh session factory is shut down")
|
|
263
|
-
|
|
264
|
-
def close(self, session_id: str, *, reusable: bool = True) -> None:
|
|
265
|
-
should_close = False
|
|
266
|
-
with self._lock:
|
|
267
|
-
if session_id not in self._leased:
|
|
268
|
-
return
|
|
269
|
-
self._leased.remove(session_id)
|
|
270
|
-
if reusable and not self._shutdown and len(self._idle) < MAX_IDLE_BACKEND_SESSIONS:
|
|
271
|
-
self._idle.append(session_id)
|
|
272
|
-
else:
|
|
273
|
-
should_close = True
|
|
274
|
-
if not should_close:
|
|
275
|
-
return
|
|
276
|
-
self._close_backend(session_id)
|
|
277
|
-
|
|
278
|
-
def shutdown(self) -> None:
|
|
279
|
-
with self._lock:
|
|
280
|
-
self._shutdown = True
|
|
281
|
-
session_ids = [*self._idle, *self._leased]
|
|
282
|
-
self._idle.clear()
|
|
283
|
-
self._leased.clear()
|
|
284
|
-
for session_id in session_ids:
|
|
285
|
-
self._close_backend(session_id)
|
|
286
|
-
|
|
287
|
-
def _close_backend(self, session_id: str) -> None:
|
|
288
|
-
last_error: Exception | None = None
|
|
289
|
-
for attempt in range(2):
|
|
290
|
-
try:
|
|
291
|
-
self._client.close_session(session_id, owner="mock-ssh", force=True)
|
|
292
|
-
return
|
|
293
|
-
except HostBridgeError as exc:
|
|
294
|
-
if exc.code == "not_found":
|
|
295
|
-
return
|
|
296
|
-
last_error = exc
|
|
297
|
-
except Exception as exc:
|
|
298
|
-
last_error = exc
|
|
299
|
-
if attempt == 0:
|
|
300
|
-
time.sleep(0.05)
|
|
301
|
-
print(
|
|
302
|
-
f"hostbridge mock-ssh failed to close backend session {session_id}: {last_error}",
|
|
303
|
-
file=sys.stderr,
|
|
304
|
-
flush=True,
|
|
305
|
-
)
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
class HostBridgeSFTPServer(asyncssh.SFTPServer):
|
|
309
|
-
def __init__(
|
|
39
|
+
def connection_requested(
|
|
310
40
|
self,
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
self._client = client
|
|
319
|
-
self._session_factory = session_factory
|
|
320
|
-
self._session_id = session_factory.open()
|
|
321
|
-
self._timeout = timeout
|
|
322
|
-
self._max_bytes = max_bytes
|
|
323
|
-
|
|
324
|
-
def exit(self) -> None:
|
|
325
|
-
self._session_factory.close(self._session_id)
|
|
326
|
-
|
|
327
|
-
def open(self, path: bytes, pflags: int, attrs: asyncssh.SFTPAttrs) -> HostBridgeSFTPHandle:
|
|
328
|
-
remote_path = self._path(path)
|
|
329
|
-
readable = bool(pflags & asyncssh.FXF_READ)
|
|
330
|
-
writable = bool(pflags & asyncssh.FXF_WRITE)
|
|
331
|
-
mode = attrs.permissions if attrs.permissions is not None else 0o644
|
|
332
|
-
data = b""
|
|
333
|
-
if readable or (writable and not (pflags & asyncssh.FXF_TRUNC) and not (pflags & asyncssh.FXF_CREAT)):
|
|
334
|
-
try:
|
|
335
|
-
data = self._download(remote_path)
|
|
336
|
-
except asyncssh.SFTPNoSuchFile:
|
|
337
|
-
if not writable:
|
|
338
|
-
raise
|
|
339
|
-
data = b""
|
|
340
|
-
return HostBridgeSFTPHandle(remote_path, readable=readable, writable=writable, data=data, mode=mode)
|
|
341
|
-
|
|
342
|
-
def read(self, file_obj: object, offset: int, size: int) -> bytes:
|
|
343
|
-
handle = self._handle(file_obj)
|
|
344
|
-
if not handle.readable:
|
|
345
|
-
raise asyncssh.SFTPPermissionDenied("file is not open for reading")
|
|
346
|
-
return handle.data[offset : offset + size]
|
|
347
|
-
|
|
348
|
-
def write(self, file_obj: object, offset: int, data: bytes) -> int:
|
|
349
|
-
handle = self._handle(file_obj)
|
|
350
|
-
if not handle.writable:
|
|
351
|
-
raise asyncssh.SFTPPermissionDenied("file is not open for writing")
|
|
352
|
-
handle.writes[int(offset)] = bytes(data)
|
|
353
|
-
return len(data)
|
|
354
|
-
|
|
355
|
-
def close(self, file_obj: object) -> None:
|
|
356
|
-
handle = self._handle(file_obj)
|
|
357
|
-
if handle.writable:
|
|
358
|
-
self._upload(handle)
|
|
359
|
-
|
|
360
|
-
def stat(self, path: bytes) -> asyncssh.SFTPAttrs:
|
|
361
|
-
return self._stat(self._path(path), follow=True)
|
|
362
|
-
|
|
363
|
-
def lstat(self, path: bytes) -> asyncssh.SFTPAttrs:
|
|
364
|
-
return self._stat(self._path(path), follow=False)
|
|
365
|
-
|
|
366
|
-
def fstat(self, file_obj: object) -> asyncssh.SFTPAttrs:
|
|
367
|
-
handle = self._handle(file_obj)
|
|
368
|
-
if handle.writable and handle.writes:
|
|
369
|
-
size = len(handle.data)
|
|
370
|
-
for offset, chunk in handle.writes.items():
|
|
371
|
-
size = max(size, offset + len(chunk))
|
|
372
|
-
return asyncssh.SFTPAttrs(size=size, permissions=handle.mode, type=asyncssh.FILEXFER_TYPE_REGULAR)
|
|
373
|
-
return self._stat(handle.path, follow=True)
|
|
374
|
-
|
|
375
|
-
def fsetstat(self, file_obj: object, attrs: asyncssh.SFTPAttrs) -> None:
|
|
376
|
-
handle = self._handle(file_obj)
|
|
377
|
-
if attrs.permissions is not None:
|
|
378
|
-
handle.mode = attrs.permissions
|
|
379
|
-
if attrs.atime is not None or attrs.mtime is not None:
|
|
380
|
-
# Applied after upload in close(); the handle may not exist remotely yet.
|
|
381
|
-
return
|
|
382
|
-
|
|
383
|
-
async def scandir(self, path: bytes):
|
|
384
|
-
for item in self._listdir(self._path(path)):
|
|
385
|
-
yield item
|
|
386
|
-
|
|
387
|
-
def mkdir(self, path: bytes, attrs: asyncssh.SFTPAttrs) -> None:
|
|
388
|
-
mode = attrs.permissions if attrs.permissions is not None else 0o755
|
|
389
|
-
self._checked_run(f"mkdir -p -m {shlex.quote(format(mode & 0o7777, 'o'))} -- {shlex.quote(self._path(path))}")
|
|
390
|
-
|
|
391
|
-
def rmdir(self, path: bytes) -> None:
|
|
392
|
-
self._checked_run(f"rmdir -- {shlex.quote(self._path(path))}")
|
|
393
|
-
|
|
394
|
-
def remove(self, path: bytes) -> None:
|
|
395
|
-
self._checked_run(f"rm -f -- {shlex.quote(self._path(path))}")
|
|
396
|
-
|
|
397
|
-
def rename(self, oldpath: bytes, newpath: bytes) -> None:
|
|
398
|
-
self._checked_run(f"mv -- {shlex.quote(self._path(oldpath))} {shlex.quote(self._path(newpath))}")
|
|
399
|
-
|
|
400
|
-
def realpath(self, path: bytes) -> bytes:
|
|
401
|
-
script = (
|
|
402
|
-
"import os,sys; "
|
|
403
|
-
"path=sys.argv[1]; "
|
|
404
|
-
"parent=os.path.dirname(path) or '/'; "
|
|
405
|
-
"base=os.path.basename(path); "
|
|
406
|
-
"print(os.path.join(os.path.realpath(parent), base) if not os.path.exists(path) else os.path.realpath(path))"
|
|
407
|
-
)
|
|
408
|
-
command = "python3 -c " + shlex.quote(script) + " " + shlex.quote(self._path(path))
|
|
409
|
-
result = self._checked_run(command)
|
|
410
|
-
return (result.output.strip() or self._path(path)).encode("utf-8")
|
|
411
|
-
|
|
412
|
-
def setstat(self, path: bytes, attrs: asyncssh.SFTPAttrs) -> None:
|
|
413
|
-
remote_path = self._path(path)
|
|
414
|
-
if attrs.permissions is not None:
|
|
415
|
-
self._checked_run(f"chmod {shlex.quote(format(attrs.permissions & 0o7777, 'o'))} -- {shlex.quote(remote_path)}")
|
|
416
|
-
if attrs.atime is not None or attrs.mtime is not None:
|
|
417
|
-
script = "import os,sys; os.utime(sys.argv[1], (int(sys.argv[2]), int(sys.argv[3])))"
|
|
418
|
-
current = self._stat(remote_path, follow=True)
|
|
419
|
-
atime = int(attrs.atime if attrs.atime is not None else current.atime or time.time())
|
|
420
|
-
mtime = int(attrs.mtime if attrs.mtime is not None else current.mtime or time.time())
|
|
421
|
-
self._checked_run(f"python3 -c {shlex.quote(script)} {shlex.quote(remote_path)} {atime} {mtime}")
|
|
422
|
-
|
|
423
|
-
def _download(self, remote_path: str) -> bytes:
|
|
424
|
-
temporary_path: Path | None = None
|
|
425
|
-
try:
|
|
426
|
-
with tempfile.NamedTemporaryFile(prefix="hostbridge-sftp-", delete=False) as temporary:
|
|
427
|
-
temporary_path = Path(temporary.name)
|
|
428
|
-
result = self._client.download_file(
|
|
429
|
-
self._session_id,
|
|
430
|
-
remote_path,
|
|
431
|
-
temporary_path,
|
|
432
|
-
timeout=self._timeout,
|
|
433
|
-
owner="mock-ssh",
|
|
434
|
-
)
|
|
435
|
-
if result.bytes_transferred > self._max_bytes:
|
|
436
|
-
raise HostBridgeError("resource_limit", f"file exceeds mock-ssh limit ({self._max_bytes} bytes)")
|
|
437
|
-
return temporary_path.read_bytes()
|
|
438
|
-
except HostBridgeError as exc:
|
|
439
|
-
raise self._sftp_error(exc) from exc
|
|
440
|
-
finally:
|
|
441
|
-
if temporary_path is not None:
|
|
442
|
-
temporary_path.unlink(missing_ok=True)
|
|
443
|
-
|
|
444
|
-
def _upload(self, handle: HostBridgeSFTPHandle) -> None:
|
|
445
|
-
size = len(handle.data)
|
|
446
|
-
for offset, chunk in handle.writes.items():
|
|
447
|
-
size = max(size, offset + len(chunk))
|
|
448
|
-
data = bytearray(handle.data)
|
|
449
|
-
if len(data) < size:
|
|
450
|
-
data.extend(b"\x00" * (size - len(data)))
|
|
451
|
-
for offset, chunk in sorted(handle.writes.items()):
|
|
452
|
-
data[offset : offset + len(chunk)] = chunk
|
|
453
|
-
if len(data) > self._max_bytes:
|
|
454
|
-
raise asyncssh.SFTPFailure(f"file exceeds mock-ssh limit ({self._max_bytes} bytes)")
|
|
455
|
-
temporary_path: Path | None = None
|
|
456
|
-
try:
|
|
457
|
-
with tempfile.NamedTemporaryFile(prefix="hostbridge-sftp-", delete=False) as temporary:
|
|
458
|
-
temporary.write(data)
|
|
459
|
-
temporary_path = Path(temporary.name)
|
|
460
|
-
self._client.upload_file(
|
|
461
|
-
self._session_id,
|
|
462
|
-
temporary_path,
|
|
463
|
-
handle.path,
|
|
464
|
-
mode=handle.mode & 0o7777,
|
|
465
|
-
timeout=self._timeout,
|
|
466
|
-
owner="mock-ssh",
|
|
467
|
-
)
|
|
468
|
-
except HostBridgeError as exc:
|
|
469
|
-
print(f"hostbridge mock-ssh sftp upload failed: {exc}", file=sys.stderr, flush=True)
|
|
470
|
-
raise self._sftp_error(exc) from exc
|
|
471
|
-
finally:
|
|
472
|
-
if temporary_path is not None:
|
|
473
|
-
temporary_path.unlink(missing_ok=True)
|
|
474
|
-
|
|
475
|
-
def _stat(self, remote_path: str, *, follow: bool) -> asyncssh.SFTPAttrs:
|
|
476
|
-
script = r"""
|
|
477
|
-
import json, os, stat, sys
|
|
478
|
-
path = sys.argv[1]
|
|
479
|
-
st = os.stat(path) if sys.argv[2] == "1" else os.lstat(path)
|
|
480
|
-
print(json.dumps({
|
|
481
|
-
"size": st.st_size,
|
|
482
|
-
"permissions": st.st_mode,
|
|
483
|
-
"uid": st.st_uid,
|
|
484
|
-
"gid": st.st_gid,
|
|
485
|
-
"atime": int(st.st_atime),
|
|
486
|
-
"mtime": int(st.st_mtime),
|
|
487
|
-
"type": "dir" if stat.S_ISDIR(st.st_mode) else "link" if stat.S_ISLNK(st.st_mode) else "file",
|
|
488
|
-
}))
|
|
489
|
-
"""
|
|
490
|
-
result = self._checked_run(f"python3 -c {shlex.quote(script)} {shlex.quote(remote_path)} {'1' if follow else '0'}")
|
|
491
|
-
return self._attrs(json.loads(result.output))
|
|
492
|
-
|
|
493
|
-
def _listdir(self, remote_path: str) -> list[asyncssh.SFTPName]:
|
|
494
|
-
script = r"""
|
|
495
|
-
import json, os, stat, sys, time
|
|
496
|
-
path = sys.argv[1]
|
|
497
|
-
items = []
|
|
498
|
-
for name in os.listdir(path):
|
|
499
|
-
full = os.path.join(path, name)
|
|
500
|
-
st = os.lstat(full)
|
|
501
|
-
item_type = "dir" if stat.S_ISDIR(st.st_mode) else "link" if stat.S_ISLNK(st.st_mode) else "file"
|
|
502
|
-
items.append({
|
|
503
|
-
"filename": name,
|
|
504
|
-
"longname": name,
|
|
505
|
-
"attrs": {
|
|
506
|
-
"size": st.st_size,
|
|
507
|
-
"permissions": st.st_mode,
|
|
508
|
-
"uid": st.st_uid,
|
|
509
|
-
"gid": st.st_gid,
|
|
510
|
-
"atime": int(st.st_atime),
|
|
511
|
-
"mtime": int(st.st_mtime),
|
|
512
|
-
"type": item_type,
|
|
513
|
-
},
|
|
514
|
-
})
|
|
515
|
-
print(json.dumps(items))
|
|
516
|
-
"""
|
|
517
|
-
result = self._checked_run(f"python3 -c {shlex.quote(script)} {shlex.quote(remote_path)}")
|
|
518
|
-
return [
|
|
519
|
-
asyncssh.SFTPName(item["filename"], item["longname"], self._attrs(item["attrs"]))
|
|
520
|
-
for item in json.loads(result.output or "[]")
|
|
521
|
-
]
|
|
522
|
-
|
|
523
|
-
def _attrs(self, data: dict[str, object]) -> asyncssh.SFTPAttrs:
|
|
524
|
-
permissions = self._int_value(data.get("permissions"), 0)
|
|
525
|
-
raw_type = data.get("type")
|
|
526
|
-
file_type = {
|
|
527
|
-
"file": asyncssh.FILEXFER_TYPE_REGULAR,
|
|
528
|
-
"dir": asyncssh.FILEXFER_TYPE_DIRECTORY,
|
|
529
|
-
"link": asyncssh.FILEXFER_TYPE_SYMLINK,
|
|
530
|
-
}.get(str(raw_type), asyncssh.FILEXFER_TYPE_UNKNOWN)
|
|
531
|
-
return asyncssh.SFTPAttrs(
|
|
532
|
-
type=file_type,
|
|
533
|
-
size=self._int_value(data.get("size"), 0),
|
|
534
|
-
uid=self._int_value(data.get("uid"), 0),
|
|
535
|
-
gid=self._int_value(data.get("gid"), 0),
|
|
536
|
-
permissions=permissions,
|
|
537
|
-
atime=self._int_value(data.get("atime"), int(time.time())),
|
|
538
|
-
mtime=self._int_value(data.get("mtime"), int(time.time())),
|
|
539
|
-
)
|
|
540
|
-
|
|
541
|
-
@staticmethod
|
|
542
|
-
def _int_value(value: object, default: int) -> int:
|
|
543
|
-
if value is None:
|
|
544
|
-
return default
|
|
545
|
-
if isinstance(value, bool) or not isinstance(value, int | float | str):
|
|
546
|
-
raise asyncssh.SFTPFailure("remote metadata contains a non-numeric field")
|
|
41
|
+
dest_host: str,
|
|
42
|
+
dest_port: int,
|
|
43
|
+
orig_host: str, # noqa: ARG002
|
|
44
|
+
orig_port: int, # noqa: ARG002
|
|
45
|
+
) -> Awaitable[asyncssh.SSHTCPSession[bytes]] | bool:
|
|
46
|
+
if self._tunnel_connector is None:
|
|
47
|
+
return False
|
|
547
48
|
try:
|
|
548
|
-
|
|
49
|
+
self._tunnel_connector.validate_destination(dest_host, dest_port)
|
|
549
50
|
except ValueError as exc:
|
|
550
|
-
raise asyncssh.
|
|
551
|
-
|
|
552
|
-
def _checked_run(self, command: str):
|
|
553
|
-
try:
|
|
554
|
-
result = self._client.exec(
|
|
555
|
-
self._session_id,
|
|
556
|
-
command,
|
|
557
|
-
timeout=self._timeout,
|
|
558
|
-
owner="mock-ssh",
|
|
559
|
-
)
|
|
560
|
-
except HostBridgeError as exc:
|
|
561
|
-
raise self._sftp_error(exc) from exc
|
|
562
|
-
output = result.stdout.decode("utf-8", errors="replace")
|
|
563
|
-
stderr = result.stderr.decode("utf-8", errors="replace")
|
|
564
|
-
if result.timed_out:
|
|
565
|
-
print(f"hostbridge mock-ssh sftp command timed out: {command}", file=sys.stderr, flush=True)
|
|
566
|
-
raise asyncssh.SFTPFailure("remote command timed out")
|
|
567
|
-
if result.exit_code not in (0, None):
|
|
568
|
-
error_output = "\n".join(part for part in (output, stderr) if part).strip()
|
|
569
|
-
error = self._sftp_error(
|
|
570
|
-
HostBridgeError("remote_error", error_output or f"remote command exited {result.exit_code}")
|
|
571
|
-
)
|
|
572
|
-
if isinstance(error, asyncssh.SFTPNoSuchFile):
|
|
573
|
-
raise error
|
|
574
|
-
print(
|
|
575
|
-
f"hostbridge mock-ssh sftp command failed exit={result.exit_code}: {command}\n{error_output}",
|
|
576
|
-
file=sys.stderr,
|
|
577
|
-
flush=True,
|
|
578
|
-
)
|
|
579
|
-
raise error
|
|
580
|
-
return _TextCommandResult(output, result.exit_code, result.timed_out)
|
|
581
|
-
|
|
582
|
-
def _path(self, path: bytes) -> str:
|
|
583
|
-
text = path.decode("utf-8", "surrogateescape")
|
|
584
|
-
if not text:
|
|
585
|
-
return "."
|
|
586
|
-
return posixpath.normpath(text)
|
|
587
|
-
|
|
588
|
-
def _handle(self, file_obj: object) -> HostBridgeSFTPHandle:
|
|
589
|
-
if not isinstance(file_obj, HostBridgeSFTPHandle):
|
|
590
|
-
raise asyncssh.SFTPInvalidHandle("invalid HostBridge SFTP handle")
|
|
591
|
-
return file_obj
|
|
592
|
-
|
|
593
|
-
def _sftp_error(self, exc: HostBridgeError) -> Exception:
|
|
594
|
-
text = str(exc)
|
|
595
|
-
lowered = text.lower()
|
|
596
|
-
if "no such file" in lowered or "not found" in lowered or "filenotfounderror" in lowered:
|
|
597
|
-
return asyncssh.SFTPNoSuchFile(text)
|
|
598
|
-
if "permission denied" in lowered:
|
|
599
|
-
return asyncssh.SFTPPermissionDenied(text)
|
|
600
|
-
return asyncssh.SFTPFailure(text)
|
|
51
|
+
raise asyncssh.ChannelOpenError(asyncssh.OPEN_CONNECT_FAILED, str(exc)) from exc
|
|
52
|
+
return self._tunnel_connector.connect(dest_host, dest_port)
|
|
601
53
|
|
|
602
54
|
|
|
603
55
|
def ensure_keypair(key_dir: Path) -> tuple[Path, Path, str]:
|
|
604
|
-
key_dir
|
|
605
|
-
with _suppress_chmod_error(key_dir):
|
|
606
|
-
key_dir.chmod(0o700)
|
|
56
|
+
ensure_private_directory(key_dir)
|
|
607
57
|
host_key_path = key_dir / "ssh_host_ed25519_key"
|
|
608
58
|
client_key_path = key_dir / "client_ed25519_key"
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
client_key_path.chmod(0o600)
|
|
620
|
-
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()
|
|
621
69
|
return host_key_path, client_key_path, public_key
|
|
622
70
|
|
|
623
71
|
|
|
@@ -662,7 +110,9 @@ async def create_server_on_available_port(
|
|
|
662
110
|
continue
|
|
663
111
|
if port is not None:
|
|
664
112
|
raise RuntimeError(f"port {port} is not available on {listen_host}") from last_error
|
|
665
|
-
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
|
|
666
116
|
|
|
667
117
|
|
|
668
118
|
def install_ssh_config_block(
|
|
@@ -728,13 +178,15 @@ def _remove_managed_block(existing: str, begin: str, end: str) -> str:
|
|
|
728
178
|
stop += len(end)
|
|
729
179
|
while stop < len(existing) and existing[stop] in "\r\n":
|
|
730
180
|
stop += 1
|
|
731
|
-
return
|
|
181
|
+
return (
|
|
182
|
+
existing[:start].rstrip()
|
|
183
|
+
+ ("\n" if existing[:start].strip() and existing[stop:].strip() else "")
|
|
184
|
+
+ existing[stop:].lstrip()
|
|
185
|
+
)
|
|
732
186
|
|
|
733
187
|
|
|
734
188
|
def _write_text_atomic(path: Path, text: str) -> None:
|
|
735
|
-
|
|
736
|
-
tmp_path.write_text(text, encoding="utf-8")
|
|
737
|
-
tmp_path.replace(path)
|
|
189
|
+
replace_private_file(path, text.encode("utf-8"))
|
|
738
190
|
|
|
739
191
|
|
|
740
192
|
class _suppress_chmod_error:
|
|
@@ -781,35 +233,40 @@ async def serve_mock_ssh(
|
|
|
781
233
|
client = HostBridgeClient()
|
|
782
234
|
client.hello()
|
|
783
235
|
session_factory = HostBridgeSessionFactory(client, host_id, connect_timeout)
|
|
236
|
+
tunnel_connector = TunnelConnector(client, session_factory, connect_timeout=connect_timeout)
|
|
784
237
|
server, selected_port = await create_server_on_available_port(
|
|
785
|
-
server_factory=lambda: HostBridgeSSHServer(public_key),
|
|
238
|
+
server_factory=lambda: HostBridgeSSHServer(public_key, tunnel_connector),
|
|
786
239
|
listen_host=listen_host,
|
|
787
240
|
port=port,
|
|
788
241
|
server_host_keys=[str(host_key_path)],
|
|
789
242
|
process_factory=HostBridgeSSHProcess(client, session_factory, command_timeout, output_limit),
|
|
790
243
|
sftp_factory=lambda chan: HostBridgeSFTPServer(chan, client, session_factory, command_timeout, output_limit),
|
|
791
244
|
)
|
|
792
|
-
installed_config_path: Path | None = None
|
|
793
|
-
if install_ssh_config:
|
|
794
|
-
installed_config_path = install_ssh_config_block(
|
|
795
|
-
host_alias=ssh_host_alias,
|
|
796
|
-
listen_host=listen_host,
|
|
797
|
-
port=selected_port,
|
|
798
|
-
client_key_path=client_key_path,
|
|
799
|
-
config_path=ssh_config_path,
|
|
800
|
-
)
|
|
801
|
-
print(f"hostbridge mock-ssh forwarding host '{host_id}'", flush=True)
|
|
802
|
-
print(f"listening on ssh://{listen_host}:{selected_port}", flush=True)
|
|
803
|
-
print(f"ssh host alias: {ssh_host_alias}", flush=True)
|
|
804
|
-
if installed_config_path is not None:
|
|
805
|
-
print(f"ssh config: {installed_config_path}", flush=True)
|
|
806
|
-
print(f"identity file: {client_key_path}", flush=True)
|
|
807
|
-
print("supports SSH exec, shell, SFTP, and SCP; press Ctrl-C to stop", flush=True)
|
|
808
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)
|
|
809
262
|
await server.wait_closed()
|
|
810
263
|
return 0
|
|
811
264
|
finally:
|
|
812
|
-
|
|
265
|
+
try:
|
|
266
|
+
server.close()
|
|
267
|
+
await server.wait_closed()
|
|
268
|
+
finally:
|
|
269
|
+
session_factory.shutdown()
|
|
813
270
|
|
|
814
271
|
|
|
815
272
|
def run_mock_ssh(**kwargs: object) -> int:
|