@neoline/hostbridge 2.0.2 → 2.0.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +15 -8
- package/bin/hostbridge.js +80 -9
- package/examples/claude_desktop_config.json +1 -1
- package/examples/codex.config.toml +1 -1
- package/examples/hosts.example.json +1 -0
- package/package.json +1 -1
- package/pyproject.toml +5 -4
- package/src/server_control_mcp/__init__.py +58 -23
- package/src/server_control_mcp/cli.py +1 -1
- package/src/server_control_mcp/client.py +99 -7
- package/src/server_control_mcp/config.py +16 -5
- package/src/server_control_mcp/daemon.py +239 -49
- package/src/server_control_mcp/doctor.py +8 -4
- package/src/server_control_mcp/hosts.py +118 -24
- package/src/server_control_mcp/mock_ssh.py +62 -605
- package/src/server_control_mcp/mock_ssh_exec.py +210 -0
- package/src/server_control_mcp/mock_ssh_session.py +68 -0
- package/src/server_control_mcp/mock_ssh_sftp.py +506 -0
- package/src/server_control_mcp/mock_ssh_tunnel.py +592 -0
- package/src/server_control_mcp/policy.py +86 -14
- package/src/server_control_mcp/protocol.py +18 -17
- package/src/server_control_mcp/remote_task_agent.py +102 -0
- package/src/server_control_mcp/remote_tunnel_agent.py +143 -0
- package/src/server_control_mcp/runtime.py +3 -2
- package/src/server_control_mcp/secrets.py +6 -6
- package/src/server_control_mcp/secure_files.py +121 -0
- package/src/server_control_mcp/server.py +30 -12
- package/src/server_control_mcp/services.py +363 -165
- package/src/server_control_mcp/transports/pty.py +114 -33
- package/src/server_control_mcp/transports/ssh.py +308 -24
|
@@ -18,12 +18,16 @@ from dataclasses import dataclass, field
|
|
|
18
18
|
from pathlib import Path
|
|
19
19
|
from typing import Any, Protocol
|
|
20
20
|
|
|
21
|
+
from .secure_files import open_private_append, read_private_text
|
|
22
|
+
|
|
21
23
|
DEFAULT_POLICY_FILE = Path("~/.hostbridge/policy.json").expanduser()
|
|
22
24
|
LEGACY_POLICY_FILE = Path("~/.server_control_mcp/policy.json").expanduser()
|
|
23
25
|
DEFAULT_AUDIT_LOG = Path("~/.hostbridge/audit.jsonl").expanduser()
|
|
24
26
|
LEGACY_AUDIT_LOG = Path("~/.server_control_mcp/audit.jsonl").expanduser()
|
|
25
27
|
DEFAULT_IDLE_TIMEOUT_SECONDS = 1800
|
|
26
28
|
DEFAULT_REAPER_INTERVAL_SECONDS = 60
|
|
29
|
+
DEFAULT_AUDIT_MAX_BYTES = 10 * 1024 * 1024
|
|
30
|
+
DEFAULT_AUDIT_BACKUP_COUNT = 3
|
|
27
31
|
|
|
28
32
|
DEFAULT_DENYLIST_PATTERNS = (
|
|
29
33
|
r"\brm\s+-rf\s+/(?:\s|$)",
|
|
@@ -46,16 +50,37 @@ class _NullAudit:
|
|
|
46
50
|
|
|
47
51
|
|
|
48
52
|
class _JsonlAudit:
|
|
49
|
-
def __init__(self, path: Path) -> None:
|
|
53
|
+
def __init__(self, path: Path, *, max_bytes: int, backup_count: int) -> None:
|
|
50
54
|
self._path = path
|
|
55
|
+
self._max_bytes = max_bytes
|
|
56
|
+
self._backup_count = backup_count
|
|
51
57
|
self._lock = threading.Lock()
|
|
52
58
|
|
|
53
59
|
def write(self, event: dict[str, Any]) -> None:
|
|
54
60
|
line = json.dumps(event, ensure_ascii=False, sort_keys=True) + "\n"
|
|
61
|
+
encoded_size = len(line.encode("utf-8"))
|
|
55
62
|
with self._lock:
|
|
56
|
-
self._path.
|
|
57
|
-
|
|
63
|
+
current_size = self._path.stat().st_size if self._path.exists() else 0
|
|
64
|
+
if current_size and current_size + encoded_size > self._max_bytes:
|
|
65
|
+
self._rotate()
|
|
66
|
+
with open_private_append(self._path) as handle:
|
|
58
67
|
handle.write(line)
|
|
68
|
+
handle.flush()
|
|
69
|
+
|
|
70
|
+
def _rotate(self) -> None:
|
|
71
|
+
if self._backup_count == 0:
|
|
72
|
+
self._path.unlink(missing_ok=True)
|
|
73
|
+
return
|
|
74
|
+
oldest = self._backup_path(self._backup_count)
|
|
75
|
+
oldest.unlink(missing_ok=True)
|
|
76
|
+
for index in range(self._backup_count - 1, 0, -1):
|
|
77
|
+
source = self._backup_path(index)
|
|
78
|
+
if source.exists():
|
|
79
|
+
source.replace(self._backup_path(index + 1))
|
|
80
|
+
self._path.replace(self._backup_path(1))
|
|
81
|
+
|
|
82
|
+
def _backup_path(self, index: int) -> Path:
|
|
83
|
+
return self._path.with_name(f"{self._path.name}.{index}")
|
|
59
84
|
|
|
60
85
|
|
|
61
86
|
@dataclass(frozen=True, slots=True)
|
|
@@ -70,6 +95,8 @@ class PolicyConfig:
|
|
|
70
95
|
idle_timeout_seconds: int = DEFAULT_IDLE_TIMEOUT_SECONDS
|
|
71
96
|
command_denylist: tuple[re.Pattern[str], ...] = field(default_factory=tuple)
|
|
72
97
|
audit_log_path: Path | None = None
|
|
98
|
+
audit_max_bytes: int = DEFAULT_AUDIT_MAX_BYTES
|
|
99
|
+
audit_backup_count: int = DEFAULT_AUDIT_BACKUP_COUNT
|
|
73
100
|
enabled: bool = True
|
|
74
101
|
|
|
75
102
|
@classmethod
|
|
@@ -90,7 +117,9 @@ class PolicyConfig:
|
|
|
90
117
|
idle_timeout = int(
|
|
91
118
|
env.get(
|
|
92
119
|
"HOSTBRIDGE_IDLE_TIMEOUT",
|
|
93
|
-
env.get(
|
|
120
|
+
env.get(
|
|
121
|
+
"SERVER_CONTROL_MCP_IDLE_TIMEOUT", raw.get("idle_timeout_seconds", DEFAULT_IDLE_TIMEOUT_SECONDS)
|
|
122
|
+
),
|
|
94
123
|
)
|
|
95
124
|
)
|
|
96
125
|
idle_timeout = max(0, idle_timeout)
|
|
@@ -104,17 +133,43 @@ class PolicyConfig:
|
|
|
104
133
|
if env_extra:
|
|
105
134
|
patterns.extend(p for p in env_extra.split(os.pathsep) if p)
|
|
106
135
|
|
|
107
|
-
default_audit =
|
|
136
|
+
default_audit = (
|
|
137
|
+
DEFAULT_AUDIT_LOG if DEFAULT_AUDIT_LOG.exists() or not LEGACY_AUDIT_LOG.exists() else LEGACY_AUDIT_LOG
|
|
138
|
+
)
|
|
108
139
|
audit_path_str = env.get(
|
|
109
140
|
"HOSTBRIDGE_AUDIT_LOG",
|
|
110
141
|
env.get("SERVER_CONTROL_MCP_AUDIT_LOG", str(raw.get("audit_log_path") or default_audit)),
|
|
111
142
|
)
|
|
112
143
|
audit_path = Path(audit_path_str).expanduser() if audit_path_str else None
|
|
144
|
+
audit_max_bytes = int(
|
|
145
|
+
env.get(
|
|
146
|
+
"HOSTBRIDGE_AUDIT_MAX_BYTES",
|
|
147
|
+
env.get(
|
|
148
|
+
"SERVER_CONTROL_MCP_AUDIT_MAX_BYTES",
|
|
149
|
+
raw.get("audit_max_bytes", DEFAULT_AUDIT_MAX_BYTES),
|
|
150
|
+
),
|
|
151
|
+
)
|
|
152
|
+
)
|
|
153
|
+
audit_backup_count = int(
|
|
154
|
+
env.get(
|
|
155
|
+
"HOSTBRIDGE_AUDIT_BACKUP_COUNT",
|
|
156
|
+
env.get(
|
|
157
|
+
"SERVER_CONTROL_MCP_AUDIT_BACKUP_COUNT",
|
|
158
|
+
raw.get("audit_backup_count", DEFAULT_AUDIT_BACKUP_COUNT),
|
|
159
|
+
),
|
|
160
|
+
)
|
|
161
|
+
)
|
|
162
|
+
if audit_max_bytes <= 0:
|
|
163
|
+
raise ValueError("audit_max_bytes must be positive")
|
|
164
|
+
if audit_backup_count < 0:
|
|
165
|
+
raise ValueError("audit_backup_count must be non-negative")
|
|
113
166
|
|
|
114
167
|
return cls(
|
|
115
168
|
idle_timeout_seconds=idle_timeout,
|
|
116
169
|
command_denylist=tuple(re.compile(pattern) for pattern in patterns),
|
|
117
170
|
audit_log_path=audit_path,
|
|
171
|
+
audit_max_bytes=audit_max_bytes,
|
|
172
|
+
audit_backup_count=audit_backup_count,
|
|
118
173
|
enabled=True,
|
|
119
174
|
)
|
|
120
175
|
|
|
@@ -130,7 +185,9 @@ class PolicyConfig:
|
|
|
130
185
|
|
|
131
186
|
|
|
132
187
|
def load_policy(config_path: Path | str | None = None) -> PolicyConfig:
|
|
133
|
-
if (
|
|
188
|
+
if (
|
|
189
|
+
os.environ.get("HOSTBRIDGE_DISABLE_POLICY", "") or os.environ.get("SERVER_CONTROL_MCP_DISABLE_POLICY", "")
|
|
190
|
+
).lower() in (
|
|
134
191
|
"1",
|
|
135
192
|
"true",
|
|
136
193
|
"yes",
|
|
@@ -141,24 +198,39 @@ def load_policy(config_path: Path | str | None = None) -> PolicyConfig:
|
|
|
141
198
|
config_path
|
|
142
199
|
or os.environ.get("HOSTBRIDGE_POLICY_FILE")
|
|
143
200
|
or os.environ.get("SERVER_CONTROL_MCP_POLICY_FILE")
|
|
144
|
-
or (
|
|
201
|
+
or (
|
|
202
|
+
DEFAULT_POLICY_FILE
|
|
203
|
+
if DEFAULT_POLICY_FILE.exists() or not LEGACY_POLICY_FILE.exists()
|
|
204
|
+
else LEGACY_POLICY_FILE
|
|
205
|
+
)
|
|
145
206
|
).expanduser()
|
|
146
207
|
|
|
147
208
|
raw: dict[str, Any] = {}
|
|
148
|
-
|
|
209
|
+
try:
|
|
210
|
+
policy_text = read_private_text(path)
|
|
211
|
+
except FileNotFoundError:
|
|
212
|
+
policy_text = None
|
|
213
|
+
except OSError as exc:
|
|
214
|
+
raise ValueError(f"failed to read HostBridge policy file {path}") from exc
|
|
215
|
+
if policy_text is not None:
|
|
149
216
|
try:
|
|
150
|
-
data = json.loads(
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
217
|
+
data = json.loads(policy_text)
|
|
218
|
+
except json.JSONDecodeError as exc:
|
|
219
|
+
raise ValueError(f"HostBridge policy file {path} is not valid JSON") from exc
|
|
220
|
+
if not isinstance(data, dict):
|
|
221
|
+
raise ValueError(f"HostBridge policy file {path} must contain a JSON object")
|
|
222
|
+
raw = data
|
|
155
223
|
return PolicyConfig.from_dict(raw)
|
|
156
224
|
|
|
157
225
|
|
|
158
226
|
def build_audit_sink(policy: PolicyConfig) -> AuditSink:
|
|
159
227
|
if not policy.enabled or policy.audit_log_path is None:
|
|
160
228
|
return _NullAudit()
|
|
161
|
-
return _JsonlAudit(
|
|
229
|
+
return _JsonlAudit(
|
|
230
|
+
policy.audit_log_path,
|
|
231
|
+
max_bytes=policy.audit_max_bytes,
|
|
232
|
+
backup_count=policy.audit_backup_count,
|
|
233
|
+
)
|
|
162
234
|
|
|
163
235
|
|
|
164
236
|
def audit_event(
|
|
@@ -66,6 +66,21 @@ def encode_frame(frame: BinaryFrame) -> bytes:
|
|
|
66
66
|
def decode_frame(data: bytes) -> tuple[BinaryFrame, bytes]:
|
|
67
67
|
if not isinstance(data, bytes):
|
|
68
68
|
raise ProtocolError("frame data must be bytes")
|
|
69
|
+
sequence, flags, stream_id_length, payload_length = _decode_header(data)
|
|
70
|
+
frame_length = _FRAME_HEADER.size + stream_id_length + payload_length
|
|
71
|
+
if len(data) < frame_length:
|
|
72
|
+
raise ProtocolError("incomplete frame payload")
|
|
73
|
+
stream_id_start = _FRAME_HEADER.size
|
|
74
|
+
payload_start = stream_id_start + stream_id_length
|
|
75
|
+
try:
|
|
76
|
+
stream_id = data[stream_id_start:payload_start].decode("utf-8")
|
|
77
|
+
except UnicodeDecodeError as exc:
|
|
78
|
+
raise ProtocolError("stream_id must be valid UTF-8") from exc
|
|
79
|
+
payload = data[payload_start:frame_length]
|
|
80
|
+
return BinaryFrame(_required_text(stream_id, "stream_id"), sequence, flags, payload), data[frame_length:]
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _decode_header(data: bytes) -> tuple[int, FrameFlags, int, int]:
|
|
69
84
|
if len(data) < _FRAME_HEADER.size:
|
|
70
85
|
raise ProtocolError("incomplete frame header")
|
|
71
86
|
magic, protocol, raw_flags, reserved, sequence, stream_id_length, payload_length = _FRAME_HEADER.unpack_from(data)
|
|
@@ -79,23 +94,13 @@ def decode_frame(data: bytes) -> tuple[BinaryFrame, bytes]:
|
|
|
79
94
|
raise ProtocolError(f"stream_id exceeds {MAX_STREAM_ID_BYTES} bytes")
|
|
80
95
|
if payload_length > MAX_FRAME_BYTES:
|
|
81
96
|
raise ProtocolError(f"frame payload exceeds {MAX_FRAME_BYTES} bytes")
|
|
82
|
-
frame_length = _FRAME_HEADER.size + stream_id_length + payload_length
|
|
83
|
-
if len(data) < frame_length:
|
|
84
|
-
raise ProtocolError("incomplete frame payload")
|
|
85
|
-
stream_id_start = _FRAME_HEADER.size
|
|
86
|
-
payload_start = stream_id_start + stream_id_length
|
|
87
|
-
try:
|
|
88
|
-
stream_id = data[stream_id_start:payload_start].decode("utf-8")
|
|
89
|
-
except UnicodeDecodeError as exc:
|
|
90
|
-
raise ProtocolError("stream_id must be valid UTF-8") from exc
|
|
91
97
|
try:
|
|
92
98
|
flags = FrameFlags(raw_flags)
|
|
93
99
|
except ValueError as exc:
|
|
94
100
|
raise ProtocolError("flags contain an unsupported frame flag") from exc
|
|
95
101
|
if not flags or int(flags) & ~int(FrameFlags.DATA | FrameFlags.EOF | FrameFlags.CANCEL):
|
|
96
102
|
raise ProtocolError("flags contain an unsupported frame flag")
|
|
97
|
-
|
|
98
|
-
return BinaryFrame(_required_text(stream_id, "stream_id"), sequence, flags, payload), data[frame_length:]
|
|
103
|
+
return sequence, flags, stream_id_length, payload_length
|
|
99
104
|
|
|
100
105
|
|
|
101
106
|
def _read_exact(stream: BinaryIO, size: int) -> bytes:
|
|
@@ -110,9 +115,7 @@ def _read_exact(stream: BinaryIO, size: int) -> bytes:
|
|
|
110
115
|
|
|
111
116
|
def read_frame(stream: BinaryIO) -> BinaryFrame:
|
|
112
117
|
header = _read_exact(stream, _FRAME_HEADER.size)
|
|
113
|
-
_, _,
|
|
114
|
-
if stream_id_length > MAX_STREAM_ID_BYTES or payload_length > MAX_FRAME_BYTES:
|
|
115
|
-
decode_frame(header)
|
|
118
|
+
_, _, stream_id_length, payload_length = _decode_header(header)
|
|
116
119
|
body = _read_exact(stream, stream_id_length + payload_length)
|
|
117
120
|
frame, remainder = decode_frame(header + body)
|
|
118
121
|
if remainder:
|
|
@@ -169,9 +172,7 @@ class FrameSequenceValidator:
|
|
|
169
172
|
def accept(self, frame: BinaryFrame) -> None:
|
|
170
173
|
expected = self._next_by_stream.get(frame.stream_id, 0)
|
|
171
174
|
if frame.sequence != expected:
|
|
172
|
-
raise ProtocolError(
|
|
173
|
-
f"stream {frame.stream_id!r} expected sequence {expected}, received {frame.sequence}"
|
|
174
|
-
)
|
|
175
|
+
raise ProtocolError(f"stream {frame.stream_id!r} expected sequence {expected}, received {frame.sequence}")
|
|
175
176
|
self._next_by_stream[frame.stream_id] = expected + 1
|
|
176
177
|
|
|
177
178
|
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import shlex
|
|
4
|
+
from collections.abc import Iterable
|
|
5
|
+
|
|
6
|
+
REMOTE_TASK_LOG_MAX_BYTES = 1024 * 1024
|
|
7
|
+
REMOTE_TASK_READ_CHUNK_BYTES = 64 * 1024
|
|
8
|
+
|
|
9
|
+
REMOTE_TASK_AGENT = r"""
|
|
10
|
+
import os
|
|
11
|
+
import pathlib
|
|
12
|
+
import subprocess
|
|
13
|
+
import sys
|
|
14
|
+
|
|
15
|
+
command, job_dir_text, log_limit_text = sys.argv[1], sys.argv[2], sys.argv[3]
|
|
16
|
+
job_dir = pathlib.Path(job_dir_text)
|
|
17
|
+
log_limit = int(log_limit_text)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def write_atomic(name, value):
|
|
21
|
+
temporary = job_dir / ("." + name + ".tmp")
|
|
22
|
+
temporary.write_text(str(value) + "\n", encoding="ascii")
|
|
23
|
+
os.replace(temporary, job_dir / name)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
os.setsid()
|
|
27
|
+
write_atomic("pid", os.getpid())
|
|
28
|
+
with (job_dir / "stdout.log").open("w+b", buffering=0) as output:
|
|
29
|
+
process = subprocess.Popen(
|
|
30
|
+
["/bin/sh", "-lc", command],
|
|
31
|
+
stdin=subprocess.DEVNULL,
|
|
32
|
+
stdout=subprocess.PIPE,
|
|
33
|
+
stderr=subprocess.STDOUT,
|
|
34
|
+
)
|
|
35
|
+
current_size = 0
|
|
36
|
+
while True:
|
|
37
|
+
chunk = process.stdout.read(65536)
|
|
38
|
+
if not chunk:
|
|
39
|
+
break
|
|
40
|
+
if len(chunk) >= log_limit:
|
|
41
|
+
retained = chunk[-log_limit:]
|
|
42
|
+
elif current_size + len(chunk) > log_limit:
|
|
43
|
+
keep = log_limit - len(chunk)
|
|
44
|
+
output.seek(current_size - keep)
|
|
45
|
+
retained = output.read(keep) + chunk
|
|
46
|
+
else:
|
|
47
|
+
output.seek(current_size)
|
|
48
|
+
output.write(chunk)
|
|
49
|
+
current_size += len(chunk)
|
|
50
|
+
continue
|
|
51
|
+
output.seek(0)
|
|
52
|
+
output.write(retained)
|
|
53
|
+
output.truncate()
|
|
54
|
+
current_size = len(retained)
|
|
55
|
+
returncode = process.wait()
|
|
56
|
+
status = returncode if returncode >= 0 else 128 - returncode
|
|
57
|
+
write_atomic("exit_code", status)
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def remote_job_dir(task_id: str) -> str:
|
|
62
|
+
return f"$HOME/.hostbridge/jobs/{task_id}"
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def remote_job_dir_assignment(task_id: str) -> str:
|
|
66
|
+
relative_path = shlex.quote(f".hostbridge/jobs/{task_id}")
|
|
67
|
+
return f'job_dir="$HOME"/{relative_path}'
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def build_remote_task_command(command: str, task_id: str) -> str:
|
|
71
|
+
supervisor = (
|
|
72
|
+
f'python3 -u -c {shlex.quote(REMOTE_TASK_AGENT)} {shlex.quote(command)} "$job_dir" {REMOTE_TASK_LOG_MAX_BYTES}'
|
|
73
|
+
)
|
|
74
|
+
return (
|
|
75
|
+
'umask 077; [ -n "${HOME:-}" ] || exit 1; '
|
|
76
|
+
f'{remote_job_dir_assignment(task_id)}; mkdir -p -- "$job_dir" || exit $?; '
|
|
77
|
+
'chmod 700 "$HOME/.hostbridge" "$HOME/.hostbridge/jobs" "$job_dir" || exit $?; '
|
|
78
|
+
f"nohup {supervisor} </dev/null >/dev/null 2>&1 & launcher=$!; "
|
|
79
|
+
'attempt=0; while [ "$attempt" -lt 100 ]; do '
|
|
80
|
+
'if [ -s "$job_dir/pid" ]; then cat "$job_dir/pid"; exit 0; fi; '
|
|
81
|
+
'if ! kill -0 "$launcher" 2>/dev/null; then break; fi; '
|
|
82
|
+
"attempt=$((attempt + 1)); sleep 0.05; done; exit 1"
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def build_remote_tasks_cleanup_command(tasks: Iterable[tuple[str, int | None]]) -> str:
|
|
87
|
+
cleanup_steps = ["umask 077", '[ -n "${HOME:-}" ] || exit 1']
|
|
88
|
+
for task_id, recorded_pid in tasks:
|
|
89
|
+
fallback_pid = str(recorded_pid) if recorded_pid is not None else ""
|
|
90
|
+
cleanup_steps.extend(
|
|
91
|
+
(
|
|
92
|
+
remote_job_dir_assignment(task_id),
|
|
93
|
+
f'pid=$(cat "$job_dir/pid" 2>/dev/null || printf %s {shlex.quote(fallback_pid)})',
|
|
94
|
+
'if [ ! -f "$job_dir/exit_code" ] && [ -n "$pid" ] && kill -0 -- -"$pid" 2>/dev/null; then '
|
|
95
|
+
'kill -TERM -- -"$pid" 2>/dev/null || true; attempt=0; '
|
|
96
|
+
'while kill -0 -- -"$pid" 2>/dev/null && [ "$attempt" -lt 20 ]; do '
|
|
97
|
+
"attempt=$((attempt + 1)); sleep 0.05; done; "
|
|
98
|
+
'kill -KILL -- -"$pid" 2>/dev/null || true; fi',
|
|
99
|
+
'rm -rf -- "$job_dir" || exit $?',
|
|
100
|
+
)
|
|
101
|
+
)
|
|
102
|
+
return "; ".join(cleanup_steps)
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import shlex
|
|
4
|
+
|
|
5
|
+
REMOTE_TUNNEL_AGENT = r"""
|
|
6
|
+
import base64
|
|
7
|
+
import os
|
|
8
|
+
import socket
|
|
9
|
+
import sys
|
|
10
|
+
import termios
|
|
11
|
+
import threading
|
|
12
|
+
|
|
13
|
+
host, port, prefix = sys.argv[1], int(sys.argv[2]), sys.argv[3]
|
|
14
|
+
prefix_bytes = prefix.encode("ascii")
|
|
15
|
+
write_lock = threading.Lock()
|
|
16
|
+
downstream_sequence = 0
|
|
17
|
+
upstream_sequence = 0
|
|
18
|
+
terminal_attributes = None
|
|
19
|
+
connection = None
|
|
20
|
+
receiver_error = []
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def emit(kind, payload=b"", status=None):
|
|
24
|
+
global downstream_sequence
|
|
25
|
+
status_text = "-" if status is None else str(status)
|
|
26
|
+
encoded = base64.b64encode(payload).decode("ascii")
|
|
27
|
+
with write_lock:
|
|
28
|
+
line = "%s:V1:D:%d:%s:%s:%s" % (
|
|
29
|
+
prefix,
|
|
30
|
+
downstream_sequence,
|
|
31
|
+
kind,
|
|
32
|
+
status_text,
|
|
33
|
+
encoded,
|
|
34
|
+
)
|
|
35
|
+
downstream_sequence += 1
|
|
36
|
+
print(line, flush=True)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def disable_echo():
|
|
40
|
+
global terminal_attributes
|
|
41
|
+
if not os.isatty(sys.stdin.fileno()):
|
|
42
|
+
return
|
|
43
|
+
terminal_attributes = termios.tcgetattr(sys.stdin.fileno())
|
|
44
|
+
updated = list(terminal_attributes)
|
|
45
|
+
updated[3] &= ~termios.ECHO
|
|
46
|
+
termios.tcsetattr(sys.stdin.fileno(), termios.TCSANOW, updated)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def restore_terminal():
|
|
50
|
+
if terminal_attributes is not None:
|
|
51
|
+
termios.tcsetattr(sys.stdin.fileno(), termios.TCSANOW, terminal_attributes)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def decode_upstream(raw_line):
|
|
55
|
+
global upstream_sequence
|
|
56
|
+
line = raw_line.rstrip(b"\r\n")
|
|
57
|
+
marker = prefix_bytes + b":V"
|
|
58
|
+
offset = line.find(marker)
|
|
59
|
+
if offset < 0:
|
|
60
|
+
return None
|
|
61
|
+
fields = line[offset:].split(b":", 6)
|
|
62
|
+
if len(fields) != 7:
|
|
63
|
+
raise ValueError("malformed upstream frame")
|
|
64
|
+
frame_prefix, version, direction, sequence, kind, status, payload = fields
|
|
65
|
+
if frame_prefix != prefix_bytes or version != b"V1" or direction != b"U":
|
|
66
|
+
raise ValueError("invalid upstream frame header")
|
|
67
|
+
if int(sequence) != upstream_sequence:
|
|
68
|
+
raise ValueError("unexpected upstream frame sequence")
|
|
69
|
+
upstream_sequence += 1
|
|
70
|
+
if status != b"-":
|
|
71
|
+
raise ValueError("upstream frame must not contain status")
|
|
72
|
+
kind_text = kind.decode("ascii")
|
|
73
|
+
if kind_text not in {"DATA", "EOF"}:
|
|
74
|
+
raise ValueError("invalid upstream frame kind")
|
|
75
|
+
decoded = base64.b64decode(payload, validate=True)
|
|
76
|
+
if kind_text == "EOF" and decoded:
|
|
77
|
+
raise ValueError("upstream EOF must not contain payload")
|
|
78
|
+
return kind_text, decoded
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def receive():
|
|
82
|
+
try:
|
|
83
|
+
while True:
|
|
84
|
+
data = connection.recv(16 * 1024)
|
|
85
|
+
if not data:
|
|
86
|
+
emit("EOF")
|
|
87
|
+
return
|
|
88
|
+
emit("DATA", data)
|
|
89
|
+
except Exception as exc:
|
|
90
|
+
receiver_error.append(exc)
|
|
91
|
+
emit("ERROR", str(exc).encode("utf-8", errors="replace"))
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
status = 0
|
|
95
|
+
try:
|
|
96
|
+
disable_echo()
|
|
97
|
+
connection = socket.create_connection((host, port), timeout=15)
|
|
98
|
+
connection.settimeout(None)
|
|
99
|
+
emit("READY")
|
|
100
|
+
receiver = threading.Thread(target=receive, daemon=True)
|
|
101
|
+
receiver.start()
|
|
102
|
+
saw_upstream_eof = False
|
|
103
|
+
for raw_line in sys.stdin.buffer:
|
|
104
|
+
frame = decode_upstream(raw_line)
|
|
105
|
+
if frame is None:
|
|
106
|
+
continue
|
|
107
|
+
kind, payload = frame
|
|
108
|
+
if kind == "DATA":
|
|
109
|
+
connection.sendall(payload)
|
|
110
|
+
else:
|
|
111
|
+
connection.shutdown(socket.SHUT_WR)
|
|
112
|
+
saw_upstream_eof = True
|
|
113
|
+
break
|
|
114
|
+
if not saw_upstream_eof:
|
|
115
|
+
raise EOFError("upstream tunnel closed without EOF frame")
|
|
116
|
+
receiver.join()
|
|
117
|
+
if receiver_error:
|
|
118
|
+
status = 1
|
|
119
|
+
except Exception as exc:
|
|
120
|
+
status = 1
|
|
121
|
+
emit("ERROR", str(exc).encode("utf-8", errors="replace"))
|
|
122
|
+
finally:
|
|
123
|
+
if connection is not None:
|
|
124
|
+
try:
|
|
125
|
+
connection.close()
|
|
126
|
+
except Exception:
|
|
127
|
+
status = 1
|
|
128
|
+
try:
|
|
129
|
+
restore_terminal()
|
|
130
|
+
except Exception as exc:
|
|
131
|
+
status = 1
|
|
132
|
+
emit("ERROR", str(exc).encode("utf-8", errors="replace"))
|
|
133
|
+
emit("CLOSED", status=status)
|
|
134
|
+
|
|
135
|
+
raise SystemExit(status)
|
|
136
|
+
"""
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def build_remote_tunnel_command(dest_host: str, dest_port: int, prefix: str) -> bytes:
|
|
140
|
+
command = (
|
|
141
|
+
f"python3 -u -c {shlex.quote(REMOTE_TUNNEL_AGENT)} {shlex.quote(dest_host)} {dest_port} {shlex.quote(prefix)}\n"
|
|
142
|
+
)
|
|
143
|
+
return command.encode("utf-8")
|
|
@@ -9,6 +9,8 @@ import tempfile
|
|
|
9
9
|
from dataclasses import dataclass
|
|
10
10
|
from pathlib import Path
|
|
11
11
|
|
|
12
|
+
from .secure_files import ensure_private_directory
|
|
13
|
+
|
|
12
14
|
|
|
13
15
|
@dataclass(frozen=True, slots=True)
|
|
14
16
|
class RuntimePaths:
|
|
@@ -18,8 +20,7 @@ class RuntimePaths:
|
|
|
18
20
|
log: Path
|
|
19
21
|
|
|
20
22
|
def ensure_directory(self) -> None:
|
|
21
|
-
self.directory
|
|
22
|
-
self.directory.chmod(0o700)
|
|
23
|
+
ensure_private_directory(self.directory)
|
|
23
24
|
|
|
24
25
|
|
|
25
26
|
def _default_runtime_dir() -> Path:
|
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
import os
|
|
4
|
-
from contextlib import suppress
|
|
5
4
|
from pathlib import Path
|
|
6
5
|
from typing import Any
|
|
7
6
|
|
|
8
7
|
from cryptography.fernet import Fernet, InvalidToken
|
|
9
8
|
|
|
9
|
+
from .secure_files import create_private_file, validate_private_file
|
|
10
|
+
|
|
10
11
|
DEFAULT_KEY_FILE = Path("~/.hostbridge/key").expanduser()
|
|
11
12
|
LEGACY_KEY_FILE = Path("~/.server_control_mcp/key").expanduser()
|
|
12
13
|
SCHEME = "fernet.v1"
|
|
@@ -24,13 +25,12 @@ def key_file_path() -> Path:
|
|
|
24
25
|
def _load_or_create_key() -> bytes:
|
|
25
26
|
path = key_file_path()
|
|
26
27
|
if path.exists():
|
|
28
|
+
validate_private_file(path)
|
|
27
29
|
return path.read_bytes().strip()
|
|
28
|
-
path.parent.mkdir(parents=True, exist_ok=True)
|
|
29
30
|
key = Fernet.generate_key()
|
|
30
|
-
path
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
return key
|
|
31
|
+
if create_private_file(path, key + b"\n"):
|
|
32
|
+
return key
|
|
33
|
+
return path.read_bytes().strip()
|
|
34
34
|
|
|
35
35
|
|
|
36
36
|
def encrypt_secret(value: str) -> dict[str, str]:
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import errno
|
|
4
|
+
import os
|
|
5
|
+
import stat
|
|
6
|
+
import tempfile
|
|
7
|
+
from contextlib import suppress
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def ensure_private_directory(path: Path) -> None:
|
|
12
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
13
|
+
with suppress(FileExistsError):
|
|
14
|
+
os.mkdir(path, 0o700)
|
|
15
|
+
metadata = path.lstat()
|
|
16
|
+
if stat.S_ISLNK(metadata.st_mode) or not stat.S_ISDIR(metadata.st_mode):
|
|
17
|
+
raise RuntimeError(f"secure directory is not a real directory: {path}")
|
|
18
|
+
if metadata.st_uid != os.getuid():
|
|
19
|
+
raise PermissionError(f"secure directory is not owned by the current user: {path}")
|
|
20
|
+
path.chmod(0o700)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def validate_private_file(path: Path) -> None:
|
|
24
|
+
metadata = path.lstat()
|
|
25
|
+
if stat.S_ISLNK(metadata.st_mode) or not stat.S_ISREG(metadata.st_mode):
|
|
26
|
+
raise RuntimeError(f"private file is not a regular file: {path}")
|
|
27
|
+
if metadata.st_uid != os.getuid():
|
|
28
|
+
raise PermissionError(f"private file is not owned by the current user: {path}")
|
|
29
|
+
path.chmod(0o600)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def read_private_text(path: Path) -> str:
|
|
33
|
+
nofollow = getattr(os, "O_NOFOLLOW", None)
|
|
34
|
+
if nofollow is None:
|
|
35
|
+
raise RuntimeError("secure private file reads require O_NOFOLLOW")
|
|
36
|
+
flags = os.O_RDONLY | nofollow
|
|
37
|
+
if hasattr(os, "O_CLOEXEC"):
|
|
38
|
+
flags |= os.O_CLOEXEC
|
|
39
|
+
try:
|
|
40
|
+
descriptor = os.open(path, flags)
|
|
41
|
+
except OSError as exc:
|
|
42
|
+
if exc.errno == errno.ELOOP:
|
|
43
|
+
raise RuntimeError(f"private file is not a regular file: {path}") from exc
|
|
44
|
+
raise
|
|
45
|
+
try:
|
|
46
|
+
metadata = os.fstat(descriptor)
|
|
47
|
+
if not stat.S_ISREG(metadata.st_mode):
|
|
48
|
+
raise RuntimeError(f"private file is not a regular file: {path}")
|
|
49
|
+
if metadata.st_uid != os.getuid():
|
|
50
|
+
raise PermissionError(f"private file is not owned by the current user: {path}")
|
|
51
|
+
os.fchmod(descriptor, 0o600)
|
|
52
|
+
with os.fdopen(descriptor, "r", encoding="utf-8") as handle:
|
|
53
|
+
descriptor = -1
|
|
54
|
+
return handle.read()
|
|
55
|
+
finally:
|
|
56
|
+
if descriptor >= 0:
|
|
57
|
+
os.close(descriptor)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def create_private_file(path: Path, data: bytes) -> bool:
|
|
61
|
+
ensure_private_directory(path.parent)
|
|
62
|
+
descriptor, name = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent)
|
|
63
|
+
temporary = Path(name)
|
|
64
|
+
try:
|
|
65
|
+
os.fchmod(descriptor, 0o600)
|
|
66
|
+
_write_all(descriptor, data)
|
|
67
|
+
os.fsync(descriptor)
|
|
68
|
+
os.close(descriptor)
|
|
69
|
+
descriptor = -1
|
|
70
|
+
try:
|
|
71
|
+
os.link(temporary, path)
|
|
72
|
+
except FileExistsError:
|
|
73
|
+
validate_private_file(path)
|
|
74
|
+
return False
|
|
75
|
+
validate_private_file(path)
|
|
76
|
+
return True
|
|
77
|
+
finally:
|
|
78
|
+
if descriptor >= 0:
|
|
79
|
+
os.close(descriptor)
|
|
80
|
+
temporary.unlink(missing_ok=True)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def replace_private_file(path: Path, data: bytes) -> None:
|
|
84
|
+
ensure_private_directory(path.parent)
|
|
85
|
+
descriptor, name = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent)
|
|
86
|
+
temporary = Path(name)
|
|
87
|
+
try:
|
|
88
|
+
os.fchmod(descriptor, 0o600)
|
|
89
|
+
_write_all(descriptor, data)
|
|
90
|
+
os.fsync(descriptor)
|
|
91
|
+
os.close(descriptor)
|
|
92
|
+
descriptor = -1
|
|
93
|
+
temporary.replace(path)
|
|
94
|
+
validate_private_file(path)
|
|
95
|
+
finally:
|
|
96
|
+
if descriptor >= 0:
|
|
97
|
+
os.close(descriptor)
|
|
98
|
+
temporary.unlink(missing_ok=True)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def open_private_append(path: Path):
|
|
102
|
+
ensure_private_directory(path.parent)
|
|
103
|
+
flags = os.O_WRONLY | os.O_APPEND | os.O_CREAT
|
|
104
|
+
if hasattr(os, "O_NOFOLLOW"):
|
|
105
|
+
flags |= os.O_NOFOLLOW
|
|
106
|
+
descriptor = os.open(path, flags, 0o600)
|
|
107
|
+
metadata = os.fstat(descriptor)
|
|
108
|
+
if not stat.S_ISREG(metadata.st_mode) or metadata.st_uid != os.getuid():
|
|
109
|
+
os.close(descriptor)
|
|
110
|
+
raise PermissionError(f"private log is not owned by the current user: {path}")
|
|
111
|
+
os.fchmod(descriptor, 0o600)
|
|
112
|
+
return os.fdopen(descriptor, "a", encoding="utf-8")
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _write_all(descriptor: int, data: bytes) -> None:
|
|
116
|
+
remaining = memoryview(data)
|
|
117
|
+
while remaining:
|
|
118
|
+
written = os.write(descriptor, remaining)
|
|
119
|
+
if written <= 0:
|
|
120
|
+
raise OSError("private file write made no progress")
|
|
121
|
+
remaining = remaining[written:]
|