@neoline/hostbridge 2.0.4 → 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 +11 -9
- package/docs/ARCHITECTURE.md +62 -0
- package/package.json +2 -1
- package/pyproject.toml +1 -1
- package/src/server_control_mcp/__init__.py +4 -3
- package/src/server_control_mcp/async_lifecycle.py +41 -0
- package/src/server_control_mcp/cli.py +1 -1
- package/src/server_control_mcp/client.py +302 -287
- package/src/server_control_mcp/config.py +2 -1
- package/src/server_control_mcp/daemon.py +233 -526
- package/src/server_control_mcp/doctor.py +34 -2
- package/src/server_control_mcp/hosts.py +136 -2
- package/src/server_control_mcp/mock_ssh.py +52 -14
- package/src/server_control_mcp/mock_ssh_exec.py +147 -58
- package/src/server_control_mcp/mock_ssh_session.py +3 -2
- package/src/server_control_mcp/mock_ssh_sftp.py +126 -28
- package/src/server_control_mcp/mock_ssh_tunnel.py +141 -444
- 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/remote_agent_bundle.py +56 -0
- package/src/server_control_mcp/remote_mux_agent.py +769 -0
- package/src/server_control_mcp/runtime_services.py +862 -0
- package/src/server_control_mcp/server.py +33 -18
- package/src/server_control_mcp/services.py +2 -666
- 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 +206 -259
- 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 -363
- package/src/server_control_mcp/remote_tunnel_agent.py +0 -143
- package/src/server_control_mcp/transports/pty.py +0 -515
|
@@ -1,530 +1,62 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
-
import
|
|
4
|
-
import binascii
|
|
5
|
-
import hashlib
|
|
6
|
-
import json
|
|
3
|
+
import asyncio
|
|
7
4
|
import os
|
|
5
|
+
import shlex
|
|
6
|
+
import signal
|
|
8
7
|
import socket
|
|
9
8
|
import stat
|
|
10
|
-
import subprocess
|
|
9
|
+
import subprocess # nosec B404
|
|
11
10
|
import sys
|
|
12
|
-
import threading
|
|
13
11
|
import time
|
|
14
12
|
from contextlib import suppress
|
|
15
|
-
from dataclasses import
|
|
13
|
+
from dataclasses import dataclass
|
|
16
14
|
from pathlib import Path
|
|
17
|
-
from socketserver import StreamRequestHandler, ThreadingMixIn, UnixStreamServer
|
|
18
|
-
from typing import Any
|
|
19
15
|
|
|
20
16
|
from . import __version__
|
|
21
17
|
from .client import HostBridgeClient, HostBridgeError
|
|
22
18
|
from .hosts import HostRegistry, configured_hosts_path, load_hosts
|
|
19
|
+
from .mux_daemon import MuxDaemonServer
|
|
20
|
+
from .mux_protocol import MUX_PROTOCOL_VERSION
|
|
21
|
+
from .mux_service import HostBridgeMuxService
|
|
23
22
|
from .policy import build_audit_sink, load_policy
|
|
24
|
-
from .protocol import (
|
|
25
|
-
PROTOCOL_VERSION,
|
|
26
|
-
BinaryFrame,
|
|
27
|
-
ErrorInfo,
|
|
28
|
-
FrameFlags,
|
|
29
|
-
FrameSequenceValidator,
|
|
30
|
-
ProtocolError,
|
|
31
|
-
Request,
|
|
32
|
-
Response,
|
|
33
|
-
encode_frame,
|
|
34
|
-
read_frame,
|
|
35
|
-
)
|
|
36
23
|
from .runtime import runtime_paths
|
|
24
|
+
from .runtime_services import AsyncHostBridgeServices
|
|
37
25
|
from .secure_files import open_private_append, replace_private_file
|
|
38
|
-
from .
|
|
39
|
-
from .
|
|
40
|
-
from .transports.ssh import SshConnectionConfig, SshTransport
|
|
26
|
+
from .tunnel_manager import HostTunnelManager
|
|
27
|
+
from .tunnel_providers import tunnel_provider_factory
|
|
41
28
|
|
|
42
|
-
MAX_V1_CONTROL_LINE_BYTES = 4 * 1024 * 1024
|
|
43
29
|
DEFAULT_CONNECT_TIMEOUT = 30
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
class ThreadingUnixRPCServer(ThreadingMixIn, UnixStreamServer):
|
|
49
|
-
daemon_threads = True
|
|
50
|
-
allow_reuse_address = True
|
|
51
|
-
|
|
52
|
-
def __init__(
|
|
53
|
-
self,
|
|
54
|
-
server_address: str,
|
|
55
|
-
request_handler_class: type[StreamRequestHandler],
|
|
56
|
-
bind_and_activate: bool = True,
|
|
57
|
-
*,
|
|
58
|
-
max_connections: int = DEFAULT_MAX_DAEMON_CONNECTIONS,
|
|
59
|
-
connection_idle_timeout: float = DEFAULT_DAEMON_CONNECTION_IDLE_TIMEOUT,
|
|
60
|
-
) -> None:
|
|
61
|
-
if not isinstance(max_connections, int) or isinstance(max_connections, bool) or max_connections <= 0:
|
|
62
|
-
raise ValueError("max_connections must be a positive integer")
|
|
63
|
-
if connection_idle_timeout <= 0:
|
|
64
|
-
raise ValueError("connection_idle_timeout must be positive")
|
|
65
|
-
self._connection_slots = threading.BoundedSemaphore(max_connections)
|
|
66
|
-
self._connection_idle_timeout = float(connection_idle_timeout)
|
|
67
|
-
super().__init__(server_address, request_handler_class, bind_and_activate)
|
|
68
|
-
|
|
69
|
-
def get_request(self) -> tuple[socket.socket, Any]:
|
|
70
|
-
request, client_address = super().get_request()
|
|
71
|
-
request.settimeout(self._connection_idle_timeout)
|
|
72
|
-
return request, client_address
|
|
73
|
-
|
|
74
|
-
def process_request(
|
|
75
|
-
self,
|
|
76
|
-
request: socket.socket | tuple[bytes, socket.socket],
|
|
77
|
-
client_address: Any,
|
|
78
|
-
) -> None:
|
|
79
|
-
self._connection_slots.acquire()
|
|
80
|
-
try:
|
|
81
|
-
super().process_request(request, client_address)
|
|
82
|
-
except BaseException:
|
|
83
|
-
self._connection_slots.release()
|
|
84
|
-
raise
|
|
85
|
-
|
|
86
|
-
def process_request_thread(
|
|
87
|
-
self,
|
|
88
|
-
request: socket.socket | tuple[bytes, socket.socket],
|
|
89
|
-
client_address: Any,
|
|
90
|
-
) -> None:
|
|
91
|
-
try:
|
|
92
|
-
super().process_request_thread(request, client_address)
|
|
93
|
-
finally:
|
|
94
|
-
self._connection_slots.release()
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
def _required_param(params: dict[str, object], name: str) -> str:
|
|
98
|
-
value = params.get(name)
|
|
99
|
-
if not isinstance(value, str) or not value.strip():
|
|
100
|
-
raise ServiceError("invalid_request", f"{name} must be a non-empty string")
|
|
101
|
-
return value
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
def _optional_owner(params: dict[str, object]) -> str | None:
|
|
105
|
-
owner = params.get("owner")
|
|
106
|
-
if owner is not None and not isinstance(owner, str):
|
|
107
|
-
raise ServiceError("invalid_request", "owner must be a string or null")
|
|
108
|
-
return owner
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
def _decode_inline_stdin(value: object) -> list[bytes] | None:
|
|
112
|
-
if value is None:
|
|
113
|
-
return None
|
|
114
|
-
if not isinstance(value, dict) or value.get("encoding") != "base64" or not isinstance(value.get("data"), str):
|
|
115
|
-
raise ServiceError("invalid_request", "stdin must contain base64 data")
|
|
116
|
-
try:
|
|
117
|
-
return [base64.b64decode(value["data"], validate=True)]
|
|
118
|
-
except (binascii.Error, ValueError) as exc:
|
|
119
|
-
raise ServiceError("invalid_request", "stdin contains invalid base64") from exc
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
def _encoded_bytes(value: bytes) -> dict[str, str]:
|
|
123
|
-
return {"encoding": "base64", "data": base64.b64encode(value).decode("ascii")}
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
class HostBridgeV1Dispatcher:
|
|
127
|
-
def __init__(self, services: HostBridgeServices) -> None:
|
|
128
|
-
self.services = services
|
|
129
|
-
self.handlers = {
|
|
130
|
-
"system.hello": self.system_hello,
|
|
131
|
-
"system.health": self.system_health,
|
|
132
|
-
"system.shutdown": self.system_shutdown,
|
|
133
|
-
"host.list": self.host_list,
|
|
134
|
-
"session.open": self.session_open,
|
|
135
|
-
"session.list": self.session_list,
|
|
136
|
-
"session.close": self.session_close,
|
|
137
|
-
"session.close_all": self.session_close_all,
|
|
138
|
-
"exec.start": self.exec_start,
|
|
139
|
-
"task.start": self.task_start,
|
|
140
|
-
"task.status": self.task_status,
|
|
141
|
-
"task.cancel": self.task_cancel,
|
|
142
|
-
"file.write_text": self.file_write_text,
|
|
143
|
-
"file.read_text": self.file_read_text,
|
|
144
|
-
"shell.write": self.shell_write,
|
|
145
|
-
"shell.read": self.shell_read,
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
def dispatch(self, request: Request) -> Response:
|
|
149
|
-
handler = self.handlers.get(request.method)
|
|
150
|
-
if handler is None:
|
|
151
|
-
return Response.failure(
|
|
152
|
-
request.request_id,
|
|
153
|
-
ErrorInfo("not_found", f"unknown method {request.method}"),
|
|
154
|
-
)
|
|
155
|
-
try:
|
|
156
|
-
return Response.success(request.request_id, handler(request.params))
|
|
157
|
-
except ServiceError as exc:
|
|
158
|
-
return Response.failure(
|
|
159
|
-
request.request_id,
|
|
160
|
-
ErrorInfo(exc.code, str(exc), retryable=exc.retryable, details=exc.details),
|
|
161
|
-
)
|
|
162
|
-
except (TypeError, ValueError) as exc:
|
|
163
|
-
return Response.failure(request.request_id, ErrorInfo("invalid_request", str(exc)))
|
|
164
|
-
except Exception:
|
|
165
|
-
return Response.failure(
|
|
166
|
-
request.request_id, ErrorInfo("internal", "internal HostBridge error", retryable=True)
|
|
167
|
-
)
|
|
168
|
-
|
|
169
|
-
def system_hello(self, params: dict[str, object]) -> dict[str, object]:
|
|
170
|
-
return {
|
|
171
|
-
"service": "hostbridge",
|
|
172
|
-
"protocol": PROTOCOL_VERSION,
|
|
173
|
-
"version": __version__,
|
|
174
|
-
"capabilities": ["sessions", "exec", "explicit_stdin"],
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
def system_health(self, params: dict[str, object]) -> dict[str, object]:
|
|
178
|
-
return {"status": "ok", "protocol": PROTOCOL_VERSION}
|
|
179
|
-
|
|
180
|
-
def system_shutdown(self, params: dict[str, object]) -> dict[str, object]:
|
|
181
|
-
self.services.stop_idle_reaper()
|
|
182
|
-
return self.services.close_all(force=True)
|
|
183
|
-
|
|
184
|
-
def host_list(self, params: dict[str, object]) -> dict[str, object]:
|
|
185
|
-
return {"hosts": self.services.hosts.describe()}
|
|
186
|
-
|
|
187
|
-
def session_open(self, params: dict[str, object]) -> dict[str, object]:
|
|
188
|
-
connect_timeout = params.get("connect_timeout")
|
|
189
|
-
if connect_timeout is not None and (
|
|
190
|
-
not isinstance(connect_timeout, int | float)
|
|
191
|
-
or isinstance(connect_timeout, bool)
|
|
192
|
-
or not 0 < connect_timeout <= 180
|
|
193
|
-
):
|
|
194
|
-
raise ServiceError("invalid_request", "connect_timeout must be between 0 and 180 seconds")
|
|
195
|
-
session = self.services.open_session(
|
|
196
|
-
_required_param(params, "host_id"),
|
|
197
|
-
owner=_optional_owner(params),
|
|
198
|
-
connect_timeout=float(connect_timeout) if connect_timeout is not None else None,
|
|
199
|
-
)
|
|
200
|
-
return session.describe()
|
|
201
|
-
|
|
202
|
-
def session_list(self, params: dict[str, object]) -> dict[str, object]:
|
|
203
|
-
return {"sessions": self.services.list_sessions()}
|
|
204
|
-
|
|
205
|
-
def session_close(self, params: dict[str, object]) -> dict[str, object]:
|
|
206
|
-
if "force" in params:
|
|
207
|
-
raise ServiceError("invalid_request", "force close is not available over the public protocol")
|
|
208
|
-
session_id = _required_param(params, "session_id")
|
|
209
|
-
self.services.close_session(session_id, owner=_optional_owner(params))
|
|
210
|
-
return {"session_id": session_id, "closed": True}
|
|
211
|
-
|
|
212
|
-
def session_close_all(self, params: dict[str, object]) -> dict[str, object]:
|
|
213
|
-
return self.services.close_all(owner=_optional_owner(params))
|
|
214
|
-
|
|
215
|
-
def exec_start(self, params: dict[str, object]) -> dict[str, object]:
|
|
216
|
-
timeout = params.get("timeout", 60)
|
|
217
|
-
output_limit = params.get("output_limit", 1_000_000)
|
|
218
|
-
if not isinstance(timeout, int | float) or isinstance(timeout, bool) or timeout <= 0:
|
|
219
|
-
raise ServiceError("invalid_request", "timeout must be positive")
|
|
220
|
-
if not isinstance(output_limit, int) or isinstance(output_limit, bool) or output_limit <= 0:
|
|
221
|
-
raise ServiceError("invalid_request", "output_limit must be a positive integer")
|
|
222
|
-
result = self.services.exec(
|
|
223
|
-
_required_param(params, "session_id"),
|
|
224
|
-
_required_param(params, "command"),
|
|
225
|
-
stdin=_decode_inline_stdin(params.get("stdin")),
|
|
226
|
-
timeout=float(timeout),
|
|
227
|
-
output_limit=output_limit,
|
|
228
|
-
owner=_optional_owner(params),
|
|
229
|
-
)
|
|
230
|
-
return {
|
|
231
|
-
"session_id": result.session_id,
|
|
232
|
-
"exit_code": result.exit_code,
|
|
233
|
-
"stdout": _encoded_bytes(result.stdout),
|
|
234
|
-
"stderr": _encoded_bytes(result.stderr),
|
|
235
|
-
"timed_out": result.timed_out,
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
def task_start(self, params: dict[str, object]) -> dict[str, object]:
|
|
239
|
-
task = self.services.start_task(
|
|
240
|
-
_required_param(params, "session_id"),
|
|
241
|
-
_required_param(params, "command"),
|
|
242
|
-
owner=_optional_owner(params),
|
|
243
|
-
)
|
|
244
|
-
return asdict(task)
|
|
245
|
-
|
|
246
|
-
def task_status(self, params: dict[str, object]) -> dict[str, object]:
|
|
247
|
-
tail_lines = params.get("tail_lines", 80)
|
|
248
|
-
if not isinstance(tail_lines, int) or isinstance(tail_lines, bool):
|
|
249
|
-
raise ServiceError("invalid_request", "tail_lines must be an integer")
|
|
250
|
-
return self.services.task_status(
|
|
251
|
-
_required_param(params, "session_id"),
|
|
252
|
-
_required_param(params, "task_id"),
|
|
253
|
-
owner=_optional_owner(params),
|
|
254
|
-
tail_lines=tail_lines,
|
|
255
|
-
)
|
|
256
|
-
|
|
257
|
-
def task_cancel(self, params: dict[str, object]) -> dict[str, object]:
|
|
258
|
-
return self.services.cancel_task(
|
|
259
|
-
_required_param(params, "session_id"),
|
|
260
|
-
_required_param(params, "task_id"),
|
|
261
|
-
owner=_optional_owner(params),
|
|
262
|
-
)
|
|
263
|
-
|
|
264
|
-
def file_write_text(self, params: dict[str, object]) -> dict[str, object]:
|
|
265
|
-
content = params.get("content")
|
|
266
|
-
mode = params.get("mode", 0o644)
|
|
267
|
-
max_bytes = params.get("max_bytes", 4 * 1024 * 1024)
|
|
268
|
-
if not isinstance(content, str):
|
|
269
|
-
raise ServiceError("invalid_request", "content must be a string")
|
|
270
|
-
if not isinstance(mode, int) or isinstance(mode, bool):
|
|
271
|
-
raise ServiceError("invalid_request", "mode must be an integer")
|
|
272
|
-
if not isinstance(max_bytes, int) or isinstance(max_bytes, bool) or max_bytes <= 0:
|
|
273
|
-
raise ServiceError("invalid_request", "max_bytes must be a positive integer")
|
|
274
|
-
result = self.services.write_text(
|
|
275
|
-
_required_param(params, "session_id"),
|
|
276
|
-
_required_param(params, "remote_path"),
|
|
277
|
-
content,
|
|
278
|
-
mode=mode,
|
|
279
|
-
max_bytes=max_bytes,
|
|
280
|
-
owner=_optional_owner(params),
|
|
281
|
-
)
|
|
282
|
-
return {"bytes_transferred": result.bytes_transferred, "sha256": result.sha256}
|
|
283
|
-
|
|
284
|
-
def file_read_text(self, params: dict[str, object]) -> dict[str, object]:
|
|
285
|
-
max_bytes = params.get("max_bytes", 4 * 1024 * 1024)
|
|
286
|
-
if not isinstance(max_bytes, int) or isinstance(max_bytes, bool) or max_bytes <= 0:
|
|
287
|
-
raise ServiceError("invalid_request", "max_bytes must be a positive integer")
|
|
288
|
-
content = self.services.read_text(
|
|
289
|
-
_required_param(params, "session_id"),
|
|
290
|
-
_required_param(params, "remote_path"),
|
|
291
|
-
max_bytes=max_bytes,
|
|
292
|
-
owner=_optional_owner(params),
|
|
293
|
-
)
|
|
294
|
-
return {"content": content, "bytes_transferred": len(content.encode("utf-8"))}
|
|
295
|
-
|
|
296
|
-
def shell_write(self, params: dict[str, object]) -> dict[str, object]:
|
|
297
|
-
chunks = _decode_inline_stdin(params.get("data"))
|
|
298
|
-
if chunks is None:
|
|
299
|
-
raise ServiceError("invalid_request", "data must contain base64 bytes")
|
|
300
|
-
written = self.services.shell_write(
|
|
301
|
-
_required_param(params, "session_id"),
|
|
302
|
-
b"".join(chunks),
|
|
303
|
-
owner=_optional_owner(params),
|
|
304
|
-
)
|
|
305
|
-
return {"written": written}
|
|
306
|
-
|
|
307
|
-
def shell_read(self, params: dict[str, object]) -> dict[str, object]:
|
|
308
|
-
timeout = params.get("timeout", 0.2)
|
|
309
|
-
max_bytes = params.get("max_bytes", 4096)
|
|
310
|
-
if not isinstance(timeout, int | float) or isinstance(timeout, bool) or timeout < 0:
|
|
311
|
-
raise ServiceError("invalid_request", "timeout must be non-negative")
|
|
312
|
-
if not isinstance(max_bytes, int) or isinstance(max_bytes, bool) or max_bytes <= 0:
|
|
313
|
-
raise ServiceError("invalid_request", "max_bytes must be a positive integer")
|
|
314
|
-
result = self.services.shell_read(
|
|
315
|
-
_required_param(params, "session_id"),
|
|
316
|
-
timeout=float(timeout),
|
|
317
|
-
max_bytes=max_bytes,
|
|
318
|
-
owner=_optional_owner(params),
|
|
319
|
-
)
|
|
320
|
-
return {"data": _encoded_bytes(result.data), "alive": result.alive}
|
|
321
|
-
|
|
30
|
+
_DAEMON_STOP_GRACE_SECONDS = 5.0
|
|
31
|
+
_DAEMON_TERM_SECONDS = 2.0
|
|
32
|
+
_DAEMON_KILL_SECONDS = 2.0
|
|
33
|
+
_PROCESS_POLL_INTERVAL_SECONDS = 0.05
|
|
322
34
|
|
|
323
|
-
class HostBridgeV1Handler(StreamRequestHandler):
|
|
324
|
-
dispatcher: HostBridgeV1Dispatcher
|
|
325
35
|
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
return
|
|
331
|
-
|
|
332
|
-
def _handle_requests(self) -> None:
|
|
333
|
-
while raw_line := self.rfile.readline(MAX_V1_CONTROL_LINE_BYTES + 1):
|
|
334
|
-
request_id = "invalid"
|
|
335
|
-
try:
|
|
336
|
-
if len(raw_line) > MAX_V1_CONTROL_LINE_BYTES:
|
|
337
|
-
raise ProtocolError("request exceeds the control limit")
|
|
338
|
-
request = Request.from_json(raw_line.decode("utf-8"))
|
|
339
|
-
request_id = request.request_id
|
|
340
|
-
if request.method == "transfer.upload":
|
|
341
|
-
response = self._handle_upload(request)
|
|
342
|
-
elif request.method == "exec.stream":
|
|
343
|
-
response = self._handle_exec_stream(request)
|
|
344
|
-
elif request.method == "transfer.download":
|
|
345
|
-
self._handle_download(request)
|
|
346
|
-
continue
|
|
347
|
-
else:
|
|
348
|
-
response = self.dispatcher.dispatch(request)
|
|
349
|
-
except ServiceError as exc:
|
|
350
|
-
response = Response.failure(
|
|
351
|
-
request_id,
|
|
352
|
-
ErrorInfo(exc.code, str(exc), retryable=exc.retryable, details=exc.details),
|
|
353
|
-
)
|
|
354
|
-
except (ProtocolError, UnicodeDecodeError) as exc:
|
|
355
|
-
response = Response.failure(request_id, ErrorInfo("invalid_request", str(exc)))
|
|
356
|
-
self.wfile.write(response.to_json().encode("utf-8") + b"\n")
|
|
357
|
-
self.wfile.flush()
|
|
358
|
-
if request_id != "invalid" and request.method == "system.shutdown":
|
|
359
|
-
threading.Thread(target=self.server.shutdown, daemon=True).start()
|
|
360
|
-
|
|
361
|
-
def _handle_upload(self, request: Request) -> Response:
|
|
362
|
-
params = request.params
|
|
363
|
-
stream_id = _required_param(params, "stream_id")
|
|
364
|
-
expected_size = params.get("expected_size")
|
|
365
|
-
mode = params.get("mode", 0o644)
|
|
366
|
-
if not isinstance(expected_size, int) or isinstance(expected_size, bool) or expected_size < 0:
|
|
367
|
-
raise ServiceError("invalid_request", "expected_size must be a non-negative integer")
|
|
368
|
-
if not isinstance(mode, int) or isinstance(mode, bool):
|
|
369
|
-
raise ServiceError("invalid_request", "mode must be an integer")
|
|
370
|
-
validator = FrameSequenceValidator()
|
|
371
|
-
|
|
372
|
-
def chunks():
|
|
373
|
-
while True:
|
|
374
|
-
frame = read_frame(self.rfile)
|
|
375
|
-
validator.accept(frame)
|
|
376
|
-
if frame.stream_id != stream_id:
|
|
377
|
-
raise ServiceError("invalid_request", "upload frame stream_id does not match")
|
|
378
|
-
if frame.flags & FrameFlags.CANCEL:
|
|
379
|
-
raise ServiceError("cancelled", "upload cancelled")
|
|
380
|
-
if frame.flags & FrameFlags.DATA:
|
|
381
|
-
yield frame.payload
|
|
382
|
-
if frame.flags & FrameFlags.EOF:
|
|
383
|
-
return
|
|
384
|
-
|
|
385
|
-
result = self.dispatcher.services.upload(
|
|
386
|
-
_required_param(params, "session_id"),
|
|
387
|
-
chunks(),
|
|
388
|
-
_required_param(params, "remote_path"),
|
|
389
|
-
mode=mode,
|
|
390
|
-
expected_size=expected_size,
|
|
391
|
-
expected_sha256=None,
|
|
392
|
-
owner=_optional_owner(params),
|
|
393
|
-
)
|
|
394
|
-
return Response.success(
|
|
395
|
-
request.request_id,
|
|
396
|
-
{"bytes_transferred": result.bytes_transferred, "sha256": result.sha256},
|
|
397
|
-
)
|
|
36
|
+
@dataclass(frozen=True, slots=True)
|
|
37
|
+
class DaemonRuntime:
|
|
38
|
+
services: AsyncHostBridgeServices
|
|
39
|
+
tunnel_manager: HostTunnelManager
|
|
398
40
|
|
|
399
|
-
def _handle_exec_stream(self, request: Request) -> Response:
|
|
400
|
-
params = request.params
|
|
401
|
-
stream_id = _required_param(params, "stream_id")
|
|
402
|
-
timeout = params.get("timeout", 60)
|
|
403
|
-
output_limit = params.get("output_limit", 1_000_000)
|
|
404
|
-
if not isinstance(timeout, int | float) or isinstance(timeout, bool) or timeout <= 0:
|
|
405
|
-
raise ServiceError("invalid_request", "timeout must be positive")
|
|
406
|
-
if not isinstance(output_limit, int) or isinstance(output_limit, bool) or output_limit <= 0:
|
|
407
|
-
raise ServiceError("invalid_request", "output_limit must be a positive integer")
|
|
408
|
-
validator = FrameSequenceValidator()
|
|
409
|
-
|
|
410
|
-
def chunks():
|
|
411
|
-
while True:
|
|
412
|
-
frame = read_frame(self.rfile)
|
|
413
|
-
validator.accept(frame)
|
|
414
|
-
if frame.stream_id != stream_id:
|
|
415
|
-
raise ServiceError("invalid_request", "exec frame stream_id does not match")
|
|
416
|
-
if frame.flags & FrameFlags.CANCEL:
|
|
417
|
-
raise ServiceError("cancelled", "exec stdin cancelled")
|
|
418
|
-
if frame.flags & FrameFlags.DATA:
|
|
419
|
-
yield frame.payload
|
|
420
|
-
if frame.flags & FrameFlags.EOF:
|
|
421
|
-
return
|
|
422
|
-
|
|
423
|
-
result = self.dispatcher.services.exec(
|
|
424
|
-
_required_param(params, "session_id"),
|
|
425
|
-
_required_param(params, "command"),
|
|
426
|
-
stdin=chunks(),
|
|
427
|
-
timeout=float(timeout),
|
|
428
|
-
output_limit=output_limit,
|
|
429
|
-
owner=_optional_owner(params),
|
|
430
|
-
)
|
|
431
|
-
return Response.success(
|
|
432
|
-
request.request_id,
|
|
433
|
-
{
|
|
434
|
-
"session_id": result.session_id,
|
|
435
|
-
"exit_code": result.exit_code,
|
|
436
|
-
"stdout": _encoded_bytes(result.stdout),
|
|
437
|
-
"stderr": _encoded_bytes(result.stderr),
|
|
438
|
-
"timed_out": result.timed_out,
|
|
439
|
-
},
|
|
440
|
-
)
|
|
441
41
|
|
|
442
|
-
|
|
443
|
-
params = request.params
|
|
444
|
-
stream_id = _required_param(params, "stream_id")
|
|
445
|
-
chunk_size = params.get("chunk_size", 256 * 1024)
|
|
446
|
-
if not isinstance(chunk_size, int) or isinstance(chunk_size, bool) or not 4096 <= chunk_size <= 1024 * 1024:
|
|
447
|
-
raise ServiceError("invalid_request", "chunk_size must be between 4096 and 1048576 bytes")
|
|
448
|
-
response = Response.success(request.request_id, {"stream_id": stream_id})
|
|
449
|
-
self.wfile.write(response.to_json().encode("utf-8") + b"\n")
|
|
450
|
-
self.wfile.flush()
|
|
451
|
-
digest = hashlib.sha256()
|
|
452
|
-
transferred = 0
|
|
453
|
-
sequence = 0
|
|
454
|
-
try:
|
|
455
|
-
chunks = self.dispatcher.services.download(
|
|
456
|
-
_required_param(params, "session_id"),
|
|
457
|
-
_required_param(params, "remote_path"),
|
|
458
|
-
chunk_size=chunk_size,
|
|
459
|
-
owner=_optional_owner(params),
|
|
460
|
-
)
|
|
461
|
-
for chunk in chunks:
|
|
462
|
-
digest.update(chunk)
|
|
463
|
-
transferred += len(chunk)
|
|
464
|
-
self.wfile.write(encode_frame(BinaryFrame(stream_id, sequence, FrameFlags.DATA, chunk)))
|
|
465
|
-
self.wfile.flush()
|
|
466
|
-
sequence += 1
|
|
467
|
-
metadata = json.dumps(
|
|
468
|
-
{"bytes_transferred": transferred, "sha256": digest.hexdigest()},
|
|
469
|
-
separators=(",", ":"),
|
|
470
|
-
).encode("utf-8")
|
|
471
|
-
self.wfile.write(encode_frame(BinaryFrame(stream_id, sequence, FrameFlags.EOF, metadata)))
|
|
472
|
-
self.wfile.flush()
|
|
473
|
-
except ServiceError as exc:
|
|
474
|
-
metadata = json.dumps({"code": exc.code, "message": str(exc)}, separators=(",", ":")).encode("utf-8")
|
|
475
|
-
self.wfile.write(encode_frame(BinaryFrame(stream_id, sequence, FrameFlags.CANCEL, metadata)))
|
|
476
|
-
self.wfile.flush()
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
def make_v1_handler(dispatcher: HostBridgeV1Dispatcher) -> type[HostBridgeV1Handler]:
|
|
480
|
-
class Handler(HostBridgeV1Handler):
|
|
481
|
-
pass
|
|
482
|
-
|
|
483
|
-
Handler.dispatcher = dispatcher
|
|
484
|
-
return Handler
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
def build_v1_services(
|
|
42
|
+
def build_runtime(
|
|
488
43
|
config_path: Path | str | None = None,
|
|
489
44
|
*,
|
|
490
45
|
connect_timeout: int = DEFAULT_CONNECT_TIMEOUT,
|
|
491
|
-
) ->
|
|
46
|
+
) -> DaemonRuntime:
|
|
492
47
|
resolved_config = configured_hosts_path(config_path)
|
|
493
48
|
registry = HostRegistry(load_hosts(resolved_config))
|
|
494
49
|
policy = load_policy()
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
native_ssh = (
|
|
498
|
-
host.ssh is not None
|
|
499
|
-
and not host.login_steps
|
|
500
|
-
and not host.ssh.extra_args
|
|
501
|
-
and host.transport in {"auto", "ssh"}
|
|
502
|
-
)
|
|
503
|
-
if native_ssh:
|
|
504
|
-
assert host.ssh is not None
|
|
505
|
-
return SshTransport.connect(
|
|
506
|
-
SshConnectionConfig(
|
|
507
|
-
host=host.ssh.host,
|
|
508
|
-
port=host.ssh.port,
|
|
509
|
-
username=host.ssh.username,
|
|
510
|
-
client_keys=([str(host.ssh.identity_file)] if host.ssh.identity_file else ()),
|
|
511
|
-
tunnel=host.ssh.proxy_jump,
|
|
512
|
-
password=host.secrets.get("password"),
|
|
513
|
-
),
|
|
514
|
-
connect_timeout=session_connect_timeout,
|
|
515
|
-
)
|
|
516
|
-
return PtyTransport.connect(host, connect_timeout=session_connect_timeout)
|
|
517
|
-
|
|
518
|
-
services = HostBridgeServices(
|
|
50
|
+
tunnel_manager = HostTunnelManager(registry, tunnel_provider_factory)
|
|
51
|
+
services = AsyncHostBridgeServices(
|
|
519
52
|
registry,
|
|
520
|
-
|
|
53
|
+
tunnel_manager,
|
|
521
54
|
default_connect_timeout=connect_timeout,
|
|
522
55
|
policy=policy,
|
|
56
|
+
idle_timeout_seconds=policy.idle_timeout_seconds,
|
|
523
57
|
audit_sink=build_audit_sink(policy),
|
|
524
58
|
)
|
|
525
|
-
|
|
526
|
-
services.start_idle_reaper(timeout=policy.idle_timeout_seconds)
|
|
527
|
-
return services
|
|
59
|
+
return DaemonRuntime(services, tunnel_manager)
|
|
528
60
|
|
|
529
61
|
|
|
530
62
|
def daemon_dir(path: Path | str | None = None) -> Path:
|
|
@@ -566,43 +98,111 @@ def _socket_accepts_connections(path: Path) -> bool:
|
|
|
566
98
|
return False
|
|
567
99
|
|
|
568
100
|
|
|
569
|
-
def
|
|
101
|
+
async def run_daemon_async(
|
|
570
102
|
*,
|
|
571
103
|
socket_file: Path | str | None = None,
|
|
572
104
|
config_path: Path | str | None = None,
|
|
573
105
|
) -> int:
|
|
574
|
-
|
|
106
|
+
runtime = build_runtime(config_path)
|
|
107
|
+
services = runtime.services
|
|
108
|
+
tunnel_manager = runtime.tunnel_manager
|
|
575
109
|
paths = runtime_paths(socket_file)
|
|
576
110
|
path = paths.socket
|
|
577
|
-
|
|
111
|
+
shutdown = asyncio.Event()
|
|
112
|
+
server: MuxDaemonServer | None = None
|
|
578
113
|
owns_runtime_paths = False
|
|
114
|
+
|
|
115
|
+
async def request_closed(method: str) -> None:
|
|
116
|
+
if method == "system.shutdown":
|
|
117
|
+
shutdown.set()
|
|
118
|
+
|
|
579
119
|
try:
|
|
580
120
|
_prepare_socket_path(path)
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
121
|
+
service = HostBridgeMuxService(services, tunnel_manager=tunnel_manager)
|
|
122
|
+
server = MuxDaemonServer(
|
|
123
|
+
path,
|
|
124
|
+
service.handle_unary_async,
|
|
125
|
+
stream_handler=service.handle_stream,
|
|
126
|
+
request_closed_handler=request_closed,
|
|
585
127
|
)
|
|
586
|
-
server.
|
|
128
|
+
await server.start()
|
|
587
129
|
owns_runtime_paths = True
|
|
588
|
-
server.server_activate()
|
|
589
|
-
path.chmod(0o600)
|
|
590
130
|
replace_private_file(paths.pid, str(os.getpid()).encode("ascii"))
|
|
591
131
|
print(f"hostbridge daemon listening on unix://{path}", flush=True)
|
|
592
|
-
|
|
132
|
+
await shutdown.wait()
|
|
593
133
|
finally:
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
path
|
|
601
|
-
|
|
602
|
-
|
|
134
|
+
primary_error = sys.exception()
|
|
135
|
+
cleanup = asyncio.create_task(
|
|
136
|
+
_cleanup_daemon_resources(
|
|
137
|
+
server,
|
|
138
|
+
services,
|
|
139
|
+
tunnel_manager,
|
|
140
|
+
path=path,
|
|
141
|
+
pid_path=paths.pid,
|
|
142
|
+
owns_runtime_paths=owns_runtime_paths,
|
|
143
|
+
),
|
|
144
|
+
name="hostbridge-daemon-cleanup",
|
|
145
|
+
)
|
|
146
|
+
cancellation: asyncio.CancelledError | None = None
|
|
147
|
+
while not cleanup.done():
|
|
148
|
+
try:
|
|
149
|
+
await asyncio.shield(cleanup)
|
|
150
|
+
except asyncio.CancelledError as exc:
|
|
151
|
+
cancellation = exc
|
|
152
|
+
cleanup_errors = cleanup.result()
|
|
153
|
+
if cancellation is not None:
|
|
154
|
+
for error in cleanup_errors:
|
|
155
|
+
cancellation.add_note(f"shutdown cleanup failed: {error!r}")
|
|
156
|
+
if primary_error is None:
|
|
157
|
+
raise cancellation
|
|
158
|
+
primary_error.add_note(f"shutdown was cancelled during cleanup: {cancellation!r}")
|
|
159
|
+
if cleanup_errors:
|
|
160
|
+
if primary_error is None:
|
|
161
|
+
raise cleanup_errors[0]
|
|
162
|
+
for error in cleanup_errors:
|
|
163
|
+
primary_error.add_note(f"shutdown cleanup failed: {error!r}")
|
|
603
164
|
return 0
|
|
604
165
|
|
|
605
166
|
|
|
167
|
+
async def _cleanup_daemon_resources(
|
|
168
|
+
server: MuxDaemonServer | None,
|
|
169
|
+
services: AsyncHostBridgeServices,
|
|
170
|
+
tunnel_manager: HostTunnelManager,
|
|
171
|
+
*,
|
|
172
|
+
path: Path,
|
|
173
|
+
pid_path: Path,
|
|
174
|
+
owns_runtime_paths: bool,
|
|
175
|
+
) -> list[BaseException]:
|
|
176
|
+
cleanup_errors: list[BaseException] = []
|
|
177
|
+
if server is not None:
|
|
178
|
+
try:
|
|
179
|
+
await server.close()
|
|
180
|
+
except BaseException as exc:
|
|
181
|
+
cleanup_errors.append(exc)
|
|
182
|
+
try:
|
|
183
|
+
await services.close_all(force=True)
|
|
184
|
+
except BaseException as exc:
|
|
185
|
+
cleanup_errors.append(exc)
|
|
186
|
+
try:
|
|
187
|
+
await tunnel_manager.close()
|
|
188
|
+
except BaseException as exc:
|
|
189
|
+
cleanup_errors.append(exc)
|
|
190
|
+
if owns_runtime_paths:
|
|
191
|
+
with suppress(FileNotFoundError):
|
|
192
|
+
path.unlink()
|
|
193
|
+
with suppress(FileNotFoundError):
|
|
194
|
+
pid_path.unlink()
|
|
195
|
+
return cleanup_errors
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def run_daemon(
|
|
199
|
+
*,
|
|
200
|
+
socket_file: Path | str | None = None,
|
|
201
|
+
config_path: Path | str | None = None,
|
|
202
|
+
) -> int:
|
|
203
|
+
return asyncio.run(run_daemon_async(socket_file=socket_file, config_path=config_path))
|
|
204
|
+
|
|
205
|
+
|
|
606
206
|
def daemon_endpoint() -> str:
|
|
607
207
|
return f"unix://{socket_path()}"
|
|
608
208
|
|
|
@@ -613,7 +213,8 @@ def is_running(*, socket_file: Path | str | None = None) -> bool:
|
|
|
613
213
|
|
|
614
214
|
def _probe_daemon(path: Path) -> dict[str, object] | None:
|
|
615
215
|
try:
|
|
616
|
-
|
|
216
|
+
with HostBridgeClient(path, default_timeout=1.0) as client:
|
|
217
|
+
return client.request("system.hello", {})
|
|
617
218
|
except HostBridgeError:
|
|
618
219
|
return None
|
|
619
220
|
|
|
@@ -622,7 +223,7 @@ def _daemon_is_compatible(hello: dict[str, object] | None) -> bool:
|
|
|
622
223
|
return bool(
|
|
623
224
|
hello is not None
|
|
624
225
|
and hello.get("service") == "hostbridge"
|
|
625
|
-
and hello.get("protocol") ==
|
|
226
|
+
and hello.get("protocol") == MUX_PROTOCOL_VERSION
|
|
626
227
|
and hello.get("version") == __version__
|
|
627
228
|
)
|
|
628
229
|
|
|
@@ -641,16 +242,23 @@ def start_background(
|
|
|
641
242
|
print(f"hostbridge daemon already running at unix://{path}")
|
|
642
243
|
return 0
|
|
643
244
|
if hello is not None and hello.get("service") == "hostbridge":
|
|
245
|
+
daemon_pid = _read_daemon_pid(paths.pid)
|
|
246
|
+
owned_daemon = daemon_pid is not None and _process_matches_daemon(daemon_pid, path)
|
|
644
247
|
try:
|
|
645
|
-
HostBridgeClient(path, default_timeout=2.0)
|
|
248
|
+
with HostBridgeClient(path, default_timeout=2.0) as client:
|
|
249
|
+
client.request("system.shutdown", {})
|
|
646
250
|
except HostBridgeError:
|
|
647
251
|
if not quiet:
|
|
648
252
|
print(f"hostbridge incompatible daemon could not be stopped at unix://{path}", file=sys.stderr)
|
|
649
253
|
return 1
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
254
|
+
if owned_daemon and daemon_pid is not None:
|
|
255
|
+
stopped = _stop_daemon_process(daemon_pid, path)
|
|
256
|
+
else:
|
|
257
|
+
stop_deadline = time.monotonic() + _DAEMON_STOP_GRACE_SECONDS
|
|
258
|
+
while _socket_accepts_connections(path) and time.monotonic() < stop_deadline:
|
|
259
|
+
time.sleep(_PROCESS_POLL_INTERVAL_SECONDS)
|
|
260
|
+
stopped = not _socket_accepts_connections(path)
|
|
261
|
+
if not stopped:
|
|
654
262
|
if not quiet:
|
|
655
263
|
print(f"hostbridge incompatible daemon did not stop at unix://{path}", file=sys.stderr)
|
|
656
264
|
return 1
|
|
@@ -660,7 +268,8 @@ def start_background(
|
|
|
660
268
|
command.extend(["--config", str(config_path)])
|
|
661
269
|
command.extend(["start", "--foreground", "--socket", str(path)])
|
|
662
270
|
with open_private_append(paths.log) as log_handle:
|
|
663
|
-
|
|
271
|
+
# The command is the current HostBridge module argv.
|
|
272
|
+
process = subprocess.Popen( # nosec B603
|
|
664
273
|
command,
|
|
665
274
|
stdin=subprocess.DEVNULL,
|
|
666
275
|
stdout=log_handle,
|
|
@@ -693,6 +302,89 @@ def _read_daemon_pid(path: Path) -> int | None:
|
|
|
693
302
|
return value if value > 0 else None
|
|
694
303
|
|
|
695
304
|
|
|
305
|
+
def _process_is_alive(pid: int) -> bool:
|
|
306
|
+
try:
|
|
307
|
+
os.kill(pid, 0)
|
|
308
|
+
except ProcessLookupError:
|
|
309
|
+
return False
|
|
310
|
+
except PermissionError:
|
|
311
|
+
return True
|
|
312
|
+
return True
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def _process_matches_daemon(pid: int, path: Path) -> bool:
|
|
316
|
+
try:
|
|
317
|
+
process = subprocess.run( # nosec B603 B607
|
|
318
|
+
["/bin/ps", "-p", str(pid), "-o", "uid=", "-o", "command="],
|
|
319
|
+
check=False,
|
|
320
|
+
capture_output=True,
|
|
321
|
+
text=True,
|
|
322
|
+
timeout=1,
|
|
323
|
+
)
|
|
324
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
325
|
+
return False
|
|
326
|
+
if process.returncode != 0:
|
|
327
|
+
return False
|
|
328
|
+
fields = process.stdout.strip().split(maxsplit=1)
|
|
329
|
+
if len(fields) != 2:
|
|
330
|
+
return False
|
|
331
|
+
try:
|
|
332
|
+
process_uid = int(fields[0])
|
|
333
|
+
argv = shlex.split(fields[1])
|
|
334
|
+
except (ValueError, UnicodeError):
|
|
335
|
+
return False
|
|
336
|
+
if process_uid != os.getuid():
|
|
337
|
+
return False
|
|
338
|
+
try:
|
|
339
|
+
module_index = next(
|
|
340
|
+
index for index in range(len(argv) - 2) if argv[index : index + 3] == ["-m", "server_control_mcp", "daemon"]
|
|
341
|
+
)
|
|
342
|
+
socket_index = argv.index("--socket", module_index + 3)
|
|
343
|
+
except (StopIteration, ValueError):
|
|
344
|
+
return False
|
|
345
|
+
expected_socket = os.path.abspath(os.path.expanduser(str(path)))
|
|
346
|
+
actual_socket = os.path.abspath(os.path.expanduser(argv[socket_index + 1])) if socket_index + 1 < len(argv) else ""
|
|
347
|
+
return (
|
|
348
|
+
"start" in argv[module_index + 3 : socket_index]
|
|
349
|
+
and "--foreground" in argv[module_index + 3 : socket_index]
|
|
350
|
+
and actual_socket == expected_socket
|
|
351
|
+
)
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def _wait_for_process_exit(pid: int, timeout: float) -> bool:
|
|
355
|
+
deadline = time.monotonic() + max(0.0, timeout)
|
|
356
|
+
while _process_is_alive(pid):
|
|
357
|
+
remaining = deadline - time.monotonic()
|
|
358
|
+
if remaining <= 0:
|
|
359
|
+
return False
|
|
360
|
+
time.sleep(min(_PROCESS_POLL_INTERVAL_SECONDS, remaining))
|
|
361
|
+
return True
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def _stop_daemon_process(pid: int, path: Path) -> bool:
|
|
365
|
+
if _wait_for_process_exit(pid, _DAEMON_STOP_GRACE_SECONDS):
|
|
366
|
+
return True
|
|
367
|
+
if not _process_matches_daemon(pid, path):
|
|
368
|
+
return False
|
|
369
|
+
try:
|
|
370
|
+
os.kill(pid, signal.SIGTERM)
|
|
371
|
+
except ProcessLookupError:
|
|
372
|
+
return True
|
|
373
|
+
except OSError:
|
|
374
|
+
return False
|
|
375
|
+
if _wait_for_process_exit(pid, _DAEMON_TERM_SECONDS):
|
|
376
|
+
return True
|
|
377
|
+
if not _process_matches_daemon(pid, path):
|
|
378
|
+
return not _process_is_alive(pid)
|
|
379
|
+
try:
|
|
380
|
+
os.kill(pid, signal.SIGKILL)
|
|
381
|
+
except ProcessLookupError:
|
|
382
|
+
return True
|
|
383
|
+
except OSError:
|
|
384
|
+
return False
|
|
385
|
+
return _wait_for_process_exit(pid, _DAEMON_KILL_SECONDS)
|
|
386
|
+
|
|
387
|
+
|
|
696
388
|
def _terminate_background_process(process: subprocess.Popen[bytes]) -> None:
|
|
697
389
|
if process.poll() is not None:
|
|
698
390
|
return
|
|
@@ -705,11 +397,26 @@ def _terminate_background_process(process: subprocess.Popen[bytes]) -> None:
|
|
|
705
397
|
|
|
706
398
|
|
|
707
399
|
def stop_background(*, socket_file: Path | str | None = None) -> int:
|
|
708
|
-
|
|
400
|
+
paths = runtime_paths(socket_file)
|
|
401
|
+
path = paths.socket
|
|
402
|
+
daemon_pid = _read_daemon_pid(paths.pid)
|
|
403
|
+
owned_daemon = daemon_pid is not None and _process_matches_daemon(daemon_pid, path)
|
|
709
404
|
hello = _probe_daemon(path)
|
|
710
405
|
if hello is not None and hello.get("service") == "hostbridge":
|
|
711
|
-
HostBridgeClient(path, default_timeout=2.0)
|
|
406
|
+
with HostBridgeClient(path, default_timeout=2.0) as client:
|
|
407
|
+
client.request("system.shutdown", {})
|
|
408
|
+
elif not owned_daemon:
|
|
409
|
+
print("hostbridge daemon is not running")
|
|
410
|
+
return 0
|
|
411
|
+
if owned_daemon and daemon_pid is not None:
|
|
412
|
+
stopped = _stop_daemon_process(daemon_pid, path)
|
|
413
|
+
else:
|
|
414
|
+
stop_deadline = time.monotonic() + _DAEMON_STOP_GRACE_SECONDS
|
|
415
|
+
while _socket_accepts_connections(path) and time.monotonic() < stop_deadline:
|
|
416
|
+
time.sleep(_PROCESS_POLL_INTERVAL_SECONDS)
|
|
417
|
+
stopped = not _socket_accepts_connections(path)
|
|
418
|
+
if stopped:
|
|
712
419
|
print("hostbridge daemon stopped")
|
|
713
420
|
return 0
|
|
714
|
-
print("hostbridge daemon
|
|
715
|
-
return
|
|
421
|
+
print(f"hostbridge daemon did not stop at unix://{path}", file=sys.stderr)
|
|
422
|
+
return 1
|