@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,108 @@
|
|
|
1
|
+
"""Deterministic HostBridge installation diagnostics."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import tomllib
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Protocol
|
|
10
|
+
|
|
11
|
+
from . import __version__
|
|
12
|
+
from .config import load_v1_config
|
|
13
|
+
from .runtime import runtime_paths
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class DoctorClient(Protocol):
|
|
17
|
+
def hello(self) -> dict[str, object]:
|
|
18
|
+
raise NotImplementedError
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(frozen=True, slots=True)
|
|
22
|
+
class DiagnosticResult:
|
|
23
|
+
name: str
|
|
24
|
+
status: str
|
|
25
|
+
message: str
|
|
26
|
+
details: dict[str, object]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(frozen=True, slots=True)
|
|
30
|
+
class DoctorReport:
|
|
31
|
+
results: tuple[DiagnosticResult, ...]
|
|
32
|
+
|
|
33
|
+
@property
|
|
34
|
+
def ok(self) -> bool:
|
|
35
|
+
return all(result.status != "fail" for result in self.results)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def collect_diagnostics(
|
|
39
|
+
config_path: Path | str,
|
|
40
|
+
*,
|
|
41
|
+
client: DoctorClient,
|
|
42
|
+
executable_paths: list[str],
|
|
43
|
+
) -> DoctorReport:
|
|
44
|
+
results = [
|
|
45
|
+
_version_diagnostic(),
|
|
46
|
+
_config_diagnostic(config_path),
|
|
47
|
+
_runtime_diagnostic(),
|
|
48
|
+
_daemon_diagnostic(client),
|
|
49
|
+
_executable_diagnostic(executable_paths),
|
|
50
|
+
]
|
|
51
|
+
return DoctorReport(tuple(results))
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _version_diagnostic() -> DiagnosticResult:
|
|
55
|
+
root = Path(__file__).parents[2]
|
|
56
|
+
npm = json.loads((root / "package.json").read_text(encoding="utf-8"))
|
|
57
|
+
pyproject = tomllib.loads((root / "pyproject.toml").read_text(encoding="utf-8"))
|
|
58
|
+
versions = {
|
|
59
|
+
"runtime": __version__,
|
|
60
|
+
"npm": npm.get("version"),
|
|
61
|
+
"python": pyproject.get("project", {}).get("version"),
|
|
62
|
+
}
|
|
63
|
+
status = "pass" if len(set(versions.values())) == 1 and __version__ == "2.0.0" else "fail"
|
|
64
|
+
return DiagnosticResult("version", status, "version sources agree" if status == "pass" else "version drift detected", versions)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _config_diagnostic(config_path: Path | str) -> DiagnosticResult:
|
|
68
|
+
try:
|
|
69
|
+
config = load_v1_config(config_path)
|
|
70
|
+
except (OSError, ValueError) as exc:
|
|
71
|
+
return DiagnosticResult("config", "fail", str(exc), {"path": str(config_path)})
|
|
72
|
+
return DiagnosticResult("config", "pass", "schema v1 configuration is valid", {"path": str(config.path), "hosts": len(config.hosts)})
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _runtime_diagnostic() -> DiagnosticResult:
|
|
76
|
+
paths = runtime_paths()
|
|
77
|
+
try:
|
|
78
|
+
paths.ensure_directory()
|
|
79
|
+
except OSError as exc:
|
|
80
|
+
return DiagnosticResult("runtime", "fail", str(exc), {"directory": str(paths.directory)})
|
|
81
|
+
mode = paths.directory.stat().st_mode & 0o777
|
|
82
|
+
status = "pass" if mode == 0o700 else "fail"
|
|
83
|
+
return DiagnosticResult(
|
|
84
|
+
"runtime",
|
|
85
|
+
status,
|
|
86
|
+
"runtime directory is private" if status == "pass" else "runtime directory must use mode 0700",
|
|
87
|
+
{"directory": str(paths.directory), "mode": format(mode, "04o")},
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _daemon_diagnostic(client: DoctorClient) -> DiagnosticResult:
|
|
92
|
+
try:
|
|
93
|
+
hello = client.hello()
|
|
94
|
+
except Exception as exc:
|
|
95
|
+
return DiagnosticResult("daemon", "fail", f"daemon unavailable: {exc}", {})
|
|
96
|
+
valid = hello.get("service") == "hostbridge" and hello.get("protocol") == 1
|
|
97
|
+
return DiagnosticResult(
|
|
98
|
+
"daemon",
|
|
99
|
+
"pass" if valid else "fail",
|
|
100
|
+
"daemon protocol v1 is healthy" if valid else "daemon returned an incompatible handshake",
|
|
101
|
+
dict(hello),
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _executable_diagnostic(paths: list[str]) -> DiagnosticResult:
|
|
106
|
+
if not paths:
|
|
107
|
+
return DiagnosticResult("executable", "fail", "hostbridge executable was not found", {"paths": []})
|
|
108
|
+
return DiagnosticResult("executable", "pass", "hostbridge executable found", {"paths": paths})
|
|
@@ -34,11 +34,12 @@ class HostConfig:
|
|
|
34
34
|
secrets: dict[str, str] = field(default_factory=dict)
|
|
35
35
|
login_steps: list[LoginStep] = field(default_factory=list)
|
|
36
36
|
ready_expect: str | None = None
|
|
37
|
+
transport: str = "auto"
|
|
37
38
|
|
|
38
39
|
@classmethod
|
|
39
|
-
def script(cls, host_id: str, label: str, script: Path | str) -> HostConfig:
|
|
40
|
+
def script(cls, host_id: str, label: str, script: Path | str, *, transport: str = "auto") -> HostConfig:
|
|
40
41
|
path = Path(script).expanduser()
|
|
41
|
-
return cls(host_id, label, "/bin/sh", [str(path)], "script", path)
|
|
42
|
+
return cls(host_id, label, "/bin/sh", [str(path)], "script", path, transport=transport)
|
|
42
43
|
|
|
43
44
|
@classmethod
|
|
44
45
|
def command_line(
|
|
@@ -52,6 +53,7 @@ class HostConfig:
|
|
|
52
53
|
secrets: dict[str, str] | None = None,
|
|
53
54
|
login_steps: list[LoginStep] | None = None,
|
|
54
55
|
ready_expect: str | None = None,
|
|
56
|
+
transport: str = "auto",
|
|
55
57
|
) -> HostConfig:
|
|
56
58
|
return cls(
|
|
57
59
|
host_id,
|
|
@@ -63,6 +65,7 @@ class HostConfig:
|
|
|
63
65
|
secrets or {},
|
|
64
66
|
login_steps or [],
|
|
65
67
|
ready_expect,
|
|
68
|
+
transport,
|
|
66
69
|
)
|
|
67
70
|
|
|
68
71
|
def describe(self) -> dict[str, object]:
|
|
@@ -72,6 +75,7 @@ class HostConfig:
|
|
|
72
75
|
"connection_type": self.connection_type,
|
|
73
76
|
"command": self.command,
|
|
74
77
|
"args": list(self.args),
|
|
78
|
+
"transport": self.transport,
|
|
75
79
|
}
|
|
76
80
|
if self.script_path is not None:
|
|
77
81
|
info.update(
|
|
@@ -130,16 +134,40 @@ def _parse_host(item: object, path: Path, index: int) -> HostConfig:
|
|
|
130
134
|
secrets = _parse_secrets(item.get("secrets", {}), path, index)
|
|
131
135
|
login_steps = _parse_login_steps(item.get("login_steps", []), path, index)
|
|
132
136
|
ready_expect = _parse_ready_expect(item.get("ready_expect"), path, index)
|
|
137
|
+
transport = str(item.get("transport", "auto")).strip().lower()
|
|
138
|
+
if transport not in {"auto", "pty", "ssh"}:
|
|
139
|
+
raise ValueError(f"{path} hosts[{index}].transport must be one of: auto, pty, ssh")
|
|
133
140
|
|
|
134
141
|
if "script" in item:
|
|
135
142
|
script = str(item.get("script", "")).strip()
|
|
136
143
|
if not script:
|
|
137
144
|
raise ValueError(f"{path} hosts[{index}].script must not be empty")
|
|
138
|
-
host = HostConfig.script(host_id, label, script)
|
|
139
|
-
return HostConfig(
|
|
145
|
+
host = HostConfig.script(host_id, label, script, transport=transport)
|
|
146
|
+
return HostConfig(
|
|
147
|
+
host.id,
|
|
148
|
+
host.label,
|
|
149
|
+
host.command,
|
|
150
|
+
host.args,
|
|
151
|
+
host.connection_type,
|
|
152
|
+
host.script_path,
|
|
153
|
+
secrets,
|
|
154
|
+
login_steps,
|
|
155
|
+
ready_expect,
|
|
156
|
+
transport,
|
|
157
|
+
)
|
|
140
158
|
|
|
141
159
|
if "ssh" in item:
|
|
142
|
-
return HostConfig.command_line(
|
|
160
|
+
return HostConfig.command_line(
|
|
161
|
+
host_id,
|
|
162
|
+
label,
|
|
163
|
+
"ssh",
|
|
164
|
+
_parse_ssh_args(item["ssh"], path, index),
|
|
165
|
+
connection_type="ssh",
|
|
166
|
+
secrets=secrets,
|
|
167
|
+
login_steps=login_steps,
|
|
168
|
+
ready_expect=ready_expect,
|
|
169
|
+
transport=transport,
|
|
170
|
+
)
|
|
143
171
|
|
|
144
172
|
command = str(item.get("command", "")).strip()
|
|
145
173
|
args = item.get("args", [])
|
|
@@ -147,7 +175,16 @@ def _parse_host(item: object, path: Path, index: int) -> HostConfig:
|
|
|
147
175
|
raise ValueError(f"{path} hosts[{index}].command must not be empty")
|
|
148
176
|
if not isinstance(args, list) or not all(isinstance(arg, str) for arg in args):
|
|
149
177
|
raise ValueError(f"{path} hosts[{index}].args must be a list of strings")
|
|
150
|
-
return HostConfig.command_line(
|
|
178
|
+
return HostConfig.command_line(
|
|
179
|
+
host_id,
|
|
180
|
+
label,
|
|
181
|
+
command,
|
|
182
|
+
args,
|
|
183
|
+
secrets=secrets,
|
|
184
|
+
login_steps=login_steps,
|
|
185
|
+
ready_expect=ready_expect,
|
|
186
|
+
transport=transport,
|
|
187
|
+
)
|
|
151
188
|
|
|
152
189
|
|
|
153
190
|
def _parse_ready_expect(raw_ready_expect: object, path: Path, index: int) -> str | None:
|
|
@@ -196,7 +233,15 @@ def _parse_ssh_args(raw_ssh: object, path: Path, index: int) -> list[str]:
|
|
|
196
233
|
args: list[str] = []
|
|
197
234
|
port = raw_ssh.get("port")
|
|
198
235
|
if port not in (None, ""):
|
|
199
|
-
|
|
236
|
+
if isinstance(port, bool) or not isinstance(port, int | str):
|
|
237
|
+
raise ValueError(f"{path} hosts[{index}].ssh.port must be an integer")
|
|
238
|
+
try:
|
|
239
|
+
parsed_port = int(port)
|
|
240
|
+
except ValueError as exc:
|
|
241
|
+
raise ValueError(f"{path} hosts[{index}].ssh.port must be an integer") from exc
|
|
242
|
+
if not 1 <= parsed_port <= 65535:
|
|
243
|
+
raise ValueError(f"{path} hosts[{index}].ssh.port must be between 1 and 65535")
|
|
244
|
+
args.extend(["-p", str(parsed_port)])
|
|
200
245
|
identity_file = str(raw_ssh.get("identity_file", "")).strip()
|
|
201
246
|
if identity_file:
|
|
202
247
|
args.extend(["-i", str(Path(identity_file).expanduser())])
|
|
@@ -3,19 +3,40 @@ from __future__ import annotations
|
|
|
3
3
|
import asyncio
|
|
4
4
|
import json
|
|
5
5
|
import posixpath
|
|
6
|
+
import re
|
|
6
7
|
import shlex
|
|
7
8
|
import sys
|
|
9
|
+
import tempfile
|
|
8
10
|
import time
|
|
9
|
-
from contextlib import suppress
|
|
10
11
|
from dataclasses import dataclass, field
|
|
12
|
+
from functools import partial
|
|
11
13
|
from pathlib import Path
|
|
14
|
+
from typing import Any, cast
|
|
12
15
|
|
|
13
16
|
import asyncssh
|
|
14
17
|
|
|
15
|
-
from .
|
|
16
|
-
from .manager import DEFAULT_COMMAND_TIMEOUT, DEFAULT_MAX_TRANSFER_BYTES, RemoteCommandError, SessionManager, _new_token
|
|
18
|
+
from .client import HostBridgeClient, HostBridgeError
|
|
17
19
|
|
|
18
20
|
DEFAULT_PORT_RANGE = range(2222, 2300)
|
|
21
|
+
DEFAULT_COMMAND_TIMEOUT = 60
|
|
22
|
+
DEFAULT_MAX_TRANSFER_BYTES = 1024 * 1024 * 1024
|
|
23
|
+
EXEC_STDIN_PROBE_TIMEOUT = 0.05
|
|
24
|
+
STDIN_READING_COMMANDS = [
|
|
25
|
+
re.compile(r"\bcat\s*(?:>|>>)"),
|
|
26
|
+
re.compile(r"\btee(?:\s|$)"),
|
|
27
|
+
re.compile(r"(?:^|\s)(?:/bin/)?(?:sh|bash|dash|zsh)\b[^\n;]*\s-s(?:\s|$)"),
|
|
28
|
+
re.compile(r"(?:^|\s)(?:python[\d.]*|node)(?:\s+\S+)*\s+-($|\s)"),
|
|
29
|
+
re.compile(r"(?:^|\s)tar\s+(?:-[A-Za-z]*x[A-Za-z]*|[A-Za-z]*x[A-Za-z]*)(?:\s|$)"),
|
|
30
|
+
re.compile(r"(?:^|\s)tar\s+.*(?:^|\s)f\s*-(?:\s|$)"),
|
|
31
|
+
re.compile(r"(?:^|\s)dd\s+.*\bof="),
|
|
32
|
+
re.compile(r"(?:^|\s)scp(?:\s+\S+)*\s+-[A-Za-z]*(?:t|f)[A-Za-z]*(?:\s|$)"),
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _to_bytes(chunk: str | bytes) -> bytes:
|
|
37
|
+
if isinstance(chunk, bytes):
|
|
38
|
+
return chunk
|
|
39
|
+
return chunk.encode("utf-8")
|
|
19
40
|
|
|
20
41
|
|
|
21
42
|
class HostBridgeSSHServer(asyncssh.SSHServer):
|
|
@@ -36,8 +57,8 @@ class HostBridgeSSHServer(asyncssh.SSHServer):
|
|
|
36
57
|
|
|
37
58
|
|
|
38
59
|
class HostBridgeSSHProcess:
|
|
39
|
-
def __init__(self,
|
|
40
|
-
self.
|
|
60
|
+
def __init__(self, client: HostBridgeClient, session_factory: HostBridgeSessionFactory, timeout: int, output_limit: int) -> None:
|
|
61
|
+
self._client = client
|
|
41
62
|
self._session_factory = session_factory
|
|
42
63
|
self._timeout = timeout
|
|
43
64
|
self._output_limit = output_limit
|
|
@@ -57,26 +78,57 @@ class HostBridgeSSHProcess:
|
|
|
57
78
|
|
|
58
79
|
loop = asyncio.get_running_loop()
|
|
59
80
|
try:
|
|
81
|
+
stdin_data = await self._read_exec_stdin(process)
|
|
60
82
|
result = await loop.run_in_executor(
|
|
61
83
|
None,
|
|
62
|
-
lambda: self.
|
|
84
|
+
lambda: self._client.exec(
|
|
63
85
|
session_id,
|
|
64
86
|
command,
|
|
87
|
+
stdin=stdin_data,
|
|
65
88
|
timeout=self._timeout,
|
|
66
|
-
token_factory=_new_token,
|
|
67
89
|
output_limit=self._output_limit,
|
|
90
|
+
owner="mock-ssh",
|
|
68
91
|
),
|
|
69
92
|
)
|
|
70
|
-
if result.
|
|
71
|
-
|
|
72
|
-
|
|
93
|
+
if result.stdout:
|
|
94
|
+
output = result.stdout.decode("utf-8", errors="replace")
|
|
95
|
+
process.stdout.write(output)
|
|
96
|
+
if not output.endswith("\n"):
|
|
73
97
|
process.stdout.write("\n")
|
|
98
|
+
if result.stderr:
|
|
99
|
+
process.stderr.write(result.stderr.decode("utf-8", errors="replace"))
|
|
74
100
|
process.exit(124 if result.timed_out else int(result.exit_code or 0))
|
|
75
101
|
except Exception as exc:
|
|
76
102
|
print(f"hostbridge mock-ssh exec failed: {exc}", file=sys.stderr, flush=True)
|
|
77
103
|
process.stderr.write(f"hostbridge mock-ssh error: {exc}\n")
|
|
78
104
|
process.exit(1)
|
|
79
105
|
|
|
106
|
+
async def _read_exec_stdin(self, process: asyncssh.SSHServerProcess[str]) -> bytes | None:
|
|
107
|
+
if process.stdin.at_eof():
|
|
108
|
+
return b""
|
|
109
|
+
try:
|
|
110
|
+
first_chunk = await asyncio.wait_for(process.stdin.read(4096), timeout=EXEC_STDIN_PROBE_TIMEOUT)
|
|
111
|
+
except TimeoutError:
|
|
112
|
+
return None
|
|
113
|
+
if not first_chunk:
|
|
114
|
+
return b""
|
|
115
|
+
|
|
116
|
+
first_chunk_bytes = _to_bytes(first_chunk)
|
|
117
|
+
total = len(first_chunk_bytes)
|
|
118
|
+
if total > self._output_limit:
|
|
119
|
+
raise HostBridgeError("resource_limit", f"mock-ssh exec stdin exceeds limit ({self._output_limit} bytes)")
|
|
120
|
+
chunks = [first_chunk_bytes]
|
|
121
|
+
while not process.stdin.at_eof():
|
|
122
|
+
chunk = await asyncio.wait_for(process.stdin.read(4096), timeout=self._timeout)
|
|
123
|
+
if not chunk:
|
|
124
|
+
break
|
|
125
|
+
chunk_bytes = _to_bytes(chunk)
|
|
126
|
+
total += len(chunk_bytes)
|
|
127
|
+
if total > self._output_limit:
|
|
128
|
+
raise HostBridgeError("resource_limit", f"mock-ssh exec stdin exceeds limit ({self._output_limit} bytes)")
|
|
129
|
+
chunks.append(chunk_bytes)
|
|
130
|
+
return b"".join(chunks)
|
|
131
|
+
|
|
80
132
|
async def _run_shell(self, process: asyncssh.SSHServerProcess[str], session_id: str) -> None:
|
|
81
133
|
loop = asyncio.get_running_loop()
|
|
82
134
|
stop = asyncio.Event()
|
|
@@ -90,7 +142,12 @@ class HostBridgeSSHProcess:
|
|
|
90
142
|
break
|
|
91
143
|
await loop.run_in_executor(
|
|
92
144
|
None,
|
|
93
|
-
|
|
145
|
+
partial(
|
|
146
|
+
self._client.shell_write,
|
|
147
|
+
session_id,
|
|
148
|
+
_to_bytes(data),
|
|
149
|
+
owner="mock-ssh",
|
|
150
|
+
),
|
|
94
151
|
)
|
|
95
152
|
finally:
|
|
96
153
|
input_done.set()
|
|
@@ -101,23 +158,22 @@ class HostBridgeSSHProcess:
|
|
|
101
158
|
while not stop.is_set():
|
|
102
159
|
result = await loop.run_in_executor(
|
|
103
160
|
None,
|
|
104
|
-
lambda: self.
|
|
161
|
+
lambda: self._client.shell_read(
|
|
105
162
|
session_id,
|
|
106
163
|
timeout=0.2,
|
|
107
|
-
|
|
108
|
-
|
|
164
|
+
max_bytes=4096,
|
|
165
|
+
owner="mock-ssh",
|
|
109
166
|
),
|
|
110
167
|
)
|
|
111
|
-
|
|
112
|
-
if output:
|
|
168
|
+
if result.data:
|
|
113
169
|
idle_after_input = 0
|
|
114
|
-
process.stdout.write(
|
|
170
|
+
process.stdout.write(result.data.decode("utf-8", errors="replace"))
|
|
115
171
|
elif input_done.is_set():
|
|
116
172
|
idle_after_input += 1
|
|
117
173
|
if idle_after_input >= 5:
|
|
118
174
|
stop.set()
|
|
119
175
|
break
|
|
120
|
-
if not result.
|
|
176
|
+
if not result.alive:
|
|
121
177
|
stop.set()
|
|
122
178
|
break
|
|
123
179
|
except Exception as exc:
|
|
@@ -143,36 +199,39 @@ class HostBridgeSFTPHandle:
|
|
|
143
199
|
mode: int = 0o644
|
|
144
200
|
|
|
145
201
|
|
|
202
|
+
@dataclass(frozen=True, slots=True)
|
|
203
|
+
class _TextCommandResult:
|
|
204
|
+
output: str
|
|
205
|
+
exit_code: int | None
|
|
206
|
+
timed_out: bool
|
|
207
|
+
|
|
208
|
+
|
|
146
209
|
class HostBridgeSessionFactory:
|
|
147
|
-
def __init__(self,
|
|
148
|
-
self.
|
|
210
|
+
def __init__(self, client: HostBridgeClient, host_id: str, connect_timeout: int = 60) -> None:
|
|
211
|
+
self._client = client
|
|
149
212
|
self._host_id = host_id
|
|
150
213
|
self._connect_timeout = connect_timeout
|
|
151
214
|
|
|
152
215
|
def open(self) -> str:
|
|
153
|
-
session = self.
|
|
154
|
-
|
|
155
|
-
connect_timeout=self._connect_timeout,
|
|
156
|
-
owner_agent_id="mock-ssh",
|
|
157
|
-
)
|
|
158
|
-
return session.id
|
|
216
|
+
session = self._client.open_session(self._host_id, owner="mock-ssh")
|
|
217
|
+
return session.session_id
|
|
159
218
|
|
|
160
219
|
def close(self, session_id: str) -> None:
|
|
161
220
|
with _suppress_error():
|
|
162
|
-
self.
|
|
221
|
+
self._client.close_session(session_id, owner="mock-ssh", force=True)
|
|
163
222
|
|
|
164
223
|
|
|
165
224
|
class HostBridgeSFTPServer(asyncssh.SFTPServer):
|
|
166
225
|
def __init__(
|
|
167
226
|
self,
|
|
168
227
|
chan: asyncssh.SSHServerChannel,
|
|
169
|
-
|
|
228
|
+
client: HostBridgeClient,
|
|
170
229
|
session_factory: HostBridgeSessionFactory,
|
|
171
230
|
timeout: int,
|
|
172
231
|
max_bytes: int,
|
|
173
232
|
) -> None:
|
|
174
233
|
super().__init__(chan)
|
|
175
|
-
self.
|
|
234
|
+
self._client = client
|
|
176
235
|
self._session_factory = session_factory
|
|
177
236
|
self._session_id = session_factory.open()
|
|
178
237
|
self._timeout = timeout
|
|
@@ -278,17 +337,25 @@ class HostBridgeSFTPServer(asyncssh.SFTPServer):
|
|
|
278
337
|
self._checked_run(f"python3 -c {shlex.quote(script)} {shlex.quote(remote_path)} {atime} {mtime}")
|
|
279
338
|
|
|
280
339
|
def _download(self, remote_path: str) -> bytes:
|
|
340
|
+
temporary_path: Path | None = None
|
|
281
341
|
try:
|
|
282
|
-
|
|
342
|
+
with tempfile.NamedTemporaryFile(prefix="hostbridge-sftp-", delete=False) as temporary:
|
|
343
|
+
temporary_path = Path(temporary.name)
|
|
344
|
+
result = self._client.download_file(
|
|
283
345
|
self._session_id,
|
|
284
346
|
remote_path,
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
347
|
+
temporary_path,
|
|
348
|
+
timeout=self._timeout,
|
|
349
|
+
owner="mock-ssh",
|
|
288
350
|
)
|
|
289
|
-
|
|
351
|
+
if result.bytes_transferred > self._max_bytes:
|
|
352
|
+
raise HostBridgeError("resource_limit", f"file exceeds mock-ssh limit ({self._max_bytes} bytes)")
|
|
353
|
+
return temporary_path.read_bytes()
|
|
354
|
+
except HostBridgeError as exc:
|
|
290
355
|
raise self._sftp_error(exc) from exc
|
|
291
|
-
|
|
356
|
+
finally:
|
|
357
|
+
if temporary_path is not None:
|
|
358
|
+
temporary_path.unlink(missing_ok=True)
|
|
292
359
|
|
|
293
360
|
def _upload(self, handle: HostBridgeSFTPHandle) -> None:
|
|
294
361
|
size = len(handle.data)
|
|
@@ -299,19 +366,27 @@ class HostBridgeSFTPServer(asyncssh.SFTPServer):
|
|
|
299
366
|
data.extend(b"\x00" * (size - len(data)))
|
|
300
367
|
for offset, chunk in sorted(handle.writes.items()):
|
|
301
368
|
data[offset : offset + len(chunk)] = chunk
|
|
369
|
+
if len(data) > self._max_bytes:
|
|
370
|
+
raise asyncssh.SFTPFailure(f"file exceeds mock-ssh limit ({self._max_bytes} bytes)")
|
|
371
|
+
temporary_path: Path | None = None
|
|
302
372
|
try:
|
|
303
|
-
|
|
373
|
+
with tempfile.NamedTemporaryFile(prefix="hostbridge-sftp-", delete=False) as temporary:
|
|
374
|
+
temporary.write(data)
|
|
375
|
+
temporary_path = Path(temporary.name)
|
|
376
|
+
self._client.upload_file(
|
|
304
377
|
self._session_id,
|
|
378
|
+
temporary_path,
|
|
305
379
|
handle.path,
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
token_factory=_new_token,
|
|
310
|
-
owner_agent_id="mock-ssh",
|
|
380
|
+
mode=handle.mode & 0o7777,
|
|
381
|
+
timeout=self._timeout,
|
|
382
|
+
owner="mock-ssh",
|
|
311
383
|
)
|
|
312
|
-
except
|
|
384
|
+
except HostBridgeError as exc:
|
|
313
385
|
print(f"hostbridge mock-ssh sftp upload failed: {exc}", file=sys.stderr, flush=True)
|
|
314
386
|
raise self._sftp_error(exc) from exc
|
|
387
|
+
finally:
|
|
388
|
+
if temporary_path is not None:
|
|
389
|
+
temporary_path.unlink(missing_ok=True)
|
|
315
390
|
|
|
316
391
|
def _stat(self, remote_path: str, *, follow: bool) -> asyncssh.SFTPAttrs:
|
|
317
392
|
script = r"""
|
|
@@ -362,7 +437,7 @@ print(json.dumps(items))
|
|
|
362
437
|
]
|
|
363
438
|
|
|
364
439
|
def _attrs(self, data: dict[str, object]) -> asyncssh.SFTPAttrs:
|
|
365
|
-
permissions =
|
|
440
|
+
permissions = self._int_value(data.get("permissions"), 0)
|
|
366
441
|
raw_type = data.get("type")
|
|
367
442
|
file_type = {
|
|
368
443
|
"file": asyncssh.FILEXFER_TYPE_REGULAR,
|
|
@@ -371,39 +446,52 @@ print(json.dumps(items))
|
|
|
371
446
|
}.get(str(raw_type), asyncssh.FILEXFER_TYPE_UNKNOWN)
|
|
372
447
|
return asyncssh.SFTPAttrs(
|
|
373
448
|
type=file_type,
|
|
374
|
-
size=
|
|
375
|
-
uid=
|
|
376
|
-
gid=
|
|
449
|
+
size=self._int_value(data.get("size"), 0),
|
|
450
|
+
uid=self._int_value(data.get("uid"), 0),
|
|
451
|
+
gid=self._int_value(data.get("gid"), 0),
|
|
377
452
|
permissions=permissions,
|
|
378
|
-
atime=
|
|
379
|
-
mtime=
|
|
453
|
+
atime=self._int_value(data.get("atime"), int(time.time())),
|
|
454
|
+
mtime=self._int_value(data.get("mtime"), int(time.time())),
|
|
380
455
|
)
|
|
381
456
|
|
|
457
|
+
@staticmethod
|
|
458
|
+
def _int_value(value: object, default: int) -> int:
|
|
459
|
+
if value is None:
|
|
460
|
+
return default
|
|
461
|
+
if isinstance(value, bool) or not isinstance(value, int | float | str):
|
|
462
|
+
raise asyncssh.SFTPFailure("remote metadata contains a non-numeric field")
|
|
463
|
+
try:
|
|
464
|
+
return int(value)
|
|
465
|
+
except ValueError as exc:
|
|
466
|
+
raise asyncssh.SFTPFailure("remote metadata contains an invalid numeric field") from exc
|
|
467
|
+
|
|
382
468
|
def _checked_run(self, command: str):
|
|
383
469
|
try:
|
|
384
|
-
result = self.
|
|
470
|
+
result = self._client.exec(
|
|
385
471
|
self._session_id,
|
|
386
472
|
command,
|
|
387
473
|
timeout=self._timeout,
|
|
388
|
-
|
|
389
|
-
owner_agent_id="mock-ssh",
|
|
474
|
+
owner="mock-ssh",
|
|
390
475
|
)
|
|
391
|
-
except
|
|
476
|
+
except HostBridgeError as exc:
|
|
392
477
|
raise self._sftp_error(exc) from exc
|
|
478
|
+
output = result.stdout.decode("utf-8", errors="replace")
|
|
393
479
|
if result.timed_out:
|
|
394
480
|
print(f"hostbridge mock-ssh sftp command timed out: {command}", file=sys.stderr, flush=True)
|
|
395
481
|
raise asyncssh.SFTPFailure("remote command timed out")
|
|
396
482
|
if result.exit_code not in (0, None):
|
|
397
|
-
error = self._sftp_error(
|
|
483
|
+
error = self._sftp_error(
|
|
484
|
+
HostBridgeError("remote_error", output or f"remote command exited {result.exit_code}")
|
|
485
|
+
)
|
|
398
486
|
if isinstance(error, asyncssh.SFTPNoSuchFile):
|
|
399
487
|
raise error
|
|
400
488
|
print(
|
|
401
|
-
f"hostbridge mock-ssh sftp command failed exit={result.exit_code}: {command}\n{
|
|
489
|
+
f"hostbridge mock-ssh sftp command failed exit={result.exit_code}: {command}\n{output}",
|
|
402
490
|
file=sys.stderr,
|
|
403
491
|
flush=True,
|
|
404
492
|
)
|
|
405
493
|
raise error
|
|
406
|
-
return result
|
|
494
|
+
return _TextCommandResult(output, result.exit_code, result.timed_out)
|
|
407
495
|
|
|
408
496
|
def _path(self, path: bytes) -> str:
|
|
409
497
|
text = path.decode("utf-8", "surrogateescape")
|
|
@@ -416,7 +504,7 @@ print(json.dumps(items))
|
|
|
416
504
|
raise asyncssh.SFTPInvalidHandle("invalid HostBridge SFTP handle")
|
|
417
505
|
return file_obj
|
|
418
506
|
|
|
419
|
-
def _sftp_error(self, exc:
|
|
507
|
+
def _sftp_error(self, exc: HostBridgeError) -> Exception:
|
|
420
508
|
text = str(exc)
|
|
421
509
|
lowered = text.lower()
|
|
422
510
|
if "no such file" in lowered or "not found" in lowered or "filenotfounderror" in lowered:
|
|
@@ -595,49 +683,51 @@ async def serve_mock_ssh(
|
|
|
595
683
|
command_timeout: int = DEFAULT_COMMAND_TIMEOUT,
|
|
596
684
|
output_limit: int = DEFAULT_MAX_TRANSFER_BYTES,
|
|
597
685
|
) -> int:
|
|
686
|
+
if config_path is not None:
|
|
687
|
+
raise HostBridgeError(
|
|
688
|
+
"invalid_request",
|
|
689
|
+
"mock-ssh no longer accepts a separate config path; start the v1 daemon with the intended config",
|
|
690
|
+
)
|
|
598
691
|
key_dir = key_dir or default_key_dir(host_id)
|
|
599
692
|
ssh_host_alias = ssh_host_alias or default_ssh_host_alias(host_id)
|
|
600
693
|
host_key_path, client_key_path, public_key = ensure_keypair(key_dir)
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
694
|
+
client = HostBridgeClient()
|
|
695
|
+
client.hello()
|
|
696
|
+
session_factory = HostBridgeSessionFactory(client, host_id, connect_timeout)
|
|
697
|
+
server, selected_port = await create_server_on_available_port(
|
|
698
|
+
server_factory=lambda: HostBridgeSSHServer(public_key),
|
|
699
|
+
listen_host=listen_host,
|
|
700
|
+
port=port,
|
|
701
|
+
server_host_keys=[str(host_key_path)],
|
|
702
|
+
process_factory=HostBridgeSSHProcess(client, session_factory, command_timeout, output_limit),
|
|
703
|
+
sftp_factory=lambda chan: HostBridgeSFTPServer(chan, client, session_factory, command_timeout, output_limit),
|
|
704
|
+
)
|
|
705
|
+
installed_config_path: Path | None = None
|
|
706
|
+
if install_ssh_config:
|
|
707
|
+
installed_config_path = install_ssh_config_block(
|
|
708
|
+
host_alias=ssh_host_alias,
|
|
606
709
|
listen_host=listen_host,
|
|
607
|
-
port=
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
sftp_factory=lambda chan: HostBridgeSFTPServer(chan, manager, session_factory, command_timeout, output_limit),
|
|
710
|
+
port=selected_port,
|
|
711
|
+
client_key_path=client_key_path,
|
|
712
|
+
config_path=ssh_config_path,
|
|
611
713
|
)
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
)
|
|
621
|
-
print(f"hostbridge mock-ssh forwarding host '{host_id}'", flush=True)
|
|
622
|
-
print(f"listening on ssh://{listen_host}:{selected_port}", flush=True)
|
|
623
|
-
print(f"ssh host alias: {ssh_host_alias}", flush=True)
|
|
624
|
-
if installed_config_path is not None:
|
|
625
|
-
print(f"ssh config: {installed_config_path}", flush=True)
|
|
626
|
-
print(f"identity file: {client_key_path}", flush=True)
|
|
627
|
-
print("supports SSH exec, shell, SFTP, and SCP; press Ctrl-C to stop", flush=True)
|
|
628
|
-
await server.wait_closed()
|
|
629
|
-
finally:
|
|
630
|
-
manager.close_all_sessions()
|
|
631
|
-
manager.stop_idle_reaper()
|
|
714
|
+
print(f"hostbridge mock-ssh forwarding host '{host_id}'", flush=True)
|
|
715
|
+
print(f"listening on ssh://{listen_host}:{selected_port}", flush=True)
|
|
716
|
+
print(f"ssh host alias: {ssh_host_alias}", flush=True)
|
|
717
|
+
if installed_config_path is not None:
|
|
718
|
+
print(f"ssh config: {installed_config_path}", flush=True)
|
|
719
|
+
print(f"identity file: {client_key_path}", flush=True)
|
|
720
|
+
print("supports SSH exec, shell, SFTP, and SCP; press Ctrl-C to stop", flush=True)
|
|
721
|
+
await server.wait_closed()
|
|
632
722
|
return 0
|
|
633
723
|
|
|
634
724
|
|
|
635
725
|
def run_mock_ssh(**kwargs: object) -> int:
|
|
636
726
|
try:
|
|
637
|
-
return asyncio.run(serve_mock_ssh(**kwargs))
|
|
727
|
+
return asyncio.run(cast(Any, serve_mock_ssh)(**kwargs))
|
|
638
728
|
except KeyboardInterrupt:
|
|
639
729
|
return 130
|
|
640
|
-
except
|
|
730
|
+
except HostBridgeError as exc:
|
|
641
731
|
print(f"hostbridge mock-ssh failed: {exc}")
|
|
642
732
|
return 1
|
|
643
733
|
|