@neoline/hostbridge 2.0.3 → 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 +53 -954
- 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
|
@@ -0,0 +1,592 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import base64
|
|
5
|
+
import binascii
|
|
6
|
+
import sys
|
|
7
|
+
import uuid
|
|
8
|
+
from collections import deque
|
|
9
|
+
from contextlib import suppress
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from enum import StrEnum
|
|
12
|
+
from typing import Protocol
|
|
13
|
+
|
|
14
|
+
import asyncssh
|
|
15
|
+
|
|
16
|
+
from .client import HostBridgeClient, HostBridgeError
|
|
17
|
+
from .remote_tunnel_agent import build_remote_tunnel_command
|
|
18
|
+
|
|
19
|
+
TUNNEL_PROTOCOL_VERSION = 1
|
|
20
|
+
UPSTREAM_FRAME_BYTES = 2048
|
|
21
|
+
DOWNSTREAM_FRAME_BYTES = 16 * 1024
|
|
22
|
+
TUNNEL_READ_BYTES = 64 * 1024
|
|
23
|
+
TUNNEL_WRITE_BATCH_BYTES = 32 * 1024
|
|
24
|
+
TUNNEL_HIGH_WATER_BYTES = 128 * 1024
|
|
25
|
+
TUNNEL_LOW_WATER_BYTES = 64 * 1024
|
|
26
|
+
TUNNEL_MAX_LINE_BYTES = 128 * 1024
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class TunnelProtocolError(ValueError):
|
|
30
|
+
pass
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class Direction(StrEnum):
|
|
34
|
+
UPSTREAM = "U"
|
|
35
|
+
DOWNSTREAM = "D"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class FrameKind(StrEnum):
|
|
39
|
+
READY = "READY"
|
|
40
|
+
DATA = "DATA"
|
|
41
|
+
EOF = "EOF"
|
|
42
|
+
ERROR = "ERROR"
|
|
43
|
+
CLOSED = "CLOSED"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass(frozen=True, slots=True)
|
|
47
|
+
class TunnelFrame:
|
|
48
|
+
direction: Direction
|
|
49
|
+
sequence: int
|
|
50
|
+
kind: FrameKind
|
|
51
|
+
payload: bytes = b""
|
|
52
|
+
status: int | None = None
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class TunnelCodec:
|
|
56
|
+
def __init__(self, prefix: str, *, inbound_direction: Direction) -> None:
|
|
57
|
+
if not prefix or any(character in prefix for character in "\r\n:"):
|
|
58
|
+
raise ValueError("tunnel prefix must be non-empty and must not contain CR, LF, or colon")
|
|
59
|
+
try:
|
|
60
|
+
prefix_bytes = prefix.encode("ascii")
|
|
61
|
+
except UnicodeEncodeError as exc:
|
|
62
|
+
raise ValueError("tunnel prefix must be ASCII") from exc
|
|
63
|
+
self._prefix = prefix_bytes
|
|
64
|
+
self._marker = prefix_bytes + b":V"
|
|
65
|
+
self._inbound_direction = inbound_direction
|
|
66
|
+
self._next_sequence = 0
|
|
67
|
+
self._buffer = bytearray()
|
|
68
|
+
|
|
69
|
+
def encode(self, frame: TunnelFrame) -> bytes:
|
|
70
|
+
self._validate_frame(frame)
|
|
71
|
+
status = b"-" if frame.status is None else str(frame.status).encode("ascii")
|
|
72
|
+
payload = base64.b64encode(frame.payload)
|
|
73
|
+
return (
|
|
74
|
+
b":".join(
|
|
75
|
+
(
|
|
76
|
+
self._prefix,
|
|
77
|
+
f"V{TUNNEL_PROTOCOL_VERSION}".encode("ascii"),
|
|
78
|
+
frame.direction.value.encode("ascii"),
|
|
79
|
+
str(frame.sequence).encode("ascii"),
|
|
80
|
+
frame.kind.value.encode("ascii"),
|
|
81
|
+
status,
|
|
82
|
+
payload,
|
|
83
|
+
)
|
|
84
|
+
)
|
|
85
|
+
+ b"\n"
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
def feed(self, data: bytes) -> list[TunnelFrame]:
|
|
89
|
+
self._buffer.extend(data)
|
|
90
|
+
frames: list[TunnelFrame] = []
|
|
91
|
+
while b"\n" in self._buffer:
|
|
92
|
+
raw_line, _, remainder = self._buffer.partition(b"\n")
|
|
93
|
+
self._buffer = bytearray(remainder)
|
|
94
|
+
line = raw_line.rstrip(b"\r")
|
|
95
|
+
marker = line.find(self._marker)
|
|
96
|
+
if marker < 0:
|
|
97
|
+
continue
|
|
98
|
+
frames.append(self._decode_line(bytes(line[marker:])))
|
|
99
|
+
if len(self._buffer) > TUNNEL_MAX_LINE_BYTES:
|
|
100
|
+
raise TunnelProtocolError("tunnel frame exceeds maximum line size")
|
|
101
|
+
return frames
|
|
102
|
+
|
|
103
|
+
def _decode_line(self, line: bytes) -> TunnelFrame:
|
|
104
|
+
fields = line.split(b":", 6)
|
|
105
|
+
if len(fields) != 7:
|
|
106
|
+
raise TunnelProtocolError("malformed tunnel frame")
|
|
107
|
+
prefix, version, direction_raw, sequence_raw, kind_raw, status_raw, payload_raw = fields
|
|
108
|
+
if prefix != self._prefix:
|
|
109
|
+
raise TunnelProtocolError("tunnel frame prefix mismatch")
|
|
110
|
+
if version != f"V{TUNNEL_PROTOCOL_VERSION}".encode("ascii"):
|
|
111
|
+
raise TunnelProtocolError("unsupported tunnel protocol version")
|
|
112
|
+
try:
|
|
113
|
+
direction = Direction(direction_raw.decode("ascii"))
|
|
114
|
+
except (UnicodeDecodeError, ValueError) as exc:
|
|
115
|
+
raise TunnelProtocolError("invalid tunnel frame direction") from exc
|
|
116
|
+
if direction is not self._inbound_direction:
|
|
117
|
+
raise TunnelProtocolError("unexpected tunnel frame direction")
|
|
118
|
+
try:
|
|
119
|
+
sequence = int(sequence_raw)
|
|
120
|
+
except ValueError as exc:
|
|
121
|
+
raise TunnelProtocolError("invalid tunnel frame sequence") from exc
|
|
122
|
+
if sequence != self._next_sequence:
|
|
123
|
+
raise TunnelProtocolError(f"tunnel frame sequence expected {self._next_sequence}, received {sequence}")
|
|
124
|
+
try:
|
|
125
|
+
kind = FrameKind(kind_raw.decode("ascii"))
|
|
126
|
+
except (UnicodeDecodeError, ValueError) as exc:
|
|
127
|
+
raise TunnelProtocolError("invalid tunnel frame kind") from exc
|
|
128
|
+
if status_raw == b"-":
|
|
129
|
+
status = None
|
|
130
|
+
else:
|
|
131
|
+
try:
|
|
132
|
+
status = int(status_raw)
|
|
133
|
+
except ValueError as exc:
|
|
134
|
+
raise TunnelProtocolError("invalid tunnel frame status") from exc
|
|
135
|
+
try:
|
|
136
|
+
payload = base64.b64decode(payload_raw, validate=True)
|
|
137
|
+
except (binascii.Error, ValueError) as exc:
|
|
138
|
+
raise TunnelProtocolError("invalid tunnel frame base64 payload") from exc
|
|
139
|
+
frame = TunnelFrame(direction, sequence, kind, payload, status)
|
|
140
|
+
self._validate_frame(frame)
|
|
141
|
+
self._next_sequence += 1
|
|
142
|
+
return frame
|
|
143
|
+
|
|
144
|
+
@staticmethod
|
|
145
|
+
def _validate_frame(frame: TunnelFrame) -> None:
|
|
146
|
+
if not isinstance(frame.direction, Direction):
|
|
147
|
+
raise TunnelProtocolError("invalid tunnel frame direction")
|
|
148
|
+
if not isinstance(frame.sequence, int) or isinstance(frame.sequence, bool) or frame.sequence < 0:
|
|
149
|
+
raise TunnelProtocolError("invalid tunnel frame sequence")
|
|
150
|
+
if not isinstance(frame.kind, FrameKind):
|
|
151
|
+
raise TunnelProtocolError("invalid tunnel frame kind")
|
|
152
|
+
if not isinstance(frame.payload, bytes):
|
|
153
|
+
raise TunnelProtocolError("tunnel frame payload must be bytes")
|
|
154
|
+
if frame.kind is FrameKind.CLOSED:
|
|
155
|
+
if not isinstance(frame.status, int) or isinstance(frame.status, bool) or not 0 <= frame.status <= 255:
|
|
156
|
+
raise TunnelProtocolError("CLOSED tunnel frame requires an exit status")
|
|
157
|
+
elif frame.status is not None:
|
|
158
|
+
raise TunnelProtocolError("only CLOSED tunnel frames may contain a status")
|
|
159
|
+
if frame.kind not in {FrameKind.DATA, FrameKind.ERROR} and frame.payload:
|
|
160
|
+
raise TunnelProtocolError(f"{frame.kind.value} tunnel frame must not contain a payload")
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
class TunnelState(StrEnum):
|
|
164
|
+
CONNECTING = "CONNECTING"
|
|
165
|
+
OPEN = "OPEN"
|
|
166
|
+
LOCAL_EOF = "LOCAL_EOF"
|
|
167
|
+
REMOTE_EOF = "REMOTE_EOF"
|
|
168
|
+
DRAINING = "DRAINING"
|
|
169
|
+
ABORTING = "ABORTING"
|
|
170
|
+
CLOSED = "CLOSED"
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
class TunnelLifecycle:
|
|
174
|
+
def __init__(self) -> None:
|
|
175
|
+
self.state = TunnelState.CONNECTING
|
|
176
|
+
self.reusable = False
|
|
177
|
+
|
|
178
|
+
def mark_ready(self) -> None:
|
|
179
|
+
self._require(TunnelState.CONNECTING)
|
|
180
|
+
self.state = TunnelState.OPEN
|
|
181
|
+
|
|
182
|
+
def mark_local_eof(self) -> None:
|
|
183
|
+
if self.state is TunnelState.OPEN:
|
|
184
|
+
self.state = TunnelState.LOCAL_EOF
|
|
185
|
+
elif self.state is TunnelState.REMOTE_EOF:
|
|
186
|
+
self.state = TunnelState.DRAINING
|
|
187
|
+
else:
|
|
188
|
+
self._invalid("local EOF")
|
|
189
|
+
|
|
190
|
+
def mark_remote_eof(self) -> None:
|
|
191
|
+
if self.state is TunnelState.OPEN:
|
|
192
|
+
self.state = TunnelState.REMOTE_EOF
|
|
193
|
+
elif self.state is TunnelState.LOCAL_EOF:
|
|
194
|
+
self.state = TunnelState.DRAINING
|
|
195
|
+
else:
|
|
196
|
+
self._invalid("remote EOF")
|
|
197
|
+
|
|
198
|
+
def mark_closed(self, status: int) -> None:
|
|
199
|
+
self._require(TunnelState.DRAINING)
|
|
200
|
+
if not isinstance(status, int) or isinstance(status, bool) or not 0 <= status <= 255:
|
|
201
|
+
raise TunnelProtocolError("terminal tunnel status is invalid")
|
|
202
|
+
self.state = TunnelState.CLOSED
|
|
203
|
+
self.reusable = status == 0
|
|
204
|
+
|
|
205
|
+
def abort(self) -> None:
|
|
206
|
+
if self.state is not TunnelState.CLOSED:
|
|
207
|
+
self.state = TunnelState.ABORTING
|
|
208
|
+
self.reusable = False
|
|
209
|
+
|
|
210
|
+
def finish_abort(self) -> None:
|
|
211
|
+
self._require(TunnelState.ABORTING)
|
|
212
|
+
self.state = TunnelState.CLOSED
|
|
213
|
+
|
|
214
|
+
def _require(self, expected: TunnelState) -> None:
|
|
215
|
+
if self.state is not expected:
|
|
216
|
+
raise TunnelProtocolError(f"tunnel state must be {expected.value}, found {self.state.value}")
|
|
217
|
+
|
|
218
|
+
def _invalid(self, event: str) -> None:
|
|
219
|
+
raise TunnelProtocolError(f"cannot accept {event} while tunnel state is {self.state.value}")
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
class SessionPool(Protocol):
|
|
223
|
+
def open(self) -> str: ...
|
|
224
|
+
|
|
225
|
+
def close(self, session_id: str, *, reusable: bool = True) -> None: ...
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
class TunnelLease:
|
|
229
|
+
def __init__(self, pool: SessionPool, session_id: str) -> None:
|
|
230
|
+
self._pool = pool
|
|
231
|
+
self.session_id = session_id
|
|
232
|
+
self._close_task: asyncio.Task[None] | None = None
|
|
233
|
+
|
|
234
|
+
@classmethod
|
|
235
|
+
async def acquire(cls, pool: SessionPool) -> TunnelLease:
|
|
236
|
+
open_task = asyncio.create_task(asyncio.to_thread(pool.open))
|
|
237
|
+
try:
|
|
238
|
+
session_id = await asyncio.shield(open_task)
|
|
239
|
+
except asyncio.CancelledError:
|
|
240
|
+
await cls._wait_ignoring_cancellation(open_task)
|
|
241
|
+
if not open_task.cancelled() and open_task.exception() is None:
|
|
242
|
+
lease = cls(pool, open_task.result())
|
|
243
|
+
await lease.close(reusable=False)
|
|
244
|
+
raise
|
|
245
|
+
return cls(pool, session_id)
|
|
246
|
+
|
|
247
|
+
async def close(self, *, reusable: bool) -> None:
|
|
248
|
+
if self._close_task is None:
|
|
249
|
+
self._close_task = asyncio.create_task(
|
|
250
|
+
asyncio.to_thread(self._pool.close, self.session_id, reusable=reusable)
|
|
251
|
+
)
|
|
252
|
+
await self._wait_ignoring_cancellation(self._close_task)
|
|
253
|
+
await self._close_task
|
|
254
|
+
|
|
255
|
+
@staticmethod
|
|
256
|
+
async def _wait_ignoring_cancellation(task: asyncio.Task[object]) -> None:
|
|
257
|
+
while not task.done():
|
|
258
|
+
try:
|
|
259
|
+
await asyncio.shield(task)
|
|
260
|
+
except asyncio.CancelledError:
|
|
261
|
+
continue
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
class _TunnelFrameStream:
|
|
265
|
+
def __init__(self, client: HostBridgeClient, lease: TunnelLease, prefix: str) -> None:
|
|
266
|
+
self._client = client
|
|
267
|
+
self._lease = lease
|
|
268
|
+
self._codec = TunnelCodec(prefix, inbound_direction=Direction.DOWNSTREAM)
|
|
269
|
+
self._pending: deque[TunnelFrame] = deque()
|
|
270
|
+
|
|
271
|
+
async def next(self) -> TunnelFrame:
|
|
272
|
+
while not self._pending:
|
|
273
|
+
result = await asyncio.to_thread(
|
|
274
|
+
self._client.shell_read,
|
|
275
|
+
self._lease.session_id,
|
|
276
|
+
timeout=0.2,
|
|
277
|
+
max_bytes=TUNNEL_READ_BYTES,
|
|
278
|
+
owner="mock-ssh",
|
|
279
|
+
)
|
|
280
|
+
if result.data:
|
|
281
|
+
try:
|
|
282
|
+
self._pending.extend(self._codec.feed(result.data))
|
|
283
|
+
except TunnelProtocolError as exc:
|
|
284
|
+
raise HostBridgeError("protocol_mismatch", str(exc)) from exc
|
|
285
|
+
if not result.alive and not self._pending:
|
|
286
|
+
raise HostBridgeError(
|
|
287
|
+
"connection_lost",
|
|
288
|
+
"remote TCP tunnel closed without a terminal frame",
|
|
289
|
+
retryable=True,
|
|
290
|
+
)
|
|
291
|
+
return self._pending.popleft()
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
class TunnelConnector:
|
|
295
|
+
def __init__(
|
|
296
|
+
self,
|
|
297
|
+
client: HostBridgeClient,
|
|
298
|
+
session_pool: SessionPool,
|
|
299
|
+
*,
|
|
300
|
+
connect_timeout: int,
|
|
301
|
+
) -> None:
|
|
302
|
+
if connect_timeout <= 0:
|
|
303
|
+
raise ValueError("connect_timeout must be positive")
|
|
304
|
+
self._client = client
|
|
305
|
+
self._session_pool = session_pool
|
|
306
|
+
self._connect_timeout = connect_timeout
|
|
307
|
+
|
|
308
|
+
@staticmethod
|
|
309
|
+
def validate_destination(dest_host: str, dest_port: int) -> None:
|
|
310
|
+
if not isinstance(dest_host, str) or not dest_host.strip() or "\x00" in dest_host or len(dest_host) > 253:
|
|
311
|
+
raise ValueError("TCP forwarding destination host is invalid")
|
|
312
|
+
if not isinstance(dest_port, int) or isinstance(dest_port, bool) or not 1 <= dest_port <= 65535:
|
|
313
|
+
raise ValueError("TCP forwarding destination port must be between 1 and 65535")
|
|
314
|
+
|
|
315
|
+
async def connect(self, dest_host: str, dest_port: int) -> asyncssh.SSHTCPSession[bytes]:
|
|
316
|
+
self.validate_destination(dest_host, dest_port)
|
|
317
|
+
try:
|
|
318
|
+
lease = await TunnelLease.acquire(self._session_pool)
|
|
319
|
+
except asyncio.CancelledError:
|
|
320
|
+
raise
|
|
321
|
+
except Exception as exc:
|
|
322
|
+
raise asyncssh.ChannelOpenError(asyncssh.OPEN_CONNECT_FAILED, str(exc)) from exc
|
|
323
|
+
|
|
324
|
+
prefix = f"__HB_TUNNEL_{uuid.uuid4().hex}__"
|
|
325
|
+
frames = _TunnelFrameStream(self._client, lease, prefix)
|
|
326
|
+
lifecycle = TunnelLifecycle()
|
|
327
|
+
try:
|
|
328
|
+
await asyncio.to_thread(
|
|
329
|
+
self._client.shell_write,
|
|
330
|
+
lease.session_id,
|
|
331
|
+
build_remote_tunnel_command(dest_host, dest_port, prefix),
|
|
332
|
+
owner="mock-ssh",
|
|
333
|
+
)
|
|
334
|
+
first = await asyncio.wait_for(frames.next(), timeout=self._connect_timeout)
|
|
335
|
+
if first.kind is FrameKind.ERROR:
|
|
336
|
+
raise HostBridgeError(
|
|
337
|
+
"connection_lost",
|
|
338
|
+
first.payload.decode("utf-8", errors="replace") or "remote TCP connection failed",
|
|
339
|
+
retryable=True,
|
|
340
|
+
)
|
|
341
|
+
if first.kind is not FrameKind.READY:
|
|
342
|
+
raise HostBridgeError(
|
|
343
|
+
"protocol_mismatch",
|
|
344
|
+
f"remote TCP tunnel returned {first.kind.value} before READY",
|
|
345
|
+
)
|
|
346
|
+
lifecycle.mark_ready()
|
|
347
|
+
except asyncio.CancelledError:
|
|
348
|
+
lifecycle.abort()
|
|
349
|
+
await lease.close(reusable=False)
|
|
350
|
+
lifecycle.finish_abort()
|
|
351
|
+
raise
|
|
352
|
+
except Exception as exc:
|
|
353
|
+
lifecycle.abort()
|
|
354
|
+
await lease.close(reusable=False)
|
|
355
|
+
lifecycle.finish_abort()
|
|
356
|
+
raise asyncssh.ChannelOpenError(asyncssh.OPEN_CONNECT_FAILED, str(exc)) from exc
|
|
357
|
+
return DirectTCPIPSession(
|
|
358
|
+
self._client,
|
|
359
|
+
lease,
|
|
360
|
+
prefix,
|
|
361
|
+
frames,
|
|
362
|
+
lifecycle,
|
|
363
|
+
dest_host=dest_host,
|
|
364
|
+
dest_port=dest_port,
|
|
365
|
+
)
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
_INPUT_EOF = object()
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
class DirectTCPIPSession(asyncssh.SSHTCPSession[bytes]):
|
|
372
|
+
def __init__(
|
|
373
|
+
self,
|
|
374
|
+
client: HostBridgeClient,
|
|
375
|
+
lease: TunnelLease,
|
|
376
|
+
prefix: str,
|
|
377
|
+
frames: _TunnelFrameStream,
|
|
378
|
+
lifecycle: TunnelLifecycle,
|
|
379
|
+
*,
|
|
380
|
+
dest_host: str,
|
|
381
|
+
dest_port: int,
|
|
382
|
+
) -> None:
|
|
383
|
+
self._client = client
|
|
384
|
+
self._lease = lease
|
|
385
|
+
self._codec = TunnelCodec(prefix, inbound_direction=Direction.DOWNSTREAM)
|
|
386
|
+
self._frames = frames
|
|
387
|
+
self._lifecycle = lifecycle
|
|
388
|
+
self._dest_host = dest_host
|
|
389
|
+
self._dest_port = dest_port
|
|
390
|
+
self._channel: asyncssh.SSHTCPChannel[bytes] | None = None
|
|
391
|
+
self._input: asyncio.Queue[bytes | object] = asyncio.Queue()
|
|
392
|
+
self._input_buffered_bytes = 0
|
|
393
|
+
self._input_paused = False
|
|
394
|
+
self._upstream_sequence = 0
|
|
395
|
+
self._write_ready = asyncio.Event()
|
|
396
|
+
self._write_ready.set()
|
|
397
|
+
self._runner: asyncio.Task[None] | None = None
|
|
398
|
+
self._orphan_cleanup: asyncio.Task[None] | None = None
|
|
399
|
+
self._finished = asyncio.Event()
|
|
400
|
+
|
|
401
|
+
@property
|
|
402
|
+
def state(self) -> TunnelState:
|
|
403
|
+
return self._lifecycle.state
|
|
404
|
+
|
|
405
|
+
def connection_made(self, chan: asyncssh.SSHTCPChannel[bytes]) -> None:
|
|
406
|
+
self._channel = chan
|
|
407
|
+
|
|
408
|
+
def session_started(self) -> None:
|
|
409
|
+
self._runner = asyncio.create_task(self._run())
|
|
410
|
+
self._runner.add_done_callback(self._runner_done)
|
|
411
|
+
|
|
412
|
+
def data_received(self, data: bytes, datatype: asyncssh.DataType) -> None: # noqa: ARG002
|
|
413
|
+
if not data:
|
|
414
|
+
return
|
|
415
|
+
payload = bytes(data)
|
|
416
|
+
self._input.put_nowait(payload)
|
|
417
|
+
self._input_buffered_bytes += len(payload)
|
|
418
|
+
if (
|
|
419
|
+
self._input_buffered_bytes >= TUNNEL_HIGH_WATER_BYTES
|
|
420
|
+
and not self._input_paused
|
|
421
|
+
and self._channel is not None
|
|
422
|
+
):
|
|
423
|
+
self._channel.pause_reading()
|
|
424
|
+
self._input_paused = True
|
|
425
|
+
|
|
426
|
+
def eof_received(self) -> bool:
|
|
427
|
+
self._input.put_nowait(_INPUT_EOF)
|
|
428
|
+
return True
|
|
429
|
+
|
|
430
|
+
def connection_lost(self, exc: Exception | None) -> None: # noqa: ARG002
|
|
431
|
+
self.abort()
|
|
432
|
+
|
|
433
|
+
def pause_writing(self) -> None:
|
|
434
|
+
self._write_ready.clear()
|
|
435
|
+
|
|
436
|
+
def resume_writing(self) -> None:
|
|
437
|
+
self._write_ready.set()
|
|
438
|
+
|
|
439
|
+
def abort(self) -> None:
|
|
440
|
+
self._lifecycle.abort()
|
|
441
|
+
if self._runner is not None and self._runner is not asyncio.current_task() and not self._runner.done():
|
|
442
|
+
self._runner.cancel()
|
|
443
|
+
elif self._runner is None:
|
|
444
|
+
self._schedule_orphan_cleanup()
|
|
445
|
+
|
|
446
|
+
async def wait_finished(self) -> None:
|
|
447
|
+
await self._finished.wait()
|
|
448
|
+
|
|
449
|
+
def _runner_done(self, task: asyncio.Task[None]) -> None: # noqa: ARG002
|
|
450
|
+
if not self._finished.is_set():
|
|
451
|
+
self._schedule_orphan_cleanup()
|
|
452
|
+
|
|
453
|
+
def _schedule_orphan_cleanup(self) -> None:
|
|
454
|
+
if self._orphan_cleanup is None:
|
|
455
|
+
self._orphan_cleanup = asyncio.create_task(self._finish_orphaned_session())
|
|
456
|
+
|
|
457
|
+
async def _finish_orphaned_session(self) -> None:
|
|
458
|
+
self._lifecycle.abort()
|
|
459
|
+
await self._lease.close(reusable=False)
|
|
460
|
+
if self._lifecycle.state is TunnelState.ABORTING:
|
|
461
|
+
self._lifecycle.finish_abort()
|
|
462
|
+
self._close_channel()
|
|
463
|
+
self._finished.set()
|
|
464
|
+
|
|
465
|
+
async def _run(self) -> None:
|
|
466
|
+
input_task = asyncio.create_task(self._pump_input())
|
|
467
|
+
output_task = asyncio.create_task(self._pump_output())
|
|
468
|
+
try:
|
|
469
|
+
await asyncio.gather(input_task, output_task)
|
|
470
|
+
except asyncio.CancelledError:
|
|
471
|
+
self._lifecycle.abort()
|
|
472
|
+
raise
|
|
473
|
+
except Exception as exc:
|
|
474
|
+
self._lifecycle.abort()
|
|
475
|
+
print(
|
|
476
|
+
f"hostbridge mock-ssh TCP forwarding to {self._dest_host}:{self._dest_port} closed: {exc}",
|
|
477
|
+
file=sys.stderr,
|
|
478
|
+
flush=True,
|
|
479
|
+
)
|
|
480
|
+
finally:
|
|
481
|
+
for task in (input_task, output_task):
|
|
482
|
+
if not task.done():
|
|
483
|
+
task.cancel()
|
|
484
|
+
await asyncio.gather(input_task, output_task, return_exceptions=True)
|
|
485
|
+
await self._lease.close(reusable=self._lifecycle.reusable)
|
|
486
|
+
if self._lifecycle.state is TunnelState.ABORTING:
|
|
487
|
+
self._lifecycle.finish_abort()
|
|
488
|
+
self._close_channel()
|
|
489
|
+
self._finished.set()
|
|
490
|
+
|
|
491
|
+
async def _pump_input(self) -> None:
|
|
492
|
+
while True:
|
|
493
|
+
item = await self._input.get()
|
|
494
|
+
if item is _INPUT_EOF:
|
|
495
|
+
await self._send_eof()
|
|
496
|
+
return
|
|
497
|
+
|
|
498
|
+
if not isinstance(item, bytes):
|
|
499
|
+
raise RuntimeError("TCP tunnel input queue contained an invalid item")
|
|
500
|
+
batch = bytearray(item)
|
|
501
|
+
saw_eof = False
|
|
502
|
+
while not self._input.empty():
|
|
503
|
+
queued = self._input.get_nowait()
|
|
504
|
+
if queued is _INPUT_EOF:
|
|
505
|
+
saw_eof = True
|
|
506
|
+
break
|
|
507
|
+
if not isinstance(queued, bytes):
|
|
508
|
+
raise RuntimeError("TCP tunnel input queue contained an invalid item")
|
|
509
|
+
batch.extend(queued)
|
|
510
|
+
try:
|
|
511
|
+
for offset in range(0, len(batch), TUNNEL_WRITE_BATCH_BYTES):
|
|
512
|
+
await self._send_data(bytes(batch[offset : offset + TUNNEL_WRITE_BATCH_BYTES]))
|
|
513
|
+
finally:
|
|
514
|
+
self._input_buffered_bytes -= len(batch)
|
|
515
|
+
self._resume_input_if_ready()
|
|
516
|
+
if saw_eof:
|
|
517
|
+
await self._send_eof()
|
|
518
|
+
return
|
|
519
|
+
|
|
520
|
+
async def _send_data(self, payload: bytes) -> None:
|
|
521
|
+
frames = []
|
|
522
|
+
for offset in range(0, len(payload), UPSTREAM_FRAME_BYTES):
|
|
523
|
+
frames.append(
|
|
524
|
+
TunnelFrame(
|
|
525
|
+
Direction.UPSTREAM,
|
|
526
|
+
self._upstream_sequence,
|
|
527
|
+
FrameKind.DATA,
|
|
528
|
+
payload=payload[offset : offset + UPSTREAM_FRAME_BYTES],
|
|
529
|
+
)
|
|
530
|
+
)
|
|
531
|
+
self._upstream_sequence += 1
|
|
532
|
+
await self._write_frames(frames)
|
|
533
|
+
|
|
534
|
+
async def _send_eof(self) -> None:
|
|
535
|
+
frame = TunnelFrame(Direction.UPSTREAM, self._upstream_sequence, FrameKind.EOF)
|
|
536
|
+
self._upstream_sequence += 1
|
|
537
|
+
await self._write_frames([frame])
|
|
538
|
+
self._lifecycle.mark_local_eof()
|
|
539
|
+
|
|
540
|
+
async def _write_frames(self, frames: list[TunnelFrame]) -> None:
|
|
541
|
+
payload = b"".join(self._codec.encode(frame) for frame in frames)
|
|
542
|
+
await asyncio.to_thread(
|
|
543
|
+
self._client.shell_write,
|
|
544
|
+
self._lease.session_id,
|
|
545
|
+
payload,
|
|
546
|
+
owner="mock-ssh",
|
|
547
|
+
)
|
|
548
|
+
|
|
549
|
+
async def _pump_output(self) -> None:
|
|
550
|
+
if self._channel is None:
|
|
551
|
+
raise RuntimeError("TCP tunnel channel was not initialized")
|
|
552
|
+
while True:
|
|
553
|
+
frame = await self._frames.next()
|
|
554
|
+
if frame.kind is FrameKind.DATA:
|
|
555
|
+
if self._lifecycle.state not in {TunnelState.OPEN, TunnelState.LOCAL_EOF}:
|
|
556
|
+
raise HostBridgeError("protocol_mismatch", "remote TCP data arrived after EOF")
|
|
557
|
+
await self._write_ready.wait()
|
|
558
|
+
self._channel.write(frame.payload)
|
|
559
|
+
elif frame.kind is FrameKind.EOF:
|
|
560
|
+
self._lifecycle.mark_remote_eof()
|
|
561
|
+
self._channel.write_eof()
|
|
562
|
+
elif frame.kind is FrameKind.ERROR:
|
|
563
|
+
raise HostBridgeError(
|
|
564
|
+
"connection_lost",
|
|
565
|
+
frame.payload.decode("utf-8", errors="replace") or "remote TCP tunnel failed",
|
|
566
|
+
retryable=True,
|
|
567
|
+
)
|
|
568
|
+
elif frame.kind is FrameKind.CLOSED:
|
|
569
|
+
assert frame.status is not None
|
|
570
|
+
self._lifecycle.mark_closed(frame.status)
|
|
571
|
+
if frame.status != 0:
|
|
572
|
+
raise HostBridgeError(
|
|
573
|
+
"connection_lost",
|
|
574
|
+
f"remote TCP tunnel exited with status {frame.status}",
|
|
575
|
+
retryable=True,
|
|
576
|
+
)
|
|
577
|
+
return
|
|
578
|
+
else:
|
|
579
|
+
raise HostBridgeError(
|
|
580
|
+
"protocol_mismatch",
|
|
581
|
+
f"unexpected {frame.kind.value} frame after READY",
|
|
582
|
+
)
|
|
583
|
+
|
|
584
|
+
def _resume_input_if_ready(self) -> None:
|
|
585
|
+
if self._input_paused and self._input_buffered_bytes <= TUNNEL_LOW_WATER_BYTES and self._channel is not None:
|
|
586
|
+
self._channel.resume_reading()
|
|
587
|
+
self._input_paused = False
|
|
588
|
+
|
|
589
|
+
def _close_channel(self) -> None:
|
|
590
|
+
if self._channel is not None:
|
|
591
|
+
with suppress(Exception):
|
|
592
|
+
self._channel.close()
|