@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
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
"""Async Unix daemon primitives for persistent multiplexed HostBridge RPC."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import os
|
|
7
|
+
from collections.abc import Awaitable, Callable
|
|
8
|
+
from contextlib import suppress
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any
|
|
11
|
+
from uuid import uuid4
|
|
12
|
+
|
|
13
|
+
from .mux_rpc import (
|
|
14
|
+
DEFAULT_CONNECTION_WINDOW_BYTES,
|
|
15
|
+
DEFAULT_STREAM_WINDOW_BYTES,
|
|
16
|
+
MuxRpcRequestClosedHandler,
|
|
17
|
+
MuxRpcServerConnection,
|
|
18
|
+
)
|
|
19
|
+
from .mux_stream import MuxRpcStream
|
|
20
|
+
|
|
21
|
+
AsyncRpcHandler = Callable[[str, dict[str, object]], Awaitable[dict[str, object]]]
|
|
22
|
+
MuxDaemonStreamHandler = Callable[
|
|
23
|
+
[str, dict[str, object], MuxRpcStream, str],
|
|
24
|
+
Awaitable[dict[str, object]],
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class MuxDaemonServer:
|
|
29
|
+
def __init__(
|
|
30
|
+
self,
|
|
31
|
+
socket_path: Path | str,
|
|
32
|
+
handler: AsyncRpcHandler,
|
|
33
|
+
*,
|
|
34
|
+
stream_handler: MuxDaemonStreamHandler | None = None,
|
|
35
|
+
request_closed_handler: MuxRpcRequestClosedHandler | None = None,
|
|
36
|
+
max_handlers_per_connection: int = 256,
|
|
37
|
+
drain_timeout: float = 5.0,
|
|
38
|
+
) -> None:
|
|
39
|
+
_positive(max_handlers_per_connection, "max_handlers_per_connection")
|
|
40
|
+
if drain_timeout <= 0:
|
|
41
|
+
raise ValueError("drain_timeout must be positive")
|
|
42
|
+
self.socket_path = Path(socket_path)
|
|
43
|
+
self._handler = handler
|
|
44
|
+
self._stream_handler = stream_handler
|
|
45
|
+
self._request_closed_handler = request_closed_handler
|
|
46
|
+
self._max_handlers_per_connection = max_handlers_per_connection
|
|
47
|
+
self._drain_timeout = float(drain_timeout)
|
|
48
|
+
self._server: asyncio.AbstractServer | None = None
|
|
49
|
+
self._tasks: set[asyncio.Task[None]] = set()
|
|
50
|
+
self._writers: set[asyncio.StreamWriter] = set()
|
|
51
|
+
self._connections: set[MuxRpcServerConnection] = set()
|
|
52
|
+
self._closing = False
|
|
53
|
+
self._close_task: asyncio.Task[None] | None = None
|
|
54
|
+
self.accepted_connections = 0
|
|
55
|
+
|
|
56
|
+
@property
|
|
57
|
+
def active_connections(self) -> int:
|
|
58
|
+
return len(self._tasks)
|
|
59
|
+
|
|
60
|
+
@property
|
|
61
|
+
def active_handlers(self) -> int:
|
|
62
|
+
return sum(connection.active_requests for connection in self._connections)
|
|
63
|
+
|
|
64
|
+
async def start(self) -> None:
|
|
65
|
+
if self._server is not None:
|
|
66
|
+
raise RuntimeError("multiplexed daemon is already started")
|
|
67
|
+
if self._closing:
|
|
68
|
+
raise RuntimeError("multiplexed daemon is closed")
|
|
69
|
+
self.socket_path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
70
|
+
self._server = await asyncio.start_unix_server(self._accept, path=self.socket_path)
|
|
71
|
+
os.chmod(self.socket_path, 0o600)
|
|
72
|
+
|
|
73
|
+
async def close(self) -> None:
|
|
74
|
+
task = self._close_task
|
|
75
|
+
if task is None:
|
|
76
|
+
self._closing = True
|
|
77
|
+
task = asyncio.create_task(
|
|
78
|
+
self._close_owned_resources(),
|
|
79
|
+
name="hostbridge-mux-daemon-close",
|
|
80
|
+
)
|
|
81
|
+
self._close_task = task
|
|
82
|
+
await asyncio.shield(task)
|
|
83
|
+
|
|
84
|
+
async def _close_owned_resources(self) -> None:
|
|
85
|
+
listener = self._server
|
|
86
|
+
if self._server is not None:
|
|
87
|
+
self._server.close()
|
|
88
|
+
self._server = None
|
|
89
|
+
connections = list(self._connections)
|
|
90
|
+
if connections:
|
|
91
|
+
drain_tasks = [asyncio.create_task(connection.start_draining()) for connection in connections]
|
|
92
|
+
await _wait_tasks_bounded(drain_tasks, self._drain_timeout)
|
|
93
|
+
pending = set(self._tasks)
|
|
94
|
+
if pending:
|
|
95
|
+
_, pending = await asyncio.wait(pending, timeout=self._drain_timeout)
|
|
96
|
+
for writer in list(self._writers):
|
|
97
|
+
writer.close()
|
|
98
|
+
if self._writers:
|
|
99
|
+
waiters = [asyncio.create_task(writer.wait_closed()) for writer in list(self._writers)]
|
|
100
|
+
await _wait_tasks_bounded(waiters, self._drain_timeout)
|
|
101
|
+
for task in pending:
|
|
102
|
+
task.cancel()
|
|
103
|
+
task.add_done_callback(_consume_task_result)
|
|
104
|
+
if listener is not None:
|
|
105
|
+
await listener.wait_closed()
|
|
106
|
+
self.socket_path.unlink(missing_ok=True)
|
|
107
|
+
|
|
108
|
+
def _accept(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
|
|
109
|
+
if self._closing:
|
|
110
|
+
writer.close()
|
|
111
|
+
return
|
|
112
|
+
self.accepted_connections += 1
|
|
113
|
+
client_connection_id = uuid4().hex
|
|
114
|
+
self._writers.add(writer)
|
|
115
|
+
|
|
116
|
+
async def run_stream_handler(
|
|
117
|
+
method: str,
|
|
118
|
+
params: dict[str, object],
|
|
119
|
+
stream: MuxRpcStream,
|
|
120
|
+
) -> dict[str, object]:
|
|
121
|
+
return await self._run_stream_handler(
|
|
122
|
+
method,
|
|
123
|
+
params,
|
|
124
|
+
stream,
|
|
125
|
+
client_connection_id,
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
connection = MuxRpcServerConnection(
|
|
129
|
+
reader,
|
|
130
|
+
writer,
|
|
131
|
+
self._run_handler,
|
|
132
|
+
max_handlers=self._max_handlers_per_connection,
|
|
133
|
+
stream_handler=run_stream_handler if self._stream_handler is not None else None,
|
|
134
|
+
request_closed_handler=self._request_closed_handler,
|
|
135
|
+
connection_window=DEFAULT_CONNECTION_WINDOW_BYTES,
|
|
136
|
+
stream_window=DEFAULT_STREAM_WINDOW_BYTES,
|
|
137
|
+
cleanup_timeout=self._drain_timeout,
|
|
138
|
+
)
|
|
139
|
+
self._connections.add(connection)
|
|
140
|
+
task = asyncio.create_task(
|
|
141
|
+
connection.serve(),
|
|
142
|
+
name=f"hostbridge-mux-daemon-{self.accepted_connections}",
|
|
143
|
+
)
|
|
144
|
+
self._tasks.add(task)
|
|
145
|
+
|
|
146
|
+
def finished(done: asyncio.Task[None]) -> None:
|
|
147
|
+
_consume_task_result(done)
|
|
148
|
+
self._tasks.discard(done)
|
|
149
|
+
self._writers.discard(writer)
|
|
150
|
+
self._connections.discard(connection)
|
|
151
|
+
|
|
152
|
+
task.add_done_callback(finished)
|
|
153
|
+
|
|
154
|
+
async def _run_handler(self, method: str, params: dict[str, object]) -> dict[str, object]:
|
|
155
|
+
return await self._handler(method, params)
|
|
156
|
+
|
|
157
|
+
async def _run_stream_handler(
|
|
158
|
+
self,
|
|
159
|
+
method: str,
|
|
160
|
+
params: dict[str, object],
|
|
161
|
+
stream: MuxRpcStream,
|
|
162
|
+
client_connection_id: str,
|
|
163
|
+
) -> dict[str, object]:
|
|
164
|
+
handler = self._stream_handler
|
|
165
|
+
if handler is None:
|
|
166
|
+
raise RuntimeError("multiplexed daemon stream handler is unavailable")
|
|
167
|
+
return await handler(method, params, stream, client_connection_id)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _positive(value: int, name: str) -> None:
|
|
171
|
+
if not isinstance(value, int) or isinstance(value, bool) or value <= 0:
|
|
172
|
+
raise ValueError(f"{name} must be a positive integer")
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _consume_task_result(task: asyncio.Task[object]) -> None:
|
|
176
|
+
with suppress(BaseException):
|
|
177
|
+
task.result()
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
async def _wait_tasks_bounded(tasks: list[asyncio.Task[Any]], timeout: float) -> None:
|
|
181
|
+
done, stalled = await asyncio.wait(tasks, timeout=timeout)
|
|
182
|
+
for task in done:
|
|
183
|
+
_consume_task_result(task)
|
|
184
|
+
for task in stalled:
|
|
185
|
+
task.cancel()
|
|
186
|
+
task.add_done_callback(_consume_task_result)
|
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
"""Binary framing primitives for the HostBridge multiplexed protocol."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import struct
|
|
7
|
+
from collections.abc import Mapping
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from enum import IntEnum
|
|
10
|
+
|
|
11
|
+
MUX_MAGIC = b"HBM2"
|
|
12
|
+
MUX_PROTOCOL_VERSION = 2
|
|
13
|
+
MAX_FRAME_PAYLOAD_BYTES = 256 * 1024
|
|
14
|
+
MAX_CONTROL_PAYLOAD_BYTES = 64 * 1024
|
|
15
|
+
MAX_SEQUENCE = 0xFFFFFFFF
|
|
16
|
+
MAX_STREAM_ID = 0xFFFFFFFFFFFFFFFF
|
|
17
|
+
|
|
18
|
+
_FRAME_HEADER = struct.Struct("!4sBBHQII")
|
|
19
|
+
FRAME_HEADER_SIZE = _FRAME_HEADER.size
|
|
20
|
+
_WINDOW_UPDATE = struct.Struct("!Q")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class MuxProtocolError(ValueError):
|
|
24
|
+
"""Raised when a multiplexed frame violates the wire contract."""
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class FrameType(IntEnum):
|
|
28
|
+
HELLO = 1
|
|
29
|
+
HELLO_OK = 2
|
|
30
|
+
GOAWAY = 3
|
|
31
|
+
PING = 4
|
|
32
|
+
PONG = 5
|
|
33
|
+
OPEN = 6
|
|
34
|
+
OPEN_OK = 7
|
|
35
|
+
DATA = 8
|
|
36
|
+
WINDOW_UPDATE = 9
|
|
37
|
+
EOF = 10
|
|
38
|
+
CLOSE = 11
|
|
39
|
+
CLOSE_ACK = 12
|
|
40
|
+
RESET = 13
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class StreamParity(IntEnum):
|
|
44
|
+
EVEN = 0
|
|
45
|
+
ODD = 1
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
_CONNECTION_FRAME_TYPES = {
|
|
49
|
+
FrameType.HELLO,
|
|
50
|
+
FrameType.HELLO_OK,
|
|
51
|
+
FrameType.GOAWAY,
|
|
52
|
+
FrameType.PING,
|
|
53
|
+
FrameType.PONG,
|
|
54
|
+
}
|
|
55
|
+
_STREAM_FRAME_TYPES = {
|
|
56
|
+
FrameType.OPEN,
|
|
57
|
+
FrameType.OPEN_OK,
|
|
58
|
+
FrameType.DATA,
|
|
59
|
+
FrameType.EOF,
|
|
60
|
+
FrameType.CLOSE,
|
|
61
|
+
FrameType.CLOSE_ACK,
|
|
62
|
+
FrameType.RESET,
|
|
63
|
+
}
|
|
64
|
+
_JSON_CONTROL_FRAME_TYPES = {
|
|
65
|
+
FrameType.HELLO,
|
|
66
|
+
FrameType.HELLO_OK,
|
|
67
|
+
FrameType.GOAWAY,
|
|
68
|
+
FrameType.OPEN,
|
|
69
|
+
FrameType.OPEN_OK,
|
|
70
|
+
FrameType.CLOSE,
|
|
71
|
+
FrameType.RESET,
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@dataclass(frozen=True, slots=True)
|
|
76
|
+
class MuxFrame:
|
|
77
|
+
frame_type: FrameType
|
|
78
|
+
stream_id: int
|
|
79
|
+
sequence: int
|
|
80
|
+
flags: int = 0
|
|
81
|
+
payload: bytes = b""
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def encode_frame(frame: MuxFrame) -> bytes:
|
|
85
|
+
_validate_frame(frame)
|
|
86
|
+
return (
|
|
87
|
+
_FRAME_HEADER.pack(
|
|
88
|
+
MUX_MAGIC,
|
|
89
|
+
MUX_PROTOCOL_VERSION,
|
|
90
|
+
int(frame.frame_type),
|
|
91
|
+
frame.flags,
|
|
92
|
+
frame.stream_id,
|
|
93
|
+
frame.sequence,
|
|
94
|
+
len(frame.payload),
|
|
95
|
+
)
|
|
96
|
+
+ frame.payload
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class MuxCodec:
|
|
101
|
+
"""Incrementally decode complete frames from an arbitrary byte stream."""
|
|
102
|
+
|
|
103
|
+
def __init__(self) -> None:
|
|
104
|
+
self._buffer = bytearray()
|
|
105
|
+
|
|
106
|
+
@property
|
|
107
|
+
def buffered_bytes(self) -> int:
|
|
108
|
+
return len(self._buffer)
|
|
109
|
+
|
|
110
|
+
def feed(self, data: bytes | bytearray) -> list[MuxFrame]:
|
|
111
|
+
if not isinstance(data, bytes | bytearray):
|
|
112
|
+
raise TypeError("mux input must be bytes-like")
|
|
113
|
+
self._buffer.extend(data)
|
|
114
|
+
frames: list[MuxFrame] = []
|
|
115
|
+
offset = 0
|
|
116
|
+
while len(self._buffer) - offset >= FRAME_HEADER_SIZE:
|
|
117
|
+
frame_type, flags, stream_id, sequence, payload_length = _decode_header(self._buffer, offset)
|
|
118
|
+
frame_length = FRAME_HEADER_SIZE + payload_length
|
|
119
|
+
if len(self._buffer) - offset < frame_length:
|
|
120
|
+
break
|
|
121
|
+
payload_start = offset + FRAME_HEADER_SIZE
|
|
122
|
+
payload = bytes(self._buffer[payload_start : offset + frame_length])
|
|
123
|
+
frame = MuxFrame(frame_type, stream_id, sequence, flags=flags, payload=payload)
|
|
124
|
+
_validate_frame(frame)
|
|
125
|
+
frames.append(frame)
|
|
126
|
+
offset += frame_length
|
|
127
|
+
if offset:
|
|
128
|
+
del self._buffer[:offset]
|
|
129
|
+
return frames
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def encode_control_payload(value: Mapping[str, object]) -> bytes:
|
|
133
|
+
if not isinstance(value, Mapping):
|
|
134
|
+
raise MuxProtocolError("control payload must be a JSON object")
|
|
135
|
+
try:
|
|
136
|
+
payload = json.dumps(dict(value), separators=(",", ":"), sort_keys=True).encode("utf-8")
|
|
137
|
+
except (TypeError, ValueError) as exc:
|
|
138
|
+
raise MuxProtocolError("control payload must be a JSON object") from exc
|
|
139
|
+
if len(payload) > MAX_CONTROL_PAYLOAD_BYTES:
|
|
140
|
+
raise MuxProtocolError(f"control payload exceeds {MAX_CONTROL_PAYLOAD_BYTES} bytes")
|
|
141
|
+
return payload
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def decode_control_payload(payload: bytes) -> dict[str, object]:
|
|
145
|
+
if not isinstance(payload, bytes):
|
|
146
|
+
raise TypeError("control payload must be bytes")
|
|
147
|
+
if len(payload) > MAX_CONTROL_PAYLOAD_BYTES:
|
|
148
|
+
raise MuxProtocolError(f"control payload exceeds {MAX_CONTROL_PAYLOAD_BYTES} bytes")
|
|
149
|
+
try:
|
|
150
|
+
value = json.loads(payload.decode("utf-8"))
|
|
151
|
+
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
|
|
152
|
+
raise MuxProtocolError("control payload must contain valid JSON") from exc
|
|
153
|
+
if not isinstance(value, dict):
|
|
154
|
+
raise MuxProtocolError("control payload must be a JSON object")
|
|
155
|
+
return value
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def encode_window_update(credit: int) -> bytes:
|
|
159
|
+
_validate_credit(credit)
|
|
160
|
+
return _WINDOW_UPDATE.pack(credit)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def decode_window_update(payload: bytes) -> int:
|
|
164
|
+
if not isinstance(payload, bytes):
|
|
165
|
+
raise TypeError("window update payload must be bytes")
|
|
166
|
+
if len(payload) != _WINDOW_UPDATE.size:
|
|
167
|
+
raise MuxProtocolError("window update payload must contain an 8-byte credit")
|
|
168
|
+
(credit,) = _WINDOW_UPDATE.unpack(payload)
|
|
169
|
+
_validate_credit(credit)
|
|
170
|
+
return credit
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
class StreamIdAllocator:
|
|
174
|
+
def __init__(self, parity: StreamParity, *, first: int | None = None) -> None:
|
|
175
|
+
if not isinstance(parity, StreamParity):
|
|
176
|
+
raise TypeError("stream parity must be a StreamParity")
|
|
177
|
+
candidate = (1 if parity is StreamParity.ODD else 2) if first is None else first
|
|
178
|
+
_validate_stream_id(candidate, allow_zero=False)
|
|
179
|
+
if candidate % 2 != int(parity):
|
|
180
|
+
raise ValueError("first stream ID does not match allocator parity")
|
|
181
|
+
self._next = candidate
|
|
182
|
+
|
|
183
|
+
def allocate(self) -> int:
|
|
184
|
+
if self._next > MAX_STREAM_ID:
|
|
185
|
+
raise MuxProtocolError("stream ID space is exhausted")
|
|
186
|
+
stream_id = self._next
|
|
187
|
+
self._next += 2
|
|
188
|
+
return stream_id
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
class SequenceTracker:
|
|
192
|
+
"""Track independent inbound and outbound sequence spaces."""
|
|
193
|
+
|
|
194
|
+
def __init__(self, *, next_inbound: int = 0, next_outbound: int = 0) -> None:
|
|
195
|
+
_validate_sequence(next_inbound)
|
|
196
|
+
_validate_sequence(next_outbound)
|
|
197
|
+
self._next_inbound = next_inbound
|
|
198
|
+
self._next_outbound = next_outbound
|
|
199
|
+
|
|
200
|
+
def accept_inbound(self, sequence: int) -> None:
|
|
201
|
+
if self._next_inbound > MAX_SEQUENCE:
|
|
202
|
+
raise MuxProtocolError("inbound sequence space is exhausted")
|
|
203
|
+
_validate_sequence(sequence)
|
|
204
|
+
if sequence != self._next_inbound:
|
|
205
|
+
raise MuxProtocolError(f"expected sequence {self._next_inbound}, received {sequence}")
|
|
206
|
+
self._next_inbound += 1
|
|
207
|
+
|
|
208
|
+
def next_outbound(self) -> int:
|
|
209
|
+
if self._next_outbound > MAX_SEQUENCE:
|
|
210
|
+
raise MuxProtocolError("outbound sequence space is exhausted")
|
|
211
|
+
sequence = self._next_outbound
|
|
212
|
+
self._next_outbound += 1
|
|
213
|
+
return sequence
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _decode_header(data: bytes | bytearray, offset: int = 0) -> tuple[FrameType, int, int, int, int]:
|
|
217
|
+
magic, version, raw_type, flags, stream_id, sequence, payload_length = _FRAME_HEADER.unpack_from(data, offset)
|
|
218
|
+
if magic != MUX_MAGIC:
|
|
219
|
+
raise MuxProtocolError("invalid mux frame magic")
|
|
220
|
+
if version != MUX_PROTOCOL_VERSION:
|
|
221
|
+
raise MuxProtocolError(f"unsupported mux protocol version: {version}")
|
|
222
|
+
try:
|
|
223
|
+
frame_type = FrameType(raw_type)
|
|
224
|
+
except ValueError as exc:
|
|
225
|
+
raise MuxProtocolError(f"unsupported mux frame type: {raw_type}") from exc
|
|
226
|
+
if flags != 0:
|
|
227
|
+
raise MuxProtocolError("unsupported mux frame flags")
|
|
228
|
+
if payload_length > MAX_FRAME_PAYLOAD_BYTES:
|
|
229
|
+
raise MuxProtocolError(f"frame payload length exceeds {MAX_FRAME_PAYLOAD_BYTES} bytes")
|
|
230
|
+
return frame_type, flags, stream_id, sequence, payload_length
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _validate_frame(frame: MuxFrame) -> None:
|
|
234
|
+
if not isinstance(frame, MuxFrame):
|
|
235
|
+
raise TypeError("frame must be a MuxFrame")
|
|
236
|
+
if not isinstance(frame.frame_type, FrameType):
|
|
237
|
+
raise MuxProtocolError("invalid mux frame type")
|
|
238
|
+
if not isinstance(frame.flags, int) or isinstance(frame.flags, bool) or frame.flags != 0:
|
|
239
|
+
raise MuxProtocolError("unsupported mux frame flags")
|
|
240
|
+
_validate_stream_id(frame.stream_id, allow_zero=True)
|
|
241
|
+
_validate_sequence(frame.sequence)
|
|
242
|
+
if not isinstance(frame.payload, bytes):
|
|
243
|
+
raise MuxProtocolError("frame payload must be bytes")
|
|
244
|
+
if len(frame.payload) > MAX_FRAME_PAYLOAD_BYTES:
|
|
245
|
+
raise MuxProtocolError(f"frame payload exceeds {MAX_FRAME_PAYLOAD_BYTES} bytes")
|
|
246
|
+
if frame.frame_type in _CONNECTION_FRAME_TYPES and frame.stream_id != 0:
|
|
247
|
+
raise MuxProtocolError(f"{frame.frame_type.name} requires connection stream ID 0")
|
|
248
|
+
if frame.frame_type in _STREAM_FRAME_TYPES and frame.stream_id == 0:
|
|
249
|
+
raise MuxProtocolError(f"{frame.frame_type.name} requires a nonzero stream ID")
|
|
250
|
+
if frame.frame_type is FrameType.DATA and not frame.payload:
|
|
251
|
+
raise MuxProtocolError("DATA requires a non-empty payload")
|
|
252
|
+
if frame.frame_type is FrameType.EOF and frame.payload:
|
|
253
|
+
raise MuxProtocolError(f"{frame.frame_type.name} must not contain a payload")
|
|
254
|
+
if frame.frame_type is FrameType.CLOSE_ACK and frame.payload:
|
|
255
|
+
decode_control_payload(frame.payload)
|
|
256
|
+
if frame.frame_type is FrameType.WINDOW_UPDATE:
|
|
257
|
+
decode_window_update(frame.payload)
|
|
258
|
+
if frame.frame_type in _JSON_CONTROL_FRAME_TYPES:
|
|
259
|
+
decode_control_payload(frame.payload)
|
|
260
|
+
if frame.frame_type in {FrameType.PING, FrameType.PONG} and len(frame.payload) > 8:
|
|
261
|
+
raise MuxProtocolError(f"{frame.frame_type.name} payload exceeds 8 bytes")
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def _validate_stream_id(stream_id: int, *, allow_zero: bool) -> None:
|
|
265
|
+
if not isinstance(stream_id, int) or isinstance(stream_id, bool):
|
|
266
|
+
raise MuxProtocolError("stream ID must be an integer")
|
|
267
|
+
minimum = 0 if allow_zero else 1
|
|
268
|
+
if not minimum <= stream_id <= MAX_STREAM_ID:
|
|
269
|
+
raise MuxProtocolError("stream ID is outside the unsigned 64-bit range")
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def _validate_sequence(sequence: int) -> None:
|
|
273
|
+
if not isinstance(sequence, int) or isinstance(sequence, bool) or not 0 <= sequence <= MAX_SEQUENCE:
|
|
274
|
+
raise MuxProtocolError("sequence is outside the unsigned 32-bit range")
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def _validate_credit(credit: int) -> None:
|
|
278
|
+
if not isinstance(credit, int) or isinstance(credit, bool) or not 1 <= credit <= MAX_STREAM_ID:
|
|
279
|
+
raise MuxProtocolError("credit must be an unsigned nonzero 64-bit integer")
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""Binary application records carried inside multiplexed DATA frames."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import struct
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from enum import IntEnum
|
|
8
|
+
|
|
9
|
+
MAX_RECORD_PAYLOAD_BYTES = 256 * 1024
|
|
10
|
+
_HEADER = struct.Struct("!BI")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class MuxRecordError(ValueError):
|
|
14
|
+
pass
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class MuxRecordChannel(IntEnum):
|
|
18
|
+
STDOUT = 1
|
|
19
|
+
STDERR = 2
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(frozen=True, slots=True)
|
|
23
|
+
class MuxRecord:
|
|
24
|
+
channel: MuxRecordChannel
|
|
25
|
+
payload: bytes
|
|
26
|
+
|
|
27
|
+
def __post_init__(self) -> None:
|
|
28
|
+
if not isinstance(self.channel, MuxRecordChannel):
|
|
29
|
+
raise TypeError("record channel must be a MuxRecordChannel")
|
|
30
|
+
if not isinstance(self.payload, bytes):
|
|
31
|
+
raise TypeError("record payload must be bytes")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def encode_record(record: MuxRecord) -> bytes:
|
|
35
|
+
if not isinstance(record, MuxRecord):
|
|
36
|
+
raise TypeError("record must be a MuxRecord")
|
|
37
|
+
if len(record.payload) > MAX_RECORD_PAYLOAD_BYTES:
|
|
38
|
+
raise MuxRecordError(f"record payload exceeds maximum of {MAX_RECORD_PAYLOAD_BYTES} bytes")
|
|
39
|
+
return _HEADER.pack(int(record.channel), len(record.payload)) + record.payload
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class MuxRecordDecoder:
|
|
43
|
+
def __init__(self) -> None:
|
|
44
|
+
self._buffer = bytearray()
|
|
45
|
+
|
|
46
|
+
@property
|
|
47
|
+
def buffered_bytes(self) -> int:
|
|
48
|
+
return len(self._buffer)
|
|
49
|
+
|
|
50
|
+
def feed(self, data: bytes) -> list[MuxRecord]:
|
|
51
|
+
if not isinstance(data, bytes):
|
|
52
|
+
raise TypeError("record data must be bytes")
|
|
53
|
+
if data:
|
|
54
|
+
self._buffer.extend(data)
|
|
55
|
+
|
|
56
|
+
records: list[MuxRecord] = []
|
|
57
|
+
while len(self._buffer) >= _HEADER.size:
|
|
58
|
+
channel_value, payload_length = _HEADER.unpack_from(self._buffer)
|
|
59
|
+
try:
|
|
60
|
+
channel = MuxRecordChannel(channel_value)
|
|
61
|
+
except ValueError as exc:
|
|
62
|
+
raise MuxRecordError(f"unknown record channel {channel_value}") from exc
|
|
63
|
+
if payload_length > MAX_RECORD_PAYLOAD_BYTES:
|
|
64
|
+
raise MuxRecordError(f"record payload exceeds maximum of {MAX_RECORD_PAYLOAD_BYTES} bytes")
|
|
65
|
+
record_length = _HEADER.size + payload_length
|
|
66
|
+
if len(self._buffer) < record_length:
|
|
67
|
+
break
|
|
68
|
+
payload = bytes(self._buffer[_HEADER.size : record_length])
|
|
69
|
+
del self._buffer[:record_length]
|
|
70
|
+
records.append(MuxRecord(channel, payload))
|
|
71
|
+
return records
|