@neoline/hostbridge 1.4.0 → 1.4.1
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 +37 -1
- package/package.json +1 -1
- package/pyproject.toml +2 -1
- package/src/server_control_mcp/__init__.py +1 -1
- package/src/server_control_mcp/cli.py +38 -1
- package/src/server_control_mcp/daemon.py +2 -2
- package/src/server_control_mcp/manager.py +52 -5
- package/src/server_control_mcp/mock_ssh.py +647 -0
- package/src/server_control_mcp/server.py +7 -0
package/README.md
CHANGED
|
@@ -85,7 +85,43 @@ file_upload(session_id, "/absolute/local/script.py", "/remote/work/script.py")
|
|
|
85
85
|
file_download(session_id, "/remote/work/result.log", "/absolute/local/result.log")
|
|
86
86
|
```
|
|
87
87
|
|
|
88
|
-
Give file tools local and remote paths; do not paste base64 into tool arguments. Files larger than 100MiB are deliberately rejected with guidance to use remote pull instead, for example `curl`, `wget`, `git clone`, `modelscope download`, or another remote-native downloader.
|
|
88
|
+
Give file tools local and remote paths; do not paste base64 into tool arguments. Files larger than 100MiB are deliberately rejected with guidance to use remote pull instead, for example `curl`, `wget`, `git clone`, `modelscope download`, or another remote-native downloader. The MCP file tools intentionally keep this shell/base64 backend for non-standard login flows. The optional `mock-ssh` bridge below exposes SFTP/SCP protocol compatibility on top of the same backend for clients which require SSH file-transfer channels.
|
|
89
|
+
|
|
90
|
+
## Mock SSH bridge
|
|
91
|
+
|
|
92
|
+
Some clients, including compute providers, know how to connect to SSH targets but cannot run HostBridge's MCP connector inside their local sandbox. `hostbridge mock-ssh` exposes one configured HostBridge host as a local SSH server while keeping the actual remote access path behind HostBridge. This is useful when the client-side SSH implementation runs outside a restricted sandbox.
|
|
93
|
+
|
|
94
|
+
```bash
|
|
95
|
+
hostbridge mock-ssh a800_95
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
By default HostBridge selects a free local port from `2222-2299`, creates host/client keys under `~/.hostbridge/mock-ssh/<host_id>/`, and writes a managed `Host hostbridge-<host_id>` block into `~/.ssh/config`. The command prints the chosen alias, port, config path, and identity file:
|
|
99
|
+
|
|
100
|
+
```text
|
|
101
|
+
hostbridge mock-ssh forwarding host 'a800_95'
|
|
102
|
+
listening on ssh://127.0.0.1:2222
|
|
103
|
+
ssh host alias: hostbridge-a800_95
|
|
104
|
+
ssh config: /Users/alice/.ssh/config
|
|
105
|
+
identity file: /Users/alice/.hostbridge/mock-ssh/a800_95/client_ed25519_key
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
Point the compute provider at the printed SSH alias, for example `ssh:hostbridge-a800_95`. To remove the managed SSH config block later:
|
|
109
|
+
|
|
110
|
+
```bash
|
|
111
|
+
hostbridge mock-ssh a800_95 --remove
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
Advanced options are still available for fixed deployments: `--listen-host`, `--port`, `--key-dir`, `--ssh-host-alias`, `--ssh-config`, and `--no-ssh-config`.
|
|
115
|
+
|
|
116
|
+
The bridge supports:
|
|
117
|
+
|
|
118
|
+
- SSH exec requests, suitable for compute `call_command` APIs.
|
|
119
|
+
- Interactive shell channels.
|
|
120
|
+
- SFTP upload/download/list/stat/mkdir/remove/rename operations.
|
|
121
|
+
- OpenSSH `scp` in its default SFTP-backed mode.
|
|
122
|
+
- Legacy OpenSSH `scp -O` mode through AsyncSSH's SCP server.
|
|
123
|
+
|
|
124
|
+
Each SSH channel opens its own HostBridge backend session and closes it when the channel ends, so an interactive shell `exit` does not kill later exec or file-transfer requests. The SFTP/SCP implementation is a compatibility layer backed by HostBridge's shell/base64 transfer primitives, so it keeps working across JumpServer/menu/wrapper login flows where direct remote SFTP is unavailable. It inherits the same transfer size limit (100MiB by default); for larger files, start a remote-native pull from the target host.
|
|
89
125
|
|
|
90
126
|
## Daemon lifecycle
|
|
91
127
|
|
package/package.json
CHANGED
package/pyproject.toml
CHANGED
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "hostbridge"
|
|
7
|
-
version = "1.4.
|
|
7
|
+
version = "1.4.1"
|
|
8
8
|
description = "HostBridge MCP server for persistent allowlisted SSH and shell sessions."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.11"
|
|
@@ -39,6 +39,7 @@ dependencies = [
|
|
|
39
39
|
"mcp>=1.0.0",
|
|
40
40
|
"pexpect>=4.9.0",
|
|
41
41
|
"cryptography>=42.0.0",
|
|
42
|
+
"asyncssh>=2.14.0",
|
|
42
43
|
]
|
|
43
44
|
|
|
44
45
|
[project.optional-dependencies]
|
|
@@ -54,7 +54,7 @@ SUPPORTED_INSTALL_TARGETS = (
|
|
|
54
54
|
"antigravity",
|
|
55
55
|
"kiro",
|
|
56
56
|
)
|
|
57
|
-
SETUP_COMMANDS = {"install", "setup", "check", "list", "remove", "reload", "secret", "step", "daemon"}
|
|
57
|
+
SETUP_COMMANDS = {"install", "setup", "check", "list", "remove", "reload", "secret", "step", "daemon", "mock-ssh"}
|
|
58
58
|
AskFunc = Callable[[str], str]
|
|
59
59
|
SecretAskFunc = Callable[[str], str]
|
|
60
60
|
VerifyFunc = Callable[[dict[str, object], int], "VerificationResult"]
|
|
@@ -1163,6 +1163,19 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
1163
1163
|
daemon_status = daemon_subparsers.add_parser("status", help="Show daemon status")
|
|
1164
1164
|
daemon_status.add_argument("--socket", dest="socket_file", default=None, help="Unix socket path; default comes from HOSTBRIDGE_DAEMON_SOCKET or HOSTBRIDGE_DAEMON_DIR")
|
|
1165
1165
|
|
|
1166
|
+
mock_ssh_parser = subparsers.add_parser("mock-ssh", parents=[config_parent], help="Expose a HostBridge host as a local SSH server")
|
|
1167
|
+
mock_ssh_parser.add_argument("host_id")
|
|
1168
|
+
mock_ssh_parser.add_argument("--listen-host", default="127.0.0.1")
|
|
1169
|
+
mock_ssh_parser.add_argument("--port", type=int, default=None, help="Local SSH port; default auto-selects a free port from 2222-2299")
|
|
1170
|
+
mock_ssh_parser.add_argument("--key-dir", help="Directory for generated mock SSH host/client keys")
|
|
1171
|
+
mock_ssh_parser.add_argument("--ssh-host-alias", help="Host alias to manage in ~/.ssh/config; default hostbridge-<host_id>")
|
|
1172
|
+
mock_ssh_parser.add_argument("--ssh-config", help="SSH config path; default ~/.ssh/config")
|
|
1173
|
+
mock_ssh_parser.add_argument("--no-ssh-config", action="store_true", help="Do not write a managed Host block to SSH config")
|
|
1174
|
+
mock_ssh_parser.add_argument("--remove", action="store_true", help="Remove the managed SSH config block and exit")
|
|
1175
|
+
mock_ssh_parser.add_argument("--connect-timeout", type=int, default=60)
|
|
1176
|
+
mock_ssh_parser.add_argument("--command-timeout", type=int, default=DEFAULT_VERIFY_TIMEOUT)
|
|
1177
|
+
mock_ssh_parser.add_argument("--output-limit", type=int, default=120000)
|
|
1178
|
+
|
|
1166
1179
|
remove_parser = subparsers.add_parser("remove", parents=[config_parent], help="Remove a configured host")
|
|
1167
1180
|
remove_parser.add_argument("host_id")
|
|
1168
1181
|
|
|
@@ -1217,6 +1230,30 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
1217
1230
|
foreground=getattr(args, "foreground", False),
|
|
1218
1231
|
socket_file=getattr(args, "socket_file", None),
|
|
1219
1232
|
)
|
|
1233
|
+
if args.command == "mock-ssh":
|
|
1234
|
+
from .mock_ssh import run_mock_ssh, uninstall_mock_ssh
|
|
1235
|
+
|
|
1236
|
+
ssh_config_path = Path(args.ssh_config).expanduser() if args.ssh_config else None
|
|
1237
|
+
if args.remove:
|
|
1238
|
+
return uninstall_mock_ssh(
|
|
1239
|
+
args.host_id,
|
|
1240
|
+
ssh_host_alias=args.ssh_host_alias,
|
|
1241
|
+
ssh_config_path=ssh_config_path,
|
|
1242
|
+
)
|
|
1243
|
+
|
|
1244
|
+
return run_mock_ssh(
|
|
1245
|
+
host_id=args.host_id,
|
|
1246
|
+
config_path=config_path,
|
|
1247
|
+
listen_host=args.listen_host,
|
|
1248
|
+
port=args.port,
|
|
1249
|
+
key_dir=Path(args.key_dir).expanduser() if args.key_dir else None,
|
|
1250
|
+
ssh_host_alias=args.ssh_host_alias,
|
|
1251
|
+
ssh_config_path=ssh_config_path,
|
|
1252
|
+
install_ssh_config=not args.no_ssh_config,
|
|
1253
|
+
connect_timeout=args.connect_timeout,
|
|
1254
|
+
command_timeout=args.command_timeout,
|
|
1255
|
+
output_limit=args.output_limit,
|
|
1256
|
+
)
|
|
1220
1257
|
if args.command == "secret" and args.secret_command == "set":
|
|
1221
1258
|
value = getpass.getpass(f"Secret value for {args.host_id}/{args.name}: ")
|
|
1222
1259
|
set_host_secret(config_path, args.host_id, args.name, value)
|
|
@@ -267,8 +267,8 @@ def _max_sessions_per_host_from_env() -> int:
|
|
|
267
267
|
return 0
|
|
268
268
|
|
|
269
269
|
|
|
270
|
-
def build_manager() -> SessionManager:
|
|
271
|
-
registry = HostRegistry(load_hosts())
|
|
270
|
+
def build_manager(config_path: Path | str | None = None) -> SessionManager:
|
|
271
|
+
registry = HostRegistry(load_hosts(config_path))
|
|
272
272
|
policy = load_policy()
|
|
273
273
|
manager = SessionManager(
|
|
274
274
|
registry,
|
|
@@ -117,10 +117,15 @@ def _tail(value: str, limit: int = MAX_OUTPUT_CHARS) -> str:
|
|
|
117
117
|
|
|
118
118
|
|
|
119
119
|
def _extract_between_markers(raw: str, start_marker: str) -> str:
|
|
120
|
+
raw = raw.replace("\x1b[?2004h", "").replace("\x1b[?2004l", "")
|
|
120
121
|
if start_marker in raw:
|
|
121
122
|
raw = raw.split(start_marker, 1)[1]
|
|
122
123
|
lines = raw.replace("\r\n", "\n").replace("\r", "\n").split("\n")
|
|
123
|
-
cleaned = [
|
|
124
|
+
cleaned = [
|
|
125
|
+
line
|
|
126
|
+
for line in lines
|
|
127
|
+
if not line.startswith("printf '") and "__SCM_START_" not in line and "__SCM_EXIT_" not in line
|
|
128
|
+
]
|
|
124
129
|
return "\n".join(cleaned).strip("\n")
|
|
125
130
|
|
|
126
131
|
|
|
@@ -536,6 +541,44 @@ class SessionManager:
|
|
|
536
541
|
result["content"] = data.decode("utf-8")
|
|
537
542
|
return result
|
|
538
543
|
|
|
544
|
+
def file_write_bytes(
|
|
545
|
+
self,
|
|
546
|
+
session_id: str,
|
|
547
|
+
remote_path: str,
|
|
548
|
+
data: bytes,
|
|
549
|
+
*,
|
|
550
|
+
mode: str = "0644",
|
|
551
|
+
max_bytes: int = DEFAULT_MAX_TRANSFER_BYTES,
|
|
552
|
+
token_factory: TokenFactory = _new_token,
|
|
553
|
+
owner_agent_id: str | None = None,
|
|
554
|
+
) -> dict[str, object]:
|
|
555
|
+
return self._upload_bytes(
|
|
556
|
+
session_id,
|
|
557
|
+
data,
|
|
558
|
+
remote_path,
|
|
559
|
+
mode=mode,
|
|
560
|
+
max_bytes=max_bytes,
|
|
561
|
+
token_factory=token_factory,
|
|
562
|
+
owner_agent_id=owner_agent_id,
|
|
563
|
+
)
|
|
564
|
+
|
|
565
|
+
def file_read_bytes(
|
|
566
|
+
self,
|
|
567
|
+
session_id: str,
|
|
568
|
+
remote_path: str,
|
|
569
|
+
*,
|
|
570
|
+
max_bytes: int = DEFAULT_MAX_TRANSFER_BYTES,
|
|
571
|
+
token_factory: TokenFactory = _new_token,
|
|
572
|
+
owner_agent_id: str | None = None,
|
|
573
|
+
) -> dict[str, object]:
|
|
574
|
+
return self._download_bytes(
|
|
575
|
+
session_id,
|
|
576
|
+
remote_path,
|
|
577
|
+
max_bytes=max_bytes,
|
|
578
|
+
token_factory=token_factory,
|
|
579
|
+
owner_agent_id=owner_agent_id,
|
|
580
|
+
)
|
|
581
|
+
|
|
539
582
|
def file_upload(
|
|
540
583
|
self,
|
|
541
584
|
session_id: str,
|
|
@@ -635,7 +678,7 @@ class SessionManager:
|
|
|
635
678
|
finalize_script = (
|
|
636
679
|
"set -e; "
|
|
637
680
|
f"target={remote_q}; mode={mode_q}; tmp_b64={shlex.quote(tmp_b64)}; tmp_out={shlex.quote(tmp_out)}; "
|
|
638
|
-
'base64 -d "$tmp_b64" > "$tmp_out"; '
|
|
681
|
+
'{ base64 -d < "$tmp_b64" 2>/dev/null || base64 -D < "$tmp_b64"; } > "$tmp_out"; '
|
|
639
682
|
'chmod "$mode" "$tmp_out"; mv "$tmp_out" "$target"; rm -f "$tmp_b64"; '
|
|
640
683
|
f"printf 'BYTES=%s\\nSHA256=%s\\n' {len(data)} {shlex.quote(sha256)}"
|
|
641
684
|
)
|
|
@@ -787,13 +830,16 @@ class SessionManager:
|
|
|
787
830
|
)
|
|
788
831
|
|
|
789
832
|
def write(self, session_id: str, text: str, *, owner_agent_id: str | None = None) -> dict[str, object]:
|
|
833
|
+
return self.send(session_id, f"{text}\n", owner_agent_id=owner_agent_id)
|
|
834
|
+
|
|
835
|
+
def send(self, session_id: str, text: str, *, owner_agent_id: str | None = None) -> dict[str, object]:
|
|
790
836
|
session = self._get_session(session_id)
|
|
791
837
|
self._check_owner(session, owner_agent_id)
|
|
792
838
|
if session.busy:
|
|
793
839
|
raise RemoteCommandError(f"session {session_id} is busy")
|
|
794
840
|
if not session.child.isalive():
|
|
795
841
|
raise RemoteCommandError(f"session {session_id} is not alive")
|
|
796
|
-
session.child.
|
|
842
|
+
session.child.send(text)
|
|
797
843
|
session.last_used_at = time.time()
|
|
798
844
|
return {"session_id": session_id, "written": True}
|
|
799
845
|
|
|
@@ -851,10 +897,11 @@ class SessionManager:
|
|
|
851
897
|
raise RemoteCommandError(f"host {host_id} reached session limit {self._max_sessions_per_host}")
|
|
852
898
|
|
|
853
899
|
def _build_remote_line(self, command: str, token: str, start_marker: str) -> str:
|
|
854
|
-
|
|
900
|
+
encoded_command = base64.b64encode(command.encode("utf-8")).decode("ascii")
|
|
855
901
|
return (
|
|
902
|
+
f"__scm_cmd=$(printf %s {shlex.quote(encoded_command)} | {{ base64 --decode 2>/dev/null || base64 -D; }}); "
|
|
856
903
|
f"printf '\\n{start_marker}\\n'; "
|
|
857
|
-
f"{self.remote_shell} -lc
|
|
904
|
+
f"{self.remote_shell} -lc \"$__scm_cmd\"; "
|
|
858
905
|
"__scm_status=$?; "
|
|
859
906
|
f"printf '\\n__SCM_EXIT_{token}:%s\\n' \"$__scm_status\""
|
|
860
907
|
)
|
|
@@ -0,0 +1,647 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import json
|
|
5
|
+
import posixpath
|
|
6
|
+
import shlex
|
|
7
|
+
import sys
|
|
8
|
+
import time
|
|
9
|
+
from contextlib import suppress
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
import asyncssh
|
|
14
|
+
|
|
15
|
+
from .daemon import build_manager
|
|
16
|
+
from .manager import DEFAULT_COMMAND_TIMEOUT, RemoteCommandError, SessionManager, _new_token
|
|
17
|
+
|
|
18
|
+
DEFAULT_PORT_RANGE = range(2222, 2300)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class HostBridgeSSHServer(asyncssh.SSHServer):
|
|
22
|
+
def __init__(self, authorized_public_key: str) -> None:
|
|
23
|
+
self._authorized_public_key = authorized_public_key.strip()
|
|
24
|
+
|
|
25
|
+
def begin_auth(self, username: str) -> bool: # noqa: ARG002
|
|
26
|
+
return True
|
|
27
|
+
|
|
28
|
+
def public_key_auth_supported(self) -> bool:
|
|
29
|
+
return True
|
|
30
|
+
|
|
31
|
+
def password_auth_supported(self) -> bool:
|
|
32
|
+
return False
|
|
33
|
+
|
|
34
|
+
def validate_public_key(self, username: str, key: asyncssh.SSHKey) -> bool: # noqa: ARG002
|
|
35
|
+
return key.export_public_key().decode("utf-8").strip() == self._authorized_public_key
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class HostBridgeSSHProcess:
|
|
39
|
+
def __init__(self, manager: SessionManager, session_factory: "HostBridgeSessionFactory", timeout: int, output_limit: int) -> None:
|
|
40
|
+
self._manager = manager
|
|
41
|
+
self._session_factory = session_factory
|
|
42
|
+
self._timeout = timeout
|
|
43
|
+
self._output_limit = output_limit
|
|
44
|
+
|
|
45
|
+
async def __call__(self, process: asyncssh.SSHServerProcess[str]) -> None:
|
|
46
|
+
session_id = self._session_factory.open()
|
|
47
|
+
try:
|
|
48
|
+
await self._run(process, session_id)
|
|
49
|
+
finally:
|
|
50
|
+
self._session_factory.close(session_id)
|
|
51
|
+
|
|
52
|
+
async def _run(self, process: asyncssh.SSHServerProcess[str], session_id: str) -> None:
|
|
53
|
+
command = process.command
|
|
54
|
+
if not command:
|
|
55
|
+
await self._run_shell(process, session_id)
|
|
56
|
+
return
|
|
57
|
+
|
|
58
|
+
loop = asyncio.get_running_loop()
|
|
59
|
+
try:
|
|
60
|
+
result = await loop.run_in_executor(
|
|
61
|
+
None,
|
|
62
|
+
lambda: self._manager.run_command(
|
|
63
|
+
session_id,
|
|
64
|
+
command,
|
|
65
|
+
timeout=self._timeout,
|
|
66
|
+
token_factory=_new_token,
|
|
67
|
+
output_limit=self._output_limit,
|
|
68
|
+
),
|
|
69
|
+
)
|
|
70
|
+
if result.output:
|
|
71
|
+
process.stdout.write(result.output)
|
|
72
|
+
if not result.output.endswith("\n"):
|
|
73
|
+
process.stdout.write("\n")
|
|
74
|
+
process.exit(124 if result.timed_out else int(result.exit_code or 0))
|
|
75
|
+
except Exception as exc:
|
|
76
|
+
print(f"hostbridge mock-ssh exec failed: {exc}", file=sys.stderr, flush=True)
|
|
77
|
+
process.stderr.write(f"hostbridge mock-ssh error: {exc}\n")
|
|
78
|
+
process.exit(1)
|
|
79
|
+
|
|
80
|
+
async def _run_shell(self, process: asyncssh.SSHServerProcess[str], session_id: str) -> None:
|
|
81
|
+
loop = asyncio.get_running_loop()
|
|
82
|
+
stop = asyncio.Event()
|
|
83
|
+
input_done = asyncio.Event()
|
|
84
|
+
|
|
85
|
+
async def pump_input() -> None:
|
|
86
|
+
try:
|
|
87
|
+
while not process.stdin.at_eof():
|
|
88
|
+
data = await process.stdin.read(4096)
|
|
89
|
+
if not data:
|
|
90
|
+
break
|
|
91
|
+
await loop.run_in_executor(
|
|
92
|
+
None,
|
|
93
|
+
lambda chunk=data: self._manager.send(session_id, chunk, owner_agent_id="mock-ssh"),
|
|
94
|
+
)
|
|
95
|
+
finally:
|
|
96
|
+
input_done.set()
|
|
97
|
+
|
|
98
|
+
async def pump_output() -> None:
|
|
99
|
+
try:
|
|
100
|
+
idle_after_input = 0
|
|
101
|
+
while not stop.is_set():
|
|
102
|
+
result = await loop.run_in_executor(
|
|
103
|
+
None,
|
|
104
|
+
lambda: self._manager.read(
|
|
105
|
+
session_id,
|
|
106
|
+
timeout=0.2,
|
|
107
|
+
max_chars=4096,
|
|
108
|
+
owner_agent_id="mock-ssh",
|
|
109
|
+
),
|
|
110
|
+
)
|
|
111
|
+
output = str(result.get("output") or "")
|
|
112
|
+
if output:
|
|
113
|
+
idle_after_input = 0
|
|
114
|
+
process.stdout.write(output)
|
|
115
|
+
elif input_done.is_set():
|
|
116
|
+
idle_after_input += 1
|
|
117
|
+
if idle_after_input >= 5:
|
|
118
|
+
stop.set()
|
|
119
|
+
break
|
|
120
|
+
if not result.get("alive", True):
|
|
121
|
+
stop.set()
|
|
122
|
+
break
|
|
123
|
+
except Exception as exc:
|
|
124
|
+
print(f"hostbridge mock-ssh shell failed: {exc}", file=sys.stderr, flush=True)
|
|
125
|
+
process.stderr.write(f"hostbridge mock-ssh shell error: {exc}\n")
|
|
126
|
+
stop.set()
|
|
127
|
+
|
|
128
|
+
tasks = [asyncio.create_task(pump_input()), asyncio.create_task(pump_output())]
|
|
129
|
+
await stop.wait()
|
|
130
|
+
for task in tasks:
|
|
131
|
+
task.cancel()
|
|
132
|
+
await asyncio.gather(*tasks, return_exceptions=True)
|
|
133
|
+
process.exit(0)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
@dataclass
|
|
137
|
+
class HostBridgeSFTPHandle:
|
|
138
|
+
path: str
|
|
139
|
+
readable: bool = False
|
|
140
|
+
writable: bool = False
|
|
141
|
+
data: bytes = b""
|
|
142
|
+
writes: dict[int, bytes] = field(default_factory=dict)
|
|
143
|
+
mode: int = 0o644
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
class HostBridgeSessionFactory:
|
|
147
|
+
def __init__(self, manager: SessionManager, host_id: str, connect_timeout: int) -> None:
|
|
148
|
+
self._manager = manager
|
|
149
|
+
self._host_id = host_id
|
|
150
|
+
self._connect_timeout = connect_timeout
|
|
151
|
+
|
|
152
|
+
def open(self) -> str:
|
|
153
|
+
session = self._manager.open_session(
|
|
154
|
+
self._host_id,
|
|
155
|
+
connect_timeout=self._connect_timeout,
|
|
156
|
+
owner_agent_id="mock-ssh",
|
|
157
|
+
)
|
|
158
|
+
return session.id
|
|
159
|
+
|
|
160
|
+
def close(self, session_id: str) -> None:
|
|
161
|
+
with _suppress_error():
|
|
162
|
+
self._manager.close_session(session_id, owner_agent_id="mock-ssh", force=True)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
class HostBridgeSFTPServer(asyncssh.SFTPServer):
|
|
166
|
+
def __init__(
|
|
167
|
+
self,
|
|
168
|
+
chan: asyncssh.SSHServerChannel,
|
|
169
|
+
manager: SessionManager,
|
|
170
|
+
session_factory: HostBridgeSessionFactory,
|
|
171
|
+
timeout: int,
|
|
172
|
+
max_bytes: int,
|
|
173
|
+
) -> None:
|
|
174
|
+
super().__init__(chan)
|
|
175
|
+
self._manager = manager
|
|
176
|
+
self._session_factory = session_factory
|
|
177
|
+
self._session_id = session_factory.open()
|
|
178
|
+
self._timeout = timeout
|
|
179
|
+
self._max_bytes = max_bytes
|
|
180
|
+
|
|
181
|
+
def exit(self) -> None:
|
|
182
|
+
self._session_factory.close(self._session_id)
|
|
183
|
+
|
|
184
|
+
def open(self, path: bytes, pflags: int, attrs: asyncssh.SFTPAttrs) -> HostBridgeSFTPHandle:
|
|
185
|
+
remote_path = self._path(path)
|
|
186
|
+
readable = bool(pflags & asyncssh.FXF_READ)
|
|
187
|
+
writable = bool(pflags & asyncssh.FXF_WRITE)
|
|
188
|
+
mode = attrs.permissions if attrs.permissions is not None else 0o644
|
|
189
|
+
data = b""
|
|
190
|
+
if readable or (writable and not (pflags & asyncssh.FXF_TRUNC) and not (pflags & asyncssh.FXF_CREAT)):
|
|
191
|
+
try:
|
|
192
|
+
data = self._download(remote_path)
|
|
193
|
+
except asyncssh.SFTPNoSuchFile:
|
|
194
|
+
if not writable:
|
|
195
|
+
raise
|
|
196
|
+
data = b""
|
|
197
|
+
return HostBridgeSFTPHandle(remote_path, readable=readable, writable=writable, data=data, mode=mode)
|
|
198
|
+
|
|
199
|
+
def read(self, file_obj: object, offset: int, size: int) -> bytes:
|
|
200
|
+
handle = self._handle(file_obj)
|
|
201
|
+
if not handle.readable:
|
|
202
|
+
raise asyncssh.SFTPPermissionDenied("file is not open for reading")
|
|
203
|
+
return handle.data[offset : offset + size]
|
|
204
|
+
|
|
205
|
+
def write(self, file_obj: object, offset: int, data: bytes) -> int:
|
|
206
|
+
handle = self._handle(file_obj)
|
|
207
|
+
if not handle.writable:
|
|
208
|
+
raise asyncssh.SFTPPermissionDenied("file is not open for writing")
|
|
209
|
+
handle.writes[int(offset)] = bytes(data)
|
|
210
|
+
return len(data)
|
|
211
|
+
|
|
212
|
+
def close(self, file_obj: object) -> None:
|
|
213
|
+
handle = self._handle(file_obj)
|
|
214
|
+
if handle.writable:
|
|
215
|
+
self._upload(handle)
|
|
216
|
+
|
|
217
|
+
def stat(self, path: bytes) -> asyncssh.SFTPAttrs:
|
|
218
|
+
return self._stat(self._path(path), follow=True)
|
|
219
|
+
|
|
220
|
+
def lstat(self, path: bytes) -> asyncssh.SFTPAttrs:
|
|
221
|
+
return self._stat(self._path(path), follow=False)
|
|
222
|
+
|
|
223
|
+
def fstat(self, file_obj: object) -> asyncssh.SFTPAttrs:
|
|
224
|
+
handle = self._handle(file_obj)
|
|
225
|
+
if handle.writable and handle.writes:
|
|
226
|
+
size = len(handle.data)
|
|
227
|
+
for offset, chunk in handle.writes.items():
|
|
228
|
+
size = max(size, offset + len(chunk))
|
|
229
|
+
return asyncssh.SFTPAttrs(size=size, permissions=handle.mode, type=asyncssh.FILEXFER_TYPE_REGULAR)
|
|
230
|
+
return self._stat(handle.path, follow=True)
|
|
231
|
+
|
|
232
|
+
def fsetstat(self, file_obj: object, attrs: asyncssh.SFTPAttrs) -> None:
|
|
233
|
+
handle = self._handle(file_obj)
|
|
234
|
+
if attrs.permissions is not None:
|
|
235
|
+
handle.mode = attrs.permissions
|
|
236
|
+
if attrs.atime is not None or attrs.mtime is not None:
|
|
237
|
+
# Applied after upload in close(); the handle may not exist remotely yet.
|
|
238
|
+
return
|
|
239
|
+
|
|
240
|
+
async def scandir(self, path: bytes):
|
|
241
|
+
for item in self._listdir(self._path(path)):
|
|
242
|
+
yield item
|
|
243
|
+
|
|
244
|
+
def mkdir(self, path: bytes, attrs: asyncssh.SFTPAttrs) -> None:
|
|
245
|
+
mode = attrs.permissions if attrs.permissions is not None else 0o755
|
|
246
|
+
self._checked_run(f"mkdir -p -m {shlex.quote(format(mode & 0o7777, 'o'))} -- {shlex.quote(self._path(path))}")
|
|
247
|
+
|
|
248
|
+
def rmdir(self, path: bytes) -> None:
|
|
249
|
+
self._checked_run(f"rmdir -- {shlex.quote(self._path(path))}")
|
|
250
|
+
|
|
251
|
+
def remove(self, path: bytes) -> None:
|
|
252
|
+
self._checked_run(f"rm -f -- {shlex.quote(self._path(path))}")
|
|
253
|
+
|
|
254
|
+
def rename(self, oldpath: bytes, newpath: bytes) -> None:
|
|
255
|
+
self._checked_run(f"mv -- {shlex.quote(self._path(oldpath))} {shlex.quote(self._path(newpath))}")
|
|
256
|
+
|
|
257
|
+
def realpath(self, path: bytes) -> bytes:
|
|
258
|
+
command = (
|
|
259
|
+
"python3 -c "
|
|
260
|
+
+ shlex.quote("import os,sys; print(os.path.realpath(sys.argv[1]))")
|
|
261
|
+
+ " "
|
|
262
|
+
+ shlex.quote(self._path(path))
|
|
263
|
+
)
|
|
264
|
+
result = self._checked_run(command)
|
|
265
|
+
return (result.output.strip() or self._path(path)).encode("utf-8")
|
|
266
|
+
|
|
267
|
+
def setstat(self, path: bytes, attrs: asyncssh.SFTPAttrs) -> None:
|
|
268
|
+
remote_path = self._path(path)
|
|
269
|
+
if attrs.permissions is not None:
|
|
270
|
+
self._checked_run(f"chmod {shlex.quote(format(attrs.permissions & 0o7777, 'o'))} -- {shlex.quote(remote_path)}")
|
|
271
|
+
if attrs.atime is not None or attrs.mtime is not None:
|
|
272
|
+
script = "import os,sys; os.utime(sys.argv[1], (int(sys.argv[2]), int(sys.argv[3])))"
|
|
273
|
+
current = self._stat(remote_path, follow=True)
|
|
274
|
+
atime = int(attrs.atime if attrs.atime is not None else current.atime or time.time())
|
|
275
|
+
mtime = int(attrs.mtime if attrs.mtime is not None else current.mtime or time.time())
|
|
276
|
+
self._checked_run(f"python3 -c {shlex.quote(script)} {shlex.quote(remote_path)} {atime} {mtime}")
|
|
277
|
+
|
|
278
|
+
def _download(self, remote_path: str) -> bytes:
|
|
279
|
+
try:
|
|
280
|
+
result = self._manager.file_read_bytes(
|
|
281
|
+
self._session_id,
|
|
282
|
+
remote_path,
|
|
283
|
+
max_bytes=self._max_bytes,
|
|
284
|
+
token_factory=_new_token,
|
|
285
|
+
owner_agent_id="mock-ssh",
|
|
286
|
+
)
|
|
287
|
+
except RemoteCommandError as exc:
|
|
288
|
+
raise self._sftp_error(exc) from exc
|
|
289
|
+
return bytes(result["data_bytes"])
|
|
290
|
+
|
|
291
|
+
def _upload(self, handle: HostBridgeSFTPHandle) -> None:
|
|
292
|
+
size = len(handle.data)
|
|
293
|
+
for offset, chunk in handle.writes.items():
|
|
294
|
+
size = max(size, offset + len(chunk))
|
|
295
|
+
data = bytearray(handle.data)
|
|
296
|
+
if len(data) < size:
|
|
297
|
+
data.extend(b"\x00" * (size - len(data)))
|
|
298
|
+
for offset, chunk in sorted(handle.writes.items()):
|
|
299
|
+
data[offset : offset + len(chunk)] = chunk
|
|
300
|
+
try:
|
|
301
|
+
self._manager.file_write_bytes(
|
|
302
|
+
self._session_id,
|
|
303
|
+
handle.path,
|
|
304
|
+
bytes(data),
|
|
305
|
+
mode=format(handle.mode & 0o7777, "04o"),
|
|
306
|
+
max_bytes=self._max_bytes,
|
|
307
|
+
token_factory=_new_token,
|
|
308
|
+
owner_agent_id="mock-ssh",
|
|
309
|
+
)
|
|
310
|
+
except RemoteCommandError as exc:
|
|
311
|
+
print(f"hostbridge mock-ssh sftp upload failed: {exc}", file=sys.stderr, flush=True)
|
|
312
|
+
raise self._sftp_error(exc) from exc
|
|
313
|
+
|
|
314
|
+
def _stat(self, remote_path: str, *, follow: bool) -> asyncssh.SFTPAttrs:
|
|
315
|
+
script = r"""
|
|
316
|
+
import json, os, stat, sys
|
|
317
|
+
path = sys.argv[1]
|
|
318
|
+
st = os.stat(path) if sys.argv[2] == "1" else os.lstat(path)
|
|
319
|
+
print(json.dumps({
|
|
320
|
+
"size": st.st_size,
|
|
321
|
+
"permissions": st.st_mode,
|
|
322
|
+
"uid": st.st_uid,
|
|
323
|
+
"gid": st.st_gid,
|
|
324
|
+
"atime": int(st.st_atime),
|
|
325
|
+
"mtime": int(st.st_mtime),
|
|
326
|
+
"type": "dir" if stat.S_ISDIR(st.st_mode) else "link" if stat.S_ISLNK(st.st_mode) else "file",
|
|
327
|
+
}))
|
|
328
|
+
"""
|
|
329
|
+
result = self._checked_run(f"python3 -c {shlex.quote(script)} {shlex.quote(remote_path)} {'1' if follow else '0'}")
|
|
330
|
+
return self._attrs(json.loads(result.output))
|
|
331
|
+
|
|
332
|
+
def _listdir(self, remote_path: str) -> list[asyncssh.SFTPName]:
|
|
333
|
+
script = r"""
|
|
334
|
+
import json, os, stat, sys, time
|
|
335
|
+
path = sys.argv[1]
|
|
336
|
+
items = []
|
|
337
|
+
for name in os.listdir(path):
|
|
338
|
+
full = os.path.join(path, name)
|
|
339
|
+
st = os.lstat(full)
|
|
340
|
+
item_type = "dir" if stat.S_ISDIR(st.st_mode) else "link" if stat.S_ISLNK(st.st_mode) else "file"
|
|
341
|
+
items.append({
|
|
342
|
+
"filename": name,
|
|
343
|
+
"longname": name,
|
|
344
|
+
"attrs": {
|
|
345
|
+
"size": st.st_size,
|
|
346
|
+
"permissions": st.st_mode,
|
|
347
|
+
"uid": st.st_uid,
|
|
348
|
+
"gid": st.st_gid,
|
|
349
|
+
"atime": int(st.st_atime),
|
|
350
|
+
"mtime": int(st.st_mtime),
|
|
351
|
+
"type": item_type,
|
|
352
|
+
},
|
|
353
|
+
})
|
|
354
|
+
print(json.dumps(items))
|
|
355
|
+
"""
|
|
356
|
+
result = self._checked_run(f"python3 -c {shlex.quote(script)} {shlex.quote(remote_path)}")
|
|
357
|
+
return [
|
|
358
|
+
asyncssh.SFTPName(item["filename"], item["longname"], self._attrs(item["attrs"]))
|
|
359
|
+
for item in json.loads(result.output or "[]")
|
|
360
|
+
]
|
|
361
|
+
|
|
362
|
+
def _attrs(self, data: dict[str, object]) -> asyncssh.SFTPAttrs:
|
|
363
|
+
permissions = int(data.get("permissions") or 0)
|
|
364
|
+
raw_type = data.get("type")
|
|
365
|
+
file_type = {
|
|
366
|
+
"file": asyncssh.FILEXFER_TYPE_REGULAR,
|
|
367
|
+
"dir": asyncssh.FILEXFER_TYPE_DIRECTORY,
|
|
368
|
+
"link": asyncssh.FILEXFER_TYPE_SYMLINK,
|
|
369
|
+
}.get(str(raw_type), asyncssh.FILEXFER_TYPE_UNKNOWN)
|
|
370
|
+
return asyncssh.SFTPAttrs(
|
|
371
|
+
type=file_type,
|
|
372
|
+
size=int(data.get("size") or 0),
|
|
373
|
+
uid=int(data.get("uid") or 0),
|
|
374
|
+
gid=int(data.get("gid") or 0),
|
|
375
|
+
permissions=permissions,
|
|
376
|
+
atime=int(data.get("atime") or time.time()),
|
|
377
|
+
mtime=int(data.get("mtime") or time.time()),
|
|
378
|
+
)
|
|
379
|
+
|
|
380
|
+
def _checked_run(self, command: str):
|
|
381
|
+
try:
|
|
382
|
+
result = self._manager.run_command(
|
|
383
|
+
self._session_id,
|
|
384
|
+
command,
|
|
385
|
+
timeout=self._timeout,
|
|
386
|
+
token_factory=_new_token,
|
|
387
|
+
owner_agent_id="mock-ssh",
|
|
388
|
+
)
|
|
389
|
+
except RemoteCommandError as exc:
|
|
390
|
+
raise self._sftp_error(exc) from exc
|
|
391
|
+
if result.timed_out:
|
|
392
|
+
print(f"hostbridge mock-ssh sftp command timed out: {command}", file=sys.stderr, flush=True)
|
|
393
|
+
raise asyncssh.SFTPFailure("remote command timed out")
|
|
394
|
+
if result.exit_code not in (0, None):
|
|
395
|
+
error = self._sftp_error(RemoteCommandError(result.output or f"remote command exited {result.exit_code}"))
|
|
396
|
+
if isinstance(error, asyncssh.SFTPNoSuchFile):
|
|
397
|
+
raise error
|
|
398
|
+
print(
|
|
399
|
+
f"hostbridge mock-ssh sftp command failed exit={result.exit_code}: {command}\n{result.output}",
|
|
400
|
+
file=sys.stderr,
|
|
401
|
+
flush=True,
|
|
402
|
+
)
|
|
403
|
+
raise error
|
|
404
|
+
return result
|
|
405
|
+
|
|
406
|
+
def _path(self, path: bytes) -> str:
|
|
407
|
+
text = path.decode("utf-8", "surrogateescape")
|
|
408
|
+
if not text:
|
|
409
|
+
return "."
|
|
410
|
+
return posixpath.normpath(text)
|
|
411
|
+
|
|
412
|
+
def _handle(self, file_obj: object) -> HostBridgeSFTPHandle:
|
|
413
|
+
if not isinstance(file_obj, HostBridgeSFTPHandle):
|
|
414
|
+
raise asyncssh.SFTPInvalidHandle("invalid HostBridge SFTP handle")
|
|
415
|
+
return file_obj
|
|
416
|
+
|
|
417
|
+
def _sftp_error(self, exc: RemoteCommandError) -> Exception:
|
|
418
|
+
text = str(exc)
|
|
419
|
+
lowered = text.lower()
|
|
420
|
+
if "no such file" in lowered or "not found" in lowered or "filenotfounderror" in lowered:
|
|
421
|
+
return asyncssh.SFTPNoSuchFile(text)
|
|
422
|
+
if "permission denied" in lowered:
|
|
423
|
+
return asyncssh.SFTPPermissionDenied(text)
|
|
424
|
+
return asyncssh.SFTPFailure(text)
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
def ensure_keypair(key_dir: Path) -> tuple[Path, Path, str]:
|
|
428
|
+
key_dir.mkdir(parents=True, exist_ok=True)
|
|
429
|
+
with _suppress_chmod_error(key_dir):
|
|
430
|
+
key_dir.chmod(0o700)
|
|
431
|
+
host_key_path = key_dir / "ssh_host_ed25519_key"
|
|
432
|
+
client_key_path = key_dir / "client_ed25519_key"
|
|
433
|
+
if not host_key_path.exists():
|
|
434
|
+
host_key = asyncssh.generate_private_key("ssh-ed25519")
|
|
435
|
+
host_key_path.write_bytes(host_key.export_private_key())
|
|
436
|
+
if not client_key_path.exists():
|
|
437
|
+
client_key = asyncssh.generate_private_key("ssh-ed25519")
|
|
438
|
+
client_key_path.write_bytes(client_key.export_private_key())
|
|
439
|
+
(key_dir / "client_ed25519_key.pub").write_bytes(client_key.export_public_key())
|
|
440
|
+
with _suppress_chmod_error(host_key_path):
|
|
441
|
+
host_key_path.chmod(0o600)
|
|
442
|
+
with _suppress_chmod_error(client_key_path):
|
|
443
|
+
client_key_path.chmod(0o600)
|
|
444
|
+
public_key = (key_dir / "client_ed25519_key.pub").read_text(encoding="utf-8").strip()
|
|
445
|
+
return host_key_path, client_key_path, public_key
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
def default_key_dir(host_id: str) -> Path:
|
|
449
|
+
safe_host_id = "".join(ch if ch.isalnum() or ch in "._-" else "_" for ch in host_id)
|
|
450
|
+
return Path("~/.hostbridge/mock-ssh").expanduser() / safe_host_id
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
def default_ssh_host_alias(host_id: str) -> str:
|
|
454
|
+
safe_host_id = "".join(ch if ch.isalnum() or ch in ".-" else "-" for ch in host_id).strip(".-")
|
|
455
|
+
return f"hostbridge-{safe_host_id or 'host'}"
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
async def create_server_on_available_port(
|
|
459
|
+
*,
|
|
460
|
+
server_factory,
|
|
461
|
+
listen_host: str,
|
|
462
|
+
port: int | None,
|
|
463
|
+
server_host_keys: list[str],
|
|
464
|
+
process_factory,
|
|
465
|
+
sftp_factory,
|
|
466
|
+
):
|
|
467
|
+
candidates = [port] if port is not None else list(DEFAULT_PORT_RANGE)
|
|
468
|
+
last_error: OSError | None = None
|
|
469
|
+
for candidate in candidates:
|
|
470
|
+
if candidate is None:
|
|
471
|
+
continue
|
|
472
|
+
try:
|
|
473
|
+
server = await asyncssh.create_server(
|
|
474
|
+
server_factory,
|
|
475
|
+
listen_host,
|
|
476
|
+
int(candidate),
|
|
477
|
+
server_host_keys=server_host_keys,
|
|
478
|
+
process_factory=process_factory,
|
|
479
|
+
sftp_factory=sftp_factory,
|
|
480
|
+
allow_scp=True,
|
|
481
|
+
)
|
|
482
|
+
return server, int(candidate)
|
|
483
|
+
except OSError as exc:
|
|
484
|
+
last_error = exc
|
|
485
|
+
continue
|
|
486
|
+
if port is not None:
|
|
487
|
+
raise RuntimeError(f"port {port} is not available on {listen_host}") from last_error
|
|
488
|
+
raise RuntimeError(f"no free port found in {DEFAULT_PORT_RANGE.start}-{DEFAULT_PORT_RANGE.stop - 1}") from last_error
|
|
489
|
+
|
|
490
|
+
|
|
491
|
+
def install_ssh_config_block(
|
|
492
|
+
*,
|
|
493
|
+
host_alias: str,
|
|
494
|
+
listen_host: str,
|
|
495
|
+
port: int,
|
|
496
|
+
client_key_path: Path,
|
|
497
|
+
config_path: Path | None = None,
|
|
498
|
+
) -> Path:
|
|
499
|
+
config_path = config_path or Path("~/.ssh/config").expanduser()
|
|
500
|
+
config_path.parent.mkdir(parents=True, exist_ok=True)
|
|
501
|
+
existing = config_path.read_text(encoding="utf-8") if config_path.exists() else ""
|
|
502
|
+
begin = f"# >>> hostbridge mock-ssh {host_alias} >>>"
|
|
503
|
+
end = f"# <<< hostbridge mock-ssh {host_alias} <<<"
|
|
504
|
+
block = "\n".join(
|
|
505
|
+
[
|
|
506
|
+
begin,
|
|
507
|
+
f"Host {host_alias}",
|
|
508
|
+
f" HostName {listen_host}",
|
|
509
|
+
f" Port {port}",
|
|
510
|
+
" User hostbridge",
|
|
511
|
+
f" IdentityFile {client_key_path}",
|
|
512
|
+
" StrictHostKeyChecking no",
|
|
513
|
+
" UserKnownHostsFile /dev/null",
|
|
514
|
+
" LogLevel ERROR",
|
|
515
|
+
end,
|
|
516
|
+
"",
|
|
517
|
+
]
|
|
518
|
+
)
|
|
519
|
+
_write_text_atomic(config_path, _replace_managed_block(existing, begin, end, block))
|
|
520
|
+
with _suppress_chmod_error(config_path):
|
|
521
|
+
config_path.chmod(0o600)
|
|
522
|
+
return config_path
|
|
523
|
+
|
|
524
|
+
|
|
525
|
+
def uninstall_ssh_config_block(host_alias: str, config_path: Path | None = None) -> bool:
|
|
526
|
+
config_path = config_path or Path("~/.ssh/config").expanduser()
|
|
527
|
+
if not config_path.exists():
|
|
528
|
+
return False
|
|
529
|
+
begin = f"# >>> hostbridge mock-ssh {host_alias} >>>"
|
|
530
|
+
end = f"# <<< hostbridge mock-ssh {host_alias} <<<"
|
|
531
|
+
existing = config_path.read_text(encoding="utf-8")
|
|
532
|
+
updated = _remove_managed_block(existing, begin, end)
|
|
533
|
+
if updated == existing:
|
|
534
|
+
return False
|
|
535
|
+
_write_text_atomic(config_path, updated)
|
|
536
|
+
return True
|
|
537
|
+
|
|
538
|
+
|
|
539
|
+
def _replace_managed_block(existing: str, begin: str, end: str, block: str) -> str:
|
|
540
|
+
without = _remove_managed_block(existing, begin, end).rstrip()
|
|
541
|
+
return f"{without}\n\n{block}" if without else block
|
|
542
|
+
|
|
543
|
+
|
|
544
|
+
def _remove_managed_block(existing: str, begin: str, end: str) -> str:
|
|
545
|
+
start = existing.find(begin)
|
|
546
|
+
if start == -1:
|
|
547
|
+
return existing
|
|
548
|
+
stop = existing.find(end, start)
|
|
549
|
+
if stop == -1:
|
|
550
|
+
return existing
|
|
551
|
+
stop += len(end)
|
|
552
|
+
while stop < len(existing) and existing[stop] in "\r\n":
|
|
553
|
+
stop += 1
|
|
554
|
+
return existing[:start].rstrip() + ("\n" if existing[:start].strip() and existing[stop:].strip() else "") + existing[stop:].lstrip()
|
|
555
|
+
|
|
556
|
+
|
|
557
|
+
def _write_text_atomic(path: Path, text: str) -> None:
|
|
558
|
+
tmp_path = path.with_name(f".{path.name}.hostbridge.tmp")
|
|
559
|
+
tmp_path.write_text(text, encoding="utf-8")
|
|
560
|
+
tmp_path.replace(path)
|
|
561
|
+
|
|
562
|
+
|
|
563
|
+
class _suppress_chmod_error:
|
|
564
|
+
def __init__(self, path: Path) -> None:
|
|
565
|
+
self.path = path
|
|
566
|
+
|
|
567
|
+
def __enter__(self) -> None:
|
|
568
|
+
return None
|
|
569
|
+
|
|
570
|
+
def __exit__(self, exc_type: object, exc: object, tb: object) -> bool:
|
|
571
|
+
return exc_type is PermissionError
|
|
572
|
+
|
|
573
|
+
|
|
574
|
+
class _suppress_error:
|
|
575
|
+
def __enter__(self) -> None:
|
|
576
|
+
return None
|
|
577
|
+
|
|
578
|
+
def __exit__(self, exc_type: object, exc: object, tb: object) -> bool:
|
|
579
|
+
return isinstance(exc, Exception)
|
|
580
|
+
|
|
581
|
+
|
|
582
|
+
async def serve_mock_ssh(
|
|
583
|
+
host_id: str,
|
|
584
|
+
*,
|
|
585
|
+
config_path: Path | str | None = None,
|
|
586
|
+
listen_host: str = "127.0.0.1",
|
|
587
|
+
port: int | None = None,
|
|
588
|
+
key_dir: Path | None = None,
|
|
589
|
+
ssh_host_alias: str | None = None,
|
|
590
|
+
ssh_config_path: Path | None = None,
|
|
591
|
+
install_ssh_config: bool = True,
|
|
592
|
+
connect_timeout: int = 60,
|
|
593
|
+
command_timeout: int = DEFAULT_COMMAND_TIMEOUT,
|
|
594
|
+
output_limit: int = 120000,
|
|
595
|
+
) -> int:
|
|
596
|
+
key_dir = key_dir or default_key_dir(host_id)
|
|
597
|
+
ssh_host_alias = ssh_host_alias or default_ssh_host_alias(host_id)
|
|
598
|
+
host_key_path, client_key_path, public_key = ensure_keypair(key_dir)
|
|
599
|
+
manager = build_manager(config_path)
|
|
600
|
+
session_factory = HostBridgeSessionFactory(manager, host_id, connect_timeout)
|
|
601
|
+
try:
|
|
602
|
+
server, selected_port = await create_server_on_available_port(
|
|
603
|
+
server_factory=lambda: HostBridgeSSHServer(public_key),
|
|
604
|
+
listen_host=listen_host,
|
|
605
|
+
port=port,
|
|
606
|
+
server_host_keys=[str(host_key_path)],
|
|
607
|
+
process_factory=HostBridgeSSHProcess(manager, session_factory, command_timeout, output_limit),
|
|
608
|
+
sftp_factory=lambda chan: HostBridgeSFTPServer(chan, manager, session_factory, command_timeout, output_limit),
|
|
609
|
+
)
|
|
610
|
+
installed_config_path: Path | None = None
|
|
611
|
+
if install_ssh_config:
|
|
612
|
+
installed_config_path = install_ssh_config_block(
|
|
613
|
+
host_alias=ssh_host_alias,
|
|
614
|
+
listen_host=listen_host,
|
|
615
|
+
port=selected_port,
|
|
616
|
+
client_key_path=client_key_path,
|
|
617
|
+
config_path=ssh_config_path,
|
|
618
|
+
)
|
|
619
|
+
print(f"hostbridge mock-ssh forwarding host '{host_id}'", flush=True)
|
|
620
|
+
print(f"listening on ssh://{listen_host}:{selected_port}", flush=True)
|
|
621
|
+
print(f"ssh host alias: {ssh_host_alias}", flush=True)
|
|
622
|
+
if installed_config_path is not None:
|
|
623
|
+
print(f"ssh config: {installed_config_path}", flush=True)
|
|
624
|
+
print(f"identity file: {client_key_path}", flush=True)
|
|
625
|
+
print("supports SSH exec, shell, SFTP, and SCP; press Ctrl-C to stop", flush=True)
|
|
626
|
+
await server.wait_closed()
|
|
627
|
+
finally:
|
|
628
|
+
manager.close_all_sessions()
|
|
629
|
+
manager.stop_idle_reaper()
|
|
630
|
+
return 0
|
|
631
|
+
|
|
632
|
+
|
|
633
|
+
def run_mock_ssh(**kwargs: object) -> int:
|
|
634
|
+
try:
|
|
635
|
+
return asyncio.run(serve_mock_ssh(**kwargs))
|
|
636
|
+
except KeyboardInterrupt:
|
|
637
|
+
return 130
|
|
638
|
+
except RemoteCommandError as exc:
|
|
639
|
+
print(f"hostbridge mock-ssh failed: {exc}")
|
|
640
|
+
return 1
|
|
641
|
+
|
|
642
|
+
|
|
643
|
+
def uninstall_mock_ssh(host_id: str, *, ssh_host_alias: str | None = None, ssh_config_path: Path | None = None) -> int:
|
|
644
|
+
host_alias = ssh_host_alias or default_ssh_host_alias(host_id)
|
|
645
|
+
removed = uninstall_ssh_config_block(host_alias, ssh_config_path)
|
|
646
|
+
print(f"removed SSH config for {host_alias}" if removed else f"no managed SSH config found for {host_alias}")
|
|
647
|
+
return 0
|
|
@@ -50,6 +50,13 @@ def _daemon_request(method: str, path: str, payload: dict[str, object] | None =
|
|
|
50
50
|
try:
|
|
51
51
|
return daemon.request_json(method, path, payload, timeout=timeout)
|
|
52
52
|
except OSError as exc:
|
|
53
|
+
if os.environ.get("HOSTBRIDGE_NPM_LAUNCHER"):
|
|
54
|
+
socket_path = daemon.socket_path()
|
|
55
|
+
raise RuntimeError(
|
|
56
|
+
"hostbridge daemon is not reachable at "
|
|
57
|
+
f"unix://{socket_path}. Start it outside the MCP sandbox, for example: "
|
|
58
|
+
f"HOSTBRIDGE_DAEMON_SOCKET={socket_path} npx -y @neoline/hostbridge daemon start --foreground"
|
|
59
|
+
) from exc
|
|
53
60
|
# MCP stdout must stay protocol-clean, so auto-start the daemon quietly.
|
|
54
61
|
if daemon.start_background(quiet=True) != 0:
|
|
55
62
|
raise RuntimeError(f"hostbridge daemon failed to start; see {daemon.log_file()}") from exc
|