@neoline/hostbridge 2.0.3 → 2.0.5
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 -15
- package/bin/hostbridge.js +80 -9
- package/docs/ARCHITECTURE.md +62 -0
- package/examples/claude_desktop_config.json +1 -1
- package/examples/codex.config.toml +1 -1
- package/examples/hosts.example.json +1 -0
- package/package.json +2 -1
- package/pyproject.toml +5 -4
- package/src/server_control_mcp/__init__.py +60 -24
- package/src/server_control_mcp/async_lifecycle.py +41 -0
- package/src/server_control_mcp/cli.py +2 -2
- package/src/server_control_mcp/client.py +345 -238
- package/src/server_control_mcp/config.py +18 -6
- package/src/server_control_mcp/daemon.py +309 -412
- package/src/server_control_mcp/doctor.py +41 -5
- package/src/server_control_mcp/hosts.py +252 -24
- package/src/server_control_mcp/mock_ssh.py +97 -960
- package/src/server_control_mcp/mock_ssh_exec.py +299 -0
- package/src/server_control_mcp/mock_ssh_session.py +69 -0
- package/src/server_control_mcp/mock_ssh_sftp.py +604 -0
- package/src/server_control_mcp/mock_ssh_tunnel.py +289 -0
- package/src/server_control_mcp/mux_connection.py +326 -0
- package/src/server_control_mcp/mux_daemon.py +186 -0
- package/src/server_control_mcp/mux_protocol.py +279 -0
- package/src/server_control_mcp/mux_records.py +71 -0
- package/src/server_control_mcp/mux_rpc.py +1779 -0
- package/src/server_control_mcp/mux_service.py +506 -0
- package/src/server_control_mcp/mux_stream.py +283 -0
- package/src/server_control_mcp/mux_sync_client.py +224 -0
- package/src/server_control_mcp/policy.py +86 -14
- package/src/server_control_mcp/remote_agent_bundle.py +56 -0
- package/src/server_control_mcp/remote_mux_agent.py +769 -0
- package/src/server_control_mcp/remote_task_agent.py +102 -0
- package/src/server_control_mcp/runtime.py +3 -2
- package/src/server_control_mcp/runtime_services.py +862 -0
- package/src/server_control_mcp/secrets.py +6 -6
- package/src/server_control_mcp/secure_files.py +121 -0
- package/src/server_control_mcp/server.py +53 -20
- package/src/server_control_mcp/services.py +3 -469
- package/src/server_control_mcp/transports/__init__.py +4 -8
- package/src/server_control_mcp/transports/base.py +60 -38
- package/src/server_control_mcp/transports/ssh.py +315 -84
- package/src/server_control_mcp/tunnel_manager.py +1245 -0
- package/src/server_control_mcp/tunnel_native_ssh.py +454 -0
- package/src/server_control_mcp/tunnel_providers.py +20 -0
- package/src/server_control_mcp/tunnel_pty_agent.py +909 -0
- package/src/server_control_mcp/protocol.py +0 -362
- package/src/server_control_mcp/transports/pty.py +0 -434
|
@@ -10,6 +10,7 @@ from typing import Protocol
|
|
|
10
10
|
|
|
11
11
|
from . import __version__
|
|
12
12
|
from .config import load_v1_config
|
|
13
|
+
from .mux_protocol import MUX_PROTOCOL_VERSION
|
|
13
14
|
from .runtime import runtime_paths
|
|
14
15
|
|
|
15
16
|
|
|
@@ -17,6 +18,9 @@ class DoctorClient(Protocol):
|
|
|
17
18
|
def hello(self) -> dict[str, object]:
|
|
18
19
|
raise NotImplementedError
|
|
19
20
|
|
|
21
|
+
def tunnel_health(self) -> dict[str, object]:
|
|
22
|
+
raise NotImplementedError
|
|
23
|
+
|
|
20
24
|
|
|
21
25
|
@dataclass(frozen=True, slots=True)
|
|
22
26
|
class DiagnosticResult:
|
|
@@ -46,6 +50,7 @@ def collect_diagnostics(
|
|
|
46
50
|
_config_diagnostic(config_path),
|
|
47
51
|
_runtime_diagnostic(),
|
|
48
52
|
_daemon_diagnostic(client),
|
|
53
|
+
_tunnel_diagnostic(client),
|
|
49
54
|
_executable_diagnostic(executable_paths),
|
|
50
55
|
]
|
|
51
56
|
return DoctorReport(tuple(results))
|
|
@@ -60,8 +65,10 @@ def _version_diagnostic() -> DiagnosticResult:
|
|
|
60
65
|
"npm": npm.get("version"),
|
|
61
66
|
"python": pyproject.get("project", {}).get("version"),
|
|
62
67
|
}
|
|
63
|
-
status = "pass" if len(set(versions.values())) == 1
|
|
64
|
-
return DiagnosticResult(
|
|
68
|
+
status = "pass" if len(set(versions.values())) == 1 else "fail"
|
|
69
|
+
return DiagnosticResult(
|
|
70
|
+
"version", status, "version sources agree" if status == "pass" else "version drift detected", versions
|
|
71
|
+
)
|
|
65
72
|
|
|
66
73
|
|
|
67
74
|
def _config_diagnostic(config_path: Path | str) -> DiagnosticResult:
|
|
@@ -69,7 +76,9 @@ def _config_diagnostic(config_path: Path | str) -> DiagnosticResult:
|
|
|
69
76
|
config = load_v1_config(config_path)
|
|
70
77
|
except (OSError, ValueError) as exc:
|
|
71
78
|
return DiagnosticResult("config", "fail", str(exc), {"path": str(config_path)})
|
|
72
|
-
return DiagnosticResult(
|
|
79
|
+
return DiagnosticResult(
|
|
80
|
+
"config", "pass", "schema v1 configuration is valid", {"path": str(config.path), "hosts": len(config.hosts)}
|
|
81
|
+
)
|
|
73
82
|
|
|
74
83
|
|
|
75
84
|
def _runtime_diagnostic() -> DiagnosticResult:
|
|
@@ -93,15 +102,42 @@ def _daemon_diagnostic(client: DoctorClient) -> DiagnosticResult:
|
|
|
93
102
|
hello = client.hello()
|
|
94
103
|
except Exception as exc:
|
|
95
104
|
return DiagnosticResult("daemon", "fail", f"daemon unavailable: {exc}", {})
|
|
96
|
-
valid =
|
|
105
|
+
valid = (
|
|
106
|
+
hello.get("service") == "hostbridge"
|
|
107
|
+
and hello.get("protocol") == MUX_PROTOCOL_VERSION
|
|
108
|
+
and hello.get("version") == __version__
|
|
109
|
+
)
|
|
97
110
|
return DiagnosticResult(
|
|
98
111
|
"daemon",
|
|
99
112
|
"pass" if valid else "fail",
|
|
100
|
-
"daemon protocol
|
|
113
|
+
f"daemon protocol v{MUX_PROTOCOL_VERSION} is healthy" if valid else "daemon returned an incompatible handshake",
|
|
101
114
|
dict(hello),
|
|
102
115
|
)
|
|
103
116
|
|
|
104
117
|
|
|
118
|
+
def _tunnel_diagnostic(client: DoctorClient) -> DiagnosticResult:
|
|
119
|
+
try:
|
|
120
|
+
result = client.tunnel_health()
|
|
121
|
+
except Exception as exc:
|
|
122
|
+
return DiagnosticResult("tunnel", "fail", f"tunnel diagnostics unavailable: {exc}", {})
|
|
123
|
+
hosts = result.get("hosts")
|
|
124
|
+
if not isinstance(hosts, list) or any(not isinstance(host, dict) for host in hosts):
|
|
125
|
+
return DiagnosticResult("tunnel", "fail", "daemon returned invalid tunnel diagnostics", dict(result))
|
|
126
|
+
enabled = [host for host in hosts if host.get("enabled") is True]
|
|
127
|
+
failed = [host for host in enabled if host.get("state") == "failed"]
|
|
128
|
+
inactive = [host for host in enabled if host.get("state") != "ready"]
|
|
129
|
+
if failed:
|
|
130
|
+
status = "fail"
|
|
131
|
+
message = f"{len(failed)} tunnel provider runtime(s) failed"
|
|
132
|
+
elif inactive:
|
|
133
|
+
status = "warn"
|
|
134
|
+
message = f"{len(inactive)} tunnel provider runtime(s) configured but not handshaken"
|
|
135
|
+
else:
|
|
136
|
+
status = "pass"
|
|
137
|
+
message = "tunnel provider runtimes are ready"
|
|
138
|
+
return DiagnosticResult("tunnel", status, message, dict(result))
|
|
139
|
+
|
|
140
|
+
|
|
105
141
|
def _executable_diagnostic(paths: list[str]) -> DiagnosticResult:
|
|
106
142
|
if not paths:
|
|
107
143
|
return DiagnosticResult("executable", "fail", "hostbridge executable was not found", {"paths": []})
|
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
-
import json
|
|
4
3
|
import os
|
|
5
4
|
import re
|
|
6
5
|
from collections.abc import Iterable, Sequence
|
|
7
6
|
from dataclasses import dataclass, field
|
|
7
|
+
from ipaddress import ip_address, ip_network
|
|
8
8
|
from pathlib import Path
|
|
9
9
|
|
|
10
|
+
from .config import load_v1_config
|
|
10
11
|
from .secrets import decrypt_secret
|
|
11
12
|
|
|
12
13
|
DEFAULT_HOSTS_FILE = Path("~/.hostbridge/hosts.json").expanduser()
|
|
@@ -14,6 +15,17 @@ LEGACY_HOSTS_FILE = Path("~/.server_control_mcp/hosts.json").expanduser()
|
|
|
14
15
|
HOST_ID_PATTERN = re.compile(r"^[A-Za-z0-9_.-]+$")
|
|
15
16
|
|
|
16
17
|
|
|
18
|
+
@dataclass(frozen=True, slots=True)
|
|
19
|
+
class HostLimits:
|
|
20
|
+
max_sessions: int = 16
|
|
21
|
+
command_timeout_seconds: int = 24 * 60 * 60
|
|
22
|
+
max_output_bytes: int = 1_000_000
|
|
23
|
+
max_stdin_bytes: int = 100 * 1024 * 1024
|
|
24
|
+
max_transfer_bytes: int = 100 * 1024 * 1024
|
|
25
|
+
max_text_bytes: int = 4 * 1024 * 1024
|
|
26
|
+
max_shell_bytes: int = 1024 * 1024
|
|
27
|
+
|
|
28
|
+
|
|
17
29
|
@dataclass(frozen=True, slots=True)
|
|
18
30
|
class LoginStep:
|
|
19
31
|
expect: str
|
|
@@ -21,6 +33,27 @@ class LoginStep:
|
|
|
21
33
|
send_secret: str | None = None
|
|
22
34
|
|
|
23
35
|
|
|
36
|
+
@dataclass(frozen=True, slots=True)
|
|
37
|
+
class SshHostConfig:
|
|
38
|
+
host: str
|
|
39
|
+
username: str | None = None
|
|
40
|
+
port: int = 22
|
|
41
|
+
identity_file: Path | None = None
|
|
42
|
+
proxy_jump: str | None = None
|
|
43
|
+
extra_args: tuple[str, ...] = ()
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass(frozen=True, slots=True)
|
|
47
|
+
class ForwardingPolicy:
|
|
48
|
+
enabled: bool = False
|
|
49
|
+
allow_cidrs: tuple[str, ...] = ()
|
|
50
|
+
allow_hostnames: tuple[str, ...] = ()
|
|
51
|
+
allow_ports: tuple[int, ...] = ()
|
|
52
|
+
max_streams: int = 128
|
|
53
|
+
idle_timeout_seconds: float = 300
|
|
54
|
+
stream_deadline_seconds: float = 24 * 60 * 60
|
|
55
|
+
|
|
56
|
+
|
|
24
57
|
@dataclass(frozen=True, slots=True)
|
|
25
58
|
class HostConfig:
|
|
26
59
|
"""A single approved connection target."""
|
|
@@ -35,11 +68,33 @@ class HostConfig:
|
|
|
35
68
|
login_steps: list[LoginStep] = field(default_factory=list)
|
|
36
69
|
ready_expect: str | None = None
|
|
37
70
|
transport: str = "auto"
|
|
71
|
+
limits: HostLimits = field(default_factory=HostLimits)
|
|
72
|
+
ssh: SshHostConfig | None = None
|
|
73
|
+
forwarding: ForwardingPolicy = field(default_factory=ForwardingPolicy)
|
|
38
74
|
|
|
39
75
|
@classmethod
|
|
40
|
-
def script(
|
|
76
|
+
def script(
|
|
77
|
+
cls,
|
|
78
|
+
host_id: str,
|
|
79
|
+
label: str,
|
|
80
|
+
script: Path | str,
|
|
81
|
+
*,
|
|
82
|
+
transport: str = "auto",
|
|
83
|
+
limits: HostLimits | None = None,
|
|
84
|
+
forwarding: ForwardingPolicy | None = None,
|
|
85
|
+
) -> HostConfig:
|
|
41
86
|
path = Path(script).expanduser()
|
|
42
|
-
return cls(
|
|
87
|
+
return cls(
|
|
88
|
+
host_id,
|
|
89
|
+
label,
|
|
90
|
+
"/bin/sh",
|
|
91
|
+
[str(path)],
|
|
92
|
+
"script",
|
|
93
|
+
path,
|
|
94
|
+
transport=transport,
|
|
95
|
+
limits=limits or HostLimits(),
|
|
96
|
+
forwarding=forwarding or ForwardingPolicy(),
|
|
97
|
+
)
|
|
43
98
|
|
|
44
99
|
@classmethod
|
|
45
100
|
def command_line(
|
|
@@ -54,6 +109,9 @@ class HostConfig:
|
|
|
54
109
|
login_steps: list[LoginStep] | None = None,
|
|
55
110
|
ready_expect: str | None = None,
|
|
56
111
|
transport: str = "auto",
|
|
112
|
+
limits: HostLimits | None = None,
|
|
113
|
+
ssh: SshHostConfig | None = None,
|
|
114
|
+
forwarding: ForwardingPolicy | None = None,
|
|
57
115
|
) -> HostConfig:
|
|
58
116
|
return cls(
|
|
59
117
|
host_id,
|
|
@@ -66,6 +124,9 @@ class HostConfig:
|
|
|
66
124
|
login_steps or [],
|
|
67
125
|
ready_expect,
|
|
68
126
|
transport,
|
|
127
|
+
limits or HostLimits(),
|
|
128
|
+
ssh,
|
|
129
|
+
forwarding or ForwardingPolicy(),
|
|
69
130
|
)
|
|
70
131
|
|
|
71
132
|
def describe(self) -> dict[str, object]:
|
|
@@ -76,6 +137,12 @@ class HostConfig:
|
|
|
76
137
|
"command": self.command,
|
|
77
138
|
"args": list(self.args),
|
|
78
139
|
"transport": self.transport,
|
|
140
|
+
"forwarding": {
|
|
141
|
+
"enabled": self.forwarding.enabled,
|
|
142
|
+
"max_streams": self.forwarding.max_streams,
|
|
143
|
+
"idle_timeout_seconds": self.forwarding.idle_timeout_seconds,
|
|
144
|
+
"stream_deadline_seconds": self.forwarding.stream_deadline_seconds,
|
|
145
|
+
},
|
|
79
146
|
}
|
|
80
147
|
if self.script_path is not None:
|
|
81
148
|
info.update(
|
|
@@ -96,25 +163,29 @@ DEFAULT_HOSTS: list[HostConfig] = []
|
|
|
96
163
|
def load_hosts(config_path: Path | str | None = None) -> list[HostConfig]:
|
|
97
164
|
"""Load approved hosts from JSON configuration."""
|
|
98
165
|
|
|
99
|
-
|
|
100
|
-
path = Path(configured).expanduser() if configured else _default_hosts_path()
|
|
166
|
+
path = configured_hosts_path(config_path)
|
|
101
167
|
if not path.exists():
|
|
102
168
|
return []
|
|
103
169
|
|
|
104
|
-
|
|
105
|
-
raw_hosts = data.get("hosts") if isinstance(data, dict) else None
|
|
106
|
-
if not isinstance(raw_hosts, list):
|
|
107
|
-
raise ValueError(f"{path} must contain a top-level 'hosts' list")
|
|
170
|
+
raw_hosts = load_v1_config(path).hosts
|
|
108
171
|
|
|
109
|
-
|
|
110
|
-
|
|
172
|
+
hosts: list[HostConfig] = []
|
|
173
|
+
seen: set[str] = set()
|
|
111
174
|
for index, item in enumerate(raw_hosts):
|
|
112
175
|
host = _parse_host(item, path, index)
|
|
113
|
-
if host.id
|
|
114
|
-
|
|
115
|
-
|
|
176
|
+
if host.id in seen:
|
|
177
|
+
raise ValueError(f"{path} contains duplicate host id {host.id!r}")
|
|
178
|
+
seen.add(host.id)
|
|
179
|
+
hosts.append(host)
|
|
116
180
|
|
|
117
|
-
return
|
|
181
|
+
return hosts
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def configured_hosts_path(config_path: Path | str | None = None) -> Path:
|
|
185
|
+
configured = (
|
|
186
|
+
config_path or os.environ.get("HOSTBRIDGE_HOSTS_FILE") or os.environ.get("SERVER_CONTROL_MCP_HOSTS_FILE")
|
|
187
|
+
)
|
|
188
|
+
return Path(configured).expanduser() if configured else _default_hosts_path()
|
|
118
189
|
|
|
119
190
|
|
|
120
191
|
def _parse_host(item: object, path: Path, index: int) -> HostConfig:
|
|
@@ -137,12 +208,23 @@ def _parse_host(item: object, path: Path, index: int) -> HostConfig:
|
|
|
137
208
|
transport = str(item.get("transport", "auto")).strip().lower()
|
|
138
209
|
if transport not in {"auto", "pty", "ssh"}:
|
|
139
210
|
raise ValueError(f"{path} hosts[{index}].transport must be one of: auto, pty, ssh")
|
|
211
|
+
if transport == "ssh" and "ssh" not in item:
|
|
212
|
+
raise ValueError(f"{path} hosts[{index}].transport ssh requires an ssh connection")
|
|
213
|
+
limits = _parse_limits(item.get("limits"), path, index)
|
|
214
|
+
forwarding = _parse_forwarding(item.get("forwarding"), path, index)
|
|
140
215
|
|
|
141
216
|
if "script" in item:
|
|
142
217
|
script = str(item.get("script", "")).strip()
|
|
143
218
|
if not script:
|
|
144
219
|
raise ValueError(f"{path} hosts[{index}].script must not be empty")
|
|
145
|
-
host = HostConfig.script(
|
|
220
|
+
host = HostConfig.script(
|
|
221
|
+
host_id,
|
|
222
|
+
label,
|
|
223
|
+
script,
|
|
224
|
+
transport=transport,
|
|
225
|
+
limits=limits,
|
|
226
|
+
forwarding=forwarding,
|
|
227
|
+
)
|
|
146
228
|
return HostConfig(
|
|
147
229
|
host.id,
|
|
148
230
|
host.label,
|
|
@@ -154,19 +236,29 @@ def _parse_host(item: object, path: Path, index: int) -> HostConfig:
|
|
|
154
236
|
login_steps,
|
|
155
237
|
ready_expect,
|
|
156
238
|
transport,
|
|
239
|
+
limits,
|
|
240
|
+
forwarding=forwarding,
|
|
157
241
|
)
|
|
158
242
|
|
|
159
243
|
if "ssh" in item:
|
|
244
|
+
ssh_config, ssh_args = _parse_ssh(item["ssh"], path, index)
|
|
245
|
+
if transport == "ssh" and ssh_config.extra_args:
|
|
246
|
+
raise ValueError(f"{path} hosts[{index}].ssh.extra_args cannot be used with native ssh transport")
|
|
247
|
+
if transport == "ssh" and login_steps:
|
|
248
|
+
raise ValueError(f"{path} hosts[{index}].login_steps cannot be used with native ssh transport")
|
|
160
249
|
return HostConfig.command_line(
|
|
161
250
|
host_id,
|
|
162
251
|
label,
|
|
163
252
|
"ssh",
|
|
164
|
-
|
|
253
|
+
ssh_args,
|
|
165
254
|
connection_type="ssh",
|
|
166
255
|
secrets=secrets,
|
|
167
256
|
login_steps=login_steps,
|
|
168
257
|
ready_expect=ready_expect,
|
|
169
258
|
transport=transport,
|
|
259
|
+
limits=limits,
|
|
260
|
+
ssh=ssh_config,
|
|
261
|
+
forwarding=forwarding,
|
|
170
262
|
)
|
|
171
263
|
|
|
172
264
|
command = str(item.get("command", "")).strip()
|
|
@@ -184,9 +276,119 @@ def _parse_host(item: object, path: Path, index: int) -> HostConfig:
|
|
|
184
276
|
login_steps=login_steps,
|
|
185
277
|
ready_expect=ready_expect,
|
|
186
278
|
transport=transport,
|
|
279
|
+
limits=limits,
|
|
280
|
+
forwarding=forwarding,
|
|
187
281
|
)
|
|
188
282
|
|
|
189
283
|
|
|
284
|
+
def _parse_forwarding(raw: object, path: Path, index: int) -> ForwardingPolicy:
|
|
285
|
+
if raw is None:
|
|
286
|
+
return ForwardingPolicy()
|
|
287
|
+
if not isinstance(raw, dict):
|
|
288
|
+
raise ValueError(f"{path} hosts[{index}].forwarding must be an object")
|
|
289
|
+
allowed_fields = {
|
|
290
|
+
"enabled",
|
|
291
|
+
"allow_cidrs",
|
|
292
|
+
"allow_hostnames",
|
|
293
|
+
"allow_ports",
|
|
294
|
+
"max_streams",
|
|
295
|
+
"idle_timeout_seconds",
|
|
296
|
+
"stream_deadline_seconds",
|
|
297
|
+
}
|
|
298
|
+
unknown = sorted(set(raw) - allowed_fields)
|
|
299
|
+
if unknown:
|
|
300
|
+
raise ValueError(f"{path} hosts[{index}].forwarding contains unknown field {unknown[0]!r}")
|
|
301
|
+
|
|
302
|
+
enabled = raw.get("enabled", False)
|
|
303
|
+
if not isinstance(enabled, bool):
|
|
304
|
+
raise ValueError(f"{path} hosts[{index}].forwarding.enabled must be a boolean")
|
|
305
|
+
|
|
306
|
+
raw_cidrs = raw.get("allow_cidrs", [])
|
|
307
|
+
if not isinstance(raw_cidrs, list) or not all(isinstance(value, str) for value in raw_cidrs):
|
|
308
|
+
raise ValueError(f"{path} hosts[{index}].forwarding.allow_cidrs must be a list of CIDR strings")
|
|
309
|
+
cidrs: list[str] = []
|
|
310
|
+
for value in raw_cidrs:
|
|
311
|
+
try:
|
|
312
|
+
network = ip_network(value, strict=True)
|
|
313
|
+
except ValueError as exc:
|
|
314
|
+
raise ValueError(f"{path} hosts[{index}].forwarding contains invalid CIDR {value!r}") from exc
|
|
315
|
+
cidrs.append(str(network))
|
|
316
|
+
|
|
317
|
+
raw_hostnames = raw.get("allow_hostnames", [])
|
|
318
|
+
if not isinstance(raw_hostnames, list) or not all(isinstance(value, str) for value in raw_hostnames):
|
|
319
|
+
raise ValueError(f"{path} hosts[{index}].forwarding.allow_hostnames must be a list of hostnames")
|
|
320
|
+
hostnames: list[str] = []
|
|
321
|
+
for value in raw_hostnames:
|
|
322
|
+
hostname = value.strip().rstrip(".").lower()
|
|
323
|
+
if not _valid_exact_hostname(hostname):
|
|
324
|
+
raise ValueError(f"{path} hosts[{index}].forwarding contains invalid hostname {value!r}")
|
|
325
|
+
hostnames.append(hostname)
|
|
326
|
+
|
|
327
|
+
raw_ports = raw.get("allow_ports", [])
|
|
328
|
+
if not isinstance(raw_ports, list) or any(
|
|
329
|
+
not isinstance(port, int) or isinstance(port, bool) or not 1 <= port <= 65535 for port in raw_ports
|
|
330
|
+
):
|
|
331
|
+
raise ValueError(f"{path} hosts[{index}].forwarding.allow_ports must contain valid TCP ports")
|
|
332
|
+
|
|
333
|
+
max_streams = raw.get("max_streams", 128)
|
|
334
|
+
if not isinstance(max_streams, int) or isinstance(max_streams, bool) or not 1 <= max_streams <= 4096:
|
|
335
|
+
raise ValueError(f"{path} hosts[{index}].forwarding.max_streams must be between 1 and 4096")
|
|
336
|
+
idle_timeout = raw.get("idle_timeout_seconds", 300)
|
|
337
|
+
if not isinstance(idle_timeout, (int, float)) or isinstance(idle_timeout, bool) or not 0 < idle_timeout <= 86400:
|
|
338
|
+
raise ValueError(f"{path} hosts[{index}].forwarding.idle_timeout_seconds must be between 0 and 86400")
|
|
339
|
+
stream_deadline = raw.get("stream_deadline_seconds", 24 * 60 * 60)
|
|
340
|
+
if (
|
|
341
|
+
not isinstance(stream_deadline, (int, float))
|
|
342
|
+
or isinstance(stream_deadline, bool)
|
|
343
|
+
or not 0 < stream_deadline <= 7 * 24 * 60 * 60
|
|
344
|
+
):
|
|
345
|
+
raise ValueError(f"{path} hosts[{index}].forwarding.stream_deadline_seconds must be between 0 and 604800")
|
|
346
|
+
|
|
347
|
+
return ForwardingPolicy(
|
|
348
|
+
enabled=enabled,
|
|
349
|
+
allow_cidrs=tuple(dict.fromkeys(cidrs)),
|
|
350
|
+
allow_hostnames=tuple(dict.fromkeys(hostnames)),
|
|
351
|
+
allow_ports=tuple(dict.fromkeys(raw_ports)),
|
|
352
|
+
max_streams=max_streams,
|
|
353
|
+
idle_timeout_seconds=float(idle_timeout),
|
|
354
|
+
stream_deadline_seconds=float(stream_deadline),
|
|
355
|
+
)
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def _valid_exact_hostname(hostname: str) -> bool:
|
|
359
|
+
if not hostname or len(hostname) > 253 or "*" in hostname or "\x00" in hostname:
|
|
360
|
+
return False
|
|
361
|
+
try:
|
|
362
|
+
ip_address(hostname)
|
|
363
|
+
except ValueError:
|
|
364
|
+
pass
|
|
365
|
+
else:
|
|
366
|
+
return False
|
|
367
|
+
labels = hostname.split(".")
|
|
368
|
+
return all(
|
|
369
|
+
1 <= len(label) <= 63
|
|
370
|
+
and label[0].isalnum()
|
|
371
|
+
and label[-1].isalnum()
|
|
372
|
+
and all(character.isalnum() or character == "-" for character in label)
|
|
373
|
+
for label in labels
|
|
374
|
+
)
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
def _parse_limits(raw_limits: object, path: Path, index: int) -> HostLimits:
|
|
378
|
+
if raw_limits is None:
|
|
379
|
+
return HostLimits()
|
|
380
|
+
if not isinstance(raw_limits, dict):
|
|
381
|
+
raise ValueError(f"{path} hosts[{index}].limits must be an object")
|
|
382
|
+
values = {field: getattr(HostLimits(), field) for field in HostLimits.__dataclass_fields__}
|
|
383
|
+
for name, value in raw_limits.items():
|
|
384
|
+
if name not in values:
|
|
385
|
+
raise ValueError(f"{path} hosts[{index}].limits contains unknown limit {name!r}")
|
|
386
|
+
if not isinstance(value, int) or isinstance(value, bool) or value <= 0:
|
|
387
|
+
raise ValueError(f"{path} hosts[{index}].limits.{name} must be a positive integer")
|
|
388
|
+
values[name] = value
|
|
389
|
+
return HostLimits(**values)
|
|
390
|
+
|
|
391
|
+
|
|
190
392
|
def _parse_ready_expect(raw_ready_expect: object, path: Path, index: int) -> str | None:
|
|
191
393
|
if raw_ready_expect in (None, ""):
|
|
192
394
|
return None
|
|
@@ -218,20 +420,36 @@ def _parse_login_steps(raw_steps: object, path: Path, index: int) -> list[LoginS
|
|
|
218
420
|
if not expect:
|
|
219
421
|
raise ValueError(f"{path} hosts[{index}].login_steps[{step_index}].expect must not be empty")
|
|
220
422
|
if (send is None) == (send_secret is None):
|
|
221
|
-
raise ValueError(
|
|
222
|
-
|
|
423
|
+
raise ValueError(
|
|
424
|
+
f"{path} hosts[{index}].login_steps[{step_index}] must define exactly one of send or send_secret"
|
|
425
|
+
)
|
|
426
|
+
steps.append(
|
|
427
|
+
LoginStep(
|
|
428
|
+
expect=expect,
|
|
429
|
+
send=None if send is None else str(send),
|
|
430
|
+
send_secret=None if send_secret is None else str(send_secret),
|
|
431
|
+
)
|
|
432
|
+
)
|
|
223
433
|
return steps
|
|
224
434
|
|
|
225
435
|
|
|
226
|
-
def
|
|
436
|
+
def _parse_ssh(raw_ssh: object, path: Path, index: int) -> tuple[SshHostConfig, list[str]]:
|
|
227
437
|
if not isinstance(raw_ssh, dict):
|
|
228
438
|
raise ValueError(f"{path} hosts[{index}].ssh must be an object")
|
|
229
439
|
target = str(raw_ssh.get("host", "")).strip()
|
|
230
440
|
if not target:
|
|
231
441
|
raise ValueError(f"{path} hosts[{index}].ssh.host must not be empty")
|
|
232
442
|
|
|
443
|
+
username: str | None = None
|
|
444
|
+
host = target
|
|
445
|
+
if "@" in target:
|
|
446
|
+
username, host = target.rsplit("@", 1)
|
|
447
|
+
if not username or not host:
|
|
448
|
+
raise ValueError(f"{path} hosts[{index}].ssh.host must be [username@]hostname")
|
|
449
|
+
|
|
233
450
|
args: list[str] = []
|
|
234
451
|
port = raw_ssh.get("port")
|
|
452
|
+
parsed_port = 22
|
|
235
453
|
if port not in (None, ""):
|
|
236
454
|
if isinstance(port, bool) or not isinstance(port, int | str):
|
|
237
455
|
raise ValueError(f"{path} hosts[{index}].ssh.port must be an integer")
|
|
@@ -243,8 +461,9 @@ def _parse_ssh_args(raw_ssh: object, path: Path, index: int) -> list[str]:
|
|
|
243
461
|
raise ValueError(f"{path} hosts[{index}].ssh.port must be between 1 and 65535")
|
|
244
462
|
args.extend(["-p", str(parsed_port)])
|
|
245
463
|
identity_file = str(raw_ssh.get("identity_file", "")).strip()
|
|
464
|
+
identity_path = Path(identity_file).expanduser() if identity_file else None
|
|
246
465
|
if identity_file:
|
|
247
|
-
args.extend(["-i", str(
|
|
466
|
+
args.extend(["-i", str(identity_path)])
|
|
248
467
|
proxy_jump = str(raw_ssh.get("proxy_jump", "")).strip()
|
|
249
468
|
if proxy_jump:
|
|
250
469
|
args.extend(["-J", proxy_jump])
|
|
@@ -253,7 +472,17 @@ def _parse_ssh_args(raw_ssh: object, path: Path, index: int) -> list[str]:
|
|
|
253
472
|
raise ValueError(f"{path} hosts[{index}].ssh.extra_args must be a list of strings")
|
|
254
473
|
args.extend(extra_args)
|
|
255
474
|
args.append(target)
|
|
256
|
-
return
|
|
475
|
+
return (
|
|
476
|
+
SshHostConfig(
|
|
477
|
+
host=host,
|
|
478
|
+
username=username,
|
|
479
|
+
port=parsed_port,
|
|
480
|
+
identity_file=identity_path,
|
|
481
|
+
proxy_jump=proxy_jump or None,
|
|
482
|
+
extra_args=tuple(extra_args),
|
|
483
|
+
),
|
|
484
|
+
args,
|
|
485
|
+
)
|
|
257
486
|
|
|
258
487
|
|
|
259
488
|
class HostRegistry:
|
|
@@ -272,8 +501,7 @@ class HostRegistry:
|
|
|
272
501
|
except KeyError as exc:
|
|
273
502
|
if not self._hosts:
|
|
274
503
|
raise KeyError(
|
|
275
|
-
"no hosts are configured; create ~/.hostbridge/hosts.json "
|
|
276
|
-
"or set HOSTBRIDGE_HOSTS_FILE"
|
|
504
|
+
"no hosts are configured; create ~/.hostbridge/hosts.json or set HOSTBRIDGE_HOSTS_FILE"
|
|
277
505
|
) from exc
|
|
278
506
|
valid = ", ".join(sorted(self._hosts))
|
|
279
507
|
raise KeyError(f"unknown host '{host_id}'; valid hosts: {valid}") from exc
|