@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
|
@@ -0,0 +1,1245 @@
|
|
|
1
|
+
"""Daemon-owned lifecycle management for persistent host tunnel providers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import logging
|
|
7
|
+
import random
|
|
8
|
+
import time
|
|
9
|
+
import uuid
|
|
10
|
+
from collections.abc import AsyncGenerator, Awaitable, Callable
|
|
11
|
+
from contextlib import suppress
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from ipaddress import ip_address, ip_network
|
|
14
|
+
from typing import Protocol, cast
|
|
15
|
+
|
|
16
|
+
from .hosts import ForwardingPolicy, HostConfig, HostRegistry
|
|
17
|
+
from .transports.base import (
|
|
18
|
+
ByteStream,
|
|
19
|
+
ExecResult,
|
|
20
|
+
ShellAttachment,
|
|
21
|
+
ShellReadResult,
|
|
22
|
+
TransferResult,
|
|
23
|
+
TransportCapabilities,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class TunnelError(RuntimeError):
|
|
28
|
+
def __init__(self, code: str, message: str, *, retryable: bool = False) -> None:
|
|
29
|
+
super().__init__(message)
|
|
30
|
+
self.code = code
|
|
31
|
+
self.retryable = retryable
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class TunnelPolicyError(TunnelError):
|
|
35
|
+
def __init__(self, message: str) -> None:
|
|
36
|
+
super().__init__("denied", message)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class TunnelCapacityError(TunnelError):
|
|
40
|
+
def __init__(self, message: str) -> None:
|
|
41
|
+
super().__init__("busy", message, retryable=True)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class TunnelBackendError(TunnelError):
|
|
45
|
+
def __init__(self, message: str) -> None:
|
|
46
|
+
super().__init__("connection_lost", message, retryable=True)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class TunnelTimeoutError(TunnelError):
|
|
50
|
+
def __init__(self, message: str) -> None:
|
|
51
|
+
super().__init__("timeout", message, retryable=True)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class TunnelManagerClosed(TunnelError):
|
|
55
|
+
def __init__(self) -> None:
|
|
56
|
+
super().__init__("connection_lost", "host tunnel manager is closed")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class TunnelProviderAbortError(RuntimeError):
|
|
60
|
+
"""Raised when a provider violates the no-throw abort contract."""
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class TunnelUnsupportedError(TunnelError):
|
|
64
|
+
def __init__(self, message: str) -> None:
|
|
65
|
+
super().__init__("unsupported_transport", message)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class TunnelProviderEndpoint(Protocol):
|
|
69
|
+
provider_stream_id: int
|
|
70
|
+
|
|
71
|
+
async def send(self, data: bytes) -> None: ...
|
|
72
|
+
|
|
73
|
+
async def send_eof(self) -> None: ...
|
|
74
|
+
|
|
75
|
+
async def receive(self, max_bytes: int = 64 * 1024) -> bytes | None: ...
|
|
76
|
+
|
|
77
|
+
async def close(self) -> None: ...
|
|
78
|
+
|
|
79
|
+
async def reset(self, reason: str) -> None: ...
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class TunnelProviderRuntime(Protocol):
|
|
83
|
+
provider_name: str
|
|
84
|
+
|
|
85
|
+
@property
|
|
86
|
+
def queued_bytes(self) -> int: ...
|
|
87
|
+
|
|
88
|
+
@property
|
|
89
|
+
def agent_pid(self) -> int | None: ...
|
|
90
|
+
|
|
91
|
+
async def start(self) -> None: ...
|
|
92
|
+
|
|
93
|
+
async def open_stream(
|
|
94
|
+
self,
|
|
95
|
+
host: str,
|
|
96
|
+
port: int,
|
|
97
|
+
policy: ForwardingPolicy,
|
|
98
|
+
) -> TunnelProviderEndpoint: ...
|
|
99
|
+
|
|
100
|
+
async def wait_closed(self) -> BaseException | None: ...
|
|
101
|
+
|
|
102
|
+
async def close(self) -> None: ...
|
|
103
|
+
|
|
104
|
+
def abort(self) -> None: ...
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class HostOperationProvider(Protocol):
|
|
108
|
+
@property
|
|
109
|
+
def capabilities(self) -> TransportCapabilities: ...
|
|
110
|
+
|
|
111
|
+
async def exec(
|
|
112
|
+
self,
|
|
113
|
+
command: str,
|
|
114
|
+
*,
|
|
115
|
+
stdin: ByteStream | None,
|
|
116
|
+
timeout: float,
|
|
117
|
+
output_limit: int,
|
|
118
|
+
) -> ExecResult: ...
|
|
119
|
+
|
|
120
|
+
async def upload(
|
|
121
|
+
self,
|
|
122
|
+
chunks: ByteStream,
|
|
123
|
+
remote_path: str,
|
|
124
|
+
*,
|
|
125
|
+
mode: int,
|
|
126
|
+
expected_size: int | None,
|
|
127
|
+
expected_sha256: str | None,
|
|
128
|
+
) -> TransferResult: ...
|
|
129
|
+
|
|
130
|
+
def download(self, remote_path: str, *, chunk_size: int) -> AsyncGenerator[bytes, None]: ...
|
|
131
|
+
|
|
132
|
+
async def shell_write(self, shell_id: str, data: bytes) -> None: ...
|
|
133
|
+
|
|
134
|
+
async def shell_read(self, shell_id: str, *, timeout: float | None, max_bytes: int) -> ShellReadResult: ...
|
|
135
|
+
|
|
136
|
+
async def attach_shell(self, shell_id: str, stdin: ByteStream, *, max_bytes: int) -> ShellAttachment: ...
|
|
137
|
+
|
|
138
|
+
async def shell_close(self, shell_id: str) -> None: ...
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
TunnelProviderFactory = Callable[[HostConfig, str, int], TunnelProviderRuntime]
|
|
142
|
+
TunnelEventSink = Callable[[dict[str, object]], None]
|
|
143
|
+
_StreamKey = tuple[str, int]
|
|
144
|
+
_LOGGER = logging.getLogger(__name__)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
@dataclass(slots=True)
|
|
148
|
+
class _TunnelMetrics:
|
|
149
|
+
bytes_sent: int = 0
|
|
150
|
+
bytes_received: int = 0
|
|
151
|
+
streams_opened: int = 0
|
|
152
|
+
streams_closed: int = 0
|
|
153
|
+
streams_reset: int = 0
|
|
154
|
+
stream_timeouts: int = 0
|
|
155
|
+
queue_high_water_bytes: int = 0
|
|
156
|
+
window_stalls: int = 0
|
|
157
|
+
protocol_failures: int = 0
|
|
158
|
+
last_open_latency_ms: float = 0.0
|
|
159
|
+
cleanup_errors: int = 0
|
|
160
|
+
|
|
161
|
+
def describe(self) -> dict[str, object]:
|
|
162
|
+
return {
|
|
163
|
+
"bytes_sent": self.bytes_sent,
|
|
164
|
+
"bytes_received": self.bytes_received,
|
|
165
|
+
"streams_opened": self.streams_opened,
|
|
166
|
+
"streams_closed": self.streams_closed,
|
|
167
|
+
"streams_reset": self.streams_reset,
|
|
168
|
+
"stream_timeouts": self.stream_timeouts,
|
|
169
|
+
"queue_high_water_bytes": self.queue_high_water_bytes,
|
|
170
|
+
"window_stalls": self.window_stalls,
|
|
171
|
+
"protocol_failures": self.protocol_failures,
|
|
172
|
+
"last_open_latency_ms": self.last_open_latency_ms,
|
|
173
|
+
"cleanup_errors": self.cleanup_errors,
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
class TunnelStreamHandle:
|
|
178
|
+
def __init__(
|
|
179
|
+
self,
|
|
180
|
+
manager: HostTunnelManager,
|
|
181
|
+
*,
|
|
182
|
+
host_id: str,
|
|
183
|
+
client_connection_id: str,
|
|
184
|
+
client_stream_id: int,
|
|
185
|
+
runtime_generation: int,
|
|
186
|
+
endpoint: TunnelProviderEndpoint,
|
|
187
|
+
idle_timeout_seconds: float,
|
|
188
|
+
stream_deadline_seconds: float,
|
|
189
|
+
destination_host: str,
|
|
190
|
+
destination_port: int,
|
|
191
|
+
policy_rule: str,
|
|
192
|
+
opened_at: float,
|
|
193
|
+
) -> None:
|
|
194
|
+
self._manager = manager
|
|
195
|
+
self.host_id = host_id
|
|
196
|
+
self.client_connection_id = client_connection_id
|
|
197
|
+
self.client_stream_id = client_stream_id
|
|
198
|
+
self.runtime_generation = runtime_generation
|
|
199
|
+
self._endpoint = endpoint
|
|
200
|
+
self.destination_host = destination_host
|
|
201
|
+
self.destination_port = destination_port
|
|
202
|
+
self.policy_rule = policy_rule
|
|
203
|
+
self.opened_at = opened_at
|
|
204
|
+
self.bytes_sent = 0
|
|
205
|
+
self.bytes_received = 0
|
|
206
|
+
self._local_eof = False
|
|
207
|
+
self._remote_eof = False
|
|
208
|
+
loop = asyncio.get_running_loop()
|
|
209
|
+
self._terminal: asyncio.Future[None] = loop.create_future()
|
|
210
|
+
self._idle_timeout_seconds = idle_timeout_seconds
|
|
211
|
+
self._last_activity = loop.time()
|
|
212
|
+
self._deadline = self._last_activity + stream_deadline_seconds
|
|
213
|
+
self._activity = asyncio.Event()
|
|
214
|
+
self._timeout_task = asyncio.create_task(
|
|
215
|
+
self._watch_timeouts(),
|
|
216
|
+
name=f"hostbridge-tunnel-stream-timeout-{host_id}-{runtime_generation}-{client_stream_id}",
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
@property
|
|
220
|
+
def provider_stream_id(self) -> int:
|
|
221
|
+
return self._endpoint.provider_stream_id
|
|
222
|
+
|
|
223
|
+
@property
|
|
224
|
+
def endpoint(self) -> TunnelProviderEndpoint:
|
|
225
|
+
return self._endpoint
|
|
226
|
+
|
|
227
|
+
@property
|
|
228
|
+
def closed(self) -> bool:
|
|
229
|
+
return self._terminal.done()
|
|
230
|
+
|
|
231
|
+
async def send(self, data: bytes) -> None:
|
|
232
|
+
if self.closed:
|
|
233
|
+
raise TunnelBackendError("tunnel stream is closed")
|
|
234
|
+
await self._endpoint.send(data)
|
|
235
|
+
if data:
|
|
236
|
+
self.bytes_sent += len(data)
|
|
237
|
+
self._manager._record_stream_bytes(self, sent=len(data))
|
|
238
|
+
self._record_activity()
|
|
239
|
+
|
|
240
|
+
async def send_eof(self) -> None:
|
|
241
|
+
if self.closed or self._local_eof:
|
|
242
|
+
return
|
|
243
|
+
await self._endpoint.send_eof()
|
|
244
|
+
self._local_eof = True
|
|
245
|
+
self._manager._record_stream_half_close(self, "to_destination")
|
|
246
|
+
self._record_activity()
|
|
247
|
+
|
|
248
|
+
async def receive(self, max_bytes: int = 64 * 1024) -> bytes | None:
|
|
249
|
+
if self.closed:
|
|
250
|
+
await self.wait_closed()
|
|
251
|
+
return None
|
|
252
|
+
if self._remote_eof:
|
|
253
|
+
return None
|
|
254
|
+
data = await self._endpoint.receive(max_bytes)
|
|
255
|
+
if data:
|
|
256
|
+
self.bytes_received += len(data)
|
|
257
|
+
self._manager._record_stream_bytes(self, received=len(data))
|
|
258
|
+
self._record_activity()
|
|
259
|
+
elif data is None:
|
|
260
|
+
self._remote_eof = True
|
|
261
|
+
self._manager._record_stream_half_close(self, "from_destination")
|
|
262
|
+
self._record_activity()
|
|
263
|
+
return data
|
|
264
|
+
|
|
265
|
+
async def close(self) -> None:
|
|
266
|
+
await self._manager._close_stream(self, reset_reason=None)
|
|
267
|
+
|
|
268
|
+
async def reset(self, reason: str = "stream reset") -> None:
|
|
269
|
+
await self._manager._close_stream(self, reset_reason=reason)
|
|
270
|
+
|
|
271
|
+
async def wait_closed(self) -> None:
|
|
272
|
+
await asyncio.shield(self._terminal)
|
|
273
|
+
|
|
274
|
+
def _finish(self, error: BaseException | None = None) -> None:
|
|
275
|
+
if self._terminal.done():
|
|
276
|
+
return
|
|
277
|
+
timeout_task = self._timeout_task
|
|
278
|
+
if timeout_task is not asyncio.current_task():
|
|
279
|
+
timeout_task.cancel()
|
|
280
|
+
self._activity.set()
|
|
281
|
+
if error is None:
|
|
282
|
+
self._terminal.set_result(None)
|
|
283
|
+
else:
|
|
284
|
+
self._terminal.set_exception(error)
|
|
285
|
+
self._terminal.exception()
|
|
286
|
+
|
|
287
|
+
def _record_activity(self) -> None:
|
|
288
|
+
self._last_activity = asyncio.get_running_loop().time()
|
|
289
|
+
self._activity.set()
|
|
290
|
+
|
|
291
|
+
async def _watch_timeouts(self) -> None:
|
|
292
|
+
loop = asyncio.get_running_loop()
|
|
293
|
+
try:
|
|
294
|
+
while not self.closed:
|
|
295
|
+
self._activity.clear()
|
|
296
|
+
now = loop.time()
|
|
297
|
+
idle_remaining = self._idle_timeout_seconds - (now - self._last_activity)
|
|
298
|
+
deadline_remaining = self._deadline - now
|
|
299
|
+
remaining = min(idle_remaining, deadline_remaining)
|
|
300
|
+
if remaining <= 0:
|
|
301
|
+
reason = "deadline" if deadline_remaining <= idle_remaining else "idle"
|
|
302
|
+
await self._manager._timeout_stream(self, reason)
|
|
303
|
+
return
|
|
304
|
+
try:
|
|
305
|
+
await asyncio.wait_for(self._activity.wait(), timeout=remaining)
|
|
306
|
+
except TimeoutError:
|
|
307
|
+
continue
|
|
308
|
+
except asyncio.CancelledError:
|
|
309
|
+
return
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
class HostRuntimeLease:
|
|
313
|
+
def __init__(
|
|
314
|
+
self,
|
|
315
|
+
manager: HostTunnelManager,
|
|
316
|
+
*,
|
|
317
|
+
host_id: str,
|
|
318
|
+
generation: int,
|
|
319
|
+
token: str,
|
|
320
|
+
capabilities: TransportCapabilities,
|
|
321
|
+
) -> None:
|
|
322
|
+
self._manager = manager
|
|
323
|
+
self.host_id = host_id
|
|
324
|
+
self.generation = generation
|
|
325
|
+
self.capabilities = capabilities
|
|
326
|
+
self._token = token
|
|
327
|
+
self._closed = False
|
|
328
|
+
self._close_task: asyncio.Task[None] | None = None
|
|
329
|
+
|
|
330
|
+
@property
|
|
331
|
+
def closed(self) -> bool:
|
|
332
|
+
return self._closed
|
|
333
|
+
|
|
334
|
+
async def exec(
|
|
335
|
+
self,
|
|
336
|
+
command: str,
|
|
337
|
+
*,
|
|
338
|
+
stdin: ByteStream | None,
|
|
339
|
+
timeout: float,
|
|
340
|
+
output_limit: int,
|
|
341
|
+
) -> ExecResult:
|
|
342
|
+
provider = await self._provider()
|
|
343
|
+
return await provider.exec(command, stdin=stdin, timeout=timeout, output_limit=output_limit)
|
|
344
|
+
|
|
345
|
+
async def upload(
|
|
346
|
+
self,
|
|
347
|
+
chunks: ByteStream,
|
|
348
|
+
remote_path: str,
|
|
349
|
+
*,
|
|
350
|
+
mode: int,
|
|
351
|
+
expected_size: int | None,
|
|
352
|
+
expected_sha256: str | None,
|
|
353
|
+
) -> TransferResult:
|
|
354
|
+
provider = await self._provider()
|
|
355
|
+
return await provider.upload(
|
|
356
|
+
chunks,
|
|
357
|
+
remote_path,
|
|
358
|
+
mode=mode,
|
|
359
|
+
expected_size=expected_size,
|
|
360
|
+
expected_sha256=expected_sha256,
|
|
361
|
+
)
|
|
362
|
+
|
|
363
|
+
async def download(self, remote_path: str, *, chunk_size: int) -> AsyncGenerator[bytes, None]:
|
|
364
|
+
provider = await self._provider()
|
|
365
|
+
async for chunk in provider.download(remote_path, chunk_size=chunk_size):
|
|
366
|
+
yield chunk
|
|
367
|
+
|
|
368
|
+
async def shell_write(self, data: bytes) -> None:
|
|
369
|
+
provider = await self._provider()
|
|
370
|
+
await provider.shell_write(self._token, data)
|
|
371
|
+
|
|
372
|
+
async def shell_read(self, *, timeout: float | None, max_bytes: int) -> ShellReadResult:
|
|
373
|
+
provider = await self._provider()
|
|
374
|
+
return await provider.shell_read(self._token, timeout=timeout, max_bytes=max_bytes)
|
|
375
|
+
|
|
376
|
+
async def attach_shell(self, stdin: ByteStream, *, max_bytes: int) -> ShellAttachment:
|
|
377
|
+
provider = await self._provider()
|
|
378
|
+
return await provider.attach_shell(self._token, stdin, max_bytes=max_bytes)
|
|
379
|
+
|
|
380
|
+
async def close(self) -> None:
|
|
381
|
+
task = self._close_task
|
|
382
|
+
if task is None:
|
|
383
|
+
task = asyncio.create_task(
|
|
384
|
+
self._close_owned_shell(),
|
|
385
|
+
name=f"hostbridge-runtime-lease-close-{self.host_id}-{self.generation}",
|
|
386
|
+
)
|
|
387
|
+
task.add_done_callback(_consume_task_result)
|
|
388
|
+
self._close_task = task
|
|
389
|
+
await asyncio.shield(task)
|
|
390
|
+
|
|
391
|
+
async def _close_owned_shell(self) -> None:
|
|
392
|
+
try:
|
|
393
|
+
provider = await self._manager._lease_provider(self)
|
|
394
|
+
await provider.shell_close(self._token)
|
|
395
|
+
finally:
|
|
396
|
+
self._closed = True
|
|
397
|
+
await self._manager._release_lease(self)
|
|
398
|
+
|
|
399
|
+
async def _provider(self) -> HostOperationProvider:
|
|
400
|
+
if self._closed or self._close_task is not None:
|
|
401
|
+
raise TunnelBackendError("host runtime lease is closed")
|
|
402
|
+
return await self._manager._lease_provider(self)
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
@dataclass(slots=True)
|
|
406
|
+
class _HostRuntime:
|
|
407
|
+
host: HostConfig
|
|
408
|
+
state: str = "disconnected"
|
|
409
|
+
generation: int = 0
|
|
410
|
+
provider: TunnelProviderRuntime | None = None
|
|
411
|
+
connect_task: asyncio.Task[TunnelProviderRuntime] | None = None
|
|
412
|
+
monitor_task: asyncio.Task[None] | None = None
|
|
413
|
+
idle_task: asyncio.Task[None] | None = None
|
|
414
|
+
streams: dict[_StreamKey, TunnelStreamHandle] = field(default_factory=dict)
|
|
415
|
+
reservations: set[_StreamKey] = field(default_factory=set)
|
|
416
|
+
leases: dict[str, int] = field(default_factory=dict)
|
|
417
|
+
last_error: str | None = None
|
|
418
|
+
connected_at: float | None = None
|
|
419
|
+
consecutive_failures: int = 0
|
|
420
|
+
reconnect_count: int = 0
|
|
421
|
+
reconnect_delay_seconds: float = 0.0
|
|
422
|
+
metrics: _TunnelMetrics = field(default_factory=_TunnelMetrics)
|
|
423
|
+
sampled_window_stalls: int = 0
|
|
424
|
+
sampled_protocol_failures: int = 0
|
|
425
|
+
lock: asyncio.Lock = field(default_factory=asyncio.Lock)
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
class HostTunnelManager:
|
|
429
|
+
def __init__(
|
|
430
|
+
self,
|
|
431
|
+
hosts: HostRegistry,
|
|
432
|
+
provider_factory: TunnelProviderFactory,
|
|
433
|
+
*,
|
|
434
|
+
sleep: Callable[[float], Awaitable[None]] = asyncio.sleep,
|
|
435
|
+
jitter: Callable[[float], float] | None = None,
|
|
436
|
+
event_sink: TunnelEventSink | None = None,
|
|
437
|
+
cleanup_timeout_seconds: float = 5,
|
|
438
|
+
) -> None:
|
|
439
|
+
if cleanup_timeout_seconds <= 0:
|
|
440
|
+
raise ValueError("cleanup_timeout_seconds must be positive")
|
|
441
|
+
self._hosts = hosts
|
|
442
|
+
self._provider_factory = provider_factory
|
|
443
|
+
self._sleep = sleep
|
|
444
|
+
self._jitter = jitter or _default_reconnect_jitter
|
|
445
|
+
self._event_sink = event_sink
|
|
446
|
+
self._cleanup_timeout_seconds = float(cleanup_timeout_seconds)
|
|
447
|
+
self._runtimes: dict[str, _HostRuntime] = {}
|
|
448
|
+
self._provider_cleanup_tasks: set[asyncio.Task[None]] = set()
|
|
449
|
+
self._closed = False
|
|
450
|
+
self._close_task: asyncio.Task[None] | None = None
|
|
451
|
+
|
|
452
|
+
@property
|
|
453
|
+
def hosts(self) -> HostRegistry:
|
|
454
|
+
return self._hosts
|
|
455
|
+
|
|
456
|
+
async def acquire(self, host_id: str) -> HostRuntimeLease:
|
|
457
|
+
if self._closed:
|
|
458
|
+
raise TunnelManagerClosed()
|
|
459
|
+
host = self._hosts.get(host_id)
|
|
460
|
+
provider_name = _provider_name(host)
|
|
461
|
+
runtime = self._runtimes.setdefault(host.id, _HostRuntime(host))
|
|
462
|
+
while True:
|
|
463
|
+
provider, generation = await self._ensure_provider(runtime, provider_name)
|
|
464
|
+
_host_operation_provider(provider)
|
|
465
|
+
async with runtime.lock:
|
|
466
|
+
if self._closed:
|
|
467
|
+
raise TunnelManagerClosed()
|
|
468
|
+
if runtime.state != "ready" or runtime.provider is not provider or runtime.generation != generation:
|
|
469
|
+
continue
|
|
470
|
+
self._cancel_idle_locked(runtime)
|
|
471
|
+
token = uuid.uuid4().hex
|
|
472
|
+
runtime.leases[token] = generation
|
|
473
|
+
return HostRuntimeLease(
|
|
474
|
+
self,
|
|
475
|
+
host_id=host.id,
|
|
476
|
+
generation=generation,
|
|
477
|
+
token=token,
|
|
478
|
+
capabilities=_host_operation_provider(provider).capabilities,
|
|
479
|
+
)
|
|
480
|
+
|
|
481
|
+
async def open_stream(
|
|
482
|
+
self,
|
|
483
|
+
host_id: str,
|
|
484
|
+
client_connection_id: str,
|
|
485
|
+
client_stream_id: int,
|
|
486
|
+
destination_host: str,
|
|
487
|
+
destination_port: int,
|
|
488
|
+
) -> TunnelStreamHandle:
|
|
489
|
+
if self._closed:
|
|
490
|
+
raise TunnelManagerClosed()
|
|
491
|
+
if not isinstance(client_connection_id, str) or not client_connection_id:
|
|
492
|
+
raise ValueError("client_connection_id must be a non-empty string")
|
|
493
|
+
if not isinstance(client_stream_id, int) or isinstance(client_stream_id, bool) or client_stream_id <= 0:
|
|
494
|
+
raise ValueError("client_stream_id must be a positive integer")
|
|
495
|
+
host = self._hosts.get(host_id)
|
|
496
|
+
provider_name = _provider_name(host)
|
|
497
|
+
policy_rule = _authorize_destination(host.forwarding, provider_name, destination_host, destination_port)
|
|
498
|
+
runtime = self._runtimes.setdefault(host.id, _HostRuntime(host))
|
|
499
|
+
key = (client_connection_id, client_stream_id)
|
|
500
|
+
open_started = time.monotonic()
|
|
501
|
+
|
|
502
|
+
while True:
|
|
503
|
+
provider, generation = await self._ensure_provider(runtime, provider_name)
|
|
504
|
+
async with runtime.lock:
|
|
505
|
+
if self._closed:
|
|
506
|
+
raise TunnelManagerClosed()
|
|
507
|
+
if runtime.state != "ready" or runtime.provider is not provider or runtime.generation != generation:
|
|
508
|
+
continue
|
|
509
|
+
self._cancel_idle_locked(runtime)
|
|
510
|
+
if key in runtime.streams or key in runtime.reservations:
|
|
511
|
+
raise TunnelCapacityError("client stream already exists")
|
|
512
|
+
policy = runtime.host.forwarding
|
|
513
|
+
if len(runtime.streams) + len(runtime.reservations) >= policy.max_streams:
|
|
514
|
+
raise TunnelCapacityError(f"host tunnel stream limit is {policy.max_streams}")
|
|
515
|
+
runtime.reservations.add(key)
|
|
516
|
+
break
|
|
517
|
+
|
|
518
|
+
try:
|
|
519
|
+
endpoint = await provider.open_stream(destination_host, destination_port, runtime.host.forwarding)
|
|
520
|
+
except asyncio.CancelledError:
|
|
521
|
+
await self._release_reservation(runtime, key, generation)
|
|
522
|
+
raise
|
|
523
|
+
except TunnelError:
|
|
524
|
+
await self._release_reservation(runtime, key, generation)
|
|
525
|
+
raise
|
|
526
|
+
except Exception as exc:
|
|
527
|
+
await self._release_reservation(runtime, key, generation)
|
|
528
|
+
raise TunnelBackendError(f"failed to open destination stream: {exc}") from exc
|
|
529
|
+
|
|
530
|
+
stale = False
|
|
531
|
+
async with runtime.lock:
|
|
532
|
+
runtime.reservations.discard(key)
|
|
533
|
+
if (
|
|
534
|
+
self._closed
|
|
535
|
+
or runtime.state != "ready"
|
|
536
|
+
or runtime.provider is not provider
|
|
537
|
+
or runtime.generation != generation
|
|
538
|
+
):
|
|
539
|
+
stale = True
|
|
540
|
+
else:
|
|
541
|
+
handle = TunnelStreamHandle(
|
|
542
|
+
self,
|
|
543
|
+
host_id=host.id,
|
|
544
|
+
client_connection_id=client_connection_id,
|
|
545
|
+
client_stream_id=client_stream_id,
|
|
546
|
+
runtime_generation=generation,
|
|
547
|
+
endpoint=endpoint,
|
|
548
|
+
idle_timeout_seconds=policy.idle_timeout_seconds,
|
|
549
|
+
stream_deadline_seconds=policy.stream_deadline_seconds,
|
|
550
|
+
destination_host=destination_host,
|
|
551
|
+
destination_port=destination_port,
|
|
552
|
+
policy_rule=policy_rule,
|
|
553
|
+
opened_at=time.monotonic(),
|
|
554
|
+
)
|
|
555
|
+
runtime.streams[key] = handle
|
|
556
|
+
if stale:
|
|
557
|
+
await self._cleanup_endpoint(endpoint, reset_reason="backend generation changed while opening stream")
|
|
558
|
+
if self._closed:
|
|
559
|
+
raise TunnelManagerClosed()
|
|
560
|
+
raise TunnelBackendError("backend generation changed while opening stream")
|
|
561
|
+
open_latency_ms = (time.monotonic() - open_started) * 1000
|
|
562
|
+
runtime.metrics.streams_opened += 1
|
|
563
|
+
runtime.metrics.last_open_latency_ms = open_latency_ms
|
|
564
|
+
self._sample_provider_metrics(runtime)
|
|
565
|
+
self._emit(
|
|
566
|
+
"tunnel.stream.opened",
|
|
567
|
+
runtime,
|
|
568
|
+
stream_id=client_stream_id,
|
|
569
|
+
provider_stream_id=handle.provider_stream_id,
|
|
570
|
+
destination_host=destination_host,
|
|
571
|
+
destination_port=destination_port,
|
|
572
|
+
policy_rule=policy_rule,
|
|
573
|
+
open_latency_ms=open_latency_ms,
|
|
574
|
+
)
|
|
575
|
+
return handle
|
|
576
|
+
|
|
577
|
+
def health(self, host_id: str) -> dict[str, object]:
|
|
578
|
+
host = self._hosts.get(host_id)
|
|
579
|
+
runtime = self._runtimes.get(host.id)
|
|
580
|
+
if runtime is None:
|
|
581
|
+
return {
|
|
582
|
+
"host_id": host.id,
|
|
583
|
+
"enabled": host.forwarding.enabled,
|
|
584
|
+
"state": "disconnected",
|
|
585
|
+
"provider": _provider_name(host),
|
|
586
|
+
"generation": 0,
|
|
587
|
+
"stream_count": 0,
|
|
588
|
+
"lease_count": 0,
|
|
589
|
+
"queue_bytes": 0,
|
|
590
|
+
"last_error": None,
|
|
591
|
+
"connected_at": None,
|
|
592
|
+
"agent_pid": None,
|
|
593
|
+
"reconnect_count": 0,
|
|
594
|
+
"reconnect_delay_seconds": 0.0,
|
|
595
|
+
"metrics": _TunnelMetrics().describe(),
|
|
596
|
+
}
|
|
597
|
+
provider = runtime.provider
|
|
598
|
+
self._sample_provider_metrics(runtime)
|
|
599
|
+
return {
|
|
600
|
+
"host_id": host.id,
|
|
601
|
+
"enabled": host.forwarding.enabled,
|
|
602
|
+
"state": runtime.state,
|
|
603
|
+
"provider": _provider_name(host),
|
|
604
|
+
"generation": runtime.generation,
|
|
605
|
+
"stream_count": len(runtime.streams),
|
|
606
|
+
"lease_count": self._lease_count(runtime, runtime.generation),
|
|
607
|
+
"queue_bytes": 0 if provider is None else getattr(provider, "queued_bytes", 0),
|
|
608
|
+
"last_error": runtime.last_error,
|
|
609
|
+
"connected_at": runtime.connected_at,
|
|
610
|
+
"agent_pid": None if provider is None else getattr(provider, "agent_pid", None),
|
|
611
|
+
"reconnect_count": runtime.reconnect_count,
|
|
612
|
+
"reconnect_delay_seconds": runtime.reconnect_delay_seconds,
|
|
613
|
+
"metrics": runtime.metrics.describe(),
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
async def close(self) -> None:
|
|
617
|
+
task = self._close_task
|
|
618
|
+
if task is None:
|
|
619
|
+
self._closed = True
|
|
620
|
+
task = asyncio.create_task(
|
|
621
|
+
self._close_owned_resources(),
|
|
622
|
+
name="hostbridge-tunnel-manager-close",
|
|
623
|
+
)
|
|
624
|
+
task.add_done_callback(_consume_task_result)
|
|
625
|
+
self._close_task = task
|
|
626
|
+
await asyncio.shield(task)
|
|
627
|
+
|
|
628
|
+
async def _close_owned_resources(self) -> None:
|
|
629
|
+
tasks: list[asyncio.Task[object]] = []
|
|
630
|
+
providers: list[tuple[_HostRuntime, TunnelProviderRuntime]] = []
|
|
631
|
+
handles: list[TunnelStreamHandle] = []
|
|
632
|
+
runtimes = list(self._runtimes.values())
|
|
633
|
+
for runtime in runtimes:
|
|
634
|
+
async with runtime.lock:
|
|
635
|
+
monitor_is_cleaning_failure = runtime.state == "failed"
|
|
636
|
+
runtime.state = "draining"
|
|
637
|
+
if runtime.connect_task is not None:
|
|
638
|
+
runtime.connect_task.cancel()
|
|
639
|
+
tasks.append(runtime.connect_task)
|
|
640
|
+
runtime.connect_task = None
|
|
641
|
+
if runtime.monitor_task is not None:
|
|
642
|
+
if not monitor_is_cleaning_failure:
|
|
643
|
+
runtime.monitor_task.cancel()
|
|
644
|
+
tasks.append(runtime.monitor_task)
|
|
645
|
+
runtime.monitor_task = None
|
|
646
|
+
if runtime.idle_task is not None:
|
|
647
|
+
runtime.idle_task.cancel()
|
|
648
|
+
tasks.append(runtime.idle_task)
|
|
649
|
+
runtime.idle_task = None
|
|
650
|
+
if runtime.provider is not None:
|
|
651
|
+
providers.append((runtime, runtime.provider))
|
|
652
|
+
runtime.provider = None
|
|
653
|
+
handles.extend(runtime.streams.values())
|
|
654
|
+
runtime.streams.clear()
|
|
655
|
+
runtime.reservations.clear()
|
|
656
|
+
runtime.leases.clear()
|
|
657
|
+
error = TunnelManagerClosed()
|
|
658
|
+
await asyncio.gather(
|
|
659
|
+
*(self._finalize_detached_stream(self._runtimes[handle.host_id], handle, error) for handle in handles),
|
|
660
|
+
return_exceptions=True,
|
|
661
|
+
)
|
|
662
|
+
cleanup_results = await asyncio.gather(
|
|
663
|
+
*(self._close_provider(runtime, provider) for runtime, provider in providers),
|
|
664
|
+
)
|
|
665
|
+
cleanup_by_host = {
|
|
666
|
+
runtime.host.id: (cleanup_error, cleanup_duration_ms)
|
|
667
|
+
for (runtime, _provider), (cleanup_error, cleanup_duration_ms) in zip(
|
|
668
|
+
providers,
|
|
669
|
+
cleanup_results,
|
|
670
|
+
strict=True,
|
|
671
|
+
)
|
|
672
|
+
}
|
|
673
|
+
if tasks:
|
|
674
|
+
await asyncio.gather(*tasks, return_exceptions=True)
|
|
675
|
+
for runtime in runtimes:
|
|
676
|
+
runtime.state = "closed"
|
|
677
|
+
runtime.connected_at = None
|
|
678
|
+
cleanup_error, cleanup_duration_ms = cleanup_by_host.get(runtime.host.id, (None, 0.0))
|
|
679
|
+
self._emit(
|
|
680
|
+
"tunnel.runtime.closed",
|
|
681
|
+
runtime,
|
|
682
|
+
cleanup_error=cleanup_error,
|
|
683
|
+
cleanup_duration_ms=cleanup_duration_ms,
|
|
684
|
+
)
|
|
685
|
+
|
|
686
|
+
async def _ensure_provider(
|
|
687
|
+
self,
|
|
688
|
+
runtime: _HostRuntime,
|
|
689
|
+
provider_name: str,
|
|
690
|
+
) -> tuple[TunnelProviderRuntime, int]:
|
|
691
|
+
async with runtime.lock:
|
|
692
|
+
if self._closed:
|
|
693
|
+
raise TunnelManagerClosed()
|
|
694
|
+
self._cancel_idle_locked(runtime)
|
|
695
|
+
if runtime.state == "ready" and runtime.provider is not None:
|
|
696
|
+
return runtime.provider, runtime.generation
|
|
697
|
+
task = runtime.connect_task
|
|
698
|
+
if task is None:
|
|
699
|
+
runtime.generation += 1
|
|
700
|
+
generation = runtime.generation
|
|
701
|
+
reconnect_delay = runtime.reconnect_delay_seconds
|
|
702
|
+
runtime.state = "connecting"
|
|
703
|
+
task = asyncio.create_task(
|
|
704
|
+
self._connect_runtime(runtime, provider_name, generation, reconnect_delay),
|
|
705
|
+
name=f"hostbridge-tunnel-connect-{runtime.host.id}-{generation}",
|
|
706
|
+
)
|
|
707
|
+
task.add_done_callback(_consume_provider_task_result)
|
|
708
|
+
runtime.connect_task = task
|
|
709
|
+
self._emit("tunnel.runtime.connecting", runtime)
|
|
710
|
+
provider = await asyncio.shield(task)
|
|
711
|
+
return provider, runtime.generation
|
|
712
|
+
|
|
713
|
+
async def _connect_runtime(
|
|
714
|
+
self,
|
|
715
|
+
runtime: _HostRuntime,
|
|
716
|
+
provider_name: str,
|
|
717
|
+
generation: int,
|
|
718
|
+
reconnect_delay: float,
|
|
719
|
+
) -> TunnelProviderRuntime:
|
|
720
|
+
provider: TunnelProviderRuntime | None = None
|
|
721
|
+
try:
|
|
722
|
+
if reconnect_delay > 0:
|
|
723
|
+
await self._sleep(reconnect_delay)
|
|
724
|
+
provider = self._provider_factory(runtime.host, provider_name, generation)
|
|
725
|
+
await provider.start()
|
|
726
|
+
async with runtime.lock:
|
|
727
|
+
if self._closed or runtime.generation != generation:
|
|
728
|
+
raise TunnelManagerClosed()
|
|
729
|
+
runtime.provider = provider
|
|
730
|
+
runtime.sampled_window_stalls = 0
|
|
731
|
+
runtime.sampled_protocol_failures = 0
|
|
732
|
+
runtime.state = "ready"
|
|
733
|
+
runtime.last_error = None
|
|
734
|
+
runtime.connected_at = time.monotonic()
|
|
735
|
+
runtime.consecutive_failures = 0
|
|
736
|
+
runtime.reconnect_delay_seconds = 0.0
|
|
737
|
+
runtime.connect_task = None
|
|
738
|
+
runtime.monitor_task = asyncio.create_task(
|
|
739
|
+
self._monitor_provider(runtime, provider, generation),
|
|
740
|
+
name=f"hostbridge-tunnel-monitor-{runtime.host.id}-{generation}",
|
|
741
|
+
)
|
|
742
|
+
self._emit("tunnel.runtime.ready", runtime)
|
|
743
|
+
return provider
|
|
744
|
+
except asyncio.CancelledError:
|
|
745
|
+
if provider is not None:
|
|
746
|
+
await self._close_provider(runtime, provider)
|
|
747
|
+
raise
|
|
748
|
+
except TunnelManagerClosed:
|
|
749
|
+
if provider is not None:
|
|
750
|
+
await self._close_provider(runtime, provider)
|
|
751
|
+
raise
|
|
752
|
+
except Exception as exc:
|
|
753
|
+
if provider is not None:
|
|
754
|
+
await self._close_provider(runtime, provider)
|
|
755
|
+
error = TunnelBackendError(f"failed to connect host tunnel: {exc}")
|
|
756
|
+
async with runtime.lock:
|
|
757
|
+
if runtime.generation == generation:
|
|
758
|
+
runtime.state = "failed"
|
|
759
|
+
runtime.last_error = str(error)
|
|
760
|
+
runtime.connect_task = None
|
|
761
|
+
runtime.provider = None
|
|
762
|
+
runtime.connected_at = None
|
|
763
|
+
self._record_runtime_failure(runtime)
|
|
764
|
+
self._emit("tunnel.runtime.failed", runtime, error=str(error))
|
|
765
|
+
raise error from exc
|
|
766
|
+
|
|
767
|
+
async def _monitor_provider(
|
|
768
|
+
self,
|
|
769
|
+
runtime: _HostRuntime,
|
|
770
|
+
provider: TunnelProviderRuntime,
|
|
771
|
+
generation: int,
|
|
772
|
+
) -> None:
|
|
773
|
+
monitor_task = asyncio.current_task()
|
|
774
|
+
try:
|
|
775
|
+
try:
|
|
776
|
+
cause = await provider.wait_closed()
|
|
777
|
+
except asyncio.CancelledError:
|
|
778
|
+
return
|
|
779
|
+
except Exception as exc:
|
|
780
|
+
cause = exc
|
|
781
|
+
error = TunnelBackendError(str(cause) if cause is not None else "backend connection closed")
|
|
782
|
+
async with runtime.lock:
|
|
783
|
+
if (
|
|
784
|
+
self._closed
|
|
785
|
+
or runtime.generation != generation
|
|
786
|
+
or runtime.provider is not provider
|
|
787
|
+
or runtime.state != "ready"
|
|
788
|
+
):
|
|
789
|
+
return
|
|
790
|
+
runtime.state = "failed"
|
|
791
|
+
self._sample_provider_metrics(runtime)
|
|
792
|
+
runtime.provider = None
|
|
793
|
+
runtime.last_error = str(error)
|
|
794
|
+
runtime.connected_at = None
|
|
795
|
+
self._record_runtime_failure(runtime)
|
|
796
|
+
self._emit("tunnel.runtime.failed", runtime, error=str(error))
|
|
797
|
+
handles = list(runtime.streams.values())
|
|
798
|
+
runtime.streams.clear()
|
|
799
|
+
runtime.reservations.clear()
|
|
800
|
+
self._cancel_idle_locked(runtime)
|
|
801
|
+
await asyncio.gather(
|
|
802
|
+
*(self._finalize_detached_stream(runtime, handle, error) for handle in handles),
|
|
803
|
+
return_exceptions=True,
|
|
804
|
+
)
|
|
805
|
+
await self._close_provider(runtime, provider)
|
|
806
|
+
finally:
|
|
807
|
+
async with runtime.lock:
|
|
808
|
+
if runtime.monitor_task is monitor_task:
|
|
809
|
+
runtime.monitor_task = None
|
|
810
|
+
|
|
811
|
+
def _record_runtime_failure(self, runtime: _HostRuntime) -> None:
|
|
812
|
+
runtime.consecutive_failures += 1
|
|
813
|
+
runtime.reconnect_count += 1
|
|
814
|
+
base_delay = min(10.0, 0.25 * (2 ** (runtime.consecutive_failures - 1)))
|
|
815
|
+
runtime.reconnect_delay_seconds = min(10.0, max(0.25, self._jitter(base_delay)))
|
|
816
|
+
|
|
817
|
+
async def _close_stream(
|
|
818
|
+
self,
|
|
819
|
+
handle: TunnelStreamHandle,
|
|
820
|
+
*,
|
|
821
|
+
reset_reason: str | None,
|
|
822
|
+
terminal_error: BaseException | None = None,
|
|
823
|
+
) -> None:
|
|
824
|
+
runtime = self._runtimes.get(handle.host_id)
|
|
825
|
+
if runtime is None:
|
|
826
|
+
handle._finish()
|
|
827
|
+
return
|
|
828
|
+
key = (handle.client_connection_id, handle.client_stream_id)
|
|
829
|
+
owns_cleanup = False
|
|
830
|
+
async with runtime.lock:
|
|
831
|
+
if runtime.streams.get(key) is handle:
|
|
832
|
+
del runtime.streams[key]
|
|
833
|
+
owns_cleanup = True
|
|
834
|
+
if not owns_cleanup:
|
|
835
|
+
return
|
|
836
|
+
cleanup_task = asyncio.create_task(
|
|
837
|
+
self._finalize_owned_stream(
|
|
838
|
+
runtime,
|
|
839
|
+
handle,
|
|
840
|
+
reset_reason=reset_reason,
|
|
841
|
+
terminal_error=terminal_error,
|
|
842
|
+
),
|
|
843
|
+
name=f"hostbridge-tunnel-stream-close-{handle.host_id}-{handle.client_stream_id}",
|
|
844
|
+
)
|
|
845
|
+
waiter = asyncio.gather(cleanup_task, return_exceptions=True)
|
|
846
|
+
cancellation_requested = False
|
|
847
|
+
while True:
|
|
848
|
+
try:
|
|
849
|
+
results = await asyncio.shield(waiter)
|
|
850
|
+
break
|
|
851
|
+
except asyncio.CancelledError:
|
|
852
|
+
cancellation_requested = True
|
|
853
|
+
result = results[0]
|
|
854
|
+
if isinstance(result, BaseException):
|
|
855
|
+
raise result
|
|
856
|
+
if cancellation_requested:
|
|
857
|
+
raise asyncio.CancelledError
|
|
858
|
+
|
|
859
|
+
async def _finalize_owned_stream(
|
|
860
|
+
self,
|
|
861
|
+
runtime: _HostRuntime,
|
|
862
|
+
handle: TunnelStreamHandle,
|
|
863
|
+
*,
|
|
864
|
+
reset_reason: str | None,
|
|
865
|
+
terminal_error: BaseException | None,
|
|
866
|
+
) -> None:
|
|
867
|
+
cleanup_error: str | None = None
|
|
868
|
+
cleanup_started = time.monotonic()
|
|
869
|
+
cleanup_error, _cleanup_duration_ms = await self._cleanup_endpoint(
|
|
870
|
+
handle.endpoint,
|
|
871
|
+
reset_reason=reset_reason,
|
|
872
|
+
)
|
|
873
|
+
if cleanup_error is not None:
|
|
874
|
+
runtime.metrics.cleanup_errors += 1
|
|
875
|
+
handle._finish(terminal_error)
|
|
876
|
+
duration_ms = (time.monotonic() - handle.opened_at) * 1000
|
|
877
|
+
if reset_reason is None:
|
|
878
|
+
runtime.metrics.streams_closed += 1
|
|
879
|
+
event = "tunnel.stream.closed"
|
|
880
|
+
else:
|
|
881
|
+
runtime.metrics.streams_reset += 1
|
|
882
|
+
if isinstance(terminal_error, TunnelTimeoutError):
|
|
883
|
+
runtime.metrics.stream_timeouts += 1
|
|
884
|
+
event = "tunnel.stream.reset"
|
|
885
|
+
self._sample_provider_metrics(runtime)
|
|
886
|
+
self._emit(
|
|
887
|
+
event,
|
|
888
|
+
runtime,
|
|
889
|
+
stream_id=handle.client_stream_id,
|
|
890
|
+
provider_stream_id=handle.provider_stream_id,
|
|
891
|
+
destination_host=handle.destination_host,
|
|
892
|
+
destination_port=handle.destination_port,
|
|
893
|
+
policy_rule=handle.policy_rule,
|
|
894
|
+
duration_ms=duration_ms,
|
|
895
|
+
bytes_sent=handle.bytes_sent,
|
|
896
|
+
bytes_received=handle.bytes_received,
|
|
897
|
+
reason=reset_reason,
|
|
898
|
+
cleanup_duration_ms=(time.monotonic() - cleanup_started) * 1000,
|
|
899
|
+
cleanup_error=cleanup_error,
|
|
900
|
+
error_code=None if terminal_error is None else getattr(terminal_error, "code", "connection_lost"),
|
|
901
|
+
)
|
|
902
|
+
async with runtime.lock:
|
|
903
|
+
if runtime.state == "ready" and not self._runtime_in_use(runtime, runtime.generation):
|
|
904
|
+
self._schedule_idle_locked(runtime)
|
|
905
|
+
|
|
906
|
+
async def _timeout_stream(self, handle: TunnelStreamHandle, reason: str) -> None:
|
|
907
|
+
message = "tunnel stream deadline exceeded" if reason == "deadline" else "tunnel stream idle timeout"
|
|
908
|
+
await self._close_stream(
|
|
909
|
+
handle,
|
|
910
|
+
reset_reason=message,
|
|
911
|
+
terminal_error=TunnelTimeoutError(message),
|
|
912
|
+
)
|
|
913
|
+
|
|
914
|
+
async def _finalize_detached_stream(
|
|
915
|
+
self,
|
|
916
|
+
runtime: _HostRuntime,
|
|
917
|
+
handle: TunnelStreamHandle,
|
|
918
|
+
error: TunnelError,
|
|
919
|
+
) -> None:
|
|
920
|
+
cleanup_started = time.monotonic()
|
|
921
|
+
cleanup_error, _cleanup_duration_ms = await self._cleanup_endpoint(
|
|
922
|
+
handle.endpoint,
|
|
923
|
+
reset_reason=str(error),
|
|
924
|
+
)
|
|
925
|
+
if cleanup_error is not None:
|
|
926
|
+
runtime.metrics.cleanup_errors += 1
|
|
927
|
+
handle._finish(error)
|
|
928
|
+
runtime.metrics.streams_reset += 1
|
|
929
|
+
self._sample_provider_metrics(runtime)
|
|
930
|
+
self._emit(
|
|
931
|
+
"tunnel.stream.reset",
|
|
932
|
+
runtime,
|
|
933
|
+
stream_id=handle.client_stream_id,
|
|
934
|
+
provider_stream_id=handle.provider_stream_id,
|
|
935
|
+
destination_host=handle.destination_host,
|
|
936
|
+
destination_port=handle.destination_port,
|
|
937
|
+
policy_rule=handle.policy_rule,
|
|
938
|
+
duration_ms=(time.monotonic() - handle.opened_at) * 1000,
|
|
939
|
+
bytes_sent=handle.bytes_sent,
|
|
940
|
+
bytes_received=handle.bytes_received,
|
|
941
|
+
reason=str(error),
|
|
942
|
+
error_code=error.code,
|
|
943
|
+
cleanup_duration_ms=(time.monotonic() - cleanup_started) * 1000,
|
|
944
|
+
cleanup_error=cleanup_error,
|
|
945
|
+
)
|
|
946
|
+
|
|
947
|
+
async def _release_reservation(self, runtime: _HostRuntime, key: _StreamKey, generation: int) -> None:
|
|
948
|
+
async with runtime.lock:
|
|
949
|
+
if runtime.generation != generation:
|
|
950
|
+
return
|
|
951
|
+
runtime.reservations.discard(key)
|
|
952
|
+
if runtime.state == "ready" and not self._runtime_in_use(runtime, generation):
|
|
953
|
+
self._schedule_idle_locked(runtime)
|
|
954
|
+
|
|
955
|
+
async def _lease_provider(self, lease: HostRuntimeLease) -> HostOperationProvider:
|
|
956
|
+
if self._closed:
|
|
957
|
+
raise TunnelManagerClosed()
|
|
958
|
+
runtime = self._runtimes.get(lease.host_id)
|
|
959
|
+
if runtime is None:
|
|
960
|
+
raise TunnelBackendError("host runtime is unavailable")
|
|
961
|
+
async with runtime.lock:
|
|
962
|
+
if (
|
|
963
|
+
runtime.state != "ready"
|
|
964
|
+
or runtime.generation != lease.generation
|
|
965
|
+
or runtime.leases.get(lease._token) != lease.generation
|
|
966
|
+
or runtime.provider is None
|
|
967
|
+
):
|
|
968
|
+
raise TunnelBackendError("host runtime generation is no longer available")
|
|
969
|
+
return _host_operation_provider(runtime.provider)
|
|
970
|
+
|
|
971
|
+
async def _release_lease(self, lease: HostRuntimeLease) -> None:
|
|
972
|
+
runtime = self._runtimes.get(lease.host_id)
|
|
973
|
+
if runtime is None:
|
|
974
|
+
return
|
|
975
|
+
async with runtime.lock:
|
|
976
|
+
if runtime.leases.get(lease._token) != lease.generation:
|
|
977
|
+
return
|
|
978
|
+
del runtime.leases[lease._token]
|
|
979
|
+
if runtime.state == "ready" and not self._runtime_in_use(runtime, runtime.generation):
|
|
980
|
+
self._schedule_idle_locked(runtime)
|
|
981
|
+
|
|
982
|
+
def _schedule_idle_locked(self, runtime: _HostRuntime) -> None:
|
|
983
|
+
self._cancel_idle_locked(runtime)
|
|
984
|
+
runtime.idle_task = asyncio.create_task(
|
|
985
|
+
self._idle_close(runtime, runtime.generation),
|
|
986
|
+
name=f"hostbridge-tunnel-idle-{runtime.host.id}-{runtime.generation}",
|
|
987
|
+
)
|
|
988
|
+
|
|
989
|
+
def _cancel_idle_locked(self, runtime: _HostRuntime) -> None:
|
|
990
|
+
if runtime.idle_task is not None:
|
|
991
|
+
runtime.idle_task.cancel()
|
|
992
|
+
runtime.idle_task = None
|
|
993
|
+
|
|
994
|
+
async def _idle_close(self, runtime: _HostRuntime, generation: int) -> None:
|
|
995
|
+
try:
|
|
996
|
+
await asyncio.sleep(runtime.host.forwarding.idle_timeout_seconds)
|
|
997
|
+
async with runtime.lock:
|
|
998
|
+
if (
|
|
999
|
+
self._closed
|
|
1000
|
+
or runtime.generation != generation
|
|
1001
|
+
or runtime.state != "ready"
|
|
1002
|
+
or runtime.streams
|
|
1003
|
+
or runtime.reservations
|
|
1004
|
+
or self._lease_count(runtime, generation)
|
|
1005
|
+
or runtime.provider is None
|
|
1006
|
+
):
|
|
1007
|
+
return
|
|
1008
|
+
runtime.state = "draining"
|
|
1009
|
+
provider = runtime.provider
|
|
1010
|
+
runtime.provider = None
|
|
1011
|
+
monitor = runtime.monitor_task
|
|
1012
|
+
runtime.monitor_task = None
|
|
1013
|
+
runtime.idle_task = None
|
|
1014
|
+
if monitor is not None:
|
|
1015
|
+
monitor.cancel()
|
|
1016
|
+
await asyncio.gather(monitor, return_exceptions=True)
|
|
1017
|
+
cleanup_error, cleanup_duration_ms = await self._close_provider(runtime, provider)
|
|
1018
|
+
async with runtime.lock:
|
|
1019
|
+
if runtime.generation == generation and runtime.state == "draining":
|
|
1020
|
+
runtime.state = "disconnected"
|
|
1021
|
+
runtime.connected_at = None
|
|
1022
|
+
self._emit(
|
|
1023
|
+
"tunnel.runtime.closed",
|
|
1024
|
+
runtime,
|
|
1025
|
+
reason="idle",
|
|
1026
|
+
cleanup_error=cleanup_error,
|
|
1027
|
+
cleanup_duration_ms=cleanup_duration_ms,
|
|
1028
|
+
)
|
|
1029
|
+
except asyncio.CancelledError:
|
|
1030
|
+
return
|
|
1031
|
+
|
|
1032
|
+
async def _close_provider(
|
|
1033
|
+
self,
|
|
1034
|
+
runtime: _HostRuntime,
|
|
1035
|
+
provider: TunnelProviderRuntime,
|
|
1036
|
+
) -> tuple[str | None, float]:
|
|
1037
|
+
started = time.monotonic()
|
|
1038
|
+
cleanup_error: str | None = None
|
|
1039
|
+
cleanup = asyncio.create_task(
|
|
1040
|
+
provider.close(),
|
|
1041
|
+
name=f"hostbridge-provider-cleanup-{runtime.host.id}",
|
|
1042
|
+
)
|
|
1043
|
+
try:
|
|
1044
|
+
done, _pending = await asyncio.wait({cleanup}, timeout=self._cleanup_timeout_seconds)
|
|
1045
|
+
except asyncio.CancelledError as cancellation:
|
|
1046
|
+
abort_error = self._abort_provider_cleanup(provider, cleanup)
|
|
1047
|
+
if abort_error is not None:
|
|
1048
|
+
cancellation.add_note(f"provider abort failed: {abort_error}")
|
|
1049
|
+
raise
|
|
1050
|
+
if not done:
|
|
1051
|
+
cleanup_error = "provider cleanup timed out"
|
|
1052
|
+
runtime.metrics.cleanup_errors += 1
|
|
1053
|
+
abort_error = self._abort_provider_cleanup(provider, cleanup)
|
|
1054
|
+
if abort_error is not None:
|
|
1055
|
+
raise TunnelProviderAbortError(f"provider abort failed: {abort_error}") from abort_error
|
|
1056
|
+
else:
|
|
1057
|
+
try:
|
|
1058
|
+
cleanup.result()
|
|
1059
|
+
except Exception as exc:
|
|
1060
|
+
cleanup_error = str(exc)
|
|
1061
|
+
runtime.metrics.cleanup_errors += 1
|
|
1062
|
+
return cleanup_error, (time.monotonic() - started) * 1000
|
|
1063
|
+
|
|
1064
|
+
def _abort_provider_cleanup(
|
|
1065
|
+
self,
|
|
1066
|
+
provider: TunnelProviderRuntime,
|
|
1067
|
+
cleanup: asyncio.Task[None],
|
|
1068
|
+
) -> Exception | None:
|
|
1069
|
+
abort_error: Exception | None = None
|
|
1070
|
+
try:
|
|
1071
|
+
provider.abort()
|
|
1072
|
+
except Exception as exc:
|
|
1073
|
+
abort_error = exc
|
|
1074
|
+
cleanup.cancel()
|
|
1075
|
+
self._provider_cleanup_tasks.add(cleanup)
|
|
1076
|
+
cleanup.add_done_callback(self._provider_cleanup_finished)
|
|
1077
|
+
return abort_error
|
|
1078
|
+
|
|
1079
|
+
def _provider_cleanup_finished(self, task: asyncio.Task[None]) -> None:
|
|
1080
|
+
self._provider_cleanup_tasks.discard(task)
|
|
1081
|
+
_consume_task_result(task)
|
|
1082
|
+
|
|
1083
|
+
async def _cleanup_endpoint(
|
|
1084
|
+
self,
|
|
1085
|
+
endpoint: TunnelProviderEndpoint,
|
|
1086
|
+
*,
|
|
1087
|
+
reset_reason: str | None,
|
|
1088
|
+
) -> tuple[str | None, float]:
|
|
1089
|
+
started = time.monotonic()
|
|
1090
|
+
cleanup_error: str | None = None
|
|
1091
|
+
try:
|
|
1092
|
+
async with asyncio.timeout(self._cleanup_timeout_seconds):
|
|
1093
|
+
if reset_reason is None:
|
|
1094
|
+
await endpoint.close()
|
|
1095
|
+
else:
|
|
1096
|
+
await endpoint.reset(reset_reason)
|
|
1097
|
+
except TimeoutError:
|
|
1098
|
+
cleanup_error = "endpoint cleanup timed out"
|
|
1099
|
+
except Exception as exc:
|
|
1100
|
+
cleanup_error = str(exc)
|
|
1101
|
+
return cleanup_error, (time.monotonic() - started) * 1000
|
|
1102
|
+
|
|
1103
|
+
def _record_stream_bytes(
|
|
1104
|
+
self,
|
|
1105
|
+
handle: TunnelStreamHandle,
|
|
1106
|
+
*,
|
|
1107
|
+
sent: int = 0,
|
|
1108
|
+
received: int = 0,
|
|
1109
|
+
) -> None:
|
|
1110
|
+
runtime = self._runtimes.get(handle.host_id)
|
|
1111
|
+
if runtime is None or runtime.generation != handle.runtime_generation:
|
|
1112
|
+
return
|
|
1113
|
+
runtime.metrics.bytes_sent += sent
|
|
1114
|
+
runtime.metrics.bytes_received += received
|
|
1115
|
+
self._sample_provider_metrics(runtime)
|
|
1116
|
+
|
|
1117
|
+
def _record_stream_half_close(self, handle: TunnelStreamHandle, direction: str) -> None:
|
|
1118
|
+
runtime = self._runtimes.get(handle.host_id)
|
|
1119
|
+
if runtime is None or runtime.generation != handle.runtime_generation:
|
|
1120
|
+
return
|
|
1121
|
+
self._emit(
|
|
1122
|
+
"tunnel.stream.half_closed",
|
|
1123
|
+
runtime,
|
|
1124
|
+
stream_id=handle.client_stream_id,
|
|
1125
|
+
provider_stream_id=handle.provider_stream_id,
|
|
1126
|
+
direction=direction,
|
|
1127
|
+
bytes_sent=handle.bytes_sent,
|
|
1128
|
+
bytes_received=handle.bytes_received,
|
|
1129
|
+
)
|
|
1130
|
+
|
|
1131
|
+
def _sample_provider_metrics(self, runtime: _HostRuntime) -> None:
|
|
1132
|
+
provider = runtime.provider
|
|
1133
|
+
queued = 0 if provider is None else max(0, getattr(provider, "queued_bytes", 0))
|
|
1134
|
+
runtime.metrics.queue_high_water_bytes = max(runtime.metrics.queue_high_water_bytes, queued)
|
|
1135
|
+
if provider is None:
|
|
1136
|
+
return
|
|
1137
|
+
window_stalls = max(0, getattr(provider, "window_stalls", 0))
|
|
1138
|
+
protocol_failures = max(0, getattr(provider, "protocol_failures", 0))
|
|
1139
|
+
if window_stalls >= runtime.sampled_window_stalls:
|
|
1140
|
+
runtime.metrics.window_stalls += window_stalls - runtime.sampled_window_stalls
|
|
1141
|
+
else:
|
|
1142
|
+
runtime.metrics.window_stalls += window_stalls
|
|
1143
|
+
if protocol_failures >= runtime.sampled_protocol_failures:
|
|
1144
|
+
runtime.metrics.protocol_failures += protocol_failures - runtime.sampled_protocol_failures
|
|
1145
|
+
else:
|
|
1146
|
+
runtime.metrics.protocol_failures += protocol_failures
|
|
1147
|
+
runtime.sampled_window_stalls = window_stalls
|
|
1148
|
+
runtime.sampled_protocol_failures = protocol_failures
|
|
1149
|
+
|
|
1150
|
+
@staticmethod
|
|
1151
|
+
def _lease_count(runtime: _HostRuntime, generation: int) -> int:
|
|
1152
|
+
return sum(lease_generation == generation for lease_generation in runtime.leases.values())
|
|
1153
|
+
|
|
1154
|
+
def _runtime_in_use(self, runtime: _HostRuntime, generation: int) -> bool:
|
|
1155
|
+
return bool(runtime.streams or runtime.reservations or self._lease_count(runtime, generation))
|
|
1156
|
+
|
|
1157
|
+
def _emit(self, event: str, runtime: _HostRuntime, **fields: object) -> None:
|
|
1158
|
+
record: dict[str, object] = {
|
|
1159
|
+
"event": event,
|
|
1160
|
+
"timestamp": time.time(),
|
|
1161
|
+
"host_id": runtime.host.id,
|
|
1162
|
+
"provider": _provider_name(runtime.host),
|
|
1163
|
+
"generation": runtime.generation,
|
|
1164
|
+
**fields,
|
|
1165
|
+
}
|
|
1166
|
+
try:
|
|
1167
|
+
if self._event_sink is not None:
|
|
1168
|
+
self._event_sink(record)
|
|
1169
|
+
else:
|
|
1170
|
+
_LOGGER.info("HostBridge tunnel event", extra={"hostbridge_event": record})
|
|
1171
|
+
except Exception:
|
|
1172
|
+
_LOGGER.exception("HostBridge tunnel event sink failed")
|
|
1173
|
+
|
|
1174
|
+
|
|
1175
|
+
def _provider_name(host: HostConfig) -> str:
|
|
1176
|
+
if host.transport == "ssh":
|
|
1177
|
+
return "native_ssh"
|
|
1178
|
+
if host.transport == "pty":
|
|
1179
|
+
return "pty_agent"
|
|
1180
|
+
if host.ssh is not None and not host.login_steps and not host.ssh.extra_args:
|
|
1181
|
+
return "native_ssh"
|
|
1182
|
+
return "pty_agent"
|
|
1183
|
+
|
|
1184
|
+
|
|
1185
|
+
def _host_operation_provider(provider: TunnelProviderRuntime) -> HostOperationProvider:
|
|
1186
|
+
required = ("exec", "upload", "download", "shell_write", "shell_read", "shell_close")
|
|
1187
|
+
if not all(callable(getattr(provider, name, None)) for name in required):
|
|
1188
|
+
raise TunnelUnsupportedError(f"provider {provider.provider_name!r} does not support host operations")
|
|
1189
|
+
if not isinstance(getattr(provider, "capabilities", None), TransportCapabilities):
|
|
1190
|
+
raise TunnelUnsupportedError(f"provider {provider.provider_name!r} does not report host capabilities")
|
|
1191
|
+
return cast(HostOperationProvider, provider)
|
|
1192
|
+
|
|
1193
|
+
|
|
1194
|
+
def _authorize_destination(
|
|
1195
|
+
policy: ForwardingPolicy,
|
|
1196
|
+
provider_name: str,
|
|
1197
|
+
destination_host: str,
|
|
1198
|
+
destination_port: int,
|
|
1199
|
+
) -> str:
|
|
1200
|
+
if provider_name not in {"native_ssh", "pty_agent"}:
|
|
1201
|
+
raise TunnelPolicyError(f"unsupported tunnel provider {provider_name!r}")
|
|
1202
|
+
if not policy.enabled:
|
|
1203
|
+
raise TunnelPolicyError("TCP forwarding is disabled for this host")
|
|
1204
|
+
if (
|
|
1205
|
+
not isinstance(destination_port, int)
|
|
1206
|
+
or isinstance(destination_port, bool)
|
|
1207
|
+
or destination_port not in policy.allow_ports
|
|
1208
|
+
):
|
|
1209
|
+
raise TunnelPolicyError(f"TCP forwarding port {destination_port!r} is not allowed")
|
|
1210
|
+
if not isinstance(destination_host, str) or not destination_host or "\x00" in destination_host:
|
|
1211
|
+
raise TunnelPolicyError("TCP forwarding destination is invalid")
|
|
1212
|
+
try:
|
|
1213
|
+
address = ip_address(destination_host)
|
|
1214
|
+
except ValueError:
|
|
1215
|
+
hostname = destination_host.rstrip(".").lower()
|
|
1216
|
+
if hostname not in policy.allow_hostnames:
|
|
1217
|
+
raise TunnelPolicyError(f"TCP forwarding hostname {destination_host!r} is not allowed") from None
|
|
1218
|
+
return f"hostname:{hostname}"
|
|
1219
|
+
networks = []
|
|
1220
|
+
try:
|
|
1221
|
+
networks = [ip_network(value, strict=True) for value in policy.allow_cidrs]
|
|
1222
|
+
except ValueError as exc:
|
|
1223
|
+
raise TunnelPolicyError(f"host has an invalid forwarding CIDR policy: {exc}") from exc
|
|
1224
|
+
matched = next(
|
|
1225
|
+
(network for network in networks if address.version == network.version and address in network),
|
|
1226
|
+
None,
|
|
1227
|
+
)
|
|
1228
|
+
if matched is None:
|
|
1229
|
+
raise TunnelPolicyError(f"TCP forwarding destination {destination_host!r} is not allowed")
|
|
1230
|
+
return f"cidr:{matched}"
|
|
1231
|
+
|
|
1232
|
+
|
|
1233
|
+
def _default_reconnect_jitter(delay: float) -> float:
|
|
1234
|
+
# Reconnect scheduling jitter is not security-sensitive randomness.
|
|
1235
|
+
return random.uniform(delay, min(10.0, delay * 1.25)) # nosec B311
|
|
1236
|
+
|
|
1237
|
+
|
|
1238
|
+
def _consume_task_result(task: asyncio.Task[None]) -> None:
|
|
1239
|
+
with suppress(BaseException):
|
|
1240
|
+
task.result()
|
|
1241
|
+
|
|
1242
|
+
|
|
1243
|
+
def _consume_provider_task_result(task: asyncio.Task[TunnelProviderRuntime]) -> None:
|
|
1244
|
+
with suppress(BaseException):
|
|
1245
|
+
task.result()
|