@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,363 +0,0 @@
|
|
|
1
|
-
"""Versioned wire models for the HostBridge daemon protocol."""
|
|
2
|
-
|
|
3
|
-
from __future__ import annotations
|
|
4
|
-
|
|
5
|
-
import json
|
|
6
|
-
import struct
|
|
7
|
-
import threading
|
|
8
|
-
import uuid
|
|
9
|
-
from dataclasses import dataclass, field
|
|
10
|
-
from enum import IntFlag
|
|
11
|
-
from typing import Any, BinaryIO
|
|
12
|
-
|
|
13
|
-
PROTOCOL_VERSION = 1
|
|
14
|
-
MAX_FRAME_BYTES = 1024 * 1024
|
|
15
|
-
MAX_STREAM_ID_BYTES = 4096
|
|
16
|
-
_FRAME_MAGIC = b"HBV1"
|
|
17
|
-
_FRAME_HEADER = struct.Struct("!4sBBHIII")
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
class ProtocolError(ValueError):
|
|
21
|
-
"""Raised when a wire message does not conform to protocol v1."""
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
class FrameFlags(IntFlag):
|
|
25
|
-
DATA = 1
|
|
26
|
-
EOF = 2
|
|
27
|
-
CANCEL = 4
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
@dataclass(frozen=True, slots=True)
|
|
31
|
-
class BinaryFrame:
|
|
32
|
-
stream_id: str
|
|
33
|
-
sequence: int
|
|
34
|
-
flags: FrameFlags
|
|
35
|
-
payload: bytes = b""
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
def encode_frame(frame: BinaryFrame) -> bytes:
|
|
39
|
-
stream_id = _required_text(frame.stream_id, "stream_id").encode("utf-8")
|
|
40
|
-
if len(stream_id) > MAX_STREAM_ID_BYTES:
|
|
41
|
-
raise ProtocolError(f"stream_id exceeds {MAX_STREAM_ID_BYTES} bytes")
|
|
42
|
-
if not isinstance(frame.sequence, int) or isinstance(frame.sequence, bool) or frame.sequence < 0:
|
|
43
|
-
raise ProtocolError("sequence must be a non-negative integer")
|
|
44
|
-
if frame.sequence > 0xFFFFFFFF:
|
|
45
|
-
raise ProtocolError("sequence exceeds frame limit")
|
|
46
|
-
if not isinstance(frame.flags, FrameFlags) or not frame.flags:
|
|
47
|
-
raise ProtocolError("flags must contain a supported frame flag")
|
|
48
|
-
if int(frame.flags) & ~int(FrameFlags.DATA | FrameFlags.EOF | FrameFlags.CANCEL):
|
|
49
|
-
raise ProtocolError("flags contain an unsupported frame flag")
|
|
50
|
-
if not isinstance(frame.payload, bytes):
|
|
51
|
-
raise ProtocolError("frame payload must be bytes")
|
|
52
|
-
if len(frame.payload) > MAX_FRAME_BYTES:
|
|
53
|
-
raise ProtocolError(f"frame payload exceeds {MAX_FRAME_BYTES} bytes")
|
|
54
|
-
header = _FRAME_HEADER.pack(
|
|
55
|
-
_FRAME_MAGIC,
|
|
56
|
-
PROTOCOL_VERSION,
|
|
57
|
-
int(frame.flags),
|
|
58
|
-
0,
|
|
59
|
-
frame.sequence,
|
|
60
|
-
len(stream_id),
|
|
61
|
-
len(frame.payload),
|
|
62
|
-
)
|
|
63
|
-
return header + stream_id + frame.payload
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
def decode_frame(data: bytes) -> tuple[BinaryFrame, bytes]:
|
|
67
|
-
if not isinstance(data, bytes):
|
|
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]:
|
|
84
|
-
if len(data) < _FRAME_HEADER.size:
|
|
85
|
-
raise ProtocolError("incomplete frame header")
|
|
86
|
-
magic, protocol, raw_flags, reserved, sequence, stream_id_length, payload_length = _FRAME_HEADER.unpack_from(data)
|
|
87
|
-
if magic != _FRAME_MAGIC:
|
|
88
|
-
raise ProtocolError("invalid frame magic")
|
|
89
|
-
if protocol != PROTOCOL_VERSION:
|
|
90
|
-
raise ProtocolError(f"unsupported protocol: {protocol!r}")
|
|
91
|
-
if reserved != 0:
|
|
92
|
-
raise ProtocolError("reserved frame bits must be zero")
|
|
93
|
-
if stream_id_length > MAX_STREAM_ID_BYTES:
|
|
94
|
-
raise ProtocolError(f"stream_id exceeds {MAX_STREAM_ID_BYTES} bytes")
|
|
95
|
-
if payload_length > MAX_FRAME_BYTES:
|
|
96
|
-
raise ProtocolError(f"frame payload exceeds {MAX_FRAME_BYTES} bytes")
|
|
97
|
-
try:
|
|
98
|
-
flags = FrameFlags(raw_flags)
|
|
99
|
-
except ValueError as exc:
|
|
100
|
-
raise ProtocolError("flags contain an unsupported frame flag") from exc
|
|
101
|
-
if not flags or int(flags) & ~int(FrameFlags.DATA | FrameFlags.EOF | FrameFlags.CANCEL):
|
|
102
|
-
raise ProtocolError("flags contain an unsupported frame flag")
|
|
103
|
-
return sequence, flags, stream_id_length, payload_length
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
def _read_exact(stream: BinaryIO, size: int) -> bytes:
|
|
107
|
-
chunks = bytearray()
|
|
108
|
-
while len(chunks) < size:
|
|
109
|
-
chunk = stream.read(size - len(chunks))
|
|
110
|
-
if not chunk:
|
|
111
|
-
raise ProtocolError("incomplete frame")
|
|
112
|
-
chunks.extend(chunk)
|
|
113
|
-
return bytes(chunks)
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
def read_frame(stream: BinaryIO) -> BinaryFrame:
|
|
117
|
-
header = _read_exact(stream, _FRAME_HEADER.size)
|
|
118
|
-
_, _, stream_id_length, payload_length = _decode_header(header)
|
|
119
|
-
body = _read_exact(stream, stream_id_length + payload_length)
|
|
120
|
-
frame, remainder = decode_frame(header + body)
|
|
121
|
-
if remainder:
|
|
122
|
-
raise ProtocolError("frame reader consumed trailing bytes")
|
|
123
|
-
return frame
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
class StreamWindow:
|
|
127
|
-
"""Thread-safe credit window used to apply stream backpressure."""
|
|
128
|
-
|
|
129
|
-
def __init__(self, initial_credit: int):
|
|
130
|
-
if not isinstance(initial_credit, int) or isinstance(initial_credit, bool) or initial_credit < 0:
|
|
131
|
-
raise ValueError("initial_credit must be a non-negative integer")
|
|
132
|
-
self._credit = initial_credit
|
|
133
|
-
self._closed = False
|
|
134
|
-
self._condition = threading.Condition()
|
|
135
|
-
|
|
136
|
-
def acquire_nowait(self) -> bool:
|
|
137
|
-
with self._condition:
|
|
138
|
-
if self._closed or self._credit == 0:
|
|
139
|
-
return False
|
|
140
|
-
self._credit -= 1
|
|
141
|
-
return True
|
|
142
|
-
|
|
143
|
-
def acquire(self, timeout: float | None = None) -> bool:
|
|
144
|
-
with self._condition:
|
|
145
|
-
ready = self._condition.wait_for(lambda: self._closed or self._credit > 0, timeout=timeout)
|
|
146
|
-
if not ready or self._closed:
|
|
147
|
-
return False
|
|
148
|
-
self._credit -= 1
|
|
149
|
-
return True
|
|
150
|
-
|
|
151
|
-
def ack(self, count: int = 1) -> None:
|
|
152
|
-
if not isinstance(count, int) or isinstance(count, bool) or count <= 0:
|
|
153
|
-
raise ValueError("count must be a positive integer")
|
|
154
|
-
with self._condition:
|
|
155
|
-
if self._closed:
|
|
156
|
-
return
|
|
157
|
-
self._credit += count
|
|
158
|
-
self._condition.notify_all()
|
|
159
|
-
|
|
160
|
-
def close(self) -> None:
|
|
161
|
-
with self._condition:
|
|
162
|
-
self._closed = True
|
|
163
|
-
self._condition.notify_all()
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
class FrameSequenceValidator:
|
|
167
|
-
"""Reject missing or repeated frames independently for each stream."""
|
|
168
|
-
|
|
169
|
-
def __init__(self) -> None:
|
|
170
|
-
self._next_by_stream: dict[str, int] = {}
|
|
171
|
-
|
|
172
|
-
def accept(self, frame: BinaryFrame) -> None:
|
|
173
|
-
expected = self._next_by_stream.get(frame.stream_id, 0)
|
|
174
|
-
if frame.sequence != expected:
|
|
175
|
-
raise ProtocolError(f"stream {frame.stream_id!r} expected sequence {expected}, received {frame.sequence}")
|
|
176
|
-
self._next_by_stream[frame.stream_id] = expected + 1
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
def _parse_object(payload: str) -> dict[str, Any]:
|
|
180
|
-
try:
|
|
181
|
-
value = json.loads(payload)
|
|
182
|
-
except (json.JSONDecodeError, TypeError) as exc:
|
|
183
|
-
raise ProtocolError("invalid JSON object") from exc
|
|
184
|
-
if not isinstance(value, dict):
|
|
185
|
-
raise ProtocolError("message must be a JSON object")
|
|
186
|
-
return value
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
def _required_text(value: object, field: str) -> str:
|
|
190
|
-
if not isinstance(value, str) or not value.strip():
|
|
191
|
-
raise ProtocolError(f"{field} must be a non-empty string")
|
|
192
|
-
return value
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
def _protocol(value: dict[str, Any]) -> int:
|
|
196
|
-
protocol = value.get("protocol")
|
|
197
|
-
if protocol != PROTOCOL_VERSION:
|
|
198
|
-
raise ProtocolError(f"unsupported protocol: {protocol!r}")
|
|
199
|
-
return PROTOCOL_VERSION
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
def _object(value: object, field_name: str) -> dict[str, object]:
|
|
203
|
-
if not isinstance(value, dict):
|
|
204
|
-
raise ProtocolError(f"{field_name} must be a JSON object")
|
|
205
|
-
return value
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
@dataclass(frozen=True, slots=True)
|
|
209
|
-
class ErrorInfo:
|
|
210
|
-
code: str
|
|
211
|
-
message: str
|
|
212
|
-
retryable: bool = False
|
|
213
|
-
details: dict[str, object] = field(default_factory=dict)
|
|
214
|
-
|
|
215
|
-
@classmethod
|
|
216
|
-
def from_dict(cls, value: object) -> ErrorInfo:
|
|
217
|
-
data = _object(value, "error")
|
|
218
|
-
retryable = data.get("retryable", False)
|
|
219
|
-
if not isinstance(retryable, bool):
|
|
220
|
-
raise ProtocolError("error.retryable must be a boolean")
|
|
221
|
-
return cls(
|
|
222
|
-
code=_required_text(data.get("code"), "error.code"),
|
|
223
|
-
message=_required_text(data.get("message"), "error.message"),
|
|
224
|
-
retryable=retryable,
|
|
225
|
-
details=_object(data.get("details", {}), "error.details"),
|
|
226
|
-
)
|
|
227
|
-
|
|
228
|
-
def to_dict(self) -> dict[str, object]:
|
|
229
|
-
return {
|
|
230
|
-
"code": self.code,
|
|
231
|
-
"message": self.message,
|
|
232
|
-
"retryable": self.retryable,
|
|
233
|
-
"details": self.details,
|
|
234
|
-
}
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
@dataclass(frozen=True, slots=True)
|
|
238
|
-
class Request:
|
|
239
|
-
protocol: int
|
|
240
|
-
request_id: str
|
|
241
|
-
method: str
|
|
242
|
-
params: dict[str, object]
|
|
243
|
-
|
|
244
|
-
@classmethod
|
|
245
|
-
def new(
|
|
246
|
-
cls,
|
|
247
|
-
method: str,
|
|
248
|
-
params: dict[str, object],
|
|
249
|
-
*,
|
|
250
|
-
request_id: str | None = None,
|
|
251
|
-
) -> Request:
|
|
252
|
-
return cls(
|
|
253
|
-
protocol=PROTOCOL_VERSION,
|
|
254
|
-
request_id=_required_text(request_id or uuid.uuid4().hex, "request_id"),
|
|
255
|
-
method=_required_text(method, "method"),
|
|
256
|
-
params=dict(params),
|
|
257
|
-
)
|
|
258
|
-
|
|
259
|
-
@classmethod
|
|
260
|
-
def from_json(cls, payload: str) -> Request:
|
|
261
|
-
value = _parse_object(payload)
|
|
262
|
-
return cls(
|
|
263
|
-
protocol=_protocol(value),
|
|
264
|
-
request_id=_required_text(value.get("request_id"), "request_id"),
|
|
265
|
-
method=_required_text(value.get("method"), "method"),
|
|
266
|
-
params=_object(value.get("params"), "params"),
|
|
267
|
-
)
|
|
268
|
-
|
|
269
|
-
def to_json(self) -> str:
|
|
270
|
-
return json.dumps(
|
|
271
|
-
{
|
|
272
|
-
"protocol": self.protocol,
|
|
273
|
-
"request_id": self.request_id,
|
|
274
|
-
"method": self.method,
|
|
275
|
-
"params": self.params,
|
|
276
|
-
},
|
|
277
|
-
ensure_ascii=False,
|
|
278
|
-
separators=(",", ":"),
|
|
279
|
-
)
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
@dataclass(frozen=True, slots=True)
|
|
283
|
-
class Response:
|
|
284
|
-
protocol: int
|
|
285
|
-
request_id: str
|
|
286
|
-
ok: bool
|
|
287
|
-
result: dict[str, object] = field(default_factory=dict)
|
|
288
|
-
error: ErrorInfo | None = None
|
|
289
|
-
|
|
290
|
-
@classmethod
|
|
291
|
-
def success(cls, request_id: str, result: dict[str, object]) -> Response:
|
|
292
|
-
return cls(PROTOCOL_VERSION, _required_text(request_id, "request_id"), True, dict(result))
|
|
293
|
-
|
|
294
|
-
@classmethod
|
|
295
|
-
def failure(cls, request_id: str, error: ErrorInfo) -> Response:
|
|
296
|
-
return cls(PROTOCOL_VERSION, _required_text(request_id, "request_id"), False, {}, error)
|
|
297
|
-
|
|
298
|
-
@classmethod
|
|
299
|
-
def from_json(cls, payload: str) -> Response:
|
|
300
|
-
value = _parse_object(payload)
|
|
301
|
-
ok = value.get("ok")
|
|
302
|
-
if not isinstance(ok, bool):
|
|
303
|
-
raise ProtocolError("ok must be a boolean")
|
|
304
|
-
request_id = _required_text(value.get("request_id"), "request_id")
|
|
305
|
-
protocol = _protocol(value)
|
|
306
|
-
if ok:
|
|
307
|
-
if value.get("error") is not None:
|
|
308
|
-
raise ProtocolError("successful response must not contain error")
|
|
309
|
-
return cls(protocol, request_id, True, _object(value.get("result", {}), "result"))
|
|
310
|
-
if value.get("result") not in (None, {}):
|
|
311
|
-
raise ProtocolError("failed response must not contain result")
|
|
312
|
-
return cls(protocol, request_id, False, {}, ErrorInfo.from_dict(value.get("error")))
|
|
313
|
-
|
|
314
|
-
def to_json(self) -> str:
|
|
315
|
-
payload: dict[str, object] = {
|
|
316
|
-
"protocol": self.protocol,
|
|
317
|
-
"request_id": self.request_id,
|
|
318
|
-
"ok": self.ok,
|
|
319
|
-
}
|
|
320
|
-
if self.ok:
|
|
321
|
-
payload["result"] = self.result
|
|
322
|
-
elif self.error is not None:
|
|
323
|
-
payload["error"] = self.error.to_dict()
|
|
324
|
-
return json.dumps(payload, ensure_ascii=False, separators=(",", ":"))
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
@dataclass(frozen=True, slots=True)
|
|
328
|
-
class Event:
|
|
329
|
-
protocol: int
|
|
330
|
-
request_id: str
|
|
331
|
-
stream_id: str
|
|
332
|
-
sequence: int
|
|
333
|
-
event: str
|
|
334
|
-
data: dict[str, object] = field(default_factory=dict)
|
|
335
|
-
|
|
336
|
-
@classmethod
|
|
337
|
-
def from_json(cls, payload: str) -> Event:
|
|
338
|
-
value = _parse_object(payload)
|
|
339
|
-
sequence = value.get("sequence")
|
|
340
|
-
if not isinstance(sequence, int) or isinstance(sequence, bool) or sequence < 0:
|
|
341
|
-
raise ProtocolError("sequence must be a non-negative integer")
|
|
342
|
-
return cls(
|
|
343
|
-
protocol=_protocol(value),
|
|
344
|
-
request_id=_required_text(value.get("request_id"), "request_id"),
|
|
345
|
-
stream_id=_required_text(value.get("stream_id"), "stream_id"),
|
|
346
|
-
sequence=sequence,
|
|
347
|
-
event=_required_text(value.get("event"), "event"),
|
|
348
|
-
data=_object(value.get("data", {}), "data"),
|
|
349
|
-
)
|
|
350
|
-
|
|
351
|
-
def to_json(self) -> str:
|
|
352
|
-
return json.dumps(
|
|
353
|
-
{
|
|
354
|
-
"protocol": self.protocol,
|
|
355
|
-
"request_id": self.request_id,
|
|
356
|
-
"stream_id": self.stream_id,
|
|
357
|
-
"sequence": self.sequence,
|
|
358
|
-
"event": self.event,
|
|
359
|
-
"data": self.data,
|
|
360
|
-
},
|
|
361
|
-
ensure_ascii=False,
|
|
362
|
-
separators=(",", ":"),
|
|
363
|
-
)
|
|
@@ -1,143 +0,0 @@
|
|
|
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")
|