@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.
Files changed (40) hide show
  1. package/README.md +11 -9
  2. package/docs/ARCHITECTURE.md +62 -0
  3. package/package.json +2 -1
  4. package/pyproject.toml +1 -1
  5. package/src/server_control_mcp/__init__.py +4 -3
  6. package/src/server_control_mcp/async_lifecycle.py +41 -0
  7. package/src/server_control_mcp/cli.py +1 -1
  8. package/src/server_control_mcp/client.py +302 -287
  9. package/src/server_control_mcp/config.py +2 -1
  10. package/src/server_control_mcp/daemon.py +233 -526
  11. package/src/server_control_mcp/doctor.py +34 -2
  12. package/src/server_control_mcp/hosts.py +136 -2
  13. package/src/server_control_mcp/mock_ssh.py +52 -14
  14. package/src/server_control_mcp/mock_ssh_exec.py +147 -58
  15. package/src/server_control_mcp/mock_ssh_session.py +3 -2
  16. package/src/server_control_mcp/mock_ssh_sftp.py +126 -28
  17. package/src/server_control_mcp/mock_ssh_tunnel.py +141 -444
  18. package/src/server_control_mcp/mux_connection.py +326 -0
  19. package/src/server_control_mcp/mux_daemon.py +186 -0
  20. package/src/server_control_mcp/mux_protocol.py +279 -0
  21. package/src/server_control_mcp/mux_records.py +71 -0
  22. package/src/server_control_mcp/mux_rpc.py +1779 -0
  23. package/src/server_control_mcp/mux_service.py +506 -0
  24. package/src/server_control_mcp/mux_stream.py +283 -0
  25. package/src/server_control_mcp/mux_sync_client.py +224 -0
  26. package/src/server_control_mcp/remote_agent_bundle.py +56 -0
  27. package/src/server_control_mcp/remote_mux_agent.py +769 -0
  28. package/src/server_control_mcp/runtime_services.py +862 -0
  29. package/src/server_control_mcp/server.py +33 -18
  30. package/src/server_control_mcp/services.py +2 -666
  31. package/src/server_control_mcp/transports/__init__.py +4 -8
  32. package/src/server_control_mcp/transports/base.py +60 -38
  33. package/src/server_control_mcp/transports/ssh.py +206 -259
  34. package/src/server_control_mcp/tunnel_manager.py +1245 -0
  35. package/src/server_control_mcp/tunnel_native_ssh.py +454 -0
  36. package/src/server_control_mcp/tunnel_providers.py +20 -0
  37. package/src/server_control_mcp/tunnel_pty_agent.py +909 -0
  38. package/src/server_control_mcp/protocol.py +0 -363
  39. package/src/server_control_mcp/remote_tunnel_agent.py +0 -143
  40. 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()