@neoline/hostbridge 1.4.4 → 2.0.0
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 -137
- package/package.json +1 -1
- package/pyproject.toml +2 -1
- package/src/server_control_mcp/__init__.py +4 -5
- package/src/server_control_mcp/cli.py +229 -1231
- package/src/server_control_mcp/client.py +468 -0
- package/src/server_control_mcp/config.py +139 -0
- package/src/server_control_mcp/daemon.py +387 -306
- package/src/server_control_mcp/doctor.py +108 -0
- package/src/server_control_mcp/hosts.py +52 -7
- package/src/server_control_mcp/mock_ssh.py +177 -87
- package/src/server_control_mcp/policy.py +2 -0
- package/src/server_control_mcp/protocol.py +362 -0
- package/src/server_control_mcp/runtime.py +40 -0
- package/src/server_control_mcp/server.py +132 -188
- package/src/server_control_mcp/services.py +508 -0
- package/src/server_control_mcp/transports/__init__.py +17 -0
- package/src/server_control_mcp/transports/base.py +78 -0
- package/src/server_control_mcp/transports/pty.py +405 -0
- package/src/server_control_mcp/transports/ssh.py +195 -0
- package/src/server_control_mcp/manager.py +0 -925
|
@@ -0,0 +1,405 @@
|
|
|
1
|
+
"""Ordered interactive PTY transport."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
import binascii
|
|
7
|
+
import hashlib
|
|
8
|
+
import re
|
|
9
|
+
import shlex
|
|
10
|
+
import threading
|
|
11
|
+
import time
|
|
12
|
+
import uuid
|
|
13
|
+
from collections.abc import Callable, Iterable, Iterator
|
|
14
|
+
from contextlib import suppress
|
|
15
|
+
from typing import Protocol
|
|
16
|
+
|
|
17
|
+
import pexpect
|
|
18
|
+
|
|
19
|
+
from ..hosts import HostConfig
|
|
20
|
+
from .base import ExecResult, ShellReadResult, TransferResult, TransportBusy, TransportCapabilities, TransportError
|
|
21
|
+
|
|
22
|
+
_REMOTE_BASE64_DECODE = "{ base64 --decode 2>/dev/null || base64 -d 2>/dev/null || base64 -D; }"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class ChildProcess(Protocol):
|
|
26
|
+
before: str
|
|
27
|
+
after: object
|
|
28
|
+
|
|
29
|
+
def sendline(self, line: str) -> None:
|
|
30
|
+
raise NotImplementedError
|
|
31
|
+
|
|
32
|
+
def send(self, text: str) -> None:
|
|
33
|
+
raise NotImplementedError
|
|
34
|
+
|
|
35
|
+
def expect(self, patterns: object, timeout: int | float = -1) -> int:
|
|
36
|
+
raise NotImplementedError
|
|
37
|
+
|
|
38
|
+
def isalive(self) -> bool:
|
|
39
|
+
raise NotImplementedError
|
|
40
|
+
|
|
41
|
+
def close(self, force: bool = False) -> None:
|
|
42
|
+
raise NotImplementedError
|
|
43
|
+
|
|
44
|
+
def read_nonblocking(self, size: int = 1, timeout: int | float = -1) -> str:
|
|
45
|
+
raise NotImplementedError
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
ChildFactory = Callable[[str, list[str], int | float], ChildProcess]
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _default_child_factory(command: str, args: list[str], timeout: int | float) -> ChildProcess:
|
|
52
|
+
return pexpect.spawn(
|
|
53
|
+
command,
|
|
54
|
+
args,
|
|
55
|
+
encoding="utf-8",
|
|
56
|
+
codec_errors="replace",
|
|
57
|
+
timeout=timeout,
|
|
58
|
+
echo=False,
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _iter_base64(chunks: Iterable[bytes]) -> Iterator[str]:
|
|
63
|
+
remainder = b""
|
|
64
|
+
for chunk in chunks:
|
|
65
|
+
if not isinstance(chunk, bytes):
|
|
66
|
+
raise TypeError("stdin chunks must be bytes")
|
|
67
|
+
data = remainder + chunk
|
|
68
|
+
complete = len(data) - (len(data) % 3)
|
|
69
|
+
if complete:
|
|
70
|
+
yield base64.b64encode(data[:complete]).decode("ascii")
|
|
71
|
+
remainder = data[complete:]
|
|
72
|
+
if remainder:
|
|
73
|
+
yield base64.b64encode(remainder).decode("ascii")
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _marker_text(value: object) -> str:
|
|
77
|
+
return value.group(0) if hasattr(value, "group") else str(value)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class PtyTransport:
|
|
81
|
+
capabilities = TransportCapabilities(
|
|
82
|
+
separate_stderr=False,
|
|
83
|
+
native_binary_transfer=False,
|
|
84
|
+
parallel_channels=1,
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
@classmethod
|
|
88
|
+
def connect(
|
|
89
|
+
cls,
|
|
90
|
+
host: HostConfig,
|
|
91
|
+
*,
|
|
92
|
+
child_factory: ChildFactory = _default_child_factory,
|
|
93
|
+
connect_timeout: int | float = 60,
|
|
94
|
+
remote_shell: str = "bash",
|
|
95
|
+
token_factory: Callable[[], str] | None = None,
|
|
96
|
+
) -> PtyTransport:
|
|
97
|
+
if connect_timeout <= 0:
|
|
98
|
+
raise ValueError("connect_timeout must be positive")
|
|
99
|
+
tokens = token_factory or (lambda: uuid.uuid4().hex)
|
|
100
|
+
child = child_factory(host.command, list(host.args), connect_timeout)
|
|
101
|
+
try:
|
|
102
|
+
for step in host.login_steps:
|
|
103
|
+
child.expect(step.expect, timeout=connect_timeout)
|
|
104
|
+
if step.send_secret is not None:
|
|
105
|
+
secret = host.secrets.get(step.send_secret)
|
|
106
|
+
if secret is None:
|
|
107
|
+
raise TransportError(f"login step references missing secret {step.send_secret!r}")
|
|
108
|
+
child.send(f"{secret}\r")
|
|
109
|
+
else:
|
|
110
|
+
child.send(f"{step.send or ''}\r")
|
|
111
|
+
if host.login_steps:
|
|
112
|
+
child.expect(host.ready_expect or r"(?m)[^\r\n]*[#$]\s*$", timeout=connect_timeout)
|
|
113
|
+
token = tokens()
|
|
114
|
+
marker = f"__HB_READY_{token}__"
|
|
115
|
+
child.send(f"printf '\n{marker}\n'\r")
|
|
116
|
+
child.expect(re.escape(marker), timeout=connect_timeout)
|
|
117
|
+
except Exception as exc:
|
|
118
|
+
child.close(force=True)
|
|
119
|
+
if isinstance(exc, TransportError):
|
|
120
|
+
raise
|
|
121
|
+
raise TransportError(f"failed to establish PTY transport for {host.id}") from exc
|
|
122
|
+
return cls(child, remote_shell=remote_shell, token_factory=tokens)
|
|
123
|
+
|
|
124
|
+
def __init__(
|
|
125
|
+
self,
|
|
126
|
+
child: ChildProcess,
|
|
127
|
+
*,
|
|
128
|
+
remote_shell: str = "bash",
|
|
129
|
+
token_factory: Callable[[], str] | None = None,
|
|
130
|
+
marker_prefix: str = "HB",
|
|
131
|
+
) -> None:
|
|
132
|
+
self.child = child
|
|
133
|
+
self.remote_shell = remote_shell
|
|
134
|
+
self._token_factory = token_factory or (lambda: uuid.uuid4().hex)
|
|
135
|
+
self._marker_prefix = marker_prefix
|
|
136
|
+
self.operation_lock = threading.Lock()
|
|
137
|
+
self._io_lock = threading.Lock()
|
|
138
|
+
|
|
139
|
+
def exec(
|
|
140
|
+
self,
|
|
141
|
+
command: str,
|
|
142
|
+
*,
|
|
143
|
+
stdin: Iterable[bytes] | None,
|
|
144
|
+
timeout: float,
|
|
145
|
+
output_limit: int,
|
|
146
|
+
) -> ExecResult:
|
|
147
|
+
if not command.strip():
|
|
148
|
+
raise ValueError("command must not be empty")
|
|
149
|
+
if timeout <= 0:
|
|
150
|
+
raise ValueError("timeout must be positive")
|
|
151
|
+
if output_limit <= 0:
|
|
152
|
+
raise ValueError("output_limit must be positive")
|
|
153
|
+
if not self.operation_lock.acquire(blocking=False):
|
|
154
|
+
raise TransportBusy("PTY transport already has an active operation")
|
|
155
|
+
stdin_path: str | None = None
|
|
156
|
+
try:
|
|
157
|
+
if not self.child.isalive():
|
|
158
|
+
raise TransportError("PTY transport is not alive")
|
|
159
|
+
if stdin is not None:
|
|
160
|
+
stdin_path = self._stage_stdin_locked(stdin, timeout=timeout)
|
|
161
|
+
command = f"{command} < {shlex.quote(stdin_path)}"
|
|
162
|
+
return self._exec_locked(command, timeout=timeout, output_limit=output_limit)
|
|
163
|
+
finally:
|
|
164
|
+
if stdin_path is not None and self.child.isalive():
|
|
165
|
+
with suppress(Exception):
|
|
166
|
+
self._exec_locked(
|
|
167
|
+
f"rm -f -- {shlex.quote(stdin_path)}",
|
|
168
|
+
timeout=min(timeout, 10),
|
|
169
|
+
output_limit=1024,
|
|
170
|
+
)
|
|
171
|
+
self.operation_lock.release()
|
|
172
|
+
|
|
173
|
+
def _stage_stdin_locked(self, chunks: Iterable[bytes], *, timeout: float) -> str:
|
|
174
|
+
token = self._token_factory()
|
|
175
|
+
path = f"/tmp/hostbridge.stdin.{token}"
|
|
176
|
+
heredoc = f"__{self._marker_prefix}_STDIN_{token}__"
|
|
177
|
+
done_pattern = re.compile(rf"__{self._marker_prefix}_STDIN_DONE_{re.escape(token)}__:(\d+)")
|
|
178
|
+
self.child.send(f"cat > {shlex.quote(path)} <<'{heredoc}'\n")
|
|
179
|
+
for encoded in _iter_base64(chunks):
|
|
180
|
+
self.child.send(encoded)
|
|
181
|
+
self.child.send(
|
|
182
|
+
f"\n{heredoc}\n"
|
|
183
|
+
f"tmp={shlex.quote(path)}.b64; mv {shlex.quote(path)} \"$tmp\"; "
|
|
184
|
+
f"{{ {_REMOTE_BASE64_DECODE} < \"$tmp\"; }} > {shlex.quote(path)}; status=$?; rm -f \"$tmp\"; "
|
|
185
|
+
f"printf '\n__{self._marker_prefix}_STDIN_DONE_{token}__:%s\n' \"$status\"\n"
|
|
186
|
+
)
|
|
187
|
+
self.child.expect(done_pattern, timeout=timeout)
|
|
188
|
+
match = re.search(r":(\d+)", _marker_text(self.child.after))
|
|
189
|
+
if match is None or int(match.group(1)) != 0:
|
|
190
|
+
raise TransportError("failed to stage PTY stdin")
|
|
191
|
+
return path
|
|
192
|
+
|
|
193
|
+
def _exec_locked(self, command: str, *, timeout: float, output_limit: int) -> ExecResult:
|
|
194
|
+
token = self._token_factory()
|
|
195
|
+
start_marker = f"__{self._marker_prefix}_START_{token}__"
|
|
196
|
+
exit_pattern = re.compile(rf"__{self._marker_prefix}_EXIT_{re.escape(token)}__:(\d+)")
|
|
197
|
+
encoded_command = base64.b64encode(command.encode("utf-8")).decode("ascii")
|
|
198
|
+
remote_line = (
|
|
199
|
+
f"__scm_cmd=$(printf %s {shlex.quote(encoded_command)} | {_REMOTE_BASE64_DECODE}); "
|
|
200
|
+
f"printf '\n{start_marker}\n'; "
|
|
201
|
+
f"{self.remote_shell} -lc \"$__scm_cmd\"; "
|
|
202
|
+
"__scm_status=$?; "
|
|
203
|
+
f"printf '\n__{self._marker_prefix}_EXIT_{token}__:%s\n' \"$__scm_status\""
|
|
204
|
+
)
|
|
205
|
+
try:
|
|
206
|
+
self.child.sendline(remote_line)
|
|
207
|
+
self.child.expect(exit_pattern, timeout=timeout)
|
|
208
|
+
except pexpect.TIMEOUT:
|
|
209
|
+
return ExecResult(None, self._output_after_marker(str(self.child.before), start_marker, output_limit), timed_out=True)
|
|
210
|
+
match = re.search(r":(\d+)", _marker_text(self.child.after))
|
|
211
|
+
exit_code = int(match.group(1)) if match else None
|
|
212
|
+
return ExecResult(exit_code, self._output_after_marker(str(self.child.before), start_marker, output_limit))
|
|
213
|
+
|
|
214
|
+
@staticmethod
|
|
215
|
+
def _output_after_marker(raw: str, marker: str, output_limit: int) -> bytes:
|
|
216
|
+
normalized = raw.replace("\x1b[?2004h", "").replace("\x1b[?2004l", "")
|
|
217
|
+
normalized = normalized.replace("\r\n", "\n").replace("\r", "\n")
|
|
218
|
+
marker_index = normalized.rfind(marker)
|
|
219
|
+
output = normalized[marker_index + len(marker) :] if marker_index >= 0 else normalized
|
|
220
|
+
cleaned = [
|
|
221
|
+
line
|
|
222
|
+
for line in output.split("\n")
|
|
223
|
+
if not line.startswith("printf '") and not re.search(r"__[A-Z]+_(?:START|EXIT)_", line)
|
|
224
|
+
]
|
|
225
|
+
encoded = "\n".join(cleaned).strip("\n").encode("utf-8", errors="replace")
|
|
226
|
+
return encoded[-output_limit:]
|
|
227
|
+
|
|
228
|
+
def upload(
|
|
229
|
+
self,
|
|
230
|
+
chunks: Iterable[bytes],
|
|
231
|
+
remote_path: str,
|
|
232
|
+
*,
|
|
233
|
+
mode: int,
|
|
234
|
+
expected_size: int | None,
|
|
235
|
+
) -> TransferResult:
|
|
236
|
+
self._validate_remote_path(remote_path)
|
|
237
|
+
if not isinstance(mode, int) or isinstance(mode, bool) or not 0 <= mode <= 0o7777:
|
|
238
|
+
raise ValueError("mode must be an integer between 0 and 07777")
|
|
239
|
+
if expected_size is not None and expected_size < 0:
|
|
240
|
+
raise ValueError("expected_size must be non-negative")
|
|
241
|
+
if not self.operation_lock.acquire(blocking=False):
|
|
242
|
+
raise TransportBusy("PTY transport already has an active operation")
|
|
243
|
+
token = self._token_factory()
|
|
244
|
+
tmp_b64 = f"/tmp/hostbridge.upload.{token}.b64"
|
|
245
|
+
tmp_out = f"/tmp/hostbridge.upload.{token}.out"
|
|
246
|
+
heredoc = f"__{self._marker_prefix}_UPLOAD_{token}__"
|
|
247
|
+
done_pattern = re.compile(rf"__{self._marker_prefix}_UPLOAD_DONE_{re.escape(token)}__:(\d+)")
|
|
248
|
+
digest = hashlib.sha256()
|
|
249
|
+
transferred = 0
|
|
250
|
+
|
|
251
|
+
def counted_chunks() -> Iterator[bytes]:
|
|
252
|
+
nonlocal transferred
|
|
253
|
+
for chunk in chunks:
|
|
254
|
+
if not isinstance(chunk, bytes):
|
|
255
|
+
raise TypeError("upload chunks must be bytes")
|
|
256
|
+
transferred += len(chunk)
|
|
257
|
+
digest.update(chunk)
|
|
258
|
+
yield chunk
|
|
259
|
+
|
|
260
|
+
try:
|
|
261
|
+
if not self.child.isalive():
|
|
262
|
+
raise TransportError("PTY transport is not alive")
|
|
263
|
+
self.child.send(f"cat > {shlex.quote(tmp_b64)} <<'{heredoc}'\n")
|
|
264
|
+
for encoded in _iter_base64(counted_chunks()):
|
|
265
|
+
self.child.send(encoded)
|
|
266
|
+
self.child.send(
|
|
267
|
+
f"\n{heredoc}\nprintf '\n__{self._marker_prefix}_UPLOAD_DONE_{token}__:%s\n' \"$?\"\n"
|
|
268
|
+
)
|
|
269
|
+
self.child.expect(done_pattern, timeout=60)
|
|
270
|
+
match = re.search(r":(\d+)", _marker_text(self.child.after))
|
|
271
|
+
if match is None or int(match.group(1)) != 0:
|
|
272
|
+
raise TransportError("failed to stream PTY upload")
|
|
273
|
+
if expected_size is not None and transferred != expected_size:
|
|
274
|
+
raise TransportError(
|
|
275
|
+
f"upload size mismatch: expected {expected_size}, received {transferred}"
|
|
276
|
+
)
|
|
277
|
+
target_q = shlex.quote(remote_path)
|
|
278
|
+
finalize = (
|
|
279
|
+
"set -e; "
|
|
280
|
+
f"target={target_q}; tmp_b64={shlex.quote(tmp_b64)}; tmp_out={shlex.quote(tmp_out)}; "
|
|
281
|
+
'mkdir -p "$(dirname "$target")"; '
|
|
282
|
+
f'{{ {_REMOTE_BASE64_DECODE} < "$tmp_b64"; }} > "$tmp_out"; '
|
|
283
|
+
f'chmod {format(mode, "04o")} "$tmp_out"; mv "$tmp_out" "$target"; rm -f "$tmp_b64"; '
|
|
284
|
+
"if command -v sha256sum >/dev/null 2>&1; then sha=$(sha256sum \"$target\" | awk '{print $1}'); "
|
|
285
|
+
"else sha=$(shasum -a 256 \"$target\" | awk '{print $1}'); fi; "
|
|
286
|
+
'bytes=$(wc -c < "$target" | tr -d " "); printf "BYTES=%s\\nSHA256=%s\\n" "$bytes" "$sha"'
|
|
287
|
+
)
|
|
288
|
+
result = self._exec_locked(finalize, timeout=60, output_limit=4096)
|
|
289
|
+
if result.timed_out or result.exit_code != 0:
|
|
290
|
+
raise TransportError("failed to finalize PTY upload")
|
|
291
|
+
fields = self._metadata_fields(result.stdout)
|
|
292
|
+
remote_size = int(fields.get("BYTES", transferred))
|
|
293
|
+
remote_sha = fields.get("SHA256", digest.hexdigest())
|
|
294
|
+
if remote_size != transferred or remote_sha != digest.hexdigest():
|
|
295
|
+
raise TransportError("PTY upload integrity check failed")
|
|
296
|
+
return TransferResult(transferred, remote_sha)
|
|
297
|
+
except Exception:
|
|
298
|
+
if self.child.isalive():
|
|
299
|
+
with suppress(Exception):
|
|
300
|
+
self._exec_locked(
|
|
301
|
+
f"rm -f -- {shlex.quote(tmp_b64)} {shlex.quote(tmp_out)}",
|
|
302
|
+
timeout=10,
|
|
303
|
+
output_limit=1024,
|
|
304
|
+
)
|
|
305
|
+
raise
|
|
306
|
+
finally:
|
|
307
|
+
self.operation_lock.release()
|
|
308
|
+
|
|
309
|
+
def download(self, remote_path: str, *, chunk_size: int) -> Iterator[bytes]:
|
|
310
|
+
self._validate_remote_path(remote_path)
|
|
311
|
+
if not isinstance(chunk_size, int) or isinstance(chunk_size, bool) or not 4096 <= chunk_size <= 1024 * 1024:
|
|
312
|
+
raise ValueError("chunk_size must be between 4096 and 1048576 bytes")
|
|
313
|
+
|
|
314
|
+
def stream() -> Iterator[bytes]:
|
|
315
|
+
if not self.operation_lock.acquire(blocking=False):
|
|
316
|
+
raise TransportBusy("PTY transport already has an active operation")
|
|
317
|
+
digest = hashlib.sha256()
|
|
318
|
+
transferred = 0
|
|
319
|
+
try:
|
|
320
|
+
if not self.child.isalive():
|
|
321
|
+
raise TransportError("PTY transport is not alive")
|
|
322
|
+
remote_q = shlex.quote(remote_path)
|
|
323
|
+
metadata = (
|
|
324
|
+
f"target={remote_q}; bytes=$(wc -c < \"$target\" | tr -d ' '); "
|
|
325
|
+
"if command -v sha256sum >/dev/null 2>&1; then sha=$(sha256sum \"$target\" | awk '{print $1}'); "
|
|
326
|
+
"else sha=$(shasum -a 256 \"$target\" | awk '{print $1}'); fi; "
|
|
327
|
+
'printf "BYTES=%s\\nSHA256=%s\\n" "$bytes" "$sha"'
|
|
328
|
+
)
|
|
329
|
+
result = self._exec_locked(metadata, timeout=60, output_limit=4096)
|
|
330
|
+
if result.timed_out or result.exit_code != 0:
|
|
331
|
+
raise TransportError(f"failed to inspect remote file {remote_path}")
|
|
332
|
+
fields = self._metadata_fields(result.stdout)
|
|
333
|
+
byte_count = int(fields.get("BYTES", "0"))
|
|
334
|
+
expected_sha = fields.get("SHA256", "")
|
|
335
|
+
chunk_count = (byte_count + chunk_size - 1) // chunk_size
|
|
336
|
+
for index in range(chunk_count):
|
|
337
|
+
command = (
|
|
338
|
+
f"dd if={remote_q} bs={chunk_size} skip={index} count=1 2>/dev/null | "
|
|
339
|
+
"{ base64 --wrap=0 2>/dev/null || base64 | tr -d '\\n'; }"
|
|
340
|
+
)
|
|
341
|
+
result = self._exec_locked(command, timeout=60, output_limit=chunk_size * 2 + 4096)
|
|
342
|
+
if result.timed_out or result.exit_code != 0:
|
|
343
|
+
raise TransportError(f"failed to download PTY chunk {index}")
|
|
344
|
+
try:
|
|
345
|
+
chunk = base64.b64decode(b"".join(result.stdout.split()), validate=True)
|
|
346
|
+
except (binascii.Error, ValueError) as exc:
|
|
347
|
+
raise TransportError(f"remote chunk {index} contains invalid base64") from exc
|
|
348
|
+
transferred += len(chunk)
|
|
349
|
+
digest.update(chunk)
|
|
350
|
+
yield chunk
|
|
351
|
+
if transferred != byte_count or (expected_sha and digest.hexdigest() != expected_sha):
|
|
352
|
+
raise TransportError("PTY download integrity check failed")
|
|
353
|
+
finally:
|
|
354
|
+
self.operation_lock.release()
|
|
355
|
+
|
|
356
|
+
return stream()
|
|
357
|
+
|
|
358
|
+
@staticmethod
|
|
359
|
+
def _validate_remote_path(remote_path: str) -> None:
|
|
360
|
+
if not isinstance(remote_path, str) or not remote_path.strip() or "\x00" in remote_path:
|
|
361
|
+
raise ValueError("remote_path must be a non-empty path without NUL bytes")
|
|
362
|
+
|
|
363
|
+
@staticmethod
|
|
364
|
+
def _metadata_fields(output: bytes) -> dict[str, str]:
|
|
365
|
+
fields: dict[str, str] = {}
|
|
366
|
+
for line in output.decode("utf-8", errors="replace").splitlines():
|
|
367
|
+
key, separator, value = line.partition("=")
|
|
368
|
+
if separator:
|
|
369
|
+
fields[key] = value
|
|
370
|
+
return fields
|
|
371
|
+
|
|
372
|
+
def close(self) -> None:
|
|
373
|
+
self.child.close(force=True)
|
|
374
|
+
|
|
375
|
+
def shell_write(self, data: bytes) -> None:
|
|
376
|
+
if not isinstance(data, bytes):
|
|
377
|
+
raise TypeError("shell data must be bytes")
|
|
378
|
+
if not self.child.isalive():
|
|
379
|
+
raise TransportError("PTY transport is not alive")
|
|
380
|
+
with self._io_lock:
|
|
381
|
+
self.child.send(data.decode("utf-8", errors="replace"))
|
|
382
|
+
|
|
383
|
+
def shell_read(self, *, timeout: float, max_bytes: int) -> ShellReadResult:
|
|
384
|
+
if timeout < 0:
|
|
385
|
+
raise ValueError("timeout must be non-negative")
|
|
386
|
+
if max_bytes <= 0:
|
|
387
|
+
raise ValueError("max_bytes must be positive")
|
|
388
|
+
output = bytearray()
|
|
389
|
+
deadline = time.monotonic() + timeout
|
|
390
|
+
with self._io_lock:
|
|
391
|
+
while len(output) < max_bytes:
|
|
392
|
+
remaining = max(0.0, deadline - time.monotonic())
|
|
393
|
+
try:
|
|
394
|
+
chunk = self.child.read_nonblocking(
|
|
395
|
+
size=min(4096, max_bytes - len(output)),
|
|
396
|
+
timeout=remaining,
|
|
397
|
+
)
|
|
398
|
+
except (pexpect.TIMEOUT, pexpect.EOF):
|
|
399
|
+
break
|
|
400
|
+
if not chunk:
|
|
401
|
+
break
|
|
402
|
+
output.extend(chunk.encode("utf-8", errors="replace"))
|
|
403
|
+
if remaining <= 0:
|
|
404
|
+
break
|
|
405
|
+
return ShellReadResult(bytes(output), self.child.isalive())
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
"""Native AsyncSSH transport for direct SSH hosts."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import hashlib
|
|
7
|
+
import uuid
|
|
8
|
+
from collections.abc import Iterable, Sequence
|
|
9
|
+
from contextlib import suppress
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
import asyncssh
|
|
14
|
+
|
|
15
|
+
from .base import ExecResult, TransferResult, TransportCapabilities
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True, slots=True)
|
|
19
|
+
class SshConnectionConfig:
|
|
20
|
+
host: str
|
|
21
|
+
port: int = 22
|
|
22
|
+
username: str | None = None
|
|
23
|
+
client_keys: Sequence[str] = ()
|
|
24
|
+
known_hosts: str | None = None
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class SshTransport:
|
|
28
|
+
capabilities = TransportCapabilities(
|
|
29
|
+
separate_stderr=True,
|
|
30
|
+
native_binary_transfer=True,
|
|
31
|
+
parallel_channels=8,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
def __init__(self, connection: asyncssh.SSHClientConnection) -> None:
|
|
35
|
+
self._connection = connection
|
|
36
|
+
|
|
37
|
+
@classmethod
|
|
38
|
+
async def connect(cls, config: SshConnectionConfig) -> SshTransport:
|
|
39
|
+
if not config.host.strip():
|
|
40
|
+
raise ValueError("SSH host must not be empty")
|
|
41
|
+
if not 1 <= config.port <= 65535:
|
|
42
|
+
raise ValueError("SSH port must be between 1 and 65535")
|
|
43
|
+
connection = await asyncssh.connect(
|
|
44
|
+
config.host,
|
|
45
|
+
port=config.port,
|
|
46
|
+
username=config.username,
|
|
47
|
+
client_keys=list(config.client_keys),
|
|
48
|
+
known_hosts=config.known_hosts,
|
|
49
|
+
encoding=None,
|
|
50
|
+
)
|
|
51
|
+
return cls(connection)
|
|
52
|
+
|
|
53
|
+
async def exec(
|
|
54
|
+
self,
|
|
55
|
+
command: str,
|
|
56
|
+
*,
|
|
57
|
+
stdin: Iterable[bytes] | None,
|
|
58
|
+
timeout: float,
|
|
59
|
+
output_limit: int,
|
|
60
|
+
) -> ExecResult:
|
|
61
|
+
if not command.strip():
|
|
62
|
+
raise ValueError("command must not be empty")
|
|
63
|
+
if timeout <= 0:
|
|
64
|
+
raise ValueError("timeout must be positive")
|
|
65
|
+
if output_limit <= 0:
|
|
66
|
+
raise ValueError("output_limit must be positive")
|
|
67
|
+
process = await self._connection.create_process(command, encoding=None)
|
|
68
|
+
stdout_task = asyncio.create_task(self._read_stream(process.stdout, output_limit))
|
|
69
|
+
stderr_task = asyncio.create_task(self._read_stream(process.stderr, output_limit))
|
|
70
|
+
input_task = asyncio.create_task(self._write_stdin(process, stdin))
|
|
71
|
+
try:
|
|
72
|
+
await asyncio.wait_for(process.wait_closed(), timeout=timeout)
|
|
73
|
+
await input_task
|
|
74
|
+
stdout, stderr = await asyncio.gather(stdout_task, stderr_task)
|
|
75
|
+
return ExecResult(process.exit_status, stdout, stderr)
|
|
76
|
+
except TimeoutError:
|
|
77
|
+
process.terminate()
|
|
78
|
+
with suppress(Exception):
|
|
79
|
+
await process.wait_closed()
|
|
80
|
+
for task in (stdout_task, stderr_task, input_task):
|
|
81
|
+
task.cancel()
|
|
82
|
+
await asyncio.gather(stdout_task, stderr_task, input_task, return_exceptions=True)
|
|
83
|
+
return ExecResult(None, b"", b"", timed_out=True)
|
|
84
|
+
|
|
85
|
+
@staticmethod
|
|
86
|
+
async def _write_stdin(process: asyncssh.SSHClientProcess[bytes], stdin: Iterable[bytes] | None) -> None:
|
|
87
|
+
if stdin is not None:
|
|
88
|
+
for chunk in stdin:
|
|
89
|
+
if not isinstance(chunk, bytes):
|
|
90
|
+
raise TypeError("stdin chunks must be bytes")
|
|
91
|
+
process.stdin.write(chunk)
|
|
92
|
+
await process.stdin.drain()
|
|
93
|
+
process.stdin.write_eof()
|
|
94
|
+
|
|
95
|
+
@staticmethod
|
|
96
|
+
async def _read_stream(stream, output_limit: int) -> bytes:
|
|
97
|
+
output = bytearray()
|
|
98
|
+
while True:
|
|
99
|
+
chunk = await stream.read(64 * 1024)
|
|
100
|
+
if not chunk:
|
|
101
|
+
break
|
|
102
|
+
output.extend(chunk)
|
|
103
|
+
if len(output) > output_limit:
|
|
104
|
+
del output[: len(output) - output_limit]
|
|
105
|
+
return bytes(output)
|
|
106
|
+
|
|
107
|
+
async def upload_file(
|
|
108
|
+
self,
|
|
109
|
+
local_path: Path | str,
|
|
110
|
+
remote_path: str,
|
|
111
|
+
*,
|
|
112
|
+
chunk_size: int = 256 * 1024,
|
|
113
|
+
mode: int = 0o644,
|
|
114
|
+
) -> TransferResult:
|
|
115
|
+
source = Path(local_path)
|
|
116
|
+
self._validate_chunk_size(chunk_size)
|
|
117
|
+
self._validate_remote_path(remote_path)
|
|
118
|
+
temporary = f"{remote_path}.hostbridge-{uuid.uuid4().hex}.tmp"
|
|
119
|
+
digest = hashlib.sha256()
|
|
120
|
+
transferred = 0
|
|
121
|
+
sftp = await self._connection.start_sftp_client()
|
|
122
|
+
try:
|
|
123
|
+
local_file = source.open("rb")
|
|
124
|
+
try:
|
|
125
|
+
async with sftp.open(temporary, "wb") as remote_file:
|
|
126
|
+
while True:
|
|
127
|
+
chunk = await asyncio.to_thread(local_file.read, chunk_size)
|
|
128
|
+
if not chunk:
|
|
129
|
+
break
|
|
130
|
+
await remote_file.write(chunk)
|
|
131
|
+
digest.update(chunk)
|
|
132
|
+
transferred += len(chunk)
|
|
133
|
+
finally:
|
|
134
|
+
local_file.close()
|
|
135
|
+
await sftp.chmod(temporary, mode)
|
|
136
|
+
await sftp.rename(temporary, remote_path)
|
|
137
|
+
except Exception:
|
|
138
|
+
with suppress(Exception):
|
|
139
|
+
await sftp.remove(temporary)
|
|
140
|
+
raise
|
|
141
|
+
finally:
|
|
142
|
+
sftp.exit()
|
|
143
|
+
return TransferResult(transferred, digest.hexdigest())
|
|
144
|
+
|
|
145
|
+
async def download_file(
|
|
146
|
+
self,
|
|
147
|
+
remote_path: str,
|
|
148
|
+
local_path: Path | str,
|
|
149
|
+
*,
|
|
150
|
+
chunk_size: int = 256 * 1024,
|
|
151
|
+
) -> TransferResult:
|
|
152
|
+
destination = Path(local_path)
|
|
153
|
+
self._validate_chunk_size(chunk_size)
|
|
154
|
+
self._validate_remote_path(remote_path)
|
|
155
|
+
temporary = destination.with_name(f".{destination.name}.hostbridge-{uuid.uuid4().hex}.tmp")
|
|
156
|
+
digest = hashlib.sha256()
|
|
157
|
+
transferred = 0
|
|
158
|
+
sftp = await self._connection.start_sftp_client()
|
|
159
|
+
try:
|
|
160
|
+
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
161
|
+
local_file = temporary.open("wb")
|
|
162
|
+
try:
|
|
163
|
+
async with sftp.open(remote_path, "rb") as remote_file:
|
|
164
|
+
while True:
|
|
165
|
+
raw_chunk = await remote_file.read(chunk_size)
|
|
166
|
+
if not raw_chunk:
|
|
167
|
+
break
|
|
168
|
+
chunk = raw_chunk.encode("utf-8") if isinstance(raw_chunk, str) else bytes(raw_chunk)
|
|
169
|
+
await asyncio.to_thread(local_file.write, chunk)
|
|
170
|
+
digest.update(chunk)
|
|
171
|
+
transferred += len(chunk)
|
|
172
|
+
finally:
|
|
173
|
+
local_file.close()
|
|
174
|
+
temporary.chmod(0o600)
|
|
175
|
+
temporary.replace(destination)
|
|
176
|
+
except Exception:
|
|
177
|
+
temporary.unlink(missing_ok=True)
|
|
178
|
+
raise
|
|
179
|
+
finally:
|
|
180
|
+
sftp.exit()
|
|
181
|
+
return TransferResult(transferred, digest.hexdigest())
|
|
182
|
+
|
|
183
|
+
@staticmethod
|
|
184
|
+
def _validate_chunk_size(chunk_size: int) -> None:
|
|
185
|
+
if not isinstance(chunk_size, int) or isinstance(chunk_size, bool) or not 4096 <= chunk_size <= 1024 * 1024:
|
|
186
|
+
raise ValueError("chunk_size must be between 4096 and 1048576 bytes")
|
|
187
|
+
|
|
188
|
+
@staticmethod
|
|
189
|
+
def _validate_remote_path(remote_path: str) -> None:
|
|
190
|
+
if not isinstance(remote_path, str) or not remote_path.strip() or "\x00" in remote_path:
|
|
191
|
+
raise ValueError("remote_path must be a non-empty path without NUL bytes")
|
|
192
|
+
|
|
193
|
+
async def close(self) -> None:
|
|
194
|
+
self._connection.close()
|
|
195
|
+
await self._connection.wait_closed()
|